USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
1090 lines | 33.262kb
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 {
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 {
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
    }
90
}
91

            
92
enum ChargeSessionStatus: String {
93
    case active
Bogdan Timofte authored a month ago
94
    case paused
Bogdan Timofte authored a month ago
95
    case completed
96
    case abandoned
97

            
98
    var title: String {
Bogdan Timofte authored a month ago
99
        switch self {
100
        case .active:
101
            return "Active"
102
        case .paused:
103
            return "Paused"
104
        case .completed:
105
            return "Completed"
106
        case .abandoned:
107
            return "Abandoned"
108
        }
109
    }
110

            
111
    var isOpen: Bool {
112
        switch self {
113
        case .active, .paused:
114
            return true
115
        case .completed, .abandoned:
116
            return false
117
        }
Bogdan Timofte authored a month ago
118
    }
119
}
120

            
121
enum ChargeSessionSourceMode: String {
122
    case live
123
    case offline
124
    case blended
125

            
126
    var title: String {
127
        switch self {
128
        case .live:
129
            return "Live"
130
        case .offline:
131
            return "Offline Counters"
132
        case .blended:
133
            return "Blended"
134
        }
135
    }
136
}
137

            
Bogdan Timofte authored a month ago
138
enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
139
    case wired
140
    case wireless
141

            
142
    var id: String { rawValue }
143

            
144
    var title: String {
145
        switch self {
146
        case .wired:
147
            return "Wired"
148
        case .wireless:
149
            return "Wireless"
150
        }
151
    }
152

            
153
    var symbolName: String {
154
        switch self {
155
        case .wired:
156
            return "cable.connector"
157
        case .wireless:
158
            return "dot.radiowaves.left.and.right"
159
        }
160
    }
161
}
162

            
Bogdan Timofte authored a month ago
163
enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
164
    case on
165
    case off
166

            
167
    var id: String { rawValue }
168

            
169
    var title: String {
170
        switch self {
171
        case .on:
172
            return "On"
173
        case .off:
174
            return "Off"
175
        }
176
    }
177

            
178
    var description: String {
179
        switch self {
180
        case .on:
181
            return "Device stays powered on while charging."
182
        case .off:
183
            return "Device is powered off while charging."
184
        }
185
    }
186
}
187

            
188
enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
189
    case onOnly
190
    case onOrOff
191
    case offOnly
192

            
193
    var id: String { rawValue }
194

            
195
    var title: String {
196
        switch self {
197
        case .onOnly:
198
            return "On Only"
199
        case .onOrOff:
200
            return "On or Off"
201
        case .offOnly:
202
            return "Off Only"
203
        }
204
    }
205

            
206
    var description: String {
207
        switch self {
208
        case .onOnly:
209
            return "The device can be recorded only while it is powered on."
210
        case .onOrOff:
211
            return "The session must specify whether the device is on or off."
212
        case .offOnly:
213
            return "The device can be recorded only while it is powered off."
214
        }
215
    }
216

            
217
    var supportedModes: [ChargingStateMode] {
218
        switch self {
219
        case .onOnly:
220
            return [.on]
221
        case .onOrOff:
222
            return [.on, .off]
223
        case .offOnly:
224
            return [.off]
225
        }
226
    }
227

            
228
    var supportsMultipleModes: Bool {
229
        supportedModes.count > 1
230
    }
231

            
232
    var supportsChargingWhileOff: Bool {
233
        self != .onOnly
234
    }
235

            
236
    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
237
        supportsChargingWhileOff ? .onOrOff : .onOnly
238
    }
239
}
240

            
241
enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
242
    case wiredOn
243
    case wiredOff
244
    case wirelessOn
245
    case wirelessOff
246

            
247
    var id: String { rawValue }
248

            
249
    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
250
        switch (chargingTransportMode, chargingStateMode) {
251
        case (.wired, .on):
252
            self = .wiredOn
253
        case (.wired, .off):
254
            self = .wiredOff
255
        case (.wireless, .on):
256
            self = .wirelessOn
257
        case (.wireless, .off):
258
            self = .wirelessOff
259
        }
260
    }
261

            
262
    var chargingTransportMode: ChargingTransportMode {
263
        switch self {
264
        case .wiredOn, .wiredOff:
265
            return .wired
266
        case .wirelessOn, .wirelessOff:
267
            return .wireless
268
        }
269
    }
270

            
271
    var chargingStateMode: ChargingStateMode {
272
        switch self {
273
        case .wiredOn, .wirelessOn:
274
            return .on
275
        case .wiredOff, .wirelessOff:
276
            return .off
277
        }
278
    }
279

            
280
    var title: String {
281
        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
282
    }
283

            
284
    var shortTitle: String {
285
        "\(chargingTransportMode.title) \(chargingStateMode.title)"
286
    }
287
}
288

            
Bogdan Timofte authored a month ago
289
enum WirelessChargingProfile: String, CaseIterable, Identifiable {
290
    case magsafe
291
    case genericQi
292

            
293
    var id: String { rawValue }
294

            
295
    var title: String {
296
        switch self {
297
        case .magsafe:
298
            return "MagSafe"
299
        case .genericQi:
300
            return "Generic Qi"
301
        }
302
    }
303

            
304
    var description: String {
305
        switch self {
306
        case .magsafe:
307
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
308
        case .genericQi:
309
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
310
        }
311
    }
312
}
313

            
314
struct ChargeCheckpointSummary: Identifiable, Hashable {
315
    let id: UUID
316
    let sessionID: UUID
317
    let chargedDeviceID: UUID
318
    let timestamp: Date
319
    let batteryPercent: Double
320
    let measuredEnergyWh: Double
321
    let measuredChargeAh: Double
322
    let currentAmps: Double
323
    let voltageVolts: Double?
324
    let label: String?
325
}
326

            
327
struct ChargeSessionSampleSummary: Identifiable, Hashable {
328
    let sessionID: UUID
329
    let chargedDeviceID: UUID
330
    let bucketIndex: Int
331
    let timestamp: Date
332
    let averageCurrentAmps: Double
333
    let averageVoltageVolts: Double?
334
    let averagePowerWatts: Double
335
    let measuredEnergyWh: Double
336
    let measuredChargeAh: Double
337
    let sampleCount: Int
338

            
339
    var id: String {
340
        "\(sessionID.uuidString)-\(bucketIndex)"
341
    }
342
}
343

            
344
struct ChargeSessionSummary: Identifiable, Hashable {
345
    let id: UUID
346
    let chargedDeviceID: UUID
347
    let chargerID: UUID?
348
    let meterMACAddress: String?
349
    let meterName: String?
350
    let meterModel: String?
351
    let startedAt: Date
352
    let endedAt: Date?
353
    let lastObservedAt: Date
Bogdan Timofte authored a month ago
354
    let pausedAt: Date?
Bogdan Timofte authored a month ago
355
    let status: ChargeSessionStatus
356
    let sourceMode: ChargeSessionSourceMode
357
    let chargingTransportMode: ChargingTransportMode
Bogdan Timofte authored a month ago
358
    let chargingStateMode: ChargingStateMode
359
    let autoStopEnabled: Bool
Bogdan Timofte authored a month ago
360
    let measuredEnergyWh: Double
361
    let effectiveBatteryEnergyWh: Double?
362
    let measuredChargeAh: Double
Bogdan Timofte authored a month ago
363
    let meterEnergyBaselineWh: Double?
364
    let meterChargeBaselineAh: Double?
Bogdan Timofte authored a month ago
365
    let meterDurationBaselineSeconds: Double?
366
    let meterLastDurationSeconds: Double?
Bogdan Timofte authored a month ago
367
    let minimumObservedCurrentAmps: Double?
368
    let maximumObservedCurrentAmps: Double?
369
    let maximumObservedPowerWatts: Double?
370
    let maximumObservedVoltageVolts: Double?
371
    let selectedSourceVoltageVolts: Double?
372
    let completionCurrentAmps: Double?
373
    let stopThresholdAmps: Double
374
    let startBatteryPercent: Double?
375
    let endBatteryPercent: Double?
376
    let capacityEstimateWh: Double?
377
    let wirelessEfficiencyFactor: Double?
378
    let usesEstimatedWirelessEfficiency: Bool
379
    let shouldWarnAboutLowWirelessEfficiency: Bool
380
    let supportsChargingWhileOff: Bool
381
    let usedOfflineMeterCounters: Bool
382
    let targetBatteryPercent: Double?
383
    let targetBatteryAlertTriggeredAt: Date?
384
    let requiresCompletionConfirmation: Bool
385
    let completionConfirmationRequestedAt: Date?
386
    let completionContradictionPercent: Double?
387
    let selectedDataGroup: UInt8?
388
    let checkpoints: [ChargeCheckpointSummary]
389
    let aggregatedSamples: [ChargeSessionSampleSummary]
390

            
Bogdan Timofte authored a month ago
391
    var sessionKind: ChargeSessionKind {
392
        ChargeSessionKind(
393
            chargingTransportMode: chargingTransportMode,
394
            chargingStateMode: chargingStateMode
395
        )
396
    }
397

            
Bogdan Timofte authored a month ago
398
    var duration: TimeInterval {
399
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
400
    }
401

            
Bogdan Timofte authored a month ago
402
    var meterObservedDuration: TimeInterval? {
403
        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
404
            return nil
405
        }
406
        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
407
            return nil
408
        }
409
        return meterLastDurationSeconds - meterDurationBaselineSeconds
410
    }
411

            
412
    var effectiveDuration: TimeInterval {
413
        meterObservedDuration ?? duration
414
    }
415

            
Bogdan Timofte authored a month ago
416
    var effectiveOrMeasuredEnergyWh: Double {
417
        effectiveBatteryEnergyWh ?? measuredEnergyWh
418
    }
419

            
420
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
421
        guard let startBatteryPercent, let endBatteryPercent,
422
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
423
        return endBatteryPercent - startBatteryPercent
424
    }
Bogdan Timofte authored a month ago
425

            
426
    var canAutoStop: Bool {
427
        autoStopEnabled && stopThresholdAmps > 0
428
    }
429

            
430
    var isPaused: Bool {
431
        status == .paused
432
    }
433

            
434
    var isOpen: Bool {
435
        status.isOpen
436
    }
Bogdan Timofte authored a month ago
437
}
438

            
439
struct BatteryLevelPrediction: Hashable {
440
    let predictedPercent: Double
441
    let estimatedCapacityWh: Double
442
    let anchorPercent: Double
443
    let anchorEnergyWh: Double
444
    let anchorDescription: String
445
}
446

            
Bogdan Timofte authored a month ago
447
enum BatteryLevelPredictionTuning {
448
    static let checkpointSettleDuration: TimeInterval = 10 * 60
449

            
450
    static func predictedPercent(
451
        anchorPercent: Double,
452
        anchorEnergyWh: Double,
453
        anchorTimestamp: Date,
454
        anchorIsCheckpoint: Bool,
455
        effectiveEnergyWh: Double,
456
        referenceTimestamp: Date,
457
        estimatedCapacityWh: Double
458
    ) -> Double {
459
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
460
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
461
        let stabilizedGainPercent: Double
462

            
463
        if anchorIsCheckpoint {
464
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
465
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
466
            stabilizedGainPercent = rawGainPercent * settleProgress
467
        } else {
468
            stabilizedGainPercent = rawGainPercent
469
        }
470

            
471
        return min(
472
            100,
473
            max(
474
                0,
475
                anchorPercent + stabilizedGainPercent
476
            )
477
        )
478
    }
479
}
480

            
Bogdan Timofte authored a month ago
481
struct CapacityTrendPoint: Identifiable, Hashable {
482
    let sessionID: UUID
483
    let timestamp: Date
484
    let capacityWh: Double
485
    let chargingTransportMode: ChargingTransportMode
486

            
487
    var id: UUID { sessionID }
488
}
489

            
490
struct TypicalChargeCurvePoint: Identifiable, Hashable {
491
    let percentBin: Int
492
    let averageEnergyWh: Double
493
    let averageChargeAh: Double
494
    let sampleCount: Int
495

            
496
    var id: Int { percentBin }
497
}
498

            
Bogdan Timofte authored a month ago
499
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
500
    let timestamp: Date
501
    let powerWatts: Double
502
    let currentAmps: Double
503
    let voltageVolts: Double
504

            
505
    var id: TimeInterval {
506
        timestamp.timeIntervalSince1970
507
    }
508
}
509

            
510
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable {
511
    let index: Int
512
    let lowerBoundWatts: Double
513
    let upperBoundWatts: Double
514
    let count: Int
515
    let relativeFrequency: Double
516

            
517
    var id: Int { index }
518
}
519

            
520
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
521
    let sampleCount: Int
522
    let observedDuration: TimeInterval
523
    let averagePowerWatts: Double
524
    let recentAveragePowerWatts: Double
525
    let medianPowerWatts: Double
526
    let minimumPowerWatts: Double
527
    let maximumPowerWatts: Double
528
    let standardDeviationPowerWatts: Double
529
    let coefficientOfVariation: Double
530
    let averageCurrentAmps: Double
531
    let averageVoltageVolts: Double
532
    let stabilityDeltaWatts: Double
533
    let stabilityToleranceWatts: Double
534
    let histogram: [ChargerStandbyPowerDistributionBin]
535

            
536
    var projectedDailyEnergyWh: Double {
537
        averagePowerWatts * 24
538
    }
539

            
540
    var projectedWeeklyEnergyWh: Double {
541
        averagePowerWatts * 24 * 7
542
    }
543

            
544
    var projectedMonthlyEnergyWh: Double {
545
        averagePowerWatts * 24 * 30
546
    }
547

            
548
    var projectedYearlyEnergyWh: Double {
549
        averagePowerWatts * 24 * 365
550
    }
551

            
552
    var stabilityDeltaMilliwatts: Double {
553
        stabilityDeltaWatts * 1000
554
    }
555

            
556
    var isStable: Bool {
557
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
558
        && stabilityDeltaWatts <= stabilityToleranceWatts
559
    }
560
}
561

            
562
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
563
    let id: UUID
564
    let chargerID: UUID
565
    let meterMACAddress: String
566
    let meterName: String?
567
    let meterModel: String?
568
    let startedAt: Date
569
    let endedAt: Date
570
    let sampleCount: Int
571
    let stabilizedAt: Date?
572
    let averagePowerWatts: Double
573
    let recentAveragePowerWatts: Double
574
    let medianPowerWatts: Double
575
    let minimumPowerWatts: Double
576
    let maximumPowerWatts: Double
577
    let standardDeviationPowerWatts: Double
578
    let coefficientOfVariation: Double
579
    let averageCurrentAmps: Double
580
    let averageVoltageVolts: Double
581
    let stabilityDeltaWatts: Double
582
    let stabilityToleranceWatts: Double
583
    let powerSamplesWatts: [Double]
584

            
585
    var duration: TimeInterval {
586
        endedAt.timeIntervalSince(startedAt)
587
    }
588

            
589
    var projectedDailyEnergyWh: Double {
590
        averagePowerWatts * 24
591
    }
592

            
593
    var projectedWeeklyEnergyWh: Double {
594
        averagePowerWatts * 24 * 7
595
    }
596

            
597
    var projectedMonthlyEnergyWh: Double {
598
        averagePowerWatts * 24 * 30
599
    }
600

            
601
    var projectedYearlyEnergyWh: Double {
602
        averagePowerWatts * 24 * 365
603
    }
604

            
605
    var isStable: Bool {
606
        stabilizedAt != nil
607
    }
608

            
609
    var histogram: [ChargerStandbyPowerDistributionBin] {
610
        ChargerStandbyPowerMeasurementAnalyzer.histogram(for: powerSamplesWatts)
611
    }
612
}
613

            
614
enum ChargerStandbyPowerMeasurementAnalyzer {
615
    static let minimumStableSampleCount = 45
616
    static let recentSampleWindow = 20
617
    static let minimumStabilityToleranceWatts = 0.003
618
    static let relativeStabilityTolerance = 0.01
619

            
620
    static func statistics(
621
        from samples: [ChargerStandbyPowerSample],
622
        startedAt: Date,
623
        referenceDate: Date = Date()
624
    ) -> ChargerStandbyPowerMeasurementStatistics? {
625
        guard !samples.isEmpty else {
626
            return nil
627
        }
628

            
629
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
630
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
631
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
632

            
633
        guard powerValues.isEmpty == false else {
634
            return nil
635
        }
636

            
637
        let averagePower = mean(powerValues)
638
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
639
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
640
        let stabilityDelta = abs(averagePower - recentAveragePower)
641
        let stabilityTolerance = max(
642
            minimumStabilityToleranceWatts,
643
            abs(averagePower) * relativeStabilityTolerance
644
        )
645

            
646
        return ChargerStandbyPowerMeasurementStatistics(
647
            sampleCount: powerValues.count,
648
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
649
            averagePowerWatts: averagePower,
650
            recentAveragePowerWatts: recentAveragePower,
651
            medianPowerWatts: median(powerValues),
652
            minimumPowerWatts: powerValues.min() ?? 0,
653
            maximumPowerWatts: powerValues.max() ?? 0,
654
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
655
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
656
            averageCurrentAmps: mean(currentValues),
657
            averageVoltageVolts: mean(voltageValues),
658
            stabilityDeltaWatts: stabilityDelta,
659
            stabilityToleranceWatts: stabilityTolerance,
660
            histogram: histogram(for: powerValues)
661
        )
662
    }
663

            
664
    static func measurementSummary(
665
        chargerID: UUID,
666
        meterMACAddress: String,
667
        meterName: String?,
668
        meterModel: String?,
669
        startedAt: Date,
670
        endedAt: Date,
671
        samples: [ChargerStandbyPowerSample],
672
        stabilizedAt: Date?
673
    ) -> ChargerStandbyPowerMeasurementSummary? {
674
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
675
            return nil
676
        }
677

            
678
        return ChargerStandbyPowerMeasurementSummary(
679
            id: UUID(),
680
            chargerID: chargerID,
681
            meterMACAddress: meterMACAddress,
682
            meterName: meterName,
683
            meterModel: meterModel,
684
            startedAt: startedAt,
685
            endedAt: endedAt,
686
            sampleCount: statistics.sampleCount,
687
            stabilizedAt: stabilizedAt,
688
            averagePowerWatts: statistics.averagePowerWatts,
689
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
690
            medianPowerWatts: statistics.medianPowerWatts,
691
            minimumPowerWatts: statistics.minimumPowerWatts,
692
            maximumPowerWatts: statistics.maximumPowerWatts,
693
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
694
            coefficientOfVariation: statistics.coefficientOfVariation,
695
            averageCurrentAmps: statistics.averageCurrentAmps,
696
            averageVoltageVolts: statistics.averageVoltageVolts,
697
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
698
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
699
            powerSamplesWatts: samples.map(\.powerWatts)
700
        )
701
    }
702

            
703
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
704
        let finiteValues = values.filter(\.isFinite)
705
        guard finiteValues.isEmpty == false else {
706
            return []
707
        }
708

            
709
        let minimum = finiteValues.min() ?? 0
710
        let maximum = finiteValues.max() ?? 0
711
        let spread = maximum - minimum
712
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
713

            
714
        guard spread > 0 else {
715
            return [
716
                ChargerStandbyPowerDistributionBin(
717
                    index: 0,
718
                    lowerBoundWatts: minimum,
719
                    upperBoundWatts: maximum,
720
                    count: finiteValues.count,
721
                    relativeFrequency: 1
722
                )
723
            ]
724
        }
725

            
726
        let safeBinCount = max(1, binCount)
727
        let binWidth = spread / Double(safeBinCount)
728
        var counts = Array(repeating: 0, count: safeBinCount)
729

            
730
        for value in finiteValues {
731
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
732
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
733
            counts[safeIndex] += 1
734
        }
735

            
736
        return counts.enumerated().map { index, count in
737
            let lowerBound = minimum + (Double(index) * binWidth)
738
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
739

            
740
            return ChargerStandbyPowerDistributionBin(
741
                index: index,
742
                lowerBoundWatts: lowerBound,
743
                upperBoundWatts: upperBound,
744
                count: count,
745
                relativeFrequency: Double(count) / Double(finiteValues.count)
746
            )
747
        }
748
    }
749

            
750
    private static func mean(_ values: [Double]) -> Double {
751
        guard values.isEmpty == false else {
752
            return 0
753
        }
754
        return values.reduce(0, +) / Double(values.count)
755
    }
756

            
757
    private static func median(_ values: [Double]) -> Double {
758
        guard values.isEmpty == false else {
759
            return 0
760
        }
761

            
762
        let sorted = values.sorted()
763
        let middleIndex = sorted.count / 2
764

            
765
        if sorted.count.isMultiple(of: 2) {
766
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
767
        }
768

            
769
        return sorted[middleIndex]
770
    }
771

            
772
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
773
        guard values.count > 1 else {
774
            return 0
775
        }
776

            
777
        let variance = values.reduce(0) { partialResult, value in
778
            let delta = value - mean
779
            return partialResult + (delta * delta)
780
        } / Double(values.count)
781

            
782
        return variance.squareRoot()
783
    }
784

            
785
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
786
        guard abs(mean) > 0.000_001 else {
787
            return 0
788
        }
789

            
790
        return standardDeviation(values, mean: mean) / abs(mean)
791
    }
792
}
793

            
Bogdan Timofte authored a month ago
794
struct ChargedDeviceSummary: Identifiable, Hashable {
795
    let id: UUID
796
    let qrIdentifier: String
797
    let name: String
798
    let deviceClass: ChargedDeviceClass
799
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
800
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
801
    let supportsWiredCharging: Bool
802
    let supportsWirelessCharging: Bool
803
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
804
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
805
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
806
    let wirelessChargerEfficiencyFactor: Double?
807
    let wiredChargeCompletionCurrentAmps: Double?
808
    let wirelessChargeCompletionCurrentAmps: Double?
809
    let chargerObservedVoltageSelections: [Double]
810
    let chargerIdleCurrentAmps: Double?
811
    let chargerEfficiencyFactor: Double?
812
    let chargerMaximumPowerWatts: Double?
813
    let notes: String?
814
    let minimumCurrentAmps: Double?
815
    let estimatedBatteryCapacityWh: Double?
816
    let wiredMinimumCurrentAmps: Double?
817
    let wirelessMinimumCurrentAmps: Double?
818
    let wiredEstimatedBatteryCapacityWh: Double?
819
    let wirelessEstimatedBatteryCapacityWh: Double?
820
    let lastAssociatedMeterMAC: String?
821
    let createdAt: Date
822
    let updatedAt: Date
823
    let sessions: [ChargeSessionSummary]
824
    let capacityHistory: [CapacityTrendPoint]
825
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
826
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
827

            
828
    var isCharger: Bool {
829
        deviceClass == .charger
830
    }
831

            
Bogdan Timofte authored a month ago
832
    var kind: ChargedDeviceKind {
833
        deviceClass.kind
834
    }
835

            
836
    var identityTitle: String {
837
        isCharger ? kind.title : deviceClass.title
838
    }
839

            
840
    var identitySymbolName: String {
841
        isCharger ? kind.symbolName : deviceClass.symbolName
842
    }
843

            
Bogdan Timofte authored a month ago
844
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
845
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
846
    }
847

            
848
    var recentCompletedSessions: [ChargeSessionSummary] {
849
        sessions.filter { $0.status == .completed }
850
    }
851

            
852
    var sessionCount: Int {
853
        sessions.count
854
    }
855

            
Bogdan Timofte authored a month ago
856
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
857
        standbyPowerMeasurements.first
858
    }
859

            
Bogdan Timofte authored a month ago
860
    var supportedChargingModes: [ChargingTransportMode] {
861
        var modes: [ChargingTransportMode] = []
862
        if supportsWiredCharging {
863
            modes.append(.wired)
864
        }
865
        if supportsWirelessCharging {
866
            modes.append(.wireless)
867
        }
Bogdan Timofte authored a month ago
868
        return modes
Bogdan Timofte authored a month ago
869
    }
870

            
Bogdan Timofte authored a month ago
871
    var supportedChargingStateModes: [ChargingStateMode] {
872
        chargingStateAvailability.supportedModes
873
    }
874

            
875
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
876
        if let matchingSession = sessions.first(where: {
877
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
878
        }) {
879
            return matchingSession.chargingStateMode
880
        }
881
        return chargingStateAvailability.supportedModes.first ?? .on
882
    }
883

            
884
    func sessionKind(
885
        for chargingTransportMode: ChargingTransportMode,
886
        chargingStateMode: ChargingStateMode? = nil
887
    ) -> ChargeSessionKind {
888
        ChargeSessionKind(
889
            chargingTransportMode: chargingTransportMode,
890
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
891
        )
892
    }
893

            
Bogdan Timofte authored a month ago
894
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
895
        switch chargingTransportMode {
896
        case .wired:
897
            return wiredEstimatedBatteryCapacityWh
898
        case .wireless:
899
            return wirelessEstimatedBatteryCapacityWh
900
        }
901
    }
902

            
903
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
904
        switch chargingTransportMode {
905
        case .wired:
906
            return wiredMinimumCurrentAmps
907
        case .wireless:
908
            return wirelessMinimumCurrentAmps
909
        }
910
    }
911

            
Bogdan Timofte authored a month ago
912
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
913
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
914
            return explicitCurrent
915
        }
916

            
917
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
918
        case .wired:
919
            return wiredChargeCompletionCurrentAmps
920
        case .wireless:
921
            return wirelessChargeCompletionCurrentAmps
922
        }
923
    }
924

            
Bogdan Timofte authored a month ago
925
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
926
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
927
            return learnedCurrent
928
        }
929

            
930
        switch sessionKind.chargingTransportMode {
931
        case .wired:
932
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
933
        case .wireless:
934
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
935
        }
936
    }
937

            
938
    func resolvedCompletionCurrentAmps(
939
        for chargingTransportMode: ChargingTransportMode,
940
        chargingStateMode: ChargingStateMode? = nil
941
    ) -> Double? {
942
        let sessionKind = sessionKind(
943
            for: chargingTransportMode,
944
            chargingStateMode: chargingStateMode
945
        )
946

            
947
        return configuredCompletionCurrentAmps(for: sessionKind)
948
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
949
            ?? minimumCurrentAmps(for: chargingTransportMode)
950
            ?? minimumCurrentAmps
951
    }
952

            
Bogdan Timofte authored a month ago
953
    func batteryLevelPrediction(
954
        for session: ChargeSessionSummary,
955
        effectiveEnergyWhOverride: Double? = nil
956
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
957
        let estimatedCapacityWh = session.capacityEstimateWh
958
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
959
            ?? estimatedBatteryCapacityWh
960

            
961
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
962
            return nil
963
        }
964

            
Bogdan Timofte authored a month ago
965
        let effectiveEnergyWh = effectiveEnergyWhOverride
966
            ?? session.effectiveBatteryEnergyWh
967
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
968

            
969
        struct Anchor {
970
            let percent: Double
971
            let energyWh: Double
Bogdan Timofte authored a month ago
972
            let timestamp: Date
Bogdan Timofte authored a month ago
973
            let description: String
Bogdan Timofte authored a month ago
974
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
975
        }
976

            
977
        var anchors: [Anchor] = []
978

            
Bogdan Timofte authored a month ago
979
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
980
            anchors.append(
981
                Anchor(
982
                    percent: startBatteryPercent,
983
                    energyWh: 0,
Bogdan Timofte authored a month ago
984
                    timestamp: session.startedAt,
985
                    description: "session start",
986
                    isCheckpoint: false
Bogdan Timofte authored a month ago
987
                )
988
            )
989
        }
990

            
991
        anchors.append(
992
            contentsOf: session.checkpoints
993
                .sorted { lhs, rhs in
994
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
995
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
996
                    }
997
                    return lhs.timestamp < rhs.timestamp
998
                }
Bogdan Timofte authored a month ago
999
                .filter { checkpoint in
1000
                    checkpoint.batteryPercent >= 0
1001
                }
Bogdan Timofte authored a month ago
1002
                .map { checkpoint in
1003
                    let trimmedLabel = checkpoint.label?.trimmingCharacters(in: .whitespacesAndNewlines)
1004
                    return Anchor(
1005
                        percent: checkpoint.batteryPercent,
1006
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1007
                        timestamp: checkpoint.timestamp,
1008
                        description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint",
1009
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1010
                    )
1011
                }
1012
        )
1013

            
1014
        guard !anchors.isEmpty else {
1015
            return nil
1016
        }
1017

            
1018
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1019
        let anchor = eligibleAnchors.last ?? anchors.first!
Bogdan Timofte authored a month ago
1020
        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1021
            anchorPercent: anchor.percent,
1022
            anchorEnergyWh: anchor.energyWh,
1023
            anchorTimestamp: anchor.timestamp,
1024
            anchorIsCheckpoint: anchor.isCheckpoint,
1025
            effectiveEnergyWh: effectiveEnergyWh,
1026
            referenceTimestamp: session.lastObservedAt,
1027
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1028
        )
1029

            
1030
        return BatteryLevelPrediction(
1031
            predictedPercent: predictedPercent,
1032
            estimatedCapacityWh: estimatedCapacityWh,
1033
            anchorPercent: anchor.percent,
1034
            anchorEnergyWh: anchor.energyWh,
1035
            anchorDescription: anchor.description
1036
        )
1037
    }
Bogdan Timofte authored a month ago
1038

            
1039
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1040
        ChargedDeviceSummary(
1041
            id: id,
1042
            qrIdentifier: qrIdentifier,
1043
            name: name,
1044
            deviceClass: deviceClass,
1045
            supportsChargingWhileOff: supportsChargingWhileOff,
1046
            chargingStateAvailability: chargingStateAvailability,
1047
            supportsWiredCharging: supportsWiredCharging,
1048
            supportsWirelessCharging: supportsWirelessCharging,
1049
            wirelessChargingProfile: wirelessChargingProfile,
1050
            configuredCompletionCurrents: configuredCompletionCurrents,
1051
            learnedCompletionCurrents: learnedCompletionCurrents,
1052
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1053
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1054
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1055
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1056
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1057
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1058
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1059
            notes: notes,
1060
            minimumCurrentAmps: minimumCurrentAmps,
1061
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1062
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1063
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1064
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1065
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1066
            lastAssociatedMeterMAC: lastAssociatedMeterMAC,
1067
            createdAt: createdAt,
1068
            updatedAt: updatedAt,
1069
            sessions: sessions,
1070
            capacityHistory: capacityHistory,
1071
            typicalCurve: typicalCurve,
1072
            standbyPowerMeasurements: measurements
1073
        )
1074
    }
Bogdan Timofte authored a month ago
1075
}
1076

            
1077
struct ChargingMonitorSnapshot {
1078
    let meterMACAddress: String
1079
    let meterName: String
1080
    let meterModel: String
1081
    let observedAt: Date
1082
    let voltageVolts: Double
1083
    let currentAmps: Double
1084
    let powerWatts: Double
1085
    let selectedDataGroup: UInt8?
1086
    let meterChargeCounterAh: Double?
1087
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
1088
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
1089
    let fallbackStopThresholdAmps: Double
1090
}