1 contributor
//
// ChargeInsightsStore.swift
// USB Meter
//
// Created by Codex on 10/04/2026.
//
import CoreData
import Foundation
final class ChargeInsightsStore {
private enum EntityName {
static let chargedDevice = "ChargedDevice"
static let chargeSession = "ChargeSession"
static let chargeCheckpoint = "ChargeCheckpoint"
static let chargeSessionSample = "ChargeSessionSample"
}
private static let maximumChargeSessionDuration: TimeInterval = 12 * 60 * 60
private static let persistedSamplesPerHour = 360
private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
private let context: NSManagedObjectContext
private let stopDetectionHoldDuration: TimeInterval = 20
private let maximumLiveIntegrationGap: TimeInterval = 90
private let activeSessionSaveInterval: TimeInterval = 60
private let aggregatedSampleSaveInterval: TimeInterval = 30
private let counterDecreaseTolerance = 0.002
private let completionConfirmationCooldown: TimeInterval = 15 * 60
private let pausedSessionTimeout: TimeInterval = 10 * 60
private let defaultCompletionPercentThreshold = 95.0
private let completionContradictionTolerancePercent = 2.0
private let minimumWirelessEfficiencyFactor = 0.35
private let maximumWirelessEfficiencyFactor = 0.95
private let lowWirelessEfficiencyThreshold = 0.72
private let unresolvedFlatBatteryPercent = -1.0
init(context: NSManagedObjectContext) {
self.context = context
}
func refreshContext() {
context.performAndWait {
context.processPendingChanges()
}
}
func resetContext() {
context.performAndWait {
context.reset()
}
}
@discardableResult
func flushPendingChanges() -> Bool {
var didSave = false
context.performAndWait {
context.processPendingChanges()
didSave = saveContext()
}
return didSave
}
@discardableResult
func completeExpiredOpenSessions(referenceDate: Date = Date()) -> Bool {
var didSave = false
context.performAndWait {
let expiredSessions = fetchOpenSessionObjects().compactMap { session -> NSManagedObject? in
guard automaticCompletionDate(for: session, referenceDate: referenceDate) != nil else {
return nil
}
return session
}
guard expiredSessions.isEmpty == false else {
return
}
var chargedDeviceIDsToRefresh = Set<String>()
for session in expiredSessions {
guard let completionDate = automaticCompletionDate(for: session, referenceDate: referenceDate) else {
continue
}
finishSession(
session,
observedAt: completionDate,
finalBatteryPercent: nil,
status: .completed
)
if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
chargedDeviceIDsToRefresh.insert(chargedDeviceID)
}
}
guard saveContext() else {
return
}
for chargedDeviceID in chargedDeviceIDsToRefresh {
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
}
didSave = saveContext()
}
return didSave
}
// Heals the invariant "at most one open session per meter MAC".
// Called after every remote CloudKit sync import to resolve sessions that were started
// independently on different devices while offline.
//
// Scenario: session A started on Device 1 and forgotten; user starts session B on Device 2
// while offline. After sync both appear open for the same meter.
//
// Winner = session with the latest startedAt (represents the user's intentional new session).
// Loser endedAt is set to winner's startedAt so there is no time overlap.
@discardableResult
func healDuplicateOpenSessions() -> Bool {
var didSave = false
context.performAndWait {
let openSessions = fetchOpenSessionObjects()
var sessionsByMAC: [String: [NSManagedObject]] = [:]
for session in openSessions {
guard let mac = stringValue(session, key: "meterMACAddress") else { continue }
sessionsByMAC[mac, default: []].append(session)
}
let duplicatedMACs = sessionsByMAC.filter { $0.value.count > 1 }
guard !duplicatedMACs.isEmpty else { return }
var chargedDeviceIDsToRefresh = Set<String>()
for (_, sessions) in duplicatedMACs {
// Winner = most recently started (explicit user intent); tie-break by measuredEnergyWh
let winner = sessions.max { a, b in
let aDate = (a.value(forKey: "startedAt") as? Date) ?? .distantPast
let bDate = (b.value(forKey: "startedAt") as? Date) ?? .distantPast
if aDate != bDate { return aDate < bDate }
let aEnergy = (a.value(forKey: "measuredEnergyWh") as? Double) ?? 0
let bEnergy = (b.value(forKey: "measuredEnergyWh") as? Double) ?? 0
return aEnergy < bEnergy
}
let winnerStartedAt = (winner?.value(forKey: "startedAt") as? Date) ?? Date()
for loser in sessions where loser !== winner {
// End the loser exactly when the winner began — no overlap.
finishSession(loser, observedAt: winnerStartedAt, finalBatteryPercent: nil, status: .abandoned)
loser.setValue(true, forKey: "wasConflictHealed")
if let chargedDeviceID = stringValue(loser, key: "chargedDeviceID") {
chargedDeviceIDsToRefresh.insert(chargedDeviceID)
}
track("ChargeInsightsStore: healed duplicate open session \(stringValue(loser, key: "id") ?? "?") for meter \(stringValue(loser, key: "meterMACAddress") ?? "?")")
}
}
guard saveContext() else { return }
for chargedDeviceID in chargedDeviceIDsToRefresh {
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
}
didSave = saveContext()
}
return didSave
}
@discardableResult
func createDevice(
name: String,
deviceClass: ChargedDeviceClass,
templateID: String?,
chargingStateAvailability: ChargingStateAvailability,
supportsWiredCharging: Bool,
supportsWirelessCharging: Bool,
wirelessChargingProfile: WirelessChargingProfile,
configuredCompletionCurrents: [ChargeSessionKind: Double],
notes: String?
) -> Bool {
guard deviceClass.kind == .device else { return false }
let normalizedName = normalizedText(name)
let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging
)
let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
guard !normalizedName.isEmpty else { return false }
guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
var didSave = false
context.performAndWait {
guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
return
}
let object = NSManagedObject(entity: entity, insertInto: context)
let now = Date()
object.setValue(UUID().uuidString, forKey: "id")
object.setValue(normalizedName, forKey: "name")
object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
object.setValue(normalizedOptionalText(notes), forKey: "notes")
object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
object.setValue(now, forKey: "createdAt")
object.setValue(now, forKey: "updatedAt")
didSave = saveContext()
}
return didSave
}
@discardableResult
func createCharger(
name: String,
chargerType: ChargerType,
notes: String?
) -> Bool {
let normalizedName = normalizedText(name)
guard !normalizedName.isEmpty else { return false }
var didSave = false
context.performAndWait {
guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
return
}
let object = NSManagedObject(entity: entity, insertInto: context)
let now = Date()
object.setValue(UUID().uuidString, forKey: "id")
object.setValue(normalizedName, forKey: "name")
object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
object.setValue(nil, forKey: "deviceTemplateID")
object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
object.setValue(false, forKey: "supportsChargingWhileOff")
object.setValue(false, forKey: "supportsWiredCharging")
object.setValue(true, forKey: "supportsWirelessCharging")
if object.entity.attributesByName["chargerTypeRawValue"] != nil {
object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
}
object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
object.setValue(normalizedOptionalText(notes), forKey: "notes")
object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
object.setValue(now, forKey: "createdAt")
object.setValue(now, forKey: "updatedAt")
didSave = saveContext()
}
return didSave
}
@discardableResult
func updateDevice(
id: UUID,
name: String,
deviceClass: ChargedDeviceClass,
templateID: String?,
chargingStateAvailability: ChargingStateAvailability,
supportsWiredCharging: Bool,
supportsWirelessCharging: Bool,
wirelessChargingProfile: WirelessChargingProfile,
configuredCompletionCurrents: [ChargeSessionKind: Double],
notes: String?
) -> Bool {
guard deviceClass.kind == .device else { return false }
let normalizedName = normalizedText(name)
let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging
)
let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
guard !normalizedName.isEmpty else { return false }
guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
var didSave = false
context.performAndWait {
guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
return
}
guard isChargerObject(object) == false else {
return
}
let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
let now = Date()
object.setValue(normalizedName, forKey: "name")
object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
object.setValue(normalizedOptionalText(notes), forKey: "notes")
object.setValue(now, forKey: "updatedAt")
let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff
let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
|| previousChargingStateAvailability != normalizedChargingStateAvailability
|| previousSupportsWiredCharging != normalizedChargingSupport.wired
|| previousSupportsWirelessCharging != normalizedChargingSupport.wireless
if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
for session in sessions {
let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
if shouldRecalculateSessionCapacity {
session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
updateCapacityEstimate(for: session)
session.setValue(now, forKey: "updatedAt")
}
guard isOpen, shouldRefreshActiveSessions else {
continue
}
let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
chargingTransportMode(for: session),
supportsWiredCharging: normalizedChargingSupport.wired,
supportsWirelessCharging: normalizedChargingSupport.wireless
)
let resolvedSessionChargingStateMode = resolvedChargingStateMode(
chargingStateMode(for: session),
availability: normalizedChargingStateAvailability
)
let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
session.setValue(
resolvedStopThreshold(
for: object,
chargingTransportMode: resolvedSessionChargingTransportMode,
chargingStateMode: resolvedSessionChargingStateMode,
charger: charger,
fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
) ?? 0,
forKey: "stopThresholdAmps"
)
session.setValue(now, forKey: "updatedAt")
updateCapacityEstimate(for: session)
}
}
refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
didSave = saveContext()
}
return didSave
}
@discardableResult
func updateCharger(
id: UUID,
name: String,
chargerType: ChargerType,
notes: String?
) -> Bool {
let normalizedName = normalizedText(name)
guard !normalizedName.isEmpty else { return false }
var didSave = false
context.performAndWait {
guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
return
}
guard isChargerObject(object) else {
return
}
object.setValue(normalizedName, forKey: "name")
object.setValue(nil, forKey: "deviceTemplateID")
object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
object.setValue(false, forKey: "supportsChargingWhileOff")
object.setValue(false, forKey: "supportsWiredCharging")
object.setValue(true, forKey: "supportsWirelessCharging")
if object.entity.attributesByName["chargerTypeRawValue"] != nil {
object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
}
object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
object.setValue(normalizedOptionalText(notes), forKey: "notes")
object.setValue(Date(), forKey: "updatedAt")
refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
didSave = saveContext()
}
return didSave
}
@discardableResult
func startSession(
for snapshot: ChargingMonitorSnapshot,
chargedDeviceID: UUID,
chargerID: UUID?,
chargingTransportMode: ChargingTransportMode,
chargingStateMode: ChargingStateMode,
autoStopEnabled: Bool,
initialBatteryPercent: Double?,
startsFromFlatBattery: Bool
) -> Bool {
if let initialBatteryPercent,
(initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
return false
}
var didSave = false
context.performAndWait {
guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
return
}
guard isChargerObject(chargedDevice) == false else {
return
}
guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
return
}
let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode(
chargingTransportMode,
supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
)
let resolvedChargingStateMode = resolvedChargingStateMode(
chargingStateMode,
availability: chargingStateAvailability(for: chargedDevice)
)
let charger = resolvedChargingTransportMode == .wireless
? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
: nil
if let charger, isChargerObject(charger) == false {
return
}
guard resolvedChargingTransportMode == .wired || charger != nil else {
return
}
let stopThreshold = resolvedStopThreshold(
for: chargedDevice,
chargingTransportMode: resolvedChargingTransportMode,
chargingStateMode: resolvedChargingStateMode,
charger: charger,
fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil
)
guard let session = createSessionObject(
for: chargedDevice,
charger: charger,
snapshot: snapshot,
stopThreshold: stopThreshold,
chargingTransportMode: resolvedChargingTransportMode,
chargingStateMode: resolvedChargingStateMode,
autoStopEnabled: autoStopEnabled
) else {
return
}
if startsFromFlatBattery {
session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
session.setValue(nil, forKey: "endBatteryPercent")
} else if let initialBatteryPercent {
guard insertBatteryCheckpoint(
percent: initialBatteryPercent,
flag: .initial,
timestamp: snapshot.observedAt,
to: session
) != nil else {
return
}
}
didSave = saveContext()
}
return didSave
}
@discardableResult
func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
guard statusValue(session, key: "statusRawValue") == .active else {
return
}
session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
session.setValue(observedAt, forKey: "pausedAt")
session.setValue(nil, forKey: "belowThresholdSince")
clearCompletionConfirmationState(for: session)
session.setValue(observedAt, forKey: "updatedAt")
didSave = saveContext()
}
return didSave
}
@discardableResult
func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
guard statusValue(session, key: "statusRawValue") == .paused else {
return
}
let resumedAt = snapshot?.observedAt ?? Date()
if let completionDate = automaticCompletionDate(for: session, referenceDate: resumedAt) {
finishSession(
session,
observedAt: completionDate,
finalBatteryPercent: nil,
status: .completed
)
guard saveContext() else {
return
}
if let deviceID = stringValue(session, key: "chargedDeviceID") {
refreshDerivedMetrics(forChargedDeviceID: deviceID)
didSave = saveContext()
} else {
didSave = true
}
return
}
session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
session.setValue(nil, forKey: "pausedAt")
session.setValue(nil, forKey: "belowThresholdSince")
clearCompletionConfirmationState(for: session)
session.setValue(resumedAt, forKey: "lastObservedAt")
if let snapshot {
session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
session.setValue(
chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
forKey: "lastObservedVoltageVolts"
)
} else {
session.setValue(0, forKey: "lastObservedCurrentAmps")
session.setValue(0, forKey: "lastObservedPowerWatts")
session.setValue(nil, forKey: "lastObservedVoltageVolts")
}
session.setValue(resumedAt, forKey: "updatedAt")
didSave = saveContext()
}
return didSave
}
@discardableResult
func stopSession(
id sessionID: UUID,
finalBatteryPercent: Double? = nil
) -> Bool {
if let finalBatteryPercent {
guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
return false
}
}
var didSave = false
var deviceIDToRefresh: String?
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
return
}
restoreMeasuredTotalsFromLatestSampleIfNeeded(session)
guard hasSavableChargeData(session) else {
return
}
let observedAt = snapshotDateForManualStop(session)
finishSession(
session,
observedAt: observedAt,
finalBatteryPercent: finalBatteryPercent,
status: .completed
)
guard saveContext() else {
return
}
didSave = true
deviceIDToRefresh = stringValue(session, key: "chargedDeviceID")
}
if let deviceID = deviceIDToRefresh {
context.perform { [weak self] in
guard let self else { return }
self.refreshDerivedMetrics(forChargedDeviceID: deviceID)
self.saveContext()
}
}
return didSave
}
@discardableResult
func addBatteryCheckpoint(
percent: Double,
for meterMACAddress: String,
measuredEnergyWh: Double? = nil
) -> Bool {
guard percent.isFinite, percent >= 0, percent <= 100 else {
return false
}
var didSave = false
context.performAndWait {
guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
return
}
didSave = addBatteryCheckpoint(
percent: percent,
measuredEnergyWh: measuredEnergyWh,
flag: .intermediate,
to: session
)
}
return didSave
}
@discardableResult
func addBatteryCheckpoint(
percent: Double,
for sessionID: UUID,
measuredEnergyWh: Double? = nil
) -> Bool {
guard percent.isFinite, percent >= 0, percent <= 100 else {
return false
}
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
didSave = addBatteryCheckpoint(
percent: percent,
measuredEnergyWh: measuredEnergyWh,
flag: .intermediate,
to: session
)
}
return didSave
}
@discardableResult
func deleteBatteryCheckpoint(
id checkpointID: UUID,
from sessionID: UUID
) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString),
let checkpoint = fetchCheckpointObject(
id: checkpointID.uuidString,
sessionID: sessionID.uuidString
) else {
return
}
let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
context.delete(checkpoint)
refreshCheckpointDerivedValues(for: session)
if let chargedDeviceID {
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
}
didSave = saveContext()
}
return didSave
}
@discardableResult
func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
return false
}
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
session.setValue(percent, forKey: "targetBatteryPercent")
session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
session.setValue(Date(), forKey: "updatedAt")
didSave = saveContext()
}
return didSave
}
@discardableResult
func confirmCompletion(for sessionID: UUID) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
guard statusValue(session, key: "statusRawValue") == .active else {
return
}
finishSession(
session,
observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
finalBatteryPercent: nil,
status: .completed
)
if saveContext() {
if let deviceID = stringValue(session, key: "chargedDeviceID") {
refreshDerivedMetrics(forChargedDeviceID: deviceID)
didSave = saveContext()
} else {
didSave = true
}
}
}
return didSave
}
@discardableResult
func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
guard statusValue(session, key: "statusRawValue") == .active else {
return
}
clearCompletionConfirmationState(for: session)
session.setValue(nil, forKey: "belowThresholdSince")
session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
session.setValue(Date(), forKey: "updatedAt")
didSave = saveContext()
}
return didSave
}
@discardableResult
func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
let sessionEnd = dateValue(session, key: "endedAt")
?? dateValue(session, key: "lastObservedAt")
?? Date.distantFuture
let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
let effectiveEnd = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
let persistedEnd = effectiveEnd == sessionEnd ? nil : effectiveEnd
let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
.compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
guard let ts = dateValue(obj, key: "timestamp") else { return nil }
return (
timestamp: ts,
energy: doubleValue(obj, key: "measuredEnergyWh"),
charge: doubleValue(obj, key: "measuredChargeAh")
)
}
.sorted { $0.timestamp < $1.timestamp }
// Each sample stores cumulative energy since session start.
// Trimmed energy = value at trimEnd - value just before trimStart.
let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
let baselineEnergy = baselineSample?.energy ?? 0
let baselineCharge = baselineSample?.charge ?? 0
if let endSample {
let trimmedEnergy = max(endSample.energy - baselineEnergy, 0)
let trimmedCharge = max(endSample.charge - baselineCharge, 0)
session.setValue(trimmedEnergy, forKey: "measuredEnergyWh")
session.setValue(trimmedCharge, forKey: "measuredChargeAh")
} else {
session.setValue(0, forKey: "measuredEnergyWh")
session.setValue(0, forKey: "measuredChargeAh")
}
session.setValue(persistedStart, forKey: "trimStart")
session.setValue(persistedEnd, forKey: "trimEnd")
session.setValue(Date(), forKey: "updatedAt")
let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
for checkpoint in checkpoints {
guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
if timestamp < effectiveStart || timestamp > effectiveEnd {
context.delete(checkpoint)
continue
}
let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0)
let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0)
checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh")
}
let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
.sorted {
(dateValue($0, key: "timestamp") ?? .distantPast)
< (dateValue($1, key: "timestamp") ?? .distantPast)
}
let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
let label = stringValue(checkpoint, key: "label")
let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture
return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart
}
if persistedStart == nil {
if let restoredInitialCheckpoint,
let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
percent >= 0 {
session.setValue(percent, forKey: "startBatteryPercent")
}
} else {
session.setValue(nil, forKey: "startBatteryPercent")
}
refreshCheckpointDerivedValues(for: session)
if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
}
didSave = saveContext()
}
return didSave
}
@discardableResult
func commitSessionTrim(sessionID: UUID) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString),
statusValue(session, key: "statusRawValue")?.isOpen == false else {
return
}
guard dateValue(session, key: "trimStart") != nil
|| dateValue(session, key: "trimEnd") != nil else {
return
}
let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
let sessionEnd = dateValue(session, key: "endedAt")
?? dateValue(session, key: "lastObservedAt")
?? sessionStart
let effectiveStart = min(max(dateValue(session, key: "trimStart") ?? sessionStart, sessionStart), sessionEnd)
let effectiveEnd = max(
min(dateValue(session, key: "trimEnd") ?? sessionEnd, sessionEnd),
effectiveStart
)
let sampleObjects = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
let allSamples = sampleObjects
.compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
guard let timestamp = dateValue(obj, key: "timestamp") else { return nil }
return (
timestamp: timestamp,
energy: doubleValue(obj, key: "measuredEnergyWh"),
charge: doubleValue(obj, key: "measuredChargeAh")
)
}
.sorted { $0.timestamp < $1.timestamp }
let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
let baselineEnergy = baselineSample?.energy ?? 0
let baselineCharge = baselineSample?.charge ?? 0
let committedEnergy = endSample.map { max($0.energy - baselineEnergy, 0) }
?? doubleValue(session, key: "measuredEnergyWh")
let committedCharge = endSample.map { max($0.charge - baselineCharge, 0) }
?? doubleValue(session, key: "measuredChargeAh")
var retainedSamples: [(current: Double, power: Double, voltage: Double?)] = []
for sample in sampleObjects {
guard let timestamp = dateValue(sample, key: "timestamp") else {
context.delete(sample)
continue
}
guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
context.delete(sample)
continue
}
let rebasedEnergy = max(doubleValue(sample, key: "measuredEnergyWh") - baselineEnergy, 0)
let rebasedCharge = max(doubleValue(sample, key: "measuredChargeAh") - baselineCharge, 0)
let elapsed = max(timestamp.timeIntervalSince(effectiveStart), 0)
let rebasedBucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
sample.setValue("\(sessionID.uuidString)-\(rebasedBucketIndex)", forKey: "id")
sample.setValue(rebasedBucketIndex, forKey: "bucketIndex")
sample.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
sample.setValue(rebasedCharge, forKey: "measuredChargeAh")
sample.setValue(Date(), forKey: "updatedAt")
retainedSamples.append(
(
current: doubleValue(sample, key: "averageCurrentAmps"),
power: doubleValue(sample, key: "averagePowerWatts"),
voltage: optionalDoubleValue(sample, key: "averageVoltageVolts")
)
)
}
for checkpoint in fetchCheckpointObjects(forSessionID: sessionID.uuidString) {
guard let timestamp = dateValue(checkpoint, key: "timestamp") else {
context.delete(checkpoint)
continue
}
guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
context.delete(checkpoint)
continue
}
let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
checkpoint.setValue(
max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0),
forKey: "measuredEnergyWh"
)
checkpoint.setValue(
max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0),
forKey: "measuredChargeAh"
)
}
if !retainedSamples.isEmpty {
let positiveCurrents = retainedSamples.map { $0.current }.filter { $0 > 0 }
session.setValue(positiveCurrents.min(), forKey: "minimumObservedCurrentAmps")
session.setValue(retainedSamples.map { $0.current }.max(), forKey: "maximumObservedCurrentAmps")
session.setValue(retainedSamples.map { $0.power }.max(), forKey: "maximumObservedPowerWatts")
session.setValue(retainedSamples.compactMap { $0.voltage }.max(), forKey: "maximumObservedVoltageVolts")
session.setValue(
retainedSamples.contains { $0.power > 0.05 || $0.current > 0.01 },
forKey: "hasObservedChargeFlow"
)
} else {
session.setValue(nil, forKey: "minimumObservedCurrentAmps")
session.setValue(nil, forKey: "maximumObservedCurrentAmps")
session.setValue(nil, forKey: "maximumObservedPowerWatts")
session.setValue(nil, forKey: "maximumObservedVoltageVolts")
session.setValue(committedEnergy > 0 || committedCharge > 0, forKey: "hasObservedChargeFlow")
}
session.setValue(effectiveStart, forKey: "startedAt")
session.setValue(effectiveEnd, forKey: "lastObservedAt")
if dateValue(session, key: "endedAt") != nil {
session.setValue(effectiveEnd, forKey: "endedAt")
}
session.setValue(committedEnergy, forKey: "measuredEnergyWh")
session.setValue(committedCharge, forKey: "measuredChargeAh")
session.setValue(nil, forKey: "trimStart")
session.setValue(nil, forKey: "trimEnd")
session.setValue(Date(), forKey: "updatedAt")
refreshCheckpointDerivedValues(for: session)
if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
}
didSave = saveContext()
}
return didSave
}
@discardableResult
func deleteChargeSession(id sessionID: UUID) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
context.delete(session)
guard saveContext() else {
return
}
if let chargedDeviceID {
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
didSave = saveContext()
} else {
didSave = true
}
}
return didSave
}
@discardableResult
func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
var didSave = false
context.performAndWait {
guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
return
}
let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
var impactedChargedDeviceIDs = Set<String>()
for session in deviceSessions {
if let impactedID = stringValue(session, key: "chargedDeviceID") {
impactedChargedDeviceIDs.insert(impactedID)
}
if let impactedChargerID = stringValue(session, key: "chargerID") {
impactedChargedDeviceIDs.insert(impactedChargerID)
}
if let sessionID = stringValue(session, key: "id") {
fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
}
context.delete(session)
}
if deviceClass == .charger {
for session in linkedWirelessSessions {
guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
continue
}
if let impactedID = stringValue(session, key: "chargedDeviceID") {
impactedChargedDeviceIDs.insert(impactedID)
}
session.setValue(nil, forKey: "chargerID")
session.setValue(Date(), forKey: "updatedAt")
}
}
context.delete(chargedDevice)
guard saveContext() else {
return
}
impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
for impactedID in impactedChargedDeviceIDs {
refreshDerivedMetrics(forChargedDeviceID: impactedID)
}
didSave = saveContext()
}
return didSave
}
@discardableResult
func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
return
}
if statusValue(session, key: "statusRawValue") == .paused {
if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
didSave = true
}
return
}
let chargingTransportMode = self.chargingTransportMode(for: session)
let chargingStateMode = self.chargingStateMode(for: session)
let charger = chargingTransportMode == .wireless
? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
: nil
guard chargingTransportMode == .wired || charger != nil else {
return
}
let stopThreshold = resolvedStopThreshold(
for: resolvedDevice,
chargingTransportMode: chargingTransportMode,
chargingStateMode: chargingStateMode,
charger: charger,
fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
)
let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session)
update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger)
let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot)
if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt),
statusValue(session, key: "statusRawValue")?.isOpen == true {
finishSession(
session,
observedAt: completionDate,
finalBatteryPercent: nil,
status: .completed
)
}
let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
let shouldPersistAggregatedCurve = aggregatedSample.map {
shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
} ?? false
guard saveReason != .none || shouldPersistAggregatedCurve else {
return
}
session.setValue(sessionSnapshot.observedAt, forKey: "updatedAt")
if saveContext() {
if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
refreshDerivedMetrics(forChargedDeviceID: deviceID)
didSave = saveContext()
} else {
didSave = true
}
}
}
return didSave
}
func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
var summaries: [ChargedDeviceSummary] = []
context.performAndWait {
let devices = fetchObjects(entityName: EntityName.chargedDevice)
let sessions = fetchObjects(entityName: EntityName.chargeSession)
let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
let sampleBackedSessionIDs = sampleBackedSessionIDs(
devices: devices,
sessionsByDeviceID: sessionsByDeviceID,
sessionsByChargerID: sessionsByChargerID
)
let samplesBySessionID = Dictionary(
grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
) { stringValue($0, key: "sessionID") ?? "" }
summaries = devices.compactMap { device in
guard
let id = uuidValue(device, key: "id"),
let name = stringValue(device, key: "name"),
let qrIdentifier = stringValue(device, key: "qrIdentifier"),
let rawClass = stringValue(device, key: "deviceClassRawValue"),
let deviceClass = ChargedDeviceClass(rawValue: rawClass)
else {
return nil
}
let chargingStateAvailability = chargingStateAvailability(for: device)
let supportsWiredCharging = supportsWiredCharging(for: device)
let supportsWirelessCharging = supportsWirelessCharging(for: device)
let templateDefinition = templateDefinition(for: device)
let sessionObjects = relevantSessionObjects(
for: id.uuidString,
deviceClass: deviceClass,
sessionsByDeviceID: sessionsByDeviceID,
sessionsByChargerID: sessionsByChargerID
)
let sessionSummaries = sessionObjects
.compactMap { session in
makeSessionSummary(
from: session,
checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
)
}
.sorted { lhs, rhs in
if lhs.status.isOpen && !rhs.status.isOpen {
return true
}
if !lhs.status.isOpen && rhs.status.isOpen {
return false
}
if lhs.status == .active && rhs.status == .paused {
return true
}
if lhs.status == .paused && rhs.status == .active {
return false
}
return lhs.startedAt > rhs.startedAt
}
return ChargedDeviceSummary(
id: id,
qrIdentifier: qrIdentifier,
name: name,
deviceClass: deviceClass,
deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
templateDefinition: templateDefinition,
supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
chargingStateAvailability: chargingStateAvailability,
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging,
chargerType: chargerType(for: device),
wirelessChargingProfile: wirelessChargingProfile(for: device),
configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
notes: stringValue(device, key: "notes"),
minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
sessions: sessionSummaries,
capacityHistory: buildCapacityHistory(from: sessionSummaries),
typicalCurve: buildTypicalCurve(from: sessionSummaries),
standbyPowerMeasurements: []
)
}
.sorted { lhs, rhs in
if lhs.activeSession != nil && rhs.activeSession == nil {
return true
}
if lhs.activeSession == nil && rhs.activeSession != nil {
return false
}
if lhs.updatedAt != rhs.updatedAt {
return lhs.updatedAt > rhs.updatedAt
}
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
return summaries
}
func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
let normalizedMAC = normalizedMACAddress(meterMACAddress)
guard !normalizedMAC.isEmpty else { return nil }
var summary: ChargeSessionSummary?
context.performAndWait {
guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
let sessionID = stringValue(session, key: "id") else {
return
}
summary = makeSessionSummary(
from: session,
checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
samples: fetchSessionSampleObjects(forSessionID: sessionID)
)
}
return summary
}
private func createSessionObject(
for chargedDevice: NSManagedObject,
charger: NSManagedObject?,
snapshot: ChargingMonitorSnapshot,
stopThreshold: Double?,
chargingTransportMode: ChargingTransportMode,
chargingStateMode: ChargingStateMode,
autoStopEnabled: Bool
) -> NSManagedObject? {
guard
let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
let chargedDeviceID = stringValue(chargedDevice, key: "id")
else {
return nil
}
let session = NSManagedObject(entity: entity, insertInto: context)
let now = snapshot.observedAt
session.setValue(UUID().uuidString, forKey: "id")
session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
session.setValue(snapshot.meterName, forKey: "meterName")
session.setValue(snapshot.meterModel, forKey: "meterModel")
session.setValue(now, forKey: "startedAt")
session.setValue(now, forKey: "lastObservedAt")
session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
session.setValue(
(usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
forKey: "sourceModeRawValue"
)
session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
session.setValue(
chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
forKey: "lastObservedVoltageVolts"
)
session.setValue(
hasObservedChargeFlow(
currentAmps: snapshot.currentAmps,
chargingTransportMode: chargingTransportMode,
charger: charger,
stopThreshold: stopThreshold
),
forKey: "hasObservedChargeFlow"
)
session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
session.setValue(
chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
forKey: "maximumObservedVoltageVolts"
)
session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
if let selectedDataGroup = snapshot.selectedDataGroup {
session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
}
if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
}
if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
}
if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
}
session.setValue(now, forKey: "createdAt")
session.setValue(now, forKey: "updatedAt")
return session
}
private func update(
session: NSManagedObject,
with snapshot: ChargingMonitorSnapshot,
stopThreshold: Double?,
charger: NSManagedObject?
) {
let sessionChargingTransportMode = chargingTransportMode(for: session)
let lastObservedAt = dateValue(session, key: "lastObservedAt")
let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
if let lastObservedAt {
let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
if sourceMode == .offline {
sourceMode = .blended
}
}
}
if let counterGroup = snapshot.selectedDataGroup,
let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
UInt8(storedGroup) != counterGroup {
session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
}
if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
}
if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
let offlineEnergy = meterEnergyCounterWh - baselineEnergy
measuredEnergyWh = max(offlineEnergy, 0)
usedOfflineMeterCounters = true
sourceMode = .offline
} else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
let delta = meterEnergyCounterWh - lastEnergy
if delta > 0 {
measuredEnergyWh += delta
usedOfflineMeterCounters = true
sourceMode = .blended
}
}
session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
}
if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
}
if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
let offlineCharge = meterChargeCounterAh - baselineCharge
measuredChargeAh = max(offlineCharge, 0)
usedOfflineMeterCounters = true
} else if let lastCharge, meterChargeCounterAh > lastCharge {
let delta = meterChargeCounterAh - lastCharge
if delta > 0 {
measuredChargeAh += delta
usedOfflineMeterCounters = true
}
}
session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
}
if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
}
setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
}
let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
let updatedMinimum: Double
if snapshot.currentAmps > 0 {
updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
} else {
updatedMinimum = existingMinimum ?? 0
}
let effectiveCurrent = effectiveCurrentAmps(
fromMeasuredCurrent: snapshot.currentAmps,
chargingTransportMode: sessionChargingTransportMode,
charger: charger
)
let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
|| hasObservedChargeFlow(
currentAmps: snapshot.currentAmps,
chargingTransportMode: sessionChargingTransportMode,
charger: charger,
stopThreshold: stopThreshold
)
session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
session.setValue(
sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
forKey: "lastObservedVoltageVolts"
)
session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
session.setValue(
max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
forKey: "maximumObservedCurrentAmps"
)
session.setValue(
max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
forKey: "maximumObservedPowerWatts"
)
session.setValue(
sessionChargingTransportMode == .wired
? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
: nil,
forKey: "maximumObservedVoltageVolts"
)
session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
session.setValue(nil, forKey: "belowThresholdSince")
clearCompletionConfirmationState(for: session)
session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
return
}
if effectiveCurrent <= stopThreshold {
let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
if boolValue(session, key: "requiresCompletionConfirmation") {
// Leave the session active until the user explicitly confirms or charging resumes.
return
}
if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
} else {
finishSession(
session,
observedAt: snapshot.observedAt,
finalBatteryPercent: nil,
status: .completed
)
}
}
} else {
session.setValue(nil, forKey: "belowThresholdSince")
clearCompletionConfirmationState(for: session)
session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
}
}
private func updateAggregatedSample(
session: NSManagedObject,
with snapshot: ChargingMonitorSnapshot
) -> NSManagedObject? {
guard
let sessionID = stringValue(session, key: "id"),
let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let startedAt = dateValue(session, key: "startedAt"),
let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
else {
return nil
}
let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
?? NSManagedObject(entity: entity, insertInto: context)
let sessionChargingTransportMode = chargingTransportMode(for: session)
let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
let updatedCount = existingCount + 1
sample.setValue(bucketIdentifier, forKey: "id")
sample.setValue(sessionID, forKey: "sessionID")
sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
sample.setValue(bucketIndex, forKey: "bucketIndex")
sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
sample.setValue(
runningAverage(
currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
currentCount: Int(existingCount),
newValue: snapshot.currentAmps
),
forKey: "averageCurrentAmps"
)
sample.setValue(
sampleVoltage.flatMap { voltage in
runningAverage(
currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
currentCount: Int(existingCount),
newValue: voltage
)
},
forKey: "averageVoltageVolts"
)
sample.setValue(
runningAverage(
currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
currentCount: Int(existingCount),
newValue: snapshot.powerWatts
),
forKey: "averagePowerWatts"
)
sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
setValue(predictedBatteryPercent(for: session), on: sample, key: "estimatedBatteryPercent")
sample.setValue(Int16(updatedCount), forKey: "sampleCount")
sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
sample.setValue(snapshot.observedAt, forKey: "updatedAt")
return sample
}
private func maybeTriggerTargetBatteryAlert(
for session: NSManagedObject,
observedAt: Date,
completionFallbackPercent: Double? = nil
) {
guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
return
}
guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
return
}
let predictedBatteryPercent = predictedBatteryPercent(for: session)
?? optionalDoubleValue(session, key: "endBatteryPercent")
?? completionFallbackPercent
guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
return
}
session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
}
private func shouldRequireCompletionConfirmation(
for session: NSManagedObject,
observedAt: Date
) -> Bool {
if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
cooldownUntil > observedAt {
return false
}
guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
return false
}
let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
?? defaultCompletionPercentThreshold
return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
}
private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
guard !boolValue(session, key: "requiresCompletionConfirmation") else {
return
}
session.setValue(true, forKey: "requiresCompletionConfirmation")
session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
}
private func clearCompletionConfirmationState(for session: NSManagedObject) {
session.setValue(false, forKey: "requiresCompletionConfirmation")
session.setValue(nil, forKey: "completionConfirmationRequestedAt")
session.setValue(nil, forKey: "completionContradictionPercent")
}
private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
if statusValue(session, key: "statusRawValue") == .paused {
return dateValue(session, key: "pausedAt")
?? dateValue(session, key: "lastObservedAt")
?? Date()
}
return dateValue(session, key: "lastObservedAt") ?? Date()
}
private func snapshotClampedToMaximumDuration(
_ snapshot: ChargingMonitorSnapshot,
for session: NSManagedObject
) -> ChargingMonitorSnapshot {
guard let maximumEndDate = maximumEndDate(for: session),
snapshot.observedAt > maximumEndDate else {
return snapshot
}
return ChargingMonitorSnapshot(
meterMACAddress: snapshot.meterMACAddress,
meterName: snapshot.meterName,
meterModel: snapshot.meterModel,
observedAt: maximumEndDate,
voltageVolts: snapshot.voltageVolts,
currentAmps: snapshot.currentAmps,
powerWatts: snapshot.powerWatts,
selectedDataGroup: snapshot.selectedDataGroup,
meterChargeCounterAh: snapshot.meterChargeCounterAh,
meterEnergyCounterWh: snapshot.meterEnergyCounterWh,
meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds,
fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps
)
}
private func automaticCompletionDate(
for session: NSManagedObject,
referenceDate: Date
) -> Date? {
guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
return nil
}
var completionDates: [Date] = []
if let maximumEndDate = maximumEndDate(for: session) {
completionDates.append(maximumEndDate)
}
if statusValue(session, key: "statusRawValue") == .paused,
let pausedAt = dateValue(session, key: "pausedAt") {
completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
}
guard let completionDate = completionDates.min(),
referenceDate >= completionDate else {
return nil
}
return completionDate
}
private func maximumEndDate(for session: NSManagedObject) -> Date? {
dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
}
@discardableResult
private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
guard statusValue(session, key: "statusRawValue")?.isOpen == true,
let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else {
return false
}
finishSession(
session,
observedAt: completionDate,
finalBatteryPercent: nil,
status: .completed
)
guard saveContext() else {
return false
}
if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
return saveContext()
}
return true
}
private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
let chargingTransportMode = chargingTransportMode(for: session)
let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
?? doubleValue(session, key: "lastObservedCurrentAmps")
guard measuredCurrent > 0 else {
return nil
}
let charger = chargingTransportMode == .wireless
? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
: nil
if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
return nil
}
let effectiveCurrent = effectiveCurrentAmps(
fromMeasuredCurrent: measuredCurrent,
chargingTransportMode: chargingTransportMode,
charger: charger
)
guard effectiveCurrent > 0 else {
return nil
}
return effectiveCurrent
}
private func finishSession(
_ session: NSManagedObject,
observedAt: Date,
finalBatteryPercent: Double?,
status: ChargeSessionStatus
) {
if let finalBatteryPercent {
_ = insertBatteryCheckpoint(
percent: finalBatteryPercent,
flag: .final,
timestamp: observedAt,
to: session
)
}
session.setValue(status.rawValue, forKey: "statusRawValue")
session.setValue(nil, forKey: "pausedAt")
session.setValue(nil, forKey: "belowThresholdSince")
session.setValue(observedAt, forKey: "endedAt")
session.setValue(observedAt, forKey: "lastObservedAt")
session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
clearCompletionConfirmationState(for: session)
session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
updateCapacityEstimate(for: session)
refreshEstimatedBatteryPercents(for: session)
session.setValue(observedAt, forKey: "updatedAt")
if status == .completed {
maybeTriggerTargetBatteryAlert(
for: session,
observedAt: observedAt,
completionFallbackPercent: defaultCompletionPercentThreshold
)
}
}
private func predictedBatteryPercent(
for session: NSManagedObject,
effectiveEnergyWhOverride: Double? = nil,
referenceTimestamp: Date? = nil
) -> Double? {
guard
let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
else {
return nil
}
let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(
for: session,
chargedDevice: chargedDevice
)
let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
let measuredEnergyWh = effectiveEnergyWhOverride
?? effectiveBatteryEnergyWh(
rawMeasuredEnergyWh: doubleValue(session, key: "measuredEnergyWh"),
for: session
)
let sessionID = stringValue(session, key: "id") ?? ""
struct Anchor {
let percent: Double
let energyWh: Double
let timestamp: Date
let isCheckpoint: Bool
}
func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
var candidates: [Double] = []
for lowerIndex in anchors.indices {
for upperIndex in anchors.indices where upperIndex > lowerIndex {
let lower = anchors[lowerIndex]
let upper = anchors[upperIndex]
let percentDelta = upper.percent - lower.percent
let energyDelta = upper.energyWh - lower.energyWh
guard percentDelta >= 3, energyDelta > 0.01 else {
continue
}
let capacityWh = energyDelta / (percentDelta / 100)
guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
continue
}
candidates.append(capacityWh)
}
}
return candidates
}
func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
let candidates = anchorCapacityCandidates(from: anchors)
guard !candidates.isEmpty else {
return nil
}
let sortedCandidates = candidates.sorted()
return sortedCandidates[sortedCandidates.count / 2]
}
var anchors: [Anchor] = []
if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
startBatteryPercent >= 0 {
anchors.append(
Anchor(
percent: startBatteryPercent,
energyWh: 0,
timestamp: dateValue(session, key: "trimStart")
?? dateValue(session, key: "startedAt")
?? Date.distantPast,
isCheckpoint: false
)
)
}
let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
.compactMap(makeCheckpointSummary(from:))
.sorted { lhs, rhs in
if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
return lhs.measuredEnergyWh < rhs.measuredEnergyWh
}
return lhs.timestamp < rhs.timestamp
}
.filter { $0.batteryPercent >= 0 }
.map {
Anchor(
percent: $0.batteryPercent,
energyWh: $0.measuredEnergyWh,
timestamp: $0.timestamp,
isCheckpoint: true
)
}
anchors.append(contentsOf: checkpointAnchors)
let sortedAnchors = anchors.sorted { lhs, rhs in
if lhs.energyWh != rhs.energyWh {
return lhs.energyWh < rhs.energyWh
}
return lhs.timestamp < rhs.timestamp
}
guard !sortedAnchors.isEmpty else {
return optionalDoubleValue(session, key: "endBatteryPercent")
}
let inferredCapacityWh = estimatedCapacityWh
?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
let lowerAnchor = sortedAnchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
let upperAnchor = sortedAnchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
if let lowerAnchor,
let upperAnchor,
upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
let interpolationProgress = min(
max(
(measuredEnergyWh - lowerAnchor.energyWh) /
(upperAnchor.energyWh - lowerAnchor.energyWh),
0
),
1
)
return min(
max(
lowerAnchor.percent +
(upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
0
),
100
)
}
guard let inferredCapacityWh, inferredCapacityWh > 0 else {
return nil
}
return BatteryLevelPredictionTuning.predictedPercent(
anchorPercent: anchor.percent,
anchorEnergyWh: anchor.energyWh,
anchorTimestamp: anchor.timestamp,
anchorIsCheckpoint: anchor.isCheckpoint,
effectiveEnergyWh: measuredEnergyWh,
referenceTimestamp: referenceTimestamp
?? dateValue(session, key: "lastObservedAt")
?? anchor.timestamp,
estimatedCapacityWh: inferredCapacityWh
)
}
private func effectiveBatteryEnergyWh(
rawMeasuredEnergyWh: Double,
for session: NSManagedObject
) -> Double {
switch chargingTransportMode(for: session) {
case .wired:
return rawMeasuredEnergyWh
case .wireless:
if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
return rawMeasuredEnergyWh * factor
}
let sessionMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
if let sessionEffectiveEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh"),
sessionMeasuredEnergyWh > 0 {
return rawMeasuredEnergyWh * (sessionEffectiveEnergyWh / sessionMeasuredEnergyWh)
}
return rawMeasuredEnergyWh
}
}
private func refreshEstimatedBatteryPercents(for session: NSManagedObject) {
guard let sessionID = stringValue(session, key: "id") else {
return
}
for sample in fetchSessionSampleObjects(forSessionID: sessionID) {
let effectiveEnergyWh = effectiveBatteryEnergyWh(
rawMeasuredEnergyWh: doubleValue(sample, key: "measuredEnergyWh"),
for: session
)
let percent = predictedBatteryPercent(
for: session,
effectiveEnergyWhOverride: effectiveEnergyWh,
referenceTimestamp: dateValue(sample, key: "timestamp")
)
setValue(percent, on: sample, key: "estimatedBatteryPercent")
setValue(Date(), on: sample, key: "updatedAt")
}
}
private func resolvedEstimatedBatteryCapacityWh(
for session: NSManagedObject,
chargedDevice: NSManagedObject
) -> Double? {
if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
sessionCapacityEstimate > 0 {
return sessionCapacityEstimate
}
switch chargingTransportMode(for: session) {
case .wired:
return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
case .wireless:
return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
}
}
private func updateCapacityEstimate(for session: NSManagedObject) {
guard
let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
else {
session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
session.setValue(nil, forKey: "capacityEstimateWh")
return
}
let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
let chargingMode = chargingTransportMode(for: session)
let wirelessResolution = chargingMode == .wireless
? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
: nil
let effectiveBatteryEnergyWh = chargingMode == .wired
? measuredEnergyWh
: wirelessResolution.map { measuredEnergyWh * $0.factor }
session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
session.setValue(nil, forKey: "capacityEstimateWh")
return
}
struct CapacityAnchor {
let percent: Double
let energyWh: Double
let timestamp: Date
}
var anchors: [CapacityAnchor] = []
if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
startBatteryPercent >= 0 {
anchors.append(
CapacityAnchor(
percent: startBatteryPercent,
energyWh: 0,
timestamp: dateValue(session, key: "trimStart")
?? dateValue(session, key: "startedAt")
?? Date.distantPast
)
)
}
if let sessionID = stringValue(session, key: "id") {
anchors.append(
contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
guard
let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
percent >= 0,
let timestamp = dateValue(checkpoint, key: "timestamp")
else {
return nil
}
return CapacityAnchor(
percent: percent,
energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
timestamp: timestamp
)
}
)
}
if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
endBatteryPercent >= 0 {
anchors.append(
CapacityAnchor(
percent: endBatteryPercent,
energyWh: effectiveBatteryEnergyWh,
timestamp: dateValue(session, key: "endedAt")
?? dateValue(session, key: "lastObservedAt")
?? Date.distantPast
)
)
}
let sortedAnchors = anchors.sorted { lhs, rhs in
if lhs.energyWh != rhs.energyWh {
return lhs.energyWh < rhs.energyWh
}
return lhs.timestamp < rhs.timestamp
}
guard let firstAnchor = sortedAnchors.first,
let lastAnchor = sortedAnchors.last else {
session.setValue(nil, forKey: "capacityEstimateWh")
return
}
let percentDelta = lastAnchor.percent - firstAnchor.percent
let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
guard percentDelta >= 20, energyDelta > 0 else {
session.setValue(nil, forKey: "capacityEstimateWh")
return
}
if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
session.setValue(nil, forKey: "capacityEstimateWh")
return
}
let capacityEstimateWh = energyDelta / (percentDelta / 100)
session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
}
@discardableResult
private func insertBatteryCheckpoint(
percent: Double,
flag: ChargeCheckpointFlag,
timestamp: Date = Date(),
measuredEnergyWhOverride: Double? = nil,
to session: NSManagedObject
) -> String? {
guard
let sessionID = stringValue(session, key: "id"),
let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
else {
return nil
}
let checkpoint = NSManagedObject(entity: entity, insertInto: context)
let checkpointEnergyWh = measuredEnergyWhOverride
?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
?? doubleValue(session, key: "measuredEnergyWh")
checkpoint.setValue(UUID().uuidString, forKey: "id")
checkpoint.setValue(sessionID, forKey: "sessionID")
checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
checkpoint.setValue(timestamp, forKey: "timestamp")
checkpoint.setValue(percent, forKey: "batteryPercent")
checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
checkpoint.setValue(
chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
forKey: "voltageVolts"
)
checkpoint.setValue(flag.rawValue, forKey: "label")
checkpoint.setValue(timestamp, forKey: "createdAt")
let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
session.setValue(percent, forKey: "startBatteryPercent")
}
if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
session.setValue(percent, forKey: "endBatteryPercent")
}
session.setValue(timestamp, forKey: "updatedAt")
updateCapacityEstimate(for: session)
refreshEstimatedBatteryPercents(for: session)
return chargedDeviceID
}
private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
guard let sessionID = stringValue(session, key: "id") else {
return
}
let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
if let latestCheckpoint = remainingCheckpoints.last {
session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
} else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
startBatteryPercent >= 0 {
session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
} else {
session.setValue(nil, forKey: "endBatteryPercent")
}
session.setValue(Date(), forKey: "updatedAt")
updateCapacityEstimate(for: session)
refreshEstimatedBatteryPercents(for: session)
}
@discardableResult
private func addBatteryCheckpoint(
percent: Double,
measuredEnergyWh: Double? = nil,
flag: ChargeCheckpointFlag,
to session: NSManagedObject,
timestamp: Date = Date()
) -> Bool {
if let measuredEnergyWh, measuredEnergyWh.isFinite {
session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
}
guard let chargedDeviceID = insertBatteryCheckpoint(
percent: percent,
flag: flag,
timestamp: timestamp,
measuredEnergyWhOverride: measuredEnergyWh,
to: session
) else {
return false
}
guard saveContext() else {
return false
}
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
return saveContext()
}
private func resolvedWirelessEfficiency(
for session: NSManagedObject,
chargedDevice: NSManagedObject
) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
storedFactor > 0 {
return (
factor: storedFactor,
usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
)
}
let chargingProfile = wirelessChargingProfile(for: chargedDevice)
let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
guard measuredEnergyWh > 0 else {
return nil
}
if chargingProfile == .magsafe,
let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
calibratedFactor > 0 {
return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
}
guard
let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
else {
return nil
}
let percentDelta = endBatteryPercent - startBatteryPercent
guard percentDelta >= 20 else {
return nil
}
guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
: nil),
wiredCapacityWh > 0
else {
return nil
}
let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
let usesEstimated = chargingProfile != .magsafe
let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
}
private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
return
}
let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
let sessions = relevantSessionObjects(
for: chargedDeviceID,
deviceClass: deviceClass,
sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
)
let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
let wiredMinimumCurrent = derivedMinimumCurrent(
from: sessions,
chargingTransportMode: .wired
)
let wirelessMinimumCurrent = derivedMinimumCurrent(
from: sessions,
chargingTransportMode: .wireless
)
let wiredCapacity = derivedCapacity(
from: sessions,
chargingTransportMode: .wired,
supportsChargingWhileOff: supportsChargingWhileOff
)
let wirelessCapacity = derivedCapacity(
from: sessions,
chargingTransportMode: .wireless,
supportsChargingWhileOff: supportsChargingWhileOff
)
let wirelessEfficiency = derivedWirelessEfficiency(
from: sessions,
chargingProfile: wirelessProfile
)
let configuredCompletionCurrents = decodedCompletionCurrents(
from: chargedDevice,
key: "configuredCompletionCurrentsRawValue"
)
let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
let preferredMinimumCurrent: Double?
let preferredCapacity: Double?
switch preferredChargingTransportMode {
case .wired:
preferredMinimumCurrent = configuredCompletionCurrents[
ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
] ?? learnedCompletionCurrents[
ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
preferredCapacity = wiredCapacity ?? wirelessCapacity
case .wireless:
preferredMinimumCurrent = configuredCompletionCurrents[
ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
] ?? learnedCompletionCurrents[
ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
preferredCapacity = wirelessCapacity ?? wiredCapacity
}
setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
setValue(Date(), on: chargedDevice, key: "updatedAt")
}
private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
sessions
.filter { $0.status == .completed }
.compactMap { session in
guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
let timestamp = session.endedAt ?? session.lastObservedAt
return CapacityTrendPoint(
sessionID: session.id,
timestamp: timestamp,
capacityWh: capacityEstimateWh,
chargingTransportMode: session.chargingTransportMode
)
}
.sorted { $0.timestamp < $1.timestamp }
}
private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
var groupedEnergyByBin: [Int: [Double]] = [:]
for session in sessions where session.status == .completed {
let anchors = normalizedTypicalCurveAnchors(for: session)
guard anchors.count >= 2 else {
continue
}
for percentBin in stride(from: 0, through: 100, by: 10) {
guard let energyWh = interpolatedTypicalCurvePoint(
for: Double(percentBin),
anchors: anchors
) else {
continue
}
groupedEnergyByBin[percentBin, default: []].append(energyWh)
}
}
let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
return nil
}
return TypicalChargeCurvePoint(
percentBin: percentBin,
averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
sampleCount: energies.count
)
}
var runningMaximumEnergyWh = 0.0
return averagedPoints.map { point in
runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
return TypicalChargeCurvePoint(
percentBin: point.percentBin,
averageEnergyWh: runningMaximumEnergyWh,
sampleCount: point.sampleCount
)
}
}
private func normalizedTypicalCurveAnchors(
for session: ChargeSessionSummary
) -> [(percent: Double, energyWh: Double)] {
struct Anchor {
let percent: Double
let energyWh: Double
let timestamp: Date
}
var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
guard checkpoint.batteryPercent.isFinite,
checkpoint.measuredEnergyWh.isFinite,
checkpoint.batteryPercent >= 0,
checkpoint.batteryPercent <= 100,
checkpoint.measuredEnergyWh >= 0 else {
return nil
}
return Anchor(
percent: checkpoint.batteryPercent,
energyWh: checkpoint.measuredEnergyWh,
timestamp: checkpoint.timestamp
)
}
if let startBatteryPercent = session.startBatteryPercent,
startBatteryPercent.isFinite,
startBatteryPercent >= 0,
startBatteryPercent <= 100 {
anchors.append(
Anchor(
percent: startBatteryPercent,
energyWh: 0,
timestamp: session.startedAt
)
)
}
if let endBatteryPercent = session.endBatteryPercent,
endBatteryPercent.isFinite,
endBatteryPercent >= 0,
endBatteryPercent <= 100 {
anchors.append(
Anchor(
percent: endBatteryPercent,
energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
timestamp: session.endedAt ?? session.lastObservedAt
)
)
}
let sortedAnchors = anchors.sorted { lhs, rhs in
if lhs.percent != rhs.percent {
return lhs.percent < rhs.percent
}
if lhs.energyWh != rhs.energyWh {
return lhs.energyWh < rhs.energyWh
}
return lhs.timestamp < rhs.timestamp
}
var collapsedAnchors: [(percent: Double, energyWh: Double)] = []
for anchor in sortedAnchors {
if let lastIndex = collapsedAnchors.indices.last,
abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
collapsedAnchors[lastIndex] = (
percent: collapsedAnchors[lastIndex].percent,
energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
)
} else {
collapsedAnchors.append(
(percent: anchor.percent, energyWh: anchor.energyWh)
)
}
}
var runningMaximumEnergyWh = 0.0
return collapsedAnchors.map { anchor in
runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
return (
percent: anchor.percent,
energyWh: runningMaximumEnergyWh
)
}
}
private func interpolatedTypicalCurvePoint(
for percent: Double,
anchors: [(percent: Double, energyWh: Double)]
) -> Double? {
guard
let firstAnchor = anchors.first,
let lastAnchor = anchors.last,
percent >= firstAnchor.percent,
percent <= lastAnchor.percent
else {
return nil
}
if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
return exactAnchor.energyWh
}
guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
upperIndex > 0 else {
return nil
}
let lowerAnchor = anchors[upperIndex - 1]
let upperAnchor = anchors[upperIndex]
let span = upperAnchor.percent - lowerAnchor.percent
guard span > 0.000_1 else {
return nil
}
let ratio = (percent - lowerAnchor.percent) / span
return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
}
private func makeSessionSummary(
from object: NSManagedObject,
checkpoints: [NSManagedObject],
samples: [NSManagedObject]
) -> ChargeSessionSummary? {
let chargingTransportMode = chargingTransportMode(for: object)
guard
let id = uuidValue(object, key: "id"),
let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
let startedAt = dateValue(object, key: "startedAt"),
let lastObservedAt = dateValue(object, key: "lastObservedAt"),
let status = statusValue(object, key: "statusRawValue"),
let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
else {
return nil
}
let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
.sorted { $0.timestamp < $1.timestamp }
let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
.sorted { lhs, rhs in
if lhs.bucketIndex != rhs.bucketIndex {
return lhs.bucketIndex < rhs.bucketIndex
}
return lhs.timestamp < rhs.timestamp
}
return ChargeSessionSummary(
id: id,
chargedDeviceID: chargedDeviceID,
chargerID: uuidValue(object, key: "chargerID"),
meterMACAddress: stringValue(object, key: "meterMACAddress"),
meterName: stringValue(object, key: "meterName"),
meterModel: stringValue(object, key: "meterModel"),
startedAt: startedAt,
endedAt: dateValue(object, key: "endedAt"),
lastObservedAt: lastObservedAt,
pausedAt: dateValue(object, key: "pausedAt"),
status: status,
sourceMode: sourceMode,
chargingTransportMode: chargingTransportMode,
chargingStateMode: chargingStateMode(for: object),
autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
maximumObservedVoltageVolts: chargingTransportMode == .wired
? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
: nil,
hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"),
selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
trimStart: dateValue(object, key: "trimStart"),
trimEnd: dateValue(object, key: "trimEnd"),
wasConflictHealed: boolValue(object, key: "wasConflictHealed"),
checkpoints: checkpointSummaries,
aggregatedSamples: sampleSummaries
)
}
private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
guard
let id = uuidValue(object, key: "id"),
let sessionID = uuidValue(object, key: "sessionID"),
let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
let timestamp = dateValue(object, key: "timestamp")
else {
return nil
}
return ChargeCheckpointSummary(
id: id,
sessionID: sessionID,
chargedDeviceID: chargedDeviceID,
timestamp: timestamp,
batteryPercent: doubleValue(object, key: "batteryPercent"),
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
currentAmps: doubleValue(object, key: "currentAmps"),
voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
label: stringValue(object, key: "label")
)
}
private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
guard
let sessionID = uuidValue(object, key: "sessionID"),
let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
let timestamp = dateValue(object, key: "timestamp")
else {
return nil
}
return ChargeSessionSampleSummary(
sessionID: sessionID,
chargedDeviceID: chargedDeviceID,
bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
timestamp: timestamp,
averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
estimatedBatteryPercent: optionalDoubleValue(object, key: "estimatedBatteryPercent"),
sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
)
}
private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
fetchSessionObject(
predicate: NSPredicate(
format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
normalizedMACAddress(meterMACAddress),
ChargeSessionStatus.active.rawValue,
ChargeSessionStatus.paused.rawValue
)
)
}
private func fetchOpenSessionObjects() -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
request.predicate = NSPredicate(
format: "statusRawValue == %@ OR statusRawValue == %@",
ChargeSessionStatus.active.rawValue,
ChargeSessionStatus.paused.rawValue
)
request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
return (try? context.fetch(request)) ?? []
}
private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
fetchSessionObject(
predicate: NSPredicate(
format: "meterMACAddress == %@ AND statusRawValue == %@",
normalizedMACAddress(meterMACAddress),
ChargeSessionStatus.active.rawValue
)
)
}
private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
request.predicate = predicate
request.fetchLimit = 1
request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
return (try? context.fetch(request))?.first
}
private func fetchSessionObject(id: String) -> NSManagedObject? {
fetchSessionObject(
predicate: NSPredicate(format: "id == %@", id)
)
}
private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
request.predicate = NSPredicate(
format: "sessionID == %@ AND bucketIndex == %d",
sessionID,
bucketIndex
)
request.fetchLimit = 1
return (try? context.fetch(request))?.first
}
private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
return (try? context.fetch(request)) ?? []
}
private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
guard !sessionIDs.isEmpty else {
return []
}
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
return (try? context.fetch(request)) ?? []
}
private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
request.fetchLimit = 1
return (try? context.fetch(request))?.first
}
private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
return (try? context.fetch(request)) ?? []
}
private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
return (try? context.fetch(request)) ?? []
}
private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
return (try? context.fetch(request)) ?? []
}
private func sampleBackedSessionIDs(
devices: [NSManagedObject],
sessionsByDeviceID: [String: [NSManagedObject]],
sessionsByChargerID: [String: [NSManagedObject]]
) -> Set<String> {
var sessionIDs: Set<String> = []
for device in devices {
guard
let deviceID = stringValue(device, key: "id"),
let rawClass = stringValue(device, key: "deviceClassRawValue"),
let deviceClass = ChargedDeviceClass(rawValue: rawClass)
else {
continue
}
let relevantSessions = relevantSessionObjects(
for: deviceID,
deviceClass: deviceClass,
sessionsByDeviceID: sessionsByDeviceID,
sessionsByChargerID: sessionsByChargerID
)
.sorted { lhs, rhs in
let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
if lhsStatus.isOpen && !rhsStatus.isOpen {
return true
}
if !lhsStatus.isOpen && rhsStatus.isOpen {
return false
}
return (dateValue(lhs, key: "startedAt") ?? .distantPast)
> (dateValue(rhs, key: "startedAt") ?? .distantPast)
}
var recentCompletedSamplesIncluded = 0
for session in relevantSessions {
guard let sessionID = stringValue(session, key: "id"),
let status = statusValue(session, key: "statusRawValue") else {
continue
}
if status.isOpen {
sessionIDs.insert(sessionID)
continue
}
guard recentCompletedSamplesIncluded < 2 else {
continue
}
sessionIDs.insert(sessionID)
recentCompletedSamplesIncluded += 1
}
}
return sessionIDs
}
private func relevantSessionObjects(
for chargedDeviceID: String,
deviceClass: ChargedDeviceClass,
sessionsByDeviceID: [String: [NSManagedObject]],
sessionsByChargerID: [String: [NSManagedObject]]
) -> [NSManagedObject] {
let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
guard deviceClass == .charger else {
return directSessions
}
var seenSessionIDs = Set<String>()
return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
.filter { session in
let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
return seenSessionIDs.insert(sessionID).inserted
}
.sorted {
let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
return lhsDate < rhsDate
}
}
private func isChargerObject(_ object: NSManagedObject) -> Bool {
ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
}
private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
request.predicate = NSPredicate(format: "id == %@", id)
request.fetchLimit = 1
return (try? context.fetch(request))?.first
}
private func fetchObjects(entityName: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
return (try? context.fetch(request)) ?? []
}
private func resolvedStopThreshold(
for chargedDevice: NSManagedObject,
chargingTransportMode: ChargingTransportMode,
chargingStateMode: ChargingStateMode,
charger: NSManagedObject?,
fallback: Double?
) -> Double? {
if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
return nil
}
let sessionKind = ChargeSessionKind(
chargingTransportMode: chargingTransportMode,
chargingStateMode: chargingStateMode
)
let configuredCurrents = decodedCompletionCurrents(
from: chargedDevice,
key: "configuredCompletionCurrentsRawValue"
)
let learnedCurrents = decodedCompletionCurrents(
from: chargedDevice,
key: "learnedCompletionCurrentsRawValue"
)
let legacyCurrent: Double?
switch chargingTransportMode {
case .wired:
legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
case .wireless:
legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
}
let resolvedCurrent = configuredCurrents[sessionKind]
?? learnedCurrents[sessionKind]
?? legacyCurrent
?? fallback
guard let resolvedCurrent, resolvedCurrent > 0 else {
return nil
}
return max(resolvedCurrent, 0.01)
}
private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
return resolvedPreferredChargingTransportMode(
.wired,
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging
)
}
private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
}
private func normalizedTemplateID(
_ templateID: String?,
kind: ChargedDeviceKind
) -> String? {
guard let templateID,
let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
templateDefinition.kind == kind else {
return nil
}
return templateDefinition.id
}
private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
return nil
}
return templateDefinition
}
private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
? true
: boolValue(chargedDevice, key: "supportsWiredCharging")
let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
? false
: boolValue(chargedDevice, key: "supportsWirelessCharging")
return deviceClass(for: chargedDevice).normalizedChargingSupport(
supportsWiredCharging: persistedWiredCharging,
supportsWirelessCharging: persistedWirelessCharging
).wired
}
private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
? true
: boolValue(chargedDevice, key: "supportsWiredCharging")
let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
? false
: boolValue(chargedDevice, key: "supportsWirelessCharging")
return deviceClass(for: chargedDevice).normalizedChargingSupport(
supportsWiredCharging: persistedWiredCharging,
supportsWirelessCharging: persistedWirelessCharging
).wireless
}
private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
.flatMap(ChargingStateAvailability.init(rawValue:))
?? ChargingStateAvailability.fallback(
for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
)
return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
}
private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
.flatMap(ChargingStateMode.init(rawValue:))
?? .on
return resolvedChargingStateMode(
persistedChargingStateMode,
availability: chargingStateAvailability(for: chargedDevice)
)
}
if let rawValue = stringValue(session, key: "chargingStateRawValue"),
let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
return chargingStateMode
}
return .on
}
private func resolvedChargingStateMode(
_ chargingStateMode: ChargingStateMode,
availability: ChargingStateAvailability
) -> ChargingStateMode {
if availability.supportedModes.contains(chargingStateMode) {
return chargingStateMode
}
return availability.supportedModes.first ?? .on
}
private func chargerType(for chargedDevice: NSManagedObject) -> ChargerType? {
let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
guard deviceClass == .charger else { return nil }
// Primary: chargerTypeRawValue (set on v13+)
if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
let type = ChargerType(rawValue: rawValue) {
return type
}
// Migration fallback: derive from old deviceTemplateID
switch stringValue(chargedDevice, key: "deviceTemplateID") {
case "apple-magsafe-charger": return .appleMagSafe
case "apple-watch-charger": return .appleWatch
default: break
}
// Last resort: derive from wirelessChargingProfileRawValue
if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
let profile = WirelessChargingProfile(rawValue: rawValue),
profile == .magsafe {
return .genericMagSafe
}
return .genericQi
}
private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
if let type = chargerType(for: chargedDevice) {
return type.wirelessChargingProfile
}
guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
let profile = WirelessChargingProfile(rawValue: rawValue) else {
return .genericQi
}
return profile
}
private func resolvedPreferredChargingTransportMode(
_ preferredChargingTransportMode: ChargingTransportMode,
supportsWiredCharging: Bool,
supportsWirelessCharging: Bool
) -> ChargingTransportMode {
switch preferredChargingTransportMode {
case .wired where supportsWiredCharging:
return .wired
case .wireless where supportsWirelessCharging:
return .wireless
default:
if supportsWiredCharging {
return .wired
}
if supportsWirelessCharging {
return .wireless
}
return .wired
}
}
private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
let payload = Dictionary(
uniqueKeysWithValues: currents.map { key, value in
(key.rawValue, value)
}
)
guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
return nil
}
return String(data: data, encoding: .utf8)
}
private func decodedCompletionCurrents(
from object: NSManagedObject,
key: String
) -> [ChargeSessionKind: Double] {
guard let rawValue = stringValue(object, key: key),
let data = rawValue.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
return [:]
}
return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
return
}
result[sessionKind] = entry.value
}
}
private func legacyConfiguredCompletionCurrent(
for currents: [ChargeSessionKind: Double],
chargingTransportMode: ChargingTransportMode
) -> Double? {
let candidates = currents
.filter { $0.key.chargingTransportMode == chargingTransportMode }
.sorted { lhs, rhs in
lhs.key.rawValue < rhs.key.rawValue
}
.map(\.value)
return candidates.first
}
private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
guard let charger else {
return nil
}
let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
guard let idleCurrent, idleCurrent >= 0 else {
return nil
}
return idleCurrent
}
private func effectiveCurrentAmps(
fromMeasuredCurrent currentAmps: Double,
chargingTransportMode: ChargingTransportMode,
charger: NSManagedObject?
) -> Double {
switch chargingTransportMode {
case .wired:
return max(currentAmps, 0)
case .wireless:
guard let idleCurrent = chargerIdleCurrent(for: charger) else {
return max(currentAmps, 0)
}
return max(currentAmps - idleCurrent, 0)
}
}
private func hasObservedChargeFlow(
currentAmps: Double,
chargingTransportMode: ChargingTransportMode,
charger: NSManagedObject?,
stopThreshold: Double?
) -> Bool {
let effectiveCurrent = effectiveCurrentAmps(
fromMeasuredCurrent: currentAmps,
chargingTransportMode: chargingTransportMode,
charger: charger
)
return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
}
private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
if boolValue(session, key: "hasObservedChargeFlow")
|| doubleValue(session, key: "measuredEnergyWh") > 0
|| doubleValue(session, key: "measuredChargeAh") > 0
|| (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
|| (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
return true
}
guard let sessionID = stringValue(session, key: "id") else {
return false
}
return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
doubleValue(sample, key: "measuredEnergyWh") > 0
|| doubleValue(sample, key: "measuredChargeAh") > 0
|| (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
|| (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
}
}
private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
guard let sessionID = stringValue(session, key: "id"),
let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
(dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
}) else {
return
}
let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
}
let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
}
}
private func derivedMinimumCurrent(
from sessions: [NSManagedObject],
chargingTransportMode: ChargingTransportMode
) -> Double? {
let completionCurrents = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard self.chargingTransportMode(for: session) == chargingTransportMode else {
return nil
}
guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
return nil
}
guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
return nil
}
return completionCurrent
}
let recentCompletionCurrents = Array(completionCurrents.suffix(5))
guard !recentCompletionCurrents.isEmpty else { return nil }
return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
}
private func derivedCompletionCurrents(from sessions: [NSManagedObject]) -> [ChargeSessionKind: Double] {
var groupedCurrents: [ChargeSessionKind: [Double]] = [:]
for session in sessions {
guard statusValue(session, key: "statusRawValue") == .completed else {
continue
}
guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
continue
}
guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
completionCurrent > 0 else {
continue
}
let sessionKind = ChargeSessionKind(
chargingTransportMode: chargingTransportMode(for: session),
chargingStateMode: chargingStateMode(for: session)
)
groupedCurrents[sessionKind, default: []].append(completionCurrent)
}
return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
let recentCurrents = Array(entry.value.suffix(5))
guard !recentCurrents.isEmpty else {
return
}
result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
}
}
private func derivedCapacity(
from sessions: [NSManagedObject],
chargingTransportMode: ChargingTransportMode,
supportsChargingWhileOff: Bool
) -> Double? {
let capacityCandidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard self.chargingTransportMode(for: session) == chargingTransportMode else {
return nil
}
guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
return nil
}
if supportsChargingWhileOff {
return capacityEstimate
}
guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
return nil
}
return capacityEstimate
}
let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
guard !recentCapacityCandidates.isEmpty else { return nil }
return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
}
private func derivedWirelessEfficiency(
from sessions: [NSManagedObject],
chargingProfile: WirelessChargingProfile
) -> Double? {
guard chargingProfile == .magsafe else {
return nil
}
let candidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard chargingTransportMode(for: session) == .wireless else { return nil }
guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
return nil
}
return factor
}
let recentCandidates = Array(candidates.suffix(6))
guard !recentCandidates.isEmpty else { return nil }
return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
}
private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
let candidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
return nil
}
return (sourceVoltage * 10).rounded() / 10
}
let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
return counts.keys.sorted()
}
private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
let candidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
return nil
}
return minimumObservedCurrent
}
let recentCandidates = Array(candidates.suffix(6))
guard !recentCandidates.isEmpty else { return nil }
return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
}
private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
let candidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
return nil
}
return factor
}
let recentCandidates = Array(candidates.suffix(6))
guard !recentCandidates.isEmpty else { return nil }
return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
}
private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
return nil
}
return maximumObservedPower
}
.max()
}
private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
return resolvedPreferredChargingTransportMode(
chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
)
}
if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
return persistedChargingTransportMode
}
return .wired
}
private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
if session.isInserted {
return .created
}
let committedValues = session.committedValues(
forKeys: [
"statusRawValue",
"updatedAt",
"targetBatteryAlertTriggeredAt",
"requiresCompletionConfirmation"
]
)
let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
let currentStatus = statusValue(session, key: "statusRawValue")
let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
?? (committedValues["requiresCompletionConfirmation"] as? Bool)
?? false
let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
if currentStatus == .completed, committedStatus != .completed {
return .completed
}
if currentStatus != committedStatus {
return .event
}
if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
|| committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
return .event
}
let lastPersistedAt = (committedValues["updatedAt"] as? Date)
?? dateValue(session, key: "createdAt")
?? observedAt
if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
return .periodic
}
return .none
}
private func shouldPersistAggregatedSample(
_ sample: NSManagedObject,
observedAt: Date
) -> Bool {
if sample.isInserted {
return true
}
let committedValues = sample.committedValues(forKeys: ["updatedAt"])
let lastPersistedAt = (committedValues["updatedAt"] as? Date)
?? dateValue(sample, key: "createdAt")
?? observedAt
return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
}
private func generateQRIdentifier() -> String {
"device:\(UUID().uuidString)"
}
@discardableResult
private func saveContext() -> Bool {
guard context.hasChanges else { return true }
do {
try context.save()
return true
} catch {
track("Failed saving charge insights context: \(error)")
context.rollback()
return false
}
}
private func normalizedText(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func normalizedOptionalText(_ text: String?) -> String? {
guard let text else { return nil }
let normalized = normalizedText(text)
return normalized.isEmpty ? nil : normalized
}
private func normalizedMACAddress(_ macAddress: String) -> String {
normalizedText(macAddress).uppercased()
}
private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
guard object.entity.propertiesByName[key] != nil else {
return nil
}
return object.value(forKey: key)
}
private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
guard object.entity.propertiesByName[key] != nil else {
return
}
object.setValue(value, forKey: key)
}
private func stringValue(_ object: NSManagedObject, key: String) -> String? {
guard let value = rawValue(object, key: key) as? String else { return nil }
let normalized = normalizedOptionalText(value)
return normalized
}
private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
rawValue(object, key: key) as? Date
}
private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
if let value = rawValue(object, key: key) as? Double {
return value
}
if let value = rawValue(object, key: key) as? NSNumber {
return value.doubleValue
}
return 0
}
private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
let value = rawValue(object, key: key)
if value == nil {
return nil
}
return doubleValue(object, key: key)
}
private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
if let value = rawValue(object, key: key) as? Int16 {
return value
}
if let value = rawValue(object, key: key) as? NSNumber {
return value.int16Value
}
return nil
}
private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
if let value = rawValue(object, key: key) as? Int32 {
return value
}
if let value = rawValue(object, key: key) as? NSNumber {
return value.int32Value
}
return nil
}
private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
if let value = rawValue(object, key: key) as? Bool {
return value
}
if let value = rawValue(object, key: key) as? NSNumber {
return value.boolValue
}
return false
}
private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
guard let value = stringValue(object, key: key) else { return nil }
return UUID(uuidString: value)
}
private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
guard let value = stringValue(object, key: key) else { return nil }
return ChargeSessionStatus(rawValue: value)
}
private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
guard let value = stringValue(object, key: key) else { return nil }
return ChargingTransportMode(rawValue: value)
}
private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
return []
}
return rawValue
.split(separator: ",")
.compactMap { Double($0) }
.sorted()
}
private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
let uniqueVoltages = Array(Set(voltages)).sorted()
guard !uniqueVoltages.isEmpty else {
return nil
}
return uniqueVoltages
.map { String(format: "%.1f", $0) }
.joined(separator: ",")
}
private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
guard currentCount > 0 else {
return newValue
}
let total = (currentAverage * Double(currentCount)) + newValue
return total / Double(currentCount + 1)
}
}
private enum ObservationSaveReason {
case none
case created
case periodic
case completed
case event
}