USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
1613 lines | 53.632kb
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
Bogdan Timofte authored a month ago
610
    let estimatedBatteryPercent: Double?
Bogdan Timofte authored a month ago
611
    let sampleCount: Int
612

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
805
    var id: UUID { sessionID }
806
}
807

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

            
813
    var id: Int { percentBin }
814
}
815

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

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

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

            
834
    var id: Int { index }
835
}
836

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

            
842
    var id: Int { rawValue }
843

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1017
    // MARK: - Computed
1018

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1219
        return sorted[middleIndex]
1220
    }
1221

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

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

            
1232
        return variance.squareRoot()
1233
    }
1234

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1459
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1460
            return nil
1461
        }
1462

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

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

            
1475
        var anchors: [Anchor] = []
1476

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

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

            
1511
        guard !anchors.isEmpty else {
1512
            return nil
1513
        }
1514

            
Bogdan Timofte authored a month ago
1515
        let lowerAnchor = anchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
1516
        let upperAnchor = anchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
1517
        let anchor = lowerAnchor ?? upperAnchor ?? anchors.first!
1518

            
1519
        let predictedPercent: Double
1520
        if let lowerAnchor,
1521
           let upperAnchor,
1522
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
1523
            let interpolationProgress = min(
1524
                max(
1525
                    (effectiveEnergyWh - lowerAnchor.energyWh) /
1526
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
1527
                    0
1528
                ),
1529
                1
1530
            )
1531
            predictedPercent = min(
1532
                max(
1533
                    lowerAnchor.percent +
1534
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
1535
                    0
1536
                ),
1537
                100
1538
            )
1539
        } else {
1540
            predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1541
                anchorPercent: anchor.percent,
1542
                anchorEnergyWh: anchor.energyWh,
1543
                anchorTimestamp: anchor.timestamp,
1544
                anchorIsCheckpoint: anchor.isCheckpoint,
1545
                effectiveEnergyWh: effectiveEnergyWh,
1546
                referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
1547
                estimatedCapacityWh: estimatedCapacityWh
1548
            )
1549
        }
Bogdan Timofte authored a month ago
1550

            
1551
        return BatteryLevelPrediction(
1552
            predictedPercent: predictedPercent,
1553
            estimatedCapacityWh: estimatedCapacityWh,
1554
            anchorPercent: anchor.percent,
1555
            anchorEnergyWh: anchor.energyWh,
1556
            anchorDescription: anchor.description
1557
        )
1558
    }
Bogdan Timofte authored a month ago
1559

            
1560
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1561
        ChargedDeviceSummary(
1562
            id: id,
1563
            qrIdentifier: qrIdentifier,
1564
            name: name,
1565
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1566
            deviceTemplateID: deviceTemplateID,
1567
            templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1568
            supportsChargingWhileOff: supportsChargingWhileOff,
1569
            chargingStateAvailability: chargingStateAvailability,
1570
            supportsWiredCharging: supportsWiredCharging,
1571
            supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1572
            chargerType: chargerType,
Bogdan Timofte authored a month ago
1573
            wirelessChargingProfile: wirelessChargingProfile,
1574
            configuredCompletionCurrents: configuredCompletionCurrents,
1575
            learnedCompletionCurrents: learnedCompletionCurrents,
1576
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1577
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1578
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1579
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1580
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1581
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1582
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1583
            notes: notes,
1584
            minimumCurrentAmps: minimumCurrentAmps,
1585
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1586
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1587
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1588
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1589
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1590
            createdAt: createdAt,
1591
            updatedAt: updatedAt,
1592
            sessions: sessions,
1593
            capacityHistory: capacityHistory,
1594
            typicalCurve: typicalCurve,
1595
            standbyPowerMeasurements: measurements
1596
        )
1597
    }
Bogdan Timofte authored a month ago
1598
}
1599

            
1600
struct ChargingMonitorSnapshot {
1601
    let meterMACAddress: String
1602
    let meterName: String
1603
    let meterModel: String
1604
    let observedAt: Date
1605
    let voltageVolts: Double
1606
    let currentAmps: Double
1607
    let powerWatts: Double
1608
    let selectedDataGroup: UInt8?
1609
    let meterChargeCounterAh: Double?
1610
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1611
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1612
    let fallbackStopThresholdAmps: Double
1613
}