USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
1337 lines | 40.754kb
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

            
117
    func normalizedChargingSupport(
118
        supportsWiredCharging: Bool,
119
        supportsWirelessCharging: Bool
120
    ) -> (wired: Bool, wireless: Bool) {
121
        enforcedChargingSupport ?? (wired: supportsWiredCharging, wireless: supportsWirelessCharging)
122
    }
123

            
124
    func normalizedChargingStateAvailability(
125
        _ chargingStateAvailability: ChargingStateAvailability
126
    ) -> ChargingStateAvailability {
127
        enforcedChargingStateAvailability ?? chargingStateAvailability
128
    }
Bogdan Timofte authored a month ago
129
}
130

            
131
enum ChargeSessionStatus: String {
132
    case active
Bogdan Timofte authored a month ago
133
    case paused
Bogdan Timofte authored a month ago
134
    case completed
135
    case abandoned
136

            
137
    var title: String {
Bogdan Timofte authored a month ago
138
        switch self {
139
        case .active:
140
            return "Active"
141
        case .paused:
142
            return "Paused"
143
        case .completed:
144
            return "Completed"
145
        case .abandoned:
146
            return "Abandoned"
147
        }
148
    }
149

            
150
    var isOpen: Bool {
151
        switch self {
152
        case .active, .paused:
153
            return true
154
        case .completed, .abandoned:
155
            return false
156
        }
Bogdan Timofte authored a month ago
157
    }
158
}
159

            
160
enum ChargeSessionSourceMode: String {
161
    case live
162
    case offline
163
    case blended
164

            
165
    var title: String {
166
        switch self {
167
        case .live:
168
            return "Live"
169
        case .offline:
170
            return "Offline Counters"
171
        case .blended:
172
            return "Blended"
173
        }
174
    }
175
}
176

            
Bogdan Timofte authored a month ago
177
enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
178
    case wired
179
    case wireless
180

            
181
    var id: String { rawValue }
182

            
183
    var title: String {
184
        switch self {
185
        case .wired:
186
            return "Wired"
187
        case .wireless:
188
            return "Wireless"
189
        }
190
    }
191

            
192
    var symbolName: String {
193
        switch self {
194
        case .wired:
195
            return "cable.connector"
196
        case .wireless:
197
            return "dot.radiowaves.left.and.right"
198
        }
199
    }
200
}
201

            
Bogdan Timofte authored a month ago
202
enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
203
    case on
204
    case off
205

            
206
    var id: String { rawValue }
207

            
208
    var title: String {
209
        switch self {
210
        case .on:
211
            return "On"
212
        case .off:
213
            return "Off"
214
        }
215
    }
216

            
217
    var description: String {
218
        switch self {
219
        case .on:
220
            return "Device stays powered on while charging."
221
        case .off:
222
            return "Device is powered off while charging."
223
        }
224
    }
225
}
226

            
227
enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
228
    case onOnly
229
    case onOrOff
230
    case offOnly
231

            
232
    var id: String { rawValue }
233

            
234
    var title: String {
235
        switch self {
236
        case .onOnly:
237
            return "On Only"
238
        case .onOrOff:
239
            return "On or Off"
240
        case .offOnly:
241
            return "Off Only"
242
        }
243
    }
244

            
245
    var description: String {
246
        switch self {
247
        case .onOnly:
248
            return "The device can be recorded only while it is powered on."
249
        case .onOrOff:
250
            return "The session must specify whether the device is on or off."
251
        case .offOnly:
252
            return "The device can be recorded only while it is powered off."
253
        }
254
    }
255

            
256
    var supportedModes: [ChargingStateMode] {
257
        switch self {
258
        case .onOnly:
259
            return [.on]
260
        case .onOrOff:
261
            return [.on, .off]
262
        case .offOnly:
263
            return [.off]
264
        }
265
    }
266

            
267
    var supportsMultipleModes: Bool {
268
        supportedModes.count > 1
269
    }
270

            
271
    var supportsChargingWhileOff: Bool {
272
        self != .onOnly
273
    }
274

            
275
    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
276
        supportsChargingWhileOff ? .onOrOff : .onOnly
277
    }
278
}
279

            
280
enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
281
    case wiredOn
282
    case wiredOff
283
    case wirelessOn
284
    case wirelessOff
285

            
286
    var id: String { rawValue }
287

            
288
    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
289
        switch (chargingTransportMode, chargingStateMode) {
290
        case (.wired, .on):
291
            self = .wiredOn
292
        case (.wired, .off):
293
            self = .wiredOff
294
        case (.wireless, .on):
295
            self = .wirelessOn
296
        case (.wireless, .off):
297
            self = .wirelessOff
298
        }
299
    }
300

            
301
    var chargingTransportMode: ChargingTransportMode {
302
        switch self {
303
        case .wiredOn, .wiredOff:
304
            return .wired
305
        case .wirelessOn, .wirelessOff:
306
            return .wireless
307
        }
308
    }
309

            
310
    var chargingStateMode: ChargingStateMode {
311
        switch self {
312
        case .wiredOn, .wirelessOn:
313
            return .on
314
        case .wiredOff, .wirelessOff:
315
            return .off
316
        }
317
    }
318

            
319
    var title: String {
320
        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
321
    }
322

            
323
    var shortTitle: String {
324
        "\(chargingTransportMode.title) \(chargingStateMode.title)"
325
    }
326
}
327

            
Bogdan Timofte authored a month ago
328
enum WirelessChargingProfile: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
329
    case magsafe
330
    case genericQi
331

            
332
    var id: String { rawValue }
333

            
334
    var title: String {
335
        switch self {
336
        case .magsafe:
337
            return "MagSafe"
338
        case .genericQi:
339
            return "Generic Qi"
340
        }
341
    }
342

            
343
    var description: String {
344
        switch self {
345
        case .magsafe:
346
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
347
        case .genericQi:
348
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
349
        }
350
    }
351
}
352

            
Bogdan Timofte authored a month ago
353
enum ChargedDeviceTemplateIconSource: String, Codable {
354
    case systemSymbol
355
    case asset
356
}
357

            
358
struct ChargedDeviceTemplateIcon: Hashable, Codable {
359
    let type: ChargedDeviceTemplateIconSource
360
    let name: String
361
    let fallbackSystemName: String?
362

            
363
    static func systemSymbol(
364
        _ name: String,
365
        fallbackSystemName: String? = nil
366
    ) -> ChargedDeviceTemplateIcon {
367
        ChargedDeviceTemplateIcon(
368
            type: .systemSymbol,
369
            name: name,
370
            fallbackSystemName: fallbackSystemName
371
        )
372
    }
373

            
374
    func resolvedSystemSymbolName(fallbackSystemName: String) -> String {
375
        switch type {
376
        case .systemSymbol:
377
            return name
378
        case .asset:
379
            return self.fallbackSystemName ?? fallbackSystemName
380
        }
381
    }
382
}
383

            
384
struct ChargedDeviceTemplateDefinition: Identifiable, Hashable, Codable {
385
    let id: String
386
    let name: String
387
    let group: String
388
    let kind: ChargedDeviceKind
389
    let deviceClass: ChargedDeviceClass
390
    let icon: ChargedDeviceTemplateIcon
391
    let chargingStateAvailability: ChargingStateAvailability
392
    let supportsWiredCharging: Bool
393
    let supportsWirelessCharging: Bool
394
    let wirelessChargingProfile: WirelessChargingProfile
395
    let sortOrder: Int
396

            
397
    var chargingSupportSummary: String {
398
        switch (supportsWiredCharging, supportsWirelessCharging) {
399
        case (true, true):
400
            return "Wired + Wireless"
401
        case (true, false):
402
            return "Wired only"
403
        case (false, true):
404
            return "Wireless only"
405
        case (false, false):
406
            return "No charging transport"
407
        }
408
    }
409

            
410
    var capabilitySummary: String {
411
        var components = [chargingStateAvailability.title, chargingSupportSummary]
412
        if supportsWirelessCharging {
413
            components.append(wirelessChargingProfile.title)
414
        }
415
        return components.joined(separator: " • ")
416
    }
417
}
418

            
419
private struct ChargedDeviceTemplateDocument: Codable {
420
    let templates: [ChargedDeviceTemplateDefinition]
421
}
422

            
423
struct ChargedDeviceTemplateCatalog {
424
    static let shared = ChargedDeviceTemplateCatalog()
425

            
426
    let templates: [ChargedDeviceTemplateDefinition]
427
    private let templatesByID: [String: ChargedDeviceTemplateDefinition]
428

            
429
    private init(bundle: Bundle = .main) {
430
        let loadedTemplates: [ChargedDeviceTemplateDefinition]
431

            
432
        if let resourceURL = bundle.url(forResource: "ChargedDeviceTemplates", withExtension: "json"),
433
           let data = try? Data(contentsOf: resourceURL),
434
           let document = try? JSONDecoder().decode(ChargedDeviceTemplateDocument.self, from: data) {
435
            loadedTemplates = document.templates
436
        } else {
437
            loadedTemplates = []
438
        }
439

            
440
        self.templates = loadedTemplates.sorted { lhs, rhs in
441
            if lhs.group != rhs.group {
442
                return lhs.group < rhs.group
443
            }
444
            if lhs.sortOrder != rhs.sortOrder {
445
                return lhs.sortOrder < rhs.sortOrder
446
            }
447
            return lhs.name < rhs.name
448
        }
449
        self.templatesByID = Dictionary(uniqueKeysWithValues: self.templates.map { ($0.id, $0) })
450
    }
451

            
452
    func template(id: String?) -> ChargedDeviceTemplateDefinition? {
453
        guard let id else {
454
            return nil
455
        }
456
        return templatesByID[id]
457
    }
458

            
459
    func templates(for kind: ChargedDeviceKind) -> [ChargedDeviceTemplateDefinition] {
460
        templates.filter { $0.kind == kind }
461
    }
462
}
463

            
Bogdan Timofte authored a month ago
464
struct ChargeCheckpointSummary: Identifiable, Hashable {
465
    let id: UUID
466
    let sessionID: UUID
467
    let chargedDeviceID: UUID
468
    let timestamp: Date
469
    let batteryPercent: Double
470
    let measuredEnergyWh: Double
471
    let measuredChargeAh: Double
472
    let currentAmps: Double
473
    let voltageVolts: Double?
474
    let label: String?
Bogdan Timofte authored a month ago
475

            
476
    var flag: ChargeCheckpointFlag {
477
        ChargeCheckpointFlag.fromStoredLabel(label)
478
    }
479
}
480

            
481
enum ChargeCheckpointFlag: String, CaseIterable {
482
    case initial
483
    case intermediate
484
    case final
485

            
486
    var title: String {
487
        switch self {
488
        case .initial:
489
            return "Initial"
490
        case .intermediate:
491
            return "Intermediate"
492
        case .final:
493
            return "Final"
494
        }
495
    }
496

            
497
    var anchorDescription: String {
498
        switch self {
499
        case .initial:
500
            return "initial checkpoint"
501
        case .intermediate:
502
            return "intermediate checkpoint"
503
        case .final:
504
            return "final checkpoint"
505
        }
506
    }
507

            
508
    static func fromStoredLabel(_ label: String?) -> ChargeCheckpointFlag {
509
        let normalized = label?
510
            .trimmingCharacters(in: .whitespacesAndNewlines)
511
            .lowercased()
512

            
513
        switch normalized {
514
        case "initial", "start":
515
            return .initial
516
        case "final", "end":
517
            return .final
518
        case "intermediate", nil, "":
519
            return .intermediate
520
        default:
521
            return .intermediate
522
        }
523
    }
Bogdan Timofte authored a month ago
524
}
525

            
526
struct ChargeSessionSampleSummary: Identifiable, Hashable {
527
    let sessionID: UUID
528
    let chargedDeviceID: UUID
529
    let bucketIndex: Int
530
    let timestamp: Date
531
    let averageCurrentAmps: Double
532
    let averageVoltageVolts: Double?
533
    let averagePowerWatts: Double
534
    let measuredEnergyWh: Double
535
    let measuredChargeAh: Double
536
    let sampleCount: Int
537

            
538
    var id: String {
539
        "\(sessionID.uuidString)-\(bucketIndex)"
540
    }
541
}
542

            
543
struct ChargeSessionSummary: Identifiable, Hashable {
544
    let id: UUID
545
    let chargedDeviceID: UUID
546
    let chargerID: UUID?
547
    let meterMACAddress: String?
548
    let meterName: String?
549
    let meterModel: String?
550
    let startedAt: Date
551
    let endedAt: Date?
552
    let lastObservedAt: Date
Bogdan Timofte authored a month ago
553
    let pausedAt: Date?
Bogdan Timofte authored a month ago
554
    let status: ChargeSessionStatus
555
    let sourceMode: ChargeSessionSourceMode
556
    let chargingTransportMode: ChargingTransportMode
Bogdan Timofte authored a month ago
557
    let chargingStateMode: ChargingStateMode
558
    let autoStopEnabled: Bool
Bogdan Timofte authored a month ago
559
    let measuredEnergyWh: Double
560
    let effectiveBatteryEnergyWh: Double?
561
    let measuredChargeAh: Double
Bogdan Timofte authored a month ago
562
    let meterEnergyBaselineWh: Double?
563
    let meterChargeBaselineAh: Double?
Bogdan Timofte authored a month ago
564
    let meterDurationBaselineSeconds: Double?
565
    let meterLastDurationSeconds: Double?
Bogdan Timofte authored a month ago
566
    let minimumObservedCurrentAmps: Double?
567
    let maximumObservedCurrentAmps: Double?
568
    let maximumObservedPowerWatts: Double?
569
    let maximumObservedVoltageVolts: Double?
570
    let selectedSourceVoltageVolts: Double?
571
    let completionCurrentAmps: Double?
572
    let stopThresholdAmps: Double
573
    let startBatteryPercent: Double?
574
    let endBatteryPercent: Double?
575
    let capacityEstimateWh: Double?
576
    let wirelessEfficiencyFactor: Double?
577
    let usesEstimatedWirelessEfficiency: Bool
578
    let shouldWarnAboutLowWirelessEfficiency: Bool
579
    let supportsChargingWhileOff: Bool
580
    let usedOfflineMeterCounters: Bool
581
    let targetBatteryPercent: Double?
582
    let targetBatteryAlertTriggeredAt: Date?
583
    let requiresCompletionConfirmation: Bool
584
    let completionConfirmationRequestedAt: Date?
585
    let completionContradictionPercent: Double?
586
    let selectedDataGroup: UInt8?
587
    let checkpoints: [ChargeCheckpointSummary]
588
    let aggregatedSamples: [ChargeSessionSampleSummary]
589

            
Bogdan Timofte authored a month ago
590
    var sessionKind: ChargeSessionKind {
591
        ChargeSessionKind(
592
            chargingTransportMode: chargingTransportMode,
593
            chargingStateMode: chargingStateMode
594
        )
595
    }
596

            
Bogdan Timofte authored a month ago
597
    var duration: TimeInterval {
598
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
599
    }
600

            
Bogdan Timofte authored a month ago
601
    var meterObservedDuration: TimeInterval? {
602
        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
603
            return nil
604
        }
605
        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
606
            return nil
607
        }
608
        return meterLastDurationSeconds - meterDurationBaselineSeconds
609
    }
610

            
611
    var effectiveDuration: TimeInterval {
612
        meterObservedDuration ?? duration
613
    }
614

            
Bogdan Timofte authored a month ago
615
    var effectiveOrMeasuredEnergyWh: Double {
616
        effectiveBatteryEnergyWh ?? measuredEnergyWh
617
    }
618

            
619
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
620
        guard let startBatteryPercent, let endBatteryPercent,
621
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
622
        return endBatteryPercent - startBatteryPercent
623
    }
Bogdan Timofte authored a month ago
624

            
625
    var canAutoStop: Bool {
626
        autoStopEnabled && stopThresholdAmps > 0
627
    }
628

            
629
    var isPaused: Bool {
630
        status == .paused
631
    }
632

            
633
    var isOpen: Bool {
634
        status.isOpen
635
    }
Bogdan Timofte authored a month ago
636
}
637

            
638
struct BatteryLevelPrediction: Hashable {
639
    let predictedPercent: Double
640
    let estimatedCapacityWh: Double
641
    let anchorPercent: Double
642
    let anchorEnergyWh: Double
643
    let anchorDescription: String
644
}
645

            
Bogdan Timofte authored a month ago
646
enum BatteryLevelPredictionTuning {
647
    static let checkpointSettleDuration: TimeInterval = 10 * 60
648

            
649
    static func predictedPercent(
650
        anchorPercent: Double,
651
        anchorEnergyWh: Double,
652
        anchorTimestamp: Date,
653
        anchorIsCheckpoint: Bool,
654
        effectiveEnergyWh: Double,
655
        referenceTimestamp: Date,
656
        estimatedCapacityWh: Double
657
    ) -> Double {
658
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
659
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
660
        let stabilizedGainPercent: Double
661

            
662
        if anchorIsCheckpoint {
663
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
664
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
665
            stabilizedGainPercent = rawGainPercent * settleProgress
666
        } else {
667
            stabilizedGainPercent = rawGainPercent
668
        }
669

            
670
        return min(
671
            100,
672
            max(
673
                0,
674
                anchorPercent + stabilizedGainPercent
675
            )
676
        )
677
    }
678
}
679

            
Bogdan Timofte authored a month ago
680
struct CapacityTrendPoint: Identifiable, Hashable {
681
    let sessionID: UUID
682
    let timestamp: Date
683
    let capacityWh: Double
684
    let chargingTransportMode: ChargingTransportMode
685

            
686
    var id: UUID { sessionID }
687
}
688

            
689
struct TypicalChargeCurvePoint: Identifiable, Hashable {
690
    let percentBin: Int
691
    let averageEnergyWh: Double
692
    let averageChargeAh: Double
693
    let sampleCount: Int
694

            
695
    var id: Int { percentBin }
696
}
697

            
Bogdan Timofte authored a month ago
698
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
699
    let timestamp: Date
700
    let powerWatts: Double
701
    let currentAmps: Double
702
    let voltageVolts: Double
703

            
704
    var id: TimeInterval {
705
        timestamp.timeIntervalSince1970
706
    }
707
}
708

            
709
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable {
710
    let index: Int
711
    let lowerBoundWatts: Double
712
    let upperBoundWatts: Double
713
    let count: Int
714
    let relativeFrequency: Double
715

            
716
    var id: Int { index }
717
}
718

            
719
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
720
    let sampleCount: Int
721
    let observedDuration: TimeInterval
722
    let averagePowerWatts: Double
723
    let recentAveragePowerWatts: Double
724
    let medianPowerWatts: Double
725
    let minimumPowerWatts: Double
726
    let maximumPowerWatts: Double
727
    let standardDeviationPowerWatts: Double
728
    let coefficientOfVariation: Double
729
    let averageCurrentAmps: Double
730
    let averageVoltageVolts: Double
731
    let stabilityDeltaWatts: Double
732
    let stabilityToleranceWatts: Double
733
    let histogram: [ChargerStandbyPowerDistributionBin]
734

            
735
    var projectedDailyEnergyWh: Double {
736
        averagePowerWatts * 24
737
    }
738

            
739
    var projectedWeeklyEnergyWh: Double {
740
        averagePowerWatts * 24 * 7
741
    }
742

            
743
    var projectedMonthlyEnergyWh: Double {
744
        averagePowerWatts * 24 * 30
745
    }
746

            
747
    var projectedYearlyEnergyWh: Double {
748
        averagePowerWatts * 24 * 365
749
    }
750

            
751
    var stabilityDeltaMilliwatts: Double {
752
        stabilityDeltaWatts * 1000
753
    }
754

            
755
    var isStable: Bool {
756
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
757
        && stabilityDeltaWatts <= stabilityToleranceWatts
758
    }
759
}
760

            
761
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
762
    let id: UUID
763
    let chargerID: UUID
764
    let meterMACAddress: String
765
    let meterName: String?
766
    let meterModel: String?
767
    let startedAt: Date
768
    let endedAt: Date
769
    let sampleCount: Int
770
    let stabilizedAt: Date?
771
    let averagePowerWatts: Double
772
    let recentAveragePowerWatts: Double
773
    let medianPowerWatts: Double
774
    let minimumPowerWatts: Double
775
    let maximumPowerWatts: Double
776
    let standardDeviationPowerWatts: Double
777
    let coefficientOfVariation: Double
778
    let averageCurrentAmps: Double
779
    let averageVoltageVolts: Double
780
    let stabilityDeltaWatts: Double
781
    let stabilityToleranceWatts: Double
782
    let powerSamplesWatts: [Double]
783

            
784
    var duration: TimeInterval {
785
        endedAt.timeIntervalSince(startedAt)
786
    }
787

            
788
    var projectedDailyEnergyWh: Double {
789
        averagePowerWatts * 24
790
    }
791

            
792
    var projectedWeeklyEnergyWh: Double {
793
        averagePowerWatts * 24 * 7
794
    }
795

            
796
    var projectedMonthlyEnergyWh: Double {
797
        averagePowerWatts * 24 * 30
798
    }
799

            
800
    var projectedYearlyEnergyWh: Double {
801
        averagePowerWatts * 24 * 365
802
    }
803

            
804
    var isStable: Bool {
805
        stabilizedAt != nil
806
    }
807

            
808
    var histogram: [ChargerStandbyPowerDistributionBin] {
809
        ChargerStandbyPowerMeasurementAnalyzer.histogram(for: powerSamplesWatts)
810
    }
811
}
812

            
813
enum ChargerStandbyPowerMeasurementAnalyzer {
814
    static let minimumStableSampleCount = 45
815
    static let recentSampleWindow = 20
816
    static let minimumStabilityToleranceWatts = 0.003
817
    static let relativeStabilityTolerance = 0.01
818

            
819
    static func statistics(
820
        from samples: [ChargerStandbyPowerSample],
821
        startedAt: Date,
822
        referenceDate: Date = Date()
823
    ) -> ChargerStandbyPowerMeasurementStatistics? {
824
        guard !samples.isEmpty else {
825
            return nil
826
        }
827

            
828
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
829
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
830
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
831

            
832
        guard powerValues.isEmpty == false else {
833
            return nil
834
        }
835

            
836
        let averagePower = mean(powerValues)
837
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
838
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
839
        let stabilityDelta = abs(averagePower - recentAveragePower)
840
        let stabilityTolerance = max(
841
            minimumStabilityToleranceWatts,
842
            abs(averagePower) * relativeStabilityTolerance
843
        )
844

            
845
        return ChargerStandbyPowerMeasurementStatistics(
846
            sampleCount: powerValues.count,
847
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
848
            averagePowerWatts: averagePower,
849
            recentAveragePowerWatts: recentAveragePower,
850
            medianPowerWatts: median(powerValues),
851
            minimumPowerWatts: powerValues.min() ?? 0,
852
            maximumPowerWatts: powerValues.max() ?? 0,
853
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
854
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
855
            averageCurrentAmps: mean(currentValues),
856
            averageVoltageVolts: mean(voltageValues),
857
            stabilityDeltaWatts: stabilityDelta,
858
            stabilityToleranceWatts: stabilityTolerance,
859
            histogram: histogram(for: powerValues)
860
        )
861
    }
862

            
863
    static func measurementSummary(
864
        chargerID: UUID,
865
        meterMACAddress: String,
866
        meterName: String?,
867
        meterModel: String?,
868
        startedAt: Date,
869
        endedAt: Date,
870
        samples: [ChargerStandbyPowerSample],
871
        stabilizedAt: Date?
872
    ) -> ChargerStandbyPowerMeasurementSummary? {
873
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
874
            return nil
875
        }
876

            
877
        return ChargerStandbyPowerMeasurementSummary(
878
            id: UUID(),
879
            chargerID: chargerID,
880
            meterMACAddress: meterMACAddress,
881
            meterName: meterName,
882
            meterModel: meterModel,
883
            startedAt: startedAt,
884
            endedAt: endedAt,
885
            sampleCount: statistics.sampleCount,
886
            stabilizedAt: stabilizedAt,
887
            averagePowerWatts: statistics.averagePowerWatts,
888
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
889
            medianPowerWatts: statistics.medianPowerWatts,
890
            minimumPowerWatts: statistics.minimumPowerWatts,
891
            maximumPowerWatts: statistics.maximumPowerWatts,
892
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
893
            coefficientOfVariation: statistics.coefficientOfVariation,
894
            averageCurrentAmps: statistics.averageCurrentAmps,
895
            averageVoltageVolts: statistics.averageVoltageVolts,
896
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
897
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
898
            powerSamplesWatts: samples.map(\.powerWatts)
899
        )
900
    }
901

            
902
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
903
        let finiteValues = values.filter(\.isFinite)
904
        guard finiteValues.isEmpty == false else {
905
            return []
906
        }
907

            
908
        let minimum = finiteValues.min() ?? 0
909
        let maximum = finiteValues.max() ?? 0
910
        let spread = maximum - minimum
911
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
912

            
913
        guard spread > 0 else {
914
            return [
915
                ChargerStandbyPowerDistributionBin(
916
                    index: 0,
917
                    lowerBoundWatts: minimum,
918
                    upperBoundWatts: maximum,
919
                    count: finiteValues.count,
920
                    relativeFrequency: 1
921
                )
922
            ]
923
        }
924

            
925
        let safeBinCount = max(1, binCount)
926
        let binWidth = spread / Double(safeBinCount)
927
        var counts = Array(repeating: 0, count: safeBinCount)
928

            
929
        for value in finiteValues {
930
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
931
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
932
            counts[safeIndex] += 1
933
        }
934

            
935
        return counts.enumerated().map { index, count in
936
            let lowerBound = minimum + (Double(index) * binWidth)
937
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
938

            
939
            return ChargerStandbyPowerDistributionBin(
940
                index: index,
941
                lowerBoundWatts: lowerBound,
942
                upperBoundWatts: upperBound,
943
                count: count,
944
                relativeFrequency: Double(count) / Double(finiteValues.count)
945
            )
946
        }
947
    }
948

            
949
    private static func mean(_ values: [Double]) -> Double {
950
        guard values.isEmpty == false else {
951
            return 0
952
        }
953
        return values.reduce(0, +) / Double(values.count)
954
    }
955

            
956
    private static func median(_ values: [Double]) -> Double {
957
        guard values.isEmpty == false else {
958
            return 0
959
        }
960

            
961
        let sorted = values.sorted()
962
        let middleIndex = sorted.count / 2
963

            
964
        if sorted.count.isMultiple(of: 2) {
965
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
966
        }
967

            
968
        return sorted[middleIndex]
969
    }
970

            
971
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
972
        guard values.count > 1 else {
973
            return 0
974
        }
975

            
976
        let variance = values.reduce(0) { partialResult, value in
977
            let delta = value - mean
978
            return partialResult + (delta * delta)
979
        } / Double(values.count)
980

            
981
        return variance.squareRoot()
982
    }
983

            
984
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
985
        guard abs(mean) > 0.000_001 else {
986
            return 0
987
        }
988

            
989
        return standardDeviation(values, mean: mean) / abs(mean)
990
    }
991
}
992

            
Bogdan Timofte authored a month ago
993
struct ChargedDeviceSummary: Identifiable, Hashable {
994
    let id: UUID
995
    let qrIdentifier: String
996
    let name: String
997
    let deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
998
    let deviceTemplateID: String?
999
    let templateDefinition: ChargedDeviceTemplateDefinition?
Bogdan Timofte authored a month ago
1000
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
1001
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
1002
    let supportsWiredCharging: Bool
1003
    let supportsWirelessCharging: Bool
1004
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
1005
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
1006
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
1007
    let wirelessChargerEfficiencyFactor: Double?
1008
    let wiredChargeCompletionCurrentAmps: Double?
1009
    let wirelessChargeCompletionCurrentAmps: Double?
1010
    let chargerObservedVoltageSelections: [Double]
1011
    let chargerIdleCurrentAmps: Double?
1012
    let chargerEfficiencyFactor: Double?
1013
    let chargerMaximumPowerWatts: Double?
1014
    let notes: String?
1015
    let minimumCurrentAmps: Double?
1016
    let estimatedBatteryCapacityWh: Double?
1017
    let wiredMinimumCurrentAmps: Double?
1018
    let wirelessMinimumCurrentAmps: Double?
1019
    let wiredEstimatedBatteryCapacityWh: Double?
1020
    let wirelessEstimatedBatteryCapacityWh: Double?
1021
    let lastAssociatedMeterMAC: String?
1022
    let createdAt: Date
1023
    let updatedAt: Date
1024
    let sessions: [ChargeSessionSummary]
1025
    let capacityHistory: [CapacityTrendPoint]
1026
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
1027
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
1028

            
1029
    var isCharger: Bool {
1030
        deviceClass == .charger
1031
    }
1032

            
Bogdan Timofte authored a month ago
1033
    var kind: ChargedDeviceKind {
1034
        deviceClass.kind
1035
    }
1036

            
1037
    var identityTitle: String {
Bogdan Timofte authored a month ago
1038
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1039
    }
1040

            
Bogdan Timofte authored a month ago
1041
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1042
        isCharger ? kind.symbolName : deviceClass.symbolName
1043
    }
1044

            
Bogdan Timofte authored a month ago
1045
    var identityIcon: ChargedDeviceTemplateIcon {
1046
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1047
    }
1048

            
1049
    var identitySymbolName: String {
1050
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1051
    }
1052

            
Bogdan Timofte authored a month ago
1053
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1054
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1055
    }
1056

            
1057
    var recentCompletedSessions: [ChargeSessionSummary] {
1058
        sessions.filter { $0.status == .completed }
1059
    }
1060

            
1061
    var sessionCount: Int {
1062
        sessions.count
1063
    }
1064

            
Bogdan Timofte authored a month ago
1065
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1066
        standbyPowerMeasurements.first
1067
    }
1068

            
Bogdan Timofte authored a month ago
1069
    var supportedChargingModes: [ChargingTransportMode] {
1070
        var modes: [ChargingTransportMode] = []
1071
        if supportsWiredCharging {
1072
            modes.append(.wired)
1073
        }
1074
        if supportsWirelessCharging {
1075
            modes.append(.wireless)
1076
        }
Bogdan Timofte authored a month ago
1077
        return modes
Bogdan Timofte authored a month ago
1078
    }
1079

            
Bogdan Timofte authored a month ago
1080
    var supportedChargingStateModes: [ChargingStateMode] {
1081
        chargingStateAvailability.supportedModes
1082
    }
1083

            
Bogdan Timofte authored a month ago
1084
    var hasMultipleChargingTransports: Bool {
1085
        supportedChargingModes.count > 1
1086
    }
1087

            
1088
    var hasMultipleChargingStateModes: Bool {
1089
        supportedChargingStateModes.count > 1
1090
    }
1091

            
1092
    var showsWirelessProfileDetails: Bool {
1093
        supportsWirelessCharging
1094
            && hasMultipleChargingTransports
1095
            && deviceClass != .watch
1096
    }
1097

            
1098
    var chargingSupportSummary: String {
1099
        switch (supportsWiredCharging, supportsWirelessCharging) {
1100
        case (true, true):
1101
            return "Supports wired and wireless charging."
1102
        case (true, false):
1103
            return "Supports wired charging only."
1104
        case (false, true):
1105
            return "Supports wireless charging only."
1106
        case (false, false):
1107
            return "No charging method configured."
1108
        }
1109
    }
1110

            
Bogdan Timofte authored a month ago
1111
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1112
        if let matchingSession = sessions.first(where: {
1113
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1114
        }) {
1115
            return matchingSession.chargingStateMode
1116
        }
1117
        return chargingStateAvailability.supportedModes.first ?? .on
1118
    }
1119

            
1120
    func sessionKind(
1121
        for chargingTransportMode: ChargingTransportMode,
1122
        chargingStateMode: ChargingStateMode? = nil
1123
    ) -> ChargeSessionKind {
1124
        ChargeSessionKind(
1125
            chargingTransportMode: chargingTransportMode,
1126
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1127
        )
1128
    }
1129

            
Bogdan Timofte authored a month ago
1130
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1131
        switch chargingTransportMode {
1132
        case .wired:
1133
            return wiredEstimatedBatteryCapacityWh
1134
        case .wireless:
1135
            return wirelessEstimatedBatteryCapacityWh
1136
        }
1137
    }
1138

            
1139
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1140
        switch chargingTransportMode {
1141
        case .wired:
1142
            return wiredMinimumCurrentAmps
1143
        case .wireless:
1144
            return wirelessMinimumCurrentAmps
1145
        }
1146
    }
1147

            
Bogdan Timofte authored a month ago
1148
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1149
        hasMultipleChargingTransports
1150
            || supportedChargingModes.contains(chargingTransportMode) == false
1151
    }
1152

            
1153
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1154
        hasMultipleChargingStateModes
1155
            || supportedChargingStateModes.contains(chargingStateMode) == false
1156
    }
1157

            
Bogdan Timofte authored a month ago
1158
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1159
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
1160
            return explicitCurrent
1161
        }
1162

            
1163
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
1164
        case .wired:
1165
            return wiredChargeCompletionCurrentAmps
1166
        case .wireless:
1167
            return wirelessChargeCompletionCurrentAmps
1168
        }
1169
    }
1170

            
Bogdan Timofte authored a month ago
1171
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1172
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
1173
            return learnedCurrent
1174
        }
1175

            
1176
        switch sessionKind.chargingTransportMode {
1177
        case .wired:
1178
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
1179
        case .wireless:
1180
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
1181
        }
1182
    }
1183

            
1184
    func resolvedCompletionCurrentAmps(
1185
        for chargingTransportMode: ChargingTransportMode,
1186
        chargingStateMode: ChargingStateMode? = nil
1187
    ) -> Double? {
1188
        let sessionKind = sessionKind(
1189
            for: chargingTransportMode,
1190
            chargingStateMode: chargingStateMode
1191
        )
1192

            
1193
        return configuredCompletionCurrentAmps(for: sessionKind)
1194
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
1195
            ?? minimumCurrentAmps(for: chargingTransportMode)
1196
            ?? minimumCurrentAmps
1197
    }
1198

            
Bogdan Timofte authored a month ago
1199
    func batteryLevelPrediction(
1200
        for session: ChargeSessionSummary,
1201
        effectiveEnergyWhOverride: Double? = nil
1202
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1203
        let estimatedCapacityWh = session.capacityEstimateWh
1204
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1205
            ?? estimatedBatteryCapacityWh
1206

            
1207
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1208
            return nil
1209
        }
1210

            
Bogdan Timofte authored a month ago
1211
        let effectiveEnergyWh = effectiveEnergyWhOverride
1212
            ?? session.effectiveBatteryEnergyWh
1213
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1214

            
1215
        struct Anchor {
1216
            let percent: Double
1217
            let energyWh: Double
Bogdan Timofte authored a month ago
1218
            let timestamp: Date
Bogdan Timofte authored a month ago
1219
            let description: String
Bogdan Timofte authored a month ago
1220
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1221
        }
1222

            
1223
        var anchors: [Anchor] = []
1224

            
Bogdan Timofte authored a month ago
1225
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1226
            anchors.append(
1227
                Anchor(
1228
                    percent: startBatteryPercent,
1229
                    energyWh: 0,
Bogdan Timofte authored a month ago
1230
                    timestamp: session.startedAt,
1231
                    description: "session start",
1232
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1233
                )
1234
            )
1235
        }
1236

            
1237
        anchors.append(
1238
            contentsOf: session.checkpoints
1239
                .sorted { lhs, rhs in
1240
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1241
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1242
                    }
1243
                    return lhs.timestamp < rhs.timestamp
1244
                }
Bogdan Timofte authored a month ago
1245
                .filter { checkpoint in
1246
                    checkpoint.batteryPercent >= 0
1247
                }
Bogdan Timofte authored a month ago
1248
                .map { checkpoint in
1249
                    return Anchor(
1250
                        percent: checkpoint.batteryPercent,
1251
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1252
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
1253
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
1254
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1255
                    )
1256
                }
1257
        )
1258

            
1259
        guard !anchors.isEmpty else {
1260
            return nil
1261
        }
1262

            
1263
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1264
        let anchor = eligibleAnchors.last ?? anchors.first!
Bogdan Timofte authored a month ago
1265
        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1266
            anchorPercent: anchor.percent,
1267
            anchorEnergyWh: anchor.energyWh,
1268
            anchorTimestamp: anchor.timestamp,
1269
            anchorIsCheckpoint: anchor.isCheckpoint,
1270
            effectiveEnergyWh: effectiveEnergyWh,
1271
            referenceTimestamp: session.lastObservedAt,
1272
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1273
        )
1274

            
1275
        return BatteryLevelPrediction(
1276
            predictedPercent: predictedPercent,
1277
            estimatedCapacityWh: estimatedCapacityWh,
1278
            anchorPercent: anchor.percent,
1279
            anchorEnergyWh: anchor.energyWh,
1280
            anchorDescription: anchor.description
1281
        )
1282
    }
Bogdan Timofte authored a month ago
1283

            
1284
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1285
        ChargedDeviceSummary(
1286
            id: id,
1287
            qrIdentifier: qrIdentifier,
1288
            name: name,
1289
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1290
            deviceTemplateID: deviceTemplateID,
1291
            templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1292
            supportsChargingWhileOff: supportsChargingWhileOff,
1293
            chargingStateAvailability: chargingStateAvailability,
1294
            supportsWiredCharging: supportsWiredCharging,
1295
            supportsWirelessCharging: supportsWirelessCharging,
1296
            wirelessChargingProfile: wirelessChargingProfile,
1297
            configuredCompletionCurrents: configuredCompletionCurrents,
1298
            learnedCompletionCurrents: learnedCompletionCurrents,
1299
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1300
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1301
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1302
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1303
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1304
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1305
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1306
            notes: notes,
1307
            minimumCurrentAmps: minimumCurrentAmps,
1308
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1309
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1310
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1311
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1312
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1313
            lastAssociatedMeterMAC: lastAssociatedMeterMAC,
1314
            createdAt: createdAt,
1315
            updatedAt: updatedAt,
1316
            sessions: sessions,
1317
            capacityHistory: capacityHistory,
1318
            typicalCurve: typicalCurve,
1319
            standbyPowerMeasurements: measurements
1320
        )
1321
    }
Bogdan Timofte authored a month ago
1322
}
1323

            
1324
struct ChargingMonitorSnapshot {
1325
    let meterMACAddress: String
1326
    let meterName: String
1327
    let meterModel: String
1328
    let observedAt: Date
1329
    let voltageVolts: Double
1330
    let currentAmps: Double
1331
    let powerWatts: Double
1332
    let selectedDataGroup: UInt8?
1333
    let meterChargeCounterAh: Double?
1334
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1335
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1336
    let fallbackStopThresholdAmps: Double
1337
}