USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
2089 lines | 94.051kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargeInsightsStore.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import CoreData
9
import Foundation
10

            
11
final class ChargeInsightsStore {
12
    private enum EntityName {
13
        static let chargedDevice = "ChargedDevice"
14
        static let chargeSession = "ChargeSession"
15
        static let chargeCheckpoint = "ChargeCheckpoint"
16
        static let chargeSessionSample = "ChargeSessionSample"
17
    }
18

            
19
    private enum MeterAssignmentKind {
20
        case chargedDevice
21
        case charger
22

            
23
        var expectsChargerClass: Bool {
24
            switch self {
25
            case .chargedDevice:
26
                return false
27
            case .charger:
28
                return true
29
            }
30
        }
31
    }
32

            
33
    private static let persistedSamplesPerHour = 300
34
    private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
35

            
36
    private let context: NSManagedObjectContext
37
    private let stopDetectionHoldDuration: TimeInterval = 20
38
    private let maximumLiveIntegrationGap: TimeInterval = 20
39
    private let activeSessionSaveInterval: TimeInterval = 15
40
    private let counterDecreaseTolerance = 0.002
41
    private let completionConfirmationCooldown: TimeInterval = 15 * 60
42
    private let defaultCompletionPercentThreshold = 95.0
43
    private let completionContradictionTolerancePercent = 2.0
44
    private let minimumWirelessEfficiencyFactor = 0.35
45
    private let maximumWirelessEfficiencyFactor = 0.95
46
    private let lowWirelessEfficiencyThreshold = 0.72
47

            
48
    init(context: NSManagedObjectContext) {
49
        self.context = context
50
    }
51

            
52
    func refreshContext() {
53
        context.performAndWait {
54
            context.processPendingChanges()
55
        }
56
    }
57

            
58
    @discardableResult
59
    func flushPendingChanges() -> Bool {
60
        var didSave = false
61
        context.performAndWait {
62
            context.processPendingChanges()
63
            didSave = saveContext()
64
        }
65
        return didSave
66
    }
67

            
68
    @discardableResult
69
    func createChargedDevice(
70
        name: String,
71
        deviceClass: ChargedDeviceClass,
72
        supportsChargingWhileOff: Bool,
73
        supportsWiredCharging: Bool,
74
        supportsWirelessCharging: Bool,
75
        preferredChargingTransportMode: ChargingTransportMode,
76
        wirelessChargingProfile: WirelessChargingProfile,
77
        wiredChargeCompletionCurrentAmps: Double?,
78
        wirelessChargeCompletionCurrentAmps: Double?,
79
        notes: String?,
80
        assignTo meterMACAddress: String?
81
    ) -> Bool {
82
        let normalizedName = normalizedText(name)
83
        guard !normalizedName.isEmpty else { return false }
84
        guard supportsWiredCharging || supportsWirelessCharging else { return false }
85

            
86
        var didSave = false
87
        context.performAndWait {
88
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
89
                return
90
            }
91

            
92
            let object = NSManagedObject(entity: entity, insertInto: context)
93
            let now = Date()
94
            object.setValue(UUID().uuidString, forKey: "id")
95
            object.setValue(normalizedName, forKey: "name")
96
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
97
            object.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
98
            object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
99
            object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
100
            object.setValue(
101
                resolvedPreferredChargingTransportMode(
102
                    preferredChargingTransportMode,
103
                    supportsWiredCharging: supportsWiredCharging,
104
                    supportsWirelessCharging: supportsWirelessCharging
105
                ).rawValue,
106
                forKey: "preferredChargingTransportRawValue"
107
            )
108
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
109
            object.setValue(wiredChargeCompletionCurrentAmps, forKey: "wiredChargeCompletionCurrentAmps")
110
            object.setValue(wirelessChargeCompletionCurrentAmps, forKey: "wirelessChargeCompletionCurrentAmps")
111
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
112
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
113
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
114
            object.setValue(now, forKey: "createdAt")
115
            object.setValue(now, forKey: "updatedAt")
116
            didSave = saveContext()
117
        }
118
        return didSave
119
    }
120

            
121
    @discardableResult
122
    func updateChargedDevice(
123
        id: UUID,
124
        name: String,
125
        deviceClass: ChargedDeviceClass,
126
        supportsChargingWhileOff: Bool,
127
        supportsWiredCharging: Bool,
128
        supportsWirelessCharging: Bool,
129
        preferredChargingTransportMode: ChargingTransportMode,
130
        wirelessChargingProfile: WirelessChargingProfile,
131
        wiredChargeCompletionCurrentAmps: Double?,
132
        wirelessChargeCompletionCurrentAmps: Double?,
133
        notes: String?
134
    ) -> Bool {
135
        let normalizedName = normalizedText(name)
136
        guard !normalizedName.isEmpty else { return false }
137
        guard supportsWiredCharging || supportsWirelessCharging else { return false }
138

            
139
        var didSave = false
140
        context.performAndWait {
141
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
142
                return
143
            }
144

            
145
            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
146
            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
147
            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
148
            let previousPreferredChargingTransportMode = self.preferredChargingTransportMode(for: object)
149
            let resolvedPreferredTransportMode = resolvedPreferredChargingTransportMode(
150
                preferredChargingTransportMode,
151
                supportsWiredCharging: supportsWiredCharging,
152
                supportsWirelessCharging: supportsWirelessCharging
153
            )
154
            let now = Date()
155

            
156
            object.setValue(normalizedName, forKey: "name")
157
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
158
            object.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
159
            object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
160
            object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
161
            object.setValue(resolvedPreferredTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
162
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
163
            object.setValue(wiredChargeCompletionCurrentAmps, forKey: "wiredChargeCompletionCurrentAmps")
164
            object.setValue(wirelessChargeCompletionCurrentAmps, forKey: "wirelessChargeCompletionCurrentAmps")
165
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
166
            object.setValue(now, forKey: "updatedAt")
167

            
168
            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
169
            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
170
                || previousSupportsWiredCharging != supportsWiredCharging
171
                || previousSupportsWirelessCharging != supportsWirelessCharging
172
                || previousPreferredChargingTransportMode != resolvedPreferredTransportMode
173

            
174
            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
175
                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
176
                for session in sessions {
177
                    let isActive = statusValue(session, key: "statusRawValue") == .active
178

            
179
                    if shouldRecalculateSessionCapacity {
180
                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
181
                        updateCapacityEstimate(for: session)
182
                        session.setValue(now, forKey: "updatedAt")
183
                    }
184

            
185
                    guard isActive, shouldRefreshActiveSessions else {
186
                        continue
187
                    }
188

            
189
                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
190
                        chargingTransportMode(for: session),
191
                        supportsWiredCharging: supportsWiredCharging,
192
                        supportsWirelessCharging: supportsWirelessCharging
193
                    )
194
                    let fallbackStopThreshold = max(optionalDoubleValue(session, key: "stopThresholdAmps") ?? 0.01, 0.01)
195

            
196
                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
197
                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
198
                    session.setValue(
199
                        resolvedStopThreshold(
200
                            for: object,
201
                            chargingTransportMode: resolvedSessionChargingTransportMode,
202
                            fallback: fallbackStopThreshold
203
                        ),
204
                        forKey: "stopThresholdAmps"
205
                    )
206
                    session.setValue(now, forKey: "updatedAt")
207
                    updateCapacityEstimate(for: session)
208
                }
209
            }
210

            
211
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
212
            didSave = saveContext()
213
        }
214
        return didSave
215
    }
216

            
217
    @discardableResult
218
    func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
219
        assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
220
    }
221

            
222
    @discardableResult
223
    func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
224
        assign(itemWithID: id, to: meterMACAddress, kind: .charger)
225
    }
226

            
227
    @discardableResult
228
    private func assign(
229
        itemWithID id: UUID,
230
        to meterMACAddress: String,
231
        kind: MeterAssignmentKind
232
    ) -> Bool {
233
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
234
        guard !normalizedMAC.isEmpty else { return false }
235

            
236
        var didSave = false
237
        context.performAndWait {
238
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
239
                return
240
            }
241

            
242
            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
243
            guard isCharger == kind.expectsChargerClass else {
244
                return
245
            }
246

            
247
            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
248
            request.predicate = NSPredicate(
249
                format: "lastAssociatedMeterMAC == %@ AND id != %@",
250
                normalizedMAC,
251
                id.uuidString
252
            )
253
            let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
254
            for previousDevice in previouslyAssignedDevices {
255
                let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
256
                guard previousIsCharger == kind.expectsChargerClass else {
257
                    continue
258
                }
259
                previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
260
                previousDevice.setValue(Date(), forKey: "updatedAt")
261
            }
262

            
263
            object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
264
            object.setValue(Date(), forKey: "updatedAt")
265

            
266
            if kind == .charger,
267
               let activeSession = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC),
268
               chargingTransportMode(for: activeSession) == .wireless {
269
                activeSession.setValue(id.uuidString, forKey: "chargerID")
270
                activeSession.setValue(Date(), forKey: "updatedAt")
271
            }
272

            
273
            didSave = saveContext()
274
        }
275
        return didSave
276
    }
277

            
278
    @discardableResult
279
    func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meterMACAddress: String) -> Bool {
280
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
281
        guard !normalizedMAC.isEmpty else { return false }
282

            
283
        var didSave = false
284
        context.performAndWait {
285
            let activeSession = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC)
286
            let device = (activeSession.flatMap { stringValue($0, key: "chargedDeviceID") }.flatMap(fetchChargedDeviceObject(id:)))
287
                ?? resolvedDeviceObject(for: normalizedMAC)
288

            
289
            guard let device else {
290
                return
291
            }
292

            
293
            let resolvedMode = resolvedPreferredChargingTransportMode(
294
                chargingTransportMode,
295
                supportsWiredCharging: supportsWiredCharging(for: device),
296
                supportsWirelessCharging: supportsWirelessCharging(for: device)
297
            )
298
            let charger = resolvedMode == .wireless ? resolvedChargerObject(for: normalizedMAC) : nil
299
            guard resolvedMode == .wired || charger != nil else {
300
                return
301
            }
302

            
303
            device.setValue(resolvedMode.rawValue, forKey: "preferredChargingTransportRawValue")
304
            device.setValue(Date(), forKey: "updatedAt")
305

            
306
            if let activeSession {
307
                activeSession.setValue(resolvedMode.rawValue, forKey: "chargingTransportRawValue")
308
                activeSession.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
309
                activeSession.setValue(Date(), forKey: "updatedAt")
310
            }
311

            
312
            didSave = saveContext()
313
        }
314

            
315
        return didSave
316
    }
317

            
318
    @discardableResult
319
    func ensureSession(for snapshot: ChargingMonitorSnapshot, forceStart: Bool) -> Bool {
320
        var didSave = false
321
        context.performAndWait {
322
            guard let resolved = resolvedDeviceObject(for: snapshot.meterMACAddress) else {
323
                return
324
            }
325

            
326
            if fetchActiveSessionObject(forMeterMACAddress: snapshot.meterMACAddress) != nil {
327
                didSave = false
328
                return
329
            }
330

            
331
            let chargingTransportMode = preferredChargingTransportMode(for: resolved)
332
            let charger = chargingTransportMode == .wireless
333
                ? resolvedChargerObject(for: snapshot.meterMACAddress)
334
                : nil
335
            guard chargingTransportMode == .wired || charger != nil else {
336
                return
337
            }
338
            let stopThreshold = resolvedStopThreshold(
339
                for: resolved,
340
                chargingTransportMode: chargingTransportMode,
341
                fallback: snapshot.fallbackStopThresholdAmps
342
            )
343
            guard forceStart || snapshot.currentAmps > stopThreshold else {
344
                return
345
            }
346

            
347
            _ = createSessionObject(
348
                for: resolved,
349
                charger: charger,
350
                snapshot: snapshot,
351
                stopThreshold: stopThreshold,
352
                chargingTransportMode: chargingTransportMode
353
            )
354
            didSave = saveContext()
355
        }
356
        return didSave
357
    }
358

            
359
    @discardableResult
360
    func addBatteryCheckpoint(
361
        percent: Double,
362
        label: String?,
363
        for meterMACAddress: String
364
    ) -> Bool {
365
        guard percent.isFinite, percent >= 0, percent <= 100 else {
366
            return false
367
        }
368

            
369
        var didSave = false
370
        context.performAndWait {
371
            guard
372
                let session = fetchActiveSessionObject(forMeterMACAddress: meterMACAddress)
373
                    ?? fetchLatestSessionObject(forMeterMACAddress: meterMACAddress)
374
            else {
375
                return
376
            }
377

            
378
            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
379
        }
380
        return didSave
381
    }
382

            
383
    @discardableResult
384
    func addBatteryCheckpoint(
385
        percent: Double,
386
        label: String?,
387
        for sessionID: UUID
388
    ) -> Bool {
389
        guard percent.isFinite, percent >= 0, percent <= 100 else {
390
            return false
391
        }
392

            
393
        var didSave = false
394
        context.performAndWait {
395
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
396
                return
397
            }
398

            
399
            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
400
        }
401
        return didSave
402
    }
403

            
404
    @discardableResult
405
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
406
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
407
            return false
408
        }
409

            
410
        var didSave = false
411
        context.performAndWait {
412
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
413
                return
414
            }
415

            
416
            session.setValue(percent, forKey: "targetBatteryPercent")
417
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
418
            session.setValue(Date(), forKey: "updatedAt")
419
            didSave = saveContext()
420
        }
421
        return didSave
422
    }
423

            
424
    @discardableResult
425
    func confirmCompletion(for sessionID: UUID) -> Bool {
426
        var didSave = false
427
        context.performAndWait {
428
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
429
                return
430
            }
431

            
432
            guard statusValue(session, key: "statusRawValue") == .active else {
433
                return
434
            }
435

            
436
            let endedAt = dateValue(session, key: "lastObservedAt") ?? Date()
437
            session.setValue(ChargeSessionStatus.completed.rawValue, forKey: "statusRawValue")
438
            session.setValue(endedAt, forKey: "endedAt")
439
            session.setValue(optionalDoubleValue(session, key: "lastObservedCurrentAmps"), forKey: "completionCurrentAmps")
440
            clearCompletionConfirmationState(for: session)
441
            updateCapacityEstimate(for: session)
442
            session.setValue(Date(), forKey: "updatedAt")
443

            
444
            if saveContext() {
445
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
446
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
447
                    didSave = saveContext()
448
                } else {
449
                    didSave = true
450
                }
451
            }
452
        }
453
        return didSave
454
    }
455

            
456
    @discardableResult
457
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
458
        var didSave = false
459
        context.performAndWait {
460
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
461
                return
462
            }
463

            
464
            guard statusValue(session, key: "statusRawValue") == .active else {
465
                return
466
            }
467

            
468
            clearCompletionConfirmationState(for: session)
469
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
470
            session.setValue(Date(), forKey: "updatedAt")
471
            didSave = saveContext()
472
        }
473
        return didSave
474
    }
475

            
476
    @discardableResult
477
    func deleteChargeSession(id sessionID: UUID) -> Bool {
478
        var didSave = false
479
        context.performAndWait {
480
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
481
                return
482
            }
483

            
484
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
485

            
486
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
487
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
488
            context.delete(session)
489

            
490
            guard saveContext() else {
491
                return
492
            }
493

            
494
            if let chargedDeviceID {
495
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
496
                didSave = saveContext()
497
            } else {
498
                didSave = true
499
            }
500
        }
501
        return didSave
502
    }
503

            
504
    @discardableResult
505
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
506
        var didSave = false
507

            
508
        context.performAndWait {
509
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
510
                return
511
            }
512

            
513
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
514
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
515
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
516

            
517
            var impactedChargedDeviceIDs = Set<String>()
518

            
519
            for session in deviceSessions {
520
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
521
                    impactedChargedDeviceIDs.insert(impactedID)
522
                }
523
                if let impactedChargerID = stringValue(session, key: "chargerID") {
524
                    impactedChargedDeviceIDs.insert(impactedChargerID)
525
                }
526
                if let sessionID = stringValue(session, key: "id") {
527
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
528
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
529
                }
530
                context.delete(session)
531
            }
532

            
533
            if deviceClass == .charger {
534
                for session in linkedWirelessSessions {
535
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
536
                        continue
537
                    }
538
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
539
                        impactedChargedDeviceIDs.insert(impactedID)
540
                    }
541
                    session.setValue(nil, forKey: "chargerID")
542
                    session.setValue(Date(), forKey: "updatedAt")
543
                }
544
            }
545

            
546
            context.delete(chargedDevice)
547

            
548
            guard saveContext() else {
549
                return
550
            }
551

            
552
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
553
            for impactedID in impactedChargedDeviceIDs {
554
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
555
            }
556
            didSave = saveContext()
557
        }
558

            
559
        return didSave
560
    }
561

            
562
    @discardableResult
563
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
564
        var didSave = false
565

            
566
        context.performAndWait {
567
            let activeSession = fetchActiveSessionObject(forMeterMACAddress: snapshot.meterMACAddress)
568
            let resolvedDevice = activeSession.flatMap {
569
                stringValue($0, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:))
570
            } ?? resolvedDeviceObject(for: snapshot.meterMACAddress)
571

            
572
            guard let resolvedDevice else {
573
                return
574
            }
575

            
576
            let chargingTransportMode = activeSession.map { self.chargingTransportMode(for: $0) }
577
                ?? preferredChargingTransportMode(for: resolvedDevice)
578
            let charger = chargingTransportMode == .wireless
579
                ? (activeSession.flatMap { stringValue($0, key: "chargerID") }.flatMap(fetchChargedDeviceObject(id:))
580
                    ?? resolvedChargerObject(for: snapshot.meterMACAddress))
581
                : nil
582
            guard chargingTransportMode == .wired || charger != nil else {
583
                return
584
            }
585
            let stopThreshold = resolvedStopThreshold(
586
                for: resolvedDevice,
587
                chargingTransportMode: chargingTransportMode,
588
                fallback: snapshot.fallbackStopThresholdAmps
589
            )
590
            let session = activeSession ?? {
591
                guard snapshot.currentAmps > stopThreshold else {
592
                    return nil
593
                }
594
                return createSessionObject(
595
                    for: resolvedDevice,
596
                    charger: charger,
597
                    snapshot: snapshot,
598
                    stopThreshold: stopThreshold,
599
                    chargingTransportMode: chargingTransportMode
600
                )
601
            }()
602

            
603
            guard let session else {
604
                return
605
            }
606

            
607
            update(session: session, with: snapshot, stopThreshold: stopThreshold)
608
            updateAggregatedSample(session: session, with: snapshot)
609

            
610
            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
611
            guard saveReason != .none else {
612
                return
613
            }
614

            
615
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
616

            
617
            if saveContext() {
618
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
619
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
620
                    didSave = saveContext()
621
                } else {
622
                    didSave = true
623
                }
624
            }
625
        }
626

            
627
        return didSave
628
    }
629

            
630
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
631
        var summaries: [ChargedDeviceSummary] = []
632

            
633
        context.performAndWait {
634
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
635
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
636
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
637
            let sessionSamples = fetchObjects(entityName: EntityName.chargeSessionSample)
638

            
639
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
640
            let samplesBySessionID = Dictionary(grouping: sessionSamples) { stringValue($0, key: "sessionID") ?? "" }
641
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
642
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
643

            
644
            summaries = devices.compactMap { device in
645
                guard
646
                    let id = uuidValue(device, key: "id"),
647
                    let name = stringValue(device, key: "name"),
648
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
649
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
650
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
651
                else {
652
                    return nil
653
                }
654

            
655
                let sessionObjects = relevantSessionObjects(
656
                    for: id.uuidString,
657
                    deviceClass: deviceClass,
658
                    sessionsByDeviceID: sessionsByDeviceID,
659
                    sessionsByChargerID: sessionsByChargerID
660
                )
661
                let sessionSummaries = sessionObjects
662
                    .compactMap { session in
663
                        makeSessionSummary(
664
                            from: session,
665
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
666
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
667
                        )
668
                    }
669
                    .sorted { lhs, rhs in
670
                        if lhs.status == .active && rhs.status != .active {
671
                            return true
672
                        }
673
                        if lhs.status != .active && rhs.status == .active {
674
                            return false
675
                        }
676
                        return lhs.startedAt > rhs.startedAt
677
                    }
678

            
679
                return ChargedDeviceSummary(
680
                    id: id,
681
                    qrIdentifier: qrIdentifier,
682
                    name: name,
683
                    deviceClass: deviceClass,
684
                    supportsChargingWhileOff: boolValue(device, key: "supportsChargingWhileOff"),
685
                    supportsWiredCharging: supportsWiredCharging(for: device),
686
                    supportsWirelessCharging: supportsWirelessCharging(for: device),
687
                    preferredChargingTransportMode: preferredChargingTransportMode(for: device),
688
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
689
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
690
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
691
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
692
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
693
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
694
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
695
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
696
                    notes: stringValue(device, key: "notes"),
697
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
698
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
699
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
700
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
701
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
702
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
703
                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
704
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
705
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
706
                    sessions: sessionSummaries,
707
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
708
                    typicalCurve: buildTypicalCurve(from: sessionSummaries)
709
                )
710
            }
711
            .sorted { lhs, rhs in
712
                if lhs.activeSession != nil && rhs.activeSession == nil {
713
                    return true
714
                }
715
                if lhs.activeSession == nil && rhs.activeSession != nil {
716
                    return false
717
                }
718
                if lhs.updatedAt != rhs.updatedAt {
719
                    return lhs.updatedAt > rhs.updatedAt
720
                }
721
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
722
            }
723
        }
724

            
725
        return summaries
726
    }
727

            
728
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
729
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
730
        guard !normalizedMAC.isEmpty else { return nil }
731

            
732
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
733

            
734
        if let activeMatch = summaries.first(where: { summary in
735
            summary.activeSession?.meterMACAddress == normalizedMAC
736
        }) {
737
            return activeMatch
738
        }
739

            
740
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
741
    }
742

            
743
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
744
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
745
        guard !normalizedMAC.isEmpty else { return nil }
746

            
747
        return fetchChargedDeviceSummaries()
748
            .flatMap(\.sessions)
749
            .first(where: {
750
                $0.status == .active && $0.meterMACAddress == normalizedMAC
751
            })
752
    }
753

            
754
    private func createSessionObject(
755
        for chargedDevice: NSManagedObject,
756
        charger: NSManagedObject?,
757
        snapshot: ChargingMonitorSnapshot,
758
        stopThreshold: Double,
759
        chargingTransportMode: ChargingTransportMode
760
    ) -> NSManagedObject? {
761
        guard
762
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
763
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
764
        else {
765
            return nil
766
        }
767

            
768
        let session = NSManagedObject(entity: entity, insertInto: context)
769
        let now = snapshot.observedAt
770
        session.setValue(UUID().uuidString, forKey: "id")
771
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
772
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
773
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
774
        session.setValue(snapshot.meterName, forKey: "meterName")
775
        session.setValue(snapshot.meterModel, forKey: "meterModel")
776
        session.setValue(now, forKey: "startedAt")
777
        session.setValue(now, forKey: "lastObservedAt")
778
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
779
        session.setValue(ChargeSessionSourceMode.live.rawValue, forKey: "sourceModeRawValue")
780
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
781
        session.setValue(stopThreshold, forKey: "stopThresholdAmps")
782
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
783
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
784
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
785
        session.setValue(
786
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
787
            forKey: "lastObservedVoltageVolts"
788
        )
789
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
790
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
791
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
792
        session.setValue(
793
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
794
            forKey: "maximumObservedVoltageVolts"
795
        )
796
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
797
        if let selectedDataGroup = snapshot.selectedDataGroup {
798
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
799
        }
800
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
801
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
802
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
803
        }
804
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
805
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
806
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
807
        }
808
        session.setValue(now, forKey: "createdAt")
809
        session.setValue(now, forKey: "updatedAt")
810

            
811
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
812
        chargedDevice.setValue(chargingTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
813
        chargedDevice.setValue(now, forKey: "updatedAt")
814
        return session
815
    }
816

            
817
    private func update(
818
        session: NSManagedObject,
819
        with snapshot: ChargingMonitorSnapshot,
820
        stopThreshold: Double
821
    ) {
822
        let sessionChargingTransportMode = chargingTransportMode(for: session)
823
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
824
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
825
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
826
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
827
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
828
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
829
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
830

            
831
        if let lastObservedAt {
832
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
833
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
834
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
835
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
836
                if sourceMode == .offline {
837
                    sourceMode = .blended
838
                }
839
            }
840
        }
841

            
842
        if let counterGroup = snapshot.selectedDataGroup,
843
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
844
           UInt8(storedGroup) != counterGroup {
845
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
846
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
847
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
848
        }
849

            
850
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
851
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
852
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
853
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
854
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
855
            }
856

            
857
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
858
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
859
                if offlineEnergy > measuredEnergyWh {
860
                    measuredEnergyWh = offlineEnergy
861
                }
862
                usedOfflineMeterCounters = true
863
                sourceMode = sourceMode == .live && measuredEnergyWh > 0 ? .blended : .offline
864
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
865
                let delta = meterEnergyCounterWh - lastEnergy
866
                if delta > 0 {
867
                    measuredEnergyWh += delta
868
                    usedOfflineMeterCounters = true
869
                    sourceMode = .blended
870
                }
871
            }
872
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
873
        }
874

            
875
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
876
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
877
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
878
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
879
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
880
            }
881

            
882
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
883
                let offlineCharge = meterChargeCounterAh - baselineCharge
884
                if offlineCharge > measuredChargeAh {
885
                    measuredChargeAh = offlineCharge
886
                }
887
                usedOfflineMeterCounters = true
888
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
889
                let delta = meterChargeCounterAh - lastCharge
890
                if delta > 0 {
891
                    measuredChargeAh += delta
892
                    usedOfflineMeterCounters = true
893
                }
894
            }
895
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
896
        }
897

            
898
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
899
        let updatedMinimum: Double
900
        if snapshot.currentAmps > 0 {
901
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
902
        } else {
903
            updatedMinimum = existingMinimum ?? 0
904
        }
905

            
906
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
907
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
908
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
909
        session.setValue(stopThreshold, forKey: "stopThresholdAmps")
910
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
911
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
912
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
913
        session.setValue(
914
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
915
            forKey: "lastObservedVoltageVolts"
916
        )
917
        session.setValue(
918
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
919
            forKey: "maximumObservedCurrentAmps"
920
        )
921
        session.setValue(
922
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
923
            forKey: "maximumObservedPowerWatts"
924
        )
925
        session.setValue(
926
            sessionChargingTransportMode == .wired
927
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
928
                : nil,
929
            forKey: "maximumObservedVoltageVolts"
930
        )
931
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
932
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
933
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
934

            
935
        if snapshot.currentAmps <= stopThreshold {
936
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
937
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
938
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
939
                if boolValue(session, key: "requiresCompletionConfirmation") {
940
                    // Leave the session active until the user explicitly confirms or charging resumes.
941
                    return
942
                }
943

            
944
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
945
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
946
                } else {
947
                    session.setValue(ChargeSessionStatus.completed.rawValue, forKey: "statusRawValue")
948
                    session.setValue(snapshot.observedAt, forKey: "endedAt")
949
                    session.setValue(snapshot.currentAmps, forKey: "completionCurrentAmps")
950
                    updateCapacityEstimate(for: session)
951
                }
952
            }
953
        } else {
954
            session.setValue(nil, forKey: "belowThresholdSince")
955
            clearCompletionConfirmationState(for: session)
956
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
957
        }
958
    }
959

            
960
    private func updateAggregatedSample(
961
        session: NSManagedObject,
962
        with snapshot: ChargingMonitorSnapshot
963
    ) {
964
        guard
965
            let sessionID = stringValue(session, key: "id"),
966
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
967
            let startedAt = dateValue(session, key: "startedAt"),
968
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
969
        else {
970
            return
971
        }
972

            
973
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
974
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
975
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
976
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
977
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
978
            ?? NSManagedObject(entity: entity, insertInto: context)
979
        let sessionChargingTransportMode = chargingTransportMode(for: session)
980
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
981

            
982
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
983
        let updatedCount = existingCount + 1
984

            
985
        sample.setValue(bucketIdentifier, forKey: "id")
986
        sample.setValue(sessionID, forKey: "sessionID")
987
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
988
        sample.setValue(bucketIndex, forKey: "bucketIndex")
989
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
990
        sample.setValue(
991
            runningAverage(
992
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
993
                currentCount: Int(existingCount),
994
                newValue: snapshot.currentAmps
995
            ),
996
            forKey: "averageCurrentAmps"
997
        )
998
        sample.setValue(
999
            sampleVoltage.flatMap { voltage in
1000
                runningAverage(
1001
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1002
                    currentCount: Int(existingCount),
1003
                    newValue: voltage
1004
                )
1005
            },
1006
            forKey: "averageVoltageVolts"
1007
        )
1008
        sample.setValue(
1009
            runningAverage(
1010
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1011
                currentCount: Int(existingCount),
1012
                newValue: snapshot.powerWatts
1013
            ),
1014
            forKey: "averagePowerWatts"
1015
        )
1016
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1017
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1018
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1019
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1020
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
1021
    }
1022

            
1023
    private func maybeTriggerTargetBatteryAlert(for session: NSManagedObject, observedAt: Date) {
1024
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1025
            return
1026
        }
1027

            
1028
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1029
            return
1030
        }
1031

            
1032
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1033
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
1034

            
1035
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1036
            return
1037
        }
1038

            
1039
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1040
    }
1041

            
1042
    private func shouldRequireCompletionConfirmation(
1043
        for session: NSManagedObject,
1044
        observedAt: Date
1045
    ) -> Bool {
1046
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1047
           cooldownUntil > observedAt {
1048
            return false
1049
        }
1050

            
1051
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1052
            return false
1053
        }
1054

            
1055
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1056
            ?? defaultCompletionPercentThreshold
1057

            
1058
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1059
    }
1060

            
1061
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1062
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1063
            return
1064
        }
1065

            
1066
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1067
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1068
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1069
    }
1070

            
1071
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1072
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1073
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1074
        session.setValue(nil, forKey: "completionContradictionPercent")
1075
    }
1076

            
1077
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1078
        guard
1079
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1080
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1081
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1082
            estimatedCapacityWh > 0
1083
        else {
1084
            return nil
1085
        }
1086

            
1087
        let measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1088
            ?? doubleValue(session, key: "measuredEnergyWh")
1089
        let sessionID = stringValue(session, key: "id") ?? ""
1090

            
1091
        struct Anchor {
1092
            let percent: Double
1093
            let energyWh: Double
1094
        }
1095

            
1096
        var anchors: [Anchor] = []
1097
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") {
1098
            anchors.append(Anchor(percent: startBatteryPercent, energyWh: 0))
1099
        }
1100

            
1101
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1102
            .compactMap(makeCheckpointSummary(from:))
1103
            .sorted { lhs, rhs in
1104
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1105
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1106
                }
1107
                return lhs.timestamp < rhs.timestamp
1108
            }
1109
            .map { Anchor(percent: $0.batteryPercent, energyWh: $0.measuredEnergyWh) }
1110
        anchors.append(contentsOf: checkpointAnchors)
1111

            
1112
        guard !anchors.isEmpty else {
1113
            return optionalDoubleValue(session, key: "endBatteryPercent")
1114
        }
1115

            
1116
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
1117
        return min(
1118
            100,
1119
            max(
1120
                0,
1121
                anchor.percent + (((measuredEnergyWh - anchor.energyWh) / estimatedCapacityWh) * 100)
1122
            )
1123
        )
1124
    }
1125

            
1126
    private func resolvedEstimatedBatteryCapacityWh(
1127
        for session: NSManagedObject,
1128
        chargedDevice: NSManagedObject
1129
    ) -> Double? {
1130
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1131
           sessionCapacityEstimate > 0 {
1132
            return sessionCapacityEstimate
1133
        }
1134

            
1135
        switch chargingTransportMode(for: session) {
1136
        case .wired:
1137
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1138
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1139
        case .wireless:
1140
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1141
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1142
        }
1143
    }
1144

            
1145
    private func updateCapacityEstimate(for session: NSManagedObject) {
1146
        guard
1147
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1148
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1149
        else {
1150
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1151
            session.setValue(nil, forKey: "capacityEstimateWh")
1152
            return
1153
        }
1154

            
1155
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1156
        let chargingMode = chargingTransportMode(for: session)
1157
        let wirelessResolution = chargingMode == .wireless
1158
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1159
            : nil
1160
        let effectiveBatteryEnergyWh = chargingMode == .wired
1161
            ? measuredEnergyWh
1162
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1163

            
1164
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1165
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1166
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1167
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1168

            
1169
        guard
1170
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1171
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1172
        else {
1173
            session.setValue(nil, forKey: "capacityEstimateWh")
1174
            return
1175
        }
1176

            
1177
        let percentDelta = endBatteryPercent - startBatteryPercent
1178
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1179

            
1180
        guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1181
            session.setValue(nil, forKey: "capacityEstimateWh")
1182
            return
1183
        }
1184

            
1185
        if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
1186
            session.setValue(nil, forKey: "capacityEstimateWh")
1187
            return
1188
        }
1189

            
1190
        let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
1191
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1192
    }
1193

            
1194
    @discardableResult
1195
    private func addBatteryCheckpoint(
1196
        percent: Double,
1197
        label: String?,
1198
        to session: NSManagedObject
1199
    ) -> Bool {
1200
        guard
1201
            let sessionID = stringValue(session, key: "id"),
1202
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1203
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1204
        else {
1205
            return false
1206
        }
1207

            
1208
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
1209
        let checkpointEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1210
            ?? doubleValue(session, key: "measuredEnergyWh")
1211
        checkpoint.setValue(UUID().uuidString, forKey: "id")
1212
        checkpoint.setValue(sessionID, forKey: "sessionID")
1213
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1214
        checkpoint.setValue(Date(), forKey: "timestamp")
1215
        checkpoint.setValue(percent, forKey: "batteryPercent")
1216
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
1217
        checkpoint.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1218
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1219
        checkpoint.setValue(
1220
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1221
            forKey: "voltageVolts"
1222
        )
1223
        checkpoint.setValue(normalizedOptionalText(label), forKey: "label")
1224
        checkpoint.setValue(Date(), forKey: "createdAt")
1225

            
1226
        if session.value(forKey: "startBatteryPercent") == nil {
1227
            session.setValue(percent, forKey: "startBatteryPercent")
1228
        }
1229
        session.setValue(percent, forKey: "endBatteryPercent")
1230
        session.setValue(Date(), forKey: "updatedAt")
1231
        updateCapacityEstimate(for: session)
1232

            
1233
        guard saveContext() else {
1234
            return false
1235
        }
1236

            
1237
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1238
        return saveContext()
1239
    }
1240

            
1241
    private func resolvedWirelessEfficiency(
1242
        for session: NSManagedObject,
1243
        chargedDevice: NSManagedObject
1244
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1245
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1246
           storedFactor > 0 {
1247
            return (
1248
                factor: storedFactor,
1249
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1250
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1251
            )
1252
        }
1253

            
1254
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1255
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1256
        guard measuredEnergyWh > 0 else {
1257
            return nil
1258
        }
1259

            
1260
        if chargingProfile == .magsafe,
1261
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1262
           calibratedFactor > 0 {
1263
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1264
        }
1265

            
1266
        guard
1267
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1268
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1269
        else {
1270
            return nil
1271
        }
1272

            
1273
        let percentDelta = endBatteryPercent - startBatteryPercent
1274
        guard percentDelta >= 20 else {
1275
            return nil
1276
        }
1277

            
1278
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1279
            ?? ((preferredChargingTransportMode(for: chargedDevice) == .wired)
1280
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1281
                : nil),
1282
              wiredCapacityWh > 0
1283
        else {
1284
            return nil
1285
        }
1286

            
1287
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
1288
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
1289
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
1290
        let usesEstimated = chargingProfile != .magsafe
1291
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
1292

            
1293
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
1294
    }
1295

            
1296
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
1297
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
1298
            return
1299
        }
1300

            
1301
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1302
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1303
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1304
        let sessions = relevantSessionObjects(
1305
            for: chargedDeviceID,
1306
            deviceClass: deviceClass,
1307
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
1308
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
1309
        )
1310
        let wiredMinimumCurrent = derivedMinimumCurrent(
1311
            from: sessions,
1312
            chargingTransportMode: .wired
1313
        )
1314
        let wirelessMinimumCurrent = derivedMinimumCurrent(
1315
            from: sessions,
1316
            chargingTransportMode: .wireless
1317
        )
1318

            
1319
        let wiredCapacity = derivedCapacity(
1320
            from: sessions,
1321
            chargingTransportMode: .wired,
1322
            supportsChargingWhileOff: supportsChargingWhileOff
1323
        )
1324
        let wirelessCapacity = derivedCapacity(
1325
            from: sessions,
1326
            chargingTransportMode: .wireless,
1327
            supportsChargingWhileOff: supportsChargingWhileOff
1328
        )
1329
        let wirelessEfficiency = derivedWirelessEfficiency(
1330
            from: sessions,
1331
            chargingProfile: wirelessProfile
1332
        )
1333
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1334
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1335
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
1336
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
1337
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
1338
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1339

            
1340
        let preferredChargingTransportMode = preferredChargingTransportMode(for: chargedDevice)
1341
        let preferredMinimumCurrent: Double?
1342
        let preferredCapacity: Double?
1343
        switch preferredChargingTransportMode {
1344
        case .wired:
1345
            preferredMinimumCurrent = configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
1346
            preferredCapacity = wiredCapacity ?? wirelessCapacity
1347
        case .wireless:
1348
            preferredMinimumCurrent = configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
1349
            preferredCapacity = wirelessCapacity ?? wiredCapacity
1350
        }
1351

            
1352
        chargedDevice.setValue(wiredMinimumCurrent, forKey: "wiredMinimumCurrentAmps")
1353
        chargedDevice.setValue(wirelessMinimumCurrent, forKey: "wirelessMinimumCurrentAmps")
1354
        chargedDevice.setValue(wiredCapacity, forKey: "wiredEstimatedBatteryCapacityWh")
1355
        chargedDevice.setValue(wirelessCapacity, forKey: "wirelessEstimatedBatteryCapacityWh")
1356
        chargedDevice.setValue(wirelessEfficiency, forKey: "wirelessChargerEfficiencyFactor")
1357
        chargedDevice.setValue(encodedObservedVoltageSelections(chargerObservedVoltages), forKey: "chargerObservedVoltageSelectionsRawValue")
1358
        chargedDevice.setValue(chargerIdleCurrent, forKey: "chargerIdleCurrentAmps")
1359
        chargedDevice.setValue(chargerEfficiency, forKey: "chargerEfficiencyFactor")
1360
        chargedDevice.setValue(chargerMaximumPower, forKey: "chargerMaximumPowerWatts")
1361
        chargedDevice.setValue(preferredMinimumCurrent, forKey: "minimumCurrentAmps")
1362
        chargedDevice.setValue(preferredCapacity, forKey: "estimatedBatteryCapacityWh")
1363
        chargedDevice.setValue(Date(), forKey: "updatedAt")
1364
    }
1365

            
1366
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
1367
        sessions
1368
            .filter { $0.status == .completed }
1369
            .compactMap { session in
1370
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
1371
                let timestamp = session.endedAt ?? session.lastObservedAt
1372
                return CapacityTrendPoint(
1373
                    sessionID: session.id,
1374
                    timestamp: timestamp,
1375
                    capacityWh: capacityEstimateWh,
1376
                    chargingTransportMode: session.chargingTransportMode
1377
                )
1378
            }
1379
            .sorted { $0.timestamp < $1.timestamp }
1380
    }
1381

            
1382
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
1383
        var groupedEnergyByBin: [Int: [Double]] = [:]
1384
        var groupedChargeByBin: [Int: [Double]] = [:]
1385

            
1386
        for session in sessions where session.status == .completed {
1387
            var points = session.checkpoints
1388

            
1389
            if let startBatteryPercent = session.startBatteryPercent {
1390
                points.append(
1391
                    ChargeCheckpointSummary(
1392
                        id: UUID(),
1393
                        sessionID: session.id,
1394
                        chargedDeviceID: session.chargedDeviceID,
1395
                        timestamp: session.startedAt,
1396
                        batteryPercent: startBatteryPercent,
1397
                        measuredEnergyWh: 0,
1398
                        measuredChargeAh: 0,
1399
                        currentAmps: 0,
1400
                        voltageVolts: nil,
1401
                        label: "Start"
1402
                    )
1403
                )
1404
            }
1405

            
1406
            if let endBatteryPercent = session.endBatteryPercent {
1407
                points.append(
1408
                    ChargeCheckpointSummary(
1409
                        id: UUID(),
1410
                        sessionID: session.id,
1411
                        chargedDeviceID: session.chargedDeviceID,
1412
                        timestamp: session.endedAt ?? session.lastObservedAt,
1413
                        batteryPercent: endBatteryPercent,
1414
                        measuredEnergyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
1415
                        measuredChargeAh: session.measuredChargeAh,
1416
                        currentAmps: 0,
1417
                        voltageVolts: nil,
1418
                        label: "End"
1419
                    )
1420
                )
1421
            }
1422

            
1423
            for point in points {
1424
                let percentBin = Int((point.batteryPercent / 10).rounded(.toNearestOrEven)) * 10
1425
                groupedEnergyByBin[percentBin, default: []].append(point.measuredEnergyWh)
1426
                groupedChargeByBin[percentBin, default: []].append(point.measuredChargeAh)
1427
            }
1428
        }
1429

            
1430
        return groupedEnergyByBin.keys.sorted().compactMap { percentBin in
1431
            guard
1432
                let energies = groupedEnergyByBin[percentBin],
1433
                let charges = groupedChargeByBin[percentBin],
1434
                !energies.isEmpty,
1435
                !charges.isEmpty
1436
            else {
1437
                return nil
1438
            }
1439

            
1440
            return TypicalChargeCurvePoint(
1441
                percentBin: percentBin,
1442
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
1443
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
1444
                sampleCount: min(energies.count, charges.count)
1445
            )
1446
        }
1447
    }
1448

            
1449
    private func makeSessionSummary(
1450
        from object: NSManagedObject,
1451
        checkpoints: [NSManagedObject],
1452
        samples: [NSManagedObject]
1453
    ) -> ChargeSessionSummary? {
1454
        let chargingTransportMode = chargingTransportMode(for: object)
1455

            
1456
        guard
1457
            let id = uuidValue(object, key: "id"),
1458
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1459
            let startedAt = dateValue(object, key: "startedAt"),
1460
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
1461
            let status = statusValue(object, key: "statusRawValue"),
1462
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
1463
        else {
1464
            return nil
1465
        }
1466

            
1467
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
1468
            .sorted { $0.timestamp < $1.timestamp }
1469
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
1470
            .sorted { lhs, rhs in
1471
                if lhs.bucketIndex != rhs.bucketIndex {
1472
                    return lhs.bucketIndex < rhs.bucketIndex
1473
                }
1474
                return lhs.timestamp < rhs.timestamp
1475
            }
1476

            
1477
        return ChargeSessionSummary(
1478
            id: id,
1479
            chargedDeviceID: chargedDeviceID,
1480
            chargerID: uuidValue(object, key: "chargerID"),
1481
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
1482
            meterName: stringValue(object, key: "meterName"),
1483
            meterModel: stringValue(object, key: "meterModel"),
1484
            startedAt: startedAt,
1485
            endedAt: dateValue(object, key: "endedAt"),
1486
            lastObservedAt: lastObservedAt,
1487
            status: status,
1488
            sourceMode: sourceMode,
1489
            chargingTransportMode: chargingTransportMode,
1490
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1491
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
1492
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1493
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
1494
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
1495
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
1496
            maximumObservedVoltageVolts: chargingTransportMode == .wired
1497
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
1498
                : nil,
1499
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
1500
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
1501
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
1502
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
1503
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
1504
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
1505
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
1506
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
1507
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
1508
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
1509
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
1510
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
1511
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
1512
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
1513
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
1514
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
1515
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
1516
            checkpoints: checkpointSummaries,
1517
            aggregatedSamples: sampleSummaries
1518
        )
1519
    }
1520

            
1521
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
1522
        guard
1523
            let id = uuidValue(object, key: "id"),
1524
            let sessionID = uuidValue(object, key: "sessionID"),
1525
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1526
            let timestamp = dateValue(object, key: "timestamp")
1527
        else {
1528
            return nil
1529
        }
1530

            
1531
        return ChargeCheckpointSummary(
1532
            id: id,
1533
            sessionID: sessionID,
1534
            chargedDeviceID: chargedDeviceID,
1535
            timestamp: timestamp,
1536
            batteryPercent: doubleValue(object, key: "batteryPercent"),
1537
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1538
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1539
            currentAmps: doubleValue(object, key: "currentAmps"),
1540
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
1541
            label: stringValue(object, key: "label")
1542
        )
1543
    }
1544

            
1545
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
1546
        guard
1547
            let sessionID = uuidValue(object, key: "sessionID"),
1548
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1549
            let timestamp = dateValue(object, key: "timestamp")
1550
        else {
1551
            return nil
1552
        }
1553

            
1554
        return ChargeSessionSampleSummary(
1555
            sessionID: sessionID,
1556
            chargedDeviceID: chargedDeviceID,
1557
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
1558
            timestamp: timestamp,
1559
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
1560
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
1561
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
1562
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1563
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1564
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
1565
        )
1566
    }
1567

            
1568
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
1569
        fetchSessionObject(
1570
            predicate: NSPredicate(
1571
                format: "meterMACAddress == %@ AND statusRawValue == %@",
1572
                normalizedMACAddress(meterMACAddress),
1573
                ChargeSessionStatus.active.rawValue
1574
            )
1575
        )
1576
    }
1577

            
1578
    private func fetchLatestSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
1579
        fetchSessionObject(
1580
            predicate: NSPredicate(
1581
                format: "meterMACAddress == %@",
1582
                normalizedMACAddress(meterMACAddress)
1583
            )
1584
        )
1585
    }
1586

            
1587
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
1588
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
1589
        request.predicate = predicate
1590
        request.fetchLimit = 1
1591
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
1592
        return (try? context.fetch(request))?.first
1593
    }
1594

            
1595
    private func fetchSessionObject(id: String) -> NSManagedObject? {
1596
        fetchSessionObject(
1597
            predicate: NSPredicate(format: "id == %@", id)
1598
        )
1599
    }
1600

            
1601
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
1602
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
1603
        request.predicate = NSPredicate(
1604
            format: "sessionID == %@ AND bucketIndex == %d",
1605
            sessionID,
1606
            bucketIndex
1607
        )
1608
        request.fetchLimit = 1
1609
        return (try? context.fetch(request))?.first
1610
    }
1611

            
1612
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
1613
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
1614
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
1615
        return (try? context.fetch(request)) ?? []
1616
    }
1617

            
1618
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
1619
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
1620
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
1621
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
1622
        return (try? context.fetch(request)) ?? []
1623
    }
1624

            
1625
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
1626
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
1627
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
1628
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
1629
        return (try? context.fetch(request)) ?? []
1630
    }
1631

            
1632
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
1633
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
1634
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
1635
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
1636
        return (try? context.fetch(request)) ?? []
1637
    }
1638

            
1639
    private func relevantSessionObjects(
1640
        for chargedDeviceID: String,
1641
        deviceClass: ChargedDeviceClass,
1642
        sessionsByDeviceID: [String: [NSManagedObject]],
1643
        sessionsByChargerID: [String: [NSManagedObject]]
1644
    ) -> [NSManagedObject] {
1645
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
1646
        guard deviceClass == .charger else {
1647
            return directSessions
1648
        }
1649

            
1650
        var seenSessionIDs = Set<String>()
1651
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
1652
            .filter { session in
1653
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
1654
                return seenSessionIDs.insert(sessionID).inserted
1655
            }
1656
            .sorted {
1657
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
1658
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
1659
                return lhsDate < rhsDate
1660
            }
1661
    }
1662

            
1663
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
1664
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
1665
    }
1666

            
1667
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
1668
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
1669
    }
1670

            
1671
    private func resolvedAssignedObject(
1672
        for meterMACAddress: String,
1673
        expectsChargerClass: Bool
1674
    ) -> NSManagedObject? {
1675
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1676
        guard !normalizedMAC.isEmpty else { return nil }
1677

            
1678
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
1679
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
1680
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
1681
        let matches = (try? context.fetch(request)) ?? []
1682
        return matches.first { object in
1683
            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
1684
            return isCharger == expectsChargerClass
1685
        }
1686
    }
1687

            
1688
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
1689
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
1690
        request.predicate = NSPredicate(format: "id == %@", id)
1691
        request.fetchLimit = 1
1692
        return (try? context.fetch(request))?.first
1693
    }
1694

            
1695
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
1696
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
1697
        return (try? context.fetch(request)) ?? []
1698
    }
1699

            
1700
    private func resolvedStopThreshold(
1701
        for chargedDevice: NSManagedObject,
1702
        chargingTransportMode: ChargingTransportMode,
1703
        fallback: Double
1704
    ) -> Double {
1705
        let persistedMinimum: Double?
1706
        switch chargingTransportMode {
1707
        case .wired:
1708
            persistedMinimum = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1709
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
1710
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
1711
        case .wireless:
1712
            persistedMinimum = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1713
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
1714
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
1715
        }
1716
        return max(persistedMinimum ?? fallback, 0.01)
1717
    }
1718

            
1719
    private func preferredChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
1720
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
1721
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
1722
        let persistedMode = chargingTransportModeValue(chargedDevice, key: "preferredChargingTransportRawValue") ?? .wired
1723
        return resolvedPreferredChargingTransportMode(
1724
            persistedMode,
1725
            supportsWiredCharging: supportsWiredCharging,
1726
            supportsWirelessCharging: supportsWirelessCharging
1727
        )
1728
    }
1729

            
1730
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
1731
        if chargedDevice.value(forKey: "supportsWiredCharging") == nil {
1732
            return true
1733
        }
1734
        return boolValue(chargedDevice, key: "supportsWiredCharging")
1735
    }
1736

            
1737
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
1738
        if chargedDevice.value(forKey: "supportsWirelessCharging") == nil {
1739
            return false
1740
        }
1741
        return boolValue(chargedDevice, key: "supportsWirelessCharging")
1742
    }
1743

            
1744
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
1745
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
1746
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
1747
            return .genericQi
1748
        }
1749
        return profile
1750
    }
1751

            
1752
    private func resolvedPreferredChargingTransportMode(
1753
        _ preferredChargingTransportMode: ChargingTransportMode,
1754
        supportsWiredCharging: Bool,
1755
        supportsWirelessCharging: Bool
1756
    ) -> ChargingTransportMode {
1757
        switch preferredChargingTransportMode {
1758
        case .wired where supportsWiredCharging:
1759
            return .wired
1760
        case .wireless where supportsWirelessCharging:
1761
            return .wireless
1762
        default:
1763
            if supportsWiredCharging {
1764
                return .wired
1765
            }
1766
            if supportsWirelessCharging {
1767
                return .wireless
1768
            }
1769
            return .wired
1770
        }
1771
    }
1772

            
1773
    private func derivedMinimumCurrent(
1774
        from sessions: [NSManagedObject],
1775
        chargingTransportMode: ChargingTransportMode
1776
    ) -> Double? {
1777
        let completionCurrents = sessions.compactMap { session -> Double? in
1778
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1779
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
1780
                return nil
1781
            }
1782
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
1783
                return nil
1784
            }
1785
            return completionCurrent
1786
        }
1787

            
1788
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
1789
        guard !recentCompletionCurrents.isEmpty else { return nil }
1790
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
1791
    }
1792

            
1793
    private func derivedCapacity(
1794
        from sessions: [NSManagedObject],
1795
        chargingTransportMode: ChargingTransportMode,
1796
        supportsChargingWhileOff: Bool
1797
    ) -> Double? {
1798
        let capacityCandidates = sessions.compactMap { session -> Double? in
1799
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1800
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
1801
                return nil
1802
            }
1803
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
1804
                return nil
1805
            }
1806
            if supportsChargingWhileOff {
1807
                return capacityEstimate
1808
            }
1809
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
1810
                return nil
1811
            }
1812
            return capacityEstimate
1813
        }
1814

            
1815
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
1816
        guard !recentCapacityCandidates.isEmpty else { return nil }
1817
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
1818
    }
1819

            
1820
    private func derivedWirelessEfficiency(
1821
        from sessions: [NSManagedObject],
1822
        chargingProfile: WirelessChargingProfile
1823
    ) -> Double? {
1824
        guard chargingProfile == .magsafe else {
1825
            return nil
1826
        }
1827

            
1828
        let candidates = sessions.compactMap { session -> Double? in
1829
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1830
            guard chargingTransportMode(for: session) == .wireless else { return nil }
1831
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
1832
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
1833
                return nil
1834
            }
1835
            return factor
1836
        }
1837

            
1838
        let recentCandidates = Array(candidates.suffix(6))
1839
        guard !recentCandidates.isEmpty else { return nil }
1840
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
1841
    }
1842

            
1843
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
1844
        let candidates = sessions.compactMap { session -> Double? in
1845
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1846
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
1847
                return nil
1848
            }
1849
            return (sourceVoltage * 10).rounded() / 10
1850
        }
1851

            
1852
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
1853
        return counts.keys.sorted()
1854
    }
1855

            
1856
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
1857
        let candidates = sessions.compactMap { session -> Double? in
1858
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1859
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
1860
                return nil
1861
            }
1862
            return minimumObservedCurrent
1863
        }
1864

            
1865
        let recentCandidates = Array(candidates.suffix(6))
1866
        guard !recentCandidates.isEmpty else { return nil }
1867
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
1868
    }
1869

            
1870
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
1871
        let candidates = sessions.compactMap { session -> Double? in
1872
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1873
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
1874
                return nil
1875
            }
1876
            return factor
1877
        }
1878

            
1879
        let recentCandidates = Array(candidates.suffix(6))
1880
        guard !recentCandidates.isEmpty else { return nil }
1881
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
1882
    }
1883

            
1884
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
1885
        sessions.compactMap { session -> Double? in
1886
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1887
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
1888
                return nil
1889
            }
1890
            return maximumObservedPower
1891
        }
1892
        .max()
1893
    }
1894

            
1895
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
1896
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
1897
            return persistedChargingTransportMode
1898
        }
1899

            
1900
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1901
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
1902
            return preferredChargingTransportMode(for: chargedDevice)
1903
        }
1904

            
1905
        return .wired
1906
    }
1907

            
1908
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
1909
        if session.isInserted {
1910
            return .created
1911
        }
1912

            
1913
        let committedValues = session.committedValues(
1914
            forKeys: [
1915
                "statusRawValue",
1916
                "updatedAt",
1917
                "targetBatteryAlertTriggeredAt",
1918
                "requiresCompletionConfirmation"
1919
            ]
1920
        )
1921
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
1922
        let currentStatus = statusValue(session, key: "statusRawValue")
1923
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
1924
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
1925
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
1926
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
1927
            ?? false
1928
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
1929

            
1930
        if currentStatus == .completed, committedStatus != .completed {
1931
            return .completed
1932
        }
1933

            
1934
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
1935
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
1936
            return .event
1937
        }
1938

            
1939
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
1940
            ?? dateValue(session, key: "createdAt")
1941
            ?? observedAt
1942

            
1943
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
1944
            return .periodic
1945
        }
1946

            
1947
        return .none
1948
    }
1949

            
1950
    private func generateQRIdentifier() -> String {
1951
        "device:\(UUID().uuidString)"
1952
    }
1953

            
1954
    @discardableResult
1955
    private func saveContext() -> Bool {
1956
        guard context.hasChanges else { return true }
1957
        do {
1958
            try context.save()
1959
            return true
1960
        } catch {
1961
            track("Failed saving charge insights context: \(error)")
1962
            context.rollback()
1963
            return false
1964
        }
1965
    }
1966

            
1967
    private func normalizedText(_ text: String) -> String {
1968
        text.trimmingCharacters(in: .whitespacesAndNewlines)
1969
    }
1970

            
1971
    private func normalizedOptionalText(_ text: String?) -> String? {
1972
        guard let text else { return nil }
1973
        let normalized = normalizedText(text)
1974
        return normalized.isEmpty ? nil : normalized
1975
    }
1976

            
1977
    private func normalizedMACAddress(_ macAddress: String) -> String {
1978
        normalizedText(macAddress).uppercased()
1979
    }
1980

            
1981
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
1982
        guard let value = object.value(forKey: key) as? String else { return nil }
1983
        let normalized = normalizedOptionalText(value)
1984
        return normalized
1985
    }
1986

            
1987
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
1988
        object.value(forKey: key) as? Date
1989
    }
1990

            
1991
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
1992
        if let value = object.value(forKey: key) as? Double {
1993
            return value
1994
        }
1995
        if let value = object.value(forKey: key) as? NSNumber {
1996
            return value.doubleValue
1997
        }
1998
        return 0
1999
    }
2000

            
2001
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
2002
        let rawValue = object.value(forKey: key)
2003
        if rawValue == nil {
2004
            return nil
2005
        }
2006
        return doubleValue(object, key: key)
2007
    }
2008

            
2009
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
2010
        if let value = object.value(forKey: key) as? Int16 {
2011
            return value
2012
        }
2013
        if let value = object.value(forKey: key) as? NSNumber {
2014
            return value.int16Value
2015
        }
2016
        return nil
2017
    }
2018

            
2019
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
2020
        if let value = object.value(forKey: key) as? Int32 {
2021
            return value
2022
        }
2023
        if let value = object.value(forKey: key) as? NSNumber {
2024
            return value.int32Value
2025
        }
2026
        return nil
2027
    }
2028

            
2029
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
2030
        if let value = object.value(forKey: key) as? Bool {
2031
            return value
2032
        }
2033
        if let value = object.value(forKey: key) as? NSNumber {
2034
            return value.boolValue
2035
        }
2036
        return false
2037
    }
2038

            
2039
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
2040
        guard let value = stringValue(object, key: key) else { return nil }
2041
        return UUID(uuidString: value)
2042
    }
2043

            
2044
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
2045
        guard let value = stringValue(object, key: key) else { return nil }
2046
        return ChargeSessionStatus(rawValue: value)
2047
    }
2048

            
2049
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
2050
        guard let value = stringValue(object, key: key) else { return nil }
2051
        return ChargingTransportMode(rawValue: value)
2052
    }
2053

            
2054
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
2055
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
2056
            return []
2057
        }
2058
        return rawValue
2059
            .split(separator: ",")
2060
            .compactMap { Double($0) }
2061
            .sorted()
2062
    }
2063

            
2064
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
2065
        let uniqueVoltages = Array(Set(voltages)).sorted()
2066
        guard !uniqueVoltages.isEmpty else {
2067
            return nil
2068
        }
2069
        return uniqueVoltages
2070
            .map { String(format: "%.1f", $0) }
2071
            .joined(separator: ",")
2072
    }
2073

            
2074
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
2075
        guard currentCount > 0 else {
2076
            return newValue
2077
        }
2078
        let total = (currentAverage * Double(currentCount)) + newValue
2079
        return total / Double(currentCount + 1)
2080
    }
2081
}
2082

            
2083
private enum ObservationSaveReason {
2084
    case none
2085
    case created
2086
    case periodic
2087
    case completed
2088
    case event
2089
}