@@ -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, |
@@ -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 |
|
@@ -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! |
@@ -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, |
@@ -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 |
|
@@ -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 |
- |
|