@@ -19,6 +19,8 @@ |
||
| 19 | 19 |
C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */; };
|
| 20 | 20 |
C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */; };
|
| 21 | 21 |
C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */; };
|
| 22 |
+ B0A000113C8F000100A10011 /* ChargerStandbyPowerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */; };
|
|
| 23 |
+ B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */; };
|
|
| 22 | 24 |
430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FB245E07EB006525C2 /* ChevronView.swift */; };
|
| 23 | 25 |
4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311E639241384960080EA59 /* DeviceHelpView.swift */; };
|
| 24 | 26 |
4327461B24619CED0009BE4B /* MeterRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4327461A24619CED0009BE4B /* MeterRowView.swift */; };
|
@@ -131,6 +133,8 @@ |
||
| 131 | 133 |
C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 8.xcdatamodel"; sourceTree = "<group>"; };
|
| 132 | 134 |
C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 9.xcdatamodel"; sourceTree = "<group>"; };
|
| 133 | 135 |
C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 10.xcdatamodel"; sourceTree = "<group>"; };
|
| 136 |
+ B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerStore.swift; sourceTree = "<group>"; };
|
|
| 137 |
+ B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerWizardView.swift; sourceTree = "<group>"; };
|
|
| 134 | 138 |
430CB4FB245E07EB006525C2 /* ChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronView.swift; sourceTree = "<group>"; };
|
| 135 | 139 |
4311E639241384960080EA59 /* DeviceHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelpView.swift; sourceTree = "<group>"; };
|
| 136 | 140 |
4327461A24619CED0009BE4B /* MeterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterRowView.swift; sourceTree = "<group>"; };
|
@@ -418,6 +422,7 @@ |
||
| 418 | 422 |
4383B461240EB5E400DAAEBF /* AppData.swift */, |
| 419 | 423 |
C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */, |
| 420 | 424 |
C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */, |
| 425 |
+ B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */, |
|
| 421 | 426 |
C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */, |
| 422 | 427 |
7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */, |
| 423 | 428 |
43CBF676240C043E00255B8B /* BluetoothManager.swift */, |
@@ -540,6 +545,7 @@ |
||
| 540 | 545 |
isa = PBXGroup; |
| 541 | 546 |
children = ( |
| 542 | 547 |
D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */, |
| 548 |
+ B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */, |
|
| 543 | 549 |
D28F11253C8E4A7A00A10035 /* Subviews */, |
| 544 | 550 |
); |
| 545 | 551 |
path = Live; |
@@ -769,6 +775,8 @@ |
||
| 769 | 775 |
C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */, |
| 770 | 776 |
C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */, |
| 771 | 777 |
C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */, |
| 778 |
+ B0A000113C8F000100A10011 /* ChargerStandbyPowerStore.swift in Sources */, |
|
| 779 |
+ B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */, |
|
| 772 | 780 |
4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */, |
| 773 | 781 |
437D47D12415F91B00B7768E /* MeterLiveContentView.swift in Sources */, |
| 774 | 782 |
4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */, |
@@ -43,6 +43,7 @@ final class AppData : ObservableObject {
|
||
| 43 | 43 |
private var chargeInsightsRemoteObserver: AnyCancellable? |
| 44 | 44 |
private let meterStore = MeterNameStore.shared |
| 45 | 45 |
private var chargeInsightsStore: ChargeInsightsStore? |
| 46 |
+ private let chargerStandbyPowerStore = ChargerStandbyPowerStore() |
|
| 46 | 47 |
private let chargeNotificationCoordinator = ChargeNotificationCoordinator() |
| 47 | 48 |
|
| 48 | 49 |
init() {
|
@@ -67,6 +68,7 @@ final class AppData : ObservableObject {
|
||
| 67 | 68 |
|
| 68 | 69 |
@Published var meters: [UUID:Meter] = [UUID:Meter]() |
| 69 | 70 |
@Published private(set) var chargedDevices: [ChargedDeviceSummary] = [] |
| 71 |
+ @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:] |
|
| 70 | 72 |
|
| 71 | 73 |
var deviceSummaries: [ChargedDeviceSummary] {
|
| 72 | 74 |
chargedDevices.filter { !$0.isCharger }
|
@@ -218,6 +220,73 @@ final class AppData : ObservableObject {
|
||
| 218 | 220 |
chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress) |
| 219 | 221 |
} |
| 220 | 222 |
|
| 223 |
+ func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
|
|
| 224 |
+ activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)] |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 227 |
+ @discardableResult |
|
| 228 |
+ func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
|
|
| 229 |
+ guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
|
|
| 230 |
+ return false |
|
| 231 |
+ } |
|
| 232 |
+ |
|
| 233 |
+ let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description) |
|
| 234 |
+ if let existingSession = activeChargerStandbySessions[normalizedMAC] {
|
|
| 235 |
+ return existingSession.chargerID == chargerID |
|
| 236 |
+ } |
|
| 237 |
+ |
|
| 238 |
+ let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter) |
|
| 239 |
+ session.onChange = { [weak self] in
|
|
| 240 |
+ self?.scheduleObjectWillChange() |
|
| 241 |
+ } |
|
| 242 |
+ session.onStabilized = { [weak self, weak session] in
|
|
| 243 |
+ guard let self, let session else { return }
|
|
| 244 |
+ self.notifyChargerStandbyMeasurementReady(for: session) |
|
| 245 |
+ } |
|
| 246 |
+ |
|
| 247 |
+ activeChargerStandbySessions[normalizedMAC] = session |
|
| 248 |
+ session.start() |
|
| 249 |
+ |
|
| 250 |
+ // Starting a standby run on an available meter should also initiate the BLE link. |
|
| 251 |
+ if meter.operationalState == .peripheralNotConnected {
|
|
| 252 |
+ meter.connect() |
|
| 253 |
+ } |
|
| 254 |
+ |
|
| 255 |
+ scheduleObjectWillChange() |
|
| 256 |
+ return true |
|
| 257 |
+ } |
|
| 258 |
+ |
|
| 259 |
+ @discardableResult |
|
| 260 |
+ func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
|
|
| 261 |
+ let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) |
|
| 262 |
+ guard let session = activeChargerStandbySessions[normalizedMAC] else {
|
|
| 263 |
+ return false |
|
| 264 |
+ } |
|
| 265 |
+ |
|
| 266 |
+ session.stop() |
|
| 267 |
+ |
|
| 268 |
+ guard save else {
|
|
| 269 |
+ activeChargerStandbySessions[normalizedMAC] = nil |
|
| 270 |
+ scheduleObjectWillChange() |
|
| 271 |
+ return true |
|
| 272 |
+ } |
|
| 273 |
+ |
|
| 274 |
+ guard let summary = session.makeSummary() else {
|
|
| 275 |
+ scheduleObjectWillChange() |
|
| 276 |
+ return false |
|
| 277 |
+ } |
|
| 278 |
+ |
|
| 279 |
+ let didSave = chargerStandbyPowerStore.save(summary) |
|
| 280 |
+ if didSave {
|
|
| 281 |
+ activeChargerStandbySessions[normalizedMAC] = nil |
|
| 282 |
+ reloadChargedDevices() |
|
| 283 |
+ } else {
|
|
| 284 |
+ scheduleObjectWillChange() |
|
| 285 |
+ } |
|
| 286 |
+ |
|
| 287 |
+ return didSave |
|
| 288 |
+ } |
|
| 289 |
+ |
|
| 221 | 290 |
@discardableResult |
| 222 | 291 |
func createDevice( |
| 223 | 292 |
name: String, |
@@ -570,6 +639,14 @@ final class AppData : ObservableObject {
|
||
| 570 | 639 |
return false |
| 571 | 640 |
} |
| 572 | 641 |
|
| 642 |
+ if deletedDevice?.isCharger == true {
|
|
| 643 |
+ _ = chargerStandbyPowerStore.removeMeasurements(for: id) |
|
| 644 |
+ for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
|
|
| 645 |
+ session.stop() |
|
| 646 |
+ activeChargerStandbySessions[meterMACAddress] = nil |
|
| 647 |
+ } |
|
| 648 |
+ } |
|
| 649 |
+ |
|
| 573 | 650 |
if deletedDevice?.isCharger == false, |
| 574 | 651 |
deletedDevice?.activeSession?.status.isOpen == true, |
| 575 | 652 |
let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress, |
@@ -663,7 +740,12 @@ final class AppData : ObservableObject {
|
||
| 663 | 740 |
} |
| 664 | 741 |
|
| 665 | 742 |
private func reloadChargedDevices() {
|
| 666 |
- chargedDevices = chargeInsightsStore?.fetchChargedDeviceSummaries() ?? [] |
|
| 743 |
+ let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID() |
|
| 744 |
+ chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
|
|
| 745 |
+ chargedDevice.withStandbyPowerMeasurements( |
|
| 746 |
+ standbyMeasurementsByChargerID[chargedDevice.id] ?? [] |
|
| 747 |
+ ) |
|
| 748 |
+ } |
|
| 667 | 749 |
chargeNotificationCoordinator.process(chargedDevices: deviceSummaries) |
| 668 | 750 |
for meter in meters.values {
|
| 669 | 751 |
restoreChargeMonitoringStateIfNeeded(for: meter) |
@@ -701,6 +783,27 @@ final class AppData : ObservableObject {
|
||
| 701 | 783 |
} |
| 702 | 784 |
} |
| 703 | 785 |
|
| 786 |
+ private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
|
|
| 787 |
+ guard let charger = chargedDeviceSummary(id: session.chargerID), |
|
| 788 |
+ let statistics = session.statistics else {
|
|
| 789 |
+ return |
|
| 790 |
+ } |
|
| 791 |
+ |
|
| 792 |
+ let content = UNMutableNotificationContent() |
|
| 793 |
+ content.title = "Standby baseline stabilised" |
|
| 794 |
+ content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready." |
|
| 795 |
+ content.sound = .default |
|
| 796 |
+ content.threadIdentifier = "charger-standby-\(charger.id.uuidString)" |
|
| 797 |
+ |
|
| 798 |
+ let request = UNNotificationRequest( |
|
| 799 |
+ identifier: "charger-standby-\(session.id.uuidString)", |
|
| 800 |
+ content: content, |
|
| 801 |
+ trigger: nil |
|
| 802 |
+ ) |
|
| 803 |
+ UNUserNotificationCenter.current().add(request) |
|
| 804 |
+ scheduleObjectWillChange() |
|
| 805 |
+ } |
|
| 806 |
+ |
|
| 704 | 807 |
private func batteryCheckpointPlausibilityWarning( |
| 705 | 808 |
percent: Double, |
| 706 | 809 |
for session: ChargeSessionSummary |
@@ -496,6 +496,301 @@ struct TypicalChargeCurvePoint: Identifiable, Hashable {
|
||
| 496 | 496 |
var id: Int { percentBin }
|
| 497 | 497 |
} |
| 498 | 498 |
|
| 499 |
+struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
|
|
| 500 |
+ let timestamp: Date |
|
| 501 |
+ let powerWatts: Double |
|
| 502 |
+ let currentAmps: Double |
|
| 503 |
+ let voltageVolts: Double |
|
| 504 |
+ |
|
| 505 |
+ var id: TimeInterval {
|
|
| 506 |
+ timestamp.timeIntervalSince1970 |
|
| 507 |
+ } |
|
| 508 |
+} |
|
| 509 |
+ |
|
| 510 |
+struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable {
|
|
| 511 |
+ let index: Int |
|
| 512 |
+ let lowerBoundWatts: Double |
|
| 513 |
+ let upperBoundWatts: Double |
|
| 514 |
+ let count: Int |
|
| 515 |
+ let relativeFrequency: Double |
|
| 516 |
+ |
|
| 517 |
+ var id: Int { index }
|
|
| 518 |
+} |
|
| 519 |
+ |
|
| 520 |
+struct ChargerStandbyPowerMeasurementStatistics: Hashable {
|
|
| 521 |
+ let sampleCount: Int |
|
| 522 |
+ let observedDuration: TimeInterval |
|
| 523 |
+ let averagePowerWatts: Double |
|
| 524 |
+ let recentAveragePowerWatts: Double |
|
| 525 |
+ let medianPowerWatts: Double |
|
| 526 |
+ let minimumPowerWatts: Double |
|
| 527 |
+ let maximumPowerWatts: Double |
|
| 528 |
+ let standardDeviationPowerWatts: Double |
|
| 529 |
+ let coefficientOfVariation: Double |
|
| 530 |
+ let averageCurrentAmps: Double |
|
| 531 |
+ let averageVoltageVolts: Double |
|
| 532 |
+ let stabilityDeltaWatts: Double |
|
| 533 |
+ let stabilityToleranceWatts: Double |
|
| 534 |
+ let histogram: [ChargerStandbyPowerDistributionBin] |
|
| 535 |
+ |
|
| 536 |
+ var projectedDailyEnergyWh: Double {
|
|
| 537 |
+ averagePowerWatts * 24 |
|
| 538 |
+ } |
|
| 539 |
+ |
|
| 540 |
+ var projectedWeeklyEnergyWh: Double {
|
|
| 541 |
+ averagePowerWatts * 24 * 7 |
|
| 542 |
+ } |
|
| 543 |
+ |
|
| 544 |
+ var projectedMonthlyEnergyWh: Double {
|
|
| 545 |
+ averagePowerWatts * 24 * 30 |
|
| 546 |
+ } |
|
| 547 |
+ |
|
| 548 |
+ var projectedYearlyEnergyWh: Double {
|
|
| 549 |
+ averagePowerWatts * 24 * 365 |
|
| 550 |
+ } |
|
| 551 |
+ |
|
| 552 |
+ var stabilityDeltaMilliwatts: Double {
|
|
| 553 |
+ stabilityDeltaWatts * 1000 |
|
| 554 |
+ } |
|
| 555 |
+ |
|
| 556 |
+ var isStable: Bool {
|
|
| 557 |
+ sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount |
|
| 558 |
+ && stabilityDeltaWatts <= stabilityToleranceWatts |
|
| 559 |
+ } |
|
| 560 |
+} |
|
| 561 |
+ |
|
| 562 |
+struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
|
|
| 563 |
+ let id: UUID |
|
| 564 |
+ let chargerID: UUID |
|
| 565 |
+ let meterMACAddress: String |
|
| 566 |
+ let meterName: String? |
|
| 567 |
+ let meterModel: String? |
|
| 568 |
+ let startedAt: Date |
|
| 569 |
+ let endedAt: Date |
|
| 570 |
+ let sampleCount: Int |
|
| 571 |
+ let stabilizedAt: Date? |
|
| 572 |
+ let averagePowerWatts: Double |
|
| 573 |
+ let recentAveragePowerWatts: Double |
|
| 574 |
+ let medianPowerWatts: Double |
|
| 575 |
+ let minimumPowerWatts: Double |
|
| 576 |
+ let maximumPowerWatts: Double |
|
| 577 |
+ let standardDeviationPowerWatts: Double |
|
| 578 |
+ let coefficientOfVariation: Double |
|
| 579 |
+ let averageCurrentAmps: Double |
|
| 580 |
+ let averageVoltageVolts: Double |
|
| 581 |
+ let stabilityDeltaWatts: Double |
|
| 582 |
+ let stabilityToleranceWatts: Double |
|
| 583 |
+ let powerSamplesWatts: [Double] |
|
| 584 |
+ |
|
| 585 |
+ var duration: TimeInterval {
|
|
| 586 |
+ endedAt.timeIntervalSince(startedAt) |
|
| 587 |
+ } |
|
| 588 |
+ |
|
| 589 |
+ var projectedDailyEnergyWh: Double {
|
|
| 590 |
+ averagePowerWatts * 24 |
|
| 591 |
+ } |
|
| 592 |
+ |
|
| 593 |
+ var projectedWeeklyEnergyWh: Double {
|
|
| 594 |
+ averagePowerWatts * 24 * 7 |
|
| 595 |
+ } |
|
| 596 |
+ |
|
| 597 |
+ var projectedMonthlyEnergyWh: Double {
|
|
| 598 |
+ averagePowerWatts * 24 * 30 |
|
| 599 |
+ } |
|
| 600 |
+ |
|
| 601 |
+ var projectedYearlyEnergyWh: Double {
|
|
| 602 |
+ averagePowerWatts * 24 * 365 |
|
| 603 |
+ } |
|
| 604 |
+ |
|
| 605 |
+ var isStable: Bool {
|
|
| 606 |
+ stabilizedAt != nil |
|
| 607 |
+ } |
|
| 608 |
+ |
|
| 609 |
+ var histogram: [ChargerStandbyPowerDistributionBin] {
|
|
| 610 |
+ ChargerStandbyPowerMeasurementAnalyzer.histogram(for: powerSamplesWatts) |
|
| 611 |
+ } |
|
| 612 |
+} |
|
| 613 |
+ |
|
| 614 |
+enum ChargerStandbyPowerMeasurementAnalyzer {
|
|
| 615 |
+ static let minimumStableSampleCount = 45 |
|
| 616 |
+ static let recentSampleWindow = 20 |
|
| 617 |
+ static let minimumStabilityToleranceWatts = 0.003 |
|
| 618 |
+ static let relativeStabilityTolerance = 0.01 |
|
| 619 |
+ |
|
| 620 |
+ static func statistics( |
|
| 621 |
+ from samples: [ChargerStandbyPowerSample], |
|
| 622 |
+ startedAt: Date, |
|
| 623 |
+ referenceDate: Date = Date() |
|
| 624 |
+ ) -> ChargerStandbyPowerMeasurementStatistics? {
|
|
| 625 |
+ guard !samples.isEmpty else {
|
|
| 626 |
+ return nil |
|
| 627 |
+ } |
|
| 628 |
+ |
|
| 629 |
+ let powerValues = samples.map(\.powerWatts).filter(\.isFinite) |
|
| 630 |
+ let currentValues = samples.map(\.currentAmps).filter(\.isFinite) |
|
| 631 |
+ let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite) |
|
| 632 |
+ |
|
| 633 |
+ guard powerValues.isEmpty == false else {
|
|
| 634 |
+ return nil |
|
| 635 |
+ } |
|
| 636 |
+ |
|
| 637 |
+ let averagePower = mean(powerValues) |
|
| 638 |
+ let recentWindow = min(recentSampleWindow, max(1, powerValues.count)) |
|
| 639 |
+ let recentAveragePower = mean(Array(powerValues.suffix(recentWindow))) |
|
| 640 |
+ let stabilityDelta = abs(averagePower - recentAveragePower) |
|
| 641 |
+ let stabilityTolerance = max( |
|
| 642 |
+ minimumStabilityToleranceWatts, |
|
| 643 |
+ abs(averagePower) * relativeStabilityTolerance |
|
| 644 |
+ ) |
|
| 645 |
+ |
|
| 646 |
+ return ChargerStandbyPowerMeasurementStatistics( |
|
| 647 |
+ sampleCount: powerValues.count, |
|
| 648 |
+ observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0), |
|
| 649 |
+ averagePowerWatts: averagePower, |
|
| 650 |
+ recentAveragePowerWatts: recentAveragePower, |
|
| 651 |
+ medianPowerWatts: median(powerValues), |
|
| 652 |
+ minimumPowerWatts: powerValues.min() ?? 0, |
|
| 653 |
+ maximumPowerWatts: powerValues.max() ?? 0, |
|
| 654 |
+ standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower), |
|
| 655 |
+ coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower), |
|
| 656 |
+ averageCurrentAmps: mean(currentValues), |
|
| 657 |
+ averageVoltageVolts: mean(voltageValues), |
|
| 658 |
+ stabilityDeltaWatts: stabilityDelta, |
|
| 659 |
+ stabilityToleranceWatts: stabilityTolerance, |
|
| 660 |
+ histogram: histogram(for: powerValues) |
|
| 661 |
+ ) |
|
| 662 |
+ } |
|
| 663 |
+ |
|
| 664 |
+ static func measurementSummary( |
|
| 665 |
+ chargerID: UUID, |
|
| 666 |
+ meterMACAddress: String, |
|
| 667 |
+ meterName: String?, |
|
| 668 |
+ meterModel: String?, |
|
| 669 |
+ startedAt: Date, |
|
| 670 |
+ endedAt: Date, |
|
| 671 |
+ samples: [ChargerStandbyPowerSample], |
|
| 672 |
+ stabilizedAt: Date? |
|
| 673 |
+ ) -> ChargerStandbyPowerMeasurementSummary? {
|
|
| 674 |
+ guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
|
|
| 675 |
+ return nil |
|
| 676 |
+ } |
|
| 677 |
+ |
|
| 678 |
+ return ChargerStandbyPowerMeasurementSummary( |
|
| 679 |
+ id: UUID(), |
|
| 680 |
+ chargerID: chargerID, |
|
| 681 |
+ meterMACAddress: meterMACAddress, |
|
| 682 |
+ meterName: meterName, |
|
| 683 |
+ meterModel: meterModel, |
|
| 684 |
+ startedAt: startedAt, |
|
| 685 |
+ endedAt: endedAt, |
|
| 686 |
+ sampleCount: statistics.sampleCount, |
|
| 687 |
+ stabilizedAt: stabilizedAt, |
|
| 688 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 689 |
+ recentAveragePowerWatts: statistics.recentAveragePowerWatts, |
|
| 690 |
+ medianPowerWatts: statistics.medianPowerWatts, |
|
| 691 |
+ minimumPowerWatts: statistics.minimumPowerWatts, |
|
| 692 |
+ maximumPowerWatts: statistics.maximumPowerWatts, |
|
| 693 |
+ standardDeviationPowerWatts: statistics.standardDeviationPowerWatts, |
|
| 694 |
+ coefficientOfVariation: statistics.coefficientOfVariation, |
|
| 695 |
+ averageCurrentAmps: statistics.averageCurrentAmps, |
|
| 696 |
+ averageVoltageVolts: statistics.averageVoltageVolts, |
|
| 697 |
+ stabilityDeltaWatts: statistics.stabilityDeltaWatts, |
|
| 698 |
+ stabilityToleranceWatts: statistics.stabilityToleranceWatts, |
|
| 699 |
+ powerSamplesWatts: samples.map(\.powerWatts) |
|
| 700 |
+ ) |
|
| 701 |
+ } |
|
| 702 |
+ |
|
| 703 |
+ static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
|
|
| 704 |
+ let finiteValues = values.filter(\.isFinite) |
|
| 705 |
+ guard finiteValues.isEmpty == false else {
|
|
| 706 |
+ return [] |
|
| 707 |
+ } |
|
| 708 |
+ |
|
| 709 |
+ let minimum = finiteValues.min() ?? 0 |
|
| 710 |
+ let maximum = finiteValues.max() ?? 0 |
|
| 711 |
+ let spread = maximum - minimum |
|
| 712 |
+ let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded()))) |
|
| 713 |
+ |
|
| 714 |
+ guard spread > 0 else {
|
|
| 715 |
+ return [ |
|
| 716 |
+ ChargerStandbyPowerDistributionBin( |
|
| 717 |
+ index: 0, |
|
| 718 |
+ lowerBoundWatts: minimum, |
|
| 719 |
+ upperBoundWatts: maximum, |
|
| 720 |
+ count: finiteValues.count, |
|
| 721 |
+ relativeFrequency: 1 |
|
| 722 |
+ ) |
|
| 723 |
+ ] |
|
| 724 |
+ } |
|
| 725 |
+ |
|
| 726 |
+ let safeBinCount = max(1, binCount) |
|
| 727 |
+ let binWidth = spread / Double(safeBinCount) |
|
| 728 |
+ var counts = Array(repeating: 0, count: safeBinCount) |
|
| 729 |
+ |
|
| 730 |
+ for value in finiteValues {
|
|
| 731 |
+ let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down)) |
|
| 732 |
+ let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1) |
|
| 733 |
+ counts[safeIndex] += 1 |
|
| 734 |
+ } |
|
| 735 |
+ |
|
| 736 |
+ return counts.enumerated().map { index, count in
|
|
| 737 |
+ let lowerBound = minimum + (Double(index) * binWidth) |
|
| 738 |
+ let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth |
|
| 739 |
+ |
|
| 740 |
+ return ChargerStandbyPowerDistributionBin( |
|
| 741 |
+ index: index, |
|
| 742 |
+ lowerBoundWatts: lowerBound, |
|
| 743 |
+ upperBoundWatts: upperBound, |
|
| 744 |
+ count: count, |
|
| 745 |
+ relativeFrequency: Double(count) / Double(finiteValues.count) |
|
| 746 |
+ ) |
|
| 747 |
+ } |
|
| 748 |
+ } |
|
| 749 |
+ |
|
| 750 |
+ private static func mean(_ values: [Double]) -> Double {
|
|
| 751 |
+ guard values.isEmpty == false else {
|
|
| 752 |
+ return 0 |
|
| 753 |
+ } |
|
| 754 |
+ return values.reduce(0, +) / Double(values.count) |
|
| 755 |
+ } |
|
| 756 |
+ |
|
| 757 |
+ private static func median(_ values: [Double]) -> Double {
|
|
| 758 |
+ guard values.isEmpty == false else {
|
|
| 759 |
+ return 0 |
|
| 760 |
+ } |
|
| 761 |
+ |
|
| 762 |
+ let sorted = values.sorted() |
|
| 763 |
+ let middleIndex = sorted.count / 2 |
|
| 764 |
+ |
|
| 765 |
+ if sorted.count.isMultiple(of: 2) {
|
|
| 766 |
+ return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2 |
|
| 767 |
+ } |
|
| 768 |
+ |
|
| 769 |
+ return sorted[middleIndex] |
|
| 770 |
+ } |
|
| 771 |
+ |
|
| 772 |
+ private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
|
|
| 773 |
+ guard values.count > 1 else {
|
|
| 774 |
+ return 0 |
|
| 775 |
+ } |
|
| 776 |
+ |
|
| 777 |
+ let variance = values.reduce(0) { partialResult, value in
|
|
| 778 |
+ let delta = value - mean |
|
| 779 |
+ return partialResult + (delta * delta) |
|
| 780 |
+ } / Double(values.count) |
|
| 781 |
+ |
|
| 782 |
+ return variance.squareRoot() |
|
| 783 |
+ } |
|
| 784 |
+ |
|
| 785 |
+ private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
|
|
| 786 |
+ guard abs(mean) > 0.000_001 else {
|
|
| 787 |
+ return 0 |
|
| 788 |
+ } |
|
| 789 |
+ |
|
| 790 |
+ return standardDeviation(values, mean: mean) / abs(mean) |
|
| 791 |
+ } |
|
| 792 |
+} |
|
| 793 |
+ |
|
| 499 | 794 |
struct ChargedDeviceSummary: Identifiable, Hashable {
|
| 500 | 795 |
let id: UUID |
| 501 | 796 |
let qrIdentifier: String |
@@ -528,6 +823,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 528 | 823 |
let sessions: [ChargeSessionSummary] |
| 529 | 824 |
let capacityHistory: [CapacityTrendPoint] |
| 530 | 825 |
let typicalCurve: [TypicalChargeCurvePoint] |
| 826 |
+ let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary] |
|
| 531 | 827 |
|
| 532 | 828 |
var isCharger: Bool {
|
| 533 | 829 |
deviceClass == .charger |
@@ -557,6 +853,10 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 557 | 853 |
sessions.count |
| 558 | 854 |
} |
| 559 | 855 |
|
| 856 |
+ var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
|
|
| 857 |
+ standbyPowerMeasurements.first |
|
| 858 |
+ } |
|
| 859 |
+ |
|
| 560 | 860 |
var supportedChargingModes: [ChargingTransportMode] {
|
| 561 | 861 |
var modes: [ChargingTransportMode] = [] |
| 562 | 862 |
if supportsWiredCharging {
|
@@ -735,6 +1035,43 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 735 | 1035 |
anchorDescription: anchor.description |
| 736 | 1036 |
) |
| 737 | 1037 |
} |
| 1038 |
+ |
|
| 1039 |
+ func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
|
|
| 1040 |
+ ChargedDeviceSummary( |
|
| 1041 |
+ id: id, |
|
| 1042 |
+ qrIdentifier: qrIdentifier, |
|
| 1043 |
+ name: name, |
|
| 1044 |
+ deviceClass: deviceClass, |
|
| 1045 |
+ supportsChargingWhileOff: supportsChargingWhileOff, |
|
| 1046 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 1047 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 1048 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 1049 |
+ wirelessChargingProfile: wirelessChargingProfile, |
|
| 1050 |
+ configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 1051 |
+ learnedCompletionCurrents: learnedCompletionCurrents, |
|
| 1052 |
+ wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor, |
|
| 1053 |
+ wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps, |
|
| 1054 |
+ wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps, |
|
| 1055 |
+ chargerObservedVoltageSelections: chargerObservedVoltageSelections, |
|
| 1056 |
+ chargerIdleCurrentAmps: chargerIdleCurrentAmps, |
|
| 1057 |
+ chargerEfficiencyFactor: chargerEfficiencyFactor, |
|
| 1058 |
+ chargerMaximumPowerWatts: chargerMaximumPowerWatts, |
|
| 1059 |
+ notes: notes, |
|
| 1060 |
+ minimumCurrentAmps: minimumCurrentAmps, |
|
| 1061 |
+ estimatedBatteryCapacityWh: estimatedBatteryCapacityWh, |
|
| 1062 |
+ wiredMinimumCurrentAmps: wiredMinimumCurrentAmps, |
|
| 1063 |
+ wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps, |
|
| 1064 |
+ wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh, |
|
| 1065 |
+ wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh, |
|
| 1066 |
+ lastAssociatedMeterMAC: lastAssociatedMeterMAC, |
|
| 1067 |
+ createdAt: createdAt, |
|
| 1068 |
+ updatedAt: updatedAt, |
|
| 1069 |
+ sessions: sessions, |
|
| 1070 |
+ capacityHistory: capacityHistory, |
|
| 1071 |
+ typicalCurve: typicalCurve, |
|
| 1072 |
+ standbyPowerMeasurements: measurements |
|
| 1073 |
+ ) |
|
| 1074 |
+ } |
|
| 738 | 1075 |
} |
| 739 | 1076 |
|
| 740 | 1077 |
struct ChargingMonitorSnapshot {
|
@@ -935,7 +935,8 @@ final class ChargeInsightsStore {
|
||
| 935 | 935 |
updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast, |
| 936 | 936 |
sessions: sessionSummaries, |
| 937 | 937 |
capacityHistory: buildCapacityHistory(from: sessionSummaries), |
| 938 |
- typicalCurve: buildTypicalCurve(from: sessionSummaries) |
|
| 938 |
+ typicalCurve: buildTypicalCurve(from: sessionSummaries), |
|
| 939 |
+ standbyPowerMeasurements: [] |
|
| 939 | 940 |
) |
| 940 | 941 |
} |
| 941 | 942 |
.sorted { lhs, rhs in
|
@@ -0,0 +1,274 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargerStandbyPowerStore.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 13/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import Foundation |
|
| 9 |
+ |
|
| 10 |
+final class ChargerStandbyPowerStore {
|
|
| 11 |
+ private struct Snapshot: Codable {
|
|
| 12 |
+ var measurements: [ChargerStandbyPowerMeasurementSummary] |
|
| 13 |
+ } |
|
| 14 |
+ |
|
| 15 |
+ private let fileManager: FileManager |
|
| 16 |
+ private let fileURL: URL |
|
| 17 |
+ private let encoder: JSONEncoder |
|
| 18 |
+ private let decoder: JSONDecoder |
|
| 19 |
+ |
|
| 20 |
+ private var cachedMeasurements: [ChargerStandbyPowerMeasurementSummary]? |
|
| 21 |
+ |
|
| 22 |
+ init(fileManager: FileManager = .default) {
|
|
| 23 |
+ self.fileManager = fileManager |
|
| 24 |
+ |
|
| 25 |
+ let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first |
|
| 26 |
+ ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first |
|
| 27 |
+ ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) |
|
| 28 |
+ |
|
| 29 |
+ let directoryURL = applicationSupportURL.appendingPathComponent("ChargeInsights", isDirectory: true)
|
|
| 30 |
+ fileURL = directoryURL.appendingPathComponent("charger-standby-power.json", isDirectory: false)
|
|
| 31 |
+ |
|
| 32 |
+ encoder = JSONEncoder() |
|
| 33 |
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys] |
|
| 34 |
+ encoder.dateEncodingStrategy = .iso8601 |
|
| 35 |
+ |
|
| 36 |
+ decoder = JSONDecoder() |
|
| 37 |
+ decoder.dateDecodingStrategy = .iso8601 |
|
| 38 |
+ } |
|
| 39 |
+ |
|
| 40 |
+ func measurementsByChargerID() -> [UUID: [ChargerStandbyPowerMeasurementSummary]] {
|
|
| 41 |
+ Dictionary(grouping: loadMeasurements()) { $0.chargerID }
|
|
| 42 |
+ .mapValues { measurements in
|
|
| 43 |
+ measurements.sorted { lhs, rhs in
|
|
| 44 |
+ if lhs.endedAt != rhs.endedAt {
|
|
| 45 |
+ return lhs.endedAt > rhs.endedAt |
|
| 46 |
+ } |
|
| 47 |
+ return lhs.id.uuidString > rhs.id.uuidString |
|
| 48 |
+ } |
|
| 49 |
+ } |
|
| 50 |
+ } |
|
| 51 |
+ |
|
| 52 |
+ @discardableResult |
|
| 53 |
+ func save(_ measurement: ChargerStandbyPowerMeasurementSummary) -> Bool {
|
|
| 54 |
+ var measurements = loadMeasurements() |
|
| 55 |
+ measurements.append(measurement) |
|
| 56 |
+ measurements.sort { lhs, rhs in
|
|
| 57 |
+ if lhs.endedAt != rhs.endedAt {
|
|
| 58 |
+ return lhs.endedAt > rhs.endedAt |
|
| 59 |
+ } |
|
| 60 |
+ return lhs.id.uuidString > rhs.id.uuidString |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 63 |
+ return persist(measurements) |
|
| 64 |
+ } |
|
| 65 |
+ |
|
| 66 |
+ @discardableResult |
|
| 67 |
+ func removeMeasurements(for chargerID: UUID) -> Bool {
|
|
| 68 |
+ let previousMeasurements = loadMeasurements() |
|
| 69 |
+ let filteredMeasurements = previousMeasurements.filter { $0.chargerID != chargerID }
|
|
| 70 |
+ guard filteredMeasurements.count != previousMeasurements.count else {
|
|
| 71 |
+ return true |
|
| 72 |
+ } |
|
| 73 |
+ |
|
| 74 |
+ return persist(filteredMeasurements) |
|
| 75 |
+ } |
|
| 76 |
+ |
|
| 77 |
+ private func loadMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
|
|
| 78 |
+ if let cachedMeasurements {
|
|
| 79 |
+ return cachedMeasurements |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 82 |
+ guard fileManager.fileExists(atPath: fileURL.path) else {
|
|
| 83 |
+ cachedMeasurements = [] |
|
| 84 |
+ return [] |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 87 |
+ do {
|
|
| 88 |
+ let data = try Data(contentsOf: fileURL) |
|
| 89 |
+ let snapshot = try decoder.decode(Snapshot.self, from: data) |
|
| 90 |
+ cachedMeasurements = snapshot.measurements |
|
| 91 |
+ return snapshot.measurements |
|
| 92 |
+ } catch {
|
|
| 93 |
+ track("Failed to load charger standby power history: \(error.localizedDescription)")
|
|
| 94 |
+ cachedMeasurements = [] |
|
| 95 |
+ return [] |
|
| 96 |
+ } |
|
| 97 |
+ } |
|
| 98 |
+ |
|
| 99 |
+ @discardableResult |
|
| 100 |
+ private func persist(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
|
|
| 101 |
+ do {
|
|
| 102 |
+ try fileManager.createDirectory( |
|
| 103 |
+ at: fileURL.deletingLastPathComponent(), |
|
| 104 |
+ withIntermediateDirectories: true, |
|
| 105 |
+ attributes: nil |
|
| 106 |
+ ) |
|
| 107 |
+ let snapshot = Snapshot(measurements: measurements) |
|
| 108 |
+ let data = try encoder.encode(snapshot) |
|
| 109 |
+ try data.write(to: fileURL, options: .atomic) |
|
| 110 |
+ cachedMeasurements = measurements |
|
| 111 |
+ return true |
|
| 112 |
+ } catch {
|
|
| 113 |
+ track("Failed to save charger standby power history: \(error.localizedDescription)")
|
|
| 114 |
+ return false |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+} |
|
| 118 |
+ |
|
| 119 |
+final class ChargerStandbyPowerMonitorSession: ObservableObject, Identifiable {
|
|
| 120 |
+ let id = UUID() |
|
| 121 |
+ let chargerID: UUID |
|
| 122 |
+ let meterMACAddress: String |
|
| 123 |
+ let meterName: String |
|
| 124 |
+ let meterModel: String |
|
| 125 |
+ let startedAt: Date |
|
| 126 |
+ |
|
| 127 |
+ @Published private(set) var samples: [ChargerStandbyPowerSample] = [] |
|
| 128 |
+ @Published private(set) var statistics: ChargerStandbyPowerMeasurementStatistics? |
|
| 129 |
+ @Published private(set) var stabilizedAt: Date? |
|
| 130 |
+ @Published private(set) var lastObservedAt: Date? |
|
| 131 |
+ @Published private(set) var isRunning = false |
|
| 132 |
+ |
|
| 133 |
+ var onChange: (() -> Void)? |
|
| 134 |
+ var onStabilized: (() -> Void)? |
|
| 135 |
+ |
|
| 136 |
+ private weak var meter: Meter? |
|
| 137 |
+ private var timer: Timer? |
|
| 138 |
+ private var hasTriggeredStabilityCallback = false |
|
| 139 |
+ private let sampleInterval: TimeInterval = 1 |
|
| 140 |
+ |
|
| 141 |
+ init(chargerID: UUID, meter: Meter, startedAt: Date = Date()) {
|
|
| 142 |
+ self.chargerID = chargerID |
|
| 143 |
+ meterMACAddress = meter.btSerial.macAddress.description |
|
| 144 |
+ meterName = meter.name |
|
| 145 |
+ meterModel = meter.deviceModelSummary |
|
| 146 |
+ self.startedAt = startedAt |
|
| 147 |
+ self.meter = meter |
|
| 148 |
+ } |
|
| 149 |
+ |
|
| 150 |
+ deinit {
|
|
| 151 |
+ stop() |
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ var sampleCount: Int {
|
|
| 155 |
+ statistics?.sampleCount ?? samples.count |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ var hasSamples: Bool {
|
|
| 159 |
+ sampleCount > 0 |
|
| 160 |
+ } |
|
| 161 |
+ |
|
| 162 |
+ var readinessDescription: String {
|
|
| 163 |
+ guard let statistics else {
|
|
| 164 |
+ if let meter {
|
|
| 165 |
+ switch meter.operationalState {
|
|
| 166 |
+ case .peripheralConnectionPending, .peripheralConnected, .peripheralReady, .comunicating: |
|
| 167 |
+ return "Connecting to meter" |
|
| 168 |
+ case .peripheralNotConnected: |
|
| 169 |
+ return "Starting meter connection" |
|
| 170 |
+ case .notPresent, .dataIsAvailable: |
|
| 171 |
+ break |
|
| 172 |
+ } |
|
| 173 |
+ } |
|
| 174 |
+ |
|
| 175 |
+ return "Waiting for live samples" |
|
| 176 |
+ } |
|
| 177 |
+ |
|
| 178 |
+ if statistics.isStable {
|
|
| 179 |
+ return "Stable average reached" |
|
| 180 |
+ } |
|
| 181 |
+ |
|
| 182 |
+ return "Collecting baseline" |
|
| 183 |
+ } |
|
| 184 |
+ |
|
| 185 |
+ func start() {
|
|
| 186 |
+ guard isRunning == false else {
|
|
| 187 |
+ return |
|
| 188 |
+ } |
|
| 189 |
+ |
|
| 190 |
+ isRunning = true |
|
| 191 |
+ captureSampleIfPossible(at: Date()) |
|
| 192 |
+ |
|
| 193 |
+ let timer = Timer(timeInterval: sampleInterval, repeats: true) { [weak self] _ in
|
|
| 194 |
+ self?.captureSampleIfPossible(at: Date()) |
|
| 195 |
+ } |
|
| 196 |
+ self.timer = timer |
|
| 197 |
+ RunLoop.main.add(timer, forMode: .common) |
|
| 198 |
+ onChange?() |
|
| 199 |
+ } |
|
| 200 |
+ |
|
| 201 |
+ func stop() {
|
|
| 202 |
+ timer?.invalidate() |
|
| 203 |
+ timer = nil |
|
| 204 |
+ guard isRunning else {
|
|
| 205 |
+ return |
|
| 206 |
+ } |
|
| 207 |
+ isRunning = false |
|
| 208 |
+ onChange?() |
|
| 209 |
+ } |
|
| 210 |
+ |
|
| 211 |
+ func makeSummary(endedAt: Date = Date()) -> ChargerStandbyPowerMeasurementSummary? {
|
|
| 212 |
+ ChargerStandbyPowerMeasurementAnalyzer.measurementSummary( |
|
| 213 |
+ chargerID: chargerID, |
|
| 214 |
+ meterMACAddress: meterMACAddress, |
|
| 215 |
+ meterName: meterName, |
|
| 216 |
+ meterModel: meterModel, |
|
| 217 |
+ startedAt: startedAt, |
|
| 218 |
+ endedAt: endedAt, |
|
| 219 |
+ samples: samples, |
|
| 220 |
+ stabilizedAt: stabilizedAt |
|
| 221 |
+ ) |
|
| 222 |
+ } |
|
| 223 |
+ |
|
| 224 |
+ private func captureSampleIfPossible(at timestamp: Date) {
|
|
| 225 |
+ defer {
|
|
| 226 |
+ statistics = ChargerStandbyPowerMeasurementAnalyzer.statistics( |
|
| 227 |
+ from: samples, |
|
| 228 |
+ startedAt: startedAt, |
|
| 229 |
+ referenceDate: timestamp |
|
| 230 |
+ ) |
|
| 231 |
+ onChange?() |
|
| 232 |
+ } |
|
| 233 |
+ |
|
| 234 |
+ guard let meter else {
|
|
| 235 |
+ return |
|
| 236 |
+ } |
|
| 237 |
+ |
|
| 238 |
+ guard meter.operationalState == .dataIsAvailable else {
|
|
| 239 |
+ return |
|
| 240 |
+ } |
|
| 241 |
+ |
|
| 242 |
+ let powerWatts = meter.power |
|
| 243 |
+ let currentAmps = meter.current |
|
| 244 |
+ let voltageVolts = meter.voltage |
|
| 245 |
+ |
|
| 246 |
+ guard powerWatts.isFinite, currentAmps.isFinite, voltageVolts.isFinite else {
|
|
| 247 |
+ return |
|
| 248 |
+ } |
|
| 249 |
+ |
|
| 250 |
+ lastObservedAt = timestamp |
|
| 251 |
+ samples.append( |
|
| 252 |
+ ChargerStandbyPowerSample( |
|
| 253 |
+ timestamp: timestamp, |
|
| 254 |
+ powerWatts: powerWatts, |
|
| 255 |
+ currentAmps: currentAmps, |
|
| 256 |
+ voltageVolts: voltageVolts |
|
| 257 |
+ ) |
|
| 258 |
+ ) |
|
| 259 |
+ |
|
| 260 |
+ if stabilizedAt == nil, |
|
| 261 |
+ let refreshedStatistics = ChargerStandbyPowerMeasurementAnalyzer.statistics( |
|
| 262 |
+ from: samples, |
|
| 263 |
+ startedAt: startedAt, |
|
| 264 |
+ referenceDate: timestamp |
|
| 265 |
+ ), |
|
| 266 |
+ refreshedStatistics.isStable {
|
|
| 267 |
+ stabilizedAt = timestamp |
|
| 268 |
+ if hasTriggeredStabilityCallback == false {
|
|
| 269 |
+ hasTriggeredStabilityCallback = true |
|
| 270 |
+ onStabilized?() |
|
| 271 |
+ } |
|
| 272 |
+ } |
|
| 273 |
+ } |
|
| 274 |
+} |
|
@@ -27,6 +27,10 @@ struct ChargedDeviceDetailView: View {
|
||
| 27 | 27 |
headerCard(chargedDevice) |
| 28 | 28 |
insightsCard(chargedDevice) |
| 29 | 29 |
|
| 30 |
+ if chargedDevice.isCharger {
|
|
| 31 |
+ standbyPowerCard(chargedDevice) |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 30 | 34 |
if let activeSession = chargedDevice.activeSession {
|
| 31 | 35 |
activeSessionCard(activeSession, chargedDevice: chargedDevice) |
| 32 | 36 |
} |
@@ -264,6 +268,16 @@ struct ChargedDeviceDetailView: View {
|
||
| 264 | 268 |
value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W" |
| 265 | 269 |
) |
| 266 | 270 |
} |
| 271 |
+ if let latestStandbyPowerMeasurement = chargedDevice.latestStandbyPowerMeasurement {
|
|
| 272 |
+ MeterInfoRowView( |
|
| 273 |
+ label: "Standby Power", |
|
| 274 |
+ value: "\(latestStandbyPowerMeasurement.averagePowerWatts.format(decimalDigits: 3)) W" |
|
| 275 |
+ ) |
|
| 276 |
+ MeterInfoRowView( |
|
| 277 |
+ label: "Standby Projection", |
|
| 278 |
+ value: standbyEnergyLabel(latestStandbyPowerMeasurement.projectedYearlyEnergyWh) + " / year" |
|
| 279 |
+ ) |
|
| 280 |
+ } |
|
| 267 | 281 |
MeterInfoRowView( |
| 268 | 282 |
label: "Wireless Sessions", |
| 269 | 283 |
value: "\(chargedDevice.sessionCount)" |
@@ -276,6 +290,112 @@ struct ChargedDeviceDetailView: View {
|
||
| 276 | 290 |
} |
| 277 | 291 |
} |
| 278 | 292 |
|
| 293 |
+ private func standbyPowerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 294 |
+ let latestMeasurement = chargedDevice.latestStandbyPowerMeasurement |
|
| 295 |
+ |
|
| 296 |
+ return MeterInfoCardView( |
|
| 297 |
+ title: "Standby Power", |
|
| 298 |
+ tint: .orange |
|
| 299 |
+ ) {
|
|
| 300 |
+ if standbyMeasurementMeters.isEmpty {
|
|
| 301 |
+ Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.")
|
|
| 302 |
+ .font(.footnote) |
|
| 303 |
+ .foregroundColor(.secondary) |
|
| 304 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 305 |
+ } else {
|
|
| 306 |
+ NavigationLink( |
|
| 307 |
+ destination: ChargerStandbyPowerWizardView( |
|
| 308 |
+ preferredChargerID: chargedDevice.id, |
|
| 309 |
+ locksChargerSelection: true |
|
| 310 |
+ ) |
|
| 311 |
+ ) {
|
|
| 312 |
+ Label("New Measurement", systemImage: "plus.circle.fill")
|
|
| 313 |
+ .font(.subheadline.weight(.semibold)) |
|
| 314 |
+ .foregroundColor(.orange) |
|
| 315 |
+ } |
|
| 316 |
+ .buttonStyle(.plain) |
|
| 317 |
+ } |
|
| 318 |
+ |
|
| 319 |
+ if let latestMeasurement {
|
|
| 320 |
+ Divider() |
|
| 321 |
+ |
|
| 322 |
+ NavigationLink( |
|
| 323 |
+ destination: ChargerStandbyPowerMeasurementDetailView( |
|
| 324 |
+ chargerID: chargedDevice.id, |
|
| 325 |
+ measurementID: latestMeasurement.id |
|
| 326 |
+ ) |
|
| 327 |
+ ) {
|
|
| 328 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 329 |
+ HStack {
|
|
| 330 |
+ Text("Latest Measurement")
|
|
| 331 |
+ .font(.subheadline.weight(.semibold)) |
|
| 332 |
+ .foregroundColor(.primary) |
|
| 333 |
+ Spacer() |
|
| 334 |
+ Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 335 |
+ .font(.subheadline.weight(.bold)) |
|
| 336 |
+ .foregroundColor(.primary) |
|
| 337 |
+ .monospacedDigit() |
|
| 338 |
+ } |
|
| 339 |
+ |
|
| 340 |
+ Text( |
|
| 341 |
+ "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year" |
|
| 342 |
+ ) |
|
| 343 |
+ .font(.caption) |
|
| 344 |
+ .foregroundColor(.secondary) |
|
| 345 |
+ } |
|
| 346 |
+ } |
|
| 347 |
+ .buttonStyle(.plain) |
|
| 348 |
+ } |
|
| 349 |
+ |
|
| 350 |
+ if chargedDevice.standbyPowerMeasurements.isEmpty == false {
|
|
| 351 |
+ Divider() |
|
| 352 |
+ |
|
| 353 |
+ NavigationLink( |
|
| 354 |
+ destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id) |
|
| 355 |
+ ) {
|
|
| 356 |
+ Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
|
|
| 357 |
+ .font(.subheadline.weight(.semibold)) |
|
| 358 |
+ .foregroundColor(.blue) |
|
| 359 |
+ } |
|
| 360 |
+ .buttonStyle(.plain) |
|
| 361 |
+ |
|
| 362 |
+ Divider() |
|
| 363 |
+ |
|
| 364 |
+ ForEach(Array(chargedDevice.standbyPowerMeasurements.prefix(3))) { measurement in
|
|
| 365 |
+ NavigationLink( |
|
| 366 |
+ destination: ChargerStandbyPowerMeasurementDetailView( |
|
| 367 |
+ chargerID: chargedDevice.id, |
|
| 368 |
+ measurementID: measurement.id |
|
| 369 |
+ ) |
|
| 370 |
+ ) {
|
|
| 371 |
+ HStack {
|
|
| 372 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 373 |
+ Text(measurement.endedAt.format()) |
|
| 374 |
+ .font(.subheadline.weight(.semibold)) |
|
| 375 |
+ .foregroundColor(.primary) |
|
| 376 |
+ Text("\(measurement.sampleCount) samples")
|
|
| 377 |
+ .font(.caption) |
|
| 378 |
+ .foregroundColor(.secondary) |
|
| 379 |
+ } |
|
| 380 |
+ |
|
| 381 |
+ Spacer() |
|
| 382 |
+ |
|
| 383 |
+ Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 384 |
+ .font(.subheadline.weight(.bold)) |
|
| 385 |
+ .foregroundColor(.primary) |
|
| 386 |
+ .monospacedDigit() |
|
| 387 |
+ } |
|
| 388 |
+ } |
|
| 389 |
+ .buttonStyle(.plain) |
|
| 390 |
+ |
|
| 391 |
+ if measurement.id != chargedDevice.standbyPowerMeasurements.prefix(3).last?.id {
|
|
| 392 |
+ Divider() |
|
| 393 |
+ } |
|
| 394 |
+ } |
|
| 395 |
+ } |
|
| 396 |
+ } |
|
| 397 |
+ } |
|
| 398 |
+ |
|
| 279 | 399 |
private func activeSessionCard( |
| 280 | 400 |
_ activeSession: ChargeSessionSummary, |
| 281 | 401 |
chargedDevice: ChargedDeviceSummary |
@@ -722,6 +842,17 @@ struct ChargedDeviceDetailView: View {
|
||
| 722 | 842 |
} |
| 723 | 843 |
} |
| 724 | 844 |
|
| 845 |
+ private func standbyEnergyLabel(_ wattHours: Double) -> String {
|
|
| 846 |
+ if wattHours >= 1000 {
|
|
| 847 |
+ return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 848 |
+ } |
|
| 849 |
+ return "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 850 |
+ } |
|
| 851 |
+ |
|
| 852 |
+ private var standbyMeasurementMeters: [AppData.MeterSummary] {
|
|
| 853 |
+ appData.meterSummaries.filter { $0.meter != nil }
|
|
| 854 |
+ } |
|
| 855 |
+ |
|
| 725 | 856 |
private func completionCurrentDescription( |
| 726 | 857 |
for chargedDevice: ChargedDeviceSummary, |
| 727 | 858 |
sessionKind: ChargeSessionKind |
@@ -402,9 +402,12 @@ struct MeterChargeRecordContentView: View {
|
||
| 402 | 402 |
.padding(.vertical, 10) |
| 403 | 403 |
.meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
| 404 | 404 |
.buttonStyle(.plain) |
| 405 |
- } else {
|
|
| 406 |
- EmptyView() |
|
| 407 | 405 |
} |
| 406 |
+ |
|
| 407 |
+ if selectedChargedDevice != nil {
|
|
| 408 |
+ Divider() |
|
| 409 |
+ } |
|
| 410 |
+ standbyMeasurementSection |
|
| 408 | 411 |
} |
| 409 | 412 |
.padding(18) |
| 410 | 413 |
.meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20) |
@@ -646,6 +649,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 646 | 649 |
} |
| 647 | 650 |
} |
| 648 | 651 |
} |
| 652 |
+ |
|
| 649 | 653 |
} else {
|
| 650 | 654 |
Text("Wireless sessions need a selected charger in addition to the charged device.")
|
| 651 | 655 |
.font(.caption) |
@@ -654,6 +658,76 @@ struct MeterChargeRecordContentView: View {
|
||
| 654 | 658 |
} |
| 655 | 659 |
} |
| 656 | 660 |
|
| 661 |
+ private var standbyMeasurementSection: some View {
|
|
| 662 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 663 |
+ HStack {
|
|
| 664 |
+ Text("Charger Standby Power")
|
|
| 665 |
+ .font(.subheadline.weight(.semibold)) |
|
| 666 |
+ Spacer() |
|
| 667 |
+ Button(selectedCharger == nil ? "Select Charger" : "Change Charger") {
|
|
| 668 |
+ chargerLibraryVisibility = true |
|
| 669 |
+ } |
|
| 670 |
+ .disabled(openChargeSession != nil) |
|
| 671 |
+ } |
|
| 672 |
+ |
|
| 673 |
+ if let selectedCharger {
|
|
| 674 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 675 |
+ ChargedDeviceQRCodeView( |
|
| 676 |
+ qrIdentifier: selectedCharger.qrIdentifier, |
|
| 677 |
+ side: 62 |
|
| 678 |
+ ) |
|
| 679 |
+ |
|
| 680 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 681 |
+ Label(selectedCharger.name, systemImage: selectedCharger.identitySymbolName) |
|
| 682 |
+ .font(.subheadline.weight(.semibold)) |
|
| 683 |
+ |
|
| 684 |
+ Text( |
|
| 685 |
+ selectedCharger.latestStandbyPowerMeasurement.map {
|
|
| 686 |
+ "Latest standby: \($0.averagePowerWatts.format(decimalDigits: 3)) W" |
|
| 687 |
+ } ?? "No standby baseline saved yet." |
|
| 688 |
+ ) |
|
| 689 |
+ .font(.caption) |
|
| 690 |
+ .foregroundColor(.secondary) |
|
| 691 |
+ } |
|
| 692 |
+ } |
|
| 693 |
+ |
|
| 694 |
+ NavigationLink( |
|
| 695 |
+ destination: ChargerStandbyPowerWizardView( |
|
| 696 |
+ preferredMeterMACAddress: meterMACAddress, |
|
| 697 |
+ preferredChargerID: selectedCharger.id |
|
| 698 |
+ ) |
|
| 699 |
+ ) {
|
|
| 700 |
+ Label("New Measurement", systemImage: "plus.circle.fill")
|
|
| 701 |
+ .font(.subheadline.weight(.semibold)) |
|
| 702 |
+ .foregroundColor(.orange) |
|
| 703 |
+ } |
|
| 704 |
+ .buttonStyle(.plain) |
|
| 705 |
+ .disabled(openChargeSession != nil) |
|
| 706 |
+ |
|
| 707 |
+ if openChargeSession != nil {
|
|
| 708 |
+ Text("Stop or pause the active charge session before starting a standby-power run on this meter.")
|
|
| 709 |
+ .font(.caption) |
|
| 710 |
+ .foregroundColor(.secondary) |
|
| 711 |
+ } |
|
| 712 |
+ } else {
|
|
| 713 |
+ NavigationLink( |
|
| 714 |
+ destination: ChargerStandbyPowerWizardView( |
|
| 715 |
+ preferredMeterMACAddress: meterMACAddress |
|
| 716 |
+ ) |
|
| 717 |
+ ) {
|
|
| 718 |
+ Label("New Measurement", systemImage: "plus.circle.fill")
|
|
| 719 |
+ .font(.subheadline.weight(.semibold)) |
|
| 720 |
+ .foregroundColor(.orange) |
|
| 721 |
+ } |
|
| 722 |
+ .buttonStyle(.plain) |
|
| 723 |
+ |
|
| 724 |
+ Text("Open the wizard and choose the charger there, or preselect one from Charge Record first.")
|
|
| 725 |
+ .font(.caption) |
|
| 726 |
+ .foregroundColor(.secondary) |
|
| 727 |
+ } |
|
| 728 |
+ } |
|
| 729 |
+ } |
|
| 730 |
+ |
|
| 657 | 731 |
private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
|
| 658 | 732 |
let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession) |
| 659 | 733 |
return VStack(alignment: .leading, spacing: 12) {
|
@@ -0,0 +1,833 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargerStandbyPowerWizardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 13/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct ChargerStandbyPowerWizardView: View {
|
|
| 11 |
+ @EnvironmentObject private var appData: AppData |
|
| 12 |
+ |
|
| 13 |
+ @State private var chargerLibraryVisibility = false |
|
| 14 |
+ @State private var discardConfirmationVisibility = false |
|
| 15 |
+ @State private var selectedMeterMACAddress: String? |
|
| 16 |
+ @State private var selectedChargerID: UUID? |
|
| 17 |
+ |
|
| 18 |
+ let preferredMeterMACAddress: String? |
|
| 19 |
+ let preferredChargerID: UUID? |
|
| 20 |
+ let locksChargerSelection: Bool |
|
| 21 |
+ |
|
| 22 |
+ init( |
|
| 23 |
+ preferredMeterMACAddress: String? = nil, |
|
| 24 |
+ preferredChargerID: UUID? = nil, |
|
| 25 |
+ locksChargerSelection: Bool = false |
|
| 26 |
+ ) {
|
|
| 27 |
+ self.preferredMeterMACAddress = preferredMeterMACAddress |
|
| 28 |
+ self.preferredChargerID = preferredChargerID |
|
| 29 |
+ self.locksChargerSelection = locksChargerSelection |
|
| 30 |
+ _selectedMeterMACAddress = State(initialValue: nil) |
|
| 31 |
+ _selectedChargerID = State(initialValue: preferredChargerID) |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ var body: some View {
|
|
| 35 |
+ ScrollView {
|
|
| 36 |
+ VStack(spacing: 18) {
|
|
| 37 |
+ if let session = activeSession {
|
|
| 38 |
+ activeMeasurementCard(session) |
|
| 39 |
+ liveSessionCard(session) |
|
| 40 |
+ } else {
|
|
| 41 |
+ newMeasurementWizardCard |
|
| 42 |
+ } |
|
| 43 |
+ } |
|
| 44 |
+ .padding() |
|
| 45 |
+ } |
|
| 46 |
+ .background( |
|
| 47 |
+ LinearGradient( |
|
| 48 |
+ colors: [.orange.opacity(0.16), Color.clear], |
|
| 49 |
+ startPoint: .topLeading, |
|
| 50 |
+ endPoint: .bottomTrailing |
|
| 51 |
+ ) |
|
| 52 |
+ .ignoresSafeArea() |
|
| 53 |
+ ) |
|
| 54 |
+ .navigationTitle(navigationTitleText) |
|
| 55 |
+ .sheet(isPresented: $chargerLibraryVisibility) {
|
|
| 56 |
+ ChargedDeviceLibrarySheetView( |
|
| 57 |
+ visibility: $chargerLibraryVisibility, |
|
| 58 |
+ meterMACAddress: selectedMeterSummary?.macAddress ?? "", |
|
| 59 |
+ meterTint: selectedMeter?.color ?? .orange, |
|
| 60 |
+ mode: .charger |
|
| 61 |
+ ) |
|
| 62 |
+ .environmentObject(appData) |
|
| 63 |
+ } |
|
| 64 |
+ .confirmationDialog( |
|
| 65 |
+ "Discard the current standby measurement?", |
|
| 66 |
+ isPresented: $discardConfirmationVisibility, |
|
| 67 |
+ titleVisibility: .visible |
|
| 68 |
+ ) {
|
|
| 69 |
+ Button("Discard", role: .destructive) {
|
|
| 70 |
+ if let activeSession {
|
|
| 71 |
+ _ = appData.finishChargerStandbyMeasurement(for: activeSession.meterMACAddress, save: false) |
|
| 72 |
+ } |
|
| 73 |
+ } |
|
| 74 |
+ Button("Cancel", role: .cancel) {}
|
|
| 75 |
+ } message: {
|
|
| 76 |
+ Text("The current sample set will be removed and nothing will be saved for this charger.")
|
|
| 77 |
+ } |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 80 |
+ private var liveMeterSummaries: [AppData.MeterSummary] {
|
|
| 81 |
+ appData.meterSummaries.filter { $0.meter != nil }
|
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 84 |
+ private var availableChargers: [ChargedDeviceSummary] {
|
|
| 85 |
+ appData.chargerSummaries |
|
| 86 |
+ } |
|
| 87 |
+ |
|
| 88 |
+ private var preferredChargerMeterMACAddress: String? {
|
|
| 89 |
+ preferredChargerID.flatMap { appData.chargedDeviceSummary(id: $0)?.lastAssociatedMeterMAC }
|
|
| 90 |
+ } |
|
| 91 |
+ |
|
| 92 |
+ private var activeSession: ChargerStandbyPowerMonitorSession? {
|
|
| 93 |
+ let candidateMACAddresses = [ |
|
| 94 |
+ selectedMeterMACAddress ?? "", |
|
| 95 |
+ preferredMeterMACAddress ?? "", |
|
| 96 |
+ preferredChargerMeterMACAddress ?? "" |
|
| 97 |
+ ] |
|
| 98 |
+ .filter { $0.isEmpty == false }
|
|
| 99 |
+ |
|
| 100 |
+ for macAddress in candidateMACAddresses {
|
|
| 101 |
+ if let session = appData.chargerStandbyMeasurementSession(for: macAddress) {
|
|
| 102 |
+ return session |
|
| 103 |
+ } |
|
| 104 |
+ } |
|
| 105 |
+ |
|
| 106 |
+ for meterSummary in liveMeterSummaries {
|
|
| 107 |
+ if let session = appData.chargerStandbyMeasurementSession(for: meterSummary.macAddress) {
|
|
| 108 |
+ return session |
|
| 109 |
+ } |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ return nil |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ private var suggestedMeterSummary: AppData.MeterSummary? {
|
|
| 116 |
+ if let preferredMeterMACAddress {
|
|
| 117 |
+ return liveMeterSummaries.first(where: { $0.macAddress == preferredMeterMACAddress })
|
|
| 118 |
+ } |
|
| 119 |
+ |
|
| 120 |
+ if let preferredChargerMeterMACAddress {
|
|
| 121 |
+ return liveMeterSummaries.first(where: { $0.macAddress == preferredChargerMeterMACAddress })
|
|
| 122 |
+ } |
|
| 123 |
+ |
|
| 124 |
+ return liveMeterSummaries.count == 1 ? liveMeterSummaries.first : nil |
|
| 125 |
+ } |
|
| 126 |
+ |
|
| 127 |
+ private var selectedMeterSummary: AppData.MeterSummary? {
|
|
| 128 |
+ if let activeSession {
|
|
| 129 |
+ return liveMeterSummaries.first(where: { $0.macAddress == activeSession.meterMACAddress })
|
|
| 130 |
+ } |
|
| 131 |
+ |
|
| 132 |
+ guard let selectedMeterMACAddress else {
|
|
| 133 |
+ return nil |
|
| 134 |
+ } |
|
| 135 |
+ |
|
| 136 |
+ return liveMeterSummaries.first(where: { $0.macAddress == selectedMeterMACAddress })
|
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 139 |
+ private var selectedMeter: Meter? {
|
|
| 140 |
+ selectedMeterSummary?.meter |
|
| 141 |
+ } |
|
| 142 |
+ |
|
| 143 |
+ private var isChargerSelectionLocked: Bool {
|
|
| 144 |
+ locksChargerSelection || preferredChargerID != nil |
|
| 145 |
+ } |
|
| 146 |
+ |
|
| 147 |
+ private var meterSelectionBinding: Binding<String?> {
|
|
| 148 |
+ Binding( |
|
| 149 |
+ get: { selectedMeterMACAddress },
|
|
| 150 |
+ set: { selectedMeterMACAddress = $0 }
|
|
| 151 |
+ ) |
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ private var selectedCharger: ChargedDeviceSummary? {
|
|
| 155 |
+ if let activeSession {
|
|
| 156 |
+ return appData.chargedDeviceSummary(id: activeSession.chargerID) |
|
| 157 |
+ } |
|
| 158 |
+ |
|
| 159 |
+ guard let selectedChargerID else {
|
|
| 160 |
+ return nil |
|
| 161 |
+ } |
|
| 162 |
+ |
|
| 163 |
+ return appData.chargedDeviceSummary(id: selectedChargerID) |
|
| 164 |
+ } |
|
| 165 |
+ |
|
| 166 |
+ private var chargerSelectionBinding: Binding<UUID?> {
|
|
| 167 |
+ Binding( |
|
| 168 |
+ get: { selectedChargerID },
|
|
| 169 |
+ set: { selectedChargerID = $0 }
|
|
| 170 |
+ ) |
|
| 171 |
+ } |
|
| 172 |
+ |
|
| 173 |
+ private var preferredChargerSummary: ChargedDeviceSummary? {
|
|
| 174 |
+ guard let preferredChargerID else {
|
|
| 175 |
+ return nil |
|
| 176 |
+ } |
|
| 177 |
+ return appData.chargedDeviceSummary(id: preferredChargerID) |
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ private var navigationTitleText: String {
|
|
| 181 |
+ "New Standby Consumption Measurement" |
|
| 182 |
+ } |
|
| 183 |
+ |
|
| 184 |
+ private var wizardCardTitle: String {
|
|
| 185 |
+ if let selectedMeterSummary {
|
|
| 186 |
+ return selectedMeterSummary.displayName |
|
| 187 |
+ } |
|
| 188 |
+ |
|
| 189 |
+ if let suggestedMeterSummary {
|
|
| 190 |
+ return suggestedMeterSummary.displayName |
|
| 191 |
+ } |
|
| 192 |
+ |
|
| 193 |
+ return "Use Meter" |
|
| 194 |
+ } |
|
| 195 |
+ |
|
| 196 |
+ private var newMeasurementWizardCard: some View {
|
|
| 197 |
+ MeterInfoCardView( |
|
| 198 |
+ title: wizardCardTitle, |
|
| 199 |
+ tint: .orange |
|
| 200 |
+ ) {
|
|
| 201 |
+ if liveMeterSummaries.isEmpty {
|
|
| 202 |
+ Text("Connect a live meter first. Standby measurement uses a live feed, so meter selection happens here in the wizard.")
|
|
| 203 |
+ .font(.footnote) |
|
| 204 |
+ .foregroundColor(.secondary) |
|
| 205 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 206 |
+ } else {
|
|
| 207 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 208 |
+ if isChargerSelectionLocked == false {
|
|
| 209 |
+ HStack(spacing: 8) {
|
|
| 210 |
+ Text("Charger")
|
|
| 211 |
+ .font(.subheadline.weight(.semibold)) |
|
| 212 |
+ ContextInfoButton( |
|
| 213 |
+ title: "Charger", |
|
| 214 |
+ message: "Choose the charger whose standby consumption you want to measure in this run." |
|
| 215 |
+ ) |
|
| 216 |
+ } |
|
| 217 |
+ |
|
| 218 |
+ if availableChargers.isEmpty {
|
|
| 219 |
+ Text("No charger available yet. Open the charger library to create one first.")
|
|
| 220 |
+ .font(.caption) |
|
| 221 |
+ .foregroundColor(.secondary) |
|
| 222 |
+ } else {
|
|
| 223 |
+ Picker("Charger", selection: chargerSelectionBinding) {
|
|
| 224 |
+ Text("Select Charger").tag(Optional<UUID>.none)
|
|
| 225 |
+ ForEach(availableChargers) { charger in
|
|
| 226 |
+ Text(charger.name).tag(Optional(charger.id)) |
|
| 227 |
+ } |
|
| 228 |
+ } |
|
| 229 |
+ .pickerStyle(.menu) |
|
| 230 |
+ } |
|
| 231 |
+ } |
|
| 232 |
+ |
|
| 233 |
+ HStack(spacing: 8) {
|
|
| 234 |
+ Text("Use Meter")
|
|
| 235 |
+ .font(.subheadline.weight(.semibold)) |
|
| 236 |
+ ContextInfoButton( |
|
| 237 |
+ title: "Use Meter", |
|
| 238 |
+ message: "Choose the live meter explicitly. Standby consumption can vary with the upstream source or when the charger is connected to a computer, so re-run after setup changes." |
|
| 239 |
+ ) |
|
| 240 |
+ } |
|
| 241 |
+ |
|
| 242 |
+ Picker("Use Meter", selection: meterSelectionBinding) {
|
|
| 243 |
+ Text("Select Meter").tag(Optional<String>.none)
|
|
| 244 |
+ ForEach(liveMeterSummaries) { meterSummary in
|
|
| 245 |
+ Text(meterSummary.displayName).tag(Optional(meterSummary.macAddress)) |
|
| 246 |
+ } |
|
| 247 |
+ } |
|
| 248 |
+ .pickerStyle(.menu) |
|
| 249 |
+ .disabled(activeSession != nil) |
|
| 250 |
+ |
|
| 251 |
+ if activeSession == nil, let suggestedMeterSummary, selectedMeterSummary == nil {
|
|
| 252 |
+ Text("Suggested from the current context: \(suggestedMeterSummary.displayName). Select it explicitly if this is the meter you want to use.")
|
|
| 253 |
+ .font(.caption) |
|
| 254 |
+ .foregroundColor(.secondary) |
|
| 255 |
+ } |
|
| 256 |
+ |
|
| 257 |
+ HStack(spacing: 12) {
|
|
| 258 |
+ if isChargerSelectionLocked == false {
|
|
| 259 |
+ Button("Manage Charger Library") {
|
|
| 260 |
+ chargerLibraryVisibility = true |
|
| 261 |
+ } |
|
| 262 |
+ .disabled(selectedMeter == nil) |
|
| 263 |
+ } |
|
| 264 |
+ |
|
| 265 |
+ Button("Start Measurement") {
|
|
| 266 |
+ startMeasurement() |
|
| 267 |
+ } |
|
| 268 |
+ .disabled(selectedCharger == nil || selectedMeter == nil) |
|
| 269 |
+ } |
|
| 270 |
+ .buttonStyle(.borderedProminent) |
|
| 271 |
+ } |
|
| 272 |
+ } |
|
| 273 |
+ |
|
| 274 |
+ if selectedMeter == nil {
|
|
| 275 |
+ Text("Choose the live meter explicitly before starting. The wizard no longer auto-confirms a suggested meter.")
|
|
| 276 |
+ .font(.caption) |
|
| 277 |
+ .foregroundColor(.secondary) |
|
| 278 |
+ } else if activeSession == nil, selectedCharger == nil {
|
|
| 279 |
+ Text("Select the charger you want to measure, then start the run.")
|
|
| 280 |
+ .font(.caption) |
|
| 281 |
+ .foregroundColor(.secondary) |
|
| 282 |
+ } else if activeSession == nil, selectedMeter?.operationalState != .dataIsAvailable {
|
|
| 283 |
+ Text("The wizard can start now, but samples will only be captured while live meter data is available.")
|
|
| 284 |
+ .font(.caption) |
|
| 285 |
+ .foregroundColor(.secondary) |
|
| 286 |
+ } else if let activeSession {
|
|
| 287 |
+ Text( |
|
| 288 |
+ "\(activeSession.readinessDescription) • \(formattedDuration(Date().timeIntervalSince(activeSession.startedAt))) • \(activeSession.sampleCount) samples" |
|
| 289 |
+ ) |
|
| 290 |
+ .font(.caption) |
|
| 291 |
+ .foregroundColor(.secondary) |
|
| 292 |
+ } |
|
| 293 |
+ } |
|
| 294 |
+ } |
|
| 295 |
+ |
|
| 296 |
+ private func activeMeasurementCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
|
|
| 297 |
+ MeterInfoCardView( |
|
| 298 |
+ title: "Measurement Running", |
|
| 299 |
+ infoMessage: "The run keeps collecting samples while this meter stays live. Save when you are happy with the sample set, or discard to cancel it.", |
|
| 300 |
+ tint: .orange |
|
| 301 |
+ ) {
|
|
| 302 |
+ MeterInfoRowView(label: "Meter", value: selectedMeterSummary?.displayName ?? session.meterMACAddress) |
|
| 303 |
+ MeterInfoRowView(label: "Charger", value: selectedCharger?.name ?? "Selected charger") |
|
| 304 |
+ MeterInfoRowView(label: "Status", value: session.readinessDescription) |
|
| 305 |
+ MeterInfoRowView(label: "Samples", value: "\(session.sampleCount)") |
|
| 306 |
+ |
|
| 307 |
+ HStack(spacing: 12) {
|
|
| 308 |
+ Button("Save Result") {
|
|
| 309 |
+ _ = appData.finishChargerStandbyMeasurement(for: session.meterMACAddress, save: true) |
|
| 310 |
+ } |
|
| 311 |
+ .disabled(session.hasSamples == false) |
|
| 312 |
+ |
|
| 313 |
+ Button("Discard") {
|
|
| 314 |
+ discardConfirmationVisibility = true |
|
| 315 |
+ } |
|
| 316 |
+ .foregroundColor(.red) |
|
| 317 |
+ } |
|
| 318 |
+ .buttonStyle(.borderedProminent) |
|
| 319 |
+ } |
|
| 320 |
+ } |
|
| 321 |
+ |
|
| 322 |
+ private func liveSessionCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
|
|
| 323 |
+ VStack(spacing: 18) {
|
|
| 324 |
+ if let statistics = session.statistics {
|
|
| 325 |
+ stabilityCard( |
|
| 326 |
+ isStable: statistics.isStable, |
|
| 327 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 328 |
+ stabilityDeltaWatts: statistics.stabilityDeltaWatts, |
|
| 329 |
+ stabilityToleranceWatts: statistics.stabilityToleranceWatts, |
|
| 330 |
+ sampleCount: statistics.sampleCount |
|
| 331 |
+ ) |
|
| 332 |
+ |
|
| 333 |
+ projectionCard( |
|
| 334 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 335 |
+ projectedDailyEnergyWh: statistics.projectedDailyEnergyWh, |
|
| 336 |
+ projectedWeeklyEnergyWh: statistics.projectedWeeklyEnergyWh, |
|
| 337 |
+ projectedMonthlyEnergyWh: statistics.projectedMonthlyEnergyWh, |
|
| 338 |
+ projectedYearlyEnergyWh: statistics.projectedYearlyEnergyWh |
|
| 339 |
+ ) |
|
| 340 |
+ |
|
| 341 |
+ distributionCard( |
|
| 342 |
+ histogram: statistics.histogram, |
|
| 343 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 344 |
+ standardDeviationPowerWatts: statistics.standardDeviationPowerWatts, |
|
| 345 |
+ tint: .orange |
|
| 346 |
+ ) |
|
| 347 |
+ |
|
| 348 |
+ statisticsCard( |
|
| 349 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 350 |
+ medianPowerWatts: statistics.medianPowerWatts, |
|
| 351 |
+ minimumPowerWatts: statistics.minimumPowerWatts, |
|
| 352 |
+ maximumPowerWatts: statistics.maximumPowerWatts, |
|
| 353 |
+ standardDeviationPowerWatts: statistics.standardDeviationPowerWatts, |
|
| 354 |
+ coefficientOfVariation: statistics.coefficientOfVariation, |
|
| 355 |
+ averageCurrentAmps: statistics.averageCurrentAmps, |
|
| 356 |
+ averageVoltageVolts: statistics.averageVoltageVolts |
|
| 357 |
+ ) |
|
| 358 |
+ } else {
|
|
| 359 |
+ MeterInfoCardView(title: "Live Stats", tint: .orange) {
|
|
| 360 |
+ Text("Waiting for the first valid power samples from the meter.")
|
|
| 361 |
+ .font(.footnote) |
|
| 362 |
+ .foregroundColor(.secondary) |
|
| 363 |
+ } |
|
| 364 |
+ } |
|
| 365 |
+ } |
|
| 366 |
+ } |
|
| 367 |
+ |
|
| 368 |
+ private func stabilityCard( |
|
| 369 |
+ isStable: Bool, |
|
| 370 |
+ averagePowerWatts: Double, |
|
| 371 |
+ stabilityDeltaWatts: Double, |
|
| 372 |
+ stabilityToleranceWatts: Double, |
|
| 373 |
+ sampleCount: Int, |
|
| 374 |
+ subtitle: String? = nil |
|
| 375 |
+ ) -> some View {
|
|
| 376 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 377 |
+ HStack {
|
|
| 378 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 379 |
+ Text(isStable ? "Enough Samples" : "Still Settling") |
|
| 380 |
+ .font(.headline) |
|
| 381 |
+ Text(subtitle ?? (isStable ? "The running average has stabilised." : "The wizard is still watching the average drift.")) |
|
| 382 |
+ .font(.caption) |
|
| 383 |
+ .foregroundColor(.secondary) |
|
| 384 |
+ } |
|
| 385 |
+ |
|
| 386 |
+ Spacer() |
|
| 387 |
+ |
|
| 388 |
+ Text(isStable ? "Ready" : "Live") |
|
| 389 |
+ .font(.caption.weight(.semibold)) |
|
| 390 |
+ .padding(.horizontal, 10) |
|
| 391 |
+ .padding(.vertical, 6) |
|
| 392 |
+ .foregroundColor(isStable ? .green : .orange) |
|
| 393 |
+ .meterCard( |
|
| 394 |
+ tint: isStable ? .green : .orange, |
|
| 395 |
+ fillOpacity: 0.10, |
|
| 396 |
+ strokeOpacity: 0.16, |
|
| 397 |
+ cornerRadius: 999 |
|
| 398 |
+ ) |
|
| 399 |
+ } |
|
| 400 |
+ |
|
| 401 |
+ Text("\(averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 402 |
+ .font(.system(.largeTitle, design: .rounded).weight(.bold)) |
|
| 403 |
+ .monospacedDigit() |
|
| 404 |
+ |
|
| 405 |
+ Text( |
|
| 406 |
+ "Recent drift: \((stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(sampleCount) samples." |
|
| 407 |
+ ) |
|
| 408 |
+ .font(.footnote) |
|
| 409 |
+ .foregroundColor(.secondary) |
|
| 410 |
+ } |
|
| 411 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 412 |
+ .padding(18) |
|
| 413 |
+ .meterCard(tint: isStable ? .green : .orange, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 414 |
+ } |
|
| 415 |
+ |
|
| 416 |
+ private func projectionCard( |
|
| 417 |
+ averagePowerWatts: Double, |
|
| 418 |
+ projectedDailyEnergyWh: Double, |
|
| 419 |
+ projectedWeeklyEnergyWh: Double, |
|
| 420 |
+ projectedMonthlyEnergyWh: Double, |
|
| 421 |
+ projectedYearlyEnergyWh: Double |
|
| 422 |
+ ) -> some View {
|
|
| 423 |
+ MeterInfoCardView( |
|
| 424 |
+ title: "Consumption Projection", |
|
| 425 |
+ infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.", |
|
| 426 |
+ tint: .teal |
|
| 427 |
+ ) {
|
|
| 428 |
+ MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W") |
|
| 429 |
+ MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(projectedDailyEnergyWh)) |
|
| 430 |
+ MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(projectedWeeklyEnergyWh)) |
|
| 431 |
+ MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(projectedMonthlyEnergyWh)) |
|
| 432 |
+ MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(projectedYearlyEnergyWh)) |
|
| 433 |
+ } |
|
| 434 |
+ } |
|
| 435 |
+ |
|
| 436 |
+ private func distributionCard( |
|
| 437 |
+ histogram: [ChargerStandbyPowerDistributionBin], |
|
| 438 |
+ averagePowerWatts: Double, |
|
| 439 |
+ standardDeviationPowerWatts: Double, |
|
| 440 |
+ tint: Color |
|
| 441 |
+ ) -> some View {
|
|
| 442 |
+ MeterInfoCardView( |
|
| 443 |
+ title: "Distribution", |
|
| 444 |
+ infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.", |
|
| 445 |
+ tint: tint |
|
| 446 |
+ ) {
|
|
| 447 |
+ StandbyPowerHistogramView( |
|
| 448 |
+ histogram: histogram, |
|
| 449 |
+ averagePowerWatts: averagePowerWatts, |
|
| 450 |
+ standardDeviationPowerWatts: standardDeviationPowerWatts, |
|
| 451 |
+ tint: tint |
|
| 452 |
+ ) |
|
| 453 |
+ .frame(height: 220) |
|
| 454 |
+ |
|
| 455 |
+ if let firstBin = histogram.first, let lastBin = histogram.last {
|
|
| 456 |
+ HStack {
|
|
| 457 |
+ Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
|
|
| 458 |
+ Spacer() |
|
| 459 |
+ Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
|
|
| 460 |
+ } |
|
| 461 |
+ .font(.caption) |
|
| 462 |
+ .foregroundColor(.secondary) |
|
| 463 |
+ .monospacedDigit() |
|
| 464 |
+ } |
|
| 465 |
+ } |
|
| 466 |
+ } |
|
| 467 |
+ |
|
| 468 |
+ private func statisticsCard( |
|
| 469 |
+ averagePowerWatts: Double, |
|
| 470 |
+ medianPowerWatts: Double, |
|
| 471 |
+ minimumPowerWatts: Double, |
|
| 472 |
+ maximumPowerWatts: Double, |
|
| 473 |
+ standardDeviationPowerWatts: Double, |
|
| 474 |
+ coefficientOfVariation: Double, |
|
| 475 |
+ averageCurrentAmps: Double, |
|
| 476 |
+ averageVoltageVolts: Double |
|
| 477 |
+ ) -> some View {
|
|
| 478 |
+ MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
|
|
| 479 |
+ MeterInfoRowView(label: "Median", value: "\(medianPowerWatts.format(decimalDigits: 3)) W") |
|
| 480 |
+ MeterInfoRowView(label: "Minimum", value: "\(minimumPowerWatts.format(decimalDigits: 3)) W") |
|
| 481 |
+ MeterInfoRowView(label: "Maximum", value: "\(maximumPowerWatts.format(decimalDigits: 3)) W") |
|
| 482 |
+ MeterInfoRowView(label: "Spread σ", value: "\(standardDeviationPowerWatts.format(decimalDigits: 4)) W") |
|
| 483 |
+ MeterInfoRowView(label: "Variation", value: "\(Int((coefficientOfVariation * 100).rounded()))%") |
|
| 484 |
+ MeterInfoRowView(label: "Mean Current", value: "\(averageCurrentAmps.format(decimalDigits: 3)) A") |
|
| 485 |
+ MeterInfoRowView(label: "Mean Voltage", value: "\(averageVoltageVolts.format(decimalDigits: 3)) V") |
|
| 486 |
+ MeterInfoRowView(label: "Power Density", value: "\(averagePowerWatts.format(decimalDigits: 3)) W steady") |
|
| 487 |
+ } |
|
| 488 |
+ } |
|
| 489 |
+ |
|
| 490 |
+ private func startMeasurement() {
|
|
| 491 |
+ guard let selectedCharger, let selectedMeter else {
|
|
| 492 |
+ return |
|
| 493 |
+ } |
|
| 494 |
+ |
|
| 495 |
+ _ = appData.startChargerStandbyMeasurement(for: selectedCharger.id, on: selectedMeter) |
|
| 496 |
+ } |
|
| 497 |
+ |
|
| 498 |
+ private func standbyEnergyLabel(_ wattHours: Double) -> String {
|
|
| 499 |
+ if wattHours >= 1000 {
|
|
| 500 |
+ return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 501 |
+ } |
|
| 502 |
+ return "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 503 |
+ } |
|
| 504 |
+ |
|
| 505 |
+ private func formattedDuration(_ duration: TimeInterval) -> String {
|
|
| 506 |
+ let formatter = DateComponentsFormatter() |
|
| 507 |
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second] |
|
| 508 |
+ formatter.unitsStyle = .abbreviated |
|
| 509 |
+ formatter.zeroFormattingBehavior = .pad |
|
| 510 |
+ return formatter.string(from: max(duration, 0)) ?? "0s" |
|
| 511 |
+ } |
|
| 512 |
+ |
|
| 513 |
+} |
|
| 514 |
+ |
|
| 515 |
+private struct StandbyPowerHistogramView: View {
|
|
| 516 |
+ let histogram: [ChargerStandbyPowerDistributionBin] |
|
| 517 |
+ let averagePowerWatts: Double |
|
| 518 |
+ let standardDeviationPowerWatts: Double |
|
| 519 |
+ let tint: Color |
|
| 520 |
+ |
|
| 521 |
+ var body: some View {
|
|
| 522 |
+ GeometryReader { proxy in
|
|
| 523 |
+ let maxCount = max(Double(histogram.map(\.count).max() ?? 1), 1) |
|
| 524 |
+ |
|
| 525 |
+ ZStack {
|
|
| 526 |
+ HStack(alignment: .bottom, spacing: 6) {
|
|
| 527 |
+ ForEach(histogram) { bin in
|
|
| 528 |
+ RoundedRectangle(cornerRadius: 8, style: .continuous) |
|
| 529 |
+ .fill(tint.opacity(0.24)) |
|
| 530 |
+ .overlay( |
|
| 531 |
+ RoundedRectangle(cornerRadius: 8, style: .continuous) |
|
| 532 |
+ .stroke(tint.opacity(0.22), lineWidth: 1) |
|
| 533 |
+ ) |
|
| 534 |
+ .frame(height: max(10, (Double(bin.count) / maxCount) * proxy.size.height)) |
|
| 535 |
+ } |
|
| 536 |
+ } |
|
| 537 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) |
|
| 538 |
+ |
|
| 539 |
+ gaussianCurve(in: proxy.size) |
|
| 540 |
+ .stroke(tint, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) |
|
| 541 |
+ |
|
| 542 |
+ meanMarker(in: proxy.size) |
|
| 543 |
+ .stroke(tint.opacity(0.9), style: StrokeStyle(lineWidth: 1.5, dash: [6, 4])) |
|
| 544 |
+ } |
|
| 545 |
+ } |
|
| 546 |
+ } |
|
| 547 |
+ |
|
| 548 |
+ private func gaussianCurve(in size: CGSize) -> Path {
|
|
| 549 |
+ guard histogram.count > 1, |
|
| 550 |
+ standardDeviationPowerWatts > 0, |
|
| 551 |
+ let firstBin = histogram.first, |
|
| 552 |
+ let lastBin = histogram.last else {
|
|
| 553 |
+ return Path() |
|
| 554 |
+ } |
|
| 555 |
+ |
|
| 556 |
+ let minimum = firstBin.lowerBoundWatts |
|
| 557 |
+ let maximum = lastBin.upperBoundWatts |
|
| 558 |
+ let span = max(maximum - minimum, 0.000_001) |
|
| 559 |
+ let sampleCount = 48 |
|
| 560 |
+ let peakDensity = 1 / (standardDeviationPowerWatts * sqrt(2 * .pi)) |
|
| 561 |
+ |
|
| 562 |
+ return Path { path in
|
|
| 563 |
+ for index in 0...sampleCount {
|
|
| 564 |
+ let progress = Double(index) / Double(sampleCount) |
|
| 565 |
+ let value = minimum + (span * progress) |
|
| 566 |
+ let zScore = (value - averagePowerWatts) / standardDeviationPowerWatts |
|
| 567 |
+ let density = exp(-0.5 * zScore * zScore) / (standardDeviationPowerWatts * sqrt(2 * .pi)) |
|
| 568 |
+ let normalizedHeight = density / peakDensity |
|
| 569 |
+ |
|
| 570 |
+ let x = progress * size.width |
|
| 571 |
+ let y = size.height - (normalizedHeight * (Double(size.height) * 0.92)) |
|
| 572 |
+ let point = CGPoint(x: x, y: y) |
|
| 573 |
+ |
|
| 574 |
+ if index == 0 {
|
|
| 575 |
+ path.move(to: point) |
|
| 576 |
+ } else {
|
|
| 577 |
+ path.addLine(to: point) |
|
| 578 |
+ } |
|
| 579 |
+ } |
|
| 580 |
+ } |
|
| 581 |
+ } |
|
| 582 |
+ |
|
| 583 |
+ private func meanMarker(in size: CGSize) -> Path {
|
|
| 584 |
+ guard let firstBin = histogram.first, let lastBin = histogram.last else {
|
|
| 585 |
+ return Path() |
|
| 586 |
+ } |
|
| 587 |
+ |
|
| 588 |
+ let minimum = firstBin.lowerBoundWatts |
|
| 589 |
+ let maximum = lastBin.upperBoundWatts |
|
| 590 |
+ let span = max(maximum - minimum, 0.000_001) |
|
| 591 |
+ let normalizedX = min(max((averagePowerWatts - minimum) / span, 0), 1) |
|
| 592 |
+ let x = normalizedX * size.width |
|
| 593 |
+ |
|
| 594 |
+ return Path { path in
|
|
| 595 |
+ path.move(to: CGPoint(x: x, y: 0)) |
|
| 596 |
+ path.addLine(to: CGPoint(x: x, y: size.height)) |
|
| 597 |
+ } |
|
| 598 |
+ } |
|
| 599 |
+} |
|
| 600 |
+ |
|
| 601 |
+struct ChargerStandbyPowerMeasurementsView: View {
|
|
| 602 |
+ @EnvironmentObject private var appData: AppData |
|
| 603 |
+ |
|
| 604 |
+ let chargerID: UUID |
|
| 605 |
+ |
|
| 606 |
+ var body: some View {
|
|
| 607 |
+ Group {
|
|
| 608 |
+ if let charger = appData.chargedDeviceSummary(id: chargerID) {
|
|
| 609 |
+ List {
|
|
| 610 |
+ if charger.standbyPowerMeasurements.isEmpty {
|
|
| 611 |
+ Text("No standby measurements saved yet.")
|
|
| 612 |
+ .foregroundColor(.secondary) |
|
| 613 |
+ } else {
|
|
| 614 |
+ ForEach(charger.standbyPowerMeasurements) { measurement in
|
|
| 615 |
+ NavigationLink( |
|
| 616 |
+ destination: ChargerStandbyPowerMeasurementDetailView( |
|
| 617 |
+ chargerID: charger.id, |
|
| 618 |
+ measurementID: measurement.id |
|
| 619 |
+ ) |
|
| 620 |
+ ) {
|
|
| 621 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 622 |
+ HStack {
|
|
| 623 |
+ Text(measurement.endedAt.format()) |
|
| 624 |
+ .font(.subheadline.weight(.semibold)) |
|
| 625 |
+ Spacer() |
|
| 626 |
+ Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 627 |
+ .font(.subheadline.weight(.bold)) |
|
| 628 |
+ .monospacedDigit() |
|
| 629 |
+ } |
|
| 630 |
+ |
|
| 631 |
+ Text( |
|
| 632 |
+ "\(formattedDuration(measurement.duration)) • \(measurement.sampleCount) samples • \(standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) / year" |
|
| 633 |
+ ) |
|
| 634 |
+ .font(.caption) |
|
| 635 |
+ .foregroundColor(.secondary) |
|
| 636 |
+ } |
|
| 637 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 638 |
+ } |
|
| 639 |
+ } |
|
| 640 |
+ } |
|
| 641 |
+ } |
|
| 642 |
+ .navigationTitle("Saved Measurements")
|
|
| 643 |
+ } else {
|
|
| 644 |
+ Text("This charger is no longer available.")
|
|
| 645 |
+ .foregroundColor(.secondary) |
|
| 646 |
+ .navigationTitle("Saved Measurements")
|
|
| 647 |
+ } |
|
| 648 |
+ } |
|
| 649 |
+ } |
|
| 650 |
+ |
|
| 651 |
+ private func standbyEnergyLabel(_ wattHours: Double) -> String {
|
|
| 652 |
+ if wattHours >= 1000 {
|
|
| 653 |
+ return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 654 |
+ } |
|
| 655 |
+ return "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 656 |
+ } |
|
| 657 |
+ |
|
| 658 |
+ private func formattedDuration(_ duration: TimeInterval) -> String {
|
|
| 659 |
+ let formatter = DateComponentsFormatter() |
|
| 660 |
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second] |
|
| 661 |
+ formatter.unitsStyle = .abbreviated |
|
| 662 |
+ formatter.zeroFormattingBehavior = .pad |
|
| 663 |
+ return formatter.string(from: max(duration, 0)) ?? "0s" |
|
| 664 |
+ } |
|
| 665 |
+} |
|
| 666 |
+ |
|
| 667 |
+struct ChargerStandbyPowerMeasurementDetailView: View {
|
|
| 668 |
+ @EnvironmentObject private var appData: AppData |
|
| 669 |
+ |
|
| 670 |
+ let chargerID: UUID |
|
| 671 |
+ let measurementID: UUID |
|
| 672 |
+ |
|
| 673 |
+ var body: some View {
|
|
| 674 |
+ Group {
|
|
| 675 |
+ if let charger = appData.chargedDeviceSummary(id: chargerID), |
|
| 676 |
+ let measurement = charger.standbyPowerMeasurements.first(where: { $0.id == measurementID }) {
|
|
| 677 |
+ ScrollView {
|
|
| 678 |
+ VStack(spacing: 18) {
|
|
| 679 |
+ MeterInfoCardView(title: charger.name, tint: .orange) {
|
|
| 680 |
+ MeterInfoRowView(label: "Saved", value: measurement.endedAt.format()) |
|
| 681 |
+ MeterInfoRowView(label: "Average", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W") |
|
| 682 |
+ MeterInfoRowView(label: "Samples", value: "\(measurement.sampleCount)") |
|
| 683 |
+ MeterInfoRowView(label: "Duration", value: formattedDuration(measurement.duration)) |
|
| 684 |
+ } |
|
| 685 |
+ |
|
| 686 |
+ ChargerStandbyPowerMeasurementSnapshotView(measurement: measurement) |
|
| 687 |
+ } |
|
| 688 |
+ .padding() |
|
| 689 |
+ } |
|
| 690 |
+ .background( |
|
| 691 |
+ LinearGradient( |
|
| 692 |
+ colors: [.orange.opacity(0.16), Color.clear], |
|
| 693 |
+ startPoint: .topLeading, |
|
| 694 |
+ endPoint: .bottomTrailing |
|
| 695 |
+ ) |
|
| 696 |
+ .ignoresSafeArea() |
|
| 697 |
+ ) |
|
| 698 |
+ .navigationTitle("Measurement")
|
|
| 699 |
+ } else {
|
|
| 700 |
+ Text("This measurement is no longer available.")
|
|
| 701 |
+ .foregroundColor(.secondary) |
|
| 702 |
+ .navigationTitle("Measurement")
|
|
| 703 |
+ } |
|
| 704 |
+ } |
|
| 705 |
+ } |
|
| 706 |
+ |
|
| 707 |
+ private func formattedDuration(_ duration: TimeInterval) -> String {
|
|
| 708 |
+ let formatter = DateComponentsFormatter() |
|
| 709 |
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second] |
|
| 710 |
+ formatter.unitsStyle = .abbreviated |
|
| 711 |
+ formatter.zeroFormattingBehavior = .pad |
|
| 712 |
+ return formatter.string(from: max(duration, 0)) ?? "0s" |
|
| 713 |
+ } |
|
| 714 |
+} |
|
| 715 |
+ |
|
| 716 |
+private struct ChargerStandbyPowerMeasurementSnapshotView: View {
|
|
| 717 |
+ let measurement: ChargerStandbyPowerMeasurementSummary |
|
| 718 |
+ |
|
| 719 |
+ var body: some View {
|
|
| 720 |
+ VStack(spacing: 18) {
|
|
| 721 |
+ stabilityCard |
|
| 722 |
+ projectionCard |
|
| 723 |
+ distributionCard |
|
| 724 |
+ statisticsCard |
|
| 725 |
+ } |
|
| 726 |
+ } |
|
| 727 |
+ |
|
| 728 |
+ private var stabilityCard: some View {
|
|
| 729 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 730 |
+ HStack {
|
|
| 731 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 732 |
+ Text(measurement.isStable ? "Enough Samples" : "Still Settling") |
|
| 733 |
+ .font(.headline) |
|
| 734 |
+ Text("Saved \(measurement.endedAt.format())")
|
|
| 735 |
+ .font(.caption) |
|
| 736 |
+ .foregroundColor(.secondary) |
|
| 737 |
+ } |
|
| 738 |
+ |
|
| 739 |
+ Spacer() |
|
| 740 |
+ |
|
| 741 |
+ Text(measurement.isStable ? "Ready" : "Live") |
|
| 742 |
+ .font(.caption.weight(.semibold)) |
|
| 743 |
+ .padding(.horizontal, 10) |
|
| 744 |
+ .padding(.vertical, 6) |
|
| 745 |
+ .foregroundColor(measurement.isStable ? .green : .orange) |
|
| 746 |
+ .meterCard( |
|
| 747 |
+ tint: measurement.isStable ? .green : .orange, |
|
| 748 |
+ fillOpacity: 0.10, |
|
| 749 |
+ strokeOpacity: 0.16, |
|
| 750 |
+ cornerRadius: 999 |
|
| 751 |
+ ) |
|
| 752 |
+ } |
|
| 753 |
+ |
|
| 754 |
+ Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 755 |
+ .font(.system(.largeTitle, design: .rounded).weight(.bold)) |
|
| 756 |
+ .monospacedDigit() |
|
| 757 |
+ |
|
| 758 |
+ Text( |
|
| 759 |
+ "Recent drift: \((measurement.stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((measurement.stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(measurement.sampleCount) samples." |
|
| 760 |
+ ) |
|
| 761 |
+ .font(.footnote) |
|
| 762 |
+ .foregroundColor(.secondary) |
|
| 763 |
+ } |
|
| 764 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 765 |
+ .padding(18) |
|
| 766 |
+ .meterCard( |
|
| 767 |
+ tint: measurement.isStable ? .green : .orange, |
|
| 768 |
+ fillOpacity: 0.18, |
|
| 769 |
+ strokeOpacity: 0.24 |
|
| 770 |
+ ) |
|
| 771 |
+ } |
|
| 772 |
+ |
|
| 773 |
+ private var projectionCard: some View {
|
|
| 774 |
+ MeterInfoCardView( |
|
| 775 |
+ title: "Consumption Projection", |
|
| 776 |
+ infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.", |
|
| 777 |
+ tint: .teal |
|
| 778 |
+ ) {
|
|
| 779 |
+ MeterInfoRowView(label: "Average Power", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W") |
|
| 780 |
+ MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(measurement.projectedDailyEnergyWh)) |
|
| 781 |
+ MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(measurement.projectedWeeklyEnergyWh)) |
|
| 782 |
+ MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(measurement.projectedMonthlyEnergyWh)) |
|
| 783 |
+ MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) |
|
| 784 |
+ } |
|
| 785 |
+ } |
|
| 786 |
+ |
|
| 787 |
+ private var distributionCard: some View {
|
|
| 788 |
+ MeterInfoCardView( |
|
| 789 |
+ title: "Distribution", |
|
| 790 |
+ infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.", |
|
| 791 |
+ tint: .orange |
|
| 792 |
+ ) {
|
|
| 793 |
+ StandbyPowerHistogramView( |
|
| 794 |
+ histogram: measurement.histogram, |
|
| 795 |
+ averagePowerWatts: measurement.averagePowerWatts, |
|
| 796 |
+ standardDeviationPowerWatts: measurement.standardDeviationPowerWatts, |
|
| 797 |
+ tint: .orange |
|
| 798 |
+ ) |
|
| 799 |
+ .frame(height: 220) |
|
| 800 |
+ |
|
| 801 |
+ if let firstBin = measurement.histogram.first, let lastBin = measurement.histogram.last {
|
|
| 802 |
+ HStack {
|
|
| 803 |
+ Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
|
|
| 804 |
+ Spacer() |
|
| 805 |
+ Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
|
|
| 806 |
+ } |
|
| 807 |
+ .font(.caption) |
|
| 808 |
+ .foregroundColor(.secondary) |
|
| 809 |
+ .monospacedDigit() |
|
| 810 |
+ } |
|
| 811 |
+ } |
|
| 812 |
+ } |
|
| 813 |
+ |
|
| 814 |
+ private var statisticsCard: some View {
|
|
| 815 |
+ MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
|
|
| 816 |
+ MeterInfoRowView(label: "Median", value: "\(measurement.medianPowerWatts.format(decimalDigits: 3)) W") |
|
| 817 |
+ MeterInfoRowView(label: "Minimum", value: "\(measurement.minimumPowerWatts.format(decimalDigits: 3)) W") |
|
| 818 |
+ MeterInfoRowView(label: "Maximum", value: "\(measurement.maximumPowerWatts.format(decimalDigits: 3)) W") |
|
| 819 |
+ MeterInfoRowView(label: "Spread σ", value: "\(measurement.standardDeviationPowerWatts.format(decimalDigits: 4)) W") |
|
| 820 |
+ MeterInfoRowView(label: "Variation", value: "\(Int((measurement.coefficientOfVariation * 100).rounded()))%") |
|
| 821 |
+ MeterInfoRowView(label: "Mean Current", value: "\(measurement.averageCurrentAmps.format(decimalDigits: 3)) A") |
|
| 822 |
+ MeterInfoRowView(label: "Mean Voltage", value: "\(measurement.averageVoltageVolts.format(decimalDigits: 3)) V") |
|
| 823 |
+ MeterInfoRowView(label: "Power Density", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W steady") |
|
| 824 |
+ } |
|
| 825 |
+ } |
|
| 826 |
+ |
|
| 827 |
+ private func standbyEnergyLabel(_ wattHours: Double) -> String {
|
|
| 828 |
+ if wattHours >= 1000 {
|
|
| 829 |
+ return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 830 |
+ } |
|
| 831 |
+ return "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 832 |
+ } |
|
| 833 |
+} |
|
@@ -9,6 +9,7 @@ |
||
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 | 11 |
struct MeterLiveContentView: View {
|
| 12 |
+ @EnvironmentObject private var appData: AppData |
|
| 12 | 13 |
@EnvironmentObject private var meter: Meter |
| 13 | 14 |
@State private var powerAverageSheetVisibility = false |
| 14 | 15 |
@State private var energyProjectionSheetVisibility = false |
@@ -35,6 +36,15 @@ struct MeterLiveContentView: View {
|
||
| 35 | 36 |
} |
| 36 | 37 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 37 | 38 |
|
| 39 |
+ NavigationLink( |
|
| 40 |
+ destination: ChargerStandbyPowerWizardView( |
|
| 41 |
+ preferredMeterMACAddress: meter.btSerial.macAddress.description |
|
| 42 |
+ ) |
|
| 43 |
+ ) {
|
|
| 44 |
+ standbyWizardCard |
|
| 45 |
+ } |
|
| 46 |
+ .buttonStyle(.plain) |
|
| 47 |
+ |
|
| 38 | 48 |
LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
|
| 39 | 49 |
if shouldShowVoltageCard {
|
| 40 | 50 |
liveMetricCard( |
@@ -219,6 +229,35 @@ struct MeterLiveContentView: View {
|
||
| 219 | 229 |
) |
| 220 | 230 |
} |
| 221 | 231 |
|
| 232 |
+ private var standbyWizardCard: some View {
|
|
| 233 |
+ MeterInfoCardView(title: "Standby Power", tint: .orange) {
|
|
| 234 |
+ Label("New Measurement", systemImage: "plus.circle.fill")
|
|
| 235 |
+ .font(.subheadline.weight(.semibold)) |
|
| 236 |
+ .foregroundColor(.orange) |
|
| 237 |
+ |
|
| 238 |
+ if let session = appData.chargerStandbyMeasurementSession(for: meter.btSerial.macAddress.description) {
|
|
| 239 |
+ Text( |
|
| 240 |
+ "Active on \(appData.chargedDeviceSummary(id: session.chargerID)?.name ?? "selected charger") • \(session.readinessDescription)" |
|
| 241 |
+ ) |
|
| 242 |
+ .font(.caption) |
|
| 243 |
+ .foregroundColor(.secondary) |
|
| 244 |
+ } else if let charger = appData.currentChargerSummary(for: meter.btSerial.macAddress.description) {
|
|
| 245 |
+ Text( |
|
| 246 |
+ charger.latestStandbyPowerMeasurement.map {
|
|
| 247 |
+ "\(charger.name) • latest \($0.averagePowerWatts.format(decimalDigits: 3)) W" |
|
| 248 |
+ } ?? "\(charger.name) • no saved standby baseline" |
|
| 249 |
+ ) |
|
| 250 |
+ .font(.caption) |
|
| 251 |
+ .foregroundColor(.secondary) |
|
| 252 |
+ } else {
|
|
| 253 |
+ Text("Open the wizard and choose the charger there.")
|
|
| 254 |
+ .font(.caption) |
|
| 255 |
+ .foregroundColor(.secondary) |
|
| 256 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 257 |
+ } |
|
| 258 |
+ } |
|
| 259 |
+ } |
|
| 260 |
+ |
|
| 222 | 261 |
private var showsCompactMetricRange: Bool {
|
| 223 | 262 |
compactLayout && (availableSize?.height ?? 0) >= 380 |
| 224 | 263 |
} |