Showing 9 changed files with 180 additions and 181 deletions
+53 -0
Documentation/No Ampere-Hours in UI or Model.md
@@ -0,0 +1,53 @@
1
+# No Ampere-Hours (Ah) in UI or Model
2
+
3
+## Decision
4
+
5
+The application does not display, expose, or compute user-visible measurements in ampere-hours (Ah or mAh). This is a deliberate policy, not an omission.
6
+
7
+## Rationale
8
+
9
+### The physics
10
+
11
+Ampere-hours measure **electric charge** — the integral of current over time (∫ I·dt). They express how many coulombs of charge flowed through a circuit, independent of voltage.
12
+
13
+Watt-hours measure **energy** — the integral of power over time (∫ V·I·dt). Energy is what actually matters for a battery: it tells you how much work the battery can do, accounting for the voltage at which charge is delivered.
14
+
15
+For a battery with a flat discharge curve these two metrics are proportional, but real batteries have voltage that drops as they discharge. A 3.5 V lithium cell and a 5.0 V USB output delivering the "same" mAh are not delivering the same energy. The difference can be 30% or more.
16
+
17
+### The marketing abuse
18
+
19
+Consumer electronics marketing adopted mAh as a proxy for battery capacity because the number is conveniently large:
20
+- "10,000 mAh power bank" sounds more impressive than "37 Wh power bank"
21
+- But 10,000 mAh at 3.7 V (lithium cell voltage) ≈ 37 Wh, while 10,000 mAh at 5 V (USB output voltage) ≈ 50 Wh
22
+- Manufacturers measure at cell voltage; the useful output is at USB voltage — the same number means different things depending on context
23
+
24
+This ambiguity has been widely documented and is a known source of consumer confusion. Rating bodies and careful reviewers use Wh.
25
+
26
+### For this app specifically
27
+
28
+USB Meter measures charge delivered over USB, where voltage is never constant. Reporting in Ah would require specifying *which voltage* to use, and any fixed reference (3.7 V, 5 V, nominal) would be arbitrary and misleading. Wh is unambiguous: it is always the integral of V·I·dt as measured at the USB port.
29
+
30
+## What this means in practice
31
+
32
+### UI
33
+- No measurement expressed in Ah or mAh should appear in any view
34
+- Battery capacity is expressed exclusively in Wh (`capacityEstimateWh`)
35
+- Session energy is expressed exclusively in Wh (`measuredEnergyWh`, `effectiveBatteryEnergyWh`)
36
+
37
+### Model layer (Swift)
38
+- `ChargeSessionSummary`, `ChargeCheckpointSummary`, and `ChargeSessionSampleSummary` do not expose Ah fields
39
+- `TypicalChargeCurvePoint` carries only `averageEnergyWh`
40
+- `ChargedDeviceSummary` capacity estimates are always in Wh
41
+
42
+### Internal / hardware layer
43
+- The USB meters (UM25C, UM34C, TC66C) report both a Wh counter (`recordedWH`) and an Ah counter (`recordedAH`) over Bluetooth
44
+- `Meter.recordedAH` and `Meter.chargeRecordAH` exist as raw hardware counters and are used only internally — they are never surfaced in summaries or shown to users
45
+- The CoreData schema retains `measuredChargeAh`, `meterChargeBaselineAh`, and `meterLastChargeAh` as legacy attributes (data already stored in existing records is preserved), but these attributes are no longer read into Swift model summaries
46
+
47
+## Enforcement
48
+
49
+When adding new features involving energy or capacity:
50
+- Always use Wh as the unit
51
+- Do not introduce new Ah-denominated properties in any public summary struct
52
+- Do not display Ah or mAh strings in any view, label, or tooltip
53
+- If a hardware counter comes in Ah (e.g., from a new meter protocol), store it as a private implementation detail and convert to energy only if an average voltage is reliably known
+4 -26
USB Meter/Model/AppData.swift
@@ -607,13 +607,11 @@ final class AppData : ObservableObject {
607 607
 
608 608
         let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
609 609
         let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
610
-        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
611 610
 
612 611
         let didSave = chargeInsightsStore?.addBatteryCheckpoint(
613 612
             percent: percent,
614 613
             for: meter.btSerial.macAddress.description,
615
-            measuredEnergyWh: checkpointEnergyWh,
616
-            measuredChargeAh: checkpointChargeAh
614
+            measuredEnergyWh: checkpointEnergyWh
617 615
         ) ?? false
618 616
 
619 617
         if didSave {
@@ -645,8 +643,7 @@ final class AppData : ObservableObject {
645 643
     func addBatteryCheckpoint(
646 644
         percent: Double,
647 645
         for sessionID: UUID,
648
-        measuredEnergyWh: Double?,
649
-        measuredChargeAh: Double?
646
+        measuredEnergyWh: Double?
650 647
     ) -> Bool {
651 648
         guard canAddBatteryCheckpoint(to: sessionID) else {
652 649
             return false
@@ -655,8 +652,7 @@ final class AppData : ObservableObject {
655 652
         let didSave = chargeInsightsStore?.addBatteryCheckpoint(
656 653
             percent: percent,
657 654
             for: sessionID,
658
-            measuredEnergyWh: measuredEnergyWh,
659
-            measuredChargeAh: measuredChargeAh
655
+            measuredEnergyWh: measuredEnergyWh
660 656
         ) ?? false
661 657
 
662 658
         if didSave {
@@ -1248,27 +1244,9 @@ final class AppData : ObservableObject {
1248 1244
         return storedEnergyWh
1249 1245
     }
1250 1246
 
1251
-    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1252
-        let storedChargeAh = session.measuredChargeAh
1253
-        guard session.isTrimmed == false else {
1254
-            return storedChargeAh
1255
-        }
1256
-        guard session.status.isOpen else {
1257
-            return storedChargeAh
1258
-        }
1259
-
1260
-        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1261
-            return storedChargeAh
1262
-        }
1263
-
1264
-        if let baselineChargeAh = session.meterChargeBaselineAh {
1265
-            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1266
-        }
1267
-
1268
-        return storedChargeAh
1269
-    }
1270 1247
 }
1271 1248
 
1249
+
1272 1250
 extension AppData.MeterSummary {
1273 1251
     var tint: Color {
1274 1252
         switch modelSummary {
+0 -6
USB Meter/Model/ChargeInsightsModel.swift
@@ -544,7 +544,6 @@ struct ChargeCheckpointSummary: Identifiable, Hashable {
544 544
     let timestamp: Date
545 545
     let batteryPercent: Double
546 546
     let measuredEnergyWh: Double
547
-    let measuredChargeAh: Double
548 547
     let currentAmps: Double
549 548
     let voltageVolts: Double?
550 549
     let label: String?
@@ -608,7 +607,6 @@ struct ChargeSessionSampleSummary: Identifiable, Hashable {
608 607
     let averageVoltageVolts: Double?
609 608
     let averagePowerWatts: Double
610 609
     let measuredEnergyWh: Double
611
-    let measuredChargeAh: Double
612 610
     let sampleCount: Int
613 611
 
614 612
     var id: String {
@@ -634,9 +632,7 @@ struct ChargeSessionSummary: Identifiable, Hashable {
634 632
     let autoStopEnabled: Bool
635 633
     let measuredEnergyWh: Double
636 634
     let effectiveBatteryEnergyWh: Double?
637
-    let measuredChargeAh: Double
638 635
     let meterEnergyBaselineWh: Double?
639
-    let meterChargeBaselineAh: Double?
640 636
     let meterDurationBaselineSeconds: Double?
641 637
     let meterLastDurationSeconds: Double?
642 638
     let minimumObservedCurrentAmps: Double?
@@ -733,7 +729,6 @@ struct ChargeSessionSummary: Identifiable, Hashable {
733 729
     var hasSavableChargeData: Bool {
734 730
         hasObservedChargeFlow
735 731
             || measuredEnergyWh > 0
736
-            || measuredChargeAh > 0
737 732
             || (maximumObservedCurrentAmps ?? 0) > 0
738 733
             || (maximumObservedPowerWatts ?? 0) > 0
739 734
             || !aggregatedSamples.isEmpty
@@ -812,7 +807,6 @@ struct CapacityTrendPoint: Identifiable, Hashable {
812 807
 struct TypicalChargeCurvePoint: Identifiable, Hashable {
813 808
     let percentBin: Int
814 809
     let averageEnergyWh: Double
815
-    let averageChargeAh: Double
816 810
     let sampleCount: Int
817 811
 
818 812
     var id: Int { percentBin }
+16 -56
USB Meter/Model/ChargeInsightsStore.swift
@@ -707,8 +707,7 @@ final class ChargeInsightsStore {
707 707
     func addBatteryCheckpoint(
708 708
         percent: Double,
709 709
         for meterMACAddress: String,
710
-        measuredEnergyWh: Double? = nil,
711
-        measuredChargeAh: Double? = nil
710
+        measuredEnergyWh: Double? = nil
712 711
     ) -> Bool {
713 712
         guard percent.isFinite, percent >= 0, percent <= 100 else {
714 713
             return false
@@ -723,7 +722,6 @@ final class ChargeInsightsStore {
723 722
             didSave = addBatteryCheckpoint(
724 723
                 percent: percent,
725 724
                 measuredEnergyWh: measuredEnergyWh,
726
-                measuredChargeAh: measuredChargeAh,
727 725
                 flag: .intermediate,
728 726
                 to: session
729 727
             )
@@ -735,8 +733,7 @@ final class ChargeInsightsStore {
735 733
     func addBatteryCheckpoint(
736 734
         percent: Double,
737 735
         for sessionID: UUID,
738
-        measuredEnergyWh: Double? = nil,
739
-        measuredChargeAh: Double? = nil
736
+        measuredEnergyWh: Double? = nil
740 737
     ) -> Bool {
741 738
         guard percent.isFinite, percent >= 0, percent <= 100 else {
742 739
             return false
@@ -751,7 +748,6 @@ final class ChargeInsightsStore {
751 748
             didSave = addBatteryCheckpoint(
752 749
                 percent: percent,
753 750
                 measuredEnergyWh: measuredEnergyWh,
754
-                measuredChargeAh: measuredChargeAh,
755 751
                 flag: .intermediate,
756 752
                 to: session
757 753
             )
@@ -2027,7 +2023,6 @@ final class ChargeInsightsStore {
2027 2023
         flag: ChargeCheckpointFlag,
2028 2024
         timestamp: Date = Date(),
2029 2025
         measuredEnergyWhOverride: Double? = nil,
2030
-        measuredChargeAhOverride: Double? = nil,
2031 2026
         to session: NSManagedObject
2032 2027
     ) -> String? {
2033 2028
         guard
@@ -2042,15 +2037,12 @@ final class ChargeInsightsStore {
2042 2037
         let checkpointEnergyWh = measuredEnergyWhOverride
2043 2038
             ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
2044 2039
             ?? doubleValue(session, key: "measuredEnergyWh")
2045
-        let checkpointChargeAh = measuredChargeAhOverride
2046
-            ?? doubleValue(session, key: "measuredChargeAh")
2047 2040
         checkpoint.setValue(UUID().uuidString, forKey: "id")
2048 2041
         checkpoint.setValue(sessionID, forKey: "sessionID")
2049 2042
         checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
2050 2043
         checkpoint.setValue(timestamp, forKey: "timestamp")
2051 2044
         checkpoint.setValue(percent, forKey: "batteryPercent")
2052 2045
         checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2053
-        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
2054 2046
         checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2055 2047
         checkpoint.setValue(
2056 2048
             chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
@@ -2095,7 +2087,6 @@ final class ChargeInsightsStore {
2095 2087
     private func addBatteryCheckpoint(
2096 2088
         percent: Double,
2097 2089
         measuredEnergyWh: Double? = nil,
2098
-        measuredChargeAh: Double? = nil,
2099 2090
         flag: ChargeCheckpointFlag,
2100 2091
         to session: NSManagedObject,
2101 2092
         timestamp: Date = Date()
@@ -2103,16 +2094,12 @@ final class ChargeInsightsStore {
2103 2094
         if let measuredEnergyWh, measuredEnergyWh.isFinite {
2104 2095
             session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2105 2096
         }
2106
-        if let measuredChargeAh, measuredChargeAh.isFinite {
2107
-            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
2108
-        }
2109 2097
 
2110 2098
         guard let chargedDeviceID = insertBatteryCheckpoint(
2111 2099
             percent: percent,
2112 2100
             flag: flag,
2113 2101
             timestamp: timestamp,
2114 2102
             measuredEnergyWhOverride: measuredEnergyWh,
2115
-            measuredChargeAhOverride: measuredChargeAh,
2116 2103
             to: session
2117 2104
         ) else {
2118 2105
             return false
@@ -2285,7 +2272,6 @@ final class ChargeInsightsStore {
2285 2272
 
2286 2273
     private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2287 2274
         var groupedEnergyByBin: [Int: [Double]] = [:]
2288
-        var groupedChargeByBin: [Int: [Double]] = [:]
2289 2275
 
2290 2276
         for session in sessions where session.status == .completed {
2291 2277
             let anchors = normalizedTypicalCurveAnchors(for: session)
@@ -2294,46 +2280,36 @@ final class ChargeInsightsStore {
2294 2280
             }
2295 2281
 
2296 2282
             for percentBin in stride(from: 0, through: 100, by: 10) {
2297
-                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
2283
+                guard let energyWh = interpolatedTypicalCurvePoint(
2298 2284
                     for: Double(percentBin),
2299 2285
                     anchors: anchors
2300 2286
                 ) else {
2301 2287
                     continue
2302 2288
                 }
2303 2289
 
2304
-                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
2305
-                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
2290
+                groupedEnergyByBin[percentBin, default: []].append(energyWh)
2306 2291
             }
2307 2292
         }
2308 2293
 
2309 2294
         let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
2310
-            guard
2311
-                let energies = groupedEnergyByBin[percentBin],
2312
-                let charges = groupedChargeByBin[percentBin],
2313
-                !energies.isEmpty,
2314
-                !charges.isEmpty
2315
-            else {
2295
+            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
2316 2296
                 return nil
2317 2297
             }
2318 2298
 
2319 2299
             return TypicalChargeCurvePoint(
2320 2300
                 percentBin: percentBin,
2321 2301
                 averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
2322
-                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
2323
-                sampleCount: min(energies.count, charges.count)
2302
+                sampleCount: energies.count
2324 2303
             )
2325 2304
         }
2326 2305
 
2327 2306
         var runningMaximumEnergyWh = 0.0
2328
-        var runningMaximumChargeAh = 0.0
2329 2307
 
2330 2308
         return averagedPoints.map { point in
2331 2309
             runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2332
-            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
2333 2310
             return TypicalChargeCurvePoint(
2334 2311
                 percentBin: point.percentBin,
2335 2312
                 averageEnergyWh: runningMaximumEnergyWh,
2336
-                averageChargeAh: runningMaximumChargeAh,
2337 2313
                 sampleCount: point.sampleCount
2338 2314
             )
2339 2315
         }
@@ -2341,29 +2317,25 @@ final class ChargeInsightsStore {
2341 2317
 
2342 2318
     private func normalizedTypicalCurveAnchors(
2343 2319
         for session: ChargeSessionSummary
2344
-    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
2320
+    ) -> [(percent: Double, energyWh: Double)] {
2345 2321
         struct Anchor {
2346 2322
             let percent: Double
2347 2323
             let energyWh: Double
2348
-            let chargeAh: Double
2349 2324
             let timestamp: Date
2350 2325
         }
2351 2326
 
2352 2327
         var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2353 2328
             guard checkpoint.batteryPercent.isFinite,
2354 2329
                   checkpoint.measuredEnergyWh.isFinite,
2355
-                  checkpoint.measuredChargeAh.isFinite,
2356 2330
                   checkpoint.batteryPercent >= 0,
2357 2331
                   checkpoint.batteryPercent <= 100,
2358
-                  checkpoint.measuredEnergyWh >= 0,
2359
-                  checkpoint.measuredChargeAh >= 0 else {
2332
+                  checkpoint.measuredEnergyWh >= 0 else {
2360 2333
                 return nil
2361 2334
             }
2362 2335
 
2363 2336
             return Anchor(
2364 2337
                 percent: checkpoint.batteryPercent,
2365 2338
                 energyWh: checkpoint.measuredEnergyWh,
2366
-                chargeAh: checkpoint.measuredChargeAh,
2367 2339
                 timestamp: checkpoint.timestamp
2368 2340
             )
2369 2341
         }
@@ -2376,7 +2348,6 @@ final class ChargeInsightsStore {
2376 2348
                 Anchor(
2377 2349
                     percent: startBatteryPercent,
2378 2350
                     energyWh: 0,
2379
-                    chargeAh: 0,
2380 2351
                     timestamp: session.startedAt
2381 2352
                 )
2382 2353
             )
@@ -2390,7 +2361,6 @@ final class ChargeInsightsStore {
2390 2361
                 Anchor(
2391 2362
                     percent: endBatteryPercent,
2392 2363
                     energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2393
-                    chargeAh: session.measuredChargeAh,
2394 2364
                     timestamp: session.endedAt ?? session.lastObservedAt
2395 2365
                 )
2396 2366
             )
@@ -2406,41 +2376,37 @@ final class ChargeInsightsStore {
2406 2376
             return lhs.timestamp < rhs.timestamp
2407 2377
         }
2408 2378
 
2409
-        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2379
+        var collapsedAnchors: [(percent: Double, energyWh: Double)] = []
2410 2380
 
2411 2381
         for anchor in sortedAnchors {
2412 2382
             if let lastIndex = collapsedAnchors.indices.last,
2413 2383
                abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2414 2384
                 collapsedAnchors[lastIndex] = (
2415 2385
                     percent: collapsedAnchors[lastIndex].percent,
2416
-                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2417
-                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
2386
+                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
2418 2387
                 )
2419 2388
             } else {
2420 2389
                 collapsedAnchors.append(
2421
-                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
2390
+                    (percent: anchor.percent, energyWh: anchor.energyWh)
2422 2391
                 )
2423 2392
             }
2424 2393
         }
2425 2394
 
2426 2395
         var runningMaximumEnergyWh = 0.0
2427
-        var runningMaximumChargeAh = 0.0
2428 2396
 
2429 2397
         return collapsedAnchors.map { anchor in
2430 2398
             runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2431
-            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2432 2399
             return (
2433 2400
                 percent: anchor.percent,
2434
-                energyWh: runningMaximumEnergyWh,
2435
-                chargeAh: runningMaximumChargeAh
2401
+                energyWh: runningMaximumEnergyWh
2436 2402
             )
2437 2403
         }
2438 2404
     }
2439 2405
 
2440 2406
     private func interpolatedTypicalCurvePoint(
2441 2407
         for percent: Double,
2442
-        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2443
-    ) -> (energyWh: Double, chargeAh: Double)? {
2408
+        anchors: [(percent: Double, energyWh: Double)]
2409
+    ) -> Double? {
2444 2410
         guard
2445 2411
             let firstAnchor = anchors.first,
2446 2412
             let lastAnchor = anchors.last,
@@ -2451,7 +2417,7 @@ final class ChargeInsightsStore {
2451 2417
         }
2452 2418
 
2453 2419
         if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2454
-            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2420
+            return exactAnchor.energyWh
2455 2421
         }
2456 2422
 
2457 2423
         guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
@@ -2467,9 +2433,7 @@ final class ChargeInsightsStore {
2467 2433
         }
2468 2434
 
2469 2435
         let ratio = (percent - lowerAnchor.percent) / span
2470
-        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2471
-        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2472
-        return (energyWh: energyWh, chargeAh: chargeAh)
2436
+        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2473 2437
     }
2474 2438
 
2475 2439
     private func makeSessionSummary(
@@ -2518,9 +2482,7 @@ final class ChargeInsightsStore {
2518 2482
             autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
2519 2483
             measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2520 2484
             effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
2521
-            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2522 2485
             meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
2523
-            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
2524 2486
             meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2525 2487
             meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
2526 2488
             minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
@@ -2572,7 +2534,6 @@ final class ChargeInsightsStore {
2572 2534
             timestamp: timestamp,
2573 2535
             batteryPercent: doubleValue(object, key: "batteryPercent"),
2574 2536
             measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2575
-            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2576 2537
             currentAmps: doubleValue(object, key: "currentAmps"),
2577 2538
             voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2578 2539
             label: stringValue(object, key: "label")
@@ -2597,7 +2558,6 @@ final class ChargeInsightsStore {
2597 2558
             averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2598 2559
             averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2599 2560
             measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2600
-            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2601 2561
             sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2602 2562
         )
2603 2563
     }
+0 -6
USB Meter/Model/Meter.swift
@@ -1011,12 +1011,6 @@ class Meter : NSObject, ObservableObject, Identifiable {
1011 1011
             didChange = true
1012 1012
         }
1013 1013
 
1014
-        let resolvedChargeAH = max(chargeRecordAH, activeSession.measuredChargeAh)
1015
-        if resolvedChargeAH != chargeRecordAH {
1016
-            chargeRecordAH = resolvedChargeAH
1017
-            didChange = true
1018
-        }
1019
-
1020 1014
         let resolvedChargeWH = max(chargeRecordWH, activeSession.measuredEnergyWh)
1021 1015
         if resolvedChargeWH != chargeRecordWH {
1022 1016
             chargeRecordWH = resolvedChargeWH
+79 -65
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -247,7 +247,6 @@ struct ChargeSessionDetailView: View {
247 247
         chargedDevice: ChargedDeviceSummary
248 248
     ) -> some View {
249 249
         let displayedEnergyWh = displayedSessionEnergyWh(for: session)
250
-        let displayedChargeAh = displayedSessionChargeAh(for: session)
251 250
         let batteryPrediction = chargedDevice.batteryLevelPrediction(
252 251
             for: session,
253 252
             effectiveEnergyWhOverride: displayedEnergyWh
@@ -316,7 +315,6 @@ struct ChargeSessionDetailView: View {
316 315
                 canDeleteCheckpoint: true,
317 316
                 requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id),
318 317
                 effectiveEnergyWhOverride: displayedEnergyWh,
319
-                measuredChargeAhOverride: displayedChargeAh,
320 318
                 onDelete: { checkpoint in
321 319
                     pendingCheckpointDeletion = checkpoint
322 320
                 }
@@ -330,8 +328,7 @@ struct ChargeSessionDetailView: View {
330 328
             if showingStopConfirm {
331 329
                 stopConfirmPanel(
332 330
                     session: session,
333
-                    displayedEnergyWh: displayedEnergyWh,
334
-                    displayedChargeAh: displayedChargeAh
331
+                    displayedEnergyWh: displayedEnergyWh
335 332
                 )
336 333
             } else {
337 334
                 monitoringActionRow(session)
@@ -345,46 +342,87 @@ struct ChargeSessionDetailView: View {
345 342
         _ session: ChargeSessionSummary,
346 343
         chargedDevice: ChargedDeviceSummary
347 344
     ) -> some View {
348
-        MeterInfoCardView(title: session.status.isOpen ? "Open Session" : "Overview", tint: statusTint(for: session)) {
349
-            MeterInfoRowView(label: "Device", value: chargedDevice.name)
350
-            MeterInfoRowView(label: "Status", value: session.status.title)
351
-            MeterInfoRowView(label: "Started", value: session.startedAt.format())
352
-            if let endedAt = session.endedAt {
353
-                MeterInfoRowView(label: "Ended", value: endedAt.format())
354
-            }
355
-            MeterInfoRowView(label: "Duration", value: sessionDurationText(session))
356
-            MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title)
357
-            MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title)
358
-            MeterInfoRowView(label: "Source", value: session.sourceMode.title)
359
-            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session))
360
-            if session.isTrimmed {
361
-                MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format())
362
-                MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format())
363
-            }
364
-            if let meterName = session.meterName {
365
-                MeterInfoRowView(label: "Meter", value: meterName)
366
-            } else if let meterMACAddress = session.meterMACAddress {
367
-                MeterInfoRowView(label: "Meter", value: meterMACAddress)
368
-            }
369
-            if let meterModel = session.meterModel {
370
-                MeterInfoRowView(label: "Meter Model", value: meterModel)
345
+        MeterInfoCardView(
346
+            title: session.status.isOpen ? "Open Session" : "Overview",
347
+            tint: statusTint(for: session),
348
+            isCollapsible: true
349
+        ) {
350
+            VStack(alignment: .leading, spacing: 10) {
351
+                MeterInfoRowView(label: "Device", value: chargedDevice.name)
352
+
353
+                Divider()
354
+
355
+                HStack(alignment: .top, spacing: 12) {
356
+                    overviewStatCell(label: "Started", value: session.startedAt.format())
357
+                    if let endedAt = session.endedAt {
358
+                        overviewStatCell(label: "Ended", value: endedAt.format())
359
+                    }
360
+                }
361
+
362
+                HStack(alignment: .top, spacing: 12) {
363
+                    overviewStatCell(label: "Duration", value: sessionDurationText(session))
364
+                    overviewStatCell(label: "Status", value: session.status.title)
365
+                }
366
+
367
+                Divider()
368
+
369
+                HStack(alignment: .top, spacing: 12) {
370
+                    overviewStatCell(label: "Charging Type", value: session.chargingTransportMode.title)
371
+                    overviewStatCell(label: "Charging Mode", value: session.chargingStateMode.title)
372
+                }
373
+
374
+                HStack(alignment: .top, spacing: 12) {
375
+                    overviewStatCell(label: "Source", value: session.sourceMode.title)
376
+                    overviewStatCell(label: "Auto Stop", value: autoStopDescription(for: session))
377
+                }
378
+
379
+                if session.isTrimmed {
380
+                    Divider()
381
+                    HStack(alignment: .top, spacing: 12) {
382
+                        overviewStatCell(label: "Trim Start", value: session.effectiveTrimStart.format())
383
+                        overviewStatCell(label: "Trim End", value: session.effectiveTrimEnd.format())
384
+                    }
385
+                }
386
+
387
+                let meterLabel: String? = session.meterName ?? session.meterMACAddress
388
+                if meterLabel != nil || session.meterModel != nil {
389
+                    Divider()
390
+                    HStack(alignment: .top, spacing: 12) {
391
+                        if let label = meterLabel {
392
+                            overviewStatCell(label: "Meter", value: label)
393
+                        }
394
+                        if let model = session.meterModel {
395
+                            overviewStatCell(label: "Meter Model", value: model)
396
+                        }
397
+                    }
398
+                }
371 399
             }
372 400
         }
373 401
     }
374 402
 
403
+    private func overviewStatCell(label: String, value: String) -> some View {
404
+        VStack(alignment: .leading, spacing: 2) {
405
+            Text(label)
406
+                .font(.caption2)
407
+                .foregroundColor(.secondary)
408
+            Text(value)
409
+                .font(.footnote.weight(.medium))
410
+                .monospacedDigit()
411
+        }
412
+        .frame(maxWidth: .infinity, alignment: .leading)
413
+    }
414
+
375 415
     private func energyCard(
376 416
         _ session: ChargeSessionSummary,
377 417
         chargedDevice: ChargedDeviceSummary
378 418
     ) -> some View {
379 419
         let displayedEnergyWh = displayedSessionEnergyWh(for: session)
380
-        let displayedChargeAh = displayedSessionChargeAh(for: session)
381 420
 
382
-        return MeterInfoCardView(title: "Energy", tint: .teal) {
421
+        return MeterInfoCardView(title: "Energy", tint: .teal, isCollapsible: true) {
383 422
             MeterInfoRowView(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
384 423
             if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
385 424
                 MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
386 425
             }
387
-            MeterInfoRowView(label: "Measured Charge", value: "\(displayedChargeAh.format(decimalDigits: 3)) Ah")
388 426
             if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
389 427
                abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
390 428
                 MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
@@ -413,7 +451,7 @@ struct ChargeSessionDetailView: View {
413 451
         _ session: ChargeSessionSummary,
414 452
         chargedDevice: ChargedDeviceSummary
415 453
     ) -> some View {
416
-        MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
454
+        MeterInfoCardView(title: "Observed Metrics", tint: .blue, isCollapsible: true, initiallyExpanded: false) {
417 455
             if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
418 456
                 MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A")
419 457
             }
@@ -443,13 +481,12 @@ struct ChargeSessionDetailView: View {
443 481
         chargedDevice: ChargedDeviceSummary
444 482
     ) -> some View {
445 483
         let displayedEnergyWh = displayedSessionEnergyWh(for: session)
446
-        let displayedChargeAh = displayedSessionChargeAh(for: session)
447 484
         let batteryPrediction = chargedDevice.batteryLevelPrediction(
448 485
             for: session,
449 486
             effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
450 487
         )
451 488
 
452
-        return MeterInfoCardView(title: "Battery", tint: .orange) {
489
+        return MeterInfoCardView(title: "Battery", tint: .orange, isCollapsible: true) {
453 490
             if let startBatteryPercent = session.startBatteryPercent {
454 491
                 MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%")
455 492
             }
@@ -484,7 +521,6 @@ struct ChargeSessionDetailView: View {
484 521
                 canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
485 522
                 requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
486 523
                 effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
487
-                measuredChargeAhOverride: hasMonitoringControls ? displayedChargeAh : nil,
488 524
                 onDelete: { checkpoint in
489 525
                     pendingCheckpointDeletion = checkpoint
490 526
                 }
@@ -888,18 +924,15 @@ struct ChargeSessionDetailView: View {
888 924
 
889 925
     private func stopConfirmPanel(
890 926
         session: ChargeSessionSummary,
891
-        displayedEnergyWh: Double,
892
-        displayedChargeAh: Double
927
+        displayedEnergyWh: Double
893 928
     ) -> some View {
894 929
         let canSave = hasSavableChargeData(
895 930
             session: session,
896
-            displayedEnergyWh: displayedEnergyWh,
897
-            displayedChargeAh: displayedChargeAh
931
+            displayedEnergyWh: displayedEnergyWh
898 932
         )
899 933
         let saveDisabledReason = saveDisabledReason(
900 934
             session: session,
901
-            displayedEnergyWh: displayedEnergyWh,
902
-            displayedChargeAh: displayedChargeAh
935
+            displayedEnergyWh: displayedEnergyWh
903 936
         )
904 937
         let isSaveEnabled = saveDisabledReason == nil
905 938
 
@@ -944,8 +977,7 @@ struct ChargeSessionDetailView: View {
944 977
                 Button {
945 978
                     stopSession(
946 979
                         session,
947
-                        displayedEnergyWh: displayedEnergyWh,
948
-                        displayedChargeAh: displayedChargeAh
980
+                        displayedEnergyWh: displayedEnergyWh
949 981
                     )
950 982
                 } label: {
951 983
                     Label("Save Session", systemImage: "checkmark.circle.fill")
@@ -1232,18 +1264,15 @@ struct ChargeSessionDetailView: View {
1232 1264
 
1233 1265
     private func hasSavableChargeData(
1234 1266
         session: ChargeSessionSummary,
1235
-        displayedEnergyWh: Double,
1236
-        displayedChargeAh: Double
1267
+        displayedEnergyWh: Double
1237 1268
     ) -> Bool {
1238 1269
         session.hasSavableChargeData
1239 1270
             || displayedEnergyWh > 0
1240
-            || displayedChargeAh > 0
1241 1271
     }
1242 1272
 
1243 1273
     private func saveDisabledReason(
1244 1274
         session: ChargeSessionSummary,
1245
-        displayedEnergyWh: Double,
1246
-        displayedChargeAh: Double
1275
+        displayedEnergyWh: Double
1247 1276
     ) -> String? {
1248 1277
         if finalCheckpointMode == .custom {
1249 1278
             let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1257,8 +1286,7 @@ struct ChargeSessionDetailView: View {
1257 1286
 
1258 1287
         guard hasSavableChargeData(
1259 1288
             session: session,
1260
-            displayedEnergyWh: displayedEnergyWh,
1261
-            displayedChargeAh: displayedChargeAh
1289
+            displayedEnergyWh: displayedEnergyWh
1262 1290
         ) else {
1263 1291
             return "This session has no charging data to save. Discard it instead."
1264 1292
         }
@@ -1268,15 +1296,13 @@ struct ChargeSessionDetailView: View {
1268 1296
 
1269 1297
     private func stopSession(
1270 1298
         _ session: ChargeSessionSummary,
1271
-        displayedEnergyWh: Double,
1272
-        displayedChargeAh: Double
1299
+        displayedEnergyWh: Double
1273 1300
     ) {
1274 1301
         stopFailureMessage = nil
1275 1302
 
1276 1303
         if let saveDisabledReason = saveDisabledReason(
1277 1304
             session: session,
1278
-            displayedEnergyWh: displayedEnergyWh,
1279
-            displayedChargeAh: displayedChargeAh
1305
+            displayedEnergyWh: displayedEnergyWh
1280 1306
         ) {
1281 1307
             stopFailureMessage = saveDisabledReason
1282 1308
             return
@@ -1352,18 +1378,6 @@ struct ChargeSessionDetailView: View {
1352 1378
         return storedEnergyWh
1353 1379
     }
1354 1380
 
1355
-    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1356
-        let storedChargeAh = session.measuredChargeAh
1357
-        guard session.isTrimmed == false else { return storedChargeAh }
1358
-        guard session.status.isOpen else { return storedChargeAh }
1359
-        guard let liveMonitoringMeter else { return storedChargeAh }
1360
-        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
1361
-        if let baselineChargeAh = session.meterChargeBaselineAh {
1362
-            return max(storedChargeAh, max(liveMonitoringMeter.recordedAH - baselineChargeAh, 0))
1363
-        }
1364
-        return storedChargeAh
1365
-    }
1366
-
1367 1381
     private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1368 1382
         let storedDuration = max(session.effectiveDuration, 0)
1369 1383
         guard session.isTrimmed == false else { return storedDuration }
+1 -8
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/BatteryCheckpointEditorSheetView.swift
@@ -13,7 +13,6 @@ struct BatteryCheckpointEditorContentView: View {
13 13
     let sessionID: UUID
14 14
     let message: String
15 15
     let effectiveEnergyWhOverride: Double?
16
-    let measuredChargeAhOverride: Double?
17 16
     let onCancel: (() -> Void)?
18 17
     let onSaved: (() -> Void)?
19 18
     let showsHeader: Bool
@@ -25,7 +24,6 @@ struct BatteryCheckpointEditorContentView: View {
25 24
         sessionID: UUID,
26 25
         message: String,
27 26
         effectiveEnergyWhOverride: Double?,
28
-        measuredChargeAhOverride: Double?,
29 27
         onCancel: (() -> Void)?,
30 28
         onSaved: (() -> Void)?,
31 29
         showsHeader: Bool = true
@@ -33,7 +31,6 @@ struct BatteryCheckpointEditorContentView: View {
33 31
         self.sessionID = sessionID
34 32
         self.message = message
35 33
         self.effectiveEnergyWhOverride = effectiveEnergyWhOverride
36
-        self.measuredChargeAhOverride = measuredChargeAhOverride
37 34
         self.onCancel = onCancel
38 35
         self.onSaved = onSaved
39 36
         self.showsHeader = showsHeader
@@ -167,8 +164,7 @@ struct BatteryCheckpointEditorContentView: View {
167 164
         if appData.addBatteryCheckpoint(
168 165
             percent: percent,
169 166
             for: sessionID,
170
-            measuredEnergyWh: effectiveEnergyWhOverride,
171
-            measuredChargeAh: measuredChargeAhOverride
167
+            measuredEnergyWh: effectiveEnergyWhOverride
172 168
         ) {
173 169
             onSaved?()
174 170
         }
@@ -183,7 +179,6 @@ struct BatteryCheckpointSectionView: View {
183 179
     let canDeleteCheckpoint: Bool
184 180
     let requirementMessage: String?
185 181
     let effectiveEnergyWhOverride: Double?
186
-    let measuredChargeAhOverride: Double?
187 182
     let onDelete: (ChargeCheckpointSummary) -> Void
188 183
 
189 184
     @State private var showsInlineCheckpointEditor = false
@@ -211,7 +206,6 @@ struct BatteryCheckpointSectionView: View {
211 206
                             sessionID: sessionID,
212 207
                             message: message,
213 208
                             effectiveEnergyWhOverride: effectiveEnergyWhOverride,
214
-                            measuredChargeAhOverride: measuredChargeAhOverride,
215 209
                             onCancel: { showsInlineCheckpointEditor = false },
216 210
                             onSaved: { showsInlineCheckpointEditor = false },
217 211
                             showsHeader: false
@@ -294,7 +288,6 @@ struct BatteryCheckpointEditorSheetView: View {
294 288
                             sessionID: activeSession.id,
295 289
                             message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
296 290
                             effectiveEnergyWhOverride: nil,
297
-                            measuredChargeAhOverride: nil,
298 291
                             onCancel: { dismiss() },
299 292
                             onSaved: { dismiss() },
300 293
                             showsHeader: true
+1 -12
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/ChargeSessionCompletionSheetView.swift
@@ -180,7 +180,6 @@ struct ChargeSessionCompletionSheetView: View {
180 180
         guard let session else { return false }
181 181
         return session.hasSavableChargeData
182 182
             || displayedSessionEnergyWh(for: session) > 0
183
-            || displayedSessionChargeAh(for: session) > 0
184 183
     }
185 184
 
186 185
     private var saveDisabledReason: String? {
@@ -252,15 +251,5 @@ struct ChargeSessionCompletionSheetView: View {
252 251
         return storedEnergyWh
253 252
     }
254 253
 
255
-    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
256
-        let storedChargeAh = session.measuredChargeAh
257
-        guard session.isTrimmed == false else { return storedChargeAh }
258
-        guard session.status.isOpen else { return storedChargeAh }
259
-        guard let monitoringMeter else { return storedChargeAh }
260
-        guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
261
-        if let baselineChargeAh = session.meterChargeBaselineAh {
262
-            return max(storedChargeAh, max(monitoringMeter.recordedAH - baselineChargeAh, 0))
263
-        }
264
-        return storedChargeAh
265
-    }
266 254
 }
255
+
+26 -2
USB Meter/Views/Meter/Components/MeterInfoCardView.swift
@@ -9,6 +9,8 @@ struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
9 9
     let title: String
10 10
     let infoMessage: String?
11 11
     let tint: Color
12
+    let isCollapsible: Bool
13
+    @State private var isExpanded: Bool
12 14
     @ViewBuilder var trailingActions: TrailingActions
13 15
     @ViewBuilder var content: Content
14 16
 
@@ -16,18 +18,22 @@ struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
16 18
         title: String,
17 19
         infoMessage: String? = nil,
18 20
         tint: Color,
21
+        isCollapsible: Bool = false,
22
+        initiallyExpanded: Bool = true,
19 23
         @ViewBuilder trailingActions: () -> TrailingActions = { EmptyView() },
20 24
         @ViewBuilder content: () -> Content
21 25
     ) {
22 26
         self.title = title
23 27
         self.infoMessage = infoMessage
24 28
         self.tint = tint
29
+        self.isCollapsible = isCollapsible
30
+        self._isExpanded = State(initialValue: initiallyExpanded)
25 31
         self.trailingActions = trailingActions()
26 32
         self.content = content()
27 33
     }
28 34
 
29 35
     var body: some View {
30
-        VStack(alignment: .leading, spacing: 12) {
36
+        VStack(alignment: .leading, spacing: isExpanded ? 12 : 0) {
31 37
             HStack(spacing: 8) {
32 38
                 Text(title)
33 39
                     .font(.headline)
@@ -36,8 +42,26 @@ struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
36 42
                 }
37 43
                 Spacer(minLength: 0)
38 44
                 trailingActions
45
+                if isCollapsible {
46
+                    Image(systemName: "chevron.up")
47
+                        .font(.caption.weight(.semibold))
48
+                        .foregroundColor(.secondary)
49
+                        .rotationEffect(.degrees(isExpanded ? 0 : -180))
50
+                        .animation(.easeInOut(duration: 0.2), value: isExpanded)
51
+                }
52
+            }
53
+            .contentShape(Rectangle())
54
+            .onTapGesture {
55
+                guard isCollapsible else { return }
56
+                withAnimation(.easeInOut(duration: 0.25)) {
57
+                    isExpanded.toggle()
58
+                }
59
+            }
60
+
61
+            if isExpanded {
62
+                content
63
+                    .transition(.opacity.combined(with: .move(edge: .top)))
39 64
             }
40
-            content
41 65
         }
42 66
         .frame(maxWidth: .infinity, alignment: .leading)
43 67
         .padding(18)