USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
1543 lines | 51.296kb
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?
Bogdan Timofte authored a month ago
612
    let hasObservedChargeFlow: Bool
Bogdan Timofte authored a month ago
613
    let selectedSourceVoltageVolts: Double?
614
    let completionCurrentAmps: Double?
615
    let stopThresholdAmps: Double
616
    let startBatteryPercent: Double?
617
    let endBatteryPercent: Double?
618
    let capacityEstimateWh: Double?
619
    let wirelessEfficiencyFactor: Double?
620
    let usesEstimatedWirelessEfficiency: Bool
621
    let shouldWarnAboutLowWirelessEfficiency: Bool
622
    let supportsChargingWhileOff: Bool
623
    let usedOfflineMeterCounters: Bool
624
    let targetBatteryPercent: Double?
625
    let targetBatteryAlertTriggeredAt: Date?
626
    let requiresCompletionConfirmation: Bool
627
    let completionConfirmationRequestedAt: Date?
628
    let completionContradictionPercent: Double?
629
    let selectedDataGroup: UInt8?
Bogdan Timofte authored a month ago
630
    let trimStart: Date?
631
    let trimEnd: Date?
Bogdan Timofte authored a month ago
632
    let checkpoints: [ChargeCheckpointSummary]
633
    let aggregatedSamples: [ChargeSessionSampleSummary]
634

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
681
    var hasSavableChargeData: Bool {
682
        hasObservedChargeFlow
683
            || measuredEnergyWh > 0
684
            || measuredChargeAh > 0
685
            || (maximumObservedCurrentAmps ?? 0) > 0
686
            || (maximumObservedPowerWatts ?? 0) > 0
687
            || !aggregatedSamples.isEmpty
688
    }
689

            
Bogdan Timofte authored a month ago
690
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
691
        guard let startBatteryPercent, let endBatteryPercent,
692
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
693
        return endBatteryPercent - startBatteryPercent
694
    }
Bogdan Timofte authored a month ago
695

            
696
    var canAutoStop: Bool {
697
        autoStopEnabled && stopThresholdAmps > 0
698
    }
699

            
700
    var isPaused: Bool {
701
        status == .paused
702
    }
703

            
704
    var isOpen: Bool {
705
        status.isOpen
706
    }
Bogdan Timofte authored a month ago
707
}
708

            
709
struct BatteryLevelPrediction: Hashable {
710
    let predictedPercent: Double
711
    let estimatedCapacityWh: Double
712
    let anchorPercent: Double
713
    let anchorEnergyWh: Double
714
    let anchorDescription: String
715
}
716

            
Bogdan Timofte authored a month ago
717
enum BatteryLevelPredictionTuning {
718
    static let checkpointSettleDuration: TimeInterval = 10 * 60
719

            
720
    static func predictedPercent(
721
        anchorPercent: Double,
722
        anchorEnergyWh: Double,
723
        anchorTimestamp: Date,
724
        anchorIsCheckpoint: Bool,
725
        effectiveEnergyWh: Double,
726
        referenceTimestamp: Date,
727
        estimatedCapacityWh: Double
728
    ) -> Double {
729
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
730
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
731
        let stabilizedGainPercent: Double
732

            
733
        if anchorIsCheckpoint {
734
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
735
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
736
            stabilizedGainPercent = rawGainPercent * settleProgress
737
        } else {
738
            stabilizedGainPercent = rawGainPercent
739
        }
740

            
741
        return min(
742
            100,
743
            max(
744
                0,
745
                anchorPercent + stabilizedGainPercent
746
            )
747
        )
748
    }
749
}
750

            
Bogdan Timofte authored a month ago
751
struct CapacityTrendPoint: Identifiable, Hashable {
752
    let sessionID: UUID
753
    let timestamp: Date
754
    let capacityWh: Double
755
    let chargingTransportMode: ChargingTransportMode
756

            
757
    var id: UUID { sessionID }
758
}
759

            
760
struct TypicalChargeCurvePoint: Identifiable, Hashable {
761
    let percentBin: Int
762
    let averageEnergyWh: Double
763
    let averageChargeAh: Double
764
    let sampleCount: Int
765

            
766
    var id: Int { percentBin }
767
}
768

            
Bogdan Timofte authored a month ago
769
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
770
    let timestamp: Date
771
    let powerWatts: Double
772
    let currentAmps: Double
773
    let voltageVolts: Double
774

            
775
    var id: TimeInterval {
776
        timestamp.timeIntervalSince1970
777
    }
778
}
779

            
Bogdan Timofte authored a month ago
780
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
Bogdan Timofte authored a month ago
781
    let index: Int
782
    let lowerBoundWatts: Double
783
    let upperBoundWatts: Double
784
    let count: Int
785
    let relativeFrequency: Double
786

            
787
    var id: Int { index }
788
}
789

            
Bogdan Timofte authored a month ago
790
enum HistogramResolution: Int, CaseIterable, Identifiable {
791
    case x1 = 1
792
    case x2 = 2
793
    case x4 = 4
794

            
795
    var id: Int { rawValue }
796

            
797
    var label: String {
798
        switch self {
799
        case .x1: return "1×"
800
        case .x2: return "2×"
801
        case .x4: return "4×"
802
        }
803
    }
804
}
805

            
Bogdan Timofte authored a month ago
806
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
807
    let sampleCount: Int
808
    let observedDuration: TimeInterval
809
    let averagePowerWatts: Double
810
    let recentAveragePowerWatts: Double
811
    let medianPowerWatts: Double
812
    let minimumPowerWatts: Double
813
    let maximumPowerWatts: Double
814
    let standardDeviationPowerWatts: Double
815
    let coefficientOfVariation: Double
816
    let averageCurrentAmps: Double
817
    let averageVoltageVolts: Double
818
    let stabilityDeltaWatts: Double
819
    let stabilityToleranceWatts: Double
820
    let histogram: [ChargerStandbyPowerDistributionBin]
821

            
822
    var projectedDailyEnergyWh: Double {
823
        averagePowerWatts * 24
824
    }
825

            
826
    var projectedWeeklyEnergyWh: Double {
827
        averagePowerWatts * 24 * 7
828
    }
829

            
830
    var projectedMonthlyEnergyWh: Double {
831
        averagePowerWatts * 24 * 30
832
    }
833

            
834
    var projectedYearlyEnergyWh: Double {
835
        averagePowerWatts * 24 * 365
836
    }
837

            
838
    var stabilityDeltaMilliwatts: Double {
839
        stabilityDeltaWatts * 1000
840
    }
841

            
842
    var isStable: Bool {
843
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
844
        && stabilityDeltaWatts <= stabilityToleranceWatts
845
    }
846
}
847

            
848
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
849
    let id: UUID
850
    let chargerID: UUID
851
    let meterMACAddress: String
852
    let meterName: String?
853
    let meterModel: String?
854
    let startedAt: Date
855
    let endedAt: Date
856
    let sampleCount: Int
857
    let stabilizedAt: Date?
858
    let averagePowerWatts: Double
859
    let recentAveragePowerWatts: Double
860
    let medianPowerWatts: Double
861
    let minimumPowerWatts: Double
862
    let maximumPowerWatts: Double
863
    let standardDeviationPowerWatts: Double
864
    let coefficientOfVariation: Double
865
    let averageCurrentAmps: Double
866
    let averageVoltageVolts: Double
867
    let stabilityDeltaWatts: Double
868
    let stabilityToleranceWatts: Double
Bogdan Timofte authored a month ago
869
    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
870
    let storedHistogram: [ChargerStandbyPowerDistributionBin]
871

            
872
    // MARK: - Codable (with migration from legacy powerSamplesWatts)
873

            
874
    private enum CodingKeys: String, CodingKey {
875
        case id, chargerID, meterMACAddress, meterName, meterModel
876
        case startedAt, endedAt, sampleCount, stabilizedAt
877
        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
878
        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
879
        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
880
        case stabilityDeltaWatts, stabilityToleranceWatts
881
        case storedHistogram
882
        case powerSamplesWatts // legacy – decode only
883
    }
884

            
885
    init(
886
        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
887
        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
888
        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
889
        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
890
        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
891
        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
892
        storedHistogram: [ChargerStandbyPowerDistributionBin]
893
    ) {
894
        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
895
        self.meterName = meterName; self.meterModel = meterModel
896
        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
897
        self.stabilizedAt = stabilizedAt
898
        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
899
        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
900
        self.maximumPowerWatts = maximumPowerWatts
901
        self.standardDeviationPowerWatts = standardDeviationPowerWatts
902
        self.coefficientOfVariation = coefficientOfVariation
903
        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
904
        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
905
        self.storedHistogram = storedHistogram
906
    }
907

            
908
    init(from decoder: Decoder) throws {
909
        let c = try decoder.container(keyedBy: CodingKeys.self)
910
        id = try c.decode(UUID.self, forKey: .id)
911
        chargerID = try c.decode(UUID.self, forKey: .chargerID)
912
        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
913
        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
914
        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
915
        startedAt = try c.decode(Date.self, forKey: .startedAt)
916
        endedAt = try c.decode(Date.self, forKey: .endedAt)
917
        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
918
        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
919
        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
920
        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
921
        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
922
        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
923
        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
924
        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
925
        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
926
        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
927
        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
928
        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
929
        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)
930

            
931
        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
932
        if let decodedBins, !decodedBins.isEmpty {
933
            storedHistogram = decodedBins
934
        } else {
935
            // Migrate from legacy raw samples format
936
            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
937
            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
938
            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
939
                for: samples,
940
                preferredBinCount: base * HistogramResolution.x4.rawValue
941
            )
942
        }
Bogdan Timofte authored a month ago
943
    }
944

            
Bogdan Timofte authored a month ago
945
    func encode(to encoder: Encoder) throws {
946
        var c = encoder.container(keyedBy: CodingKeys.self)
947
        try c.encode(id, forKey: .id)
948
        try c.encode(chargerID, forKey: .chargerID)
949
        try c.encode(meterMACAddress, forKey: .meterMACAddress)
950
        try c.encodeIfPresent(meterName, forKey: .meterName)
951
        try c.encodeIfPresent(meterModel, forKey: .meterModel)
952
        try c.encode(startedAt, forKey: .startedAt)
953
        try c.encode(endedAt, forKey: .endedAt)
954
        try c.encode(sampleCount, forKey: .sampleCount)
955
        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
956
        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
957
        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
958
        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
959
        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
960
        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
961
        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
962
        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
963
        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
964
        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
965
        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
966
        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
967
        try c.encode(storedHistogram, forKey: .storedHistogram)
968
    }
969

            
970
    // MARK: - Computed
971

            
972
    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
973
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
974
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
975
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
976
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
977
    var isStable: Bool { stabilizedAt != nil }
978

            
979
    /// Returns the histogram downsampled to the requested resolution.
980
    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
981
    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
982
        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
983
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
Bogdan Timofte authored a month ago
984
    }
985
}
986

            
987
enum ChargerStandbyPowerMeasurementAnalyzer {
988
    static let minimumStableSampleCount = 45
Bogdan Timofte authored a month ago
989
    static let recentSampleWindow = 40
990
    static let minimumStabilityToleranceWatts = 0.010
991
    static let relativeStabilityTolerance = 0.05
Bogdan Timofte authored a month ago
992

            
993
    static func statistics(
994
        from samples: [ChargerStandbyPowerSample],
995
        startedAt: Date,
996
        referenceDate: Date = Date()
997
    ) -> ChargerStandbyPowerMeasurementStatistics? {
998
        guard !samples.isEmpty else {
999
            return nil
1000
        }
1001

            
1002
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
1003
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
1004
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
1005

            
1006
        guard powerValues.isEmpty == false else {
1007
            return nil
1008
        }
1009

            
1010
        let averagePower = mean(powerValues)
1011
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
1012
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
1013
        let stabilityDelta = abs(averagePower - recentAveragePower)
1014
        let stabilityTolerance = max(
1015
            minimumStabilityToleranceWatts,
1016
            abs(averagePower) * relativeStabilityTolerance
1017
        )
1018

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

            
Bogdan Timofte authored a month ago
1022
        return ChargerStandbyPowerMeasurementStatistics(
1023
            sampleCount: powerValues.count,
1024
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
1025
            averagePowerWatts: averagePower,
1026
            recentAveragePowerWatts: recentAveragePower,
1027
            medianPowerWatts: median(powerValues),
1028
            minimumPowerWatts: powerValues.min() ?? 0,
1029
            maximumPowerWatts: powerValues.max() ?? 0,
1030
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
1031
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
1032
            averageCurrentAmps: mean(currentValues),
1033
            averageVoltageVolts: mean(voltageValues),
1034
            stabilityDeltaWatts: stabilityDelta,
1035
            stabilityToleranceWatts: stabilityTolerance,
Bogdan Timofte authored a month ago
1036
            histogram: liveHistogram
Bogdan Timofte authored a month ago
1037
        )
1038
    }
1039

            
1040
    static func measurementSummary(
1041
        chargerID: UUID,
1042
        meterMACAddress: String,
1043
        meterName: String?,
1044
        meterModel: String?,
1045
        startedAt: Date,
1046
        endedAt: Date,
1047
        samples: [ChargerStandbyPowerSample],
1048
        stabilizedAt: Date?
1049
    ) -> ChargerStandbyPowerMeasurementSummary? {
1050
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
1051
            return nil
1052
        }
1053

            
1054
        return ChargerStandbyPowerMeasurementSummary(
1055
            id: UUID(),
1056
            chargerID: chargerID,
1057
            meterMACAddress: meterMACAddress,
1058
            meterName: meterName,
1059
            meterModel: meterModel,
1060
            startedAt: startedAt,
1061
            endedAt: endedAt,
1062
            sampleCount: statistics.sampleCount,
1063
            stabilizedAt: stabilizedAt,
1064
            averagePowerWatts: statistics.averagePowerWatts,
1065
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
1066
            medianPowerWatts: statistics.medianPowerWatts,
1067
            minimumPowerWatts: statistics.minimumPowerWatts,
1068
            maximumPowerWatts: statistics.maximumPowerWatts,
1069
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
1070
            coefficientOfVariation: statistics.coefficientOfVariation,
1071
            averageCurrentAmps: statistics.averageCurrentAmps,
1072
            averageVoltageVolts: statistics.averageVoltageVolts,
1073
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
1074
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
Bogdan Timofte authored a month ago
1075
            storedHistogram: statistics.histogram
Bogdan Timofte authored a month ago
1076
        )
1077
    }
1078

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

            
Bogdan Timofte authored a month ago
1106
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
1107
        let finiteValues = values.filter(\.isFinite)
1108
        guard finiteValues.isEmpty == false else {
1109
            return []
1110
        }
1111

            
1112
        let minimum = finiteValues.min() ?? 0
1113
        let maximum = finiteValues.max() ?? 0
1114
        let spread = maximum - minimum
1115
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
1116

            
1117
        guard spread > 0 else {
1118
            return [
1119
                ChargerStandbyPowerDistributionBin(
1120
                    index: 0,
1121
                    lowerBoundWatts: minimum,
1122
                    upperBoundWatts: maximum,
1123
                    count: finiteValues.count,
1124
                    relativeFrequency: 1
1125
                )
1126
            ]
1127
        }
1128

            
1129
        let safeBinCount = max(1, binCount)
1130
        let binWidth = spread / Double(safeBinCount)
1131
        var counts = Array(repeating: 0, count: safeBinCount)
1132

            
1133
        for value in finiteValues {
1134
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
1135
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
1136
            counts[safeIndex] += 1
1137
        }
1138

            
1139
        return counts.enumerated().map { index, count in
1140
            let lowerBound = minimum + (Double(index) * binWidth)
1141
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
1142

            
1143
            return ChargerStandbyPowerDistributionBin(
1144
                index: index,
1145
                lowerBoundWatts: lowerBound,
1146
                upperBoundWatts: upperBound,
1147
                count: count,
1148
                relativeFrequency: Double(count) / Double(finiteValues.count)
1149
            )
1150
        }
1151
    }
1152

            
1153
    private static func mean(_ values: [Double]) -> Double {
1154
        guard values.isEmpty == false else {
1155
            return 0
1156
        }
1157
        return values.reduce(0, +) / Double(values.count)
1158
    }
1159

            
1160
    private static func median(_ values: [Double]) -> Double {
1161
        guard values.isEmpty == false else {
1162
            return 0
1163
        }
1164

            
1165
        let sorted = values.sorted()
1166
        let middleIndex = sorted.count / 2
1167

            
1168
        if sorted.count.isMultiple(of: 2) {
1169
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
1170
        }
1171

            
1172
        return sorted[middleIndex]
1173
    }
1174

            
1175
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
1176
        guard values.count > 1 else {
1177
            return 0
1178
        }
1179

            
1180
        let variance = values.reduce(0) { partialResult, value in
1181
            let delta = value - mean
1182
            return partialResult + (delta * delta)
1183
        } / Double(values.count)
1184

            
1185
        return variance.squareRoot()
1186
    }
1187

            
1188
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
1189
        guard abs(mean) > 0.000_001 else {
1190
            return 0
1191
        }
1192

            
1193
        return standardDeviation(values, mean: mean) / abs(mean)
1194
    }
1195
}
1196

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

            
1234
    var isCharger: Bool {
1235
        deviceClass == .charger
1236
    }
1237

            
Bogdan Timofte authored a month ago
1238
    var kind: ChargedDeviceKind {
1239
        deviceClass.kind
1240
    }
1241

            
1242
    var identityTitle: String {
Bogdan Timofte authored a month ago
1243
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1244
    }
1245

            
Bogdan Timofte authored a month ago
1246
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1247
        isCharger ? kind.symbolName : deviceClass.symbolName
1248
    }
1249

            
Bogdan Timofte authored a month ago
1250
    var identityIcon: ChargedDeviceTemplateIcon {
1251
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1252
    }
1253

            
1254
    var identitySymbolName: String {
1255
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1256
    }
1257

            
Bogdan Timofte authored a month ago
1258
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1259
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1260
    }
1261

            
1262
    var recentCompletedSessions: [ChargeSessionSummary] {
1263
        sessions.filter { $0.status == .completed }
1264
    }
1265

            
1266
    var sessionCount: Int {
1267
        sessions.count
1268
    }
1269

            
Bogdan Timofte authored a month ago
1270
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1271
        standbyPowerMeasurements.first
1272
    }
1273

            
Bogdan Timofte authored a month ago
1274
    var supportedChargingModes: [ChargingTransportMode] {
1275
        var modes: [ChargingTransportMode] = []
1276
        if supportsWiredCharging {
1277
            modes.append(.wired)
1278
        }
1279
        if supportsWirelessCharging {
1280
            modes.append(.wireless)
1281
        }
Bogdan Timofte authored a month ago
1282
        return modes
Bogdan Timofte authored a month ago
1283
    }
1284

            
Bogdan Timofte authored a month ago
1285
    var supportedChargingStateModes: [ChargingStateMode] {
1286
        chargingStateAvailability.supportedModes
1287
    }
1288

            
Bogdan Timofte authored a month ago
1289
    var hasMultipleChargingTransports: Bool {
1290
        supportedChargingModes.count > 1
1291
    }
1292

            
1293
    var hasMultipleChargingStateModes: Bool {
1294
        supportedChargingStateModes.count > 1
1295
    }
1296

            
1297
    var showsWirelessProfileDetails: Bool {
1298
        supportsWirelessCharging
1299
            && hasMultipleChargingTransports
1300
            && deviceClass != .watch
1301
    }
1302

            
1303
    var chargingSupportSummary: String {
1304
        switch (supportsWiredCharging, supportsWirelessCharging) {
1305
        case (true, true):
1306
            return "Supports wired and wireless charging."
1307
        case (true, false):
1308
            return "Supports wired charging only."
1309
        case (false, true):
1310
            return "Supports wireless charging only."
1311
        case (false, false):
1312
            return "No charging method configured."
1313
        }
1314
    }
1315

            
Bogdan Timofte authored a month ago
1316
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1317
        if let matchingSession = sessions.first(where: {
1318
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1319
        }) {
1320
            return matchingSession.chargingStateMode
1321
        }
1322
        return chargingStateAvailability.supportedModes.first ?? .on
1323
    }
1324

            
1325
    func sessionKind(
1326
        for chargingTransportMode: ChargingTransportMode,
1327
        chargingStateMode: ChargingStateMode? = nil
1328
    ) -> ChargeSessionKind {
1329
        ChargeSessionKind(
1330
            chargingTransportMode: chargingTransportMode,
1331
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1332
        )
1333
    }
1334

            
Bogdan Timofte authored a month ago
1335
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1336
        switch chargingTransportMode {
1337
        case .wired:
1338
            return wiredEstimatedBatteryCapacityWh
1339
        case .wireless:
1340
            return wirelessEstimatedBatteryCapacityWh
1341
        }
1342
    }
1343

            
1344
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1345
        switch chargingTransportMode {
1346
        case .wired:
1347
            return wiredMinimumCurrentAmps
1348
        case .wireless:
1349
            return wirelessMinimumCurrentAmps
1350
        }
1351
    }
1352

            
Bogdan Timofte authored a month ago
1353
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1354
        hasMultipleChargingTransports
1355
            || supportedChargingModes.contains(chargingTransportMode) == false
1356
    }
1357

            
1358
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1359
        hasMultipleChargingStateModes
1360
            || supportedChargingStateModes.contains(chargingStateMode) == false
1361
    }
1362

            
Bogdan Timofte authored a month ago
1363
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1364
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
1365
            return explicitCurrent
1366
        }
1367

            
1368
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
1369
        case .wired:
1370
            return wiredChargeCompletionCurrentAmps
1371
        case .wireless:
1372
            return wirelessChargeCompletionCurrentAmps
1373
        }
1374
    }
1375

            
Bogdan Timofte authored a month ago
1376
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1377
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
1378
            return learnedCurrent
1379
        }
1380

            
1381
        switch sessionKind.chargingTransportMode {
1382
        case .wired:
1383
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
1384
        case .wireless:
1385
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
1386
        }
1387
    }
1388

            
1389
    func resolvedCompletionCurrentAmps(
1390
        for chargingTransportMode: ChargingTransportMode,
1391
        chargingStateMode: ChargingStateMode? = nil
1392
    ) -> Double? {
1393
        let sessionKind = sessionKind(
1394
            for: chargingTransportMode,
1395
            chargingStateMode: chargingStateMode
1396
        )
1397

            
1398
        return configuredCompletionCurrentAmps(for: sessionKind)
1399
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
1400
            ?? minimumCurrentAmps(for: chargingTransportMode)
1401
            ?? minimumCurrentAmps
1402
    }
1403

            
Bogdan Timofte authored a month ago
1404
    func batteryLevelPrediction(
1405
        for session: ChargeSessionSummary,
1406
        effectiveEnergyWhOverride: Double? = nil
1407
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1408
        let estimatedCapacityWh = session.capacityEstimateWh
1409
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1410
            ?? estimatedBatteryCapacityWh
1411

            
1412
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1413
            return nil
1414
        }
1415

            
Bogdan Timofte authored a month ago
1416
        let effectiveEnergyWh = effectiveEnergyWhOverride
1417
            ?? session.effectiveBatteryEnergyWh
1418
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1419

            
1420
        struct Anchor {
1421
            let percent: Double
1422
            let energyWh: Double
Bogdan Timofte authored a month ago
1423
            let timestamp: Date
Bogdan Timofte authored a month ago
1424
            let description: String
Bogdan Timofte authored a month ago
1425
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1426
        }
1427

            
1428
        var anchors: [Anchor] = []
1429

            
Bogdan Timofte authored a month ago
1430
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1431
            anchors.append(
1432
                Anchor(
1433
                    percent: startBatteryPercent,
1434
                    energyWh: 0,
Bogdan Timofte authored a month ago
1435
                    timestamp: session.effectiveTrimStart,
Bogdan Timofte authored a month ago
1436
                    description: "session start",
1437
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1438
                )
1439
            )
1440
        }
1441

            
1442
        anchors.append(
1443
            contentsOf: session.checkpoints
1444
                .sorted { lhs, rhs in
1445
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1446
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1447
                    }
1448
                    return lhs.timestamp < rhs.timestamp
1449
                }
Bogdan Timofte authored a month ago
1450
                .filter { checkpoint in
1451
                    checkpoint.batteryPercent >= 0
1452
                }
Bogdan Timofte authored a month ago
1453
                .map { checkpoint in
1454
                    return Anchor(
1455
                        percent: checkpoint.batteryPercent,
1456
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1457
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
1458
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
1459
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1460
                    )
1461
                }
1462
        )
1463

            
1464
        guard !anchors.isEmpty else {
1465
            return nil
1466
        }
1467

            
1468
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1469
        let anchor = eligibleAnchors.last ?? anchors.first!
Bogdan Timofte authored a month ago
1470
        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1471
            anchorPercent: anchor.percent,
1472
            anchorEnergyWh: anchor.energyWh,
1473
            anchorTimestamp: anchor.timestamp,
1474
            anchorIsCheckpoint: anchor.isCheckpoint,
1475
            effectiveEnergyWh: effectiveEnergyWh,
1476
            referenceTimestamp: session.lastObservedAt,
1477
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1478
        )
1479

            
1480
        return BatteryLevelPrediction(
1481
            predictedPercent: predictedPercent,
1482
            estimatedCapacityWh: estimatedCapacityWh,
1483
            anchorPercent: anchor.percent,
1484
            anchorEnergyWh: anchor.energyWh,
1485
            anchorDescription: anchor.description
1486
        )
1487
    }
Bogdan Timofte authored a month ago
1488

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

            
1530
struct ChargingMonitorSnapshot {
1531
    let meterMACAddress: String
1532
    let meterName: String
1533
    let meterModel: String
1534
    let observedAt: Date
1535
    let voltageVolts: Double
1536
    let currentAmps: Double
1537
    let powerWatts: Double
1538
    let selectedDataGroup: UInt8?
1539
    let meterChargeCounterAh: Double?
1540
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1541
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1542
    let fallbackStopThresholdAmps: Double
1543
}