Showing 9 changed files with 1804 additions and 4 deletions
+8 -0
USB Meter.xcodeproj/project.pbxproj
@@ -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 */,
+104 -1
USB Meter/Model/AppData.swift
@@ -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
+337 -0
USB Meter/Model/ChargeInsightsModel.swift
@@ -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 {
+2 -1
USB Meter/Model/ChargeInsightsStore.swift
@@ -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
+274 -0
USB Meter/Model/ChargerStandbyPowerStore.swift
@@ -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
+}
+131 -0
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -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
+76 -2
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -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) {
+833 -0
USB Meter/Views/Meter/Tabs/Live/ChargerStandbyPowerWizardView.swift
@@ -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
+}
+39 -0
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveContentView.swift
@@ -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
     }