@@ -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; |
@@ -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> |
@@ -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> |
|
@@ -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, |
@@ -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 |
} |
@@ -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 |
|
@@ -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 {
|
@@ -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: |
@@ -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 |
|
@@ -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 |
|