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

            
8
import Foundation
9

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

            
14
    var id: String { rawValue }
15

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

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

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

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

            
51
    var id: String { rawValue }
52

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
215
    var id: String { rawValue }
216

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

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

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

            
240
    var id: String { rawValue }
241

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

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

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

            
266
    var id: String { rawValue }
267

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

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

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

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

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

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

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

            
320
    var id: String { rawValue }
321

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

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

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

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

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

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

            
366
    var id: String { rawValue }
367

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

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

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

            
393
    var id: String { rawValue }
394

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
756
struct BatteryLevelPrediction: Hashable {
757
    let predictedPercent: Double
758
    let estimatedCapacityWh: Double
759
    let anchorPercent: Double
760
    let anchorEnergyWh: Double
761
    let anchorDescription: String
762
}
763

            
Bogdan Timofte authored a month ago
764
enum BatteryLevelPredictionTuning {
765
    static let checkpointSettleDuration: TimeInterval = 10 * 60
766

            
767
    static func predictedPercent(
768
        anchorPercent: Double,
769
        anchorEnergyWh: Double,
770
        anchorTimestamp: Date,
771
        anchorIsCheckpoint: Bool,
772
        effectiveEnergyWh: Double,
773
        referenceTimestamp: Date,
774
        estimatedCapacityWh: Double
775
    ) -> Double {
776
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
777
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
778
        let stabilizedGainPercent: Double
779

            
780
        if anchorIsCheckpoint {
781
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
782
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
783
            stabilizedGainPercent = rawGainPercent * settleProgress
784
        } else {
785
            stabilizedGainPercent = rawGainPercent
786
        }
787

            
788
        return min(
789
            100,
790
            max(
791
                0,
792
                anchorPercent + stabilizedGainPercent
793
            )
794
        )
795
    }
796
}
797

            
Bogdan Timofte authored a month ago
798
struct CapacityTrendPoint: Identifiable, Hashable {
799
    let sessionID: UUID
800
    let timestamp: Date
801
    let capacityWh: Double
802
    let chargingTransportMode: ChargingTransportMode
803

            
804
    var id: UUID { sessionID }
805
}
806

            
807
struct TypicalChargeCurvePoint: Identifiable, Hashable {
808
    let percentBin: Int
809
    let averageEnergyWh: Double
810
    let sampleCount: Int
811

            
812
    var id: Int { percentBin }
813
}
814

            
Bogdan Timofte authored a month ago
815
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
816
    let timestamp: Date
817
    let powerWatts: Double
818
    let currentAmps: Double
819
    let voltageVolts: Double
820

            
821
    var id: TimeInterval {
822
        timestamp.timeIntervalSince1970
823
    }
824
}
825

            
Bogdan Timofte authored a month ago
826
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
Bogdan Timofte authored a month ago
827
    let index: Int
828
    let lowerBoundWatts: Double
829
    let upperBoundWatts: Double
830
    let count: Int
831
    let relativeFrequency: Double
832

            
833
    var id: Int { index }
834
}
835

            
Bogdan Timofte authored a month ago
836
enum HistogramResolution: Int, CaseIterable, Identifiable {
837
    case x1 = 1
838
    case x2 = 2
839
    case x4 = 4
840

            
841
    var id: Int { rawValue }
842

            
843
    var label: String {
844
        switch self {
845
        case .x1: return "1×"
846
        case .x2: return "2×"
847
        case .x4: return "4×"
848
        }
849
    }
850
}
851

            
Bogdan Timofte authored a month ago
852
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
853
    let sampleCount: Int
854
    let observedDuration: TimeInterval
855
    let averagePowerWatts: Double
856
    let recentAveragePowerWatts: Double
857
    let medianPowerWatts: Double
858
    let minimumPowerWatts: Double
859
    let maximumPowerWatts: Double
860
    let standardDeviationPowerWatts: Double
861
    let coefficientOfVariation: Double
862
    let averageCurrentAmps: Double
863
    let averageVoltageVolts: Double
864
    let stabilityDeltaWatts: Double
865
    let stabilityToleranceWatts: Double
866
    let histogram: [ChargerStandbyPowerDistributionBin]
867

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

            
872
    var projectedWeeklyEnergyWh: Double {
873
        averagePowerWatts * 24 * 7
874
    }
875

            
876
    var projectedMonthlyEnergyWh: Double {
877
        averagePowerWatts * 24 * 30
878
    }
879

            
880
    var projectedYearlyEnergyWh: Double {
881
        averagePowerWatts * 24 * 365
882
    }
883

            
884
    var stabilityDeltaMilliwatts: Double {
885
        stabilityDeltaWatts * 1000
886
    }
887

            
888
    var isStable: Bool {
889
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
890
        && stabilityDeltaWatts <= stabilityToleranceWatts
891
    }
892
}
893

            
894
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
895
    let id: UUID
896
    let chargerID: UUID
897
    let meterMACAddress: String
898
    let meterName: String?
899
    let meterModel: String?
900
    let startedAt: Date
901
    let endedAt: Date
902
    let sampleCount: Int
903
    let stabilizedAt: Date?
904
    let averagePowerWatts: Double
905
    let recentAveragePowerWatts: Double
906
    let medianPowerWatts: Double
907
    let minimumPowerWatts: Double
908
    let maximumPowerWatts: Double
909
    let standardDeviationPowerWatts: Double
910
    let coefficientOfVariation: Double
911
    let averageCurrentAmps: Double
912
    let averageVoltageVolts: Double
913
    let stabilityDeltaWatts: Double
914
    let stabilityToleranceWatts: Double
Bogdan Timofte authored a month ago
915
    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
916
    let storedHistogram: [ChargerStandbyPowerDistributionBin]
917

            
918
    // MARK: - Codable (with migration from legacy powerSamplesWatts)
919

            
920
    private enum CodingKeys: String, CodingKey {
921
        case id, chargerID, meterMACAddress, meterName, meterModel
922
        case startedAt, endedAt, sampleCount, stabilizedAt
923
        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
924
        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
925
        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
926
        case stabilityDeltaWatts, stabilityToleranceWatts
927
        case storedHistogram
928
        case powerSamplesWatts // legacy – decode only
929
    }
930

            
931
    init(
932
        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
933
        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
934
        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
935
        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
936
        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
937
        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
938
        storedHistogram: [ChargerStandbyPowerDistributionBin]
939
    ) {
940
        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
941
        self.meterName = meterName; self.meterModel = meterModel
942
        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
943
        self.stabilizedAt = stabilizedAt
944
        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
945
        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
946
        self.maximumPowerWatts = maximumPowerWatts
947
        self.standardDeviationPowerWatts = standardDeviationPowerWatts
948
        self.coefficientOfVariation = coefficientOfVariation
949
        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
950
        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
951
        self.storedHistogram = storedHistogram
952
    }
953

            
954
    init(from decoder: Decoder) throws {
955
        let c = try decoder.container(keyedBy: CodingKeys.self)
956
        id = try c.decode(UUID.self, forKey: .id)
957
        chargerID = try c.decode(UUID.self, forKey: .chargerID)
958
        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
959
        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
960
        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
961
        startedAt = try c.decode(Date.self, forKey: .startedAt)
962
        endedAt = try c.decode(Date.self, forKey: .endedAt)
963
        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
964
        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
965
        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
966
        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
967
        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
968
        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
969
        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
970
        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
971
        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
972
        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
973
        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
974
        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
975
        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)
976

            
977
        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
978
        if let decodedBins, !decodedBins.isEmpty {
979
            storedHistogram = decodedBins
980
        } else {
981
            // Migrate from legacy raw samples format
982
            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
983
            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
984
            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
985
                for: samples,
986
                preferredBinCount: base * HistogramResolution.x4.rawValue
987
            )
988
        }
Bogdan Timofte authored a month ago
989
    }
990

            
Bogdan Timofte authored a month ago
991
    func encode(to encoder: Encoder) throws {
992
        var c = encoder.container(keyedBy: CodingKeys.self)
993
        try c.encode(id, forKey: .id)
994
        try c.encode(chargerID, forKey: .chargerID)
995
        try c.encode(meterMACAddress, forKey: .meterMACAddress)
996
        try c.encodeIfPresent(meterName, forKey: .meterName)
997
        try c.encodeIfPresent(meterModel, forKey: .meterModel)
998
        try c.encode(startedAt, forKey: .startedAt)
999
        try c.encode(endedAt, forKey: .endedAt)
1000
        try c.encode(sampleCount, forKey: .sampleCount)
1001
        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
1002
        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
1003
        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
1004
        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
1005
        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
1006
        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
1007
        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
1008
        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
1009
        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
1010
        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
1011
        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
1012
        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
1013
        try c.encode(storedHistogram, forKey: .storedHistogram)
1014
    }
1015

            
1016
    // MARK: - Computed
1017

            
1018
    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
1019
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
1020
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
1021
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
1022
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
1023
    var isStable: Bool { stabilizedAt != nil }
1024

            
1025
    /// Returns the histogram downsampled to the requested resolution.
1026
    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
1027
    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
1028
        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
1029
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
Bogdan Timofte authored a month ago
1030
    }
1031
}
1032

            
1033
enum ChargerStandbyPowerMeasurementAnalyzer {
1034
    static let minimumStableSampleCount = 45
Bogdan Timofte authored a month ago
1035
    static let recentSampleWindow = 40
1036
    static let minimumStabilityToleranceWatts = 0.010
1037
    static let relativeStabilityTolerance = 0.05
Bogdan Timofte authored a month ago
1038

            
1039
    static func statistics(
1040
        from samples: [ChargerStandbyPowerSample],
1041
        startedAt: Date,
1042
        referenceDate: Date = Date()
1043
    ) -> ChargerStandbyPowerMeasurementStatistics? {
1044
        guard !samples.isEmpty else {
1045
            return nil
1046
        }
1047

            
1048
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
1049
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
1050
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
1051

            
1052
        guard powerValues.isEmpty == false else {
1053
            return nil
1054
        }
1055

            
1056
        let averagePower = mean(powerValues)
1057
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
1058
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
1059
        let stabilityDelta = abs(averagePower - recentAveragePower)
1060
        let stabilityTolerance = max(
1061
            minimumStabilityToleranceWatts,
1062
            abs(averagePower) * relativeStabilityTolerance
1063
        )
1064

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

            
Bogdan Timofte authored a month ago
1068
        return ChargerStandbyPowerMeasurementStatistics(
1069
            sampleCount: powerValues.count,
1070
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
1071
            averagePowerWatts: averagePower,
1072
            recentAveragePowerWatts: recentAveragePower,
1073
            medianPowerWatts: median(powerValues),
1074
            minimumPowerWatts: powerValues.min() ?? 0,
1075
            maximumPowerWatts: powerValues.max() ?? 0,
1076
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
1077
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
1078
            averageCurrentAmps: mean(currentValues),
1079
            averageVoltageVolts: mean(voltageValues),
1080
            stabilityDeltaWatts: stabilityDelta,
1081
            stabilityToleranceWatts: stabilityTolerance,
Bogdan Timofte authored a month ago
1082
            histogram: liveHistogram
Bogdan Timofte authored a month ago
1083
        )
1084
    }
1085

            
1086
    static func measurementSummary(
1087
        chargerID: UUID,
1088
        meterMACAddress: String,
1089
        meterName: String?,
1090
        meterModel: String?,
1091
        startedAt: Date,
1092
        endedAt: Date,
1093
        samples: [ChargerStandbyPowerSample],
1094
        stabilizedAt: Date?
1095
    ) -> ChargerStandbyPowerMeasurementSummary? {
1096
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
1097
            return nil
1098
        }
1099

            
1100
        return ChargerStandbyPowerMeasurementSummary(
1101
            id: UUID(),
1102
            chargerID: chargerID,
1103
            meterMACAddress: meterMACAddress,
1104
            meterName: meterName,
1105
            meterModel: meterModel,
1106
            startedAt: startedAt,
1107
            endedAt: endedAt,
1108
            sampleCount: statistics.sampleCount,
1109
            stabilizedAt: stabilizedAt,
1110
            averagePowerWatts: statistics.averagePowerWatts,
1111
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
1112
            medianPowerWatts: statistics.medianPowerWatts,
1113
            minimumPowerWatts: statistics.minimumPowerWatts,
1114
            maximumPowerWatts: statistics.maximumPowerWatts,
1115
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
1116
            coefficientOfVariation: statistics.coefficientOfVariation,
1117
            averageCurrentAmps: statistics.averageCurrentAmps,
1118
            averageVoltageVolts: statistics.averageVoltageVolts,
1119
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
1120
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
Bogdan Timofte authored a month ago
1121
            storedHistogram: statistics.histogram
Bogdan Timofte authored a month ago
1122
        )
1123
    }
1124

            
Bogdan Timofte authored a month ago
1125
    /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1.
1126
    static func downsample(
1127
        _ bins: [ChargerStandbyPowerDistributionBin],
1128
        factor: Int
1129
    ) -> [ChargerStandbyPowerDistributionBin] {
1130
        guard factor > 1, !bins.isEmpty else { return bins }
1131
        let totalCount = bins.reduce(0) { $0 + $1.count }
1132
        var result: [ChargerStandbyPowerDistributionBin] = []
1133
        var inputIndex = 0
1134
        var outputIndex = 0
1135
        while inputIndex < bins.count {
1136
            let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)])
1137
            let mergedCount = group.reduce(0) { $0 + $1.count }
1138
            let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0
1139
            result.append(ChargerStandbyPowerDistributionBin(
1140
                index: outputIndex,
1141
                lowerBoundWatts: group.first!.lowerBoundWatts,
1142
                upperBoundWatts: group.last!.upperBoundWatts,
1143
                count: mergedCount,
1144
                relativeFrequency: relFreq
1145
            ))
1146
            inputIndex += factor
1147
            outputIndex += 1
1148
        }
1149
        return result
1150
    }
1151

            
Bogdan Timofte authored a month ago
1152
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
1153
        let finiteValues = values.filter(\.isFinite)
1154
        guard finiteValues.isEmpty == false else {
1155
            return []
1156
        }
1157

            
1158
        let minimum = finiteValues.min() ?? 0
1159
        let maximum = finiteValues.max() ?? 0
1160
        let spread = maximum - minimum
1161
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
1162

            
1163
        guard spread > 0 else {
1164
            return [
1165
                ChargerStandbyPowerDistributionBin(
1166
                    index: 0,
1167
                    lowerBoundWatts: minimum,
1168
                    upperBoundWatts: maximum,
1169
                    count: finiteValues.count,
1170
                    relativeFrequency: 1
1171
                )
1172
            ]
1173
        }
1174

            
1175
        let safeBinCount = max(1, binCount)
1176
        let binWidth = spread / Double(safeBinCount)
1177
        var counts = Array(repeating: 0, count: safeBinCount)
1178

            
1179
        for value in finiteValues {
1180
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
1181
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
1182
            counts[safeIndex] += 1
1183
        }
1184

            
1185
        return counts.enumerated().map { index, count in
1186
            let lowerBound = minimum + (Double(index) * binWidth)
1187
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
1188

            
1189
            return ChargerStandbyPowerDistributionBin(
1190
                index: index,
1191
                lowerBoundWatts: lowerBound,
1192
                upperBoundWatts: upperBound,
1193
                count: count,
1194
                relativeFrequency: Double(count) / Double(finiteValues.count)
1195
            )
1196
        }
1197
    }
1198

            
1199
    private static func mean(_ values: [Double]) -> Double {
1200
        guard values.isEmpty == false else {
1201
            return 0
1202
        }
1203
        return values.reduce(0, +) / Double(values.count)
1204
    }
1205

            
1206
    private static func median(_ values: [Double]) -> Double {
1207
        guard values.isEmpty == false else {
1208
            return 0
1209
        }
1210

            
1211
        let sorted = values.sorted()
1212
        let middleIndex = sorted.count / 2
1213

            
1214
        if sorted.count.isMultiple(of: 2) {
1215
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
1216
        }
1217

            
1218
        return sorted[middleIndex]
1219
    }
1220

            
1221
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
1222
        guard values.count > 1 else {
1223
            return 0
1224
        }
1225

            
1226
        let variance = values.reduce(0) { partialResult, value in
1227
            let delta = value - mean
1228
            return partialResult + (delta * delta)
1229
        } / Double(values.count)
1230

            
1231
        return variance.squareRoot()
1232
    }
1233

            
1234
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
1235
        guard abs(mean) > 0.000_001 else {
1236
            return 0
1237
        }
1238

            
1239
        return standardDeviation(values, mean: mean) / abs(mean)
1240
    }
1241
}
1242

            
Bogdan Timofte authored a month ago
1243
struct ChargedDeviceSummary: Identifiable, Hashable {
1244
    let id: UUID
1245
    let qrIdentifier: String
1246
    let name: String
1247
    let deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
1248
    let deviceTemplateID: String?
1249
    let templateDefinition: ChargedDeviceTemplateDefinition?
Bogdan Timofte authored a month ago
1250
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
1251
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
1252
    let supportsWiredCharging: Bool
1253
    let supportsWirelessCharging: Bool
Bogdan Timofte authored a month ago
1254
    let chargerType: ChargerType?
Bogdan Timofte authored a month ago
1255
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
1256
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
1257
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
1258
    let wirelessChargerEfficiencyFactor: Double?
1259
    let wiredChargeCompletionCurrentAmps: Double?
1260
    let wirelessChargeCompletionCurrentAmps: Double?
1261
    let chargerObservedVoltageSelections: [Double]
1262
    let chargerIdleCurrentAmps: Double?
1263
    let chargerEfficiencyFactor: Double?
1264
    let chargerMaximumPowerWatts: Double?
1265
    let notes: String?
1266
    let minimumCurrentAmps: Double?
1267
    let estimatedBatteryCapacityWh: Double?
1268
    let wiredMinimumCurrentAmps: Double?
1269
    let wirelessMinimumCurrentAmps: Double?
1270
    let wiredEstimatedBatteryCapacityWh: Double?
1271
    let wirelessEstimatedBatteryCapacityWh: Double?
1272
    let createdAt: Date
1273
    let updatedAt: Date
1274
    let sessions: [ChargeSessionSummary]
1275
    let capacityHistory: [CapacityTrendPoint]
1276
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
1277
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
1278

            
1279
    var isCharger: Bool {
1280
        deviceClass == .charger
1281
    }
1282

            
Bogdan Timofte authored a month ago
1283
    var kind: ChargedDeviceKind {
1284
        deviceClass.kind
1285
    }
1286

            
1287
    var identityTitle: String {
Bogdan Timofte authored a month ago
1288
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1289
    }
1290

            
Bogdan Timofte authored a month ago
1291
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1292
        isCharger ? kind.symbolName : deviceClass.symbolName
1293
    }
1294

            
Bogdan Timofte authored a month ago
1295
    var identityIcon: ChargedDeviceTemplateIcon {
1296
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1297
    }
1298

            
1299
    var identitySymbolName: String {
1300
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1301
    }
1302

            
Bogdan Timofte authored a month ago
1303
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1304
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1305
    }
1306

            
1307
    var recentCompletedSessions: [ChargeSessionSummary] {
1308
        sessions.filter { $0.status == .completed }
1309
    }
1310

            
1311
    var sessionCount: Int {
1312
        sessions.count
1313
    }
1314

            
Bogdan Timofte authored a month ago
1315
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1316
        standbyPowerMeasurements.first
1317
    }
1318

            
Bogdan Timofte authored a month ago
1319
    var supportedChargingModes: [ChargingTransportMode] {
1320
        var modes: [ChargingTransportMode] = []
1321
        if supportsWiredCharging {
1322
            modes.append(.wired)
1323
        }
1324
        if supportsWirelessCharging {
1325
            modes.append(.wireless)
1326
        }
Bogdan Timofte authored a month ago
1327
        return modes
Bogdan Timofte authored a month ago
1328
    }
1329

            
Bogdan Timofte authored a month ago
1330
    var supportedChargingStateModes: [ChargingStateMode] {
1331
        chargingStateAvailability.supportedModes
1332
    }
1333

            
Bogdan Timofte authored a month ago
1334
    var hasMultipleChargingTransports: Bool {
1335
        supportedChargingModes.count > 1
1336
    }
1337

            
1338
    var hasMultipleChargingStateModes: Bool {
1339
        supportedChargingStateModes.count > 1
1340
    }
1341

            
1342
    var showsWirelessProfileDetails: Bool {
1343
        supportsWirelessCharging
1344
            && hasMultipleChargingTransports
1345
            && deviceClass != .watch
1346
    }
1347

            
1348
    var chargingSupportSummary: String {
1349
        switch (supportsWiredCharging, supportsWirelessCharging) {
1350
        case (true, true):
1351
            return "Supports wired and wireless charging."
1352
        case (true, false):
1353
            return "Supports wired charging only."
1354
        case (false, true):
1355
            return "Supports wireless charging only."
1356
        case (false, false):
1357
            return "No charging method configured."
1358
        }
1359
    }
1360

            
Bogdan Timofte authored a month ago
1361
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1362
        if let matchingSession = sessions.first(where: {
1363
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1364
        }) {
1365
            return matchingSession.chargingStateMode
1366
        }
1367
        return chargingStateAvailability.supportedModes.first ?? .on
1368
    }
1369

            
1370
    func sessionKind(
1371
        for chargingTransportMode: ChargingTransportMode,
1372
        chargingStateMode: ChargingStateMode? = nil
1373
    ) -> ChargeSessionKind {
1374
        ChargeSessionKind(
1375
            chargingTransportMode: chargingTransportMode,
1376
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1377
        )
1378
    }
1379

            
Bogdan Timofte authored a month ago
1380
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1381
        switch chargingTransportMode {
1382
        case .wired:
1383
            return wiredEstimatedBatteryCapacityWh
1384
        case .wireless:
1385
            return wirelessEstimatedBatteryCapacityWh
1386
        }
1387
    }
1388

            
1389
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1390
        switch chargingTransportMode {
1391
        case .wired:
1392
            return wiredMinimumCurrentAmps
1393
        case .wireless:
1394
            return wirelessMinimumCurrentAmps
1395
        }
1396
    }
1397

            
Bogdan Timofte authored a month ago
1398
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1399
        hasMultipleChargingTransports
1400
            || supportedChargingModes.contains(chargingTransportMode) == false
1401
    }
1402

            
1403
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1404
        hasMultipleChargingStateModes
1405
            || supportedChargingStateModes.contains(chargingStateMode) == false
1406
    }
1407

            
Bogdan Timofte authored a month ago
1408
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1409
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
1410
            return explicitCurrent
1411
        }
1412

            
1413
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
1414
        case .wired:
1415
            return wiredChargeCompletionCurrentAmps
1416
        case .wireless:
1417
            return wirelessChargeCompletionCurrentAmps
1418
        }
1419
    }
1420

            
Bogdan Timofte authored a month ago
1421
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1422
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
1423
            return learnedCurrent
1424
        }
1425

            
1426
        switch sessionKind.chargingTransportMode {
1427
        case .wired:
1428
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
1429
        case .wireless:
1430
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
1431
        }
1432
    }
1433

            
1434
    func resolvedCompletionCurrentAmps(
1435
        for chargingTransportMode: ChargingTransportMode,
1436
        chargingStateMode: ChargingStateMode? = nil
1437
    ) -> Double? {
1438
        let sessionKind = sessionKind(
1439
            for: chargingTransportMode,
1440
            chargingStateMode: chargingStateMode
1441
        )
1442

            
1443
        return configuredCompletionCurrentAmps(for: sessionKind)
1444
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
1445
            ?? minimumCurrentAmps(for: chargingTransportMode)
1446
            ?? minimumCurrentAmps
1447
    }
1448

            
Bogdan Timofte authored a month ago
1449
    func batteryLevelPrediction(
1450
        for session: ChargeSessionSummary,
1451
        effectiveEnergyWhOverride: Double? = nil
1452
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1453
        let estimatedCapacityWh = session.capacityEstimateWh
1454
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1455
            ?? estimatedBatteryCapacityWh
1456

            
1457
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1458
            return nil
1459
        }
1460

            
Bogdan Timofte authored a month ago
1461
        let effectiveEnergyWh = effectiveEnergyWhOverride
1462
            ?? session.effectiveBatteryEnergyWh
1463
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1464

            
1465
        struct Anchor {
1466
            let percent: Double
1467
            let energyWh: Double
Bogdan Timofte authored a month ago
1468
            let timestamp: Date
Bogdan Timofte authored a month ago
1469
            let description: String
Bogdan Timofte authored a month ago
1470
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1471
        }
1472

            
1473
        var anchors: [Anchor] = []
1474

            
Bogdan Timofte authored a month ago
1475
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1476
            anchors.append(
1477
                Anchor(
1478
                    percent: startBatteryPercent,
1479
                    energyWh: 0,
Bogdan Timofte authored a month ago
1480
                    timestamp: session.effectiveTrimStart,
Bogdan Timofte authored a month ago
1481
                    description: "session start",
1482
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1483
                )
1484
            )
1485
        }
1486

            
1487
        anchors.append(
1488
            contentsOf: session.checkpoints
1489
                .sorted { lhs, rhs in
1490
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1491
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1492
                    }
1493
                    return lhs.timestamp < rhs.timestamp
1494
                }
Bogdan Timofte authored a month ago
1495
                .filter { checkpoint in
1496
                    checkpoint.batteryPercent >= 0
1497
                }
Bogdan Timofte authored a month ago
1498
                .map { checkpoint in
1499
                    return Anchor(
1500
                        percent: checkpoint.batteryPercent,
1501
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1502
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
1503
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
1504
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1505
                    )
1506
                }
1507
        )
1508

            
1509
        guard !anchors.isEmpty else {
1510
            return nil
1511
        }
1512

            
1513
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1514
        let anchor = eligibleAnchors.last ?? anchors.first!
Bogdan Timofte authored a month ago
1515
        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1516
            anchorPercent: anchor.percent,
1517
            anchorEnergyWh: anchor.energyWh,
1518
            anchorTimestamp: anchor.timestamp,
1519
            anchorIsCheckpoint: anchor.isCheckpoint,
1520
            effectiveEnergyWh: effectiveEnergyWh,
1521
            referenceTimestamp: session.lastObservedAt,
1522
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1523
        )
1524

            
1525
        return BatteryLevelPrediction(
1526
            predictedPercent: predictedPercent,
1527
            estimatedCapacityWh: estimatedCapacityWh,
1528
            anchorPercent: anchor.percent,
1529
            anchorEnergyWh: anchor.energyWh,
1530
            anchorDescription: anchor.description
1531
        )
1532
    }
Bogdan Timofte authored a month ago
1533

            
1534
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1535
        ChargedDeviceSummary(
1536
            id: id,
1537
            qrIdentifier: qrIdentifier,
1538
            name: name,
1539
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1540
            deviceTemplateID: deviceTemplateID,
1541
            templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1542
            supportsChargingWhileOff: supportsChargingWhileOff,
1543
            chargingStateAvailability: chargingStateAvailability,
1544
            supportsWiredCharging: supportsWiredCharging,
1545
            supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1546
            chargerType: chargerType,
Bogdan Timofte authored a month ago
1547
            wirelessChargingProfile: wirelessChargingProfile,
1548
            configuredCompletionCurrents: configuredCompletionCurrents,
1549
            learnedCompletionCurrents: learnedCompletionCurrents,
1550
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1551
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1552
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1553
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1554
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1555
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1556
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1557
            notes: notes,
1558
            minimumCurrentAmps: minimumCurrentAmps,
1559
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1560
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1561
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1562
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1563
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1564
            createdAt: createdAt,
1565
            updatedAt: updatedAt,
1566
            sessions: sessions,
1567
            capacityHistory: capacityHistory,
1568
            typicalCurve: typicalCurve,
1569
            standbyPowerMeasurements: measurements
1570
        )
1571
    }
Bogdan Timofte authored a month ago
1572
}
1573

            
1574
struct ChargingMonitorSnapshot {
1575
    let meterMACAddress: String
1576
    let meterName: String
1577
    let meterModel: String
1578
    let observedAt: Date
1579
    let voltageVolts: Double
1580
    let currentAmps: Double
1581
    let powerWatts: Double
1582
    let selectedDataGroup: UInt8?
1583
    let meterChargeCounterAh: Double?
1584
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1585
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1586
    let fallbackStopThresholdAmps: Double
1587
}