Showing 10 changed files with 783 additions and 87 deletions
+3 -1
USB Meter.xcodeproj/project.pbxproj
@@ -229,6 +229,7 @@
229 229
 		F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 15.xcdatamodel"; sourceTree = "<group>"; };
230 230
 		F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 16.xcdatamodel"; sourceTree = "<group>"; };
231 231
 		F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 17.xcdatamodel"; sourceTree = "<group>"; };
232
+		F10000046F5D4C95B6487F19 /* USB_Meter 18.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 18.xcdatamodel"; sourceTree = "<group>"; };
232 233
 		F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
233 234
 /* End PBXFileReference section */
234 235
 
@@ -1196,8 +1197,9 @@
1196 1197
 				F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */,
1197 1198
 				F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */,
1198 1199
 				F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */,
1200
+				F10000046F5D4C95B6487F19 /* USB_Meter 18.xcdatamodel */,
1199 1201
 			);
1200
-			currentVersion = F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */;
1202
+			currentVersion = F10000046F5D4C95B6487F19 /* USB_Meter 18.xcdatamodel */;
1201 1203
 			path = CKModel.xcdatamodeld;
1202 1204
 			sourceTree = "<group>";
1203 1205
 			versionGroupType = wrapper.xcdatamodel;
+1 -1
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -3,6 +3,6 @@
3 3
 <plist version="1.0">
4 4
 <dict>
5 5
 	<key>_XCCurrentVersionName</key>
6
-	<string>USB_Meter 17.xcdatamodel</string>
6
+	<string>USB_Meter 18.xcdatamodel</string>
7 7
 </dict>
8 8
 </plist>
+127 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 18.xcdatamodel/contents
@@ -0,0 +1,127 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
8
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
9
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
10
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
11
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
12
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
13
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
14
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
26
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
30
+        <attribute name="notes" optional="YES" attributeType="String"/>
31
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+    </entity>
34
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
35
+        <attribute name="id" optional="YES" attributeType="String"/>
36
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
37
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
38
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
39
+        <attribute name="meterName" optional="YES" attributeType="String"/>
40
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
41
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
42
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
43
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
44
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
45
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
46
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
47
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
49
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
50
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
51
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
52
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
56
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
57
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
58
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
59
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
60
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
61
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
62
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
64
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
65
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
66
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
67
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
68
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
69
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
70
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
71
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
72
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
73
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
74
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
75
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
76
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
77
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
78
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
79
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
80
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
81
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
82
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
83
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
84
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
85
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
86
+        <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
87
+        <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
88
+        <attribute name="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
89
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
90
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
91
+    </entity>
92
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
93
+        <attribute name="id" optional="YES" attributeType="String"/>
94
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
95
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
96
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
97
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
98
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
99
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
100
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
101
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
102
+        <attribute name="label" optional="YES" attributeType="String"/>
103
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
104
+    </entity>
105
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class">
106
+        <attribute name="id" optional="YES" attributeType="String"/>
107
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
108
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
109
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
110
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
111
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
112
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
113
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
114
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
115
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
116
+        <attribute name="estimatedBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
117
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
118
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
119
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
120
+    </entity>
121
+    <elements>
122
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/>
123
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="883"/>
124
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
125
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="268"/>
126
+    </elements>
127
+</model>
+38 -12
USB Meter/Model/ChargeInsightsModel.swift
@@ -607,6 +607,7 @@ struct ChargeSessionSampleSummary: Identifiable, Hashable {
607 607
     let averageVoltageVolts: Double?
608 608
     let averagePowerWatts: Double
609 609
     let measuredEnergyWh: Double
610
+    let estimatedBatteryPercent: Double?
610 611
     let sampleCount: Int
611 612
 
612 613
     var id: String {
@@ -1448,7 +1449,8 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1448 1449
 
1449 1450
     func batteryLevelPrediction(
1450 1451
         for session: ChargeSessionSummary,
1451
-        effectiveEnergyWhOverride: Double? = nil
1452
+        effectiveEnergyWhOverride: Double? = nil,
1453
+        referenceTimestamp: Date? = nil
1452 1454
     ) -> BatteryLevelPrediction? {
1453 1455
         let estimatedCapacityWh = session.capacityEstimateWh
1454 1456
             ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
@@ -1510,17 +1512,41 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1510 1512
             return nil
1511 1513
         }
1512 1514
 
1513
-        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1514
-        let anchor = eligibleAnchors.last ?? anchors.first!
1515
-        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1516
-            anchorPercent: anchor.percent,
1517
-            anchorEnergyWh: anchor.energyWh,
1518
-            anchorTimestamp: anchor.timestamp,
1519
-            anchorIsCheckpoint: anchor.isCheckpoint,
1520
-            effectiveEnergyWh: effectiveEnergyWh,
1521
-            referenceTimestamp: session.lastObservedAt,
1522
-            estimatedCapacityWh: estimatedCapacityWh
1523
-        )
1515
+        let lowerAnchor = anchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
1516
+        let upperAnchor = anchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
1517
+        let anchor = lowerAnchor ?? upperAnchor ?? anchors.first!
1518
+
1519
+        let predictedPercent: Double
1520
+        if let lowerAnchor,
1521
+           let upperAnchor,
1522
+           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
1523
+            let interpolationProgress = min(
1524
+                max(
1525
+                    (effectiveEnergyWh - lowerAnchor.energyWh) /
1526
+                    (upperAnchor.energyWh - lowerAnchor.energyWh),
1527
+                    0
1528
+                ),
1529
+                1
1530
+            )
1531
+            predictedPercent = min(
1532
+                max(
1533
+                    lowerAnchor.percent +
1534
+                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
1535
+                    0
1536
+                ),
1537
+                100
1538
+            )
1539
+        } else {
1540
+            predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1541
+                anchorPercent: anchor.percent,
1542
+                anchorEnergyWh: anchor.energyWh,
1543
+                anchorTimestamp: anchor.timestamp,
1544
+                anchorIsCheckpoint: anchor.isCheckpoint,
1545
+                effectiveEnergyWh: effectiveEnergyWh,
1546
+                referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
1547
+                estimatedCapacityWh: estimatedCapacityWh
1548
+            )
1549
+        }
1524 1550
 
1525 1551
         return BatteryLevelPrediction(
1526 1552
             predictedPercent: predictedPercent,
+83 -19
USB Meter/Model/ChargeInsightsStore.swift
@@ -1622,6 +1622,7 @@ final class ChargeInsightsStore {
1622 1622
         )
1623 1623
         sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1624 1624
         sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1625
+        setValue(predictedBatteryPercent(for: session), on: sample, key: "estimatedBatteryPercent")
1625 1626
         sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1626 1627
         sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1627 1628
         sample.setValue(snapshot.observedAt, forKey: "updatedAt")
@@ -1830,6 +1831,7 @@ final class ChargeInsightsStore {
1830 1831
         clearCompletionConfirmationState(for: session)
1831 1832
         session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1832 1833
         updateCapacityEstimate(for: session)
1834
+        refreshEstimatedBatteryPercents(for: session)
1833 1835
         session.setValue(observedAt, forKey: "updatedAt")
1834 1836
 
1835 1837
         if status == .completed {
@@ -1841,7 +1843,11 @@ final class ChargeInsightsStore {
1841 1843
         }
1842 1844
     }
1843 1845
 
1844
-    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1846
+    private func predictedBatteryPercent(
1847
+        for session: NSManagedObject,
1848
+        effectiveEnergyWhOverride: Double? = nil,
1849
+        referenceTimestamp: Date? = nil
1850
+    ) -> Double? {
1845 1851
         guard
1846 1852
             let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1847 1853
             let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
@@ -1851,22 +1857,11 @@ final class ChargeInsightsStore {
1851 1857
             return nil
1852 1858
         }
1853 1859
 
1854
-        // Compute effective battery energy dynamically so the prediction uses the
1855
-        // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh
1856
-        // (which is only refreshed at session start, checkpoint insertion, and finish).
1857
-        let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1858
-        let measuredEnergyWh: Double
1859
-        switch chargingTransportMode(for: session) {
1860
-        case .wired:
1861
-            measuredEnergyWh = rawMeasuredEnergyWh
1862
-        case .wireless:
1863
-            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1864
-                measuredEnergyWh = rawMeasuredEnergyWh * factor
1865
-            } else {
1866
-                measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1867
-                    ?? rawMeasuredEnergyWh
1868
-            }
1869
-        }
1860
+        let measuredEnergyWh = effectiveEnergyWhOverride
1861
+            ?? effectiveBatteryEnergyWh(
1862
+                rawMeasuredEnergyWh: doubleValue(session, key: "measuredEnergyWh"),
1863
+                for: session
1864
+            )
1870 1865
         let sessionID = stringValue(session, key: "id") ?? ""
1871 1866
 
1872 1867
         struct Anchor {
@@ -1914,18 +1909,84 @@ final class ChargeInsightsStore {
1914 1909
             return optionalDoubleValue(session, key: "endBatteryPercent")
1915 1910
         }
1916 1911
 
1917
-        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
1912
+        let lowerAnchor = anchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
1913
+        let upperAnchor = anchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
1914
+        let anchor = lowerAnchor ?? upperAnchor ?? anchors.first!
1915
+
1916
+        if let lowerAnchor,
1917
+           let upperAnchor,
1918
+           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
1919
+            let interpolationProgress = min(
1920
+                max(
1921
+                    (measuredEnergyWh - lowerAnchor.energyWh) /
1922
+                    (upperAnchor.energyWh - lowerAnchor.energyWh),
1923
+                    0
1924
+                ),
1925
+                1
1926
+            )
1927
+            return min(
1928
+                max(
1929
+                    lowerAnchor.percent +
1930
+                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
1931
+                    0
1932
+                ),
1933
+                100
1934
+            )
1935
+        }
1936
+
1918 1937
         return BatteryLevelPredictionTuning.predictedPercent(
1919 1938
             anchorPercent: anchor.percent,
1920 1939
             anchorEnergyWh: anchor.energyWh,
1921 1940
             anchorTimestamp: anchor.timestamp,
1922 1941
             anchorIsCheckpoint: anchor.isCheckpoint,
1923 1942
             effectiveEnergyWh: measuredEnergyWh,
1924
-            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1943
+            referenceTimestamp: referenceTimestamp
1944
+                ?? dateValue(session, key: "lastObservedAt")
1945
+                ?? anchor.timestamp,
1925 1946
             estimatedCapacityWh: estimatedCapacityWh
1926 1947
         )
1927 1948
     }
1928 1949
 
1950
+    private func effectiveBatteryEnergyWh(
1951
+        rawMeasuredEnergyWh: Double,
1952
+        for session: NSManagedObject
1953
+    ) -> Double {
1954
+        switch chargingTransportMode(for: session) {
1955
+        case .wired:
1956
+            return rawMeasuredEnergyWh
1957
+        case .wireless:
1958
+            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1959
+                return rawMeasuredEnergyWh * factor
1960
+            }
1961
+            let sessionMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1962
+            if let sessionEffectiveEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh"),
1963
+               sessionMeasuredEnergyWh > 0 {
1964
+                return rawMeasuredEnergyWh * (sessionEffectiveEnergyWh / sessionMeasuredEnergyWh)
1965
+            }
1966
+            return rawMeasuredEnergyWh
1967
+        }
1968
+    }
1969
+
1970
+    private func refreshEstimatedBatteryPercents(for session: NSManagedObject) {
1971
+        guard let sessionID = stringValue(session, key: "id") else {
1972
+            return
1973
+        }
1974
+
1975
+        for sample in fetchSessionSampleObjects(forSessionID: sessionID) {
1976
+            let effectiveEnergyWh = effectiveBatteryEnergyWh(
1977
+                rawMeasuredEnergyWh: doubleValue(sample, key: "measuredEnergyWh"),
1978
+                for: session
1979
+            )
1980
+            let percent = predictedBatteryPercent(
1981
+                for: session,
1982
+                effectiveEnergyWhOverride: effectiveEnergyWh,
1983
+                referenceTimestamp: dateValue(sample, key: "timestamp")
1984
+            )
1985
+            setValue(percent, on: sample, key: "estimatedBatteryPercent")
1986
+            setValue(Date(), on: sample, key: "updatedAt")
1987
+        }
1988
+    }
1989
+
1929 1990
     private func resolvedEstimatedBatteryCapacityWh(
1930 1991
         for session: NSManagedObject,
1931 1992
         chargedDevice: NSManagedObject
@@ -2103,6 +2164,7 @@ final class ChargeInsightsStore {
2103 2164
         }
2104 2165
         session.setValue(timestamp, forKey: "updatedAt")
2105 2166
         updateCapacityEstimate(for: session)
2167
+        refreshEstimatedBatteryPercents(for: session)
2106 2168
 
2107 2169
         return chargedDeviceID
2108 2170
     }
@@ -2124,6 +2186,7 @@ final class ChargeInsightsStore {
2124 2186
 
2125 2187
         session.setValue(Date(), forKey: "updatedAt")
2126 2188
         updateCapacityEstimate(for: session)
2189
+        refreshEstimatedBatteryPercents(for: session)
2127 2190
     }
2128 2191
 
2129 2192
     @discardableResult
@@ -2601,6 +2664,7 @@ final class ChargeInsightsStore {
2601 2664
             averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2602 2665
             averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2603 2666
             measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2667
+            estimatedBatteryPercent: optionalDoubleValue(object, key: "estimatedBatteryPercent"),
2604 2668
             sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2605 2669
         )
2606 2670
     }
+125 -1
USB Meter/Model/Measurements.swift
@@ -267,6 +267,7 @@ class Measurements : ObservableObject {
267 267
     @Published var temperature = Measurement()
268 268
     @Published var energy = Measurement()
269 269
     @Published var rssi = Measurement()
270
+    @Published var batteryPercent = Measurement()
270 271
 
271 272
     let averagePowerSampleOptions: [Int] = [5, 10, 20, 50, 100, 250]
272 273
 
@@ -328,7 +329,8 @@ class Measurements : ObservableObject {
328 329
             current.points.isEmpty == false ||
329 330
             temperature.points.isEmpty == false ||
330 331
             energy.points.isEmpty == false ||
331
-            rssi.points.isEmpty == false
332
+            rssi.points.isEmpty == false ||
333
+            batteryPercent.points.isEmpty == false
332 334
 
333 335
         restoreTrace(
334 336
             "measurements-restore-start session=\(session.id.uuidString) status=\(session.status.rawValue) persistedSamples=\(session.aggregatedSamples.count) replaceLive=\(replacingLiveBufferIfNeeded) existingBuffer=\(hasExistingBuffer) existingCounts=p:\(power.points.count),v:\(voltage.points.count),c:\(current.points.count),t:\(temperature.points.count),e:\(energy.points.count),r:\(rssi.points.count)"
@@ -372,6 +374,9 @@ class Measurements : ObservableObject {
372 374
         let restoredEnergyPoints = restoredPoints(from: sortedSamples) { sample in
373 375
             sample.measuredEnergyWh
374 376
         }
377
+        let restoredBatteryPercentPoints = restoredPoints(from: sortedSamples) { sample in
378
+            sample.estimatedBatteryPercent ?? estimatedBatteryPercent(for: sample, in: session)
379
+        }
375 380
 
376 381
         let mergedPowerPoints = mergedRestoredPoints(
377 382
             restored: restoredPowerPoints,
@@ -393,6 +398,11 @@ class Measurements : ObservableObject {
393 398
             existing: energy.points,
394 399
             persistedRangeUpperBound: persistedRangeUpperBound
395 400
         )
401
+        let mergedBatteryPercentPoints = mergedRestoredPoints(
402
+            restored: restoredBatteryPercentPoints,
403
+            existing: batteryPercent.points,
404
+            persistedRangeUpperBound: persistedRangeUpperBound
405
+        )
396 406
         let preservedRssiTail = preservedTailPoints(
397 407
             from: rssi.points,
398 408
             after: persistedRangeUpperBound
@@ -406,6 +416,7 @@ class Measurements : ObservableObject {
406 416
         current.replacePoints(mergedCurrentPoints)
407 417
         voltage.replacePoints(mergedVoltagePoints)
408 418
         energy.replacePoints(mergedEnergyPoints)
419
+        batteryPercent.replacePoints(mergedBatteryPercentPoints)
409 420
         temperature.resetSeries()
410 421
         rssi.replacePoints(preservedRssiTail)
411 422
 
@@ -455,6 +466,113 @@ class Measurements : ObservableObject {
455 466
         return restored
456 467
     }
457 468
 
469
+    private func estimatedBatteryPercent(
470
+        for sample: ChargeSessionSampleSummary,
471
+        in session: ChargeSessionSummary
472
+    ) -> Double? {
473
+        let estimatedCapacityWh = session.capacityEstimateWh
474
+
475
+        struct Anchor {
476
+            let percent: Double
477
+            let energyWh: Double
478
+            let timestamp: Date
479
+            let isCheckpoint: Bool
480
+        }
481
+
482
+        var anchors: [Anchor] = []
483
+        if let startBatteryPercent = session.startBatteryPercent,
484
+           startBatteryPercent >= 0 {
485
+            anchors.append(
486
+                Anchor(
487
+                    percent: startBatteryPercent,
488
+                    energyWh: 0,
489
+                    timestamp: session.effectiveTrimStart,
490
+                    isCheckpoint: false
491
+                )
492
+            )
493
+        }
494
+
495
+        anchors.append(
496
+            contentsOf: session.checkpoints
497
+                .filter { $0.batteryPercent >= 0 }
498
+                .sorted { lhs, rhs in
499
+                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
500
+                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
501
+                    }
502
+                    return lhs.timestamp < rhs.timestamp
503
+                }
504
+                .map {
505
+                    Anchor(
506
+                        percent: $0.batteryPercent,
507
+                        energyWh: $0.measuredEnergyWh,
508
+                        timestamp: $0.timestamp,
509
+                        isCheckpoint: true
510
+                    )
511
+                }
512
+        )
513
+
514
+        guard !anchors.isEmpty else { return nil }
515
+
516
+        let effectiveEnergyWh = effectiveBatteryEnergyWh(for: sample, in: session)
517
+        let lowerAnchor = anchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
518
+        let upperAnchor = anchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
519
+        let anchor = lowerAnchor ?? upperAnchor ?? anchors.first!
520
+
521
+        if let lowerAnchor,
522
+           let upperAnchor,
523
+           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
524
+            let interpolationProgress = min(
525
+                max(
526
+                    (effectiveEnergyWh - lowerAnchor.energyWh) /
527
+                    (upperAnchor.energyWh - lowerAnchor.energyWh),
528
+                    0
529
+                ),
530
+                1
531
+            )
532
+            return min(
533
+                max(
534
+                    lowerAnchor.percent +
535
+                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
536
+                    0
537
+                ),
538
+                100
539
+            )
540
+        }
541
+
542
+        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
543
+            return nil
544
+        }
545
+
546
+        return BatteryLevelPredictionTuning.predictedPercent(
547
+            anchorPercent: anchor.percent,
548
+            anchorEnergyWh: anchor.energyWh,
549
+            anchorTimestamp: anchor.timestamp,
550
+            anchorIsCheckpoint: anchor.isCheckpoint,
551
+            effectiveEnergyWh: effectiveEnergyWh,
552
+            referenceTimestamp: sample.timestamp,
553
+            estimatedCapacityWh: estimatedCapacityWh
554
+        )
555
+    }
556
+
557
+    private func effectiveBatteryEnergyWh(
558
+        for sample: ChargeSessionSampleSummary,
559
+        in session: ChargeSessionSummary
560
+    ) -> Double {
561
+        switch session.chargingTransportMode {
562
+        case .wired:
563
+            return sample.measuredEnergyWh
564
+        case .wireless:
565
+            if let factor = session.wirelessEfficiencyFactor, factor > 0 {
566
+                return sample.measuredEnergyWh * factor
567
+            }
568
+            if let sessionEffectiveEnergyWh = session.effectiveBatteryEnergyWh,
569
+               session.measuredEnergyWh > 0 {
570
+                return sample.measuredEnergyWh * (sessionEffectiveEnergyWh / session.measuredEnergyWh)
571
+            }
572
+            return sample.measuredEnergyWh
573
+        }
574
+    }
575
+
458 576
     private func mergedRestoredPoints(
459 577
         restored: [Measurement.Point],
460 578
         existing: [Measurement.Point],
@@ -519,6 +637,7 @@ class Measurements : ObservableObject {
519 637
         temperature.resetSeries()
520 638
         energy.resetSeries()
521 639
         rssi.resetSeries()
640
+        batteryPercent.resetSeries()
522 641
         resetPendingAggregation()
523 642
         lastEnergyCounterValue = nil
524 643
         lastEnergyGroupID = nil
@@ -537,6 +656,7 @@ class Measurements : ObservableObject {
537 656
         temperature.removeValue(index: idx)
538 657
         energy.removeValue(index: idx)
539 658
         rssi.removeValue(index: idx)
659
+        batteryPercent.removeValue(index: idx)
540 660
         realignEnergyBufferStart()
541 661
         self.objectWillChange.send()
542 662
     }
@@ -549,6 +669,7 @@ class Measurements : ObservableObject {
549 669
         temperature.trim(before: cutoff)
550 670
         energy.trim(before: cutoff)
551 671
         rssi.trim(before: cutoff)
672
+        batteryPercent.trim(before: cutoff)
552 673
         realignEnergyBufferStart()
553 674
         self.objectWillChange.send()
554 675
     }
@@ -561,6 +682,7 @@ class Measurements : ObservableObject {
561 682
         temperature.filterSamples { range.contains($0) }
562 683
         energy.filterSamples { range.contains($0) }
563 684
         rssi.filterSamples { range.contains($0) }
685
+        batteryPercent.filterSamples { range.contains($0) }
564 686
         realignEnergyBufferStart()
565 687
         self.objectWillChange.send()
566 688
     }
@@ -573,6 +695,7 @@ class Measurements : ObservableObject {
573 695
         temperature.filterSamples { !range.contains($0) }
574 696
         energy.filterSamples { !range.contains($0) }
575 697
         rssi.filterSamples { !range.contains($0) }
698
+        batteryPercent.filterSamples { !range.contains($0) }
576 699
         realignEnergyBufferStart()
577 700
         self.objectWillChange.send()
578 701
     }
@@ -620,6 +743,7 @@ class Measurements : ObservableObject {
620 743
         temperature.addDiscontinuity(timestamp: timestamp)
621 744
         energy.addDiscontinuity(timestamp: timestamp)
622 745
         rssi.addDiscontinuity(timestamp: timestamp)
746
+        batteryPercent.addDiscontinuity(timestamp: timestamp)
623 747
         self.objectWillChange.send()
624 748
     }
625 749
 
+7 -3
USB Meter/Model/Meter.swift
@@ -75,6 +75,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
75 75
         let sessionID: UUID
76 76
         let sampleCount: Int
77 77
         let lastSampleTimestamp: Date?
78
+        let checkpointCount: Int
78 79
     }
79 80
 
80 81
 
@@ -988,14 +989,17 @@ class Meter : NSObject, ObservableObject, Identifiable {
988 989
         let restoreSignature = ChargeRecordRestoreSignature(
989 990
             sessionID: activeSession.id,
990 991
             sampleCount: activeSession.aggregatedSamples.count,
991
-            lastSampleTimestamp: activeSession.aggregatedSamples.last?.timestamp
992
+            lastSampleTimestamp: activeSession.aggregatedSamples.last?.timestamp,
993
+            checkpointCount: activeSession.checkpoints.count
992 994
         )
993 995
 
994 996
         if restoreSignature != restoredChargeRecordSignature {
995
-            restoreTrace("meter=\(name) charge-record-restore-start session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue) samples=\(restoreSignature.sampleCount) lastTs=\(restoreSignature.lastSampleTimestamp?.description ?? "nil") replaceLive=\(replacingLiveBufferIfNeeded) state=\(chargeRecordState) existingPower=\(chargeRecordMeasurements.power.samplePoints.count)")
997
+            let shouldRefreshPersistedBuffer = replacingLiveBufferIfNeeded ||
998
+                restoreSignature.checkpointCount != restoredChargeRecordSignature?.checkpointCount
999
+            restoreTrace("meter=\(name) charge-record-restore-start session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue) samples=\(restoreSignature.sampleCount) lastTs=\(restoreSignature.lastSampleTimestamp?.description ?? "nil") replaceLive=\(shouldRefreshPersistedBuffer) state=\(chargeRecordState) existingPower=\(chargeRecordMeasurements.power.samplePoints.count)")
996 1000
             let didRestorePersistedSamples = chargeRecordMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
997 1001
                 from: activeSession,
998
-                replacingLiveBufferIfNeeded: replacingLiveBufferIfNeeded
1002
+                replacingLiveBufferIfNeeded: shouldRefreshPersistedBuffer
999 1003
             )
1000 1004
             restoreTrace("meter=\(name) charge-record-restore-result session=\(activeSession.id.uuidString) didRestore=\(didRestorePersistedSamples) priorSignatureSamples=\(restoredChargeRecordSignature?.sampleCount.description ?? "nil")")
1001 1005
             if didRestorePersistedSamples || activeSession.aggregatedSamples.isEmpty == false {
+168 -3
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -35,6 +35,12 @@ struct ChargeSessionDetailView: View {
35 35
         }
36 36
     }
37 37
 
38
+    private struct BatteryPercentCandidate {
39
+        let timestamp: Date
40
+        let percent: Double
41
+        let isCheckpoint: Bool
42
+    }
43
+
38 44
     @EnvironmentObject private var appData: AppData
39 45
 
40 46
     let chargedDeviceID: UUID
@@ -185,6 +191,9 @@ struct ChargeSessionDetailView: View {
185 191
             syncMonitoringRestore()
186 192
             runTrimDetection()
187 193
         }
194
+        .onChange(of: session?.checkpoints.count) { _ in
195
+            syncMonitoringRestore()
196
+        }
188 197
         .onChange(of: finalCheckpointMode) { _ in
189 198
             stopFailureMessage = nil
190 199
         }
@@ -207,14 +216,14 @@ struct ChargeSessionDetailView: View {
207 216
                     }
208 217
 
209 218
                     if shouldShowSessionChart(session) {
210
-                        chartCard(session)
219
+                        chartCard(session, chargedDevice: chargedDevice)
211 220
                     }
212 221
                 } else {
213 222
                     overviewCard(session, chargedDevice: chargedDevice)
214 223
                     batteryCard(session, chargedDevice: chargedDevice)
215 224
 
216 225
                     if shouldShowSessionChart(session) {
217
-                        chartCard(session)
226
+                        chartCard(session, chargedDevice: chargedDevice)
218 227
                     }
219 228
 
220 229
                     if session.status.isOpen {
@@ -1323,10 +1332,17 @@ struct ChargeSessionDetailView: View {
1323 1332
         !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1324 1333
     }
1325 1334
 
1326
-    private func chartCard(_ session: ChargeSessionSummary) -> some View {
1335
+    private func chartCard(
1336
+        _ session: ChargeSessionSummary,
1337
+        chargedDevice: ChargedDeviceSummary
1338
+    ) -> some View {
1327 1339
         ChargeSessionChartCardView(
1328 1340
             session: session,
1329 1341
             monitoringMeter: liveMonitoringMeter,
1342
+            batteryPercentPoints: batteryPercentChartPoints(
1343
+                for: session,
1344
+                chargedDevice: chargedDevice
1345
+            ),
1330 1346
             controlMode: chartControlMode(for: session),
1331 1347
             onSetTrim: { start, end in
1332 1348
                 setSessionTrim(sessionID: session.id, start: start, end: end)
@@ -1366,6 +1382,144 @@ struct ChargeSessionDetailView: View {
1366 1382
         trimBannerDismissedForSessionID = sessionID
1367 1383
     }
1368 1384
 
1385
+    private func batteryPercentChartPoints(
1386
+        for session: ChargeSessionSummary,
1387
+        chargedDevice: ChargedDeviceSummary
1388
+    ) -> [Measurements.Measurement.Point] {
1389
+        var candidates: [BatteryPercentCandidate] = []
1390
+
1391
+        for sample in session.displayedAggregatedSamples {
1392
+            let percent = chargedDevice.batteryLevelPrediction(
1393
+                    for: session,
1394
+                    effectiveEnergyWhOverride: effectiveBatteryEnergyWh(
1395
+                        rawMeasuredEnergyWh: sample.measuredEnergyWh,
1396
+                        for: session
1397
+                    ),
1398
+                    referenceTimestamp: sample.timestamp
1399
+                )?.predictedPercent
1400
+                ?? sample.estimatedBatteryPercent
1401
+
1402
+            if let percent, percent.isFinite {
1403
+                candidates.append(
1404
+                    BatteryPercentCandidate(
1405
+                        timestamp: sample.timestamp,
1406
+                        percent: percent,
1407
+                        isCheckpoint: false
1408
+                    )
1409
+                )
1410
+            }
1411
+        }
1412
+
1413
+        for checkpoint in session.checkpoints where session.effectiveTimeRange.contains(checkpoint.timestamp) {
1414
+            guard checkpoint.batteryPercent.isFinite,
1415
+                  checkpoint.batteryPercent >= 0,
1416
+                  checkpoint.batteryPercent <= 100 else {
1417
+                continue
1418
+            }
1419
+            candidates.append(
1420
+                BatteryPercentCandidate(
1421
+                    timestamp: checkpoint.timestamp,
1422
+                    percent: checkpoint.batteryPercent,
1423
+                    isCheckpoint: true
1424
+                )
1425
+            )
1426
+        }
1427
+
1428
+        if hasMonitoringControls,
1429
+           let prediction = chargedDevice.batteryLevelPrediction(
1430
+            for: session,
1431
+            effectiveEnergyWhOverride: displayedSessionEnergyWh(for: session)
1432
+           ) {
1433
+            candidates.append(
1434
+                BatteryPercentCandidate(
1435
+                    timestamp: max(session.lastObservedAt, Date()),
1436
+                    percent: prediction.predictedPercent,
1437
+                    isCheckpoint: false
1438
+                )
1439
+            )
1440
+        }
1441
+
1442
+        let sortedCandidates = coalescedBatteryPercentCandidates(candidates).sorted { lhs, rhs in
1443
+            if lhs.timestamp != rhs.timestamp {
1444
+                return lhs.timestamp < rhs.timestamp
1445
+            }
1446
+            return lhs.isCheckpoint && !rhs.isCheckpoint
1447
+        }
1448
+
1449
+        var points: [Measurements.Measurement.Point] = []
1450
+        var previousCandidate: BatteryPercentCandidate?
1451
+
1452
+        for candidate in sortedCandidates {
1453
+            if let previousCandidate,
1454
+               candidate.timestamp.timeIntervalSince(previousCandidate.timestamp) > 90 {
1455
+                points.append(
1456
+                    Measurements.Measurement.Point(
1457
+                        id: points.count,
1458
+                        timestamp: candidate.timestamp,
1459
+                        value: points.last?.value ?? candidate.percent,
1460
+                        kind: .discontinuity
1461
+                    )
1462
+                )
1463
+            }
1464
+
1465
+            points.append(
1466
+                Measurements.Measurement.Point(
1467
+                    id: points.count,
1468
+                    timestamp: candidate.timestamp,
1469
+                    value: min(max(candidate.percent, 0), 100)
1470
+                )
1471
+            )
1472
+            previousCandidate = candidate
1473
+        }
1474
+
1475
+        return points
1476
+    }
1477
+
1478
+    private func coalescedBatteryPercentCandidates(
1479
+        _ candidates: [BatteryPercentCandidate]
1480
+    ) -> [BatteryPercentCandidate] {
1481
+        let sortedCandidates = candidates.sorted { lhs, rhs in
1482
+            if lhs.timestamp != rhs.timestamp {
1483
+                return lhs.timestamp < rhs.timestamp
1484
+            }
1485
+            return lhs.isCheckpoint && !rhs.isCheckpoint
1486
+        }
1487
+
1488
+        var coalesced: [BatteryPercentCandidate] = []
1489
+
1490
+        for candidate in sortedCandidates {
1491
+            if let last = coalesced.last,
1492
+               abs(candidate.timestamp.timeIntervalSince(last.timestamp)) <= 1 {
1493
+                if candidate.isCheckpoint || !last.isCheckpoint {
1494
+                    coalesced[coalesced.count - 1] = candidate
1495
+                }
1496
+            } else {
1497
+                coalesced.append(candidate)
1498
+            }
1499
+        }
1500
+
1501
+        return coalesced
1502
+    }
1503
+
1504
+    private func effectiveBatteryEnergyWh(
1505
+        rawMeasuredEnergyWh: Double,
1506
+        for session: ChargeSessionSummary
1507
+    ) -> Double {
1508
+        switch session.chargingTransportMode {
1509
+        case .wired:
1510
+            return rawMeasuredEnergyWh
1511
+        case .wireless:
1512
+            if let factor = session.wirelessEfficiencyFactor, factor > 0 {
1513
+                return rawMeasuredEnergyWh * factor
1514
+            }
1515
+            if let effectiveEnergyWh = session.effectiveBatteryEnergyWh,
1516
+               session.measuredEnergyWh > 0 {
1517
+                return rawMeasuredEnergyWh * (effectiveEnergyWh / session.measuredEnergyWh)
1518
+            }
1519
+            return rawMeasuredEnergyWh
1520
+        }
1521
+    }
1522
+
1369 1523
     private func requestStop(
1370 1524
         _ session: ChargeSessionSummary,
1371 1525
         applyingTrimStart trimStart: Date?,
@@ -1725,6 +1879,7 @@ enum ChargeSessionChartControlMode {
1725 1879
 struct ChargeSessionChartCardView: View {
1726 1880
     let session: ChargeSessionSummary
1727 1881
     let monitoringMeter: Meter?
1882
+    let batteryPercentPoints: [Measurements.Measurement.Point]
1728 1883
     let controlMode: ChargeSessionChartControlMode
1729 1884
     let onSetTrim: (Date?, Date?) -> Void
1730 1885
     let onStopWithTrim: (Date?, Date?) -> Void
@@ -1787,6 +1942,9 @@ struct ChargeSessionChartCardView: View {
1787 1942
                 rebasesEnergyToVisibleRangeStart: true,
1788 1943
                 extendsTimelineToPresent: false,
1789 1944
                 showsTemperatureSeries: false,
1945
+                showsBatteryPercentSeries: shouldShowBatteryPercentSeries,
1946
+                batteryCheckpoints: session.checkpoints,
1947
+                batteryPercentPoints: batteryPercentPoints,
1790 1948
                 rangeSelectorConfiguration: rangeSelectorConfiguration
1791 1949
             )
1792 1950
             .environmentObject(chartMeasurements)
@@ -1823,6 +1981,9 @@ struct ChargeSessionChartCardView: View {
1823 1981
         .onChange(of: session.aggregatedSamples.count) { _ in
1824 1982
             restoreStoredMeasurementsIfNeeded()
1825 1983
         }
1984
+        .onChange(of: session.checkpoints.count) { _ in
1985
+            restoreStoredMeasurementsIfNeeded()
1986
+        }
1826 1987
     }
1827 1988
 
1828 1989
     private var chartInfoMessage: String {
@@ -1833,6 +1994,10 @@ struct ChargeSessionChartCardView: View {
1833 1994
         return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1834 1995
     }
1835 1996
 
1997
+    private var shouldShowBatteryPercentSeries: Bool {
1998
+        !batteryPercentPoints.isEmpty
1999
+    }
2000
+
1836 2001
     private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
1837 2002
         switch controlMode {
1838 2003
         case .none:
+203 -25
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -121,6 +121,7 @@ struct MeasurementChartView: View {
121 121
         case voltage
122 122
         case current
123 123
         case temperature
124
+        case batteryPercent
124 125
 
125 126
         var displayName: String {
126 127
             switch self {
@@ -129,6 +130,7 @@ struct MeasurementChartView: View {
129 130
             case .voltage: return "Voltage"
130 131
             case .current: return "Current"
131 132
             case .temperature: return "Temperature"
133
+            case .batteryPercent: return "Battery"
132 134
             }
133 135
         }
134 136
 
@@ -139,6 +141,7 @@ struct MeasurementChartView: View {
139 141
             case .voltage: return "V"
140 142
             case .current: return "A"
141 143
             case .temperature: return ""
144
+            case .batteryPercent: return "%"
142 145
             }
143 146
         }
144 147
 
@@ -149,6 +152,7 @@ struct MeasurementChartView: View {
149 152
             case .voltage: return .green
150 153
             case .current: return .blue
151 154
             case .temperature: return .orange
155
+            case .batteryPercent: return .mint
152 156
             }
153 157
         }
154 158
     }
@@ -179,6 +183,7 @@ struct MeasurementChartView: View {
179 183
     private let minimumPowerSpan = 0.5
180 184
     private let minimumEnergySpan = 0.1
181 185
     private let minimumTemperatureSpan = 1.0
186
+    private let minimumBatteryPercentSpan = 10.0
182 187
     private let defaultEmptyChartTimeSpan: TimeInterval = 60
183 188
     private let selectorTint: Color = .blue
184 189
 
@@ -187,6 +192,9 @@ struct MeasurementChartView: View {
187 192
     let rebasesEnergyToVisibleRangeStart: Bool
188 193
     let extendsTimelineToPresent: Bool
189 194
     let showsTemperatureSeries: Bool
195
+    let showsBatteryPercentSeries: Bool
196
+    let batteryCheckpoints: [ChargeCheckpointSummary]
197
+    let batteryPercentPoints: [Measurements.Measurement.Point]
190 198
     let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
191 199
 
192 200
     @EnvironmentObject private var measurements: Measurements
@@ -219,6 +227,7 @@ struct MeasurementChartView: View {
219 227
     @State var displayPower: Bool = true
220 228
     @State var displayEnergy: Bool = false
221 229
     @State var displayTemperature: Bool = false
230
+    @State private var displayBatteryPercent: Bool = false
222 231
     @State private var smoothingLevel: SmoothingLevel = .off
223 232
     @State private var chartNow: Date = Date()
224 233
     @State private var selectedVisibleTimeRange: ClosedRange<Date>?
@@ -233,6 +242,7 @@ struct MeasurementChartView: View {
233 242
     @State private var voltageAxisOrigin: Double = 0
234 243
     @State private var currentAxisOrigin: Double = 0
235 244
     @State private var temperatureAxisOrigin: Double = 0
245
+    @State private var batteryPercentAxisOrigin: Double = 0
236 246
     let xLabels: Int = 4
237 247
     let yLabels: Int = 4
238 248
 
@@ -245,6 +255,9 @@ struct MeasurementChartView: View {
245 255
         rebasesEnergyToVisibleRangeStart: Bool = false,
246 256
         extendsTimelineToPresent: Bool = true,
247 257
         showsTemperatureSeries: Bool = true,
258
+        showsBatteryPercentSeries: Bool = false,
259
+        batteryCheckpoints: [ChargeCheckpointSummary] = [],
260
+        batteryPercentPoints: [Measurements.Measurement.Point] = [],
248 261
         rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
249 262
     ) {
250 263
         self.sizing = sizing
@@ -255,7 +268,12 @@ struct MeasurementChartView: View {
255 268
         self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
256 269
         self.extendsTimelineToPresent = extendsTimelineToPresent
257 270
         self.showsTemperatureSeries = showsTemperatureSeries
271
+        self.showsBatteryPercentSeries = showsBatteryPercentSeries
272
+        self.batteryCheckpoints = batteryCheckpoints
273
+        self.batteryPercentPoints = batteryPercentPoints
258 274
         self.rangeSelectorConfiguration = rangeSelectorConfiguration
275
+        _displayPower = State(initialValue: showsBatteryPercentSeries == false)
276
+        _displayBatteryPercent = State(initialValue: showsBatteryPercentSeries)
259 277
     }
260 278
 
261 279
     private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
@@ -390,10 +408,16 @@ struct MeasurementChartView: View {
390 408
                     }
391 409
             }
392 410
         }
393
-        .onAppear(perform: resetHiddenTemperatureDisplay)
411
+        .onAppear {
412
+            resetHiddenTemperatureDisplay()
413
+            resetHiddenBatteryPercentDisplay()
414
+        }
394 415
         .onChange(of: showsTemperatureSeries) { _ in
395 416
             resetHiddenTemperatureDisplay()
396 417
         }
418
+        .onChange(of: showsBatteryPercentSeries) { _ in
419
+            resetHiddenBatteryPercentDisplay()
420
+        }
397 421
     }
398 422
 
399 423
     private func resetHiddenTemperatureDisplay() {
@@ -401,6 +425,14 @@ struct MeasurementChartView: View {
401 425
         displayTemperature = false
402 426
     }
403 427
 
428
+    private func resetHiddenBatteryPercentDisplay() {
429
+        guard !showsBatteryPercentSeries, displayBatteryPercent else { return }
430
+        displayBatteryPercent = false
431
+        if !displayPower && !displayEnergy && !displayVoltage && !displayCurrent {
432
+            displayPower = true
433
+        }
434
+    }
435
+
404 436
     @ViewBuilder
405 437
     private var chartBody: some View {
406 438
         let availableTimeRange = availableSelectionTimeRange()
@@ -435,11 +467,18 @@ struct MeasurementChartView: View {
435 467
             minimumYSpan: minimumTemperatureSpan,
436 468
             visibleTimeRange: visibleTimeRange
437 469
         )
470
+        let batteryPercentSeries = series(
471
+            for: batteryPercentPoints.isEmpty ? measurements.batteryPercent.points : batteryPercentPoints,
472
+            kind: .batteryPercent,
473
+            minimumYSpan: minimumBatteryPercentSpan,
474
+            visibleTimeRange: visibleTimeRange
475
+        )
438 476
         let primarySeries = displayedPrimarySeries(
439 477
             powerSeries: powerSeries,
440 478
             energySeries: energySeries,
441 479
             voltageSeries: voltageSeries,
442
-            currentSeries: currentSeries
480
+            currentSeries: currentSeries,
481
+            batteryPercentSeries: batteryPercentSeries
443 482
         )
444 483
         let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
445 484
 
@@ -465,7 +504,8 @@ struct MeasurementChartView: View {
465 504
                                         powerSeries: powerSeries,
466 505
                                         energySeries: energySeries,
467 506
                                         voltageSeries: voltageSeries,
468
-                                        currentSeries: currentSeries
507
+                                        currentSeries: currentSeries,
508
+                                        batteryPercentSeries: batteryPercentSeries
469 509
                                     )
470 510
                                     .frame(width: axisColumnWidth, height: plotHeight)
471 511
 
@@ -484,7 +524,8 @@ struct MeasurementChartView: View {
484 524
                                             energySeries: energySeries,
485 525
                                             voltageSeries: voltageSeries,
486 526
                                             currentSeries: currentSeries,
487
-                                            temperatureSeries: temperatureSeries
527
+                                            temperatureSeries: temperatureSeries,
528
+                                            batteryPercentSeries: batteryPercentSeries
488 529
                                         )
489 530
                                     }
490 531
                                     .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
@@ -497,7 +538,8 @@ struct MeasurementChartView: View {
497 538
                                         energySeries: energySeries,
498 539
                                         voltageSeries: voltageSeries,
499 540
                                         currentSeries: currentSeries,
500
-                                        temperatureSeries: temperatureSeries
541
+                                        temperatureSeries: temperatureSeries,
542
+                                        batteryPercentSeries: batteryPercentSeries
501 543
                                     )
502 544
                                     .frame(width: axisColumnWidth, height: plotHeight)
503 545
                                 }
@@ -515,7 +557,8 @@ struct MeasurementChartView: View {
515 557
                                 energySeries: energySeries,
516 558
                                 voltageSeries: voltageSeries,
517 559
                                 currentSeries: currentSeries,
518
-                                temperatureSeries: temperatureSeries
560
+                                temperatureSeries: temperatureSeries,
561
+                                batteryPercentSeries: batteryPercentSeries
519 562
                             )
520 563
                         )
521 564
 
@@ -633,11 +676,14 @@ struct MeasurementChartView: View {
633 676
         energySeries: SeriesData,
634 677
         voltageSeries: SeriesData,
635 678
         currentSeries: SeriesData,
636
-        temperatureSeries: SeriesData
679
+        temperatureSeries: SeriesData,
680
+        batteryPercentSeries: SeriesData
637 681
     ) -> [SeriesLegendEntry] {
638 682
         var entries: [SeriesLegendEntry] = []
639 683
 
640
-        if displayPower {
684
+        if displayBatteryPercent {
685
+            entries.append(contentsOf: legendEntry(for: batteryPercentSeries))
686
+        } else if displayPower {
641 687
             entries.append(contentsOf: legendEntry(for: powerSeries))
642 688
         } else if displayEnergy {
643 689
             entries.append(contentsOf: legendEntry(for: energySeries))
@@ -767,7 +813,7 @@ struct MeasurementChartView: View {
767 813
             decimalDigits = 2
768 814
         case .energy, .voltage, .current:
769 815
             decimalDigits = 3
770
-        case .temperature:
816
+        case .temperature, .batteryPercent:
771 817
             decimalDigits = 1
772 818
         }
773 819
 
@@ -786,6 +832,7 @@ struct MeasurementChartView: View {
786 832
             seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
787 833
                 displayVoltage.toggle()
788 834
                 if displayVoltage {
835
+                    displayBatteryPercent = false
789 836
                     displayPower = false
790 837
                     displayEnergy = false
791 838
                     if displayTemperature && displayCurrent {
@@ -797,6 +844,7 @@ struct MeasurementChartView: View {
797 844
             seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
798 845
                 displayCurrent.toggle()
799 846
                 if displayCurrent {
847
+                    displayBatteryPercent = false
800 848
                     displayPower = false
801 849
                     displayEnergy = false
802 850
                     if displayTemperature && displayVoltage {
@@ -808,6 +856,7 @@ struct MeasurementChartView: View {
808 856
             seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
809 857
                 displayPower.toggle()
810 858
                 if displayPower {
859
+                    displayBatteryPercent = false
811 860
                     displayEnergy = false
812 861
                     displayCurrent = false
813 862
                     displayVoltage = false
@@ -817,12 +866,25 @@ struct MeasurementChartView: View {
817 866
             seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
818 867
                 displayEnergy.toggle()
819 868
                 if displayEnergy {
869
+                    displayBatteryPercent = false
820 870
                     displayPower = false
821 871
                     displayCurrent = false
822 872
                     displayVoltage = false
823 873
                 }
824 874
             }
825 875
 
876
+            if showsBatteryPercentSeries {
877
+                seriesToggleButton(title: "Battery", isOn: displayBatteryPercent, condensedLayout: condensedLayout) {
878
+                    displayBatteryPercent.toggle()
879
+                    if displayBatteryPercent {
880
+                        displayPower = false
881
+                        displayEnergy = false
882
+                        displayCurrent = false
883
+                        displayVoltage = false
884
+                    }
885
+                }
886
+            }
887
+
826 888
             if showsTemperatureSeries {
827 889
                 seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
828 890
                     displayTemperature.toggle()
@@ -1083,9 +1145,18 @@ struct MeasurementChartView: View {
1083 1145
         powerSeries: SeriesData,
1084 1146
         energySeries: SeriesData,
1085 1147
         voltageSeries: SeriesData,
1086
-        currentSeries: SeriesData
1148
+        currentSeries: SeriesData,
1149
+        batteryPercentSeries: SeriesData
1087 1150
     ) -> some View {
1088
-        if displayPower {
1151
+        if displayBatteryPercent {
1152
+            yAxisLabelsView(
1153
+                height: height,
1154
+                context: batteryPercentSeries.context,
1155
+                seriesKind: .batteryPercent,
1156
+                measurementUnit: batteryPercentSeries.kind.unit,
1157
+                tint: batteryPercentSeries.kind.tint
1158
+            )
1159
+        } else if displayPower {
1089 1160
             yAxisLabelsView(
1090 1161
                 height: height,
1091 1162
                 context: powerSeries.context,
@@ -1126,9 +1197,14 @@ struct MeasurementChartView: View {
1126 1197
         energySeries: SeriesData,
1127 1198
         voltageSeries: SeriesData,
1128 1199
         currentSeries: SeriesData,
1129
-        temperatureSeries: SeriesData
1200
+        temperatureSeries: SeriesData,
1201
+        batteryPercentSeries: SeriesData
1130 1202
     ) -> some View {
1131
-        if self.displayPower {
1203
+        if self.displayBatteryPercent {
1204
+            TimeSeriesChart(points: batteryPercentSeries.points, context: batteryPercentSeries.context, strokeColor: batteryPercentSeries.kind.tint)
1205
+                .opacity(0.82)
1206
+            batteryCheckpointMarkers(context: batteryPercentSeries.context)
1207
+        } else if self.displayPower {
1132 1208
             TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint)
1133 1209
                 .opacity(0.72)
1134 1210
         } else if self.displayEnergy {
@@ -1151,6 +1227,57 @@ struct MeasurementChartView: View {
1151 1227
         }
1152 1228
     }
1153 1229
 
1230
+    private func batteryCheckpointMarkers(context: ChartContext) -> some View {
1231
+        GeometryReader { geometry in
1232
+            ForEach(visibleBatteryCheckpoints(context: context)) { checkpoint in
1233
+                let normalizedPoint = context.placeInRect(
1234
+                    point: CGPoint(
1235
+                        x: checkpoint.timestamp.timeIntervalSince1970,
1236
+                        y: checkpoint.batteryPercent
1237
+                    )
1238
+                )
1239
+                let location = CGPoint(
1240
+                    x: normalizedPoint.x * geometry.size.width,
1241
+                    y: normalizedPoint.y * geometry.size.height
1242
+                )
1243
+
1244
+                Circle()
1245
+                    .fill(Color(.systemBackground))
1246
+                    .frame(width: 10, height: 10)
1247
+                    .overlay(
1248
+                        Circle()
1249
+                            .stroke(Color.mint, lineWidth: 2)
1250
+                    )
1251
+                    .shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
1252
+                    .position(location)
1253
+            }
1254
+        }
1255
+        .accessibilityHidden(true)
1256
+    }
1257
+
1258
+    private func visibleBatteryCheckpoints(context: ChartContext) -> [ChargeCheckpointSummary] {
1259
+        guard context.isValid else { return [] }
1260
+
1261
+        return batteryCheckpoints
1262
+            .filter { checkpoint in
1263
+                checkpoint.batteryPercent.isFinite &&
1264
+                checkpoint.batteryPercent >= 0 &&
1265
+                checkpoint.batteryPercent <= 100
1266
+            }
1267
+            .filter { checkpoint in
1268
+                let normalizedPoint = context.placeInRect(
1269
+                    point: CGPoint(
1270
+                        x: checkpoint.timestamp.timeIntervalSince1970,
1271
+                        y: checkpoint.batteryPercent
1272
+                    )
1273
+                )
1274
+                return normalizedPoint.x >= 0 &&
1275
+                    normalizedPoint.x <= 1 &&
1276
+                    normalizedPoint.y >= 0 &&
1277
+                    normalizedPoint.y <= 1
1278
+            }
1279
+    }
1280
+
1154 1281
     @ViewBuilder
1155 1282
     private func secondaryAxisView(
1156 1283
         height: CGFloat,
@@ -1158,7 +1285,8 @@ struct MeasurementChartView: View {
1158 1285
         energySeries: SeriesData,
1159 1286
         voltageSeries: SeriesData,
1160 1287
         currentSeries: SeriesData,
1161
-        temperatureSeries: SeriesData
1288
+        temperatureSeries: SeriesData,
1289
+        batteryPercentSeries: SeriesData
1162 1290
     ) -> some View {
1163 1291
         if displayTemperature {
1164 1292
             yAxisLabelsView(
@@ -1182,7 +1310,8 @@ struct MeasurementChartView: View {
1182 1310
                 powerSeries: powerSeries,
1183 1311
                 energySeries: energySeries,
1184 1312
                 voltageSeries: voltageSeries,
1185
-                currentSeries: currentSeries
1313
+                currentSeries: currentSeries,
1314
+                batteryPercentSeries: batteryPercentSeries
1186 1315
             )
1187 1316
         }
1188 1317
     }
@@ -1191,8 +1320,12 @@ struct MeasurementChartView: View {
1191 1320
         powerSeries: SeriesData,
1192 1321
         energySeries: SeriesData,
1193 1322
         voltageSeries: SeriesData,
1194
-        currentSeries: SeriesData
1323
+        currentSeries: SeriesData,
1324
+        batteryPercentSeries: SeriesData
1195 1325
     ) -> SeriesData? {
1326
+        if displayBatteryPercent {
1327
+            return batteryPercentSeries
1328
+        }
1196 1329
         if displayPower {
1197 1330
             return powerSeries
1198 1331
         }
@@ -1214,19 +1347,34 @@ struct MeasurementChartView: View {
1214 1347
         minimumYSpan: Double,
1215 1348
         visibleTimeRange: ClosedRange<Date>? = nil
1216 1349
     ) -> SeriesData {
1217
-        let rawPoints = filteredPoints(
1218
-            measurement,
1350
+        series(
1351
+            for: filteredPoints(
1352
+                measurement,
1353
+                visibleTimeRange: visibleTimeRange
1354
+            ),
1355
+            kind: kind,
1356
+            minimumYSpan: minimumYSpan,
1219 1357
             visibleTimeRange: visibleTimeRange
1220 1358
         )
1359
+    }
1360
+
1361
+    private func series(
1362
+        for rawPoints: [Measurements.Measurement.Point],
1363
+        kind: SeriesKind,
1364
+        minimumYSpan: Double,
1365
+        visibleTimeRange: ClosedRange<Date>? = nil
1366
+    ) -> SeriesData {
1221 1367
         let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
1222 1368
         let points = smoothedPoints(from: normalizedRawPoints)
1223 1369
         let samplePoints = points.filter { $0.isSample }
1224 1370
         let context = ChartContext()
1225 1371
 
1226
-        let autoBounds = automaticYBounds(
1227
-            for: samplePoints,
1228
-            minimumYSpan: minimumYSpan
1229
-        )
1372
+        let autoBounds = kind == .batteryPercent
1373
+            ? (lowerBound: 0.0, upperBound: 100.0)
1374
+            : automaticYBounds(
1375
+                for: samplePoints,
1376
+                minimumYSpan: minimumYSpan
1377
+            )
1230 1378
         let xBounds = xBounds(
1231 1379
             for: samplePoints,
1232 1380
             visibleTimeRange: visibleTimeRange
@@ -1380,6 +1528,8 @@ struct MeasurementChartView: View {
1380 1528
             return measurements.current
1381 1529
         case .temperature:
1382 1530
             return measurements.temperature
1531
+        case .batteryPercent:
1532
+            return measurements.batteryPercent
1383 1533
         }
1384 1534
     }
1385 1535
 
@@ -1395,11 +1545,13 @@ struct MeasurementChartView: View {
1395 1545
             return minimumCurrentSpan
1396 1546
         case .temperature:
1397 1547
             return minimumTemperatureSpan
1548
+        case .batteryPercent:
1549
+            return minimumBatteryPercentSpan
1398 1550
         }
1399 1551
     }
1400 1552
 
1401 1553
     private var supportsSharedOrigin: Bool {
1402
-        displayVoltage && displayCurrent && !displayPower && !displayEnergy
1554
+        displayVoltage && displayCurrent && !displayPower && !displayEnergy && !displayBatteryPercent
1403 1555
     }
1404 1556
 
1405 1557
     private var minimumSharedScaleSpan: Double {
@@ -1419,6 +1571,10 @@ struct MeasurementChartView: View {
1419 1571
             return pinOrigin && energyAxisOrigin == 0
1420 1572
         }
1421 1573
 
1574
+        if displayBatteryPercent {
1575
+            return pinOrigin && batteryPercentAxisOrigin == 0
1576
+        }
1577
+
1422 1578
         let visibleOrigins = [
1423 1579
             displayVoltage ? voltageAxisOrigin : nil,
1424 1580
             displayCurrent ? currentAxisOrigin : nil
@@ -1491,6 +1647,9 @@ struct MeasurementChartView: View {
1491 1647
             if displayTemperature {
1492 1648
                 temperatureAxisOrigin = 0
1493 1649
             }
1650
+            if displayBatteryPercent {
1651
+                batteryPercentAxisOrigin = 0
1652
+            }
1494 1653
         }
1495 1654
 
1496 1655
         pinOrigin = true
@@ -1505,6 +1664,7 @@ struct MeasurementChartView: View {
1505 1664
         voltageAxisOrigin = voltageSeries.autoLowerBound
1506 1665
         currentAxisOrigin = currentSeries.autoLowerBound
1507 1666
         temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
1667
+        batteryPercentAxisOrigin = displayedLowerBoundForSeries(.batteryPercent)
1508 1668
         sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
1509 1669
         sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1510 1670
         ensureSharedScaleSpan()
@@ -1570,6 +1730,8 @@ struct MeasurementChartView: View {
1570 1730
                     ),
1571 1731
                     minimumYSpan: minimumTemperatureSpan
1572 1732
                 ).lowerBound
1733
+        case .batteryPercent:
1734
+            return pinOrigin ? batteryPercentAxisOrigin : 0
1573 1735
         }
1574 1736
     }
1575 1737
 
@@ -1658,7 +1820,10 @@ struct MeasurementChartView: View {
1658 1820
             filteredSamplePoints(measurements.energy),
1659 1821
             filteredSamplePoints(measurements.voltage),
1660 1822
             filteredSamplePoints(measurements.current),
1661
-            filteredSamplePoints(measurements.temperature)
1823
+            filteredSamplePoints(measurements.temperature),
1824
+            batteryPercentPoints.isEmpty
1825
+                ? filteredSamplePoints(measurements.batteryPercent)
1826
+                : batteryPercentPoints.filter { $0.isSample }
1662 1827
         ]
1663 1828
 
1664 1829
         return candidates.first(where: { !$0.isEmpty }) ?? []
@@ -1805,6 +1970,8 @@ struct MeasurementChartView: View {
1805 1970
             return currentAxisOrigin
1806 1971
         case .temperature:
1807 1972
             return temperatureAxisOrigin
1973
+        case .batteryPercent:
1974
+            return batteryPercentAxisOrigin
1808 1975
         }
1809 1976
     }
1810 1977
 
@@ -1823,7 +1990,7 @@ struct MeasurementChartView: View {
1823 1990
             return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1824 1991
         }
1825 1992
 
1826
-        if kind == .temperature {
1993
+        if kind == .temperature || kind == .batteryPercent {
1827 1994
             return autoUpperBound
1828 1995
         }
1829 1996
 
@@ -1855,6 +2022,8 @@ struct MeasurementChartView: View {
1855 2022
                 currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
1856 2023
             case .temperature:
1857 2024
                 temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
2025
+            case .batteryPercent:
2026
+                batteryPercentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .batteryPercent))
1858 2027
             }
1859 2028
         }
1860 2029
 
@@ -1881,6 +2050,8 @@ struct MeasurementChartView: View {
1881 2050
                 currentAxisOrigin = 0
1882 2051
             case .temperature:
1883 2052
                 temperatureAxisOrigin = 0
2053
+            case .batteryPercent:
2054
+                batteryPercentAxisOrigin = 0
1884 2055
             }
1885 2056
         }
1886 2057
 
@@ -1939,6 +2110,13 @@ struct MeasurementChartView: View {
1939 2110
                     visibleTimeRange: visibleTimeRange
1940 2111
                 ).map(\.value).min() ?? 0
1941 2112
             )
2113
+        case .batteryPercent:
2114
+            return snappedOriginValue(
2115
+                filteredSamplePoints(
2116
+                    measurements.batteryPercent,
2117
+                    visibleTimeRange: visibleTimeRange
2118
+                ).map(\.value).min() ?? 0
2119
+            )
1942 2120
         }
1943 2121
     }
1944 2122
 
+28 -22
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -384,30 +384,10 @@ struct MeterChargeRecordContentView: View {
384 384
                 }
385 385
             }
386 386
 
387
-            // Charging state — only when device supports multiple
388
-            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
389
-                Divider().padding(.leading, 46)
390
-                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
391
-                    Text("Mode")
392
-                        .foregroundColor(.secondary)
393
-                        .font(.subheadline)
394
-                    Spacer()
395
-                    compactSelectionMenu(
396
-                        title: draftChargingStateMode?.title ?? "Choose",
397
-                        options: device.supportedChargingStateModes.map { mode in
398
-                            CompactSelectionOption(
399
-                                id: mode.id, title: mode.title,
400
-                                isSelected: draftChargingStateMode == mode,
401
-                                action: { draftChargingStateMode = mode }
402
-                            )
403
-                        }
404
-                    )
405
-                }
406
-            }
407
-
408
-            // Wireless charger — only when wireless transport
387
+            // Wireless charger — appears immediately after Type when wireless is selected
409 388
             if showsWirelessChargerSection {
410 389
                 Divider().padding(.leading, 46)
390
+                    .transition(.opacity)
411 391
                 setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
412 392
                     Picker(selection: selectedChargerID) {
413 393
                         Text("Choose charger").tag(UUID?.none)
@@ -433,6 +413,31 @@ struct MeterChargeRecordContentView: View {
433 413
                     .pickerStyle(.menu)
434 414
                     .disabled(availableChargers.isEmpty)
435 415
                 }
416
+                .transition(.asymmetric(
417
+                    insertion: .move(edge: .top).combined(with: .opacity),
418
+                    removal: .opacity
419
+                ))
420
+            }
421
+
422
+            // Charging state — only when device supports multiple
423
+            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
424
+                Divider().padding(.leading, 46)
425
+                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
426
+                    Text("Mode")
427
+                        .foregroundColor(.secondary)
428
+                        .font(.subheadline)
429
+                    Spacer()
430
+                    compactSelectionMenu(
431
+                        title: draftChargingStateMode?.title ?? "Choose",
432
+                        options: device.supportedChargingStateModes.map { mode in
433
+                            CompactSelectionOption(
434
+                                id: mode.id, title: mode.title,
435
+                                isSelected: draftChargingStateMode == mode,
436
+                                action: { draftChargingStateMode = mode }
437
+                            )
438
+                        }
439
+                    )
440
+                }
436 441
             }
437 442
 
438 443
             // Battery checkpoint
@@ -504,6 +509,7 @@ struct MeterChargeRecordContentView: View {
504 509
             .buttonStyle(.plain)
505 510
             .disabled(!canStartSession)
506 511
         }
512
+        .animation(.spring(response: 0.35, dampingFraction: 0.8), value: showsWirelessChargerSection)
507 513
         .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
508 514
     }
509 515