USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
1533 lines | 50.967kb
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?
Bogdan Timofte authored a month ago
629
    let trimStart: Date?
630
    let trimEnd: Date?
Bogdan Timofte authored a month ago
631
    let checkpoints: [ChargeCheckpointSummary]
632
    let aggregatedSamples: [ChargeSessionSampleSummary]
633

            
Bogdan Timofte authored a month ago
634
    var effectiveTrimStart: Date { trimStart ?? startedAt }
635
    var effectiveTrimEnd: Date { trimEnd ?? (endedAt ?? lastObservedAt) }
636
    var isTrimmed: Bool { trimStart != nil || trimEnd != nil }
637
    var effectiveTimeRange: ClosedRange<Date> {
638
        let start = effectiveTrimStart
639
        let end = max(effectiveTrimEnd, start)
640
        return start...end
641
    }
642
    var displayedAggregatedSamples: [ChargeSessionSampleSummary] {
643
        guard isTrimmed else { return aggregatedSamples }
644
        let range = effectiveTimeRange
645
        return aggregatedSamples.filter { range.contains($0.timestamp) }
646
    }
647

            
Bogdan Timofte authored a month ago
648
    var sessionKind: ChargeSessionKind {
649
        ChargeSessionKind(
650
            chargingTransportMode: chargingTransportMode,
651
            chargingStateMode: chargingStateMode
652
        )
653
    }
654

            
Bogdan Timofte authored a month ago
655
    var duration: TimeInterval {
656
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
657
    }
658

            
Bogdan Timofte authored a month ago
659
    var meterObservedDuration: TimeInterval? {
660
        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
661
            return nil
662
        }
663
        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
664
            return nil
665
        }
666
        return meterLastDurationSeconds - meterDurationBaselineSeconds
667
    }
668

            
669
    var effectiveDuration: TimeInterval {
Bogdan Timofte authored a month ago
670
        if isTrimmed {
671
            return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0)
672
        }
673
        return meterObservedDuration ?? duration
Bogdan Timofte authored a month ago
674
    }
675

            
Bogdan Timofte authored a month ago
676
    var effectiveOrMeasuredEnergyWh: Double {
677
        effectiveBatteryEnergyWh ?? measuredEnergyWh
678
    }
679

            
680
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
681
        guard let startBatteryPercent, let endBatteryPercent,
682
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
683
        return endBatteryPercent - startBatteryPercent
684
    }
Bogdan Timofte authored a month ago
685

            
686
    var canAutoStop: Bool {
687
        autoStopEnabled && stopThresholdAmps > 0
688
    }
689

            
690
    var isPaused: Bool {
691
        status == .paused
692
    }
693

            
694
    var isOpen: Bool {
695
        status.isOpen
696
    }
Bogdan Timofte authored a month ago
697
}
698

            
699
struct BatteryLevelPrediction: Hashable {
700
    let predictedPercent: Double
701
    let estimatedCapacityWh: Double
702
    let anchorPercent: Double
703
    let anchorEnergyWh: Double
704
    let anchorDescription: String
705
}
706

            
Bogdan Timofte authored a month ago
707
enum BatteryLevelPredictionTuning {
708
    static let checkpointSettleDuration: TimeInterval = 10 * 60
709

            
710
    static func predictedPercent(
711
        anchorPercent: Double,
712
        anchorEnergyWh: Double,
713
        anchorTimestamp: Date,
714
        anchorIsCheckpoint: Bool,
715
        effectiveEnergyWh: Double,
716
        referenceTimestamp: Date,
717
        estimatedCapacityWh: Double
718
    ) -> Double {
719
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
720
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
721
        let stabilizedGainPercent: Double
722

            
723
        if anchorIsCheckpoint {
724
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
725
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
726
            stabilizedGainPercent = rawGainPercent * settleProgress
727
        } else {
728
            stabilizedGainPercent = rawGainPercent
729
        }
730

            
731
        return min(
732
            100,
733
            max(
734
                0,
735
                anchorPercent + stabilizedGainPercent
736
            )
737
        )
738
    }
739
}
740

            
Bogdan Timofte authored a month ago
741
struct CapacityTrendPoint: Identifiable, Hashable {
742
    let sessionID: UUID
743
    let timestamp: Date
744
    let capacityWh: Double
745
    let chargingTransportMode: ChargingTransportMode
746

            
747
    var id: UUID { sessionID }
748
}
749

            
750
struct TypicalChargeCurvePoint: Identifiable, Hashable {
751
    let percentBin: Int
752
    let averageEnergyWh: Double
753
    let averageChargeAh: Double
754
    let sampleCount: Int
755

            
756
    var id: Int { percentBin }
757
}
758

            
Bogdan Timofte authored a month ago
759
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
760
    let timestamp: Date
761
    let powerWatts: Double
762
    let currentAmps: Double
763
    let voltageVolts: Double
764

            
765
    var id: TimeInterval {
766
        timestamp.timeIntervalSince1970
767
    }
768
}
769

            
Bogdan Timofte authored a month ago
770
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
Bogdan Timofte authored a month ago
771
    let index: Int
772
    let lowerBoundWatts: Double
773
    let upperBoundWatts: Double
774
    let count: Int
775
    let relativeFrequency: Double
776

            
777
    var id: Int { index }
778
}
779

            
Bogdan Timofte authored a month ago
780
enum HistogramResolution: Int, CaseIterable, Identifiable {
781
    case x1 = 1
782
    case x2 = 2
783
    case x4 = 4
784

            
785
    var id: Int { rawValue }
786

            
787
    var label: String {
788
        switch self {
789
        case .x1: return "1×"
790
        case .x2: return "2×"
791
        case .x4: return "4×"
792
        }
793
    }
794
}
795

            
Bogdan Timofte authored a month ago
796
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
797
    let sampleCount: Int
798
    let observedDuration: TimeInterval
799
    let averagePowerWatts: Double
800
    let recentAveragePowerWatts: Double
801
    let medianPowerWatts: Double
802
    let minimumPowerWatts: Double
803
    let maximumPowerWatts: Double
804
    let standardDeviationPowerWatts: Double
805
    let coefficientOfVariation: Double
806
    let averageCurrentAmps: Double
807
    let averageVoltageVolts: Double
808
    let stabilityDeltaWatts: Double
809
    let stabilityToleranceWatts: Double
810
    let histogram: [ChargerStandbyPowerDistributionBin]
811

            
812
    var projectedDailyEnergyWh: Double {
813
        averagePowerWatts * 24
814
    }
815

            
816
    var projectedWeeklyEnergyWh: Double {
817
        averagePowerWatts * 24 * 7
818
    }
819

            
820
    var projectedMonthlyEnergyWh: Double {
821
        averagePowerWatts * 24 * 30
822
    }
823

            
824
    var projectedYearlyEnergyWh: Double {
825
        averagePowerWatts * 24 * 365
826
    }
827

            
828
    var stabilityDeltaMilliwatts: Double {
829
        stabilityDeltaWatts * 1000
830
    }
831

            
832
    var isStable: Bool {
833
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
834
        && stabilityDeltaWatts <= stabilityToleranceWatts
835
    }
836
}
837

            
838
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
839
    let id: UUID
840
    let chargerID: UUID
841
    let meterMACAddress: String
842
    let meterName: String?
843
    let meterModel: String?
844
    let startedAt: Date
845
    let endedAt: Date
846
    let sampleCount: Int
847
    let stabilizedAt: Date?
848
    let averagePowerWatts: Double
849
    let recentAveragePowerWatts: Double
850
    let medianPowerWatts: Double
851
    let minimumPowerWatts: Double
852
    let maximumPowerWatts: Double
853
    let standardDeviationPowerWatts: Double
854
    let coefficientOfVariation: Double
855
    let averageCurrentAmps: Double
856
    let averageVoltageVolts: Double
857
    let stabilityDeltaWatts: Double
858
    let stabilityToleranceWatts: Double
Bogdan Timofte authored a month ago
859
    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
860
    let storedHistogram: [ChargerStandbyPowerDistributionBin]
861

            
862
    // MARK: - Codable (with migration from legacy powerSamplesWatts)
863

            
864
    private enum CodingKeys: String, CodingKey {
865
        case id, chargerID, meterMACAddress, meterName, meterModel
866
        case startedAt, endedAt, sampleCount, stabilizedAt
867
        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
868
        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
869
        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
870
        case stabilityDeltaWatts, stabilityToleranceWatts
871
        case storedHistogram
872
        case powerSamplesWatts // legacy – decode only
873
    }
874

            
875
    init(
876
        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
877
        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
878
        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
879
        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
880
        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
881
        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
882
        storedHistogram: [ChargerStandbyPowerDistributionBin]
883
    ) {
884
        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
885
        self.meterName = meterName; self.meterModel = meterModel
886
        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
887
        self.stabilizedAt = stabilizedAt
888
        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
889
        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
890
        self.maximumPowerWatts = maximumPowerWatts
891
        self.standardDeviationPowerWatts = standardDeviationPowerWatts
892
        self.coefficientOfVariation = coefficientOfVariation
893
        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
894
        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
895
        self.storedHistogram = storedHistogram
896
    }
897

            
898
    init(from decoder: Decoder) throws {
899
        let c = try decoder.container(keyedBy: CodingKeys.self)
900
        id = try c.decode(UUID.self, forKey: .id)
901
        chargerID = try c.decode(UUID.self, forKey: .chargerID)
902
        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
903
        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
904
        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
905
        startedAt = try c.decode(Date.self, forKey: .startedAt)
906
        endedAt = try c.decode(Date.self, forKey: .endedAt)
907
        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
908
        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
909
        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
910
        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
911
        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
912
        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
913
        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
914
        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
915
        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
916
        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
917
        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
918
        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
919
        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)
920

            
921
        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
922
        if let decodedBins, !decodedBins.isEmpty {
923
            storedHistogram = decodedBins
924
        } else {
925
            // Migrate from legacy raw samples format
926
            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
927
            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
928
            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
929
                for: samples,
930
                preferredBinCount: base * HistogramResolution.x4.rawValue
931
            )
932
        }
Bogdan Timofte authored a month ago
933
    }
934

            
Bogdan Timofte authored a month ago
935
    func encode(to encoder: Encoder) throws {
936
        var c = encoder.container(keyedBy: CodingKeys.self)
937
        try c.encode(id, forKey: .id)
938
        try c.encode(chargerID, forKey: .chargerID)
939
        try c.encode(meterMACAddress, forKey: .meterMACAddress)
940
        try c.encodeIfPresent(meterName, forKey: .meterName)
941
        try c.encodeIfPresent(meterModel, forKey: .meterModel)
942
        try c.encode(startedAt, forKey: .startedAt)
943
        try c.encode(endedAt, forKey: .endedAt)
944
        try c.encode(sampleCount, forKey: .sampleCount)
945
        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
946
        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
947
        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
948
        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
949
        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
950
        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
951
        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
952
        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
953
        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
954
        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
955
        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
956
        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
957
        try c.encode(storedHistogram, forKey: .storedHistogram)
958
    }
959

            
960
    // MARK: - Computed
961

            
962
    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
963
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
964
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
965
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
966
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
967
    var isStable: Bool { stabilizedAt != nil }
968

            
969
    /// Returns the histogram downsampled to the requested resolution.
970
    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
971
    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
972
        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
973
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
Bogdan Timofte authored a month ago
974
    }
975
}
976

            
977
enum ChargerStandbyPowerMeasurementAnalyzer {
978
    static let minimumStableSampleCount = 45
Bogdan Timofte authored a month ago
979
    static let recentSampleWindow = 40
980
    static let minimumStabilityToleranceWatts = 0.010
981
    static let relativeStabilityTolerance = 0.05
Bogdan Timofte authored a month ago
982

            
983
    static func statistics(
984
        from samples: [ChargerStandbyPowerSample],
985
        startedAt: Date,
986
        referenceDate: Date = Date()
987
    ) -> ChargerStandbyPowerMeasurementStatistics? {
988
        guard !samples.isEmpty else {
989
            return nil
990
        }
991

            
992
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
993
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
994
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
995

            
996
        guard powerValues.isEmpty == false else {
997
            return nil
998
        }
999

            
1000
        let averagePower = mean(powerValues)
1001
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
1002
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
1003
        let stabilityDelta = abs(averagePower - recentAveragePower)
1004
        let stabilityTolerance = max(
1005
            minimumStabilityToleranceWatts,
1006
            abs(averagePower) * relativeStabilityTolerance
1007
        )
1008

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

            
Bogdan Timofte authored a month ago
1012
        return ChargerStandbyPowerMeasurementStatistics(
1013
            sampleCount: powerValues.count,
1014
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
1015
            averagePowerWatts: averagePower,
1016
            recentAveragePowerWatts: recentAveragePower,
1017
            medianPowerWatts: median(powerValues),
1018
            minimumPowerWatts: powerValues.min() ?? 0,
1019
            maximumPowerWatts: powerValues.max() ?? 0,
1020
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
1021
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
1022
            averageCurrentAmps: mean(currentValues),
1023
            averageVoltageVolts: mean(voltageValues),
1024
            stabilityDeltaWatts: stabilityDelta,
1025
            stabilityToleranceWatts: stabilityTolerance,
Bogdan Timofte authored a month ago
1026
            histogram: liveHistogram
Bogdan Timofte authored a month ago
1027
        )
1028
    }
1029

            
1030
    static func measurementSummary(
1031
        chargerID: UUID,
1032
        meterMACAddress: String,
1033
        meterName: String?,
1034
        meterModel: String?,
1035
        startedAt: Date,
1036
        endedAt: Date,
1037
        samples: [ChargerStandbyPowerSample],
1038
        stabilizedAt: Date?
1039
    ) -> ChargerStandbyPowerMeasurementSummary? {
1040
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
1041
            return nil
1042
        }
1043

            
1044
        return ChargerStandbyPowerMeasurementSummary(
1045
            id: UUID(),
1046
            chargerID: chargerID,
1047
            meterMACAddress: meterMACAddress,
1048
            meterName: meterName,
1049
            meterModel: meterModel,
1050
            startedAt: startedAt,
1051
            endedAt: endedAt,
1052
            sampleCount: statistics.sampleCount,
1053
            stabilizedAt: stabilizedAt,
1054
            averagePowerWatts: statistics.averagePowerWatts,
1055
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
1056
            medianPowerWatts: statistics.medianPowerWatts,
1057
            minimumPowerWatts: statistics.minimumPowerWatts,
1058
            maximumPowerWatts: statistics.maximumPowerWatts,
1059
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
1060
            coefficientOfVariation: statistics.coefficientOfVariation,
1061
            averageCurrentAmps: statistics.averageCurrentAmps,
1062
            averageVoltageVolts: statistics.averageVoltageVolts,
1063
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
1064
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
Bogdan Timofte authored a month ago
1065
            storedHistogram: statistics.histogram
Bogdan Timofte authored a month ago
1066
        )
1067
    }
1068

            
Bogdan Timofte authored a month ago
1069
    /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1.
1070
    static func downsample(
1071
        _ bins: [ChargerStandbyPowerDistributionBin],
1072
        factor: Int
1073
    ) -> [ChargerStandbyPowerDistributionBin] {
1074
        guard factor > 1, !bins.isEmpty else { return bins }
1075
        let totalCount = bins.reduce(0) { $0 + $1.count }
1076
        var result: [ChargerStandbyPowerDistributionBin] = []
1077
        var inputIndex = 0
1078
        var outputIndex = 0
1079
        while inputIndex < bins.count {
1080
            let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)])
1081
            let mergedCount = group.reduce(0) { $0 + $1.count }
1082
            let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0
1083
            result.append(ChargerStandbyPowerDistributionBin(
1084
                index: outputIndex,
1085
                lowerBoundWatts: group.first!.lowerBoundWatts,
1086
                upperBoundWatts: group.last!.upperBoundWatts,
1087
                count: mergedCount,
1088
                relativeFrequency: relFreq
1089
            ))
1090
            inputIndex += factor
1091
            outputIndex += 1
1092
        }
1093
        return result
1094
    }
1095

            
Bogdan Timofte authored a month ago
1096
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
1097
        let finiteValues = values.filter(\.isFinite)
1098
        guard finiteValues.isEmpty == false else {
1099
            return []
1100
        }
1101

            
1102
        let minimum = finiteValues.min() ?? 0
1103
        let maximum = finiteValues.max() ?? 0
1104
        let spread = maximum - minimum
1105
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
1106

            
1107
        guard spread > 0 else {
1108
            return [
1109
                ChargerStandbyPowerDistributionBin(
1110
                    index: 0,
1111
                    lowerBoundWatts: minimum,
1112
                    upperBoundWatts: maximum,
1113
                    count: finiteValues.count,
1114
                    relativeFrequency: 1
1115
                )
1116
            ]
1117
        }
1118

            
1119
        let safeBinCount = max(1, binCount)
1120
        let binWidth = spread / Double(safeBinCount)
1121
        var counts = Array(repeating: 0, count: safeBinCount)
1122

            
1123
        for value in finiteValues {
1124
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
1125
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
1126
            counts[safeIndex] += 1
1127
        }
1128

            
1129
        return counts.enumerated().map { index, count in
1130
            let lowerBound = minimum + (Double(index) * binWidth)
1131
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
1132

            
1133
            return ChargerStandbyPowerDistributionBin(
1134
                index: index,
1135
                lowerBoundWatts: lowerBound,
1136
                upperBoundWatts: upperBound,
1137
                count: count,
1138
                relativeFrequency: Double(count) / Double(finiteValues.count)
1139
            )
1140
        }
1141
    }
1142

            
1143
    private static func mean(_ values: [Double]) -> Double {
1144
        guard values.isEmpty == false else {
1145
            return 0
1146
        }
1147
        return values.reduce(0, +) / Double(values.count)
1148
    }
1149

            
1150
    private static func median(_ values: [Double]) -> Double {
1151
        guard values.isEmpty == false else {
1152
            return 0
1153
        }
1154

            
1155
        let sorted = values.sorted()
1156
        let middleIndex = sorted.count / 2
1157

            
1158
        if sorted.count.isMultiple(of: 2) {
1159
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
1160
        }
1161

            
1162
        return sorted[middleIndex]
1163
    }
1164

            
1165
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
1166
        guard values.count > 1 else {
1167
            return 0
1168
        }
1169

            
1170
        let variance = values.reduce(0) { partialResult, value in
1171
            let delta = value - mean
1172
            return partialResult + (delta * delta)
1173
        } / Double(values.count)
1174

            
1175
        return variance.squareRoot()
1176
    }
1177

            
1178
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
1179
        guard abs(mean) > 0.000_001 else {
1180
            return 0
1181
        }
1182

            
1183
        return standardDeviation(values, mean: mean) / abs(mean)
1184
    }
1185
}
1186

            
Bogdan Timofte authored a month ago
1187
struct ChargedDeviceSummary: Identifiable, Hashable {
1188
    let id: UUID
1189
    let qrIdentifier: String
1190
    let name: String
1191
    let deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
1192
    let deviceTemplateID: String?
1193
    let templateDefinition: ChargedDeviceTemplateDefinition?
Bogdan Timofte authored a month ago
1194
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
1195
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
1196
    let supportsWiredCharging: Bool
1197
    let supportsWirelessCharging: Bool
Bogdan Timofte authored a month ago
1198
    let chargerType: ChargerType?
Bogdan Timofte authored a month ago
1199
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
1200
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
1201
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
1202
    let wirelessChargerEfficiencyFactor: Double?
1203
    let wiredChargeCompletionCurrentAmps: Double?
1204
    let wirelessChargeCompletionCurrentAmps: Double?
1205
    let chargerObservedVoltageSelections: [Double]
1206
    let chargerIdleCurrentAmps: Double?
1207
    let chargerEfficiencyFactor: Double?
1208
    let chargerMaximumPowerWatts: Double?
1209
    let notes: String?
1210
    let minimumCurrentAmps: Double?
1211
    let estimatedBatteryCapacityWh: Double?
1212
    let wiredMinimumCurrentAmps: Double?
1213
    let wirelessMinimumCurrentAmps: Double?
1214
    let wiredEstimatedBatteryCapacityWh: Double?
1215
    let wirelessEstimatedBatteryCapacityWh: Double?
1216
    let lastAssociatedMeterMAC: String?
1217
    let createdAt: Date
1218
    let updatedAt: Date
1219
    let sessions: [ChargeSessionSummary]
1220
    let capacityHistory: [CapacityTrendPoint]
1221
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
1222
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
1223

            
1224
    var isCharger: Bool {
1225
        deviceClass == .charger
1226
    }
1227

            
Bogdan Timofte authored a month ago
1228
    var kind: ChargedDeviceKind {
1229
        deviceClass.kind
1230
    }
1231

            
1232
    var identityTitle: String {
Bogdan Timofte authored a month ago
1233
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1234
    }
1235

            
Bogdan Timofte authored a month ago
1236
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1237
        isCharger ? kind.symbolName : deviceClass.symbolName
1238
    }
1239

            
Bogdan Timofte authored a month ago
1240
    var identityIcon: ChargedDeviceTemplateIcon {
1241
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1242
    }
1243

            
1244
    var identitySymbolName: String {
1245
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1246
    }
1247

            
Bogdan Timofte authored a month ago
1248
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1249
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1250
    }
1251

            
1252
    var recentCompletedSessions: [ChargeSessionSummary] {
1253
        sessions.filter { $0.status == .completed }
1254
    }
1255

            
1256
    var sessionCount: Int {
1257
        sessions.count
1258
    }
1259

            
Bogdan Timofte authored a month ago
1260
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1261
        standbyPowerMeasurements.first
1262
    }
1263

            
Bogdan Timofte authored a month ago
1264
    var supportedChargingModes: [ChargingTransportMode] {
1265
        var modes: [ChargingTransportMode] = []
1266
        if supportsWiredCharging {
1267
            modes.append(.wired)
1268
        }
1269
        if supportsWirelessCharging {
1270
            modes.append(.wireless)
1271
        }
Bogdan Timofte authored a month ago
1272
        return modes
Bogdan Timofte authored a month ago
1273
    }
1274

            
Bogdan Timofte authored a month ago
1275
    var supportedChargingStateModes: [ChargingStateMode] {
1276
        chargingStateAvailability.supportedModes
1277
    }
1278

            
Bogdan Timofte authored a month ago
1279
    var hasMultipleChargingTransports: Bool {
1280
        supportedChargingModes.count > 1
1281
    }
1282

            
1283
    var hasMultipleChargingStateModes: Bool {
1284
        supportedChargingStateModes.count > 1
1285
    }
1286

            
1287
    var showsWirelessProfileDetails: Bool {
1288
        supportsWirelessCharging
1289
            && hasMultipleChargingTransports
1290
            && deviceClass != .watch
1291
    }
1292

            
1293
    var chargingSupportSummary: String {
1294
        switch (supportsWiredCharging, supportsWirelessCharging) {
1295
        case (true, true):
1296
            return "Supports wired and wireless charging."
1297
        case (true, false):
1298
            return "Supports wired charging only."
1299
        case (false, true):
1300
            return "Supports wireless charging only."
1301
        case (false, false):
1302
            return "No charging method configured."
1303
        }
1304
    }
1305

            
Bogdan Timofte authored a month ago
1306
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1307
        if let matchingSession = sessions.first(where: {
1308
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1309
        }) {
1310
            return matchingSession.chargingStateMode
1311
        }
1312
        return chargingStateAvailability.supportedModes.first ?? .on
1313
    }
1314

            
1315
    func sessionKind(
1316
        for chargingTransportMode: ChargingTransportMode,
1317
        chargingStateMode: ChargingStateMode? = nil
1318
    ) -> ChargeSessionKind {
1319
        ChargeSessionKind(
1320
            chargingTransportMode: chargingTransportMode,
1321
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1322
        )
1323
    }
1324

            
Bogdan Timofte authored a month ago
1325
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1326
        switch chargingTransportMode {
1327
        case .wired:
1328
            return wiredEstimatedBatteryCapacityWh
1329
        case .wireless:
1330
            return wirelessEstimatedBatteryCapacityWh
1331
        }
1332
    }
1333

            
1334
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1335
        switch chargingTransportMode {
1336
        case .wired:
1337
            return wiredMinimumCurrentAmps
1338
        case .wireless:
1339
            return wirelessMinimumCurrentAmps
1340
        }
1341
    }
1342

            
Bogdan Timofte authored a month ago
1343
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1344
        hasMultipleChargingTransports
1345
            || supportedChargingModes.contains(chargingTransportMode) == false
1346
    }
1347

            
1348
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1349
        hasMultipleChargingStateModes
1350
            || supportedChargingStateModes.contains(chargingStateMode) == false
1351
    }
1352

            
Bogdan Timofte authored a month ago
1353
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1354
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
1355
            return explicitCurrent
1356
        }
1357

            
1358
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
1359
        case .wired:
1360
            return wiredChargeCompletionCurrentAmps
1361
        case .wireless:
1362
            return wirelessChargeCompletionCurrentAmps
1363
        }
1364
    }
1365

            
Bogdan Timofte authored a month ago
1366
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1367
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
1368
            return learnedCurrent
1369
        }
1370

            
1371
        switch sessionKind.chargingTransportMode {
1372
        case .wired:
1373
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
1374
        case .wireless:
1375
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
1376
        }
1377
    }
1378

            
1379
    func resolvedCompletionCurrentAmps(
1380
        for chargingTransportMode: ChargingTransportMode,
1381
        chargingStateMode: ChargingStateMode? = nil
1382
    ) -> Double? {
1383
        let sessionKind = sessionKind(
1384
            for: chargingTransportMode,
1385
            chargingStateMode: chargingStateMode
1386
        )
1387

            
1388
        return configuredCompletionCurrentAmps(for: sessionKind)
1389
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
1390
            ?? minimumCurrentAmps(for: chargingTransportMode)
1391
            ?? minimumCurrentAmps
1392
    }
1393

            
Bogdan Timofte authored a month ago
1394
    func batteryLevelPrediction(
1395
        for session: ChargeSessionSummary,
1396
        effectiveEnergyWhOverride: Double? = nil
1397
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1398
        let estimatedCapacityWh = session.capacityEstimateWh
1399
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1400
            ?? estimatedBatteryCapacityWh
1401

            
1402
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1403
            return nil
1404
        }
1405

            
Bogdan Timofte authored a month ago
1406
        let effectiveEnergyWh = effectiveEnergyWhOverride
1407
            ?? session.effectiveBatteryEnergyWh
1408
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1409

            
1410
        struct Anchor {
1411
            let percent: Double
1412
            let energyWh: Double
Bogdan Timofte authored a month ago
1413
            let timestamp: Date
Bogdan Timofte authored a month ago
1414
            let description: String
Bogdan Timofte authored a month ago
1415
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1416
        }
1417

            
1418
        var anchors: [Anchor] = []
1419

            
Bogdan Timofte authored a month ago
1420
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1421
            anchors.append(
1422
                Anchor(
1423
                    percent: startBatteryPercent,
1424
                    energyWh: 0,
Bogdan Timofte authored a month ago
1425
                    timestamp: session.effectiveTrimStart,
Bogdan Timofte authored a month ago
1426
                    description: "session start",
1427
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1428
                )
1429
            )
1430
        }
1431

            
1432
        anchors.append(
1433
            contentsOf: session.checkpoints
1434
                .sorted { lhs, rhs in
1435
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1436
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1437
                    }
1438
                    return lhs.timestamp < rhs.timestamp
1439
                }
Bogdan Timofte authored a month ago
1440
                .filter { checkpoint in
1441
                    checkpoint.batteryPercent >= 0
1442
                }
Bogdan Timofte authored a month ago
1443
                .map { checkpoint in
1444
                    return Anchor(
1445
                        percent: checkpoint.batteryPercent,
1446
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1447
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
1448
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
1449
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1450
                    )
1451
                }
1452
        )
1453

            
1454
        guard !anchors.isEmpty else {
1455
            return nil
1456
        }
1457

            
1458
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1459
        let anchor = eligibleAnchors.last ?? anchors.first!
Bogdan Timofte authored a month ago
1460
        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1461
            anchorPercent: anchor.percent,
1462
            anchorEnergyWh: anchor.energyWh,
1463
            anchorTimestamp: anchor.timestamp,
1464
            anchorIsCheckpoint: anchor.isCheckpoint,
1465
            effectiveEnergyWh: effectiveEnergyWh,
1466
            referenceTimestamp: session.lastObservedAt,
1467
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1468
        )
1469

            
1470
        return BatteryLevelPrediction(
1471
            predictedPercent: predictedPercent,
1472
            estimatedCapacityWh: estimatedCapacityWh,
1473
            anchorPercent: anchor.percent,
1474
            anchorEnergyWh: anchor.energyWh,
1475
            anchorDescription: anchor.description
1476
        )
1477
    }
Bogdan Timofte authored a month ago
1478

            
1479
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1480
        ChargedDeviceSummary(
1481
            id: id,
1482
            qrIdentifier: qrIdentifier,
1483
            name: name,
1484
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1485
            deviceTemplateID: deviceTemplateID,
1486
            templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1487
            supportsChargingWhileOff: supportsChargingWhileOff,
1488
            chargingStateAvailability: chargingStateAvailability,
1489
            supportsWiredCharging: supportsWiredCharging,
1490
            supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1491
            chargerType: chargerType,
Bogdan Timofte authored a month ago
1492
            wirelessChargingProfile: wirelessChargingProfile,
1493
            configuredCompletionCurrents: configuredCompletionCurrents,
1494
            learnedCompletionCurrents: learnedCompletionCurrents,
1495
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1496
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1497
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1498
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1499
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1500
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1501
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1502
            notes: notes,
1503
            minimumCurrentAmps: minimumCurrentAmps,
1504
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1505
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1506
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1507
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1508
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1509
            lastAssociatedMeterMAC: lastAssociatedMeterMAC,
1510
            createdAt: createdAt,
1511
            updatedAt: updatedAt,
1512
            sessions: sessions,
1513
            capacityHistory: capacityHistory,
1514
            typicalCurve: typicalCurve,
1515
            standbyPowerMeasurements: measurements
1516
        )
1517
    }
Bogdan Timofte authored a month ago
1518
}
1519

            
1520
struct ChargingMonitorSnapshot {
1521
    let meterMACAddress: String
1522
    let meterName: String
1523
    let meterModel: String
1524
    let observedAt: Date
1525
    let voltageVolts: Double
1526
    let currentAmps: Double
1527
    let powerWatts: Double
1528
    let selectedDataGroup: UInt8?
1529
    let meterChargeCounterAh: Double?
1530
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1531
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1532
    let fallbackStopThresholdAmps: Double
1533
}