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

            
8
import Foundation
9

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

            
14
    var id: String { rawValue }
15

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

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

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

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

            
51
    var id: String { rawValue }
52

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
215
    var id: String { rawValue }
216

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

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

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

            
240
    var id: String { rawValue }
241

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

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

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

            
266
    var id: String { rawValue }
267

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

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

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

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

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

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

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

            
320
    var id: String { rawValue }
321

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

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

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

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

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

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

            
366
    var id: String { rawValue }
367

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

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

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

            
393
    var id: String { rawValue }
394

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
602
struct ChargeSessionSampleSummary: Identifiable, Hashable {
603
    let sessionID: UUID
604
    let chargedDeviceID: UUID
605
    let bucketIndex: Int
606
    let timestamp: Date
607
    let averageCurrentAmps: Double
608
    let averageVoltageVolts: Double?
609
    let averagePowerWatts: Double
610
    let measuredEnergyWh: Double
611
    let measuredChargeAh: Double
612
    let sampleCount: Int
613

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

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

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

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

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

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

            
704
    var effectiveDuration: TimeInterval {
Bogdan Timofte authored a month ago
705
        if isTrimmed {
706
            return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0)
707
        }
708
        return meterObservedDuration ?? duration
Bogdan Timofte authored a month ago
709
    }
710

            
Bogdan Timofte authored a month ago
711
    var effectiveOrMeasuredEnergyWh: Double {
712
        effectiveBatteryEnergyWh ?? measuredEnergyWh
713
    }
714

            
Bogdan Timofte authored a month ago
715
    var hasSavableChargeData: Bool {
716
        hasObservedChargeFlow
717
            || measuredEnergyWh > 0
718
            || measuredChargeAh > 0
719
            || (maximumObservedCurrentAmps ?? 0) > 0
720
            || (maximumObservedPowerWatts ?? 0) > 0
721
            || !aggregatedSamples.isEmpty
722
    }
723

            
Bogdan Timofte authored a month ago
724
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
725
        guard let startBatteryPercent, let endBatteryPercent,
726
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
727
        return endBatteryPercent - startBatteryPercent
728
    }
Bogdan Timofte authored a month ago
729

            
730
    var canAutoStop: Bool {
731
        autoStopEnabled && stopThresholdAmps > 0
732
    }
733

            
734
    var isPaused: Bool {
735
        status == .paused
736
    }
737

            
738
    var isOpen: Bool {
739
        status.isOpen
740
    }
Bogdan Timofte authored a month ago
741
}
742

            
743
struct BatteryLevelPrediction: Hashable {
744
    let predictedPercent: Double
745
    let estimatedCapacityWh: Double
746
    let anchorPercent: Double
747
    let anchorEnergyWh: Double
748
    let anchorDescription: String
749
}
750

            
Bogdan Timofte authored a month ago
751
enum BatteryLevelPredictionTuning {
752
    static let checkpointSettleDuration: TimeInterval = 10 * 60
753

            
754
    static func predictedPercent(
755
        anchorPercent: Double,
756
        anchorEnergyWh: Double,
757
        anchorTimestamp: Date,
758
        anchorIsCheckpoint: Bool,
759
        effectiveEnergyWh: Double,
760
        referenceTimestamp: Date,
761
        estimatedCapacityWh: Double
762
    ) -> Double {
763
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
764
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
765
        let stabilizedGainPercent: Double
766

            
767
        if anchorIsCheckpoint {
768
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
769
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
770
            stabilizedGainPercent = rawGainPercent * settleProgress
771
        } else {
772
            stabilizedGainPercent = rawGainPercent
773
        }
774

            
775
        return min(
776
            100,
777
            max(
778
                0,
779
                anchorPercent + stabilizedGainPercent
780
            )
781
        )
782
    }
783
}
784

            
Bogdan Timofte authored a month ago
785
struct CapacityTrendPoint: Identifiable, Hashable {
786
    let sessionID: UUID
787
    let timestamp: Date
788
    let capacityWh: Double
789
    let chargingTransportMode: ChargingTransportMode
790

            
791
    var id: UUID { sessionID }
792
}
793

            
794
struct TypicalChargeCurvePoint: Identifiable, Hashable {
795
    let percentBin: Int
796
    let averageEnergyWh: Double
797
    let averageChargeAh: Double
798
    let sampleCount: Int
799

            
800
    var id: Int { percentBin }
801
}
802

            
Bogdan Timofte authored a month ago
803
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
804
    let timestamp: Date
805
    let powerWatts: Double
806
    let currentAmps: Double
807
    let voltageVolts: Double
808

            
809
    var id: TimeInterval {
810
        timestamp.timeIntervalSince1970
811
    }
812
}
813

            
Bogdan Timofte authored a month ago
814
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
Bogdan Timofte authored a month ago
815
    let index: Int
816
    let lowerBoundWatts: Double
817
    let upperBoundWatts: Double
818
    let count: Int
819
    let relativeFrequency: Double
820

            
821
    var id: Int { index }
822
}
823

            
Bogdan Timofte authored a month ago
824
enum HistogramResolution: Int, CaseIterable, Identifiable {
825
    case x1 = 1
826
    case x2 = 2
827
    case x4 = 4
828

            
829
    var id: Int { rawValue }
830

            
831
    var label: String {
832
        switch self {
833
        case .x1: return "1×"
834
        case .x2: return "2×"
835
        case .x4: return "4×"
836
        }
837
    }
838
}
839

            
Bogdan Timofte authored a month ago
840
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
841
    let sampleCount: Int
842
    let observedDuration: TimeInterval
843
    let averagePowerWatts: Double
844
    let recentAveragePowerWatts: Double
845
    let medianPowerWatts: Double
846
    let minimumPowerWatts: Double
847
    let maximumPowerWatts: Double
848
    let standardDeviationPowerWatts: Double
849
    let coefficientOfVariation: Double
850
    let averageCurrentAmps: Double
851
    let averageVoltageVolts: Double
852
    let stabilityDeltaWatts: Double
853
    let stabilityToleranceWatts: Double
854
    let histogram: [ChargerStandbyPowerDistributionBin]
855

            
856
    var projectedDailyEnergyWh: Double {
857
        averagePowerWatts * 24
858
    }
859

            
860
    var projectedWeeklyEnergyWh: Double {
861
        averagePowerWatts * 24 * 7
862
    }
863

            
864
    var projectedMonthlyEnergyWh: Double {
865
        averagePowerWatts * 24 * 30
866
    }
867

            
868
    var projectedYearlyEnergyWh: Double {
869
        averagePowerWatts * 24 * 365
870
    }
871

            
872
    var stabilityDeltaMilliwatts: Double {
873
        stabilityDeltaWatts * 1000
874
    }
875

            
876
    var isStable: Bool {
877
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
878
        && stabilityDeltaWatts <= stabilityToleranceWatts
879
    }
880
}
881

            
882
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
883
    let id: UUID
884
    let chargerID: UUID
885
    let meterMACAddress: String
886
    let meterName: String?
887
    let meterModel: String?
888
    let startedAt: Date
889
    let endedAt: Date
890
    let sampleCount: Int
891
    let stabilizedAt: Date?
892
    let averagePowerWatts: Double
893
    let recentAveragePowerWatts: Double
894
    let medianPowerWatts: Double
895
    let minimumPowerWatts: Double
896
    let maximumPowerWatts: Double
897
    let standardDeviationPowerWatts: Double
898
    let coefficientOfVariation: Double
899
    let averageCurrentAmps: Double
900
    let averageVoltageVolts: Double
901
    let stabilityDeltaWatts: Double
902
    let stabilityToleranceWatts: Double
Bogdan Timofte authored a month ago
903
    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
904
    let storedHistogram: [ChargerStandbyPowerDistributionBin]
905

            
906
    // MARK: - Codable (with migration from legacy powerSamplesWatts)
907

            
908
    private enum CodingKeys: String, CodingKey {
909
        case id, chargerID, meterMACAddress, meterName, meterModel
910
        case startedAt, endedAt, sampleCount, stabilizedAt
911
        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
912
        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
913
        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
914
        case stabilityDeltaWatts, stabilityToleranceWatts
915
        case storedHistogram
916
        case powerSamplesWatts // legacy – decode only
917
    }
918

            
919
    init(
920
        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
921
        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
922
        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
923
        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
924
        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
925
        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
926
        storedHistogram: [ChargerStandbyPowerDistributionBin]
927
    ) {
928
        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
929
        self.meterName = meterName; self.meterModel = meterModel
930
        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
931
        self.stabilizedAt = stabilizedAt
932
        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
933
        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
934
        self.maximumPowerWatts = maximumPowerWatts
935
        self.standardDeviationPowerWatts = standardDeviationPowerWatts
936
        self.coefficientOfVariation = coefficientOfVariation
937
        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
938
        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
939
        self.storedHistogram = storedHistogram
940
    }
941

            
942
    init(from decoder: Decoder) throws {
943
        let c = try decoder.container(keyedBy: CodingKeys.self)
944
        id = try c.decode(UUID.self, forKey: .id)
945
        chargerID = try c.decode(UUID.self, forKey: .chargerID)
946
        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
947
        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
948
        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
949
        startedAt = try c.decode(Date.self, forKey: .startedAt)
950
        endedAt = try c.decode(Date.self, forKey: .endedAt)
951
        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
952
        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
953
        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
954
        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
955
        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
956
        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
957
        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
958
        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
959
        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
960
        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
961
        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
962
        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
963
        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)
964

            
965
        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
966
        if let decodedBins, !decodedBins.isEmpty {
967
            storedHistogram = decodedBins
968
        } else {
969
            // Migrate from legacy raw samples format
970
            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
971
            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
972
            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
973
                for: samples,
974
                preferredBinCount: base * HistogramResolution.x4.rawValue
975
            )
976
        }
Bogdan Timofte authored a month ago
977
    }
978

            
Bogdan Timofte authored a month ago
979
    func encode(to encoder: Encoder) throws {
980
        var c = encoder.container(keyedBy: CodingKeys.self)
981
        try c.encode(id, forKey: .id)
982
        try c.encode(chargerID, forKey: .chargerID)
983
        try c.encode(meterMACAddress, forKey: .meterMACAddress)
984
        try c.encodeIfPresent(meterName, forKey: .meterName)
985
        try c.encodeIfPresent(meterModel, forKey: .meterModel)
986
        try c.encode(startedAt, forKey: .startedAt)
987
        try c.encode(endedAt, forKey: .endedAt)
988
        try c.encode(sampleCount, forKey: .sampleCount)
989
        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
990
        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
991
        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
992
        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
993
        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
994
        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
995
        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
996
        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
997
        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
998
        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
999
        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
1000
        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
1001
        try c.encode(storedHistogram, forKey: .storedHistogram)
1002
    }
1003

            
1004
    // MARK: - Computed
1005

            
1006
    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
1007
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
1008
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
1009
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
1010
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
1011
    var isStable: Bool { stabilizedAt != nil }
1012

            
1013
    /// Returns the histogram downsampled to the requested resolution.
1014
    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
1015
    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
1016
        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
1017
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
Bogdan Timofte authored a month ago
1018
    }
1019
}
1020

            
1021
enum ChargerStandbyPowerMeasurementAnalyzer {
1022
    static let minimumStableSampleCount = 45
Bogdan Timofte authored a month ago
1023
    static let recentSampleWindow = 40
1024
    static let minimumStabilityToleranceWatts = 0.010
1025
    static let relativeStabilityTolerance = 0.05
Bogdan Timofte authored a month ago
1026

            
1027
    static func statistics(
1028
        from samples: [ChargerStandbyPowerSample],
1029
        startedAt: Date,
1030
        referenceDate: Date = Date()
1031
    ) -> ChargerStandbyPowerMeasurementStatistics? {
1032
        guard !samples.isEmpty else {
1033
            return nil
1034
        }
1035

            
1036
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
1037
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
1038
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
1039

            
1040
        guard powerValues.isEmpty == false else {
1041
            return nil
1042
        }
1043

            
1044
        let averagePower = mean(powerValues)
1045
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
1046
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
1047
        let stabilityDelta = abs(averagePower - recentAveragePower)
1048
        let stabilityTolerance = max(
1049
            minimumStabilityToleranceWatts,
1050
            abs(averagePower) * relativeStabilityTolerance
1051
        )
1052

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

            
Bogdan Timofte authored a month ago
1056
        return ChargerStandbyPowerMeasurementStatistics(
1057
            sampleCount: powerValues.count,
1058
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
1059
            averagePowerWatts: averagePower,
1060
            recentAveragePowerWatts: recentAveragePower,
1061
            medianPowerWatts: median(powerValues),
1062
            minimumPowerWatts: powerValues.min() ?? 0,
1063
            maximumPowerWatts: powerValues.max() ?? 0,
1064
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
1065
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
1066
            averageCurrentAmps: mean(currentValues),
1067
            averageVoltageVolts: mean(voltageValues),
1068
            stabilityDeltaWatts: stabilityDelta,
1069
            stabilityToleranceWatts: stabilityTolerance,
Bogdan Timofte authored a month ago
1070
            histogram: liveHistogram
Bogdan Timofte authored a month ago
1071
        )
1072
    }
1073

            
1074
    static func measurementSummary(
1075
        chargerID: UUID,
1076
        meterMACAddress: String,
1077
        meterName: String?,
1078
        meterModel: String?,
1079
        startedAt: Date,
1080
        endedAt: Date,
1081
        samples: [ChargerStandbyPowerSample],
1082
        stabilizedAt: Date?
1083
    ) -> ChargerStandbyPowerMeasurementSummary? {
1084
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
1085
            return nil
1086
        }
1087

            
1088
        return ChargerStandbyPowerMeasurementSummary(
1089
            id: UUID(),
1090
            chargerID: chargerID,
1091
            meterMACAddress: meterMACAddress,
1092
            meterName: meterName,
1093
            meterModel: meterModel,
1094
            startedAt: startedAt,
1095
            endedAt: endedAt,
1096
            sampleCount: statistics.sampleCount,
1097
            stabilizedAt: stabilizedAt,
1098
            averagePowerWatts: statistics.averagePowerWatts,
1099
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
1100
            medianPowerWatts: statistics.medianPowerWatts,
1101
            minimumPowerWatts: statistics.minimumPowerWatts,
1102
            maximumPowerWatts: statistics.maximumPowerWatts,
1103
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
1104
            coefficientOfVariation: statistics.coefficientOfVariation,
1105
            averageCurrentAmps: statistics.averageCurrentAmps,
1106
            averageVoltageVolts: statistics.averageVoltageVolts,
1107
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
1108
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
Bogdan Timofte authored a month ago
1109
            storedHistogram: statistics.histogram
Bogdan Timofte authored a month ago
1110
        )
1111
    }
1112

            
Bogdan Timofte authored a month ago
1113
    /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1.
1114
    static func downsample(
1115
        _ bins: [ChargerStandbyPowerDistributionBin],
1116
        factor: Int
1117
    ) -> [ChargerStandbyPowerDistributionBin] {
1118
        guard factor > 1, !bins.isEmpty else { return bins }
1119
        let totalCount = bins.reduce(0) { $0 + $1.count }
1120
        var result: [ChargerStandbyPowerDistributionBin] = []
1121
        var inputIndex = 0
1122
        var outputIndex = 0
1123
        while inputIndex < bins.count {
1124
            let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)])
1125
            let mergedCount = group.reduce(0) { $0 + $1.count }
1126
            let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0
1127
            result.append(ChargerStandbyPowerDistributionBin(
1128
                index: outputIndex,
1129
                lowerBoundWatts: group.first!.lowerBoundWatts,
1130
                upperBoundWatts: group.last!.upperBoundWatts,
1131
                count: mergedCount,
1132
                relativeFrequency: relFreq
1133
            ))
1134
            inputIndex += factor
1135
            outputIndex += 1
1136
        }
1137
        return result
1138
    }
1139

            
Bogdan Timofte authored a month ago
1140
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
1141
        let finiteValues = values.filter(\.isFinite)
1142
        guard finiteValues.isEmpty == false else {
1143
            return []
1144
        }
1145

            
1146
        let minimum = finiteValues.min() ?? 0
1147
        let maximum = finiteValues.max() ?? 0
1148
        let spread = maximum - minimum
1149
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
1150

            
1151
        guard spread > 0 else {
1152
            return [
1153
                ChargerStandbyPowerDistributionBin(
1154
                    index: 0,
1155
                    lowerBoundWatts: minimum,
1156
                    upperBoundWatts: maximum,
1157
                    count: finiteValues.count,
1158
                    relativeFrequency: 1
1159
                )
1160
            ]
1161
        }
1162

            
1163
        let safeBinCount = max(1, binCount)
1164
        let binWidth = spread / Double(safeBinCount)
1165
        var counts = Array(repeating: 0, count: safeBinCount)
1166

            
1167
        for value in finiteValues {
1168
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
1169
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
1170
            counts[safeIndex] += 1
1171
        }
1172

            
1173
        return counts.enumerated().map { index, count in
1174
            let lowerBound = minimum + (Double(index) * binWidth)
1175
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
1176

            
1177
            return ChargerStandbyPowerDistributionBin(
1178
                index: index,
1179
                lowerBoundWatts: lowerBound,
1180
                upperBoundWatts: upperBound,
1181
                count: count,
1182
                relativeFrequency: Double(count) / Double(finiteValues.count)
1183
            )
1184
        }
1185
    }
1186

            
1187
    private static func mean(_ values: [Double]) -> Double {
1188
        guard values.isEmpty == false else {
1189
            return 0
1190
        }
1191
        return values.reduce(0, +) / Double(values.count)
1192
    }
1193

            
1194
    private static func median(_ values: [Double]) -> Double {
1195
        guard values.isEmpty == false else {
1196
            return 0
1197
        }
1198

            
1199
        let sorted = values.sorted()
1200
        let middleIndex = sorted.count / 2
1201

            
1202
        if sorted.count.isMultiple(of: 2) {
1203
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
1204
        }
1205

            
1206
        return sorted[middleIndex]
1207
    }
1208

            
1209
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
1210
        guard values.count > 1 else {
1211
            return 0
1212
        }
1213

            
1214
        let variance = values.reduce(0) { partialResult, value in
1215
            let delta = value - mean
1216
            return partialResult + (delta * delta)
1217
        } / Double(values.count)
1218

            
1219
        return variance.squareRoot()
1220
    }
1221

            
1222
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
1223
        guard abs(mean) > 0.000_001 else {
1224
            return 0
1225
        }
1226

            
1227
        return standardDeviation(values, mean: mean) / abs(mean)
1228
    }
1229
}
1230

            
Bogdan Timofte authored a month ago
1231
struct ChargedDeviceSummary: Identifiable, Hashable {
1232
    let id: UUID
1233
    let qrIdentifier: String
1234
    let name: String
1235
    let deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
1236
    let deviceTemplateID: String?
1237
    let templateDefinition: ChargedDeviceTemplateDefinition?
Bogdan Timofte authored a month ago
1238
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
1239
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
1240
    let supportsWiredCharging: Bool
1241
    let supportsWirelessCharging: Bool
Bogdan Timofte authored a month ago
1242
    let chargerType: ChargerType?
Bogdan Timofte authored a month ago
1243
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
1244
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
1245
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
1246
    let wirelessChargerEfficiencyFactor: Double?
1247
    let wiredChargeCompletionCurrentAmps: Double?
1248
    let wirelessChargeCompletionCurrentAmps: Double?
1249
    let chargerObservedVoltageSelections: [Double]
1250
    let chargerIdleCurrentAmps: Double?
1251
    let chargerEfficiencyFactor: Double?
1252
    let chargerMaximumPowerWatts: Double?
1253
    let notes: String?
1254
    let minimumCurrentAmps: Double?
1255
    let estimatedBatteryCapacityWh: Double?
1256
    let wiredMinimumCurrentAmps: Double?
1257
    let wirelessMinimumCurrentAmps: Double?
1258
    let wiredEstimatedBatteryCapacityWh: Double?
1259
    let wirelessEstimatedBatteryCapacityWh: Double?
1260
    let lastAssociatedMeterMAC: String?
1261
    let createdAt: Date
1262
    let updatedAt: Date
1263
    let sessions: [ChargeSessionSummary]
1264
    let capacityHistory: [CapacityTrendPoint]
1265
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
1266
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
1267

            
1268
    var isCharger: Bool {
1269
        deviceClass == .charger
1270
    }
1271

            
Bogdan Timofte authored a month ago
1272
    var kind: ChargedDeviceKind {
1273
        deviceClass.kind
1274
    }
1275

            
1276
    var identityTitle: String {
Bogdan Timofte authored a month ago
1277
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1278
    }
1279

            
Bogdan Timofte authored a month ago
1280
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1281
        isCharger ? kind.symbolName : deviceClass.symbolName
1282
    }
1283

            
Bogdan Timofte authored a month ago
1284
    var identityIcon: ChargedDeviceTemplateIcon {
1285
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1286
    }
1287

            
1288
    var identitySymbolName: String {
1289
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1290
    }
1291

            
Bogdan Timofte authored a month ago
1292
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1293
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1294
    }
1295

            
1296
    var recentCompletedSessions: [ChargeSessionSummary] {
1297
        sessions.filter { $0.status == .completed }
1298
    }
1299

            
1300
    var sessionCount: Int {
1301
        sessions.count
1302
    }
1303

            
Bogdan Timofte authored a month ago
1304
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1305
        standbyPowerMeasurements.first
1306
    }
1307

            
Bogdan Timofte authored a month ago
1308
    var supportedChargingModes: [ChargingTransportMode] {
1309
        var modes: [ChargingTransportMode] = []
1310
        if supportsWiredCharging {
1311
            modes.append(.wired)
1312
        }
1313
        if supportsWirelessCharging {
1314
            modes.append(.wireless)
1315
        }
Bogdan Timofte authored a month ago
1316
        return modes
Bogdan Timofte authored a month ago
1317
    }
1318

            
Bogdan Timofte authored a month ago
1319
    var supportedChargingStateModes: [ChargingStateMode] {
1320
        chargingStateAvailability.supportedModes
1321
    }
1322

            
Bogdan Timofte authored a month ago
1323
    var hasMultipleChargingTransports: Bool {
1324
        supportedChargingModes.count > 1
1325
    }
1326

            
1327
    var hasMultipleChargingStateModes: Bool {
1328
        supportedChargingStateModes.count > 1
1329
    }
1330

            
1331
    var showsWirelessProfileDetails: Bool {
1332
        supportsWirelessCharging
1333
            && hasMultipleChargingTransports
1334
            && deviceClass != .watch
1335
    }
1336

            
1337
    var chargingSupportSummary: String {
1338
        switch (supportsWiredCharging, supportsWirelessCharging) {
1339
        case (true, true):
1340
            return "Supports wired and wireless charging."
1341
        case (true, false):
1342
            return "Supports wired charging only."
1343
        case (false, true):
1344
            return "Supports wireless charging only."
1345
        case (false, false):
1346
            return "No charging method configured."
1347
        }
1348
    }
1349

            
Bogdan Timofte authored a month ago
1350
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1351
        if let matchingSession = sessions.first(where: {
1352
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1353
        }) {
1354
            return matchingSession.chargingStateMode
1355
        }
1356
        return chargingStateAvailability.supportedModes.first ?? .on
1357
    }
1358

            
1359
    func sessionKind(
1360
        for chargingTransportMode: ChargingTransportMode,
1361
        chargingStateMode: ChargingStateMode? = nil
1362
    ) -> ChargeSessionKind {
1363
        ChargeSessionKind(
1364
            chargingTransportMode: chargingTransportMode,
1365
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1366
        )
1367
    }
1368

            
Bogdan Timofte authored a month ago
1369
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1370
        switch chargingTransportMode {
1371
        case .wired:
1372
            return wiredEstimatedBatteryCapacityWh
1373
        case .wireless:
1374
            return wirelessEstimatedBatteryCapacityWh
1375
        }
1376
    }
1377

            
1378
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1379
        switch chargingTransportMode {
1380
        case .wired:
1381
            return wiredMinimumCurrentAmps
1382
        case .wireless:
1383
            return wirelessMinimumCurrentAmps
1384
        }
1385
    }
1386

            
Bogdan Timofte authored a month ago
1387
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1388
        hasMultipleChargingTransports
1389
            || supportedChargingModes.contains(chargingTransportMode) == false
1390
    }
1391

            
1392
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1393
        hasMultipleChargingStateModes
1394
            || supportedChargingStateModes.contains(chargingStateMode) == false
1395
    }
1396

            
Bogdan Timofte authored a month ago
1397
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1398
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
1399
            return explicitCurrent
1400
        }
1401

            
1402
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
1403
        case .wired:
1404
            return wiredChargeCompletionCurrentAmps
1405
        case .wireless:
1406
            return wirelessChargeCompletionCurrentAmps
1407
        }
1408
    }
1409

            
Bogdan Timofte authored a month ago
1410
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1411
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
1412
            return learnedCurrent
1413
        }
1414

            
1415
        switch sessionKind.chargingTransportMode {
1416
        case .wired:
1417
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
1418
        case .wireless:
1419
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
1420
        }
1421
    }
1422

            
1423
    func resolvedCompletionCurrentAmps(
1424
        for chargingTransportMode: ChargingTransportMode,
1425
        chargingStateMode: ChargingStateMode? = nil
1426
    ) -> Double? {
1427
        let sessionKind = sessionKind(
1428
            for: chargingTransportMode,
1429
            chargingStateMode: chargingStateMode
1430
        )
1431

            
1432
        return configuredCompletionCurrentAmps(for: sessionKind)
1433
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
1434
            ?? minimumCurrentAmps(for: chargingTransportMode)
1435
            ?? minimumCurrentAmps
1436
    }
1437

            
Bogdan Timofte authored a month ago
1438
    func batteryLevelPrediction(
1439
        for session: ChargeSessionSummary,
1440
        effectiveEnergyWhOverride: Double? = nil
1441
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1442
        let estimatedCapacityWh = session.capacityEstimateWh
1443
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1444
            ?? estimatedBatteryCapacityWh
1445

            
1446
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1447
            return nil
1448
        }
1449

            
Bogdan Timofte authored a month ago
1450
        let effectiveEnergyWh = effectiveEnergyWhOverride
1451
            ?? session.effectiveBatteryEnergyWh
1452
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1453

            
1454
        struct Anchor {
1455
            let percent: Double
1456
            let energyWh: Double
Bogdan Timofte authored a month ago
1457
            let timestamp: Date
Bogdan Timofte authored a month ago
1458
            let description: String
Bogdan Timofte authored a month ago
1459
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1460
        }
1461

            
1462
        var anchors: [Anchor] = []
1463

            
Bogdan Timofte authored a month ago
1464
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1465
            anchors.append(
1466
                Anchor(
1467
                    percent: startBatteryPercent,
1468
                    energyWh: 0,
Bogdan Timofte authored a month ago
1469
                    timestamp: session.effectiveTrimStart,
Bogdan Timofte authored a month ago
1470
                    description: "session start",
1471
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1472
                )
1473
            )
1474
        }
1475

            
1476
        anchors.append(
1477
            contentsOf: session.checkpoints
1478
                .sorted { lhs, rhs in
1479
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1480
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1481
                    }
1482
                    return lhs.timestamp < rhs.timestamp
1483
                }
Bogdan Timofte authored a month ago
1484
                .filter { checkpoint in
1485
                    checkpoint.batteryPercent >= 0
1486
                }
Bogdan Timofte authored a month ago
1487
                .map { checkpoint in
1488
                    return Anchor(
1489
                        percent: checkpoint.batteryPercent,
1490
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1491
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
1492
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
1493
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1494
                    )
1495
                }
1496
        )
1497

            
1498
        guard !anchors.isEmpty else {
1499
            return nil
1500
        }
1501

            
1502
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1503
        let anchor = eligibleAnchors.last ?? anchors.first!
Bogdan Timofte authored a month ago
1504
        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1505
            anchorPercent: anchor.percent,
1506
            anchorEnergyWh: anchor.energyWh,
1507
            anchorTimestamp: anchor.timestamp,
1508
            anchorIsCheckpoint: anchor.isCheckpoint,
1509
            effectiveEnergyWh: effectiveEnergyWh,
1510
            referenceTimestamp: session.lastObservedAt,
1511
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1512
        )
1513

            
1514
        return BatteryLevelPrediction(
1515
            predictedPercent: predictedPercent,
1516
            estimatedCapacityWh: estimatedCapacityWh,
1517
            anchorPercent: anchor.percent,
1518
            anchorEnergyWh: anchor.energyWh,
1519
            anchorDescription: anchor.description
1520
        )
1521
    }
Bogdan Timofte authored a month ago
1522

            
1523
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1524
        ChargedDeviceSummary(
1525
            id: id,
1526
            qrIdentifier: qrIdentifier,
1527
            name: name,
1528
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1529
            deviceTemplateID: deviceTemplateID,
1530
            templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1531
            supportsChargingWhileOff: supportsChargingWhileOff,
1532
            chargingStateAvailability: chargingStateAvailability,
1533
            supportsWiredCharging: supportsWiredCharging,
1534
            supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1535
            chargerType: chargerType,
Bogdan Timofte authored a month ago
1536
            wirelessChargingProfile: wirelessChargingProfile,
1537
            configuredCompletionCurrents: configuredCompletionCurrents,
1538
            learnedCompletionCurrents: learnedCompletionCurrents,
1539
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1540
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1541
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1542
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1543
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1544
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1545
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1546
            notes: notes,
1547
            minimumCurrentAmps: minimumCurrentAmps,
1548
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1549
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1550
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1551
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1552
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1553
            lastAssociatedMeterMAC: lastAssociatedMeterMAC,
1554
            createdAt: createdAt,
1555
            updatedAt: updatedAt,
1556
            sessions: sessions,
1557
            capacityHistory: capacityHistory,
1558
            typicalCurve: typicalCurve,
1559
            standbyPowerMeasurements: measurements
1560
        )
1561
    }
Bogdan Timofte authored a month ago
1562
}
1563

            
1564
struct ChargingMonitorSnapshot {
1565
    let meterMACAddress: String
1566
    let meterName: String
1567
    let meterModel: String
1568
    let observedAt: Date
1569
    let voltageVolts: Double
1570
    let currentAmps: Double
1571
    let powerWatts: Double
1572
    let selectedDataGroup: UInt8?
1573
    let meterChargeCounterAh: Double?
1574
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1575
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1576
    let fallbackStopThresholdAmps: Double
1577
}