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

            
8
import Foundation
9

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

            
14
    var id: String { rawValue }
15

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

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

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

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

            
51
    var id: String { rawValue }
52

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
215
    var id: String { rawValue }
216

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

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

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

            
240
    var id: String { rawValue }
241

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

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

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

            
266
    var id: String { rawValue }
267

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

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

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

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

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

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

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

            
320
    var id: String { rawValue }
321

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

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

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

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

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

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

            
366
    var id: String { rawValue }
367

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

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

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

            
393
    var id: String { rawValue }
394

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
709
        // Use timestamp-based duration as primary source; only use meter counter if it's consistent
710
        let timestampDuration = duration
711

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

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

            
725
        return timestampDuration
Bogdan Timofte authored a month ago
726
    }
727

            
Bogdan Timofte authored a month ago
728
    var effectiveOrMeasuredEnergyWh: Double {
729
        effectiveBatteryEnergyWh ?? measuredEnergyWh
730
    }
731

            
Bogdan Timofte authored a month ago
732
    var hasSavableChargeData: Bool {
733
        hasObservedChargeFlow
734
            || measuredEnergyWh > 0
735
            || measuredChargeAh > 0
736
            || (maximumObservedCurrentAmps ?? 0) > 0
737
            || (maximumObservedPowerWatts ?? 0) > 0
738
            || !aggregatedSamples.isEmpty
739
    }
740

            
Bogdan Timofte authored a month ago
741
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
742
        guard let startBatteryPercent, let endBatteryPercent,
743
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
744
        return endBatteryPercent - startBatteryPercent
745
    }
Bogdan Timofte authored a month ago
746

            
747
    var canAutoStop: Bool {
748
        autoStopEnabled && stopThresholdAmps > 0
749
    }
750

            
751
    var isPaused: Bool {
752
        status == .paused
753
    }
754

            
755
    var isOpen: Bool {
756
        status.isOpen
757
    }
Bogdan Timofte authored a month ago
758
}
759

            
760
struct BatteryLevelPrediction: Hashable {
761
    let predictedPercent: Double
762
    let estimatedCapacityWh: Double
763
    let anchorPercent: Double
764
    let anchorEnergyWh: Double
765
    let anchorDescription: String
766
}
767

            
Bogdan Timofte authored a month ago
768
enum BatteryLevelPredictionTuning {
769
    static let checkpointSettleDuration: TimeInterval = 10 * 60
770

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

            
784
        if anchorIsCheckpoint {
785
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
786
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
787
            stabilizedGainPercent = rawGainPercent * settleProgress
788
        } else {
789
            stabilizedGainPercent = rawGainPercent
790
        }
791

            
792
        return min(
793
            100,
794
            max(
795
                0,
796
                anchorPercent + stabilizedGainPercent
797
            )
798
        )
799
    }
800
}
801

            
Bogdan Timofte authored a month ago
802
struct CapacityTrendPoint: Identifiable, Hashable {
803
    let sessionID: UUID
804
    let timestamp: Date
805
    let capacityWh: Double
806
    let chargingTransportMode: ChargingTransportMode
807

            
808
    var id: UUID { sessionID }
809
}
810

            
811
struct TypicalChargeCurvePoint: Identifiable, Hashable {
812
    let percentBin: Int
813
    let averageEnergyWh: Double
814
    let averageChargeAh: Double
815
    let sampleCount: Int
816

            
817
    var id: Int { percentBin }
818
}
819

            
Bogdan Timofte authored a month ago
820
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
821
    let timestamp: Date
822
    let powerWatts: Double
823
    let currentAmps: Double
824
    let voltageVolts: Double
825

            
826
    var id: TimeInterval {
827
        timestamp.timeIntervalSince1970
828
    }
829
}
830

            
Bogdan Timofte authored a month ago
831
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
Bogdan Timofte authored a month ago
832
    let index: Int
833
    let lowerBoundWatts: Double
834
    let upperBoundWatts: Double
835
    let count: Int
836
    let relativeFrequency: Double
837

            
838
    var id: Int { index }
839
}
840

            
Bogdan Timofte authored a month ago
841
enum HistogramResolution: Int, CaseIterable, Identifiable {
842
    case x1 = 1
843
    case x2 = 2
844
    case x4 = 4
845

            
846
    var id: Int { rawValue }
847

            
848
    var label: String {
849
        switch self {
850
        case .x1: return "1×"
851
        case .x2: return "2×"
852
        case .x4: return "4×"
853
        }
854
    }
855
}
856

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

            
873
    var projectedDailyEnergyWh: Double {
874
        averagePowerWatts * 24
875
    }
876

            
877
    var projectedWeeklyEnergyWh: Double {
878
        averagePowerWatts * 24 * 7
879
    }
880

            
881
    var projectedMonthlyEnergyWh: Double {
882
        averagePowerWatts * 24 * 30
883
    }
884

            
885
    var projectedYearlyEnergyWh: Double {
886
        averagePowerWatts * 24 * 365
887
    }
888

            
889
    var stabilityDeltaMilliwatts: Double {
890
        stabilityDeltaWatts * 1000
891
    }
892

            
893
    var isStable: Bool {
894
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
895
        && stabilityDeltaWatts <= stabilityToleranceWatts
896
    }
897
}
898

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

            
923
    // MARK: - Codable (with migration from legacy powerSamplesWatts)
924

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

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

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

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

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

            
1021
    // MARK: - Computed
1022

            
1023
    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
1024
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
1025
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
1026
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
1027
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
1028
    var isStable: Bool { stabilizedAt != nil }
1029

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

            
1038
enum ChargerStandbyPowerMeasurementAnalyzer {
1039
    static let minimumStableSampleCount = 45
Bogdan Timofte authored a month ago
1040
    static let recentSampleWindow = 40
1041
    static let minimumStabilityToleranceWatts = 0.010
1042
    static let relativeStabilityTolerance = 0.05
Bogdan Timofte authored a month ago
1043

            
1044
    static func statistics(
1045
        from samples: [ChargerStandbyPowerSample],
1046
        startedAt: Date,
1047
        referenceDate: Date = Date()
1048
    ) -> ChargerStandbyPowerMeasurementStatistics? {
1049
        guard !samples.isEmpty else {
1050
            return nil
1051
        }
1052

            
1053
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
1054
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
1055
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
1056

            
1057
        guard powerValues.isEmpty == false else {
1058
            return nil
1059
        }
1060

            
1061
        let averagePower = mean(powerValues)
1062
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
1063
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
1064
        let stabilityDelta = abs(averagePower - recentAveragePower)
1065
        let stabilityTolerance = max(
1066
            minimumStabilityToleranceWatts,
1067
            abs(averagePower) * relativeStabilityTolerance
1068
        )
1069

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

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

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

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

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

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

            
1163
        let minimum = finiteValues.min() ?? 0
1164
        let maximum = finiteValues.max() ?? 0
1165
        let spread = maximum - minimum
1166
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
1167

            
1168
        guard spread > 0 else {
1169
            return [
1170
                ChargerStandbyPowerDistributionBin(
1171
                    index: 0,
1172
                    lowerBoundWatts: minimum,
1173
                    upperBoundWatts: maximum,
1174
                    count: finiteValues.count,
1175
                    relativeFrequency: 1
1176
                )
1177
            ]
1178
        }
1179

            
1180
        let safeBinCount = max(1, binCount)
1181
        let binWidth = spread / Double(safeBinCount)
1182
        var counts = Array(repeating: 0, count: safeBinCount)
1183

            
1184
        for value in finiteValues {
1185
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
1186
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
1187
            counts[safeIndex] += 1
1188
        }
1189

            
1190
        return counts.enumerated().map { index, count in
1191
            let lowerBound = minimum + (Double(index) * binWidth)
1192
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
1193

            
1194
            return ChargerStandbyPowerDistributionBin(
1195
                index: index,
1196
                lowerBoundWatts: lowerBound,
1197
                upperBoundWatts: upperBound,
1198
                count: count,
1199
                relativeFrequency: Double(count) / Double(finiteValues.count)
1200
            )
1201
        }
1202
    }
1203

            
1204
    private static func mean(_ values: [Double]) -> Double {
1205
        guard values.isEmpty == false else {
1206
            return 0
1207
        }
1208
        return values.reduce(0, +) / Double(values.count)
1209
    }
1210

            
1211
    private static func median(_ values: [Double]) -> Double {
1212
        guard values.isEmpty == false else {
1213
            return 0
1214
        }
1215

            
1216
        let sorted = values.sorted()
1217
        let middleIndex = sorted.count / 2
1218

            
1219
        if sorted.count.isMultiple(of: 2) {
1220
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
1221
        }
1222

            
1223
        return sorted[middleIndex]
1224
    }
1225

            
1226
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
1227
        guard values.count > 1 else {
1228
            return 0
1229
        }
1230

            
1231
        let variance = values.reduce(0) { partialResult, value in
1232
            let delta = value - mean
1233
            return partialResult + (delta * delta)
1234
        } / Double(values.count)
1235

            
1236
        return variance.squareRoot()
1237
    }
1238

            
1239
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
1240
        guard abs(mean) > 0.000_001 else {
1241
            return 0
1242
        }
1243

            
1244
        return standardDeviation(values, mean: mean) / abs(mean)
1245
    }
1246
}
1247

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

            
1285
    var isCharger: Bool {
1286
        deviceClass == .charger
1287
    }
1288

            
Bogdan Timofte authored a month ago
1289
    var kind: ChargedDeviceKind {
1290
        deviceClass.kind
1291
    }
1292

            
1293
    var identityTitle: String {
Bogdan Timofte authored a month ago
1294
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1295
    }
1296

            
Bogdan Timofte authored a month ago
1297
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1298
        isCharger ? kind.symbolName : deviceClass.symbolName
1299
    }
1300

            
Bogdan Timofte authored a month ago
1301
    var identityIcon: ChargedDeviceTemplateIcon {
1302
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1303
    }
1304

            
1305
    var identitySymbolName: String {
1306
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1307
    }
1308

            
Bogdan Timofte authored a month ago
1309
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1310
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1311
    }
1312

            
1313
    var recentCompletedSessions: [ChargeSessionSummary] {
1314
        sessions.filter { $0.status == .completed }
1315
    }
1316

            
1317
    var sessionCount: Int {
1318
        sessions.count
1319
    }
1320

            
Bogdan Timofte authored a month ago
1321
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1322
        standbyPowerMeasurements.first
1323
    }
1324

            
Bogdan Timofte authored a month ago
1325
    var supportedChargingModes: [ChargingTransportMode] {
1326
        var modes: [ChargingTransportMode] = []
1327
        if supportsWiredCharging {
1328
            modes.append(.wired)
1329
        }
1330
        if supportsWirelessCharging {
1331
            modes.append(.wireless)
1332
        }
Bogdan Timofte authored a month ago
1333
        return modes
Bogdan Timofte authored a month ago
1334
    }
1335

            
Bogdan Timofte authored a month ago
1336
    var supportedChargingStateModes: [ChargingStateMode] {
1337
        chargingStateAvailability.supportedModes
1338
    }
1339

            
Bogdan Timofte authored a month ago
1340
    var hasMultipleChargingTransports: Bool {
1341
        supportedChargingModes.count > 1
1342
    }
1343

            
1344
    var hasMultipleChargingStateModes: Bool {
1345
        supportedChargingStateModes.count > 1
1346
    }
1347

            
1348
    var showsWirelessProfileDetails: Bool {
1349
        supportsWirelessCharging
1350
            && hasMultipleChargingTransports
1351
            && deviceClass != .watch
1352
    }
1353

            
1354
    var chargingSupportSummary: String {
1355
        switch (supportsWiredCharging, supportsWirelessCharging) {
1356
        case (true, true):
1357
            return "Supports wired and wireless charging."
1358
        case (true, false):
1359
            return "Supports wired charging only."
1360
        case (false, true):
1361
            return "Supports wireless charging only."
1362
        case (false, false):
1363
            return "No charging method configured."
1364
        }
1365
    }
1366

            
Bogdan Timofte authored a month ago
1367
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1368
        if let matchingSession = sessions.first(where: {
1369
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1370
        }) {
1371
            return matchingSession.chargingStateMode
1372
        }
1373
        return chargingStateAvailability.supportedModes.first ?? .on
1374
    }
1375

            
1376
    func sessionKind(
1377
        for chargingTransportMode: ChargingTransportMode,
1378
        chargingStateMode: ChargingStateMode? = nil
1379
    ) -> ChargeSessionKind {
1380
        ChargeSessionKind(
1381
            chargingTransportMode: chargingTransportMode,
1382
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1383
        )
1384
    }
1385

            
Bogdan Timofte authored a month ago
1386
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1387
        switch chargingTransportMode {
1388
        case .wired:
1389
            return wiredEstimatedBatteryCapacityWh
1390
        case .wireless:
1391
            return wirelessEstimatedBatteryCapacityWh
1392
        }
1393
    }
1394

            
1395
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1396
        switch chargingTransportMode {
1397
        case .wired:
1398
            return wiredMinimumCurrentAmps
1399
        case .wireless:
1400
            return wirelessMinimumCurrentAmps
1401
        }
1402
    }
1403

            
Bogdan Timofte authored a month ago
1404
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1405
        hasMultipleChargingTransports
1406
            || supportedChargingModes.contains(chargingTransportMode) == false
1407
    }
1408

            
1409
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1410
        hasMultipleChargingStateModes
1411
            || supportedChargingStateModes.contains(chargingStateMode) == false
1412
    }
1413

            
Bogdan Timofte authored a month ago
1414
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1415
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
1416
            return explicitCurrent
1417
        }
1418

            
1419
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
1420
        case .wired:
1421
            return wiredChargeCompletionCurrentAmps
1422
        case .wireless:
1423
            return wirelessChargeCompletionCurrentAmps
1424
        }
1425
    }
1426

            
Bogdan Timofte authored a month ago
1427
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1428
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
1429
            return learnedCurrent
1430
        }
1431

            
1432
        switch sessionKind.chargingTransportMode {
1433
        case .wired:
1434
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
1435
        case .wireless:
1436
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
1437
        }
1438
    }
1439

            
1440
    func resolvedCompletionCurrentAmps(
1441
        for chargingTransportMode: ChargingTransportMode,
1442
        chargingStateMode: ChargingStateMode? = nil
1443
    ) -> Double? {
1444
        let sessionKind = sessionKind(
1445
            for: chargingTransportMode,
1446
            chargingStateMode: chargingStateMode
1447
        )
1448

            
1449
        return configuredCompletionCurrentAmps(for: sessionKind)
1450
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
1451
            ?? minimumCurrentAmps(for: chargingTransportMode)
1452
            ?? minimumCurrentAmps
1453
    }
1454

            
Bogdan Timofte authored a month ago
1455
    func batteryLevelPrediction(
1456
        for session: ChargeSessionSummary,
1457
        effectiveEnergyWhOverride: Double? = nil
1458
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1459
        let estimatedCapacityWh = session.capacityEstimateWh
1460
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1461
            ?? estimatedBatteryCapacityWh
1462

            
1463
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1464
            return nil
1465
        }
1466

            
Bogdan Timofte authored a month ago
1467
        let effectiveEnergyWh = effectiveEnergyWhOverride
1468
            ?? session.effectiveBatteryEnergyWh
1469
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1470

            
1471
        struct Anchor {
1472
            let percent: Double
1473
            let energyWh: Double
Bogdan Timofte authored a month ago
1474
            let timestamp: Date
Bogdan Timofte authored a month ago
1475
            let description: String
Bogdan Timofte authored a month ago
1476
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1477
        }
1478

            
1479
        var anchors: [Anchor] = []
1480

            
Bogdan Timofte authored a month ago
1481
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1482
            anchors.append(
1483
                Anchor(
1484
                    percent: startBatteryPercent,
1485
                    energyWh: 0,
Bogdan Timofte authored a month ago
1486
                    timestamp: session.effectiveTrimStart,
Bogdan Timofte authored a month ago
1487
                    description: "session start",
1488
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1489
                )
1490
            )
1491
        }
1492

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

            
1515
        guard !anchors.isEmpty else {
1516
            return nil
1517
        }
1518

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

            
1531
        return BatteryLevelPrediction(
1532
            predictedPercent: predictedPercent,
1533
            estimatedCapacityWh: estimatedCapacityWh,
1534
            anchorPercent: anchor.percent,
1535
            anchorEnergyWh: anchor.energyWh,
1536
            anchorDescription: anchor.description
1537
        )
1538
    }
Bogdan Timofte authored a month ago
1539

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

            
1581
struct ChargingMonitorSnapshot {
1582
    let meterMACAddress: String
1583
    let meterName: String
1584
    let meterModel: String
1585
    let observedAt: Date
1586
    let voltageVolts: Double
1587
    let currentAmps: Double
1588
    let powerWatts: Double
1589
    let selectedDataGroup: UInt8?
1590
    let meterChargeCounterAh: Double?
1591
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1592
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1593
    let fallbackStopThresholdAmps: Double
1594
}