USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
2452 lines | 82.989kb
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
enum ProfileCategory: String, CaseIterable, Identifiable, Codable {
541
    case phone
542
    case tablet
543
    case laptop
544
    case watch
545
    case audioAccessory
546
    case accessoryCase
547
    case charger
548
    case powerbank
549
    case other
550

            
551
    var id: String { rawValue }
552

            
553
    var title: String {
554
        switch self {
555
        case .phone: return "Phone"
556
        case .tablet: return "Tablet"
557
        case .laptop: return "Laptop"
558
        case .watch: return "Watch"
559
        case .audioAccessory: return "Audio Accessory"
560
        case .accessoryCase: return "Charging Case"
561
        case .charger: return "Charger"
562
        case .powerbank: return "Powerbank"
563
        case .other: return "Other"
564
        }
565
    }
566

            
567
    var pluralTitle: String {
568
        switch self {
569
        case .phone: return "Phones"
570
        case .tablet: return "Tablets"
571
        case .laptop: return "Laptops"
572
        case .watch: return "Watches"
573
        case .audioAccessory: return "Audio Accessories"
574
        case .accessoryCase: return "Charging Cases"
575
        case .charger: return "Chargers"
576
        case .powerbank: return "Powerbanks"
577
        case .other: return "Other"
578
        }
579
    }
580

            
581
    var symbolName: String {
582
        switch self {
583
        case .phone: return "iphone"
584
        case .tablet: return "ipad"
585
        case .laptop: return "laptopcomputer"
586
        case .watch: return "applewatch"
587
        case .audioAccessory: return "earbuds.case"
588
        case .accessoryCase: return "airpods.case.fill"
589
        case .charger: return "bolt.horizontal.circle"
590
        case .powerbank: return "battery.100.bolt"
591
        case .other: return "shippingbox"
592
        }
593
    }
594

            
595
    var kind: ChargedDeviceKind {
596
        self == .charger ? .charger : .device
597
    }
598

            
599
    static func fromLegacyDeviceClass(_ deviceClass: ChargedDeviceClass) -> ProfileCategory {
600
        switch deviceClass {
601
        case .iphone: return .phone
602
        case .watch: return .watch
603
        case .powerbank: return .powerbank
604
        case .charger: return .charger
605
        case .other: return .other
606
        }
607
    }
608
}
609

            
610
struct DeviceProfileDefinition: Identifiable, Hashable, Codable {
611
    let id: String
612
    let name: String
613
    let group: String
614
    let category: ProfileCategory
615
    let icon: ChargedDeviceTemplateIcon
616
    let sortOrder: Int
617

            
618
    let capWiredCharging: Bool
619
    let capWirelessCharging: Bool
620
    let capWirelessProfiles: [WirelessChargingProfile]
621
    let capChargingStateAvailability: ChargingStateAvailability
622
    let capHasInternalSubject: Bool
623

            
624
    let defaultWirelessChargingProfile: WirelessChargingProfile?
625
    let defaultWiredMinimumCurrentAmps: Double?
626
    let defaultWirelessMinimumCurrentAmps: Double?
627
    let defaultWiredEstimatedBatteryCapacityWh: Double?
628
    let defaultWirelessEstimatedBatteryCapacityWh: Double?
629

            
630
    init(
631
        id: String,
632
        name: String,
633
        group: String,
634
        category: ProfileCategory,
635
        icon: ChargedDeviceTemplateIcon,
636
        sortOrder: Int,
637
        capWiredCharging: Bool,
638
        capWirelessCharging: Bool,
639
        capWirelessProfiles: [WirelessChargingProfile],
640
        capChargingStateAvailability: ChargingStateAvailability,
641
        capHasInternalSubject: Bool,
642
        defaultWirelessChargingProfile: WirelessChargingProfile? = nil,
643
        defaultWiredMinimumCurrentAmps: Double? = nil,
644
        defaultWirelessMinimumCurrentAmps: Double? = nil,
645
        defaultWiredEstimatedBatteryCapacityWh: Double? = nil,
646
        defaultWirelessEstimatedBatteryCapacityWh: Double? = nil
647
    ) {
648
        self.id = id
649
        self.name = name
650
        self.group = group
651
        self.category = category
652
        self.icon = icon
653
        self.sortOrder = sortOrder
654
        self.capWiredCharging = capWiredCharging
655
        self.capWirelessCharging = capWirelessCharging
656
        self.capWirelessProfiles = capWirelessProfiles
657
        self.capChargingStateAvailability = capChargingStateAvailability
658
        self.capHasInternalSubject = capHasInternalSubject
659
        self.defaultWirelessChargingProfile = defaultWirelessChargingProfile
660
        self.defaultWiredMinimumCurrentAmps = defaultWiredMinimumCurrentAmps
661
        self.defaultWirelessMinimumCurrentAmps = defaultWirelessMinimumCurrentAmps
662
        self.defaultWiredEstimatedBatteryCapacityWh = defaultWiredEstimatedBatteryCapacityWh
663
        self.defaultWirelessEstimatedBatteryCapacityWh = defaultWirelessEstimatedBatteryCapacityWh
664
    }
665

            
666
    var capabilitySummary: String {
667
        var components: [String] = [capChargingStateAvailability.title]
668
        switch (capWiredCharging, capWirelessCharging) {
669
        case (true, true): components.append("Wired + Wireless")
670
        case (true, false): components.append("Wired only")
671
        case (false, true): components.append("Wireless only")
672
        case (false, false): components.append("No transport")
673
        }
674
        if capWirelessCharging, let primary = defaultWirelessChargingProfile {
675
            components.append(primary.title)
676
        }
677
        return components.joined(separator: " • ")
678
    }
679

            
680
    var wirelessProfilesCSV: String {
681
        capWirelessProfiles.map { $0.rawValue }.joined(separator: ",")
682
    }
683

            
684
    static func decodeWirelessProfilesCSV(_ csv: String?) -> [WirelessChargingProfile] {
685
        guard let csv, !csv.isEmpty else { return [] }
686
        return csv
687
            .split(separator: ",")
688
            .compactMap { WirelessChargingProfile(rawValue: String($0).trimmingCharacters(in: .whitespaces)) }
689
    }
690
}
691

            
692
private struct DeviceProfileCatalogDocument: Codable {
693
    let profiles: [DeviceProfileDefinition]
694
}
695

            
696
struct DeviceProfileCatalog {
697
    static let shared = DeviceProfileCatalog()
698

            
699
    let profiles: [DeviceProfileDefinition]
700
    private let profilesByID: [String: DeviceProfileDefinition]
701

            
702
    private init(bundle: Bundle = .main) {
703
        let loaded: [DeviceProfileDefinition]
704

            
705
        if let resourceURL = bundle.url(forResource: "DeviceProfilesCatalog", withExtension: "json"),
706
           let data = try? Data(contentsOf: resourceURL),
707
           let document = try? JSONDecoder().decode(DeviceProfileCatalogDocument.self, from: data) {
708
            loaded = document.profiles
709
        } else {
710
            loaded = []
711
        }
712

            
713
        self.profiles = loaded.sorted { lhs, rhs in
714
            if lhs.group != rhs.group {
715
                return lhs.group < rhs.group
716
            }
717
            if lhs.sortOrder != rhs.sortOrder {
718
                return lhs.sortOrder < rhs.sortOrder
719
            }
720
            return lhs.name < rhs.name
721
        }
722
        self.profilesByID = Dictionary(uniqueKeysWithValues: self.profiles.map { ($0.id, $0) })
723
    }
724

            
725
    func profile(id: String?) -> DeviceProfileDefinition? {
726
        guard let id else { return nil }
727
        return profilesByID[id]
728
    }
729

            
730
    func profiles(for category: ProfileCategory) -> [DeviceProfileDefinition] {
731
        profiles.filter { $0.category == category }
732
    }
733
}
734

            
735
/// Centralizes the autoexclusion rules that turn a `DeviceProfile` into a coherent
736
/// device state. Called from the editor at edit time so impossible combinations are
737
/// not even expressible — instead of being silently corrected at read time.
738
enum DeviceProfileValidator {
739
    struct AppliedState: Equatable {
740
        var chargingStateAvailability: ChargingStateAvailability
741
        var supportsWiredCharging: Bool
742
        var supportsWirelessCharging: Bool
743
        var wirelessChargingProfile: WirelessChargingProfile
744
        var hasInternalSubject: Bool
745
    }
746

            
747
    /// Returns the canonical state for a freshly selected profile.
748
    /// Used both when the user picks a profile in the editor and when seeding
749
    /// new device defaults from a catalog entry.
750
    static func canonicalState(for profile: DeviceProfileDefinition) -> AppliedState {
751
        AppliedState(
752
            chargingStateAvailability: profile.capChargingStateAvailability,
753
            supportsWiredCharging: profile.capWiredCharging,
754
            supportsWirelessCharging: profile.capWirelessCharging,
755
            wirelessChargingProfile: profile.defaultWirelessChargingProfile
756
                ?? profile.capWirelessProfiles.first
757
                ?? .genericQi,
758
            hasInternalSubject: false
759
        )
760
    }
761

            
762
    /// Coerces a possibly-contradictory state to fit the profile's capabilities.
763
    /// Preserves user-set values where they are still allowed; otherwise falls
764
    /// back to canonical defaults.
765
    static func coerce(
766
        state: AppliedState,
767
        to profile: DeviceProfileDefinition
768
    ) -> AppliedState {
769
        var coerced = state
770
        coerced.supportsWiredCharging = state.supportsWiredCharging && profile.capWiredCharging
771
        coerced.supportsWirelessCharging = state.supportsWirelessCharging && profile.capWirelessCharging
772
        if !coerced.supportsWiredCharging && !coerced.supportsWirelessCharging {
773
            coerced.supportsWiredCharging = profile.capWiredCharging
774
            coerced.supportsWirelessCharging = profile.capWirelessCharging
775
        }
776
        coerced.chargingStateAvailability = profile.capChargingStateAvailability
777
        if !profile.capWirelessProfiles.contains(state.wirelessChargingProfile) {
778
            coerced.wirelessChargingProfile = profile.defaultWirelessChargingProfile
779
                ?? profile.capWirelessProfiles.first
780
                ?? .genericQi
781
        }
782
        if !profile.capHasInternalSubject {
783
            coerced.hasInternalSubject = false
784
        }
785
        return coerced
786
    }
787

            
788
    /// True when the editor should offer the user a toggle for wired charging.
789
    /// (False means the profile forbids wired entirely — hide the row.)
790
    static func allowsWiredToggle(_ profile: DeviceProfileDefinition) -> Bool {
791
        profile.capWiredCharging
792
    }
793

            
794
    static func allowsWirelessToggle(_ profile: DeviceProfileDefinition) -> Bool {
795
        profile.capWirelessCharging
796
    }
797

            
798
    /// True when both transports are permitted — meaning the user may opt out of
799
    /// either; otherwise the surviving transport is mandatory.
800
    static func allowsTransportChoice(_ profile: DeviceProfileDefinition) -> Bool {
801
        profile.capWiredCharging && profile.capWirelessCharging
802
    }
803

            
804
    /// True when there is more than one wireless profile to choose from for this
805
    /// catalog entry. Shown as a picker; otherwise hidden (single value implied).
806
    static func allowsWirelessProfileChoice(_ profile: DeviceProfileDefinition) -> Bool {
807
        profile.capWirelessProfiles.count > 1
808
    }
809

            
810
    /// True when the profile's `capChargingStateAvailability` is fixed to a single
811
    /// state mode — in which case the editor renders a locked badge instead of a picker.
812
    static func chargingStateIsLocked(_ profile: DeviceProfileDefinition) -> Bool {
813
        profile.capChargingStateAvailability == .onOnly
814
            || profile.capChargingStateAvailability == .offOnly
815
    }
816
}
817

            
Bogdan Timofte authored a month ago
818
struct ChargeCheckpointSummary: Identifiable, Hashable {
819
    let id: UUID
820
    let sessionID: UUID
821
    let chargedDeviceID: UUID
Bogdan Timofte authored a month ago
822
    let powerbankID: UUID?
823
    let batteryBarsValue: Int
Bogdan Timofte authored a month ago
824
    let timestamp: Date
825
    let batteryPercent: Double
826
    let measuredEnergyWh: Double
827
    let currentAmps: Double
828
    let voltageVolts: Double?
829
    let label: String?
Bogdan Timofte authored a month ago
830

            
831
    var flag: ChargeCheckpointFlag {
832
        ChargeCheckpointFlag.fromStoredLabel(label)
833
    }
Bogdan Timofte authored a month ago
834

            
835
    var subject: CheckpointSubject {
836
        powerbankID == nil ? .chargedDevice : .powerbank
837
    }
Bogdan Timofte authored a month ago
838
}
839

            
840
enum ChargeCheckpointFlag: String, CaseIterable {
841
    case initial
842
    case intermediate
843
    case final
844

            
845
    var title: String {
846
        switch self {
847
        case .initial:
848
            return "Initial"
849
        case .intermediate:
850
            return "Intermediate"
851
        case .final:
852
            return "Final"
853
        }
854
    }
855

            
856
    var anchorDescription: String {
857
        switch self {
858
        case .initial:
859
            return "initial checkpoint"
860
        case .intermediate:
861
            return "intermediate checkpoint"
862
        case .final:
863
            return "final checkpoint"
864
        }
865
    }
866

            
867
    static func fromStoredLabel(_ label: String?) -> ChargeCheckpointFlag {
868
        let normalized = label?
869
            .trimmingCharacters(in: .whitespacesAndNewlines)
870
            .lowercased()
871

            
872
        switch normalized {
873
        case "initial", "start":
874
            return .initial
875
        case "final", "end":
876
            return .final
877
        case "intermediate", nil, "":
878
            return .intermediate
879
        default:
880
            return .intermediate
881
        }
882
    }
Bogdan Timofte authored a month ago
883
}
884

            
885
struct ChargeSessionSampleSummary: Identifiable, Hashable {
886
    let sessionID: UUID
887
    let chargedDeviceID: UUID
888
    let bucketIndex: Int
889
    let timestamp: Date
890
    let averageCurrentAmps: Double
891
    let averageVoltageVolts: Double?
892
    let averagePowerWatts: Double
893
    let measuredEnergyWh: Double
Bogdan Timofte authored a month ago
894
    let estimatedBatteryPercent: Double?
Bogdan Timofte authored a month ago
895
    let sampleCount: Int
896

            
897
    var id: String {
898
        "\(sessionID.uuidString)-\(bucketIndex)"
899
    }
900
}
901

            
902
struct ChargeSessionSummary: Identifiable, Hashable {
903
    let id: UUID
904
    let chargedDeviceID: UUID
Bogdan Timofte authored a month ago
905
    let chargedPowerbankID: UUID?
Bogdan Timofte authored a month ago
906
    let chargerID: UUID?
Bogdan Timofte authored a month ago
907
    let sourcePowerbankID: UUID?
Bogdan Timofte authored a month ago
908
    let meterMACAddress: String?
909
    let meterName: String?
910
    let meterModel: String?
911
    let startedAt: Date
912
    let endedAt: Date?
913
    let lastObservedAt: Date
Bogdan Timofte authored a month ago
914
    let pausedAt: Date?
Bogdan Timofte authored a month ago
915
    let status: ChargeSessionStatus
916
    let sourceMode: ChargeSessionSourceMode
917
    let chargingTransportMode: ChargingTransportMode
Bogdan Timofte authored a month ago
918
    let chargingStateMode: ChargingStateMode
919
    let autoStopEnabled: Bool
Bogdan Timofte authored a month ago
920
    let measuredEnergyWh: Double
921
    let effectiveBatteryEnergyWh: Double?
Bogdan Timofte authored a month ago
922
    let meterEnergyBaselineWh: Double?
Bogdan Timofte authored a month ago
923
    let meterDurationBaselineSeconds: Double?
924
    let meterLastDurationSeconds: Double?
Bogdan Timofte authored a month ago
925
    let minimumObservedCurrentAmps: Double?
926
    let maximumObservedCurrentAmps: Double?
927
    let maximumObservedPowerWatts: Double?
928
    let maximumObservedVoltageVolts: Double?
Bogdan Timofte authored a month ago
929
    let hasObservedChargeFlow: Bool
Bogdan Timofte authored a month ago
930
    let selectedSourceVoltageVolts: Double?
931
    let completionCurrentAmps: Double?
932
    let stopThresholdAmps: Double
933
    let startBatteryPercent: Double?
934
    let endBatteryPercent: Double?
935
    let capacityEstimateWh: Double?
936
    let wirelessEfficiencyFactor: Double?
937
    let usesEstimatedWirelessEfficiency: Bool
938
    let shouldWarnAboutLowWirelessEfficiency: Bool
939
    let supportsChargingWhileOff: Bool
940
    let usedOfflineMeterCounters: Bool
941
    let targetBatteryPercent: Double?
942
    let targetBatteryAlertTriggeredAt: Date?
943
    let requiresCompletionConfirmation: Bool
944
    let completionConfirmationRequestedAt: Date?
945
    let completionContradictionPercent: Double?
946
    let selectedDataGroup: UInt8?
Bogdan Timofte authored a month ago
947
    let trimStart: Date?
948
    let trimEnd: Date?
Bogdan Timofte authored a month ago
949
    let wasConflictHealed: Bool
Bogdan Timofte authored a month ago
950
    let checkpoints: [ChargeCheckpointSummary]
951
    let aggregatedSamples: [ChargeSessionSampleSummary]
952

            
Bogdan Timofte authored a month ago
953
    var effectiveTrimStart: Date { trimStart ?? startedAt }
954
    var effectiveTrimEnd: Date { trimEnd ?? (endedAt ?? lastObservedAt) }
955
    var isTrimmed: Bool { trimStart != nil || trimEnd != nil }
956
    var effectiveTimeRange: ClosedRange<Date> {
957
        let start = effectiveTrimStart
958
        let end = max(effectiveTrimEnd, start)
959
        return start...end
960
    }
961
    var displayedAggregatedSamples: [ChargeSessionSampleSummary] {
962
        guard isTrimmed else { return aggregatedSamples }
963
        let range = effectiveTimeRange
964
        return aggregatedSamples.filter { range.contains($0.timestamp) }
965
    }
966

            
Bogdan Timofte authored a month ago
967
    var sessionKind: ChargeSessionKind {
968
        ChargeSessionKind(
969
            chargingTransportMode: chargingTransportMode,
970
            chargingStateMode: chargingStateMode
971
        )
972
    }
973

            
Bogdan Timofte authored a month ago
974
    /// Generalized source slot. `none` when no source is tracked, `charger(id)` for the existing
975
    /// charger flow, `powerbank(id)` when a powerbank is supplying power for this session.
976
    var source: ChargeSessionSource {
977
        if let sourcePowerbankID {
978
            return .powerbank(sourcePowerbankID)
979
        }
980
        if let chargerID {
981
            return .charger(chargerID)
982
        }
983
        return .none
984
    }
985

            
986
    var hasPowerbankSubject: Bool { chargedPowerbankID != nil }
987
    var hasPowerbankSource: Bool { sourcePowerbankID != nil }
988

            
Bogdan Timofte authored a month ago
989
    var duration: TimeInterval {
990
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
991
    }
992

            
Bogdan Timofte authored a month ago
993
    var meterObservedDuration: TimeInterval? {
994
        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
995
            return nil
996
        }
997
        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
998
            return nil
999
        }
1000
        return meterLastDurationSeconds - meterDurationBaselineSeconds
1001
    }
1002

            
1003
    var effectiveDuration: TimeInterval {
Bogdan Timofte authored a month ago
1004
        if isTrimmed {
1005
            return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0)
1006
        }
Bogdan Timofte authored a month ago
1007

            
1008
        // Use timestamp-based duration as primary source; only use meter counter if it's consistent
1009
        let timestampDuration = duration
1010

            
1011
        if let meterDuration = meterObservedDuration {
1012
            // Allow 5% tolerance for meter counter vs timestamp calculation
1013
            let tolerance = timestampDuration * 0.05
1014
            let lower = timestampDuration - tolerance
1015
            let upper = timestampDuration + tolerance
1016

            
1017
            // If meter duration is within tolerance range, use it (more precise)
1018
            // Otherwise fall back to timestamp-based duration
1019
            if meterDuration >= lower && meterDuration <= upper {
1020
                return meterDuration
1021
            }
1022
        }
1023

            
1024
        return timestampDuration
Bogdan Timofte authored a month ago
1025
    }
1026

            
Bogdan Timofte authored a month ago
1027
    var effectiveOrMeasuredEnergyWh: Double {
1028
        effectiveBatteryEnergyWh ?? measuredEnergyWh
1029
    }
1030

            
Bogdan Timofte authored a month ago
1031
    var hasSavableChargeData: Bool {
1032
        hasObservedChargeFlow
1033
            || measuredEnergyWh > 0
1034
            || (maximumObservedCurrentAmps ?? 0) > 0
1035
            || (maximumObservedPowerWatts ?? 0) > 0
1036
            || !aggregatedSamples.isEmpty
1037
    }
1038

            
Bogdan Timofte authored a month ago
1039
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
1040
        guard let startBatteryPercent, let endBatteryPercent,
1041
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
1042
        return endBatteryPercent - startBatteryPercent
1043
    }
Bogdan Timofte authored a month ago
1044

            
Bogdan Timofte authored a month ago
1045
    var startsFromFlatBattery: Bool {
1046
        guard let startBatteryPercent else {
1047
            return false
1048
        }
1049
        return startBatteryPercent.isFinite && startBatteryPercent < 0
1050
    }
1051

            
Bogdan Timofte authored a month ago
1052
    var canAutoStop: Bool {
1053
        autoStopEnabled && stopThresholdAmps > 0
1054
    }
1055

            
1056
    var isPaused: Bool {
1057
        status == .paused
1058
    }
1059

            
1060
    var isOpen: Bool {
1061
        status.isOpen
1062
    }
Bogdan Timofte authored a month ago
1063
}
1064

            
Bogdan Timofte authored a month ago
1065
enum BatteryLevelPredictionBasis: Hashable {
1066
    case capacityEstimate
1067
    case checkpointEnergyMap
Bogdan Timofte authored a month ago
1068
    case typicalChargeCurve
Bogdan Timofte authored a month ago
1069

            
1070
    var metricLabel: String {
1071
        switch self {
1072
        case .capacityEstimate:
1073
            return "est. capacity"
1074
        case .checkpointEnergyMap:
1075
            return "energy map"
Bogdan Timofte authored a month ago
1076
        case .typicalChargeCurve:
1077
            return "charge curve"
Bogdan Timofte authored a month ago
1078
        }
1079
    }
1080

            
1081
    var explanatoryLabel: String {
1082
        switch self {
1083
        case .capacityEstimate:
1084
            return "estimated capacity"
1085
        case .checkpointEnergyMap:
1086
            return "checkpoint energy map"
Bogdan Timofte authored a month ago
1087
        case .typicalChargeCurve:
1088
            return "typical charge curve"
Bogdan Timofte authored a month ago
1089
        }
1090
    }
1091
}
1092

            
Bogdan Timofte authored a month ago
1093
struct BatteryLevelPrediction: Hashable {
1094
    let predictedPercent: Double
Bogdan Timofte authored a month ago
1095
    let estimatedCapacityWh: Double?
1096
    let basis: BatteryLevelPredictionBasis
Bogdan Timofte authored a month ago
1097
    let anchorPercent: Double
1098
    let anchorEnergyWh: Double
1099
    let anchorDescription: String
Bogdan Timofte authored a month ago
1100

            
1101
    func energyWh(forPercent percent: Double) -> Double? {
1102
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1103
            return nil
1104
        }
1105

            
1106
        return anchorEnergyWh + ((percent - anchorPercent) / 100) * estimatedCapacityWh
1107
    }
Bogdan Timofte authored a month ago
1108
}
1109

            
Bogdan Timofte authored a month ago
1110
enum BatteryLevelPredictionTuning {
Bogdan Timofte authored a month ago
1111
    static func inferredVirtualZeroEnergyWh(
1112
        from anchors: [BatteryLevelPredictionAnchor],
1113
        estimatedCapacityWh: Double? = nil,
1114
        historicalReserveEnergyWh: Double? = nil
1115
    ) -> Double? {
1116
        let sortedAnchors = anchors
1117
            .filter { $0.percent > 0 && $0.percent <= 100 && $0.energyWh >= 0 }
1118
            .sorted { lhs, rhs in
1119
                if lhs.energyWh != rhs.energyWh {
1120
                    return lhs.energyWh < rhs.energyWh
1121
                }
1122
                return lhs.timestamp < rhs.timestamp
1123
            }
1124

            
1125
        guard let firstAnchor = sortedAnchors.first else {
1126
            return nil
1127
        }
1128

            
1129
        func clampedReserve(_ reserveEnergyWh: Double) -> Double? {
1130
            guard reserveEnergyWh.isFinite else {
1131
                return nil
1132
            }
1133
            return min(max(reserveEnergyWh, 0), firstAnchor.energyWh)
1134
        }
1135

            
1136
        if let historicalReserveEnergyWh,
1137
           let reserveEnergyWh = clampedReserve(historicalReserveEnergyWh) {
1138
            return reserveEnergyWh
1139
        }
1140

            
1141
        if let estimatedCapacityWh,
1142
           estimatedCapacityWh > 0 {
1143
            return clampedReserve(
1144
                firstAnchor.energyWh - ((firstAnchor.percent / 100) * estimatedCapacityWh)
1145
            )
1146
        }
1147

            
1148
        var zeroCandidates: [Double] = []
1149

            
1150
        for lowerIndex in sortedAnchors.indices {
1151
            for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex {
1152
                let lower = sortedAnchors[lowerIndex]
1153
                let upper = sortedAnchors[upperIndex]
1154
                let percentDelta = upper.percent - lower.percent
1155
                let energyDeltaWh = upper.energyWh - lower.energyWh
1156

            
1157
                guard percentDelta >= 3, energyDeltaWh > 0.01 else {
1158
                    continue
1159
                }
1160

            
1161
                let capacityWh = energyDeltaWh / (percentDelta / 100)
1162
                guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
1163
                    continue
1164
                }
1165

            
1166
                zeroCandidates.append(lower.energyWh - ((lower.percent / 100) * capacityWh))
1167
                zeroCandidates.append(upper.energyWh - ((upper.percent / 100) * capacityWh))
1168
            }
1169
        }
1170

            
1171
        guard !zeroCandidates.isEmpty else {
1172
            return nil
1173
        }
1174

            
1175
        let sortedCandidates = zeroCandidates.sorted()
1176
        return clampedReserve(sortedCandidates[sortedCandidates.count / 2])
1177
    }
Bogdan Timofte authored a month ago
1178

            
1179
    static func predictedPercent(
1180
        anchorPercent: Double,
1181
        anchorEnergyWh: Double,
1182
        anchorTimestamp: Date,
1183
        anchorIsCheckpoint: Bool,
1184
        effectiveEnergyWh: Double,
1185
        referenceTimestamp: Date,
1186
        estimatedCapacityWh: Double
1187
    ) -> Double {
Bogdan Timofte authored a month ago
1188
        _ = anchorTimestamp
1189
        _ = anchorIsCheckpoint
1190
        _ = referenceTimestamp
1191

            
Bogdan Timofte authored a month ago
1192
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
1193
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
1194

            
1195
        return min(
1196
            100,
1197
            max(
1198
                0,
Bogdan Timofte authored a month ago
1199
                anchorPercent + rawGainPercent
Bogdan Timofte authored a month ago
1200
            )
1201
        )
1202
    }
Bogdan Timofte authored a month ago
1203

            
1204
    static func predictedPercent(
1205
        anchorPercent: Double,
1206
        anchorEnergyWh: Double,
1207
        effectiveEnergyWh: Double,
1208
        chargeCurve: BatteryChargeCurve,
1209
        deviationFactor: Double?
1210
    ) -> Double? {
1211
        guard
1212
            let curveAnchorEnergyWh = chargeCurve.energyWh(forPercent: anchorPercent)
1213
        else {
1214
            return nil
1215
        }
1216

            
1217
        let sessionEnergyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
1218
        let normalizedEnergyDeltaWh = sessionEnergyDeltaWh / max(deviationFactor ?? 1, 0.05)
1219
        let projectedCurveEnergyWh = curveAnchorEnergyWh + normalizedEnergyDeltaWh
1220

            
1221
        guard let curvePercent = chargeCurve.percent(forEnergyWh: projectedCurveEnergyWh) else {
1222
            return nil
1223
        }
1224

            
1225
        return min(100, max(anchorPercent, curvePercent))
1226
    }
1227

            
1228
    static func deviationFactor(
1229
        anchors: [BatteryLevelPredictionAnchor],
1230
        chargeCurve: BatteryChargeCurve
1231
    ) -> Double? {
1232
        let sortedAnchors = anchors.sorted { lhs, rhs in
1233
            if lhs.timestamp != rhs.timestamp {
1234
                return lhs.timestamp < rhs.timestamp
1235
            }
1236
            return lhs.energyWh < rhs.energyWh
1237
        }
1238
        var ratios: [Double] = []
1239

            
1240
        for lowerIndex in sortedAnchors.indices {
1241
            for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex {
1242
                let lower = sortedAnchors[lowerIndex]
1243
                let upper = sortedAnchors[upperIndex]
1244
                let percentDelta = upper.percent - lower.percent
1245
                let energyDeltaWh = upper.energyWh - lower.energyWh
1246

            
1247
                guard percentDelta >= 3, energyDeltaWh > 0.01,
1248
                      let curveLowerEnergyWh = chargeCurve.energyWh(forPercent: lower.percent),
1249
                      let curveUpperEnergyWh = chargeCurve.energyWh(forPercent: upper.percent) else {
1250
                    continue
1251
                }
1252

            
1253
                let curveEnergyDeltaWh = curveUpperEnergyWh - curveLowerEnergyWh
1254
                guard curveEnergyDeltaWh > 0.01 else {
1255
                    continue
1256
                }
1257

            
1258
                let ratio = energyDeltaWh / curveEnergyDeltaWh
1259
                guard ratio.isFinite, ratio > 0 else {
1260
                    continue
1261
                }
1262

            
1263
                ratios.append(min(max(ratio, 0.25), 4.0))
1264
            }
1265
        }
1266

            
1267
        guard !ratios.isEmpty else {
1268
            return nil
1269
        }
1270

            
1271
        let sortedRatios = ratios.sorted()
1272
        return sortedRatios[sortedRatios.count / 2]
1273
    }
Bogdan Timofte authored a month ago
1274
}
1275

            
Bogdan Timofte authored a month ago
1276
struct CapacityTrendPoint: Identifiable, Hashable {
1277
    let sessionID: UUID
1278
    let timestamp: Date
1279
    let capacityWh: Double
1280
    let chargingTransportMode: ChargingTransportMode
1281

            
1282
    var id: UUID { sessionID }
1283
}
1284

            
1285
struct TypicalChargeCurvePoint: Identifiable, Hashable {
1286
    let percentBin: Int
1287
    let averageEnergyWh: Double
1288
    let sampleCount: Int
1289

            
1290
    var id: Int { percentBin }
1291
}
1292

            
Bogdan Timofte authored a month ago
1293
struct BatteryLevelPredictionAnchor: Hashable {
1294
    let percent: Double
1295
    let energyWh: Double
1296
    let timestamp: Date
1297
    let description: String
1298
    let isCheckpoint: Bool
1299

            
1300
    init(
1301
        percent: Double,
1302
        energyWh: Double,
1303
        timestamp: Date,
1304
        description: String = "",
1305
        isCheckpoint: Bool
1306
    ) {
1307
        self.percent = percent
1308
        self.energyWh = energyWh
1309
        self.timestamp = timestamp
1310
        self.description = description
1311
        self.isCheckpoint = isCheckpoint
1312
    }
1313
}
1314

            
1315
struct BatteryChargeCurve {
1316
    private let points: [(percent: Double, energyWh: Double)]
1317

            
1318
    init?(typicalCurvePoints: [TypicalChargeCurvePoint]) {
1319
        let validPoints = typicalCurvePoints
1320
            .filter {
1321
                $0.averageEnergyWh.isFinite
1322
                    && $0.averageEnergyWh >= 0
1323
                    && $0.percentBin >= 0
1324
                    && $0.percentBin <= 100
1325
            }
1326
            .sorted { lhs, rhs in
1327
                lhs.percentBin < rhs.percentBin
1328
            }
1329

            
1330
        var normalizedPoints: [(percent: Double, energyWh: Double)] = []
1331
        var runningMaximumEnergyWh = 0.0
1332

            
1333
        for point in validPoints {
1334
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
1335
            normalizedPoints.append(
1336
                (percent: Double(point.percentBin), energyWh: runningMaximumEnergyWh)
1337
            )
1338
        }
1339

            
1340
        guard normalizedPoints.count >= 2 else {
1341
            return nil
1342
        }
1343

            
1344
        self.points = normalizedPoints
1345
    }
1346

            
1347
    func energyWh(forPercent percent: Double) -> Double? {
1348
        interpolatedValue(
1349
            lookup: min(max(percent, 0), 100),
1350
            key: { $0.percent },
1351
            value: { $0.energyWh }
1352
        )
1353
    }
1354

            
1355
    func percent(forEnergyWh energyWh: Double) -> Double? {
1356
        interpolatedValue(
1357
            lookup: max(energyWh, 0),
1358
            key: { $0.energyWh },
1359
            value: { $0.percent }
1360
        )
1361
    }
1362

            
1363
    private func interpolatedValue(
1364
        lookup: Double,
1365
        key: ((percent: Double, energyWh: Double)) -> Double,
1366
        value: ((percent: Double, energyWh: Double)) -> Double
1367
    ) -> Double? {
1368
        guard let first = points.first, let last = points.last else {
1369
            return nil
1370
        }
1371

            
1372
        let firstKey = key(first)
1373
        let lastKey = key(last)
1374
        guard lookup >= firstKey, lookup <= lastKey else {
1375
            return nil
1376
        }
1377

            
1378
        if abs(lookup - firstKey) < 0.000_1 {
1379
            return value(first)
1380
        }
1381
        if abs(lookup - lastKey) < 0.000_1 {
1382
            return value(last)
1383
        }
1384

            
1385
        guard let upperIndex = points.firstIndex(where: { key($0) >= lookup }),
1386
              upperIndex > 0 else {
1387
            return nil
1388
        }
1389

            
1390
        let lower = points[upperIndex - 1]
1391
        let upper = points[upperIndex]
1392
        let lowerKey = key(lower)
1393
        let upperKey = key(upper)
1394
        let span = upperKey - lowerKey
1395
        guard span > 0.000_1 else {
1396
            return value(upper)
1397
        }
1398

            
1399
        let progress = (lookup - lowerKey) / span
1400
        return value(lower) + ((value(upper) - value(lower)) * progress)
1401
    }
1402
}
1403

            
Bogdan Timofte authored a month ago
1404
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
1405
    let timestamp: Date
1406
    let powerWatts: Double
1407
    let currentAmps: Double
1408
    let voltageVolts: Double
1409

            
1410
    var id: TimeInterval {
1411
        timestamp.timeIntervalSince1970
1412
    }
1413
}
1414

            
Bogdan Timofte authored a month ago
1415
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
Bogdan Timofte authored a month ago
1416
    let index: Int
1417
    let lowerBoundWatts: Double
1418
    let upperBoundWatts: Double
1419
    let count: Int
1420
    let relativeFrequency: Double
1421

            
1422
    var id: Int { index }
1423
}
1424

            
Bogdan Timofte authored a month ago
1425
enum HistogramResolution: Int, CaseIterable, Identifiable {
1426
    case x1 = 1
1427
    case x2 = 2
1428
    case x4 = 4
1429

            
1430
    var id: Int { rawValue }
1431

            
1432
    var label: String {
1433
        switch self {
1434
        case .x1: return "1×"
1435
        case .x2: return "2×"
1436
        case .x4: return "4×"
1437
        }
1438
    }
1439
}
1440

            
Bogdan Timofte authored a month ago
1441
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
1442
    let sampleCount: Int
1443
    let observedDuration: TimeInterval
1444
    let averagePowerWatts: Double
1445
    let recentAveragePowerWatts: Double
1446
    let medianPowerWatts: Double
1447
    let minimumPowerWatts: Double
1448
    let maximumPowerWatts: Double
1449
    let standardDeviationPowerWatts: Double
1450
    let coefficientOfVariation: Double
1451
    let averageCurrentAmps: Double
1452
    let averageVoltageVolts: Double
1453
    let stabilityDeltaWatts: Double
1454
    let stabilityToleranceWatts: Double
1455
    let histogram: [ChargerStandbyPowerDistributionBin]
1456

            
1457
    var projectedDailyEnergyWh: Double {
1458
        averagePowerWatts * 24
1459
    }
1460

            
1461
    var projectedWeeklyEnergyWh: Double {
1462
        averagePowerWatts * 24 * 7
1463
    }
1464

            
1465
    var projectedMonthlyEnergyWh: Double {
1466
        averagePowerWatts * 24 * 30
1467
    }
1468

            
1469
    var projectedYearlyEnergyWh: Double {
1470
        averagePowerWatts * 24 * 365
1471
    }
1472

            
1473
    var stabilityDeltaMilliwatts: Double {
1474
        stabilityDeltaWatts * 1000
1475
    }
1476

            
1477
    var isStable: Bool {
1478
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
1479
        && stabilityDeltaWatts <= stabilityToleranceWatts
1480
    }
1481
}
1482

            
1483
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
1484
    let id: UUID
1485
    let chargerID: UUID
1486
    let meterMACAddress: String
1487
    let meterName: String?
1488
    let meterModel: String?
1489
    let startedAt: Date
1490
    let endedAt: Date
1491
    let sampleCount: Int
1492
    let stabilizedAt: Date?
1493
    let averagePowerWatts: Double
1494
    let recentAveragePowerWatts: Double
1495
    let medianPowerWatts: Double
1496
    let minimumPowerWatts: Double
1497
    let maximumPowerWatts: Double
1498
    let standardDeviationPowerWatts: Double
1499
    let coefficientOfVariation: Double
1500
    let averageCurrentAmps: Double
1501
    let averageVoltageVolts: Double
1502
    let stabilityDeltaWatts: Double
1503
    let stabilityToleranceWatts: Double
Bogdan Timofte authored a month ago
1504
    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
1505
    let storedHistogram: [ChargerStandbyPowerDistributionBin]
1506

            
1507
    // MARK: - Codable (with migration from legacy powerSamplesWatts)
1508

            
1509
    private enum CodingKeys: String, CodingKey {
1510
        case id, chargerID, meterMACAddress, meterName, meterModel
1511
        case startedAt, endedAt, sampleCount, stabilizedAt
1512
        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
1513
        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
1514
        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
1515
        case stabilityDeltaWatts, stabilityToleranceWatts
1516
        case storedHistogram
1517
        case powerSamplesWatts // legacy – decode only
1518
    }
1519

            
1520
    init(
1521
        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
1522
        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
1523
        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
1524
        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
1525
        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
1526
        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
1527
        storedHistogram: [ChargerStandbyPowerDistributionBin]
1528
    ) {
1529
        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
1530
        self.meterName = meterName; self.meterModel = meterModel
1531
        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
1532
        self.stabilizedAt = stabilizedAt
1533
        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
1534
        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
1535
        self.maximumPowerWatts = maximumPowerWatts
1536
        self.standardDeviationPowerWatts = standardDeviationPowerWatts
1537
        self.coefficientOfVariation = coefficientOfVariation
1538
        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
1539
        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
1540
        self.storedHistogram = storedHistogram
1541
    }
1542

            
1543
    init(from decoder: Decoder) throws {
1544
        let c = try decoder.container(keyedBy: CodingKeys.self)
1545
        id = try c.decode(UUID.self, forKey: .id)
1546
        chargerID = try c.decode(UUID.self, forKey: .chargerID)
1547
        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
1548
        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
1549
        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
1550
        startedAt = try c.decode(Date.self, forKey: .startedAt)
1551
        endedAt = try c.decode(Date.self, forKey: .endedAt)
1552
        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
1553
        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
1554
        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
1555
        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
1556
        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
1557
        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
1558
        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
1559
        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
1560
        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
1561
        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
1562
        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
1563
        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
1564
        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)
1565

            
1566
        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
1567
        if let decodedBins, !decodedBins.isEmpty {
1568
            storedHistogram = decodedBins
1569
        } else {
1570
            // Migrate from legacy raw samples format
1571
            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
1572
            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
1573
            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
1574
                for: samples,
1575
                preferredBinCount: base * HistogramResolution.x4.rawValue
1576
            )
1577
        }
Bogdan Timofte authored a month ago
1578
    }
1579

            
Bogdan Timofte authored a month ago
1580
    func encode(to encoder: Encoder) throws {
1581
        var c = encoder.container(keyedBy: CodingKeys.self)
1582
        try c.encode(id, forKey: .id)
1583
        try c.encode(chargerID, forKey: .chargerID)
1584
        try c.encode(meterMACAddress, forKey: .meterMACAddress)
1585
        try c.encodeIfPresent(meterName, forKey: .meterName)
1586
        try c.encodeIfPresent(meterModel, forKey: .meterModel)
1587
        try c.encode(startedAt, forKey: .startedAt)
1588
        try c.encode(endedAt, forKey: .endedAt)
1589
        try c.encode(sampleCount, forKey: .sampleCount)
1590
        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
1591
        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
1592
        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
1593
        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
1594
        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
1595
        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
1596
        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
1597
        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
1598
        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
1599
        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
1600
        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
1601
        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
1602
        try c.encode(storedHistogram, forKey: .storedHistogram)
1603
    }
1604

            
1605
    // MARK: - Computed
1606

            
1607
    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
1608
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
1609
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
1610
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
1611
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
1612
    var isStable: Bool { stabilizedAt != nil }
1613

            
1614
    /// Returns the histogram downsampled to the requested resolution.
1615
    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
1616
    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
1617
        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
1618
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
Bogdan Timofte authored a month ago
1619
    }
1620
}
1621

            
1622
enum ChargerStandbyPowerMeasurementAnalyzer {
1623
    static let minimumStableSampleCount = 45
Bogdan Timofte authored a month ago
1624
    static let recentSampleWindow = 40
1625
    static let minimumStabilityToleranceWatts = 0.010
1626
    static let relativeStabilityTolerance = 0.05
Bogdan Timofte authored a month ago
1627

            
1628
    static func statistics(
1629
        from samples: [ChargerStandbyPowerSample],
1630
        startedAt: Date,
1631
        referenceDate: Date = Date()
1632
    ) -> ChargerStandbyPowerMeasurementStatistics? {
1633
        guard !samples.isEmpty else {
1634
            return nil
1635
        }
1636

            
1637
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
1638
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
1639
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
1640

            
1641
        guard powerValues.isEmpty == false else {
1642
            return nil
1643
        }
1644

            
1645
        let averagePower = mean(powerValues)
1646
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
1647
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
1648
        let stabilityDelta = abs(averagePower - recentAveragePower)
1649
        let stabilityTolerance = max(
1650
            minimumStabilityToleranceWatts,
1651
            abs(averagePower) * relativeStabilityTolerance
1652
        )
1653

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

            
Bogdan Timofte authored a month ago
1657
        return ChargerStandbyPowerMeasurementStatistics(
1658
            sampleCount: powerValues.count,
1659
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
1660
            averagePowerWatts: averagePower,
1661
            recentAveragePowerWatts: recentAveragePower,
1662
            medianPowerWatts: median(powerValues),
1663
            minimumPowerWatts: powerValues.min() ?? 0,
1664
            maximumPowerWatts: powerValues.max() ?? 0,
1665
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
1666
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
1667
            averageCurrentAmps: mean(currentValues),
1668
            averageVoltageVolts: mean(voltageValues),
1669
            stabilityDeltaWatts: stabilityDelta,
1670
            stabilityToleranceWatts: stabilityTolerance,
Bogdan Timofte authored a month ago
1671
            histogram: liveHistogram
Bogdan Timofte authored a month ago
1672
        )
1673
    }
1674

            
1675
    static func measurementSummary(
1676
        chargerID: UUID,
1677
        meterMACAddress: String,
1678
        meterName: String?,
1679
        meterModel: String?,
1680
        startedAt: Date,
1681
        endedAt: Date,
1682
        samples: [ChargerStandbyPowerSample],
1683
        stabilizedAt: Date?
1684
    ) -> ChargerStandbyPowerMeasurementSummary? {
1685
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
1686
            return nil
1687
        }
1688

            
1689
        return ChargerStandbyPowerMeasurementSummary(
1690
            id: UUID(),
1691
            chargerID: chargerID,
1692
            meterMACAddress: meterMACAddress,
1693
            meterName: meterName,
1694
            meterModel: meterModel,
1695
            startedAt: startedAt,
1696
            endedAt: endedAt,
1697
            sampleCount: statistics.sampleCount,
1698
            stabilizedAt: stabilizedAt,
1699
            averagePowerWatts: statistics.averagePowerWatts,
1700
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
1701
            medianPowerWatts: statistics.medianPowerWatts,
1702
            minimumPowerWatts: statistics.minimumPowerWatts,
1703
            maximumPowerWatts: statistics.maximumPowerWatts,
1704
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
1705
            coefficientOfVariation: statistics.coefficientOfVariation,
1706
            averageCurrentAmps: statistics.averageCurrentAmps,
1707
            averageVoltageVolts: statistics.averageVoltageVolts,
1708
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
1709
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
Bogdan Timofte authored a month ago
1710
            storedHistogram: statistics.histogram
Bogdan Timofte authored a month ago
1711
        )
1712
    }
1713

            
Bogdan Timofte authored a month ago
1714
    /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1.
1715
    static func downsample(
1716
        _ bins: [ChargerStandbyPowerDistributionBin],
1717
        factor: Int
1718
    ) -> [ChargerStandbyPowerDistributionBin] {
1719
        guard factor > 1, !bins.isEmpty else { return bins }
1720
        let totalCount = bins.reduce(0) { $0 + $1.count }
1721
        var result: [ChargerStandbyPowerDistributionBin] = []
1722
        var inputIndex = 0
1723
        var outputIndex = 0
1724
        while inputIndex < bins.count {
1725
            let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)])
1726
            let mergedCount = group.reduce(0) { $0 + $1.count }
1727
            let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0
1728
            result.append(ChargerStandbyPowerDistributionBin(
1729
                index: outputIndex,
1730
                lowerBoundWatts: group.first!.lowerBoundWatts,
1731
                upperBoundWatts: group.last!.upperBoundWatts,
1732
                count: mergedCount,
1733
                relativeFrequency: relFreq
1734
            ))
1735
            inputIndex += factor
1736
            outputIndex += 1
1737
        }
1738
        return result
1739
    }
1740

            
Bogdan Timofte authored a month ago
1741
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
1742
        let finiteValues = values.filter(\.isFinite)
1743
        guard finiteValues.isEmpty == false else {
1744
            return []
1745
        }
1746

            
1747
        let minimum = finiteValues.min() ?? 0
1748
        let maximum = finiteValues.max() ?? 0
1749
        let spread = maximum - minimum
1750
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
1751

            
1752
        guard spread > 0 else {
1753
            return [
1754
                ChargerStandbyPowerDistributionBin(
1755
                    index: 0,
1756
                    lowerBoundWatts: minimum,
1757
                    upperBoundWatts: maximum,
1758
                    count: finiteValues.count,
1759
                    relativeFrequency: 1
1760
                )
1761
            ]
1762
        }
1763

            
1764
        let safeBinCount = max(1, binCount)
1765
        let binWidth = spread / Double(safeBinCount)
1766
        var counts = Array(repeating: 0, count: safeBinCount)
1767

            
1768
        for value in finiteValues {
1769
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
1770
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
1771
            counts[safeIndex] += 1
1772
        }
1773

            
1774
        return counts.enumerated().map { index, count in
1775
            let lowerBound = minimum + (Double(index) * binWidth)
1776
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
1777

            
1778
            return ChargerStandbyPowerDistributionBin(
1779
                index: index,
1780
                lowerBoundWatts: lowerBound,
1781
                upperBoundWatts: upperBound,
1782
                count: count,
1783
                relativeFrequency: Double(count) / Double(finiteValues.count)
1784
            )
1785
        }
1786
    }
1787

            
1788
    private static func mean(_ values: [Double]) -> Double {
1789
        guard values.isEmpty == false else {
1790
            return 0
1791
        }
1792
        return values.reduce(0, +) / Double(values.count)
1793
    }
1794

            
1795
    private static func median(_ values: [Double]) -> Double {
1796
        guard values.isEmpty == false else {
1797
            return 0
1798
        }
1799

            
1800
        let sorted = values.sorted()
1801
        let middleIndex = sorted.count / 2
1802

            
1803
        if sorted.count.isMultiple(of: 2) {
1804
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
1805
        }
1806

            
1807
        return sorted[middleIndex]
1808
    }
1809

            
1810
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
1811
        guard values.count > 1 else {
1812
            return 0
1813
        }
1814

            
1815
        let variance = values.reduce(0) { partialResult, value in
1816
            let delta = value - mean
1817
            return partialResult + (delta * delta)
1818
        } / Double(values.count)
1819

            
1820
        return variance.squareRoot()
1821
    }
1822

            
1823
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
1824
        guard abs(mean) > 0.000_001 else {
1825
            return 0
1826
        }
1827

            
1828
        return standardDeviation(values, mean: mean) / abs(mean)
1829
    }
1830
}
1831

            
Bogdan Timofte authored a month ago
1832
struct ChargedDeviceSummary: Identifiable, Hashable {
1833
    let id: UUID
1834
    let qrIdentifier: String
1835
    let name: String
1836
    let deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
1837
    let deviceTemplateID: String?
1838
    let templateDefinition: ChargedDeviceTemplateDefinition?
Bogdan Timofte authored a month ago
1839
    let profileID: String?
1840
    let hasInternalSubject: Bool
Bogdan Timofte authored a month ago
1841
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
1842
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
1843
    let supportsWiredCharging: Bool
1844
    let supportsWirelessCharging: Bool
Bogdan Timofte authored a month ago
1845
    let chargerType: ChargerType?
Bogdan Timofte authored a month ago
1846
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
1847
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
1848
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
1849
    let wirelessChargerEfficiencyFactor: Double?
1850
    let wiredChargeCompletionCurrentAmps: Double?
1851
    let wirelessChargeCompletionCurrentAmps: Double?
1852
    let chargerObservedVoltageSelections: [Double]
1853
    let chargerIdleCurrentAmps: Double?
1854
    let chargerEfficiencyFactor: Double?
1855
    let chargerMaximumPowerWatts: Double?
1856
    let notes: String?
1857
    let minimumCurrentAmps: Double?
1858
    let estimatedBatteryCapacityWh: Double?
1859
    let wiredMinimumCurrentAmps: Double?
1860
    let wirelessMinimumCurrentAmps: Double?
1861
    let wiredEstimatedBatteryCapacityWh: Double?
1862
    let wirelessEstimatedBatteryCapacityWh: Double?
1863
    let createdAt: Date
1864
    let updatedAt: Date
1865
    let sessions: [ChargeSessionSummary]
1866
    let capacityHistory: [CapacityTrendPoint]
1867
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
1868
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
1869

            
1870
    var isCharger: Bool {
1871
        deviceClass == .charger
1872
    }
1873

            
Bogdan Timofte authored a month ago
1874
    /// True when the device's active catalog profile is one of the case-style
1875
    /// profiles (AirPods case, charging case, …) — i.e. the editor exposes the
1876
    /// `hasInternalSubject` toggle and the detail UI should surface its state.
1877
    var supportsInternalSubject: Bool {
1878
        guard let profileID,
1879
              let profile = DeviceProfileCatalog.shared.profile(id: profileID) else {
1880
            return false
1881
        }
1882
        return profile.capHasInternalSubject
1883
    }
1884

            
Bogdan Timofte authored a month ago
1885
    var kind: ChargedDeviceKind {
1886
        deviceClass.kind
1887
    }
1888

            
1889
    var identityTitle: String {
Bogdan Timofte authored a month ago
1890
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1891
    }
1892

            
Bogdan Timofte authored a month ago
1893
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1894
        isCharger ? kind.symbolName : deviceClass.symbolName
1895
    }
1896

            
Bogdan Timofte authored a month ago
1897
    var identityIcon: ChargedDeviceTemplateIcon {
1898
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1899
    }
1900

            
1901
    var identitySymbolName: String {
1902
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1903
    }
1904

            
Bogdan Timofte authored a month ago
1905
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1906
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1907
    }
1908

            
1909
    var recentCompletedSessions: [ChargeSessionSummary] {
1910
        sessions.filter { $0.status == .completed }
1911
    }
1912

            
1913
    var sessionCount: Int {
1914
        sessions.count
1915
    }
1916

            
Bogdan Timofte authored a month ago
1917
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1918
        standbyPowerMeasurements.first
1919
    }
1920

            
Bogdan Timofte authored a month ago
1921
    var supportedChargingModes: [ChargingTransportMode] {
1922
        var modes: [ChargingTransportMode] = []
1923
        if supportsWiredCharging {
1924
            modes.append(.wired)
1925
        }
1926
        if supportsWirelessCharging {
1927
            modes.append(.wireless)
1928
        }
Bogdan Timofte authored a month ago
1929
        return modes
Bogdan Timofte authored a month ago
1930
    }
1931

            
Bogdan Timofte authored a month ago
1932
    var supportedChargingStateModes: [ChargingStateMode] {
1933
        chargingStateAvailability.supportedModes
1934
    }
1935

            
Bogdan Timofte authored a month ago
1936
    var hasMultipleChargingTransports: Bool {
1937
        supportedChargingModes.count > 1
1938
    }
1939

            
1940
    var hasMultipleChargingStateModes: Bool {
1941
        supportedChargingStateModes.count > 1
1942
    }
1943

            
1944
    var showsWirelessProfileDetails: Bool {
1945
        supportsWirelessCharging
1946
            && hasMultipleChargingTransports
1947
            && deviceClass != .watch
1948
    }
1949

            
1950
    var chargingSupportSummary: String {
1951
        switch (supportsWiredCharging, supportsWirelessCharging) {
1952
        case (true, true):
1953
            return "Supports wired and wireless charging."
1954
        case (true, false):
1955
            return "Supports wired charging only."
1956
        case (false, true):
1957
            return "Supports wireless charging only."
1958
        case (false, false):
1959
            return "No charging method configured."
1960
        }
1961
    }
1962

            
Bogdan Timofte authored a month ago
1963
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1964
        if let matchingSession = sessions.first(where: {
1965
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1966
        }) {
1967
            return matchingSession.chargingStateMode
1968
        }
1969
        return chargingStateAvailability.supportedModes.first ?? .on
1970
    }
1971

            
1972
    func sessionKind(
1973
        for chargingTransportMode: ChargingTransportMode,
1974
        chargingStateMode: ChargingStateMode? = nil
1975
    ) -> ChargeSessionKind {
1976
        ChargeSessionKind(
1977
            chargingTransportMode: chargingTransportMode,
1978
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1979
        )
1980
    }
1981

            
Bogdan Timofte authored a month ago
1982
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1983
        switch chargingTransportMode {
1984
        case .wired:
1985
            return wiredEstimatedBatteryCapacityWh
1986
        case .wireless:
1987
            return wirelessEstimatedBatteryCapacityWh
1988
        }
1989
    }
1990

            
1991
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1992
        switch chargingTransportMode {
1993
        case .wired:
1994
            return wiredMinimumCurrentAmps
1995
        case .wireless:
1996
            return wirelessMinimumCurrentAmps
1997
        }
1998
    }
1999

            
Bogdan Timofte authored a month ago
2000
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
2001
        hasMultipleChargingTransports
2002
            || supportedChargingModes.contains(chargingTransportMode) == false
2003
    }
2004

            
2005
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
2006
        hasMultipleChargingStateModes
2007
            || supportedChargingStateModes.contains(chargingStateMode) == false
2008
    }
2009

            
Bogdan Timofte authored a month ago
2010
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
2011
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
2012
            return explicitCurrent
2013
        }
2014

            
2015
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
2016
        case .wired:
2017
            return wiredChargeCompletionCurrentAmps
2018
        case .wireless:
2019
            return wirelessChargeCompletionCurrentAmps
2020
        }
2021
    }
2022

            
Bogdan Timofte authored a month ago
2023
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
2024
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
2025
            return learnedCurrent
2026
        }
2027

            
2028
        switch sessionKind.chargingTransportMode {
2029
        case .wired:
2030
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
2031
        case .wireless:
2032
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
2033
        }
2034
    }
2035

            
2036
    func resolvedCompletionCurrentAmps(
2037
        for chargingTransportMode: ChargingTransportMode,
2038
        chargingStateMode: ChargingStateMode? = nil
2039
    ) -> Double? {
2040
        let sessionKind = sessionKind(
2041
            for: chargingTransportMode,
2042
            chargingStateMode: chargingStateMode
2043
        )
2044

            
2045
        return configuredCompletionCurrentAmps(for: sessionKind)
2046
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
2047
            ?? minimumCurrentAmps(for: chargingTransportMode)
2048
            ?? minimumCurrentAmps
2049
    }
2050

            
Bogdan Timofte authored a month ago
2051
    func batteryLevelPrediction(
2052
        for session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
2053
        effectiveEnergyWhOverride: Double? = nil,
2054
        referenceTimestamp: Date? = nil
Bogdan Timofte authored a month ago
2055
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
2056
        let estimatedCapacityWh = session.capacityEstimateWh
2057
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
2058
            ?? estimatedBatteryCapacityWh
2059

            
Bogdan Timofte authored a month ago
2060
        let effectiveEnergyWh = effectiveEnergyWhOverride
2061
            ?? session.effectiveBatteryEnergyWh
2062
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
2063

            
Bogdan Timofte authored a month ago
2064
        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
Bogdan Timofte authored a month ago
2065
            var candidates: [Double] = []
2066

            
2067
            for lowerIndex in anchors.indices {
2068
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
2069
                    let lower = anchors[lowerIndex]
2070
                    let upper = anchors[upperIndex]
2071
                    let percentDelta = upper.percent - lower.percent
2072
                    let energyDelta = upper.energyWh - lower.energyWh
2073

            
2074
                    guard percentDelta >= 3, energyDelta > 0.01 else {
2075
                        continue
2076
                    }
2077

            
2078
                    let capacityWh = energyDelta / (percentDelta / 100)
2079
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
2080
                        continue
2081
                    }
2082

            
2083
                    candidates.append(capacityWh)
2084
                }
2085
            }
2086

            
2087
            return candidates
2088
        }
2089

            
Bogdan Timofte authored a month ago
2090
        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
Bogdan Timofte authored a month ago
2091
            let candidates = anchorCapacityCandidates(from: anchors)
2092
            guard !candidates.isEmpty else {
2093
                return nil
2094
            }
2095

            
2096
            let sortedCandidates = candidates.sorted()
2097
            return sortedCandidates[sortedCandidates.count / 2]
2098
        }
2099

            
Bogdan Timofte authored a month ago
2100
        var anchors: [BatteryLevelPredictionAnchor] = []
Bogdan Timofte authored a month ago
2101

            
Bogdan Timofte authored a month ago
2102
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
2103
            anchors.append(
Bogdan Timofte authored a month ago
2104
                BatteryLevelPredictionAnchor(
Bogdan Timofte authored a month ago
2105
                    percent: startBatteryPercent,
2106
                    energyWh: 0,
Bogdan Timofte authored a month ago
2107
                    timestamp: session.effectiveTrimStart,
Bogdan Timofte authored a month ago
2108
                    description: "session start",
2109
                    isCheckpoint: false
Bogdan Timofte authored a month ago
2110
                )
2111
            )
2112
        }
2113

            
2114
        anchors.append(
2115
            contentsOf: session.checkpoints
Bogdan Timofte authored a month ago
2116
                .filter { $0.batteryPercent >= 0 }
Bogdan Timofte authored a month ago
2117
                .map { checkpoint in
Bogdan Timofte authored a month ago
2118
                    BatteryLevelPredictionAnchor(
Bogdan Timofte authored a month ago
2119
                        percent: checkpoint.batteryPercent,
2120
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
2121
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
2122
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
2123
                        isCheckpoint: true
Bogdan Timofte authored a month ago
2124
                    )
2125
                }
2126
        )
2127

            
Bogdan Timofte authored a month ago
2128
        if session.startsFromFlatBattery {
2129
            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2130
                from: anchors,
2131
                estimatedCapacityWh: estimatedCapacityWh,
2132
                historicalReserveEnergyWh: estimatedFlatReserveEnergyWh(excluding: session.id)
2133
            ) {
2134
                anchors.append(
2135
                    BatteryLevelPredictionAnchor(
2136
                        percent: 0,
2137
                        energyWh: virtualZeroEnergyWh,
2138
                        timestamp: session.effectiveTrimStart,
2139
                        description: "estimated flat reserve",
2140
                        isCheckpoint: false
2141
                    )
2142
                )
2143
            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
2144
                      effectiveEnergyWh < firstCheckpoint.energyWh - 0.05 {
2145
                return nil
2146
            }
2147
        }
2148

            
Bogdan Timofte authored a month ago
2149
        let sortedAnchors = anchors.sorted { lhs, rhs in
2150
            if lhs.energyWh != rhs.energyWh {
2151
                return lhs.energyWh < rhs.energyWh
2152
            }
2153
            return lhs.timestamp < rhs.timestamp
2154
        }
2155

            
2156
        guard !sortedAnchors.isEmpty else {
Bogdan Timofte authored a month ago
2157
            return nil
2158
        }
2159

            
Bogdan Timofte authored a month ago
2160
        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
2161
        let inferredCapacityWh = estimatedCapacityWh
2162
            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
Bogdan Timofte authored a month ago
2163
        let fallbackBasis: BatteryLevelPredictionBasis = estimatedCapacityWh == nil
Bogdan Timofte authored a month ago
2164
            ? .checkpointEnergyMap
2165
            : .capacityEstimate
2166

            
2167
        let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
2168
        let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
2169
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
Bogdan Timofte authored a month ago
2170

            
2171
        let predictedPercent: Double
Bogdan Timofte authored a month ago
2172
        let basis: BatteryLevelPredictionBasis
Bogdan Timofte authored a month ago
2173
        if let lowerAnchor,
2174
           let upperAnchor,
2175
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
2176
            let interpolationProgress = min(
2177
                max(
2178
                    (effectiveEnergyWh - lowerAnchor.energyWh) /
2179
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
2180
                    0
2181
                ),
2182
                1
2183
            )
2184
            predictedPercent = min(
2185
                max(
2186
                    lowerAnchor.percent +
2187
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
2188
                    0
2189
                ),
2190
                100
2191
            )
Bogdan Timofte authored a month ago
2192
            basis = fallbackBasis
Bogdan Timofte authored a month ago
2193
        } else {
Bogdan Timofte authored a month ago
2194
            let chargeCurve = BatteryChargeCurve(typicalCurvePoints: typicalCurve)
2195
            let curveDeviationFactor = chargeCurve.flatMap {
2196
                BatteryLevelPredictionTuning.deviationFactor(
2197
                    anchors: sortedAnchors,
2198
                    chargeCurve: $0
2199
                )
2200
            }
2201
            let curvePredictedPercent = chargeCurve.flatMap {
2202
                BatteryLevelPredictionTuning.predictedPercent(
2203
                    anchorPercent: anchor.percent,
2204
                    anchorEnergyWh: anchor.energyWh,
2205
                    effectiveEnergyWh: effectiveEnergyWh,
2206
                    chargeCurve: $0,
2207
                    deviationFactor: curveDeviationFactor
2208
                )
Bogdan Timofte authored a month ago
2209
            }
2210

            
Bogdan Timofte authored a month ago
2211
            if let curvePredictedPercent {
2212
                predictedPercent = curvePredictedPercent
2213
                basis = .typicalChargeCurve
2214
            } else {
2215
                guard let inferredCapacityWh, inferredCapacityWh > 0 else {
2216
                    return nil
2217
                }
2218

            
2219
                predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
2220
                    anchorPercent: anchor.percent,
2221
                    anchorEnergyWh: anchor.energyWh,
2222
                    anchorTimestamp: anchor.timestamp,
2223
                    anchorIsCheckpoint: anchor.isCheckpoint,
2224
                    effectiveEnergyWh: effectiveEnergyWh,
2225
                    referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
2226
                    estimatedCapacityWh: inferredCapacityWh
2227
                )
2228
                basis = fallbackBasis
2229
            }
Bogdan Timofte authored a month ago
2230
        }
Bogdan Timofte authored a month ago
2231

            
2232
        return BatteryLevelPrediction(
2233
            predictedPercent: predictedPercent,
Bogdan Timofte authored a month ago
2234
            estimatedCapacityWh: inferredCapacityWh,
2235
            basis: basis,
Bogdan Timofte authored a month ago
2236
            anchorPercent: anchor.percent,
2237
            anchorEnergyWh: anchor.energyWh,
2238
            anchorDescription: anchor.description
2239
        )
2240
    }
Bogdan Timofte authored a month ago
2241

            
Bogdan Timofte authored a month ago
2242
    private func estimatedFlatReserveEnergyWh(excluding excludedSessionID: UUID? = nil) -> Double? {
2243
        let reserves = sessions.compactMap { session -> Double? in
2244
            guard session.id != excludedSessionID,
2245
                  session.status == .completed,
2246
                  session.startsFromFlatBattery else {
2247
                return nil
2248
            }
2249

            
2250
            let anchors = session.checkpoints.map {
2251
                BatteryLevelPredictionAnchor(
2252
                    percent: $0.batteryPercent,
2253
                    energyWh: $0.measuredEnergyWh,
2254
                    timestamp: $0.timestamp,
2255
                    description: $0.flag.anchorDescription,
2256
                    isCheckpoint: true
2257
                )
2258
            }
2259

            
2260
            return BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2261
                from: anchors,
2262
                estimatedCapacityWh: session.capacityEstimateWh
2263
            )
2264
        }
2265

            
2266
        guard !reserves.isEmpty else {
2267
            return nil
2268
        }
2269

            
2270
        let sortedReserves = reserves.sorted()
2271
        return sortedReserves[sortedReserves.count / 2]
2272
    }
2273

            
Bogdan Timofte authored a month ago
2274
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
2275
        ChargedDeviceSummary(
2276
            id: id,
2277
            qrIdentifier: qrIdentifier,
2278
            name: name,
2279
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
2280
            deviceTemplateID: deviceTemplateID,
2281
            templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
2282
            profileID: profileID,
2283
            hasInternalSubject: hasInternalSubject,
Bogdan Timofte authored a month ago
2284
            supportsChargingWhileOff: supportsChargingWhileOff,
2285
            chargingStateAvailability: chargingStateAvailability,
2286
            supportsWiredCharging: supportsWiredCharging,
2287
            supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
2288
            chargerType: chargerType,
Bogdan Timofte authored a month ago
2289
            wirelessChargingProfile: wirelessChargingProfile,
2290
            configuredCompletionCurrents: configuredCompletionCurrents,
2291
            learnedCompletionCurrents: learnedCompletionCurrents,
2292
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
2293
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
2294
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
2295
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
2296
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
2297
            chargerEfficiencyFactor: chargerEfficiencyFactor,
2298
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
2299
            notes: notes,
2300
            minimumCurrentAmps: minimumCurrentAmps,
2301
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
2302
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
2303
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
2304
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
2305
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
2306
            createdAt: createdAt,
2307
            updatedAt: updatedAt,
2308
            sessions: sessions,
2309
            capacityHistory: capacityHistory,
2310
            typicalCurve: typicalCurve,
2311
            standbyPowerMeasurements: measurements
2312
        )
2313
    }
Bogdan Timofte authored a month ago
2314
}
2315

            
2316
struct ChargingMonitorSnapshot {
2317
    let meterMACAddress: String
2318
    let meterName: String
2319
    let meterModel: String
2320
    let observedAt: Date
2321
    let voltageVolts: Double
2322
    let currentAmps: Double
2323
    let powerWatts: Double
2324
    let selectedDataGroup: UInt8?
2325
    let meterChargeCounterAh: Double?
2326
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
2327
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
2328
    let fallbackStopThresholdAmps: Double
2329
}
Bogdan Timofte authored a month ago
2330

            
2331
// MARK: - Powerbank
2332

            
2333
enum BatteryLevelReporting: String, CaseIterable, Identifiable, Codable {
2334
    case percent
2335
    case bars
2336
    case fullOnly
2337
    case none
2338

            
2339
    var id: String { rawValue }
2340

            
2341
    var title: String {
2342
        switch self {
2343
        case .percent: return "Percent"
2344
        case .bars: return "Bars"
2345
        case .fullOnly: return "Full only"
2346
        case .none: return "Not reported"
2347
        }
2348
    }
2349

            
2350
    var description: String {
2351
        switch self {
2352
        case .percent:
2353
            return "The powerbank reports battery level as 0–100%."
2354
        case .bars:
2355
            return "The powerbank reports battery level as discrete bars (e.g. 4 of 4)."
2356
        case .fullOnly:
2357
            return "The powerbank has a single LED that lights only when charging completes — there is no signal for any partial level."
2358
        case .none:
2359
            return "The powerbank does not report a battery level."
2360
        }
2361
    }
2362

            
2363
    var allowsCheckpoints: Bool {
2364
        self != .none
2365
    }
2366
}
2367

            
2368
enum CheckpointSubject: String, Codable, Hashable {
2369
    case chargedDevice
2370
    case powerbank
2371

            
2372
    var title: String {
2373
        switch self {
2374
        case .chargedDevice: return "Device"
2375
        case .powerbank: return "Powerbank"
2376
        }
2377
    }
2378
}
2379

            
2380
enum ChargeSessionSource: Hashable {
2381
    case none
2382
    case charger(UUID)
2383
    case powerbank(UUID)
2384

            
2385
    var chargerID: UUID? {
2386
        if case .charger(let id) = self { return id }
2387
        return nil
2388
    }
2389

            
2390
    var powerbankID: UUID? {
2391
        if case .powerbank(let id) = self { return id }
2392
        return nil
2393
    }
2394

            
2395
    var isTracked: Bool {
2396
        if case .none = self { return false }
2397
        return true
2398
    }
2399
}
2400

            
2401
struct PowerbankSummary: Identifiable, Hashable {
2402
    let id: UUID
2403
    let qrIdentifier: String
2404
    let name: String
2405
    let deviceTemplateID: String?
2406
    let templateDefinition: ChargedDeviceTemplateDefinition?
2407
    let batteryLevelReporting: BatteryLevelReporting
2408
    let batteryBarsCount: Int
2409
    let estimatedBatteryCapacityWh: Double?
2410
    let apparentCapacityWh: Double?
2411
    let configuredCompletionCurrentAmps: Double?
2412
    let learnedCompletionCurrentAmps: Double?
2413
    let minimumCurrentAmps: Double?
2414
    let sourceObservedVoltageSelections: [Double]
2415
    let sourceVoltageMaxCurrents: [Double: Double]
2416
    let sourceIdleCurrentAmps: Double?
2417
    let sourceMaximumPowerWatts: Double?
2418
    let sourceEfficiencyFactor: Double?
2419
    let notes: String?
2420
    let createdAt: Date
2421
    let updatedAt: Date
2422
    let sessionsAsSubject: [ChargeSessionSummary]
2423
    let sessionsAsSource: [ChargeSessionSummary]
2424

            
2425
    var fallbackIdentitySymbolName: String { "battery.100.bolt" }
2426

            
2427
    var identityIcon: ChargedDeviceTemplateIcon {
2428
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
2429
    }
2430

            
2431
    var identitySymbolName: String {
2432
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
2433
    }
2434

            
2435
    var identityTitle: String {
2436
        templateDefinition?.name ?? "Powerbank"
2437
    }
2438

            
2439
    /// Open session in which this powerbank participates as either subject or source.
2440
    var openSession: ChargeSessionSummary? {
2441
        sessionsAsSubject.first(where: \.isOpen)
2442
            ?? sessionsAsSource.first(where: \.isOpen)
2443
    }
2444

            
2445
    var totalDeliveredEnergyWh: Double {
2446
        sessionsAsSource.reduce(0) { $0 + $1.measuredEnergyWh }
2447
    }
2448

            
2449
    var totalReceivedEnergyWh: Double {
2450
        sessionsAsSubject.reduce(0) { $0 + $1.measuredEnergyWh }
2451
    }
2452
}