Showing 6 changed files with 522 additions and 67 deletions
+361 -36
USB Meter/Model/ChargeInsightsModel.swift
@@ -1042,6 +1042,13 @@ struct ChargeSessionSummary: Identifiable, Hashable {
1042 1042
         return endBatteryPercent - startBatteryPercent
1043 1043
     }
1044 1044
 
1045
+    var startsFromFlatBattery: Bool {
1046
+        guard let startBatteryPercent else {
1047
+            return false
1048
+        }
1049
+        return startBatteryPercent.isFinite && startBatteryPercent < 0
1050
+    }
1051
+
1045 1052
     var canAutoStop: Bool {
1046 1053
         autoStopEnabled && stopThresholdAmps > 0
1047 1054
     }
@@ -1058,6 +1065,7 @@ struct ChargeSessionSummary: Identifiable, Hashable {
1058 1065
 enum BatteryLevelPredictionBasis: Hashable {
1059 1066
     case capacityEstimate
1060 1067
     case checkpointEnergyMap
1068
+    case typicalChargeCurve
1061 1069
 
1062 1070
     var metricLabel: String {
1063 1071
         switch self {
@@ -1065,6 +1073,8 @@ enum BatteryLevelPredictionBasis: Hashable {
1065 1073
             return "est. capacity"
1066 1074
         case .checkpointEnergyMap:
1067 1075
             return "energy map"
1076
+        case .typicalChargeCurve:
1077
+            return "charge curve"
1068 1078
         }
1069 1079
     }
1070 1080
 
@@ -1074,6 +1084,8 @@ enum BatteryLevelPredictionBasis: Hashable {
1074 1084
             return "estimated capacity"
1075 1085
         case .checkpointEnergyMap:
1076 1086
             return "checkpoint energy map"
1087
+        case .typicalChargeCurve:
1088
+            return "typical charge curve"
1077 1089
         }
1078 1090
     }
1079 1091
 }
@@ -1096,7 +1108,73 @@ struct BatteryLevelPrediction: Hashable {
1096 1108
 }
1097 1109
 
1098 1110
 enum BatteryLevelPredictionTuning {
1099
-    static let checkpointSettleDuration: TimeInterval = 10 * 60
1111
+    static func inferredVirtualZeroEnergyWh(
1112
+        from anchors: [BatteryLevelPredictionAnchor],
1113
+        estimatedCapacityWh: Double? = nil,
1114
+        historicalReserveEnergyWh: Double? = nil
1115
+    ) -> Double? {
1116
+        let sortedAnchors = anchors
1117
+            .filter { $0.percent > 0 && $0.percent <= 100 && $0.energyWh >= 0 }
1118
+            .sorted { lhs, rhs in
1119
+                if lhs.energyWh != rhs.energyWh {
1120
+                    return lhs.energyWh < rhs.energyWh
1121
+                }
1122
+                return lhs.timestamp < rhs.timestamp
1123
+            }
1124
+
1125
+        guard let firstAnchor = sortedAnchors.first else {
1126
+            return nil
1127
+        }
1128
+
1129
+        func clampedReserve(_ reserveEnergyWh: Double) -> Double? {
1130
+            guard reserveEnergyWh.isFinite else {
1131
+                return nil
1132
+            }
1133
+            return min(max(reserveEnergyWh, 0), firstAnchor.energyWh)
1134
+        }
1135
+
1136
+        if let historicalReserveEnergyWh,
1137
+           let reserveEnergyWh = clampedReserve(historicalReserveEnergyWh) {
1138
+            return reserveEnergyWh
1139
+        }
1140
+
1141
+        if let estimatedCapacityWh,
1142
+           estimatedCapacityWh > 0 {
1143
+            return clampedReserve(
1144
+                firstAnchor.energyWh - ((firstAnchor.percent / 100) * estimatedCapacityWh)
1145
+            )
1146
+        }
1147
+
1148
+        var zeroCandidates: [Double] = []
1149
+
1150
+        for lowerIndex in sortedAnchors.indices {
1151
+            for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex {
1152
+                let lower = sortedAnchors[lowerIndex]
1153
+                let upper = sortedAnchors[upperIndex]
1154
+                let percentDelta = upper.percent - lower.percent
1155
+                let energyDeltaWh = upper.energyWh - lower.energyWh
1156
+
1157
+                guard percentDelta >= 3, energyDeltaWh > 0.01 else {
1158
+                    continue
1159
+                }
1160
+
1161
+                let capacityWh = energyDeltaWh / (percentDelta / 100)
1162
+                guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
1163
+                    continue
1164
+                }
1165
+
1166
+                zeroCandidates.append(lower.energyWh - ((lower.percent / 100) * capacityWh))
1167
+                zeroCandidates.append(upper.energyWh - ((upper.percent / 100) * capacityWh))
1168
+            }
1169
+        }
1170
+
1171
+        guard !zeroCandidates.isEmpty else {
1172
+            return nil
1173
+        }
1174
+
1175
+        let sortedCandidates = zeroCandidates.sorted()
1176
+        return clampedReserve(sortedCandidates[sortedCandidates.count / 2])
1177
+    }
1100 1178
 
1101 1179
     static func predictedPercent(
1102 1180
         anchorPercent: Double,
@@ -1107,26 +1185,92 @@ enum BatteryLevelPredictionTuning {
1107 1185
         referenceTimestamp: Date,
1108 1186
         estimatedCapacityWh: Double
1109 1187
     ) -> Double {
1188
+        _ = anchorTimestamp
1189
+        _ = anchorIsCheckpoint
1190
+        _ = referenceTimestamp
1191
+
1110 1192
         let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
1111 1193
         let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
1112
-        let stabilizedGainPercent: Double
1113
-
1114
-        if anchorIsCheckpoint {
1115
-            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
1116
-            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
1117
-            stabilizedGainPercent = rawGainPercent * settleProgress
1118
-        } else {
1119
-            stabilizedGainPercent = rawGainPercent
1120
-        }
1121 1194
 
1122 1195
         return min(
1123 1196
             100,
1124 1197
             max(
1125 1198
                 0,
1126
-                anchorPercent + stabilizedGainPercent
1199
+                anchorPercent + rawGainPercent
1127 1200
             )
1128 1201
         )
1129 1202
     }
1203
+
1204
+    static func predictedPercent(
1205
+        anchorPercent: Double,
1206
+        anchorEnergyWh: Double,
1207
+        effectiveEnergyWh: Double,
1208
+        chargeCurve: BatteryChargeCurve,
1209
+        deviationFactor: Double?
1210
+    ) -> Double? {
1211
+        guard
1212
+            let curveAnchorEnergyWh = chargeCurve.energyWh(forPercent: anchorPercent)
1213
+        else {
1214
+            return nil
1215
+        }
1216
+
1217
+        let sessionEnergyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
1218
+        let normalizedEnergyDeltaWh = sessionEnergyDeltaWh / max(deviationFactor ?? 1, 0.05)
1219
+        let projectedCurveEnergyWh = curveAnchorEnergyWh + normalizedEnergyDeltaWh
1220
+
1221
+        guard let curvePercent = chargeCurve.percent(forEnergyWh: projectedCurveEnergyWh) else {
1222
+            return nil
1223
+        }
1224
+
1225
+        return min(100, max(anchorPercent, curvePercent))
1226
+    }
1227
+
1228
+    static func deviationFactor(
1229
+        anchors: [BatteryLevelPredictionAnchor],
1230
+        chargeCurve: BatteryChargeCurve
1231
+    ) -> Double? {
1232
+        let sortedAnchors = anchors.sorted { lhs, rhs in
1233
+            if lhs.timestamp != rhs.timestamp {
1234
+                return lhs.timestamp < rhs.timestamp
1235
+            }
1236
+            return lhs.energyWh < rhs.energyWh
1237
+        }
1238
+        var ratios: [Double] = []
1239
+
1240
+        for lowerIndex in sortedAnchors.indices {
1241
+            for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex {
1242
+                let lower = sortedAnchors[lowerIndex]
1243
+                let upper = sortedAnchors[upperIndex]
1244
+                let percentDelta = upper.percent - lower.percent
1245
+                let energyDeltaWh = upper.energyWh - lower.energyWh
1246
+
1247
+                guard percentDelta >= 3, energyDeltaWh > 0.01,
1248
+                      let curveLowerEnergyWh = chargeCurve.energyWh(forPercent: lower.percent),
1249
+                      let curveUpperEnergyWh = chargeCurve.energyWh(forPercent: upper.percent) else {
1250
+                    continue
1251
+                }
1252
+
1253
+                let curveEnergyDeltaWh = curveUpperEnergyWh - curveLowerEnergyWh
1254
+                guard curveEnergyDeltaWh > 0.01 else {
1255
+                    continue
1256
+                }
1257
+
1258
+                let ratio = energyDeltaWh / curveEnergyDeltaWh
1259
+                guard ratio.isFinite, ratio > 0 else {
1260
+                    continue
1261
+                }
1262
+
1263
+                ratios.append(min(max(ratio, 0.25), 4.0))
1264
+            }
1265
+        }
1266
+
1267
+        guard !ratios.isEmpty else {
1268
+            return nil
1269
+        }
1270
+
1271
+        let sortedRatios = ratios.sorted()
1272
+        return sortedRatios[sortedRatios.count / 2]
1273
+    }
1130 1274
 }
1131 1275
 
1132 1276
 struct CapacityTrendPoint: Identifiable, Hashable {
@@ -1146,6 +1290,117 @@ struct TypicalChargeCurvePoint: Identifiable, Hashable {
1146 1290
     var id: Int { percentBin }
1147 1291
 }
1148 1292
 
1293
+struct BatteryLevelPredictionAnchor: Hashable {
1294
+    let percent: Double
1295
+    let energyWh: Double
1296
+    let timestamp: Date
1297
+    let description: String
1298
+    let isCheckpoint: Bool
1299
+
1300
+    init(
1301
+        percent: Double,
1302
+        energyWh: Double,
1303
+        timestamp: Date,
1304
+        description: String = "",
1305
+        isCheckpoint: Bool
1306
+    ) {
1307
+        self.percent = percent
1308
+        self.energyWh = energyWh
1309
+        self.timestamp = timestamp
1310
+        self.description = description
1311
+        self.isCheckpoint = isCheckpoint
1312
+    }
1313
+}
1314
+
1315
+struct BatteryChargeCurve {
1316
+    private let points: [(percent: Double, energyWh: Double)]
1317
+
1318
+    init?(typicalCurvePoints: [TypicalChargeCurvePoint]) {
1319
+        let validPoints = typicalCurvePoints
1320
+            .filter {
1321
+                $0.averageEnergyWh.isFinite
1322
+                    && $0.averageEnergyWh >= 0
1323
+                    && $0.percentBin >= 0
1324
+                    && $0.percentBin <= 100
1325
+            }
1326
+            .sorted { lhs, rhs in
1327
+                lhs.percentBin < rhs.percentBin
1328
+            }
1329
+
1330
+        var normalizedPoints: [(percent: Double, energyWh: Double)] = []
1331
+        var runningMaximumEnergyWh = 0.0
1332
+
1333
+        for point in validPoints {
1334
+            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
1335
+            normalizedPoints.append(
1336
+                (percent: Double(point.percentBin), energyWh: runningMaximumEnergyWh)
1337
+            )
1338
+        }
1339
+
1340
+        guard normalizedPoints.count >= 2 else {
1341
+            return nil
1342
+        }
1343
+
1344
+        self.points = normalizedPoints
1345
+    }
1346
+
1347
+    func energyWh(forPercent percent: Double) -> Double? {
1348
+        interpolatedValue(
1349
+            lookup: min(max(percent, 0), 100),
1350
+            key: { $0.percent },
1351
+            value: { $0.energyWh }
1352
+        )
1353
+    }
1354
+
1355
+    func percent(forEnergyWh energyWh: Double) -> Double? {
1356
+        interpolatedValue(
1357
+            lookup: max(energyWh, 0),
1358
+            key: { $0.energyWh },
1359
+            value: { $0.percent }
1360
+        )
1361
+    }
1362
+
1363
+    private func interpolatedValue(
1364
+        lookup: Double,
1365
+        key: ((percent: Double, energyWh: Double)) -> Double,
1366
+        value: ((percent: Double, energyWh: Double)) -> Double
1367
+    ) -> Double? {
1368
+        guard let first = points.first, let last = points.last else {
1369
+            return nil
1370
+        }
1371
+
1372
+        let firstKey = key(first)
1373
+        let lastKey = key(last)
1374
+        guard lookup >= firstKey, lookup <= lastKey else {
1375
+            return nil
1376
+        }
1377
+
1378
+        if abs(lookup - firstKey) < 0.000_1 {
1379
+            return value(first)
1380
+        }
1381
+        if abs(lookup - lastKey) < 0.000_1 {
1382
+            return value(last)
1383
+        }
1384
+
1385
+        guard let upperIndex = points.firstIndex(where: { key($0) >= lookup }),
1386
+              upperIndex > 0 else {
1387
+            return nil
1388
+        }
1389
+
1390
+        let lower = points[upperIndex - 1]
1391
+        let upper = points[upperIndex]
1392
+        let lowerKey = key(lower)
1393
+        let upperKey = key(upper)
1394
+        let span = upperKey - lowerKey
1395
+        guard span > 0.000_1 else {
1396
+            return value(upper)
1397
+        }
1398
+
1399
+        let progress = (lookup - lowerKey) / span
1400
+        return value(lower) + ((value(upper) - value(lower)) * progress)
1401
+    }
1402
+}
1403
+
1149 1404
 struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
1150 1405
     let timestamp: Date
1151 1406
     let powerWatts: Double
@@ -1806,15 +2061,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1806 2061
             ?? session.effectiveBatteryEnergyWh
1807 2062
             ?? session.measuredEnergyWh
1808 2063
 
1809
-        struct Anchor {
1810
-            let percent: Double
1811
-            let energyWh: Double
1812
-            let timestamp: Date
1813
-            let description: String
1814
-            let isCheckpoint: Bool
1815
-        }
1816
-
1817
-        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
2064
+        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
1818 2065
             var candidates: [Double] = []
1819 2066
 
1820 2067
             for lowerIndex in anchors.indices {
@@ -1840,7 +2087,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1840 2087
             return candidates
1841 2088
         }
1842 2089
 
1843
-        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
2090
+        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
1844 2091
             let candidates = anchorCapacityCandidates(from: anchors)
1845 2092
             guard !candidates.isEmpty else {
1846 2093
                 return nil
@@ -1850,11 +2097,11 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1850 2097
             return sortedCandidates[sortedCandidates.count / 2]
1851 2098
         }
1852 2099
 
1853
-        var anchors: [Anchor] = []
2100
+        var anchors: [BatteryLevelPredictionAnchor] = []
1854 2101
 
1855 2102
         if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
1856 2103
             anchors.append(
1857
-                Anchor(
2104
+                BatteryLevelPredictionAnchor(
1858 2105
                     percent: startBatteryPercent,
1859 2106
                     energyWh: 0,
1860 2107
                     timestamp: session.effectiveTrimStart,
@@ -1868,7 +2115,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1868 2115
             contentsOf: session.checkpoints
1869 2116
                 .filter { $0.batteryPercent >= 0 }
1870 2117
                 .map { checkpoint in
1871
-                    Anchor(
2118
+                    BatteryLevelPredictionAnchor(
1872 2119
                         percent: checkpoint.batteryPercent,
1873 2120
                         energyWh: checkpoint.measuredEnergyWh,
1874 2121
                         timestamp: checkpoint.timestamp,
@@ -1878,6 +2125,27 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1878 2125
                 }
1879 2126
         )
1880 2127
 
2128
+        if session.startsFromFlatBattery {
2129
+            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2130
+                from: anchors,
2131
+                estimatedCapacityWh: estimatedCapacityWh,
2132
+                historicalReserveEnergyWh: estimatedFlatReserveEnergyWh(excluding: session.id)
2133
+            ) {
2134
+                anchors.append(
2135
+                    BatteryLevelPredictionAnchor(
2136
+                        percent: 0,
2137
+                        energyWh: virtualZeroEnergyWh,
2138
+                        timestamp: session.effectiveTrimStart,
2139
+                        description: "estimated flat reserve",
2140
+                        isCheckpoint: false
2141
+                    )
2142
+                )
2143
+            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
2144
+                      effectiveEnergyWh < firstCheckpoint.energyWh - 0.05 {
2145
+                return nil
2146
+            }
2147
+        }
2148
+
1881 2149
         let sortedAnchors = anchors.sorted { lhs, rhs in
1882 2150
             if lhs.energyWh != rhs.energyWh {
1883 2151
                 return lhs.energyWh < rhs.energyWh
@@ -1892,7 +2160,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1892 2160
         let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
1893 2161
         let inferredCapacityWh = estimatedCapacityWh
1894 2162
             ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
1895
-        let basis: BatteryLevelPredictionBasis = estimatedCapacityWh == nil
2163
+        let fallbackBasis: BatteryLevelPredictionBasis = estimatedCapacityWh == nil
1896 2164
             ? .checkpointEnergyMap
1897 2165
             : .capacityEstimate
1898 2166
 
@@ -1901,6 +2169,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1901 2169
         let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
1902 2170
 
1903 2171
         let predictedPercent: Double
2172
+        let basis: BatteryLevelPredictionBasis
1904 2173
         if let lowerAnchor,
1905 2174
            let upperAnchor,
1906 2175
            upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
@@ -1920,20 +2189,44 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1920 2189
                 ),
1921 2190
                 100
1922 2191
             )
2192
+            basis = fallbackBasis
1923 2193
         } else {
1924
-            guard let inferredCapacityWh, inferredCapacityWh > 0 else {
1925
-                return nil
2194
+            let chargeCurve = BatteryChargeCurve(typicalCurvePoints: typicalCurve)
2195
+            let curveDeviationFactor = chargeCurve.flatMap {
2196
+                BatteryLevelPredictionTuning.deviationFactor(
2197
+                    anchors: sortedAnchors,
2198
+                    chargeCurve: $0
2199
+                )
2200
+            }
2201
+            let curvePredictedPercent = chargeCurve.flatMap {
2202
+                BatteryLevelPredictionTuning.predictedPercent(
2203
+                    anchorPercent: anchor.percent,
2204
+                    anchorEnergyWh: anchor.energyWh,
2205
+                    effectiveEnergyWh: effectiveEnergyWh,
2206
+                    chargeCurve: $0,
2207
+                    deviationFactor: curveDeviationFactor
2208
+                )
1926 2209
             }
1927 2210
 
1928
-            predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1929
-                anchorPercent: anchor.percent,
1930
-                anchorEnergyWh: anchor.energyWh,
1931
-                anchorTimestamp: anchor.timestamp,
1932
-                anchorIsCheckpoint: anchor.isCheckpoint,
1933
-                effectiveEnergyWh: effectiveEnergyWh,
1934
-                referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
1935
-                estimatedCapacityWh: inferredCapacityWh
1936
-            )
2211
+            if let curvePredictedPercent {
2212
+                predictedPercent = curvePredictedPercent
2213
+                basis = .typicalChargeCurve
2214
+            } else {
2215
+                guard let inferredCapacityWh, inferredCapacityWh > 0 else {
2216
+                    return nil
2217
+                }
2218
+
2219
+                predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
2220
+                    anchorPercent: anchor.percent,
2221
+                    anchorEnergyWh: anchor.energyWh,
2222
+                    anchorTimestamp: anchor.timestamp,
2223
+                    anchorIsCheckpoint: anchor.isCheckpoint,
2224
+                    effectiveEnergyWh: effectiveEnergyWh,
2225
+                    referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
2226
+                    estimatedCapacityWh: inferredCapacityWh
2227
+                )
2228
+                basis = fallbackBasis
2229
+            }
1937 2230
         }
1938 2231
 
1939 2232
         return BatteryLevelPrediction(
@@ -1946,6 +2239,38 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1946 2239
         )
1947 2240
     }
1948 2241
 
2242
+    private func estimatedFlatReserveEnergyWh(excluding excludedSessionID: UUID? = nil) -> Double? {
2243
+        let reserves = sessions.compactMap { session -> Double? in
2244
+            guard session.id != excludedSessionID,
2245
+                  session.status == .completed,
2246
+                  session.startsFromFlatBattery else {
2247
+                return nil
2248
+            }
2249
+
2250
+            let anchors = session.checkpoints.map {
2251
+                BatteryLevelPredictionAnchor(
2252
+                    percent: $0.batteryPercent,
2253
+                    energyWh: $0.measuredEnergyWh,
2254
+                    timestamp: $0.timestamp,
2255
+                    description: $0.flag.anchorDescription,
2256
+                    isCheckpoint: true
2257
+                )
2258
+            }
2259
+
2260
+            return BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2261
+                from: anchors,
2262
+                estimatedCapacityWh: session.capacityEstimateWh
2263
+            )
2264
+        }
2265
+
2266
+        guard !reserves.isEmpty else {
2267
+            return nil
2268
+        }
2269
+
2270
+        let sortedReserves = reserves.sorted()
2271
+        return sortedReserves[sortedReserves.count / 2]
2272
+    }
2273
+
1949 2274
     func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1950 2275
         ChargedDeviceSummary(
1951 2276
             id: id,
+119 -13
USB Meter/Model/ChargeInsightsStore.swift
@@ -2315,14 +2315,7 @@ final class ChargeInsightsStore {
2315 2315
             )
2316 2316
         let sessionID = stringValue(session, key: "id") ?? ""
2317 2317
 
2318
-        struct Anchor {
2319
-            let percent: Double
2320
-            let energyWh: Double
2321
-            let timestamp: Date
2322
-            let isCheckpoint: Bool
2323
-        }
2324
-
2325
-        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
2318
+        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
2326 2319
             var candidates: [Double] = []
2327 2320
 
2328 2321
             for lowerIndex in anchors.indices {
@@ -2348,7 +2341,7 @@ final class ChargeInsightsStore {
2348 2341
             return candidates
2349 2342
         }
2350 2343
 
2351
-        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
2344
+        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
2352 2345
             let candidates = anchorCapacityCandidates(from: anchors)
2353 2346
             guard !candidates.isEmpty else {
2354 2347
                 return nil
@@ -2358,16 +2351,17 @@ final class ChargeInsightsStore {
2358 2351
             return sortedCandidates[sortedCandidates.count / 2]
2359 2352
         }
2360 2353
 
2361
-        var anchors: [Anchor] = []
2354
+        var anchors: [BatteryLevelPredictionAnchor] = []
2362 2355
         if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2363 2356
            startBatteryPercent >= 0 {
2364 2357
             anchors.append(
2365
-                Anchor(
2358
+                BatteryLevelPredictionAnchor(
2366 2359
                     percent: startBatteryPercent,
2367 2360
                     energyWh: 0,
2368 2361
                     timestamp: dateValue(session, key: "trimStart")
2369 2362
                         ?? dateValue(session, key: "startedAt")
2370 2363
                         ?? Date.distantPast,
2364
+                    description: "session start",
2371 2365
                     isCheckpoint: false
2372 2366
                 )
2373 2367
             )
@@ -2383,15 +2377,42 @@ final class ChargeInsightsStore {
2383 2377
             }
2384 2378
             .filter { $0.batteryPercent >= 0 }
2385 2379
             .map {
2386
-                Anchor(
2380
+                BatteryLevelPredictionAnchor(
2387 2381
                     percent: $0.batteryPercent,
2388 2382
                     energyWh: $0.measuredEnergyWh,
2389 2383
                     timestamp: $0.timestamp,
2384
+                    description: $0.flag.anchorDescription,
2390 2385
                     isCheckpoint: true
2391 2386
                 )
2392 2387
             }
2393 2388
         anchors.append(contentsOf: checkpointAnchors)
2394 2389
 
2390
+        if optionalDoubleValue(session, key: "startBatteryPercent") == unresolvedFlatBatteryPercent {
2391
+            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2392
+                from: anchors,
2393
+                estimatedCapacityWh: estimatedCapacityWh,
2394
+                historicalReserveEnergyWh: estimatedFlatReserveEnergyWh(
2395
+                    forChargedDeviceID: chargedDeviceID,
2396
+                    excludingSessionID: sessionID
2397
+                )
2398
+            ) {
2399
+                anchors.append(
2400
+                    BatteryLevelPredictionAnchor(
2401
+                        percent: 0,
2402
+                        energyWh: virtualZeroEnergyWh,
2403
+                        timestamp: dateValue(session, key: "trimStart")
2404
+                            ?? dateValue(session, key: "startedAt")
2405
+                            ?? Date.distantPast,
2406
+                        description: "estimated flat reserve",
2407
+                        isCheckpoint: false
2408
+                    )
2409
+                )
2410
+            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
2411
+                      measuredEnergyWh < firstCheckpoint.energyWh - 0.05 {
2412
+                return nil
2413
+            }
2414
+        }
2415
+
2395 2416
         let sortedAnchors = anchors.sorted { lhs, rhs in
2396 2417
             if lhs.energyWh != rhs.energyWh {
2397 2418
                 return lhs.energyWh < rhs.energyWh
@@ -2430,6 +2451,23 @@ final class ChargeInsightsStore {
2430 2451
             )
2431 2452
         }
2432 2453
 
2454
+        if let chargeCurve = typicalChargeCurve(
2455
+            forChargedDeviceID: chargedDeviceID,
2456
+            excludingSessionID: sessionID
2457
+        ),
2458
+           let curvePredictedPercent = BatteryLevelPredictionTuning.predictedPercent(
2459
+            anchorPercent: anchor.percent,
2460
+            anchorEnergyWh: anchor.energyWh,
2461
+            effectiveEnergyWh: measuredEnergyWh,
2462
+            chargeCurve: chargeCurve,
2463
+            deviationFactor: BatteryLevelPredictionTuning.deviationFactor(
2464
+                anchors: sortedAnchors,
2465
+                chargeCurve: chargeCurve
2466
+            )
2467
+           ) {
2468
+            return curvePredictedPercent
2469
+        }
2470
+
2433 2471
         guard let inferredCapacityWh, inferredCapacityWh > 0 else {
2434 2472
             return nil
2435 2473
         }
@@ -2671,7 +2709,7 @@ final class ChargeInsightsStore {
2671 2709
         // Session start/end battery percent fields track the device subject only.
2672 2710
         if subject == .chargedDevice {
2673 2711
             let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2674
-            if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
2712
+            if existingStartBatteryPercent == nil {
2675 2713
                 session.setValue(percent, forKey: "startBatteryPercent")
2676 2714
             }
2677 2715
             if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
@@ -2901,6 +2939,74 @@ final class ChargeInsightsStore {
2901 2939
             .sorted { $0.timestamp < $1.timestamp }
2902 2940
     }
2903 2941
 
2942
+    private func typicalChargeCurve(
2943
+        forChargedDeviceID chargedDeviceID: String,
2944
+        excludingSessionID excludedSessionID: String? = nil
2945
+    ) -> BatteryChargeCurve? {
2946
+        let sessionObjects = fetchSessions(forChargedDeviceID: chargedDeviceID)
2947
+            .filter {
2948
+                statusValue($0, key: "statusRawValue") == .completed
2949
+            }
2950
+
2951
+        let sessionSummaries = sessionObjects.compactMap { session -> ChargeSessionSummary? in
2952
+            guard let sessionID = stringValue(session, key: "id"),
2953
+                  sessionID != excludedSessionID else {
2954
+                return nil
2955
+            }
2956
+
2957
+            return makeSessionSummary(
2958
+                from: session,
2959
+                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
2960
+                samples: []
2961
+            )
2962
+        }
2963
+
2964
+        return BatteryChargeCurve(
2965
+            typicalCurvePoints: buildTypicalCurve(from: sessionSummaries)
2966
+        )
2967
+    }
2968
+
2969
+    private func estimatedFlatReserveEnergyWh(
2970
+        forChargedDeviceID chargedDeviceID: String,
2971
+        excludingSessionID excludedSessionID: String? = nil
2972
+    ) -> Double? {
2973
+        let reserves = fetchSessions(forChargedDeviceID: chargedDeviceID)
2974
+            .filter {
2975
+                statusValue($0, key: "statusRawValue") == .completed
2976
+                    && optionalDoubleValue($0, key: "startBatteryPercent") == unresolvedFlatBatteryPercent
2977
+                    && stringValue($0, key: "id") != excludedSessionID
2978
+            }
2979
+            .compactMap { session -> Double? in
2980
+                guard let sessionID = stringValue(session, key: "id") else {
2981
+                    return nil
2982
+                }
2983
+
2984
+                let anchors = fetchCheckpointObjects(forSessionID: sessionID)
2985
+                    .compactMap(makeCheckpointSummary(from:))
2986
+                    .map {
2987
+                        BatteryLevelPredictionAnchor(
2988
+                            percent: $0.batteryPercent,
2989
+                            energyWh: $0.measuredEnergyWh,
2990
+                            timestamp: $0.timestamp,
2991
+                            description: $0.flag.anchorDescription,
2992
+                            isCheckpoint: true
2993
+                        )
2994
+                    }
2995
+
2996
+                return BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2997
+                    from: anchors,
2998
+                    estimatedCapacityWh: optionalDoubleValue(session, key: "capacityEstimateWh")
2999
+                )
3000
+            }
3001
+
3002
+        guard !reserves.isEmpty else {
3003
+            return nil
3004
+        }
3005
+
3006
+        let sortedReserves = reserves.sorted()
3007
+        return sortedReserves[sortedReserves.count / 2]
3008
+    }
3009
+
2904 3010
     private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2905 3011
         var groupedEnergyByBin: [Int: [Double]] = [:]
2906 3012
 
+29 -13
USB Meter/Model/Measurements.swift
@@ -472,14 +472,7 @@ class Measurements : ObservableObject {
472 472
     ) -> Double? {
473 473
         let estimatedCapacityWh = session.capacityEstimateWh
474 474
 
475
-        struct Anchor {
476
-            let percent: Double
477
-            let energyWh: Double
478
-            let timestamp: Date
479
-            let isCheckpoint: Bool
480
-        }
481
-
482
-        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
475
+        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
483 476
             var candidates: [Double] = []
484 477
 
485 478
             for lowerIndex in anchors.indices {
@@ -505,7 +498,7 @@ class Measurements : ObservableObject {
505 498
             return candidates
506 499
         }
507 500
 
508
-        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
501
+        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
509 502
             let candidates = anchorCapacityCandidates(from: anchors)
510 503
             guard !candidates.isEmpty else {
511 504
                 return nil
@@ -515,14 +508,15 @@ class Measurements : ObservableObject {
515 508
             return sortedCandidates[sortedCandidates.count / 2]
516 509
         }
517 510
 
518
-        var anchors: [Anchor] = []
511
+        var anchors: [BatteryLevelPredictionAnchor] = []
519 512
         if let startBatteryPercent = session.startBatteryPercent,
520 513
            startBatteryPercent >= 0 {
521 514
             anchors.append(
522
-                Anchor(
515
+                BatteryLevelPredictionAnchor(
523 516
                     percent: startBatteryPercent,
524 517
                     energyWh: 0,
525 518
                     timestamp: session.effectiveTrimStart,
519
+                    description: "session start",
526 520
                     isCheckpoint: false
527 521
                 )
528 522
             )
@@ -538,15 +532,38 @@ class Measurements : ObservableObject {
538 532
                     return lhs.timestamp < rhs.timestamp
539 533
                 }
540 534
                 .map {
541
-                    Anchor(
535
+                    BatteryLevelPredictionAnchor(
542 536
                         percent: $0.batteryPercent,
543 537
                         energyWh: $0.measuredEnergyWh,
544 538
                         timestamp: $0.timestamp,
539
+                        description: $0.flag.anchorDescription,
545 540
                         isCheckpoint: true
546 541
                     )
547 542
                 }
548 543
         )
549 544
 
545
+        let effectiveEnergyWh = effectiveBatteryEnergyWh(for: sample, in: session)
546
+
547
+        if session.startsFromFlatBattery {
548
+            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
549
+                from: anchors,
550
+                estimatedCapacityWh: estimatedCapacityWh
551
+            ) {
552
+                anchors.append(
553
+                    BatteryLevelPredictionAnchor(
554
+                        percent: 0,
555
+                        energyWh: virtualZeroEnergyWh,
556
+                        timestamp: session.effectiveTrimStart,
557
+                        description: "estimated flat reserve",
558
+                        isCheckpoint: false
559
+                    )
560
+                )
561
+            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
562
+                      effectiveEnergyWh < firstCheckpoint.energyWh - 0.05 {
563
+                return nil
564
+            }
565
+        }
566
+
550 567
         let sortedAnchors = anchors.sorted { lhs, rhs in
551 568
             if lhs.energyWh != rhs.energyWh {
552 569
                 return lhs.energyWh < rhs.energyWh
@@ -556,7 +573,6 @@ class Measurements : ObservableObject {
556 573
 
557 574
         guard !sortedAnchors.isEmpty else { return nil }
558 575
 
559
-        let effectiveEnergyWh = effectiveBatteryEnergyWh(for: sample, in: session)
560 576
         let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
561 577
         let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
562 578
         let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
+8 -2
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -594,7 +594,10 @@ struct ChargeSessionDetailView: View {
594 594
                         Divider()
595 595
                         HStack(alignment: .top, spacing: 12) {
596 596
                             if let v = startPercent {
597
-                                overviewStatCell(label: "Start Battery", value: "\(v.format(decimalDigits: 0))%")
597
+                                overviewStatCell(
598
+                                    label: "Start Battery",
599
+                                    value: session.startsFromFlatBattery ? "Flat" : "\(v.format(decimalDigits: 0))%"
600
+                                )
598 601
                             }
599 602
                             if let v = session.endBatteryPercent {
600 603
                                 overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
@@ -1557,7 +1560,7 @@ struct ChargeSessionDetailView: View {
1557 1560
 
1558 1561
     private var resolvedFinalCheckpoint: Double? {
1559 1562
         switch finalCheckpointMode {
1560
-        case .full:   return 100
1563
+        case .full:   return suggestedFinalCheckpointPercent(for: session)
1561 1564
         case .skip:   return nil
1562 1565
         case .custom: return parsedFinalCheckpoint
1563 1566
         }
@@ -1609,6 +1612,9 @@ struct ChargeSessionDetailView: View {
1609 1612
                 return "Final battery percentage must be between 0 and 100."
1610 1613
             }
1611 1614
         }
1615
+        if finalCheckpointMode == .full && resolvedFinalCheckpoint == nil {
1616
+            return "Full can be saved only when there is a current battery estimate. Choose Skip or enter the displayed percentage."
1617
+        }
1612 1618
 
1613 1619
         guard hasSavableChargeData(
1614 1620
             session: session,
+1 -1
USB Meter/Views/ChargedDevices/Sessions/ChargedDeviceSessionsView.swift
@@ -246,7 +246,7 @@ struct ChargedDeviceSessionsView: View {
246 246
         let start = session.startBatteryPercent
247 247
         let end = session.endBatteryPercent
248 248
 
249
-        if let s = start, let e = end, e > s {
249
+        if let s = start, let e = end, s >= 0, e > s {
250 250
             return (s, e)
251 251
         }
252 252
 
+4 -2
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/ChargeSessionCompletionSheetView.swift
@@ -192,6 +192,9 @@ struct ChargeSessionCompletionSheetView: View {
192 192
                 return "Final battery percentage must be between 0 and 100."
193 193
             }
194 194
         }
195
+        if finalCheckpoint == .full && resolvedFinalBatteryPercent == nil {
196
+            return "Full can be saved only when there is a current battery estimate. Choose Skip or enter the displayed percentage."
197
+        }
195 198
 
196 199
         guard hasChargeDataToSave else {
197 200
             return "This session has no charging data to save. Discard it instead."
@@ -210,7 +213,7 @@ struct ChargeSessionCompletionSheetView: View {
210 213
 
211 214
     private var resolvedFinalBatteryPercent: Double? {
212 215
         switch finalCheckpoint {
213
-        case .full:   return 100
216
+        case .full:   return suggestedFinalBatteryPercent
214 217
         case .skip:   return nil
215 218
         case .custom: return parsedBatteryPercent
216 219
         }
@@ -252,4 +255,3 @@ struct ChargeSessionCompletionSheetView: View {
252 255
     }
253 256
 
254 257
 }
255
-