USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
1138 lines | 34.306kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargeInsightsModel.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import Foundation
9

            
Bogdan Timofte authored a month ago
10
enum ChargedDeviceKind: String, Identifiable {
11
    case device
12
    case charger
13

            
14
    var id: String { rawValue }
15

            
16
    var title: String {
17
        switch self {
18
        case .device:
19
            return "Device"
20
        case .charger:
21
            return "Charger"
22
        }
23
    }
24

            
25
    var pluralTitle: String {
26
        switch self {
27
        case .device:
28
            return "Devices"
29
        case .charger:
30
            return "Chargers"
31
        }
32
    }
33

            
34
    var symbolName: String {
35
        switch self {
36
        case .device:
37
            return "iphone"
38
        case .charger:
39
            return "bolt.horizontal.circle"
40
        }
41
    }
42
}
43

            
Bogdan Timofte authored a month ago
44
enum ChargedDeviceClass: String, CaseIterable, Identifiable {
45
    case iphone
46
    case watch
47
    case powerbank
48
    case charger
49
    case other
50

            
51
    var id: String { rawValue }
52

            
Bogdan Timofte authored a month ago
53
    static var deviceCases: [ChargedDeviceClass] {
54
        allCases.filter { $0 != .charger }
55
    }
56

            
57
    var kind: ChargedDeviceKind {
58
        self == .charger ? .charger : .device
59
    }
60

            
Bogdan Timofte authored a month ago
61
    var title: String {
62
        switch self {
63
        case .iphone:
64
            return "iPhone"
65
        case .watch:
66
            return "Watch"
67
        case .powerbank:
68
            return "Powerbank"
69
        case .charger:
70
            return "Charger"
71
        case .other:
72
            return "Other"
73
        }
74
    }
75

            
76
    var symbolName: String {
77
        switch self {
78
        case .iphone:
79
            return "iphone"
80
        case .watch:
81
            return "applewatch"
82
        case .powerbank:
83
            return "battery.100.bolt"
84
        case .charger:
85
            return "bolt.badge.clock"
86
        case .other:
87
            return "shippingbox"
88
        }
89
    }
90
}
91

            
92
enum ChargeSessionStatus: String {
93
    case active
Bogdan Timofte authored a month ago
94
    case paused
Bogdan Timofte authored a month ago
95
    case completed
96
    case abandoned
97

            
98
    var title: String {
Bogdan Timofte authored a month ago
99
        switch self {
100
        case .active:
101
            return "Active"
102
        case .paused:
103
            return "Paused"
104
        case .completed:
105
            return "Completed"
106
        case .abandoned:
107
            return "Abandoned"
108
        }
109
    }
110

            
111
    var isOpen: Bool {
112
        switch self {
113
        case .active, .paused:
114
            return true
115
        case .completed, .abandoned:
116
            return false
117
        }
Bogdan Timofte authored a month ago
118
    }
119
}
120

            
121
enum ChargeSessionSourceMode: String {
122
    case live
123
    case offline
124
    case blended
125

            
126
    var title: String {
127
        switch self {
128
        case .live:
129
            return "Live"
130
        case .offline:
131
            return "Offline Counters"
132
        case .blended:
133
            return "Blended"
134
        }
135
    }
136
}
137

            
Bogdan Timofte authored a month ago
138
enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
139
    case wired
140
    case wireless
141

            
142
    var id: String { rawValue }
143

            
144
    var title: String {
145
        switch self {
146
        case .wired:
147
            return "Wired"
148
        case .wireless:
149
            return "Wireless"
150
        }
151
    }
152

            
153
    var symbolName: String {
154
        switch self {
155
        case .wired:
156
            return "cable.connector"
157
        case .wireless:
158
            return "dot.radiowaves.left.and.right"
159
        }
160
    }
161
}
162

            
Bogdan Timofte authored a month ago
163
enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
164
    case on
165
    case off
166

            
167
    var id: String { rawValue }
168

            
169
    var title: String {
170
        switch self {
171
        case .on:
172
            return "On"
173
        case .off:
174
            return "Off"
175
        }
176
    }
177

            
178
    var description: String {
179
        switch self {
180
        case .on:
181
            return "Device stays powered on while charging."
182
        case .off:
183
            return "Device is powered off while charging."
184
        }
185
    }
186
}
187

            
188
enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
189
    case onOnly
190
    case onOrOff
191
    case offOnly
192

            
193
    var id: String { rawValue }
194

            
195
    var title: String {
196
        switch self {
197
        case .onOnly:
198
            return "On Only"
199
        case .onOrOff:
200
            return "On or Off"
201
        case .offOnly:
202
            return "Off Only"
203
        }
204
    }
205

            
206
    var description: String {
207
        switch self {
208
        case .onOnly:
209
            return "The device can be recorded only while it is powered on."
210
        case .onOrOff:
211
            return "The session must specify whether the device is on or off."
212
        case .offOnly:
213
            return "The device can be recorded only while it is powered off."
214
        }
215
    }
216

            
217
    var supportedModes: [ChargingStateMode] {
218
        switch self {
219
        case .onOnly:
220
            return [.on]
221
        case .onOrOff:
222
            return [.on, .off]
223
        case .offOnly:
224
            return [.off]
225
        }
226
    }
227

            
228
    var supportsMultipleModes: Bool {
229
        supportedModes.count > 1
230
    }
231

            
232
    var supportsChargingWhileOff: Bool {
233
        self != .onOnly
234
    }
235

            
236
    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
237
        supportsChargingWhileOff ? .onOrOff : .onOnly
238
    }
239
}
240

            
241
enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
242
    case wiredOn
243
    case wiredOff
244
    case wirelessOn
245
    case wirelessOff
246

            
247
    var id: String { rawValue }
248

            
249
    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
250
        switch (chargingTransportMode, chargingStateMode) {
251
        case (.wired, .on):
252
            self = .wiredOn
253
        case (.wired, .off):
254
            self = .wiredOff
255
        case (.wireless, .on):
256
            self = .wirelessOn
257
        case (.wireless, .off):
258
            self = .wirelessOff
259
        }
260
    }
261

            
262
    var chargingTransportMode: ChargingTransportMode {
263
        switch self {
264
        case .wiredOn, .wiredOff:
265
            return .wired
266
        case .wirelessOn, .wirelessOff:
267
            return .wireless
268
        }
269
    }
270

            
271
    var chargingStateMode: ChargingStateMode {
272
        switch self {
273
        case .wiredOn, .wirelessOn:
274
            return .on
275
        case .wiredOff, .wirelessOff:
276
            return .off
277
        }
278
    }
279

            
280
    var title: String {
281
        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
282
    }
283

            
284
    var shortTitle: String {
285
        "\(chargingTransportMode.title) \(chargingStateMode.title)"
286
    }
287
}
288

            
Bogdan Timofte authored a month ago
289
enum WirelessChargingProfile: String, CaseIterable, Identifiable {
290
    case magsafe
291
    case genericQi
292

            
293
    var id: String { rawValue }
294

            
295
    var title: String {
296
        switch self {
297
        case .magsafe:
298
            return "MagSafe"
299
        case .genericQi:
300
            return "Generic Qi"
301
        }
302
    }
303

            
304
    var description: String {
305
        switch self {
306
        case .magsafe:
307
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
308
        case .genericQi:
309
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
310
        }
311
    }
312
}
313

            
314
struct ChargeCheckpointSummary: Identifiable, Hashable {
315
    let id: UUID
316
    let sessionID: UUID
317
    let chargedDeviceID: UUID
318
    let timestamp: Date
319
    let batteryPercent: Double
320
    let measuredEnergyWh: Double
321
    let measuredChargeAh: Double
322
    let currentAmps: Double
323
    let voltageVolts: Double?
324
    let label: String?
Bogdan Timofte authored a month ago
325

            
326
    var flag: ChargeCheckpointFlag {
327
        ChargeCheckpointFlag.fromStoredLabel(label)
328
    }
329
}
330

            
331
enum ChargeCheckpointFlag: String, CaseIterable {
332
    case initial
333
    case intermediate
334
    case final
335

            
336
    var title: String {
337
        switch self {
338
        case .initial:
339
            return "Initial"
340
        case .intermediate:
341
            return "Intermediate"
342
        case .final:
343
            return "Final"
344
        }
345
    }
346

            
347
    var anchorDescription: String {
348
        switch self {
349
        case .initial:
350
            return "initial checkpoint"
351
        case .intermediate:
352
            return "intermediate checkpoint"
353
        case .final:
354
            return "final checkpoint"
355
        }
356
    }
357

            
358
    static func fromStoredLabel(_ label: String?) -> ChargeCheckpointFlag {
359
        let normalized = label?
360
            .trimmingCharacters(in: .whitespacesAndNewlines)
361
            .lowercased()
362

            
363
        switch normalized {
364
        case "initial", "start":
365
            return .initial
366
        case "final", "end":
367
            return .final
368
        case "intermediate", nil, "":
369
            return .intermediate
370
        default:
371
            return .intermediate
372
        }
373
    }
Bogdan Timofte authored a month ago
374
}
375

            
376
struct ChargeSessionSampleSummary: Identifiable, Hashable {
377
    let sessionID: UUID
378
    let chargedDeviceID: UUID
379
    let bucketIndex: Int
380
    let timestamp: Date
381
    let averageCurrentAmps: Double
382
    let averageVoltageVolts: Double?
383
    let averagePowerWatts: Double
384
    let measuredEnergyWh: Double
385
    let measuredChargeAh: Double
386
    let sampleCount: Int
387

            
388
    var id: String {
389
        "\(sessionID.uuidString)-\(bucketIndex)"
390
    }
391
}
392

            
393
struct ChargeSessionSummary: Identifiable, Hashable {
394
    let id: UUID
395
    let chargedDeviceID: UUID
396
    let chargerID: UUID?
397
    let meterMACAddress: String?
398
    let meterName: String?
399
    let meterModel: String?
400
    let startedAt: Date
401
    let endedAt: Date?
402
    let lastObservedAt: Date
Bogdan Timofte authored a month ago
403
    let pausedAt: Date?
Bogdan Timofte authored a month ago
404
    let status: ChargeSessionStatus
405
    let sourceMode: ChargeSessionSourceMode
406
    let chargingTransportMode: ChargingTransportMode
Bogdan Timofte authored a month ago
407
    let chargingStateMode: ChargingStateMode
408
    let autoStopEnabled: Bool
Bogdan Timofte authored a month ago
409
    let measuredEnergyWh: Double
410
    let effectiveBatteryEnergyWh: Double?
411
    let measuredChargeAh: Double
Bogdan Timofte authored a month ago
412
    let meterEnergyBaselineWh: Double?
413
    let meterChargeBaselineAh: Double?
Bogdan Timofte authored a month ago
414
    let meterDurationBaselineSeconds: Double?
415
    let meterLastDurationSeconds: Double?
Bogdan Timofte authored a month ago
416
    let minimumObservedCurrentAmps: Double?
417
    let maximumObservedCurrentAmps: Double?
418
    let maximumObservedPowerWatts: Double?
419
    let maximumObservedVoltageVolts: Double?
420
    let selectedSourceVoltageVolts: Double?
421
    let completionCurrentAmps: Double?
422
    let stopThresholdAmps: Double
423
    let startBatteryPercent: Double?
424
    let endBatteryPercent: Double?
425
    let capacityEstimateWh: Double?
426
    let wirelessEfficiencyFactor: Double?
427
    let usesEstimatedWirelessEfficiency: Bool
428
    let shouldWarnAboutLowWirelessEfficiency: Bool
429
    let supportsChargingWhileOff: Bool
430
    let usedOfflineMeterCounters: Bool
431
    let targetBatteryPercent: Double?
432
    let targetBatteryAlertTriggeredAt: Date?
433
    let requiresCompletionConfirmation: Bool
434
    let completionConfirmationRequestedAt: Date?
435
    let completionContradictionPercent: Double?
436
    let selectedDataGroup: UInt8?
437
    let checkpoints: [ChargeCheckpointSummary]
438
    let aggregatedSamples: [ChargeSessionSampleSummary]
439

            
Bogdan Timofte authored a month ago
440
    var sessionKind: ChargeSessionKind {
441
        ChargeSessionKind(
442
            chargingTransportMode: chargingTransportMode,
443
            chargingStateMode: chargingStateMode
444
        )
445
    }
446

            
Bogdan Timofte authored a month ago
447
    var duration: TimeInterval {
448
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
449
    }
450

            
Bogdan Timofte authored a month ago
451
    var meterObservedDuration: TimeInterval? {
452
        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
453
            return nil
454
        }
455
        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
456
            return nil
457
        }
458
        return meterLastDurationSeconds - meterDurationBaselineSeconds
459
    }
460

            
461
    var effectiveDuration: TimeInterval {
462
        meterObservedDuration ?? duration
463
    }
464

            
Bogdan Timofte authored a month ago
465
    var effectiveOrMeasuredEnergyWh: Double {
466
        effectiveBatteryEnergyWh ?? measuredEnergyWh
467
    }
468

            
469
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
470
        guard let startBatteryPercent, let endBatteryPercent,
471
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
472
        return endBatteryPercent - startBatteryPercent
473
    }
Bogdan Timofte authored a month ago
474

            
475
    var canAutoStop: Bool {
476
        autoStopEnabled && stopThresholdAmps > 0
477
    }
478

            
479
    var isPaused: Bool {
480
        status == .paused
481
    }
482

            
483
    var isOpen: Bool {
484
        status.isOpen
485
    }
Bogdan Timofte authored a month ago
486
}
487

            
488
struct BatteryLevelPrediction: Hashable {
489
    let predictedPercent: Double
490
    let estimatedCapacityWh: Double
491
    let anchorPercent: Double
492
    let anchorEnergyWh: Double
493
    let anchorDescription: String
494
}
495

            
Bogdan Timofte authored a month ago
496
enum BatteryLevelPredictionTuning {
497
    static let checkpointSettleDuration: TimeInterval = 10 * 60
498

            
499
    static func predictedPercent(
500
        anchorPercent: Double,
501
        anchorEnergyWh: Double,
502
        anchorTimestamp: Date,
503
        anchorIsCheckpoint: Bool,
504
        effectiveEnergyWh: Double,
505
        referenceTimestamp: Date,
506
        estimatedCapacityWh: Double
507
    ) -> Double {
508
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
509
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
510
        let stabilizedGainPercent: Double
511

            
512
        if anchorIsCheckpoint {
513
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
514
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
515
            stabilizedGainPercent = rawGainPercent * settleProgress
516
        } else {
517
            stabilizedGainPercent = rawGainPercent
518
        }
519

            
520
        return min(
521
            100,
522
            max(
523
                0,
524
                anchorPercent + stabilizedGainPercent
525
            )
526
        )
527
    }
528
}
529

            
Bogdan Timofte authored a month ago
530
struct CapacityTrendPoint: Identifiable, Hashable {
531
    let sessionID: UUID
532
    let timestamp: Date
533
    let capacityWh: Double
534
    let chargingTransportMode: ChargingTransportMode
535

            
536
    var id: UUID { sessionID }
537
}
538

            
539
struct TypicalChargeCurvePoint: Identifiable, Hashable {
540
    let percentBin: Int
541
    let averageEnergyWh: Double
542
    let averageChargeAh: Double
543
    let sampleCount: Int
544

            
545
    var id: Int { percentBin }
546
}
547

            
Bogdan Timofte authored a month ago
548
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
549
    let timestamp: Date
550
    let powerWatts: Double
551
    let currentAmps: Double
552
    let voltageVolts: Double
553

            
554
    var id: TimeInterval {
555
        timestamp.timeIntervalSince1970
556
    }
557
}
558

            
559
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable {
560
    let index: Int
561
    let lowerBoundWatts: Double
562
    let upperBoundWatts: Double
563
    let count: Int
564
    let relativeFrequency: Double
565

            
566
    var id: Int { index }
567
}
568

            
569
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
570
    let sampleCount: Int
571
    let observedDuration: TimeInterval
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 histogram: [ChargerStandbyPowerDistributionBin]
584

            
585
    var projectedDailyEnergyWh: Double {
586
        averagePowerWatts * 24
587
    }
588

            
589
    var projectedWeeklyEnergyWh: Double {
590
        averagePowerWatts * 24 * 7
591
    }
592

            
593
    var projectedMonthlyEnergyWh: Double {
594
        averagePowerWatts * 24 * 30
595
    }
596

            
597
    var projectedYearlyEnergyWh: Double {
598
        averagePowerWatts * 24 * 365
599
    }
600

            
601
    var stabilityDeltaMilliwatts: Double {
602
        stabilityDeltaWatts * 1000
603
    }
604

            
605
    var isStable: Bool {
606
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
607
        && stabilityDeltaWatts <= stabilityToleranceWatts
608
    }
609
}
610

            
611
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
612
    let id: UUID
613
    let chargerID: UUID
614
    let meterMACAddress: String
615
    let meterName: String?
616
    let meterModel: String?
617
    let startedAt: Date
618
    let endedAt: Date
619
    let sampleCount: Int
620
    let stabilizedAt: Date?
621
    let averagePowerWatts: Double
622
    let recentAveragePowerWatts: Double
623
    let medianPowerWatts: Double
624
    let minimumPowerWatts: Double
625
    let maximumPowerWatts: Double
626
    let standardDeviationPowerWatts: Double
627
    let coefficientOfVariation: Double
628
    let averageCurrentAmps: Double
629
    let averageVoltageVolts: Double
630
    let stabilityDeltaWatts: Double
631
    let stabilityToleranceWatts: Double
632
    let powerSamplesWatts: [Double]
633

            
634
    var duration: TimeInterval {
635
        endedAt.timeIntervalSince(startedAt)
636
    }
637

            
638
    var projectedDailyEnergyWh: Double {
639
        averagePowerWatts * 24
640
    }
641

            
642
    var projectedWeeklyEnergyWh: Double {
643
        averagePowerWatts * 24 * 7
644
    }
645

            
646
    var projectedMonthlyEnergyWh: Double {
647
        averagePowerWatts * 24 * 30
648
    }
649

            
650
    var projectedYearlyEnergyWh: Double {
651
        averagePowerWatts * 24 * 365
652
    }
653

            
654
    var isStable: Bool {
655
        stabilizedAt != nil
656
    }
657

            
658
    var histogram: [ChargerStandbyPowerDistributionBin] {
659
        ChargerStandbyPowerMeasurementAnalyzer.histogram(for: powerSamplesWatts)
660
    }
661
}
662

            
663
enum ChargerStandbyPowerMeasurementAnalyzer {
664
    static let minimumStableSampleCount = 45
665
    static let recentSampleWindow = 20
666
    static let minimumStabilityToleranceWatts = 0.003
667
    static let relativeStabilityTolerance = 0.01
668

            
669
    static func statistics(
670
        from samples: [ChargerStandbyPowerSample],
671
        startedAt: Date,
672
        referenceDate: Date = Date()
673
    ) -> ChargerStandbyPowerMeasurementStatistics? {
674
        guard !samples.isEmpty else {
675
            return nil
676
        }
677

            
678
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
679
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
680
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
681

            
682
        guard powerValues.isEmpty == false else {
683
            return nil
684
        }
685

            
686
        let averagePower = mean(powerValues)
687
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
688
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
689
        let stabilityDelta = abs(averagePower - recentAveragePower)
690
        let stabilityTolerance = max(
691
            minimumStabilityToleranceWatts,
692
            abs(averagePower) * relativeStabilityTolerance
693
        )
694

            
695
        return ChargerStandbyPowerMeasurementStatistics(
696
            sampleCount: powerValues.count,
697
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
698
            averagePowerWatts: averagePower,
699
            recentAveragePowerWatts: recentAveragePower,
700
            medianPowerWatts: median(powerValues),
701
            minimumPowerWatts: powerValues.min() ?? 0,
702
            maximumPowerWatts: powerValues.max() ?? 0,
703
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
704
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
705
            averageCurrentAmps: mean(currentValues),
706
            averageVoltageVolts: mean(voltageValues),
707
            stabilityDeltaWatts: stabilityDelta,
708
            stabilityToleranceWatts: stabilityTolerance,
709
            histogram: histogram(for: powerValues)
710
        )
711
    }
712

            
713
    static func measurementSummary(
714
        chargerID: UUID,
715
        meterMACAddress: String,
716
        meterName: String?,
717
        meterModel: String?,
718
        startedAt: Date,
719
        endedAt: Date,
720
        samples: [ChargerStandbyPowerSample],
721
        stabilizedAt: Date?
722
    ) -> ChargerStandbyPowerMeasurementSummary? {
723
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
724
            return nil
725
        }
726

            
727
        return ChargerStandbyPowerMeasurementSummary(
728
            id: UUID(),
729
            chargerID: chargerID,
730
            meterMACAddress: meterMACAddress,
731
            meterName: meterName,
732
            meterModel: meterModel,
733
            startedAt: startedAt,
734
            endedAt: endedAt,
735
            sampleCount: statistics.sampleCount,
736
            stabilizedAt: stabilizedAt,
737
            averagePowerWatts: statistics.averagePowerWatts,
738
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
739
            medianPowerWatts: statistics.medianPowerWatts,
740
            minimumPowerWatts: statistics.minimumPowerWatts,
741
            maximumPowerWatts: statistics.maximumPowerWatts,
742
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
743
            coefficientOfVariation: statistics.coefficientOfVariation,
744
            averageCurrentAmps: statistics.averageCurrentAmps,
745
            averageVoltageVolts: statistics.averageVoltageVolts,
746
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
747
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
748
            powerSamplesWatts: samples.map(\.powerWatts)
749
        )
750
    }
751

            
752
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
753
        let finiteValues = values.filter(\.isFinite)
754
        guard finiteValues.isEmpty == false else {
755
            return []
756
        }
757

            
758
        let minimum = finiteValues.min() ?? 0
759
        let maximum = finiteValues.max() ?? 0
760
        let spread = maximum - minimum
761
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
762

            
763
        guard spread > 0 else {
764
            return [
765
                ChargerStandbyPowerDistributionBin(
766
                    index: 0,
767
                    lowerBoundWatts: minimum,
768
                    upperBoundWatts: maximum,
769
                    count: finiteValues.count,
770
                    relativeFrequency: 1
771
                )
772
            ]
773
        }
774

            
775
        let safeBinCount = max(1, binCount)
776
        let binWidth = spread / Double(safeBinCount)
777
        var counts = Array(repeating: 0, count: safeBinCount)
778

            
779
        for value in finiteValues {
780
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
781
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
782
            counts[safeIndex] += 1
783
        }
784

            
785
        return counts.enumerated().map { index, count in
786
            let lowerBound = minimum + (Double(index) * binWidth)
787
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
788

            
789
            return ChargerStandbyPowerDistributionBin(
790
                index: index,
791
                lowerBoundWatts: lowerBound,
792
                upperBoundWatts: upperBound,
793
                count: count,
794
                relativeFrequency: Double(count) / Double(finiteValues.count)
795
            )
796
        }
797
    }
798

            
799
    private static func mean(_ values: [Double]) -> Double {
800
        guard values.isEmpty == false else {
801
            return 0
802
        }
803
        return values.reduce(0, +) / Double(values.count)
804
    }
805

            
806
    private static func median(_ values: [Double]) -> Double {
807
        guard values.isEmpty == false else {
808
            return 0
809
        }
810

            
811
        let sorted = values.sorted()
812
        let middleIndex = sorted.count / 2
813

            
814
        if sorted.count.isMultiple(of: 2) {
815
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
816
        }
817

            
818
        return sorted[middleIndex]
819
    }
820

            
821
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
822
        guard values.count > 1 else {
823
            return 0
824
        }
825

            
826
        let variance = values.reduce(0) { partialResult, value in
827
            let delta = value - mean
828
            return partialResult + (delta * delta)
829
        } / Double(values.count)
830

            
831
        return variance.squareRoot()
832
    }
833

            
834
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
835
        guard abs(mean) > 0.000_001 else {
836
            return 0
837
        }
838

            
839
        return standardDeviation(values, mean: mean) / abs(mean)
840
    }
841
}
842

            
Bogdan Timofte authored a month ago
843
struct ChargedDeviceSummary: Identifiable, Hashable {
844
    let id: UUID
845
    let qrIdentifier: String
846
    let name: String
847
    let deviceClass: ChargedDeviceClass
848
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
849
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
850
    let supportsWiredCharging: Bool
851
    let supportsWirelessCharging: Bool
852
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
853
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
854
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
855
    let wirelessChargerEfficiencyFactor: Double?
856
    let wiredChargeCompletionCurrentAmps: Double?
857
    let wirelessChargeCompletionCurrentAmps: Double?
858
    let chargerObservedVoltageSelections: [Double]
859
    let chargerIdleCurrentAmps: Double?
860
    let chargerEfficiencyFactor: Double?
861
    let chargerMaximumPowerWatts: Double?
862
    let notes: String?
863
    let minimumCurrentAmps: Double?
864
    let estimatedBatteryCapacityWh: Double?
865
    let wiredMinimumCurrentAmps: Double?
866
    let wirelessMinimumCurrentAmps: Double?
867
    let wiredEstimatedBatteryCapacityWh: Double?
868
    let wirelessEstimatedBatteryCapacityWh: Double?
869
    let lastAssociatedMeterMAC: String?
870
    let createdAt: Date
871
    let updatedAt: Date
872
    let sessions: [ChargeSessionSummary]
873
    let capacityHistory: [CapacityTrendPoint]
874
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
875
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
876

            
877
    var isCharger: Bool {
878
        deviceClass == .charger
879
    }
880

            
Bogdan Timofte authored a month ago
881
    var kind: ChargedDeviceKind {
882
        deviceClass.kind
883
    }
884

            
885
    var identityTitle: String {
886
        isCharger ? kind.title : deviceClass.title
887
    }
888

            
889
    var identitySymbolName: String {
890
        isCharger ? kind.symbolName : deviceClass.symbolName
891
    }
892

            
Bogdan Timofte authored a month ago
893
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
894
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
895
    }
896

            
897
    var recentCompletedSessions: [ChargeSessionSummary] {
898
        sessions.filter { $0.status == .completed }
899
    }
900

            
901
    var sessionCount: Int {
902
        sessions.count
903
    }
904

            
Bogdan Timofte authored a month ago
905
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
906
        standbyPowerMeasurements.first
907
    }
908

            
Bogdan Timofte authored a month ago
909
    var supportedChargingModes: [ChargingTransportMode] {
910
        var modes: [ChargingTransportMode] = []
911
        if supportsWiredCharging {
912
            modes.append(.wired)
913
        }
914
        if supportsWirelessCharging {
915
            modes.append(.wireless)
916
        }
Bogdan Timofte authored a month ago
917
        return modes
Bogdan Timofte authored a month ago
918
    }
919

            
Bogdan Timofte authored a month ago
920
    var supportedChargingStateModes: [ChargingStateMode] {
921
        chargingStateAvailability.supportedModes
922
    }
923

            
924
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
925
        if let matchingSession = sessions.first(where: {
926
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
927
        }) {
928
            return matchingSession.chargingStateMode
929
        }
930
        return chargingStateAvailability.supportedModes.first ?? .on
931
    }
932

            
933
    func sessionKind(
934
        for chargingTransportMode: ChargingTransportMode,
935
        chargingStateMode: ChargingStateMode? = nil
936
    ) -> ChargeSessionKind {
937
        ChargeSessionKind(
938
            chargingTransportMode: chargingTransportMode,
939
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
940
        )
941
    }
942

            
Bogdan Timofte authored a month ago
943
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
944
        switch chargingTransportMode {
945
        case .wired:
946
            return wiredEstimatedBatteryCapacityWh
947
        case .wireless:
948
            return wirelessEstimatedBatteryCapacityWh
949
        }
950
    }
951

            
952
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
953
        switch chargingTransportMode {
954
        case .wired:
955
            return wiredMinimumCurrentAmps
956
        case .wireless:
957
            return wirelessMinimumCurrentAmps
958
        }
959
    }
960

            
Bogdan Timofte authored a month ago
961
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
962
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
963
            return explicitCurrent
964
        }
965

            
966
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
967
        case .wired:
968
            return wiredChargeCompletionCurrentAmps
969
        case .wireless:
970
            return wirelessChargeCompletionCurrentAmps
971
        }
972
    }
973

            
Bogdan Timofte authored a month ago
974
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
975
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
976
            return learnedCurrent
977
        }
978

            
979
        switch sessionKind.chargingTransportMode {
980
        case .wired:
981
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
982
        case .wireless:
983
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
984
        }
985
    }
986

            
987
    func resolvedCompletionCurrentAmps(
988
        for chargingTransportMode: ChargingTransportMode,
989
        chargingStateMode: ChargingStateMode? = nil
990
    ) -> Double? {
991
        let sessionKind = sessionKind(
992
            for: chargingTransportMode,
993
            chargingStateMode: chargingStateMode
994
        )
995

            
996
        return configuredCompletionCurrentAmps(for: sessionKind)
997
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
998
            ?? minimumCurrentAmps(for: chargingTransportMode)
999
            ?? minimumCurrentAmps
1000
    }
1001

            
Bogdan Timofte authored a month ago
1002
    func batteryLevelPrediction(
1003
        for session: ChargeSessionSummary,
1004
        effectiveEnergyWhOverride: Double? = nil
1005
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1006
        let estimatedCapacityWh = session.capacityEstimateWh
1007
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1008
            ?? estimatedBatteryCapacityWh
1009

            
1010
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1011
            return nil
1012
        }
1013

            
Bogdan Timofte authored a month ago
1014
        let effectiveEnergyWh = effectiveEnergyWhOverride
1015
            ?? session.effectiveBatteryEnergyWh
1016
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1017

            
1018
        struct Anchor {
1019
            let percent: Double
1020
            let energyWh: Double
Bogdan Timofte authored a month ago
1021
            let timestamp: Date
Bogdan Timofte authored a month ago
1022
            let description: String
Bogdan Timofte authored a month ago
1023
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1024
        }
1025

            
1026
        var anchors: [Anchor] = []
1027

            
Bogdan Timofte authored a month ago
1028
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1029
            anchors.append(
1030
                Anchor(
1031
                    percent: startBatteryPercent,
1032
                    energyWh: 0,
Bogdan Timofte authored a month ago
1033
                    timestamp: session.startedAt,
1034
                    description: "session start",
1035
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1036
                )
1037
            )
1038
        }
1039

            
1040
        anchors.append(
1041
            contentsOf: session.checkpoints
1042
                .sorted { lhs, rhs in
1043
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1044
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1045
                    }
1046
                    return lhs.timestamp < rhs.timestamp
1047
                }
Bogdan Timofte authored a month ago
1048
                .filter { checkpoint in
1049
                    checkpoint.batteryPercent >= 0
1050
                }
Bogdan Timofte authored a month ago
1051
                .map { checkpoint in
1052
                    return Anchor(
1053
                        percent: checkpoint.batteryPercent,
1054
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1055
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
1056
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
1057
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1058
                    )
1059
                }
1060
        )
1061

            
1062
        guard !anchors.isEmpty else {
1063
            return nil
1064
        }
1065

            
1066
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1067
        let anchor = eligibleAnchors.last ?? anchors.first!
Bogdan Timofte authored a month ago
1068
        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1069
            anchorPercent: anchor.percent,
1070
            anchorEnergyWh: anchor.energyWh,
1071
            anchorTimestamp: anchor.timestamp,
1072
            anchorIsCheckpoint: anchor.isCheckpoint,
1073
            effectiveEnergyWh: effectiveEnergyWh,
1074
            referenceTimestamp: session.lastObservedAt,
1075
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1076
        )
1077

            
1078
        return BatteryLevelPrediction(
1079
            predictedPercent: predictedPercent,
1080
            estimatedCapacityWh: estimatedCapacityWh,
1081
            anchorPercent: anchor.percent,
1082
            anchorEnergyWh: anchor.energyWh,
1083
            anchorDescription: anchor.description
1084
        )
1085
    }
Bogdan Timofte authored a month ago
1086

            
1087
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1088
        ChargedDeviceSummary(
1089
            id: id,
1090
            qrIdentifier: qrIdentifier,
1091
            name: name,
1092
            deviceClass: deviceClass,
1093
            supportsChargingWhileOff: supportsChargingWhileOff,
1094
            chargingStateAvailability: chargingStateAvailability,
1095
            supportsWiredCharging: supportsWiredCharging,
1096
            supportsWirelessCharging: supportsWirelessCharging,
1097
            wirelessChargingProfile: wirelessChargingProfile,
1098
            configuredCompletionCurrents: configuredCompletionCurrents,
1099
            learnedCompletionCurrents: learnedCompletionCurrents,
1100
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1101
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1102
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1103
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1104
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1105
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1106
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1107
            notes: notes,
1108
            minimumCurrentAmps: minimumCurrentAmps,
1109
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1110
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1111
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1112
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1113
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1114
            lastAssociatedMeterMAC: lastAssociatedMeterMAC,
1115
            createdAt: createdAt,
1116
            updatedAt: updatedAt,
1117
            sessions: sessions,
1118
            capacityHistory: capacityHistory,
1119
            typicalCurve: typicalCurve,
1120
            standbyPowerMeasurements: measurements
1121
        )
1122
    }
Bogdan Timofte authored a month ago
1123
}
1124

            
1125
struct ChargingMonitorSnapshot {
1126
    let meterMACAddress: String
1127
    let meterName: String
1128
    let meterModel: String
1129
    let observedAt: Date
1130
    let voltageVolts: Double
1131
    let currentAmps: Double
1132
    let powerWatts: Double
1133
    let selectedDataGroup: UInt8?
1134
    let meterChargeCounterAh: Double?
1135
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1136
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1137
    let fallbackStopThresholdAmps: Double
1138
}