USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
1595 lines | 53.kb
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 wasConflictHealed: Bool
Bogdan Timofte authored a month ago
667
    let checkpoints: [ChargeCheckpointSummary]
668
    let aggregatedSamples: [ChargeSessionSampleSummary]
669

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
809
    var id: UUID { sessionID }
810
}
811

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

            
818
    var id: Int { percentBin }
819
}
820

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

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

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

            
839
    var id: Int { index }
840
}
841

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

            
847
    var id: Int { rawValue }
848

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1022
    // MARK: - Computed
1023

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1224
        return sorted[middleIndex]
1225
    }
1226

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

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

            
1237
        return variance.squareRoot()
1238
    }
1239

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1480
        var anchors: [Anchor] = []
1481

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

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

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

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

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

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

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