USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
1688 lines | 56.135kb
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, Codable {
Bogdan Timofte authored a month ago
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, Codable {
Bogdan Timofte authored a month ago
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
    }
Bogdan Timofte authored a month ago
90

            
91
    var enforcedChargingSupport: (wired: Bool, wireless: Bool)? {
92
        switch self {
93
        case .watch:
94
            return (wired: false, wireless: true)
95
        case .powerbank:
96
            return (wired: true, wireless: false)
97
        case .charger:
98
            return (wired: false, wireless: true)
99
        case .iphone, .other:
100
            return nil
101
        }
102
    }
103

            
104
    var enforcedChargingStateAvailability: ChargingStateAvailability? {
105
        switch self {
106
        case .watch:
107
            return .onOnly
108
        case .powerbank:
109
            return .offOnly
110
        case .charger:
111
            return .onOnly
112
        case .iphone, .other:
113
            return nil
114
        }
115
    }
116

            
Bogdan Timofte authored a month ago
117
    var defaultChargingSupport: (wired: Bool, wireless: Bool) {
118
        if let enforcedChargingSupport {
119
            return enforcedChargingSupport
120
        }
121

            
122
        switch self {
123
        case .iphone:
124
            return (wired: true, wireless: true)
125
        case .watch:
126
            return (wired: false, wireless: true)
127
        case .powerbank:
128
            return (wired: true, wireless: false)
129
        case .charger:
130
            return (wired: false, wireless: true)
131
        case .other:
132
            return (wired: true, wireless: false)
133
        }
134
    }
135

            
136
    var defaultChargingStateAvailability: ChargingStateAvailability {
137
        enforcedChargingStateAvailability ?? {
138
            switch self {
139
            case .iphone:
140
                return .onOrOff
141
            case .watch:
142
                return .onOnly
143
            case .powerbank:
144
                return .offOnly
145
            case .charger, .other:
146
                return .onOrOff
147
            }
148
        }()
149
    }
150

            
Bogdan Timofte authored a month ago
151
    func normalizedChargingSupport(
152
        supportsWiredCharging: Bool,
153
        supportsWirelessCharging: Bool
154
    ) -> (wired: Bool, wireless: Bool) {
155
        enforcedChargingSupport ?? (wired: supportsWiredCharging, wireless: supportsWirelessCharging)
156
    }
157

            
158
    func normalizedChargingStateAvailability(
159
        _ chargingStateAvailability: ChargingStateAvailability
160
    ) -> ChargingStateAvailability {
161
        enforcedChargingStateAvailability ?? chargingStateAvailability
162
    }
Bogdan Timofte authored a month ago
163
}
164

            
165
enum ChargeSessionStatus: String {
166
    case active
Bogdan Timofte authored a month ago
167
    case paused
Bogdan Timofte authored a month ago
168
    case completed
169
    case abandoned
170

            
171
    var title: String {
Bogdan Timofte authored a month ago
172
        switch self {
173
        case .active:
174
            return "Active"
175
        case .paused:
176
            return "Paused"
177
        case .completed:
178
            return "Completed"
179
        case .abandoned:
180
            return "Abandoned"
181
        }
182
    }
183

            
184
    var isOpen: Bool {
185
        switch self {
186
        case .active, .paused:
187
            return true
188
        case .completed, .abandoned:
189
            return false
190
        }
Bogdan Timofte authored a month ago
191
    }
192
}
193

            
194
enum ChargeSessionSourceMode: String {
195
    case live
196
    case offline
197
    case blended
198

            
199
    var title: String {
200
        switch self {
201
        case .live:
202
            return "Live"
203
        case .offline:
204
            return "Offline Counters"
205
        case .blended:
206
            return "Blended"
207
        }
208
    }
209
}
210

            
Bogdan Timofte authored a month ago
211
enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
212
    case wired
213
    case wireless
214

            
215
    var id: String { rawValue }
216

            
217
    var title: String {
218
        switch self {
219
        case .wired:
220
            return "Wired"
221
        case .wireless:
222
            return "Wireless"
223
        }
224
    }
225

            
226
    var symbolName: String {
227
        switch self {
228
        case .wired:
229
            return "cable.connector"
230
        case .wireless:
231
            return "dot.radiowaves.left.and.right"
232
        }
233
    }
234
}
235

            
Bogdan Timofte authored a month ago
236
enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
237
    case on
238
    case off
239

            
240
    var id: String { rawValue }
241

            
242
    var title: String {
243
        switch self {
244
        case .on:
245
            return "On"
246
        case .off:
247
            return "Off"
248
        }
249
    }
250

            
251
    var description: String {
252
        switch self {
253
        case .on:
254
            return "Device stays powered on while charging."
255
        case .off:
256
            return "Device is powered off while charging."
257
        }
258
    }
259
}
260

            
261
enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
262
    case onOnly
263
    case onOrOff
264
    case offOnly
265

            
266
    var id: String { rawValue }
267

            
268
    var title: String {
269
        switch self {
270
        case .onOnly:
271
            return "On Only"
272
        case .onOrOff:
273
            return "On or Off"
274
        case .offOnly:
275
            return "Off Only"
276
        }
277
    }
278

            
279
    var description: String {
280
        switch self {
281
        case .onOnly:
282
            return "The device can be recorded only while it is powered on."
283
        case .onOrOff:
284
            return "The session must specify whether the device is on or off."
285
        case .offOnly:
286
            return "The device can be recorded only while it is powered off."
287
        }
288
    }
289

            
290
    var supportedModes: [ChargingStateMode] {
291
        switch self {
292
        case .onOnly:
293
            return [.on]
294
        case .onOrOff:
295
            return [.on, .off]
296
        case .offOnly:
297
            return [.off]
298
        }
299
    }
300

            
301
    var supportsMultipleModes: Bool {
302
        supportedModes.count > 1
303
    }
304

            
305
    var supportsChargingWhileOff: Bool {
306
        self != .onOnly
307
    }
308

            
309
    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
310
        supportsChargingWhileOff ? .onOrOff : .onOnly
311
    }
312
}
313

            
314
enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
315
    case wiredOn
316
    case wiredOff
317
    case wirelessOn
318
    case wirelessOff
319

            
320
    var id: String { rawValue }
321

            
322
    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
323
        switch (chargingTransportMode, chargingStateMode) {
324
        case (.wired, .on):
325
            self = .wiredOn
326
        case (.wired, .off):
327
            self = .wiredOff
328
        case (.wireless, .on):
329
            self = .wirelessOn
330
        case (.wireless, .off):
331
            self = .wirelessOff
332
        }
333
    }
334

            
335
    var chargingTransportMode: ChargingTransportMode {
336
        switch self {
337
        case .wiredOn, .wiredOff:
338
            return .wired
339
        case .wirelessOn, .wirelessOff:
340
            return .wireless
341
        }
342
    }
343

            
344
    var chargingStateMode: ChargingStateMode {
345
        switch self {
346
        case .wiredOn, .wirelessOn:
347
            return .on
348
        case .wiredOff, .wirelessOff:
349
            return .off
350
        }
351
    }
352

            
353
    var title: String {
354
        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
355
    }
356

            
357
    var shortTitle: String {
358
        "\(chargingTransportMode.title) \(chargingStateMode.title)"
359
    }
360
}
361

            
Bogdan Timofte authored a month ago
362
enum WirelessChargingProfile: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
363
    case magsafe
364
    case genericQi
365

            
366
    var id: String { rawValue }
367

            
368
    var title: String {
369
        switch self {
370
        case .magsafe:
371
            return "MagSafe"
372
        case .genericQi:
373
            return "Generic Qi"
374
        }
375
    }
376

            
377
    var description: String {
378
        switch self {
379
        case .magsafe:
380
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
381
        case .genericQi:
382
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
383
        }
384
    }
385
}
386

            
Bogdan Timofte authored a month ago
387
enum ChargerType: String, CaseIterable, Identifiable, Codable {
388
    case appleMagSafe
389
    case appleWatch
390
    case genericMagSafe
391
    case genericQi
392

            
393
    var id: String { rawValue }
394

            
395
    var title: String {
396
        switch self {
397
        case .appleMagSafe: return "Apple MagSafe Charger"
398
        case .appleWatch: return "Apple Watch Charger"
399
        case .genericMagSafe: return "Generic MagSafe"
400
        case .genericQi: return "Generic Qi"
401
        }
402
    }
403

            
404
    var symbolName: String {
405
        switch self {
406
        case .appleMagSafe: return "magsafe.batterypack"
407
        case .appleWatch: return "applewatch.radiowaves.left.and.right"
408
        case .genericMagSafe: return "bolt.circle"
409
        case .genericQi: return "bolt.horizontal.circle"
410
        }
411
    }
412

            
413
    /// Whether this charger type uses magnetic alignment, enabling more accurate efficiency calibration.
414
    var supportsAlignment: Bool {
415
        switch self {
416
        case .appleMagSafe, .appleWatch, .genericMagSafe: return true
417
        case .genericQi: return false
418
        }
419
    }
420

            
421
    var wirelessChargingProfile: WirelessChargingProfile {
422
        supportsAlignment ? .magsafe : .genericQi
423
    }
424
}
425

            
Bogdan Timofte authored a month ago
426
enum ChargedDeviceTemplateIconSource: String, Codable {
427
    case systemSymbol
428
    case asset
429
}
430

            
431
struct ChargedDeviceTemplateIcon: Hashable, Codable {
432
    let type: ChargedDeviceTemplateIconSource
433
    let name: String
434
    let fallbackSystemName: String?
435

            
436
    static func systemSymbol(
437
        _ name: String,
438
        fallbackSystemName: String? = nil
439
    ) -> ChargedDeviceTemplateIcon {
440
        ChargedDeviceTemplateIcon(
441
            type: .systemSymbol,
442
            name: name,
443
            fallbackSystemName: fallbackSystemName
444
        )
445
    }
446

            
447
    func resolvedSystemSymbolName(fallbackSystemName: String) -> String {
448
        switch type {
449
        case .systemSymbol:
450
            return name
451
        case .asset:
452
            return self.fallbackSystemName ?? fallbackSystemName
453
        }
454
    }
455
}
456

            
457
struct ChargedDeviceTemplateDefinition: Identifiable, Hashable, Codable {
458
    let id: String
459
    let name: String
460
    let group: String
461
    let kind: ChargedDeviceKind
462
    let deviceClass: ChargedDeviceClass
463
    let icon: ChargedDeviceTemplateIcon
464
    let chargingStateAvailability: ChargingStateAvailability
465
    let supportsWiredCharging: Bool
466
    let supportsWirelessCharging: Bool
467
    let wirelessChargingProfile: WirelessChargingProfile
468
    let sortOrder: Int
469

            
470
    var chargingSupportSummary: String {
471
        switch (supportsWiredCharging, supportsWirelessCharging) {
472
        case (true, true):
473
            return "Wired + Wireless"
474
        case (true, false):
475
            return "Wired only"
476
        case (false, true):
477
            return "Wireless only"
478
        case (false, false):
479
            return "No charging transport"
480
        }
481
    }
482

            
483
    var capabilitySummary: String {
Bogdan Timofte authored a month ago
484
        if kind == .charger {
485
            return wirelessChargingProfile.title
486
        }
Bogdan Timofte authored a month ago
487
        var components = [chargingStateAvailability.title, chargingSupportSummary]
488
        if supportsWirelessCharging {
489
            components.append(wirelessChargingProfile.title)
490
        }
491
        return components.joined(separator: " • ")
492
    }
493
}
494

            
495
private struct ChargedDeviceTemplateDocument: Codable {
496
    let templates: [ChargedDeviceTemplateDefinition]
497
}
498

            
499
struct ChargedDeviceTemplateCatalog {
500
    static let shared = ChargedDeviceTemplateCatalog()
501

            
502
    let templates: [ChargedDeviceTemplateDefinition]
503
    private let templatesByID: [String: ChargedDeviceTemplateDefinition]
504

            
505
    private init(bundle: Bundle = .main) {
506
        let loadedTemplates: [ChargedDeviceTemplateDefinition]
507

            
508
        if let resourceURL = bundle.url(forResource: "ChargedDeviceTemplates", withExtension: "json"),
509
           let data = try? Data(contentsOf: resourceURL),
510
           let document = try? JSONDecoder().decode(ChargedDeviceTemplateDocument.self, from: data) {
511
            loadedTemplates = document.templates
512
        } else {
513
            loadedTemplates = []
514
        }
515

            
516
        self.templates = loadedTemplates.sorted { lhs, rhs in
517
            if lhs.group != rhs.group {
518
                return lhs.group < rhs.group
519
            }
520
            if lhs.sortOrder != rhs.sortOrder {
521
                return lhs.sortOrder < rhs.sortOrder
522
            }
523
            return lhs.name < rhs.name
524
        }
525
        self.templatesByID = Dictionary(uniqueKeysWithValues: self.templates.map { ($0.id, $0) })
526
    }
527

            
528
    func template(id: String?) -> ChargedDeviceTemplateDefinition? {
529
        guard let id else {
530
            return nil
531
        }
532
        return templatesByID[id]
533
    }
534

            
535
    func templates(for kind: ChargedDeviceKind) -> [ChargedDeviceTemplateDefinition] {
536
        templates.filter { $0.kind == kind }
537
    }
538
}
539

            
Bogdan Timofte authored a month ago
540
struct ChargeCheckpointSummary: Identifiable, Hashable {
541
    let id: UUID
542
    let sessionID: UUID
543
    let chargedDeviceID: UUID
544
    let timestamp: Date
545
    let batteryPercent: Double
546
    let measuredEnergyWh: Double
547
    let currentAmps: Double
548
    let voltageVolts: Double?
549
    let label: String?
Bogdan Timofte authored a month ago
550

            
551
    var flag: ChargeCheckpointFlag {
552
        ChargeCheckpointFlag.fromStoredLabel(label)
553
    }
554
}
555

            
556
enum ChargeCheckpointFlag: String, CaseIterable {
557
    case initial
558
    case intermediate
559
    case final
560

            
561
    var title: String {
562
        switch self {
563
        case .initial:
564
            return "Initial"
565
        case .intermediate:
566
            return "Intermediate"
567
        case .final:
568
            return "Final"
569
        }
570
    }
571

            
572
    var anchorDescription: String {
573
        switch self {
574
        case .initial:
575
            return "initial checkpoint"
576
        case .intermediate:
577
            return "intermediate checkpoint"
578
        case .final:
579
            return "final checkpoint"
580
        }
581
    }
582

            
583
    static func fromStoredLabel(_ label: String?) -> ChargeCheckpointFlag {
584
        let normalized = label?
585
            .trimmingCharacters(in: .whitespacesAndNewlines)
586
            .lowercased()
587

            
588
        switch normalized {
589
        case "initial", "start":
590
            return .initial
591
        case "final", "end":
592
            return .final
593
        case "intermediate", nil, "":
594
            return .intermediate
595
        default:
596
            return .intermediate
597
        }
598
    }
Bogdan Timofte authored a month ago
599
}
600

            
601
struct ChargeSessionSampleSummary: Identifiable, Hashable {
602
    let sessionID: UUID
603
    let chargedDeviceID: UUID
604
    let bucketIndex: Int
605
    let timestamp: Date
606
    let averageCurrentAmps: Double
607
    let averageVoltageVolts: Double?
608
    let averagePowerWatts: Double
609
    let measuredEnergyWh: Double
Bogdan Timofte authored a month ago
610
    let estimatedBatteryPercent: Double?
Bogdan Timofte authored a month ago
611
    let sampleCount: Int
612

            
613
    var id: String {
614
        "\(sessionID.uuidString)-\(bucketIndex)"
615
    }
616
}
617

            
618
struct ChargeSessionSummary: Identifiable, Hashable {
619
    let id: UUID
620
    let chargedDeviceID: UUID
621
    let chargerID: UUID?
622
    let meterMACAddress: String?
623
    let meterName: String?
624
    let meterModel: String?
625
    let startedAt: Date
626
    let endedAt: Date?
627
    let lastObservedAt: Date
Bogdan Timofte authored a month ago
628
    let pausedAt: Date?
Bogdan Timofte authored a month ago
629
    let status: ChargeSessionStatus
630
    let sourceMode: ChargeSessionSourceMode
631
    let chargingTransportMode: ChargingTransportMode
Bogdan Timofte authored a month ago
632
    let chargingStateMode: ChargingStateMode
633
    let autoStopEnabled: Bool
Bogdan Timofte authored a month ago
634
    let measuredEnergyWh: Double
635
    let effectiveBatteryEnergyWh: Double?
Bogdan Timofte authored a month ago
636
    let meterEnergyBaselineWh: Double?
Bogdan Timofte authored a month ago
637
    let meterDurationBaselineSeconds: Double?
638
    let meterLastDurationSeconds: Double?
Bogdan Timofte authored a month ago
639
    let minimumObservedCurrentAmps: Double?
640
    let maximumObservedCurrentAmps: Double?
641
    let maximumObservedPowerWatts: Double?
642
    let maximumObservedVoltageVolts: Double?
Bogdan Timofte authored a month ago
643
    let hasObservedChargeFlow: Bool
Bogdan Timofte authored a month ago
644
    let selectedSourceVoltageVolts: Double?
645
    let completionCurrentAmps: Double?
646
    let stopThresholdAmps: Double
647
    let startBatteryPercent: Double?
648
    let endBatteryPercent: Double?
649
    let capacityEstimateWh: Double?
650
    let wirelessEfficiencyFactor: Double?
651
    let usesEstimatedWirelessEfficiency: Bool
652
    let shouldWarnAboutLowWirelessEfficiency: Bool
653
    let supportsChargingWhileOff: Bool
654
    let usedOfflineMeterCounters: Bool
655
    let targetBatteryPercent: Double?
656
    let targetBatteryAlertTriggeredAt: Date?
657
    let requiresCompletionConfirmation: Bool
658
    let completionConfirmationRequestedAt: Date?
659
    let completionContradictionPercent: Double?
660
    let selectedDataGroup: UInt8?
Bogdan Timofte authored a month ago
661
    let trimStart: Date?
662
    let trimEnd: Date?
Bogdan Timofte authored a month ago
663
    let wasConflictHealed: Bool
Bogdan Timofte authored a month ago
664
    let checkpoints: [ChargeCheckpointSummary]
665
    let aggregatedSamples: [ChargeSessionSampleSummary]
666

            
Bogdan Timofte authored a month ago
667
    var effectiveTrimStart: Date { trimStart ?? startedAt }
668
    var effectiveTrimEnd: Date { trimEnd ?? (endedAt ?? lastObservedAt) }
669
    var isTrimmed: Bool { trimStart != nil || trimEnd != nil }
670
    var effectiveTimeRange: ClosedRange<Date> {
671
        let start = effectiveTrimStart
672
        let end = max(effectiveTrimEnd, start)
673
        return start...end
674
    }
675
    var displayedAggregatedSamples: [ChargeSessionSampleSummary] {
676
        guard isTrimmed else { return aggregatedSamples }
677
        let range = effectiveTimeRange
678
        return aggregatedSamples.filter { range.contains($0.timestamp) }
679
    }
680

            
Bogdan Timofte authored a month ago
681
    var sessionKind: ChargeSessionKind {
682
        ChargeSessionKind(
683
            chargingTransportMode: chargingTransportMode,
684
            chargingStateMode: chargingStateMode
685
        )
686
    }
687

            
Bogdan Timofte authored a month ago
688
    var duration: TimeInterval {
689
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
690
    }
691

            
Bogdan Timofte authored a month ago
692
    var meterObservedDuration: TimeInterval? {
693
        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
694
            return nil
695
        }
696
        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
697
            return nil
698
        }
699
        return meterLastDurationSeconds - meterDurationBaselineSeconds
700
    }
701

            
702
    var effectiveDuration: TimeInterval {
Bogdan Timofte authored a month ago
703
        if isTrimmed {
704
            return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0)
705
        }
Bogdan Timofte authored a month ago
706

            
707
        // Use timestamp-based duration as primary source; only use meter counter if it's consistent
708
        let timestampDuration = duration
709

            
710
        if let meterDuration = meterObservedDuration {
711
            // Allow 5% tolerance for meter counter vs timestamp calculation
712
            let tolerance = timestampDuration * 0.05
713
            let lower = timestampDuration - tolerance
714
            let upper = timestampDuration + tolerance
715

            
716
            // If meter duration is within tolerance range, use it (more precise)
717
            // Otherwise fall back to timestamp-based duration
718
            if meterDuration >= lower && meterDuration <= upper {
719
                return meterDuration
720
            }
721
        }
722

            
723
        return timestampDuration
Bogdan Timofte authored a month ago
724
    }
725

            
Bogdan Timofte authored a month ago
726
    var effectiveOrMeasuredEnergyWh: Double {
727
        effectiveBatteryEnergyWh ?? measuredEnergyWh
728
    }
729

            
Bogdan Timofte authored a month ago
730
    var hasSavableChargeData: Bool {
731
        hasObservedChargeFlow
732
            || measuredEnergyWh > 0
733
            || (maximumObservedCurrentAmps ?? 0) > 0
734
            || (maximumObservedPowerWatts ?? 0) > 0
735
            || !aggregatedSamples.isEmpty
736
    }
737

            
Bogdan Timofte authored a month ago
738
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
739
        guard let startBatteryPercent, let endBatteryPercent,
740
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
741
        return endBatteryPercent - startBatteryPercent
742
    }
Bogdan Timofte authored a month ago
743

            
744
    var canAutoStop: Bool {
745
        autoStopEnabled && stopThresholdAmps > 0
746
    }
747

            
748
    var isPaused: Bool {
749
        status == .paused
750
    }
751

            
752
    var isOpen: Bool {
753
        status.isOpen
754
    }
Bogdan Timofte authored a month ago
755
}
756

            
Bogdan Timofte authored a month ago
757
enum BatteryLevelPredictionBasis: Hashable {
758
    case capacityEstimate
759
    case checkpointEnergyMap
760

            
761
    var metricLabel: String {
762
        switch self {
763
        case .capacityEstimate:
764
            return "est. capacity"
765
        case .checkpointEnergyMap:
766
            return "energy map"
767
        }
768
    }
769

            
770
    var explanatoryLabel: String {
771
        switch self {
772
        case .capacityEstimate:
773
            return "estimated capacity"
774
        case .checkpointEnergyMap:
775
            return "checkpoint energy map"
776
        }
777
    }
778
}
779

            
Bogdan Timofte authored a month ago
780
struct BatteryLevelPrediction: Hashable {
781
    let predictedPercent: Double
Bogdan Timofte authored a month ago
782
    let estimatedCapacityWh: Double?
783
    let basis: BatteryLevelPredictionBasis
Bogdan Timofte authored a month ago
784
    let anchorPercent: Double
785
    let anchorEnergyWh: Double
786
    let anchorDescription: String
Bogdan Timofte authored a month ago
787

            
788
    func energyWh(forPercent percent: Double) -> Double? {
789
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
790
            return nil
791
        }
792

            
793
        return anchorEnergyWh + ((percent - anchorPercent) / 100) * estimatedCapacityWh
794
    }
Bogdan Timofte authored a month ago
795
}
796

            
Bogdan Timofte authored a month ago
797
enum BatteryLevelPredictionTuning {
798
    static let checkpointSettleDuration: TimeInterval = 10 * 60
799

            
800
    static func predictedPercent(
801
        anchorPercent: Double,
802
        anchorEnergyWh: Double,
803
        anchorTimestamp: Date,
804
        anchorIsCheckpoint: Bool,
805
        effectiveEnergyWh: Double,
806
        referenceTimestamp: Date,
807
        estimatedCapacityWh: Double
808
    ) -> Double {
809
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
810
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
811
        let stabilizedGainPercent: Double
812

            
813
        if anchorIsCheckpoint {
814
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
815
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
816
            stabilizedGainPercent = rawGainPercent * settleProgress
817
        } else {
818
            stabilizedGainPercent = rawGainPercent
819
        }
820

            
821
        return min(
822
            100,
823
            max(
824
                0,
825
                anchorPercent + stabilizedGainPercent
826
            )
827
        )
828
    }
829
}
830

            
Bogdan Timofte authored a month ago
831
struct CapacityTrendPoint: Identifiable, Hashable {
832
    let sessionID: UUID
833
    let timestamp: Date
834
    let capacityWh: Double
835
    let chargingTransportMode: ChargingTransportMode
836

            
837
    var id: UUID { sessionID }
838
}
839

            
840
struct TypicalChargeCurvePoint: Identifiable, Hashable {
841
    let percentBin: Int
842
    let averageEnergyWh: Double
843
    let sampleCount: Int
844

            
845
    var id: Int { percentBin }
846
}
847

            
Bogdan Timofte authored a month ago
848
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
849
    let timestamp: Date
850
    let powerWatts: Double
851
    let currentAmps: Double
852
    let voltageVolts: Double
853

            
854
    var id: TimeInterval {
855
        timestamp.timeIntervalSince1970
856
    }
857
}
858

            
Bogdan Timofte authored a month ago
859
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
Bogdan Timofte authored a month ago
860
    let index: Int
861
    let lowerBoundWatts: Double
862
    let upperBoundWatts: Double
863
    let count: Int
864
    let relativeFrequency: Double
865

            
866
    var id: Int { index }
867
}
868

            
Bogdan Timofte authored a month ago
869
enum HistogramResolution: Int, CaseIterable, Identifiable {
870
    case x1 = 1
871
    case x2 = 2
872
    case x4 = 4
873

            
874
    var id: Int { rawValue }
875

            
876
    var label: String {
877
        switch self {
878
        case .x1: return "1×"
879
        case .x2: return "2×"
880
        case .x4: return "4×"
881
        }
882
    }
883
}
884

            
Bogdan Timofte authored a month ago
885
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
886
    let sampleCount: Int
887
    let observedDuration: TimeInterval
888
    let averagePowerWatts: Double
889
    let recentAveragePowerWatts: Double
890
    let medianPowerWatts: Double
891
    let minimumPowerWatts: Double
892
    let maximumPowerWatts: Double
893
    let standardDeviationPowerWatts: Double
894
    let coefficientOfVariation: Double
895
    let averageCurrentAmps: Double
896
    let averageVoltageVolts: Double
897
    let stabilityDeltaWatts: Double
898
    let stabilityToleranceWatts: Double
899
    let histogram: [ChargerStandbyPowerDistributionBin]
900

            
901
    var projectedDailyEnergyWh: Double {
902
        averagePowerWatts * 24
903
    }
904

            
905
    var projectedWeeklyEnergyWh: Double {
906
        averagePowerWatts * 24 * 7
907
    }
908

            
909
    var projectedMonthlyEnergyWh: Double {
910
        averagePowerWatts * 24 * 30
911
    }
912

            
913
    var projectedYearlyEnergyWh: Double {
914
        averagePowerWatts * 24 * 365
915
    }
916

            
917
    var stabilityDeltaMilliwatts: Double {
918
        stabilityDeltaWatts * 1000
919
    }
920

            
921
    var isStable: Bool {
922
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
923
        && stabilityDeltaWatts <= stabilityToleranceWatts
924
    }
925
}
926

            
927
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
928
    let id: UUID
929
    let chargerID: UUID
930
    let meterMACAddress: String
931
    let meterName: String?
932
    let meterModel: String?
933
    let startedAt: Date
934
    let endedAt: Date
935
    let sampleCount: Int
936
    let stabilizedAt: Date?
937
    let averagePowerWatts: Double
938
    let recentAveragePowerWatts: Double
939
    let medianPowerWatts: Double
940
    let minimumPowerWatts: Double
941
    let maximumPowerWatts: Double
942
    let standardDeviationPowerWatts: Double
943
    let coefficientOfVariation: Double
944
    let averageCurrentAmps: Double
945
    let averageVoltageVolts: Double
946
    let stabilityDeltaWatts: Double
947
    let stabilityToleranceWatts: Double
Bogdan Timofte authored a month ago
948
    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
949
    let storedHistogram: [ChargerStandbyPowerDistributionBin]
950

            
951
    // MARK: - Codable (with migration from legacy powerSamplesWatts)
952

            
953
    private enum CodingKeys: String, CodingKey {
954
        case id, chargerID, meterMACAddress, meterName, meterModel
955
        case startedAt, endedAt, sampleCount, stabilizedAt
956
        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
957
        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
958
        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
959
        case stabilityDeltaWatts, stabilityToleranceWatts
960
        case storedHistogram
961
        case powerSamplesWatts // legacy – decode only
962
    }
963

            
964
    init(
965
        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
966
        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
967
        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
968
        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
969
        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
970
        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
971
        storedHistogram: [ChargerStandbyPowerDistributionBin]
972
    ) {
973
        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
974
        self.meterName = meterName; self.meterModel = meterModel
975
        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
976
        self.stabilizedAt = stabilizedAt
977
        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
978
        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
979
        self.maximumPowerWatts = maximumPowerWatts
980
        self.standardDeviationPowerWatts = standardDeviationPowerWatts
981
        self.coefficientOfVariation = coefficientOfVariation
982
        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
983
        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
984
        self.storedHistogram = storedHistogram
985
    }
986

            
987
    init(from decoder: Decoder) throws {
988
        let c = try decoder.container(keyedBy: CodingKeys.self)
989
        id = try c.decode(UUID.self, forKey: .id)
990
        chargerID = try c.decode(UUID.self, forKey: .chargerID)
991
        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
992
        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
993
        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
994
        startedAt = try c.decode(Date.self, forKey: .startedAt)
995
        endedAt = try c.decode(Date.self, forKey: .endedAt)
996
        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
997
        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
998
        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
999
        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
1000
        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
1001
        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
1002
        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
1003
        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
1004
        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
1005
        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
1006
        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
1007
        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
1008
        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)
1009

            
1010
        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
1011
        if let decodedBins, !decodedBins.isEmpty {
1012
            storedHistogram = decodedBins
1013
        } else {
1014
            // Migrate from legacy raw samples format
1015
            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
1016
            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
1017
            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
1018
                for: samples,
1019
                preferredBinCount: base * HistogramResolution.x4.rawValue
1020
            )
1021
        }
Bogdan Timofte authored a month ago
1022
    }
1023

            
Bogdan Timofte authored a month ago
1024
    func encode(to encoder: Encoder) throws {
1025
        var c = encoder.container(keyedBy: CodingKeys.self)
1026
        try c.encode(id, forKey: .id)
1027
        try c.encode(chargerID, forKey: .chargerID)
1028
        try c.encode(meterMACAddress, forKey: .meterMACAddress)
1029
        try c.encodeIfPresent(meterName, forKey: .meterName)
1030
        try c.encodeIfPresent(meterModel, forKey: .meterModel)
1031
        try c.encode(startedAt, forKey: .startedAt)
1032
        try c.encode(endedAt, forKey: .endedAt)
1033
        try c.encode(sampleCount, forKey: .sampleCount)
1034
        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
1035
        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
1036
        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
1037
        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
1038
        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
1039
        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
1040
        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
1041
        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
1042
        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
1043
        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
1044
        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
1045
        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
1046
        try c.encode(storedHistogram, forKey: .storedHistogram)
1047
    }
1048

            
1049
    // MARK: - Computed
1050

            
1051
    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
1052
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
1053
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
1054
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
1055
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
1056
    var isStable: Bool { stabilizedAt != nil }
1057

            
1058
    /// Returns the histogram downsampled to the requested resolution.
1059
    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
1060
    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
1061
        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
1062
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
Bogdan Timofte authored a month ago
1063
    }
1064
}
1065

            
1066
enum ChargerStandbyPowerMeasurementAnalyzer {
1067
    static let minimumStableSampleCount = 45
Bogdan Timofte authored a month ago
1068
    static let recentSampleWindow = 40
1069
    static let minimumStabilityToleranceWatts = 0.010
1070
    static let relativeStabilityTolerance = 0.05
Bogdan Timofte authored a month ago
1071

            
1072
    static func statistics(
1073
        from samples: [ChargerStandbyPowerSample],
1074
        startedAt: Date,
1075
        referenceDate: Date = Date()
1076
    ) -> ChargerStandbyPowerMeasurementStatistics? {
1077
        guard !samples.isEmpty else {
1078
            return nil
1079
        }
1080

            
1081
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
1082
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
1083
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
1084

            
1085
        guard powerValues.isEmpty == false else {
1086
            return nil
1087
        }
1088

            
1089
        let averagePower = mean(powerValues)
1090
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
1091
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
1092
        let stabilityDelta = abs(averagePower - recentAveragePower)
1093
        let stabilityTolerance = max(
1094
            minimumStabilityToleranceWatts,
1095
            abs(averagePower) * relativeStabilityTolerance
1096
        )
1097

            
Bogdan Timofte authored a month ago
1098
        let baseBinCount = min(18, max(8, Int(Double(powerValues.count).squareRoot().rounded())))
1099
        let liveHistogram = histogram(for: powerValues, preferredBinCount: baseBinCount * HistogramResolution.x4.rawValue)
1100

            
Bogdan Timofte authored a month ago
1101
        return ChargerStandbyPowerMeasurementStatistics(
1102
            sampleCount: powerValues.count,
1103
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
1104
            averagePowerWatts: averagePower,
1105
            recentAveragePowerWatts: recentAveragePower,
1106
            medianPowerWatts: median(powerValues),
1107
            minimumPowerWatts: powerValues.min() ?? 0,
1108
            maximumPowerWatts: powerValues.max() ?? 0,
1109
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
1110
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
1111
            averageCurrentAmps: mean(currentValues),
1112
            averageVoltageVolts: mean(voltageValues),
1113
            stabilityDeltaWatts: stabilityDelta,
1114
            stabilityToleranceWatts: stabilityTolerance,
Bogdan Timofte authored a month ago
1115
            histogram: liveHistogram
Bogdan Timofte authored a month ago
1116
        )
1117
    }
1118

            
1119
    static func measurementSummary(
1120
        chargerID: UUID,
1121
        meterMACAddress: String,
1122
        meterName: String?,
1123
        meterModel: String?,
1124
        startedAt: Date,
1125
        endedAt: Date,
1126
        samples: [ChargerStandbyPowerSample],
1127
        stabilizedAt: Date?
1128
    ) -> ChargerStandbyPowerMeasurementSummary? {
1129
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
1130
            return nil
1131
        }
1132

            
1133
        return ChargerStandbyPowerMeasurementSummary(
1134
            id: UUID(),
1135
            chargerID: chargerID,
1136
            meterMACAddress: meterMACAddress,
1137
            meterName: meterName,
1138
            meterModel: meterModel,
1139
            startedAt: startedAt,
1140
            endedAt: endedAt,
1141
            sampleCount: statistics.sampleCount,
1142
            stabilizedAt: stabilizedAt,
1143
            averagePowerWatts: statistics.averagePowerWatts,
1144
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
1145
            medianPowerWatts: statistics.medianPowerWatts,
1146
            minimumPowerWatts: statistics.minimumPowerWatts,
1147
            maximumPowerWatts: statistics.maximumPowerWatts,
1148
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
1149
            coefficientOfVariation: statistics.coefficientOfVariation,
1150
            averageCurrentAmps: statistics.averageCurrentAmps,
1151
            averageVoltageVolts: statistics.averageVoltageVolts,
1152
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
1153
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
Bogdan Timofte authored a month ago
1154
            storedHistogram: statistics.histogram
Bogdan Timofte authored a month ago
1155
        )
1156
    }
1157

            
Bogdan Timofte authored a month ago
1158
    /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1.
1159
    static func downsample(
1160
        _ bins: [ChargerStandbyPowerDistributionBin],
1161
        factor: Int
1162
    ) -> [ChargerStandbyPowerDistributionBin] {
1163
        guard factor > 1, !bins.isEmpty else { return bins }
1164
        let totalCount = bins.reduce(0) { $0 + $1.count }
1165
        var result: [ChargerStandbyPowerDistributionBin] = []
1166
        var inputIndex = 0
1167
        var outputIndex = 0
1168
        while inputIndex < bins.count {
1169
            let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)])
1170
            let mergedCount = group.reduce(0) { $0 + $1.count }
1171
            let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0
1172
            result.append(ChargerStandbyPowerDistributionBin(
1173
                index: outputIndex,
1174
                lowerBoundWatts: group.first!.lowerBoundWatts,
1175
                upperBoundWatts: group.last!.upperBoundWatts,
1176
                count: mergedCount,
1177
                relativeFrequency: relFreq
1178
            ))
1179
            inputIndex += factor
1180
            outputIndex += 1
1181
        }
1182
        return result
1183
    }
1184

            
Bogdan Timofte authored a month ago
1185
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
1186
        let finiteValues = values.filter(\.isFinite)
1187
        guard finiteValues.isEmpty == false else {
1188
            return []
1189
        }
1190

            
1191
        let minimum = finiteValues.min() ?? 0
1192
        let maximum = finiteValues.max() ?? 0
1193
        let spread = maximum - minimum
1194
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
1195

            
1196
        guard spread > 0 else {
1197
            return [
1198
                ChargerStandbyPowerDistributionBin(
1199
                    index: 0,
1200
                    lowerBoundWatts: minimum,
1201
                    upperBoundWatts: maximum,
1202
                    count: finiteValues.count,
1203
                    relativeFrequency: 1
1204
                )
1205
            ]
1206
        }
1207

            
1208
        let safeBinCount = max(1, binCount)
1209
        let binWidth = spread / Double(safeBinCount)
1210
        var counts = Array(repeating: 0, count: safeBinCount)
1211

            
1212
        for value in finiteValues {
1213
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
1214
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
1215
            counts[safeIndex] += 1
1216
        }
1217

            
1218
        return counts.enumerated().map { index, count in
1219
            let lowerBound = minimum + (Double(index) * binWidth)
1220
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
1221

            
1222
            return ChargerStandbyPowerDistributionBin(
1223
                index: index,
1224
                lowerBoundWatts: lowerBound,
1225
                upperBoundWatts: upperBound,
1226
                count: count,
1227
                relativeFrequency: Double(count) / Double(finiteValues.count)
1228
            )
1229
        }
1230
    }
1231

            
1232
    private static func mean(_ values: [Double]) -> Double {
1233
        guard values.isEmpty == false else {
1234
            return 0
1235
        }
1236
        return values.reduce(0, +) / Double(values.count)
1237
    }
1238

            
1239
    private static func median(_ values: [Double]) -> Double {
1240
        guard values.isEmpty == false else {
1241
            return 0
1242
        }
1243

            
1244
        let sorted = values.sorted()
1245
        let middleIndex = sorted.count / 2
1246

            
1247
        if sorted.count.isMultiple(of: 2) {
1248
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
1249
        }
1250

            
1251
        return sorted[middleIndex]
1252
    }
1253

            
1254
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
1255
        guard values.count > 1 else {
1256
            return 0
1257
        }
1258

            
1259
        let variance = values.reduce(0) { partialResult, value in
1260
            let delta = value - mean
1261
            return partialResult + (delta * delta)
1262
        } / Double(values.count)
1263

            
1264
        return variance.squareRoot()
1265
    }
1266

            
1267
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
1268
        guard abs(mean) > 0.000_001 else {
1269
            return 0
1270
        }
1271

            
1272
        return standardDeviation(values, mean: mean) / abs(mean)
1273
    }
1274
}
1275

            
Bogdan Timofte authored a month ago
1276
struct ChargedDeviceSummary: Identifiable, Hashable {
1277
    let id: UUID
1278
    let qrIdentifier: String
1279
    let name: String
1280
    let deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
1281
    let deviceTemplateID: String?
1282
    let templateDefinition: ChargedDeviceTemplateDefinition?
Bogdan Timofte authored a month ago
1283
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
1284
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
1285
    let supportsWiredCharging: Bool
1286
    let supportsWirelessCharging: Bool
Bogdan Timofte authored a month ago
1287
    let chargerType: ChargerType?
Bogdan Timofte authored a month ago
1288
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
1289
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
1290
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
1291
    let wirelessChargerEfficiencyFactor: Double?
1292
    let wiredChargeCompletionCurrentAmps: Double?
1293
    let wirelessChargeCompletionCurrentAmps: Double?
1294
    let chargerObservedVoltageSelections: [Double]
1295
    let chargerIdleCurrentAmps: Double?
1296
    let chargerEfficiencyFactor: Double?
1297
    let chargerMaximumPowerWatts: Double?
1298
    let notes: String?
1299
    let minimumCurrentAmps: Double?
1300
    let estimatedBatteryCapacityWh: Double?
1301
    let wiredMinimumCurrentAmps: Double?
1302
    let wirelessMinimumCurrentAmps: Double?
1303
    let wiredEstimatedBatteryCapacityWh: Double?
1304
    let wirelessEstimatedBatteryCapacityWh: Double?
1305
    let createdAt: Date
1306
    let updatedAt: Date
1307
    let sessions: [ChargeSessionSummary]
1308
    let capacityHistory: [CapacityTrendPoint]
1309
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
1310
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
1311

            
1312
    var isCharger: Bool {
1313
        deviceClass == .charger
1314
    }
1315

            
Bogdan Timofte authored a month ago
1316
    var kind: ChargedDeviceKind {
1317
        deviceClass.kind
1318
    }
1319

            
1320
    var identityTitle: String {
Bogdan Timofte authored a month ago
1321
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1322
    }
1323

            
Bogdan Timofte authored a month ago
1324
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1325
        isCharger ? kind.symbolName : deviceClass.symbolName
1326
    }
1327

            
Bogdan Timofte authored a month ago
1328
    var identityIcon: ChargedDeviceTemplateIcon {
1329
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1330
    }
1331

            
1332
    var identitySymbolName: String {
1333
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1334
    }
1335

            
Bogdan Timofte authored a month ago
1336
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1337
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1338
    }
1339

            
1340
    var recentCompletedSessions: [ChargeSessionSummary] {
1341
        sessions.filter { $0.status == .completed }
1342
    }
1343

            
1344
    var sessionCount: Int {
1345
        sessions.count
1346
    }
1347

            
Bogdan Timofte authored a month ago
1348
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1349
        standbyPowerMeasurements.first
1350
    }
1351

            
Bogdan Timofte authored a month ago
1352
    var supportedChargingModes: [ChargingTransportMode] {
1353
        var modes: [ChargingTransportMode] = []
1354
        if supportsWiredCharging {
1355
            modes.append(.wired)
1356
        }
1357
        if supportsWirelessCharging {
1358
            modes.append(.wireless)
1359
        }
Bogdan Timofte authored a month ago
1360
        return modes
Bogdan Timofte authored a month ago
1361
    }
1362

            
Bogdan Timofte authored a month ago
1363
    var supportedChargingStateModes: [ChargingStateMode] {
1364
        chargingStateAvailability.supportedModes
1365
    }
1366

            
Bogdan Timofte authored a month ago
1367
    var hasMultipleChargingTransports: Bool {
1368
        supportedChargingModes.count > 1
1369
    }
1370

            
1371
    var hasMultipleChargingStateModes: Bool {
1372
        supportedChargingStateModes.count > 1
1373
    }
1374

            
1375
    var showsWirelessProfileDetails: Bool {
1376
        supportsWirelessCharging
1377
            && hasMultipleChargingTransports
1378
            && deviceClass != .watch
1379
    }
1380

            
1381
    var chargingSupportSummary: String {
1382
        switch (supportsWiredCharging, supportsWirelessCharging) {
1383
        case (true, true):
1384
            return "Supports wired and wireless charging."
1385
        case (true, false):
1386
            return "Supports wired charging only."
1387
        case (false, true):
1388
            return "Supports wireless charging only."
1389
        case (false, false):
1390
            return "No charging method configured."
1391
        }
1392
    }
1393

            
Bogdan Timofte authored a month ago
1394
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1395
        if let matchingSession = sessions.first(where: {
1396
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1397
        }) {
1398
            return matchingSession.chargingStateMode
1399
        }
1400
        return chargingStateAvailability.supportedModes.first ?? .on
1401
    }
1402

            
1403
    func sessionKind(
1404
        for chargingTransportMode: ChargingTransportMode,
1405
        chargingStateMode: ChargingStateMode? = nil
1406
    ) -> ChargeSessionKind {
1407
        ChargeSessionKind(
1408
            chargingTransportMode: chargingTransportMode,
1409
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1410
        )
1411
    }
1412

            
Bogdan Timofte authored a month ago
1413
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1414
        switch chargingTransportMode {
1415
        case .wired:
1416
            return wiredEstimatedBatteryCapacityWh
1417
        case .wireless:
1418
            return wirelessEstimatedBatteryCapacityWh
1419
        }
1420
    }
1421

            
1422
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1423
        switch chargingTransportMode {
1424
        case .wired:
1425
            return wiredMinimumCurrentAmps
1426
        case .wireless:
1427
            return wirelessMinimumCurrentAmps
1428
        }
1429
    }
1430

            
Bogdan Timofte authored a month ago
1431
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1432
        hasMultipleChargingTransports
1433
            || supportedChargingModes.contains(chargingTransportMode) == false
1434
    }
1435

            
1436
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1437
        hasMultipleChargingStateModes
1438
            || supportedChargingStateModes.contains(chargingStateMode) == false
1439
    }
1440

            
Bogdan Timofte authored a month ago
1441
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1442
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
1443
            return explicitCurrent
1444
        }
1445

            
1446
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
1447
        case .wired:
1448
            return wiredChargeCompletionCurrentAmps
1449
        case .wireless:
1450
            return wirelessChargeCompletionCurrentAmps
1451
        }
1452
    }
1453

            
Bogdan Timofte authored a month ago
1454
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1455
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
1456
            return learnedCurrent
1457
        }
1458

            
1459
        switch sessionKind.chargingTransportMode {
1460
        case .wired:
1461
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
1462
        case .wireless:
1463
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
1464
        }
1465
    }
1466

            
1467
    func resolvedCompletionCurrentAmps(
1468
        for chargingTransportMode: ChargingTransportMode,
1469
        chargingStateMode: ChargingStateMode? = nil
1470
    ) -> Double? {
1471
        let sessionKind = sessionKind(
1472
            for: chargingTransportMode,
1473
            chargingStateMode: chargingStateMode
1474
        )
1475

            
1476
        return configuredCompletionCurrentAmps(for: sessionKind)
1477
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
1478
            ?? minimumCurrentAmps(for: chargingTransportMode)
1479
            ?? minimumCurrentAmps
1480
    }
1481

            
Bogdan Timofte authored a month ago
1482
    func batteryLevelPrediction(
1483
        for session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1484
        effectiveEnergyWhOverride: Double? = nil,
1485
        referenceTimestamp: Date? = nil
Bogdan Timofte authored a month ago
1486
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1487
        let estimatedCapacityWh = session.capacityEstimateWh
1488
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1489
            ?? estimatedBatteryCapacityWh
1490

            
Bogdan Timofte authored a month ago
1491
        let effectiveEnergyWh = effectiveEnergyWhOverride
1492
            ?? session.effectiveBatteryEnergyWh
1493
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1494

            
1495
        struct Anchor {
1496
            let percent: Double
1497
            let energyWh: Double
Bogdan Timofte authored a month ago
1498
            let timestamp: Date
Bogdan Timofte authored a month ago
1499
            let description: String
Bogdan Timofte authored a month ago
1500
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1501
        }
1502

            
Bogdan Timofte authored a month ago
1503
        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
1504
            var candidates: [Double] = []
1505

            
1506
            for lowerIndex in anchors.indices {
1507
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
1508
                    let lower = anchors[lowerIndex]
1509
                    let upper = anchors[upperIndex]
1510
                    let percentDelta = upper.percent - lower.percent
1511
                    let energyDelta = upper.energyWh - lower.energyWh
1512

            
1513
                    guard percentDelta >= 3, energyDelta > 0.01 else {
1514
                        continue
1515
                    }
1516

            
1517
                    let capacityWh = energyDelta / (percentDelta / 100)
1518
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
1519
                        continue
1520
                    }
1521

            
1522
                    candidates.append(capacityWh)
1523
                }
1524
            }
1525

            
1526
            return candidates
1527
        }
1528

            
1529
        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
1530
            let candidates = anchorCapacityCandidates(from: anchors)
1531
            guard !candidates.isEmpty else {
1532
                return nil
1533
            }
1534

            
1535
            let sortedCandidates = candidates.sorted()
1536
            return sortedCandidates[sortedCandidates.count / 2]
1537
        }
1538

            
Bogdan Timofte authored a month ago
1539
        var anchors: [Anchor] = []
1540

            
Bogdan Timofte authored a month ago
1541
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1542
            anchors.append(
1543
                Anchor(
1544
                    percent: startBatteryPercent,
1545
                    energyWh: 0,
Bogdan Timofte authored a month ago
1546
                    timestamp: session.effectiveTrimStart,
Bogdan Timofte authored a month ago
1547
                    description: "session start",
1548
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1549
                )
1550
            )
1551
        }
1552

            
1553
        anchors.append(
1554
            contentsOf: session.checkpoints
Bogdan Timofte authored a month ago
1555
                .filter { $0.batteryPercent >= 0 }
Bogdan Timofte authored a month ago
1556
                .map { checkpoint in
Bogdan Timofte authored a month ago
1557
                    Anchor(
Bogdan Timofte authored a month ago
1558
                        percent: checkpoint.batteryPercent,
1559
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1560
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
1561
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
1562
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1563
                    )
1564
                }
1565
        )
1566

            
Bogdan Timofte authored a month ago
1567
        let sortedAnchors = anchors.sorted { lhs, rhs in
1568
            if lhs.energyWh != rhs.energyWh {
1569
                return lhs.energyWh < rhs.energyWh
1570
            }
1571
            return lhs.timestamp < rhs.timestamp
1572
        }
1573

            
1574
        guard !sortedAnchors.isEmpty else {
Bogdan Timofte authored a month ago
1575
            return nil
1576
        }
1577

            
Bogdan Timofte authored a month ago
1578
        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
1579
        let inferredCapacityWh = estimatedCapacityWh
1580
            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
1581
        let basis: BatteryLevelPredictionBasis = estimatedCapacityWh == nil
1582
            ? .checkpointEnergyMap
1583
            : .capacityEstimate
1584

            
1585
        let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
1586
        let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
1587
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
Bogdan Timofte authored a month ago
1588

            
1589
        let predictedPercent: Double
1590
        if let lowerAnchor,
1591
           let upperAnchor,
1592
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
1593
            let interpolationProgress = min(
1594
                max(
1595
                    (effectiveEnergyWh - lowerAnchor.energyWh) /
1596
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
1597
                    0
1598
                ),
1599
                1
1600
            )
1601
            predictedPercent = min(
1602
                max(
1603
                    lowerAnchor.percent +
1604
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
1605
                    0
1606
                ),
1607
                100
1608
            )
1609
        } else {
Bogdan Timofte authored a month ago
1610
            guard let inferredCapacityWh, inferredCapacityWh > 0 else {
1611
                return nil
1612
            }
1613

            
Bogdan Timofte authored a month ago
1614
            predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1615
                anchorPercent: anchor.percent,
1616
                anchorEnergyWh: anchor.energyWh,
1617
                anchorTimestamp: anchor.timestamp,
1618
                anchorIsCheckpoint: anchor.isCheckpoint,
1619
                effectiveEnergyWh: effectiveEnergyWh,
1620
                referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
Bogdan Timofte authored a month ago
1621
                estimatedCapacityWh: inferredCapacityWh
Bogdan Timofte authored a month ago
1622
            )
1623
        }
Bogdan Timofte authored a month ago
1624

            
1625
        return BatteryLevelPrediction(
1626
            predictedPercent: predictedPercent,
Bogdan Timofte authored a month ago
1627
            estimatedCapacityWh: inferredCapacityWh,
1628
            basis: basis,
Bogdan Timofte authored a month ago
1629
            anchorPercent: anchor.percent,
1630
            anchorEnergyWh: anchor.energyWh,
1631
            anchorDescription: anchor.description
1632
        )
1633
    }
Bogdan Timofte authored a month ago
1634

            
1635
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1636
        ChargedDeviceSummary(
1637
            id: id,
1638
            qrIdentifier: qrIdentifier,
1639
            name: name,
1640
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1641
            deviceTemplateID: deviceTemplateID,
1642
            templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1643
            supportsChargingWhileOff: supportsChargingWhileOff,
1644
            chargingStateAvailability: chargingStateAvailability,
1645
            supportsWiredCharging: supportsWiredCharging,
1646
            supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1647
            chargerType: chargerType,
Bogdan Timofte authored a month ago
1648
            wirelessChargingProfile: wirelessChargingProfile,
1649
            configuredCompletionCurrents: configuredCompletionCurrents,
1650
            learnedCompletionCurrents: learnedCompletionCurrents,
1651
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1652
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1653
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1654
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1655
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1656
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1657
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1658
            notes: notes,
1659
            minimumCurrentAmps: minimumCurrentAmps,
1660
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1661
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1662
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1663
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1664
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1665
            createdAt: createdAt,
1666
            updatedAt: updatedAt,
1667
            sessions: sessions,
1668
            capacityHistory: capacityHistory,
1669
            typicalCurve: typicalCurve,
1670
            standbyPowerMeasurements: measurements
1671
        )
1672
    }
Bogdan Timofte authored a month ago
1673
}
1674

            
1675
struct ChargingMonitorSnapshot {
1676
    let meterMACAddress: String
1677
    let meterName: String
1678
    let meterModel: String
1679
    let observedAt: Date
1680
    let voltageVolts: Double
1681
    let currentAmps: Double
1682
    let powerWatts: Double
1683
    let selectedDataGroup: UInt8?
1684
    let meterChargeCounterAh: Double?
1685
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1686
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1687
    let fallbackStopThresholdAmps: Double
1688
}