USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
1514 lines | 50.18kb
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

            
117
    func normalizedChargingSupport(
118
        supportsWiredCharging: Bool,
119
        supportsWirelessCharging: Bool
120
    ) -> (wired: Bool, wireless: Bool) {
121
        enforcedChargingSupport ?? (wired: supportsWiredCharging, wireless: supportsWirelessCharging)
122
    }
123

            
124
    func normalizedChargingStateAvailability(
125
        _ chargingStateAvailability: ChargingStateAvailability
126
    ) -> ChargingStateAvailability {
127
        enforcedChargingStateAvailability ?? chargingStateAvailability
128
    }
Bogdan Timofte authored a month ago
129
}
130

            
131
enum ChargeSessionStatus: String {
132
    case active
Bogdan Timofte authored a month ago
133
    case paused
Bogdan Timofte authored a month ago
134
    case completed
135
    case abandoned
136

            
137
    var title: String {
Bogdan Timofte authored a month ago
138
        switch self {
139
        case .active:
140
            return "Active"
141
        case .paused:
142
            return "Paused"
143
        case .completed:
144
            return "Completed"
145
        case .abandoned:
146
            return "Abandoned"
147
        }
148
    }
149

            
150
    var isOpen: Bool {
151
        switch self {
152
        case .active, .paused:
153
            return true
154
        case .completed, .abandoned:
155
            return false
156
        }
Bogdan Timofte authored a month ago
157
    }
158
}
159

            
160
enum ChargeSessionSourceMode: String {
161
    case live
162
    case offline
163
    case blended
164

            
165
    var title: String {
166
        switch self {
167
        case .live:
168
            return "Live"
169
        case .offline:
170
            return "Offline Counters"
171
        case .blended:
172
            return "Blended"
173
        }
174
    }
175
}
176

            
Bogdan Timofte authored a month ago
177
enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
178
    case wired
179
    case wireless
180

            
181
    var id: String { rawValue }
182

            
183
    var title: String {
184
        switch self {
185
        case .wired:
186
            return "Wired"
187
        case .wireless:
188
            return "Wireless"
189
        }
190
    }
191

            
192
    var symbolName: String {
193
        switch self {
194
        case .wired:
195
            return "cable.connector"
196
        case .wireless:
197
            return "dot.radiowaves.left.and.right"
198
        }
199
    }
200
}
201

            
Bogdan Timofte authored a month ago
202
enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
203
    case on
204
    case off
205

            
206
    var id: String { rawValue }
207

            
208
    var title: String {
209
        switch self {
210
        case .on:
211
            return "On"
212
        case .off:
213
            return "Off"
214
        }
215
    }
216

            
217
    var description: String {
218
        switch self {
219
        case .on:
220
            return "Device stays powered on while charging."
221
        case .off:
222
            return "Device is powered off while charging."
223
        }
224
    }
225
}
226

            
227
enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
228
    case onOnly
229
    case onOrOff
230
    case offOnly
231

            
232
    var id: String { rawValue }
233

            
234
    var title: String {
235
        switch self {
236
        case .onOnly:
237
            return "On Only"
238
        case .onOrOff:
239
            return "On or Off"
240
        case .offOnly:
241
            return "Off Only"
242
        }
243
    }
244

            
245
    var description: String {
246
        switch self {
247
        case .onOnly:
248
            return "The device can be recorded only while it is powered on."
249
        case .onOrOff:
250
            return "The session must specify whether the device is on or off."
251
        case .offOnly:
252
            return "The device can be recorded only while it is powered off."
253
        }
254
    }
255

            
256
    var supportedModes: [ChargingStateMode] {
257
        switch self {
258
        case .onOnly:
259
            return [.on]
260
        case .onOrOff:
261
            return [.on, .off]
262
        case .offOnly:
263
            return [.off]
264
        }
265
    }
266

            
267
    var supportsMultipleModes: Bool {
268
        supportedModes.count > 1
269
    }
270

            
271
    var supportsChargingWhileOff: Bool {
272
        self != .onOnly
273
    }
274

            
275
    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
276
        supportsChargingWhileOff ? .onOrOff : .onOnly
277
    }
278
}
279

            
280
enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
281
    case wiredOn
282
    case wiredOff
283
    case wirelessOn
284
    case wirelessOff
285

            
286
    var id: String { rawValue }
287

            
288
    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
289
        switch (chargingTransportMode, chargingStateMode) {
290
        case (.wired, .on):
291
            self = .wiredOn
292
        case (.wired, .off):
293
            self = .wiredOff
294
        case (.wireless, .on):
295
            self = .wirelessOn
296
        case (.wireless, .off):
297
            self = .wirelessOff
298
        }
299
    }
300

            
301
    var chargingTransportMode: ChargingTransportMode {
302
        switch self {
303
        case .wiredOn, .wiredOff:
304
            return .wired
305
        case .wirelessOn, .wirelessOff:
306
            return .wireless
307
        }
308
    }
309

            
310
    var chargingStateMode: ChargingStateMode {
311
        switch self {
312
        case .wiredOn, .wirelessOn:
313
            return .on
314
        case .wiredOff, .wirelessOff:
315
            return .off
316
        }
317
    }
318

            
319
    var title: String {
320
        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
321
    }
322

            
323
    var shortTitle: String {
324
        "\(chargingTransportMode.title) \(chargingStateMode.title)"
325
    }
326
}
327

            
Bogdan Timofte authored a month ago
328
enum WirelessChargingProfile: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
329
    case magsafe
330
    case genericQi
331

            
332
    var id: String { rawValue }
333

            
334
    var title: String {
335
        switch self {
336
        case .magsafe:
337
            return "MagSafe"
338
        case .genericQi:
339
            return "Generic Qi"
340
        }
341
    }
342

            
343
    var description: String {
344
        switch self {
345
        case .magsafe:
346
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
347
        case .genericQi:
348
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
349
        }
350
    }
351
}
352

            
Bogdan Timofte authored a month ago
353
enum ChargerType: String, CaseIterable, Identifiable, Codable {
354
    case appleMagSafe
355
    case appleWatch
356
    case genericMagSafe
357
    case genericQi
358

            
359
    var id: String { rawValue }
360

            
361
    var title: String {
362
        switch self {
363
        case .appleMagSafe: return "Apple MagSafe Charger"
364
        case .appleWatch: return "Apple Watch Charger"
365
        case .genericMagSafe: return "Generic MagSafe"
366
        case .genericQi: return "Generic Qi"
367
        }
368
    }
369

            
370
    var symbolName: String {
371
        switch self {
372
        case .appleMagSafe: return "magsafe.batterypack"
373
        case .appleWatch: return "applewatch.radiowaves.left.and.right"
374
        case .genericMagSafe: return "bolt.circle"
375
        case .genericQi: return "bolt.horizontal.circle"
376
        }
377
    }
378

            
379
    /// Whether this charger type uses magnetic alignment, enabling more accurate efficiency calibration.
380
    var supportsAlignment: Bool {
381
        switch self {
382
        case .appleMagSafe, .appleWatch, .genericMagSafe: return true
383
        case .genericQi: return false
384
        }
385
    }
386

            
387
    var wirelessChargingProfile: WirelessChargingProfile {
388
        supportsAlignment ? .magsafe : .genericQi
389
    }
390
}
391

            
Bogdan Timofte authored a month ago
392
enum ChargedDeviceTemplateIconSource: String, Codable {
393
    case systemSymbol
394
    case asset
395
}
396

            
397
struct ChargedDeviceTemplateIcon: Hashable, Codable {
398
    let type: ChargedDeviceTemplateIconSource
399
    let name: String
400
    let fallbackSystemName: String?
401

            
402
    static func systemSymbol(
403
        _ name: String,
404
        fallbackSystemName: String? = nil
405
    ) -> ChargedDeviceTemplateIcon {
406
        ChargedDeviceTemplateIcon(
407
            type: .systemSymbol,
408
            name: name,
409
            fallbackSystemName: fallbackSystemName
410
        )
411
    }
412

            
413
    func resolvedSystemSymbolName(fallbackSystemName: String) -> String {
414
        switch type {
415
        case .systemSymbol:
416
            return name
417
        case .asset:
418
            return self.fallbackSystemName ?? fallbackSystemName
419
        }
420
    }
421
}
422

            
423
struct ChargedDeviceTemplateDefinition: Identifiable, Hashable, Codable {
424
    let id: String
425
    let name: String
426
    let group: String
427
    let kind: ChargedDeviceKind
428
    let deviceClass: ChargedDeviceClass
429
    let icon: ChargedDeviceTemplateIcon
430
    let chargingStateAvailability: ChargingStateAvailability
431
    let supportsWiredCharging: Bool
432
    let supportsWirelessCharging: Bool
433
    let wirelessChargingProfile: WirelessChargingProfile
434
    let sortOrder: Int
435

            
436
    var chargingSupportSummary: String {
437
        switch (supportsWiredCharging, supportsWirelessCharging) {
438
        case (true, true):
439
            return "Wired + Wireless"
440
        case (true, false):
441
            return "Wired only"
442
        case (false, true):
443
            return "Wireless only"
444
        case (false, false):
445
            return "No charging transport"
446
        }
447
    }
448

            
449
    var capabilitySummary: String {
Bogdan Timofte authored a month ago
450
        if kind == .charger {
451
            return wirelessChargingProfile.title
452
        }
Bogdan Timofte authored a month ago
453
        var components = [chargingStateAvailability.title, chargingSupportSummary]
454
        if supportsWirelessCharging {
455
            components.append(wirelessChargingProfile.title)
456
        }
457
        return components.joined(separator: " • ")
458
    }
459
}
460

            
461
private struct ChargedDeviceTemplateDocument: Codable {
462
    let templates: [ChargedDeviceTemplateDefinition]
463
}
464

            
465
struct ChargedDeviceTemplateCatalog {
466
    static let shared = ChargedDeviceTemplateCatalog()
467

            
468
    let templates: [ChargedDeviceTemplateDefinition]
469
    private let templatesByID: [String: ChargedDeviceTemplateDefinition]
470

            
471
    private init(bundle: Bundle = .main) {
472
        let loadedTemplates: [ChargedDeviceTemplateDefinition]
473

            
474
        if let resourceURL = bundle.url(forResource: "ChargedDeviceTemplates", withExtension: "json"),
475
           let data = try? Data(contentsOf: resourceURL),
476
           let document = try? JSONDecoder().decode(ChargedDeviceTemplateDocument.self, from: data) {
477
            loadedTemplates = document.templates
478
        } else {
479
            loadedTemplates = []
480
        }
481

            
482
        self.templates = loadedTemplates.sorted { lhs, rhs in
483
            if lhs.group != rhs.group {
484
                return lhs.group < rhs.group
485
            }
486
            if lhs.sortOrder != rhs.sortOrder {
487
                return lhs.sortOrder < rhs.sortOrder
488
            }
489
            return lhs.name < rhs.name
490
        }
491
        self.templatesByID = Dictionary(uniqueKeysWithValues: self.templates.map { ($0.id, $0) })
492
    }
493

            
494
    func template(id: String?) -> ChargedDeviceTemplateDefinition? {
495
        guard let id else {
496
            return nil
497
        }
498
        return templatesByID[id]
499
    }
500

            
501
    func templates(for kind: ChargedDeviceKind) -> [ChargedDeviceTemplateDefinition] {
502
        templates.filter { $0.kind == kind }
503
    }
504
}
505

            
Bogdan Timofte authored a month ago
506
struct ChargeCheckpointSummary: Identifiable, Hashable {
507
    let id: UUID
508
    let sessionID: UUID
509
    let chargedDeviceID: UUID
510
    let timestamp: Date
511
    let batteryPercent: Double
512
    let measuredEnergyWh: Double
513
    let measuredChargeAh: Double
514
    let currentAmps: Double
515
    let voltageVolts: Double?
516
    let label: String?
Bogdan Timofte authored a month ago
517

            
518
    var flag: ChargeCheckpointFlag {
519
        ChargeCheckpointFlag.fromStoredLabel(label)
520
    }
521
}
522

            
523
enum ChargeCheckpointFlag: String, CaseIterable {
524
    case initial
525
    case intermediate
526
    case final
527

            
528
    var title: String {
529
        switch self {
530
        case .initial:
531
            return "Initial"
532
        case .intermediate:
533
            return "Intermediate"
534
        case .final:
535
            return "Final"
536
        }
537
    }
538

            
539
    var anchorDescription: String {
540
        switch self {
541
        case .initial:
542
            return "initial checkpoint"
543
        case .intermediate:
544
            return "intermediate checkpoint"
545
        case .final:
546
            return "final checkpoint"
547
        }
548
    }
549

            
550
    static func fromStoredLabel(_ label: String?) -> ChargeCheckpointFlag {
551
        let normalized = label?
552
            .trimmingCharacters(in: .whitespacesAndNewlines)
553
            .lowercased()
554

            
555
        switch normalized {
556
        case "initial", "start":
557
            return .initial
558
        case "final", "end":
559
            return .final
560
        case "intermediate", nil, "":
561
            return .intermediate
562
        default:
563
            return .intermediate
564
        }
565
    }
Bogdan Timofte authored a month ago
566
}
567

            
568
struct ChargeSessionSampleSummary: Identifiable, Hashable {
569
    let sessionID: UUID
570
    let chargedDeviceID: UUID
571
    let bucketIndex: Int
572
    let timestamp: Date
573
    let averageCurrentAmps: Double
574
    let averageVoltageVolts: Double?
575
    let averagePowerWatts: Double
576
    let measuredEnergyWh: Double
577
    let measuredChargeAh: Double
578
    let sampleCount: Int
579

            
580
    var id: String {
581
        "\(sessionID.uuidString)-\(bucketIndex)"
582
    }
583
}
584

            
585
struct ChargeSessionSummary: Identifiable, Hashable {
586
    let id: UUID
587
    let chargedDeviceID: UUID
588
    let chargerID: UUID?
589
    let meterMACAddress: String?
590
    let meterName: String?
591
    let meterModel: String?
592
    let startedAt: Date
593
    let endedAt: Date?
594
    let lastObservedAt: Date
Bogdan Timofte authored a month ago
595
    let pausedAt: Date?
Bogdan Timofte authored a month ago
596
    let status: ChargeSessionStatus
597
    let sourceMode: ChargeSessionSourceMode
598
    let chargingTransportMode: ChargingTransportMode
Bogdan Timofte authored a month ago
599
    let chargingStateMode: ChargingStateMode
600
    let autoStopEnabled: Bool
Bogdan Timofte authored a month ago
601
    let measuredEnergyWh: Double
602
    let effectiveBatteryEnergyWh: Double?
603
    let measuredChargeAh: Double
Bogdan Timofte authored a month ago
604
    let meterEnergyBaselineWh: Double?
605
    let meterChargeBaselineAh: Double?
Bogdan Timofte authored a month ago
606
    let meterDurationBaselineSeconds: Double?
607
    let meterLastDurationSeconds: Double?
Bogdan Timofte authored a month ago
608
    let minimumObservedCurrentAmps: Double?
609
    let maximumObservedCurrentAmps: Double?
610
    let maximumObservedPowerWatts: Double?
611
    let maximumObservedVoltageVolts: Double?
612
    let selectedSourceVoltageVolts: Double?
613
    let completionCurrentAmps: Double?
614
    let stopThresholdAmps: Double
615
    let startBatteryPercent: Double?
616
    let endBatteryPercent: Double?
617
    let capacityEstimateWh: Double?
618
    let wirelessEfficiencyFactor: Double?
619
    let usesEstimatedWirelessEfficiency: Bool
620
    let shouldWarnAboutLowWirelessEfficiency: Bool
621
    let supportsChargingWhileOff: Bool
622
    let usedOfflineMeterCounters: Bool
623
    let targetBatteryPercent: Double?
624
    let targetBatteryAlertTriggeredAt: Date?
625
    let requiresCompletionConfirmation: Bool
626
    let completionConfirmationRequestedAt: Date?
627
    let completionContradictionPercent: Double?
628
    let selectedDataGroup: UInt8?
629
    let checkpoints: [ChargeCheckpointSummary]
630
    let aggregatedSamples: [ChargeSessionSampleSummary]
631

            
Bogdan Timofte authored a month ago
632
    var sessionKind: ChargeSessionKind {
633
        ChargeSessionKind(
634
            chargingTransportMode: chargingTransportMode,
635
            chargingStateMode: chargingStateMode
636
        )
637
    }
638

            
Bogdan Timofte authored a month ago
639
    var duration: TimeInterval {
640
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
641
    }
642

            
Bogdan Timofte authored a month ago
643
    var meterObservedDuration: TimeInterval? {
644
        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
645
            return nil
646
        }
647
        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
648
            return nil
649
        }
650
        return meterLastDurationSeconds - meterDurationBaselineSeconds
651
    }
652

            
653
    var effectiveDuration: TimeInterval {
654
        meterObservedDuration ?? duration
655
    }
656

            
Bogdan Timofte authored a month ago
657
    var effectiveOrMeasuredEnergyWh: Double {
658
        effectiveBatteryEnergyWh ?? measuredEnergyWh
659
    }
660

            
661
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
662
        guard let startBatteryPercent, let endBatteryPercent,
663
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
664
        return endBatteryPercent - startBatteryPercent
665
    }
Bogdan Timofte authored a month ago
666

            
667
    var canAutoStop: Bool {
668
        autoStopEnabled && stopThresholdAmps > 0
669
    }
670

            
671
    var isPaused: Bool {
672
        status == .paused
673
    }
674

            
675
    var isOpen: Bool {
676
        status.isOpen
677
    }
Bogdan Timofte authored a month ago
678
}
679

            
680
struct BatteryLevelPrediction: Hashable {
681
    let predictedPercent: Double
682
    let estimatedCapacityWh: Double
683
    let anchorPercent: Double
684
    let anchorEnergyWh: Double
685
    let anchorDescription: String
686
}
687

            
Bogdan Timofte authored a month ago
688
enum BatteryLevelPredictionTuning {
689
    static let checkpointSettleDuration: TimeInterval = 10 * 60
690

            
691
    static func predictedPercent(
692
        anchorPercent: Double,
693
        anchorEnergyWh: Double,
694
        anchorTimestamp: Date,
695
        anchorIsCheckpoint: Bool,
696
        effectiveEnergyWh: Double,
697
        referenceTimestamp: Date,
698
        estimatedCapacityWh: Double
699
    ) -> Double {
700
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
701
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
702
        let stabilizedGainPercent: Double
703

            
704
        if anchorIsCheckpoint {
705
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
706
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
707
            stabilizedGainPercent = rawGainPercent * settleProgress
708
        } else {
709
            stabilizedGainPercent = rawGainPercent
710
        }
711

            
712
        return min(
713
            100,
714
            max(
715
                0,
716
                anchorPercent + stabilizedGainPercent
717
            )
718
        )
719
    }
720
}
721

            
Bogdan Timofte authored a month ago
722
struct CapacityTrendPoint: Identifiable, Hashable {
723
    let sessionID: UUID
724
    let timestamp: Date
725
    let capacityWh: Double
726
    let chargingTransportMode: ChargingTransportMode
727

            
728
    var id: UUID { sessionID }
729
}
730

            
731
struct TypicalChargeCurvePoint: Identifiable, Hashable {
732
    let percentBin: Int
733
    let averageEnergyWh: Double
734
    let averageChargeAh: Double
735
    let sampleCount: Int
736

            
737
    var id: Int { percentBin }
738
}
739

            
Bogdan Timofte authored a month ago
740
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
741
    let timestamp: Date
742
    let powerWatts: Double
743
    let currentAmps: Double
744
    let voltageVolts: Double
745

            
746
    var id: TimeInterval {
747
        timestamp.timeIntervalSince1970
748
    }
749
}
750

            
Bogdan Timofte authored a month ago
751
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
Bogdan Timofte authored a month ago
752
    let index: Int
753
    let lowerBoundWatts: Double
754
    let upperBoundWatts: Double
755
    let count: Int
756
    let relativeFrequency: Double
757

            
758
    var id: Int { index }
759
}
760

            
Bogdan Timofte authored a month ago
761
enum HistogramResolution: Int, CaseIterable, Identifiable {
762
    case x1 = 1
763
    case x2 = 2
764
    case x4 = 4
765

            
766
    var id: Int { rawValue }
767

            
768
    var label: String {
769
        switch self {
770
        case .x1: return "1×"
771
        case .x2: return "2×"
772
        case .x4: return "4×"
773
        }
774
    }
775
}
776

            
Bogdan Timofte authored a month ago
777
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
778
    let sampleCount: Int
779
    let observedDuration: TimeInterval
780
    let averagePowerWatts: Double
781
    let recentAveragePowerWatts: Double
782
    let medianPowerWatts: Double
783
    let minimumPowerWatts: Double
784
    let maximumPowerWatts: Double
785
    let standardDeviationPowerWatts: Double
786
    let coefficientOfVariation: Double
787
    let averageCurrentAmps: Double
788
    let averageVoltageVolts: Double
789
    let stabilityDeltaWatts: Double
790
    let stabilityToleranceWatts: Double
791
    let histogram: [ChargerStandbyPowerDistributionBin]
792

            
793
    var projectedDailyEnergyWh: Double {
794
        averagePowerWatts * 24
795
    }
796

            
797
    var projectedWeeklyEnergyWh: Double {
798
        averagePowerWatts * 24 * 7
799
    }
800

            
801
    var projectedMonthlyEnergyWh: Double {
802
        averagePowerWatts * 24 * 30
803
    }
804

            
805
    var projectedYearlyEnergyWh: Double {
806
        averagePowerWatts * 24 * 365
807
    }
808

            
809
    var stabilityDeltaMilliwatts: Double {
810
        stabilityDeltaWatts * 1000
811
    }
812

            
813
    var isStable: Bool {
814
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
815
        && stabilityDeltaWatts <= stabilityToleranceWatts
816
    }
817
}
818

            
819
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
820
    let id: UUID
821
    let chargerID: UUID
822
    let meterMACAddress: String
823
    let meterName: String?
824
    let meterModel: String?
825
    let startedAt: Date
826
    let endedAt: Date
827
    let sampleCount: Int
828
    let stabilizedAt: Date?
829
    let averagePowerWatts: Double
830
    let recentAveragePowerWatts: Double
831
    let medianPowerWatts: Double
832
    let minimumPowerWatts: Double
833
    let maximumPowerWatts: Double
834
    let standardDeviationPowerWatts: Double
835
    let coefficientOfVariation: Double
836
    let averageCurrentAmps: Double
837
    let averageVoltageVolts: Double
838
    let stabilityDeltaWatts: Double
839
    let stabilityToleranceWatts: Double
Bogdan Timofte authored a month ago
840
    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
841
    let storedHistogram: [ChargerStandbyPowerDistributionBin]
842

            
843
    // MARK: - Codable (with migration from legacy powerSamplesWatts)
844

            
845
    private enum CodingKeys: String, CodingKey {
846
        case id, chargerID, meterMACAddress, meterName, meterModel
847
        case startedAt, endedAt, sampleCount, stabilizedAt
848
        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
849
        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
850
        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
851
        case stabilityDeltaWatts, stabilityToleranceWatts
852
        case storedHistogram
853
        case powerSamplesWatts // legacy – decode only
854
    }
855

            
856
    init(
857
        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
858
        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
859
        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
860
        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
861
        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
862
        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
863
        storedHistogram: [ChargerStandbyPowerDistributionBin]
864
    ) {
865
        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
866
        self.meterName = meterName; self.meterModel = meterModel
867
        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
868
        self.stabilizedAt = stabilizedAt
869
        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
870
        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
871
        self.maximumPowerWatts = maximumPowerWatts
872
        self.standardDeviationPowerWatts = standardDeviationPowerWatts
873
        self.coefficientOfVariation = coefficientOfVariation
874
        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
875
        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
876
        self.storedHistogram = storedHistogram
877
    }
878

            
879
    init(from decoder: Decoder) throws {
880
        let c = try decoder.container(keyedBy: CodingKeys.self)
881
        id = try c.decode(UUID.self, forKey: .id)
882
        chargerID = try c.decode(UUID.self, forKey: .chargerID)
883
        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
884
        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
885
        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
886
        startedAt = try c.decode(Date.self, forKey: .startedAt)
887
        endedAt = try c.decode(Date.self, forKey: .endedAt)
888
        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
889
        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
890
        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
891
        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
892
        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
893
        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
894
        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
895
        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
896
        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
897
        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
898
        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
899
        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
900
        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)
901

            
902
        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
903
        if let decodedBins, !decodedBins.isEmpty {
904
            storedHistogram = decodedBins
905
        } else {
906
            // Migrate from legacy raw samples format
907
            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
908
            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
909
            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
910
                for: samples,
911
                preferredBinCount: base * HistogramResolution.x4.rawValue
912
            )
913
        }
Bogdan Timofte authored a month ago
914
    }
915

            
Bogdan Timofte authored a month ago
916
    func encode(to encoder: Encoder) throws {
917
        var c = encoder.container(keyedBy: CodingKeys.self)
918
        try c.encode(id, forKey: .id)
919
        try c.encode(chargerID, forKey: .chargerID)
920
        try c.encode(meterMACAddress, forKey: .meterMACAddress)
921
        try c.encodeIfPresent(meterName, forKey: .meterName)
922
        try c.encodeIfPresent(meterModel, forKey: .meterModel)
923
        try c.encode(startedAt, forKey: .startedAt)
924
        try c.encode(endedAt, forKey: .endedAt)
925
        try c.encode(sampleCount, forKey: .sampleCount)
926
        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
927
        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
928
        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
929
        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
930
        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
931
        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
932
        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
933
        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
934
        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
935
        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
936
        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
937
        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
938
        try c.encode(storedHistogram, forKey: .storedHistogram)
939
    }
940

            
941
    // MARK: - Computed
942

            
943
    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
944
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
945
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
946
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
947
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
948
    var isStable: Bool { stabilizedAt != nil }
949

            
950
    /// Returns the histogram downsampled to the requested resolution.
951
    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
952
    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
953
        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
954
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
Bogdan Timofte authored a month ago
955
    }
956
}
957

            
958
enum ChargerStandbyPowerMeasurementAnalyzer {
959
    static let minimumStableSampleCount = 45
Bogdan Timofte authored a month ago
960
    static let recentSampleWindow = 40
961
    static let minimumStabilityToleranceWatts = 0.010
962
    static let relativeStabilityTolerance = 0.05
Bogdan Timofte authored a month ago
963

            
964
    static func statistics(
965
        from samples: [ChargerStandbyPowerSample],
966
        startedAt: Date,
967
        referenceDate: Date = Date()
968
    ) -> ChargerStandbyPowerMeasurementStatistics? {
969
        guard !samples.isEmpty else {
970
            return nil
971
        }
972

            
973
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
974
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
975
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
976

            
977
        guard powerValues.isEmpty == false else {
978
            return nil
979
        }
980

            
981
        let averagePower = mean(powerValues)
982
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
983
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
984
        let stabilityDelta = abs(averagePower - recentAveragePower)
985
        let stabilityTolerance = max(
986
            minimumStabilityToleranceWatts,
987
            abs(averagePower) * relativeStabilityTolerance
988
        )
989

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

            
Bogdan Timofte authored a month ago
993
        return ChargerStandbyPowerMeasurementStatistics(
994
            sampleCount: powerValues.count,
995
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
996
            averagePowerWatts: averagePower,
997
            recentAveragePowerWatts: recentAveragePower,
998
            medianPowerWatts: median(powerValues),
999
            minimumPowerWatts: powerValues.min() ?? 0,
1000
            maximumPowerWatts: powerValues.max() ?? 0,
1001
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
1002
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
1003
            averageCurrentAmps: mean(currentValues),
1004
            averageVoltageVolts: mean(voltageValues),
1005
            stabilityDeltaWatts: stabilityDelta,
1006
            stabilityToleranceWatts: stabilityTolerance,
Bogdan Timofte authored a month ago
1007
            histogram: liveHistogram
Bogdan Timofte authored a month ago
1008
        )
1009
    }
1010

            
1011
    static func measurementSummary(
1012
        chargerID: UUID,
1013
        meterMACAddress: String,
1014
        meterName: String?,
1015
        meterModel: String?,
1016
        startedAt: Date,
1017
        endedAt: Date,
1018
        samples: [ChargerStandbyPowerSample],
1019
        stabilizedAt: Date?
1020
    ) -> ChargerStandbyPowerMeasurementSummary? {
1021
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
1022
            return nil
1023
        }
1024

            
1025
        return ChargerStandbyPowerMeasurementSummary(
1026
            id: UUID(),
1027
            chargerID: chargerID,
1028
            meterMACAddress: meterMACAddress,
1029
            meterName: meterName,
1030
            meterModel: meterModel,
1031
            startedAt: startedAt,
1032
            endedAt: endedAt,
1033
            sampleCount: statistics.sampleCount,
1034
            stabilizedAt: stabilizedAt,
1035
            averagePowerWatts: statistics.averagePowerWatts,
1036
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
1037
            medianPowerWatts: statistics.medianPowerWatts,
1038
            minimumPowerWatts: statistics.minimumPowerWatts,
1039
            maximumPowerWatts: statistics.maximumPowerWatts,
1040
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
1041
            coefficientOfVariation: statistics.coefficientOfVariation,
1042
            averageCurrentAmps: statistics.averageCurrentAmps,
1043
            averageVoltageVolts: statistics.averageVoltageVolts,
1044
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
1045
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
Bogdan Timofte authored a month ago
1046
            storedHistogram: statistics.histogram
Bogdan Timofte authored a month ago
1047
        )
1048
    }
1049

            
Bogdan Timofte authored a month ago
1050
    /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1.
1051
    static func downsample(
1052
        _ bins: [ChargerStandbyPowerDistributionBin],
1053
        factor: Int
1054
    ) -> [ChargerStandbyPowerDistributionBin] {
1055
        guard factor > 1, !bins.isEmpty else { return bins }
1056
        let totalCount = bins.reduce(0) { $0 + $1.count }
1057
        var result: [ChargerStandbyPowerDistributionBin] = []
1058
        var inputIndex = 0
1059
        var outputIndex = 0
1060
        while inputIndex < bins.count {
1061
            let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)])
1062
            let mergedCount = group.reduce(0) { $0 + $1.count }
1063
            let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0
1064
            result.append(ChargerStandbyPowerDistributionBin(
1065
                index: outputIndex,
1066
                lowerBoundWatts: group.first!.lowerBoundWatts,
1067
                upperBoundWatts: group.last!.upperBoundWatts,
1068
                count: mergedCount,
1069
                relativeFrequency: relFreq
1070
            ))
1071
            inputIndex += factor
1072
            outputIndex += 1
1073
        }
1074
        return result
1075
    }
1076

            
Bogdan Timofte authored a month ago
1077
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
1078
        let finiteValues = values.filter(\.isFinite)
1079
        guard finiteValues.isEmpty == false else {
1080
            return []
1081
        }
1082

            
1083
        let minimum = finiteValues.min() ?? 0
1084
        let maximum = finiteValues.max() ?? 0
1085
        let spread = maximum - minimum
1086
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
1087

            
1088
        guard spread > 0 else {
1089
            return [
1090
                ChargerStandbyPowerDistributionBin(
1091
                    index: 0,
1092
                    lowerBoundWatts: minimum,
1093
                    upperBoundWatts: maximum,
1094
                    count: finiteValues.count,
1095
                    relativeFrequency: 1
1096
                )
1097
            ]
1098
        }
1099

            
1100
        let safeBinCount = max(1, binCount)
1101
        let binWidth = spread / Double(safeBinCount)
1102
        var counts = Array(repeating: 0, count: safeBinCount)
1103

            
1104
        for value in finiteValues {
1105
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
1106
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
1107
            counts[safeIndex] += 1
1108
        }
1109

            
1110
        return counts.enumerated().map { index, count in
1111
            let lowerBound = minimum + (Double(index) * binWidth)
1112
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
1113

            
1114
            return ChargerStandbyPowerDistributionBin(
1115
                index: index,
1116
                lowerBoundWatts: lowerBound,
1117
                upperBoundWatts: upperBound,
1118
                count: count,
1119
                relativeFrequency: Double(count) / Double(finiteValues.count)
1120
            )
1121
        }
1122
    }
1123

            
1124
    private static func mean(_ values: [Double]) -> Double {
1125
        guard values.isEmpty == false else {
1126
            return 0
1127
        }
1128
        return values.reduce(0, +) / Double(values.count)
1129
    }
1130

            
1131
    private static func median(_ values: [Double]) -> Double {
1132
        guard values.isEmpty == false else {
1133
            return 0
1134
        }
1135

            
1136
        let sorted = values.sorted()
1137
        let middleIndex = sorted.count / 2
1138

            
1139
        if sorted.count.isMultiple(of: 2) {
1140
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
1141
        }
1142

            
1143
        return sorted[middleIndex]
1144
    }
1145

            
1146
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
1147
        guard values.count > 1 else {
1148
            return 0
1149
        }
1150

            
1151
        let variance = values.reduce(0) { partialResult, value in
1152
            let delta = value - mean
1153
            return partialResult + (delta * delta)
1154
        } / Double(values.count)
1155

            
1156
        return variance.squareRoot()
1157
    }
1158

            
1159
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
1160
        guard abs(mean) > 0.000_001 else {
1161
            return 0
1162
        }
1163

            
1164
        return standardDeviation(values, mean: mean) / abs(mean)
1165
    }
1166
}
1167

            
Bogdan Timofte authored a month ago
1168
struct ChargedDeviceSummary: Identifiable, Hashable {
1169
    let id: UUID
1170
    let qrIdentifier: String
1171
    let name: String
1172
    let deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
1173
    let deviceTemplateID: String?
1174
    let templateDefinition: ChargedDeviceTemplateDefinition?
Bogdan Timofte authored a month ago
1175
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
1176
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
1177
    let supportsWiredCharging: Bool
1178
    let supportsWirelessCharging: Bool
Bogdan Timofte authored a month ago
1179
    let chargerType: ChargerType?
Bogdan Timofte authored a month ago
1180
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
1181
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
1182
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
1183
    let wirelessChargerEfficiencyFactor: Double?
1184
    let wiredChargeCompletionCurrentAmps: Double?
1185
    let wirelessChargeCompletionCurrentAmps: Double?
1186
    let chargerObservedVoltageSelections: [Double]
1187
    let chargerIdleCurrentAmps: Double?
1188
    let chargerEfficiencyFactor: Double?
1189
    let chargerMaximumPowerWatts: Double?
1190
    let notes: String?
1191
    let minimumCurrentAmps: Double?
1192
    let estimatedBatteryCapacityWh: Double?
1193
    let wiredMinimumCurrentAmps: Double?
1194
    let wirelessMinimumCurrentAmps: Double?
1195
    let wiredEstimatedBatteryCapacityWh: Double?
1196
    let wirelessEstimatedBatteryCapacityWh: Double?
1197
    let lastAssociatedMeterMAC: String?
1198
    let createdAt: Date
1199
    let updatedAt: Date
1200
    let sessions: [ChargeSessionSummary]
1201
    let capacityHistory: [CapacityTrendPoint]
1202
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
1203
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
1204

            
1205
    var isCharger: Bool {
1206
        deviceClass == .charger
1207
    }
1208

            
Bogdan Timofte authored a month ago
1209
    var kind: ChargedDeviceKind {
1210
        deviceClass.kind
1211
    }
1212

            
1213
    var identityTitle: String {
Bogdan Timofte authored a month ago
1214
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1215
    }
1216

            
Bogdan Timofte authored a month ago
1217
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1218
        isCharger ? kind.symbolName : deviceClass.symbolName
1219
    }
1220

            
Bogdan Timofte authored a month ago
1221
    var identityIcon: ChargedDeviceTemplateIcon {
1222
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1223
    }
1224

            
1225
    var identitySymbolName: String {
1226
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1227
    }
1228

            
Bogdan Timofte authored a month ago
1229
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1230
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1231
    }
1232

            
1233
    var recentCompletedSessions: [ChargeSessionSummary] {
1234
        sessions.filter { $0.status == .completed }
1235
    }
1236

            
1237
    var sessionCount: Int {
1238
        sessions.count
1239
    }
1240

            
Bogdan Timofte authored a month ago
1241
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1242
        standbyPowerMeasurements.first
1243
    }
1244

            
Bogdan Timofte authored a month ago
1245
    var supportedChargingModes: [ChargingTransportMode] {
1246
        var modes: [ChargingTransportMode] = []
1247
        if supportsWiredCharging {
1248
            modes.append(.wired)
1249
        }
1250
        if supportsWirelessCharging {
1251
            modes.append(.wireless)
1252
        }
Bogdan Timofte authored a month ago
1253
        return modes
Bogdan Timofte authored a month ago
1254
    }
1255

            
Bogdan Timofte authored a month ago
1256
    var supportedChargingStateModes: [ChargingStateMode] {
1257
        chargingStateAvailability.supportedModes
1258
    }
1259

            
Bogdan Timofte authored a month ago
1260
    var hasMultipleChargingTransports: Bool {
1261
        supportedChargingModes.count > 1
1262
    }
1263

            
1264
    var hasMultipleChargingStateModes: Bool {
1265
        supportedChargingStateModes.count > 1
1266
    }
1267

            
1268
    var showsWirelessProfileDetails: Bool {
1269
        supportsWirelessCharging
1270
            && hasMultipleChargingTransports
1271
            && deviceClass != .watch
1272
    }
1273

            
1274
    var chargingSupportSummary: String {
1275
        switch (supportsWiredCharging, supportsWirelessCharging) {
1276
        case (true, true):
1277
            return "Supports wired and wireless charging."
1278
        case (true, false):
1279
            return "Supports wired charging only."
1280
        case (false, true):
1281
            return "Supports wireless charging only."
1282
        case (false, false):
1283
            return "No charging method configured."
1284
        }
1285
    }
1286

            
Bogdan Timofte authored a month ago
1287
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1288
        if let matchingSession = sessions.first(where: {
1289
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1290
        }) {
1291
            return matchingSession.chargingStateMode
1292
        }
1293
        return chargingStateAvailability.supportedModes.first ?? .on
1294
    }
1295

            
1296
    func sessionKind(
1297
        for chargingTransportMode: ChargingTransportMode,
1298
        chargingStateMode: ChargingStateMode? = nil
1299
    ) -> ChargeSessionKind {
1300
        ChargeSessionKind(
1301
            chargingTransportMode: chargingTransportMode,
1302
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1303
        )
1304
    }
1305

            
Bogdan Timofte authored a month ago
1306
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1307
        switch chargingTransportMode {
1308
        case .wired:
1309
            return wiredEstimatedBatteryCapacityWh
1310
        case .wireless:
1311
            return wirelessEstimatedBatteryCapacityWh
1312
        }
1313
    }
1314

            
1315
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1316
        switch chargingTransportMode {
1317
        case .wired:
1318
            return wiredMinimumCurrentAmps
1319
        case .wireless:
1320
            return wirelessMinimumCurrentAmps
1321
        }
1322
    }
1323

            
Bogdan Timofte authored a month ago
1324
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1325
        hasMultipleChargingTransports
1326
            || supportedChargingModes.contains(chargingTransportMode) == false
1327
    }
1328

            
1329
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1330
        hasMultipleChargingStateModes
1331
            || supportedChargingStateModes.contains(chargingStateMode) == false
1332
    }
1333

            
Bogdan Timofte authored a month ago
1334
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1335
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
1336
            return explicitCurrent
1337
        }
1338

            
1339
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
1340
        case .wired:
1341
            return wiredChargeCompletionCurrentAmps
1342
        case .wireless:
1343
            return wirelessChargeCompletionCurrentAmps
1344
        }
1345
    }
1346

            
Bogdan Timofte authored a month ago
1347
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1348
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
1349
            return learnedCurrent
1350
        }
1351

            
1352
        switch sessionKind.chargingTransportMode {
1353
        case .wired:
1354
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
1355
        case .wireless:
1356
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
1357
        }
1358
    }
1359

            
1360
    func resolvedCompletionCurrentAmps(
1361
        for chargingTransportMode: ChargingTransportMode,
1362
        chargingStateMode: ChargingStateMode? = nil
1363
    ) -> Double? {
1364
        let sessionKind = sessionKind(
1365
            for: chargingTransportMode,
1366
            chargingStateMode: chargingStateMode
1367
        )
1368

            
1369
        return configuredCompletionCurrentAmps(for: sessionKind)
1370
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
1371
            ?? minimumCurrentAmps(for: chargingTransportMode)
1372
            ?? minimumCurrentAmps
1373
    }
1374

            
Bogdan Timofte authored a month ago
1375
    func batteryLevelPrediction(
1376
        for session: ChargeSessionSummary,
1377
        effectiveEnergyWhOverride: Double? = nil
1378
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1379
        let estimatedCapacityWh = session.capacityEstimateWh
1380
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1381
            ?? estimatedBatteryCapacityWh
1382

            
1383
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1384
            return nil
1385
        }
1386

            
Bogdan Timofte authored a month ago
1387
        let effectiveEnergyWh = effectiveEnergyWhOverride
1388
            ?? session.effectiveBatteryEnergyWh
1389
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1390

            
1391
        struct Anchor {
1392
            let percent: Double
1393
            let energyWh: Double
Bogdan Timofte authored a month ago
1394
            let timestamp: Date
Bogdan Timofte authored a month ago
1395
            let description: String
Bogdan Timofte authored a month ago
1396
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1397
        }
1398

            
1399
        var anchors: [Anchor] = []
1400

            
Bogdan Timofte authored a month ago
1401
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1402
            anchors.append(
1403
                Anchor(
1404
                    percent: startBatteryPercent,
1405
                    energyWh: 0,
Bogdan Timofte authored a month ago
1406
                    timestamp: session.startedAt,
1407
                    description: "session start",
1408
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1409
                )
1410
            )
1411
        }
1412

            
1413
        anchors.append(
1414
            contentsOf: session.checkpoints
1415
                .sorted { lhs, rhs in
1416
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1417
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1418
                    }
1419
                    return lhs.timestamp < rhs.timestamp
1420
                }
Bogdan Timofte authored a month ago
1421
                .filter { checkpoint in
1422
                    checkpoint.batteryPercent >= 0
1423
                }
Bogdan Timofte authored a month ago
1424
                .map { checkpoint in
1425
                    return Anchor(
1426
                        percent: checkpoint.batteryPercent,
1427
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1428
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
1429
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
1430
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1431
                    )
1432
                }
1433
        )
1434

            
1435
        guard !anchors.isEmpty else {
1436
            return nil
1437
        }
1438

            
1439
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1440
        let anchor = eligibleAnchors.last ?? anchors.first!
Bogdan Timofte authored a month ago
1441
        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1442
            anchorPercent: anchor.percent,
1443
            anchorEnergyWh: anchor.energyWh,
1444
            anchorTimestamp: anchor.timestamp,
1445
            anchorIsCheckpoint: anchor.isCheckpoint,
1446
            effectiveEnergyWh: effectiveEnergyWh,
1447
            referenceTimestamp: session.lastObservedAt,
1448
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1449
        )
1450

            
1451
        return BatteryLevelPrediction(
1452
            predictedPercent: predictedPercent,
1453
            estimatedCapacityWh: estimatedCapacityWh,
1454
            anchorPercent: anchor.percent,
1455
            anchorEnergyWh: anchor.energyWh,
1456
            anchorDescription: anchor.description
1457
        )
1458
    }
Bogdan Timofte authored a month ago
1459

            
1460
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1461
        ChargedDeviceSummary(
1462
            id: id,
1463
            qrIdentifier: qrIdentifier,
1464
            name: name,
1465
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1466
            deviceTemplateID: deviceTemplateID,
1467
            templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1468
            supportsChargingWhileOff: supportsChargingWhileOff,
1469
            chargingStateAvailability: chargingStateAvailability,
1470
            supportsWiredCharging: supportsWiredCharging,
1471
            supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1472
            chargerType: chargerType,
Bogdan Timofte authored a month ago
1473
            wirelessChargingProfile: wirelessChargingProfile,
1474
            configuredCompletionCurrents: configuredCompletionCurrents,
1475
            learnedCompletionCurrents: learnedCompletionCurrents,
1476
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1477
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1478
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1479
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1480
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1481
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1482
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1483
            notes: notes,
1484
            minimumCurrentAmps: minimumCurrentAmps,
1485
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1486
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1487
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1488
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1489
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1490
            lastAssociatedMeterMAC: lastAssociatedMeterMAC,
1491
            createdAt: createdAt,
1492
            updatedAt: updatedAt,
1493
            sessions: sessions,
1494
            capacityHistory: capacityHistory,
1495
            typicalCurve: typicalCurve,
1496
            standbyPowerMeasurements: measurements
1497
        )
1498
    }
Bogdan Timofte authored a month ago
1499
}
1500

            
1501
struct ChargingMonitorSnapshot {
1502
    let meterMACAddress: String
1503
    let meterName: String
1504
    let meterModel: String
1505
    let observedAt: Date
1506
    let voltageVolts: Double
1507
    let currentAmps: Double
1508
    let powerWatts: Double
1509
    let selectedDataGroup: UInt8?
1510
    let meterChargeCounterAh: Double?
1511
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1512
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1513
    let fallbackStopThresholdAmps: Double
1514
}