// // ChargeInsightsModel.swift // USB Meter // // Created by Codex on 10/04/2026. // import Foundation enum ChargedDeviceKind: String, Identifiable, Codable { case device case charger var id: String { rawValue } var title: String { switch self { case .device: return "Device" case .charger: return "Charger" } } var pluralTitle: String { switch self { case .device: return "Devices" case .charger: return "Chargers" } } var symbolName: String { switch self { case .device: return "iphone" case .charger: return "bolt.horizontal.circle" } } } enum ChargedDeviceClass: String, CaseIterable, Identifiable, Codable { case iphone case watch case powerbank case charger case other var id: String { rawValue } static var deviceCases: [ChargedDeviceClass] { allCases.filter { $0 != .charger } } var kind: ChargedDeviceKind { self == .charger ? .charger : .device } var title: String { switch self { case .iphone: return "iPhone" case .watch: return "Watch" case .powerbank: return "Powerbank" case .charger: return "Charger" case .other: return "Other" } } var symbolName: String { switch self { case .iphone: return "iphone" case .watch: return "applewatch" case .powerbank: return "battery.100.bolt" case .charger: return "bolt.badge.clock" case .other: return "shippingbox" } } var enforcedChargingSupport: (wired: Bool, wireless: Bool)? { switch self { case .watch: return (wired: false, wireless: true) case .powerbank: return (wired: true, wireless: false) case .charger: return (wired: false, wireless: true) case .iphone, .other: return nil } } var enforcedChargingStateAvailability: ChargingStateAvailability? { switch self { case .watch: return .onOnly case .powerbank: return .offOnly case .charger: return .onOnly case .iphone, .other: return nil } } var defaultChargingSupport: (wired: Bool, wireless: Bool) { if let enforcedChargingSupport { return enforcedChargingSupport } switch self { case .iphone: return (wired: true, wireless: true) case .watch: return (wired: false, wireless: true) case .powerbank: return (wired: true, wireless: false) case .charger: return (wired: false, wireless: true) case .other: return (wired: true, wireless: false) } } var defaultChargingStateAvailability: ChargingStateAvailability { enforcedChargingStateAvailability ?? { switch self { case .iphone: return .onOrOff case .watch: return .onOnly case .powerbank: return .offOnly case .charger, .other: return .onOrOff } }() } func normalizedChargingSupport( supportsWiredCharging: Bool, supportsWirelessCharging: Bool ) -> (wired: Bool, wireless: Bool) { enforcedChargingSupport ?? (wired: supportsWiredCharging, wireless: supportsWirelessCharging) } func normalizedChargingStateAvailability( _ chargingStateAvailability: ChargingStateAvailability ) -> ChargingStateAvailability { enforcedChargingStateAvailability ?? chargingStateAvailability } } enum ChargeSessionStatus: String { case active case paused case completed case abandoned var title: String { switch self { case .active: return "Active" case .paused: return "Paused" case .completed: return "Completed" case .abandoned: return "Abandoned" } } var isOpen: Bool { switch self { case .active, .paused: return true case .completed, .abandoned: return false } } } enum ChargeSessionSourceMode: String { case live case offline case blended var title: String { switch self { case .live: return "Live" case .offline: return "Offline Counters" case .blended: return "Blended" } } } enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable { case wired case wireless var id: String { rawValue } var title: String { switch self { case .wired: return "Wired" case .wireless: return "Wireless" } } var symbolName: String { switch self { case .wired: return "cable.connector" case .wireless: return "dot.radiowaves.left.and.right" } } } enum ChargingStateMode: String, CaseIterable, Identifiable, Codable { case on case off var id: String { rawValue } var title: String { switch self { case .on: return "On" case .off: return "Off" } } var description: String { switch self { case .on: return "Device stays powered on while charging." case .off: return "Device is powered off while charging." } } } enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable { case onOnly case onOrOff case offOnly var id: String { rawValue } var title: String { switch self { case .onOnly: return "On Only" case .onOrOff: return "On or Off" case .offOnly: return "Off Only" } } var description: String { switch self { case .onOnly: return "The device can be recorded only while it is powered on." case .onOrOff: return "The session must specify whether the device is on or off." case .offOnly: return "The device can be recorded only while it is powered off." } } var supportedModes: [ChargingStateMode] { switch self { case .onOnly: return [.on] case .onOrOff: return [.on, .off] case .offOnly: return [.off] } } var supportsMultipleModes: Bool { supportedModes.count > 1 } var supportsChargingWhileOff: Bool { self != .onOnly } static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability { supportsChargingWhileOff ? .onOrOff : .onOnly } } enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable { case wiredOn case wiredOff case wirelessOn case wirelessOff var id: String { rawValue } init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) { switch (chargingTransportMode, chargingStateMode) { case (.wired, .on): self = .wiredOn case (.wired, .off): self = .wiredOff case (.wireless, .on): self = .wirelessOn case (.wireless, .off): self = .wirelessOff } } var chargingTransportMode: ChargingTransportMode { switch self { case .wiredOn, .wiredOff: return .wired case .wirelessOn, .wirelessOff: return .wireless } } var chargingStateMode: ChargingStateMode { switch self { case .wiredOn, .wirelessOn: return .on case .wiredOff, .wirelessOff: return .off } } var title: String { "\(chargingTransportMode.title) • \(chargingStateMode.title)" } var shortTitle: String { "\(chargingTransportMode.title) \(chargingStateMode.title)" } } enum WirelessChargingProfile: String, CaseIterable, Identifiable, Codable { case magsafe case genericQi var id: String { rawValue } var title: String { switch self { case .magsafe: return "MagSafe" case .genericQi: return "Generic Qi" } } var description: String { switch self { case .magsafe: return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity." case .genericQi: return "Use only automatic efficiency estimates and show a low-efficiency warning when needed." } } } enum ChargerType: String, CaseIterable, Identifiable, Codable { case appleMagSafe case appleWatch case genericMagSafe case genericQi var id: String { rawValue } var title: String { switch self { case .appleMagSafe: return "Apple MagSafe Charger" case .appleWatch: return "Apple Watch Charger" case .genericMagSafe: return "Generic MagSafe" case .genericQi: return "Generic Qi" } } var symbolName: String { switch self { case .appleMagSafe: return "magsafe.batterypack" case .appleWatch: return "applewatch.radiowaves.left.and.right" case .genericMagSafe: return "bolt.circle" case .genericQi: return "bolt.horizontal.circle" } } /// Whether this charger type uses magnetic alignment, enabling more accurate efficiency calibration. var supportsAlignment: Bool { switch self { case .appleMagSafe, .appleWatch, .genericMagSafe: return true case .genericQi: return false } } var wirelessChargingProfile: WirelessChargingProfile { supportsAlignment ? .magsafe : .genericQi } } enum ChargedDeviceTemplateIconSource: String, Codable { case systemSymbol case asset } struct ChargedDeviceTemplateIcon: Hashable, Codable { let type: ChargedDeviceTemplateIconSource let name: String let fallbackSystemName: String? static func systemSymbol( _ name: String, fallbackSystemName: String? = nil ) -> ChargedDeviceTemplateIcon { ChargedDeviceTemplateIcon( type: .systemSymbol, name: name, fallbackSystemName: fallbackSystemName ) } func resolvedSystemSymbolName(fallbackSystemName: String) -> String { switch type { case .systemSymbol: return name case .asset: return self.fallbackSystemName ?? fallbackSystemName } } } struct ChargedDeviceTemplateDefinition: Identifiable, Hashable, Codable { let id: String let name: String let group: String let kind: ChargedDeviceKind let deviceClass: ChargedDeviceClass let icon: ChargedDeviceTemplateIcon let chargingStateAvailability: ChargingStateAvailability let supportsWiredCharging: Bool let supportsWirelessCharging: Bool let wirelessChargingProfile: WirelessChargingProfile let sortOrder: Int var chargingSupportSummary: String { switch (supportsWiredCharging, supportsWirelessCharging) { case (true, true): return "Wired + Wireless" case (true, false): return "Wired only" case (false, true): return "Wireless only" case (false, false): return "No charging transport" } } var capabilitySummary: String { if kind == .charger { return wirelessChargingProfile.title } var components = [chargingStateAvailability.title, chargingSupportSummary] if supportsWirelessCharging { components.append(wirelessChargingProfile.title) } return components.joined(separator: " • ") } } private struct ChargedDeviceTemplateDocument: Codable { let templates: [ChargedDeviceTemplateDefinition] } struct ChargedDeviceTemplateCatalog { static let shared = ChargedDeviceTemplateCatalog() let templates: [ChargedDeviceTemplateDefinition] private let templatesByID: [String: ChargedDeviceTemplateDefinition] private init(bundle: Bundle = .main) { let loadedTemplates: [ChargedDeviceTemplateDefinition] if let resourceURL = bundle.url(forResource: "ChargedDeviceTemplates", withExtension: "json"), let data = try? Data(contentsOf: resourceURL), let document = try? JSONDecoder().decode(ChargedDeviceTemplateDocument.self, from: data) { loadedTemplates = document.templates } else { loadedTemplates = [] } self.templates = loadedTemplates.sorted { lhs, rhs in if lhs.group != rhs.group { return lhs.group < rhs.group } if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder } return lhs.name < rhs.name } self.templatesByID = Dictionary(uniqueKeysWithValues: self.templates.map { ($0.id, $0) }) } func template(id: String?) -> ChargedDeviceTemplateDefinition? { guard let id else { return nil } return templatesByID[id] } func templates(for kind: ChargedDeviceKind) -> [ChargedDeviceTemplateDefinition] { templates.filter { $0.kind == kind } } } enum ProfileCategory: String, CaseIterable, Identifiable, Codable { case phone case tablet case laptop case watch case audioAccessory case accessoryCase case charger case powerbank case other var id: String { rawValue } var title: String { switch self { case .phone: return "Phone" case .tablet: return "Tablet" case .laptop: return "Laptop" case .watch: return "Watch" case .audioAccessory: return "Audio Accessory" case .accessoryCase: return "Charging Case" case .charger: return "Charger" case .powerbank: return "Powerbank" case .other: return "Other" } } var pluralTitle: String { switch self { case .phone: return "Phones" case .tablet: return "Tablets" case .laptop: return "Laptops" case .watch: return "Watches" case .audioAccessory: return "Audio Accessories" case .accessoryCase: return "Charging Cases" case .charger: return "Chargers" case .powerbank: return "Powerbanks" case .other: return "Other" } } var symbolName: String { switch self { case .phone: return "iphone" case .tablet: return "ipad" case .laptop: return "laptopcomputer" case .watch: return "applewatch" case .audioAccessory: return "earbuds.case" case .accessoryCase: return "airpods.case.fill" case .charger: return "bolt.horizontal.circle" case .powerbank: return "battery.100.bolt" case .other: return "shippingbox" } } var kind: ChargedDeviceKind { self == .charger ? .charger : .device } static func fromLegacyDeviceClass(_ deviceClass: ChargedDeviceClass) -> ProfileCategory { switch deviceClass { case .iphone: return .phone case .watch: return .watch case .powerbank: return .powerbank case .charger: return .charger case .other: return .other } } } struct DeviceProfileDefinition: Identifiable, Hashable, Codable { let id: String let name: String let group: String let category: ProfileCategory let icon: ChargedDeviceTemplateIcon let sortOrder: Int let capWiredCharging: Bool let capWirelessCharging: Bool let capWirelessProfiles: [WirelessChargingProfile] let capChargingStateAvailability: ChargingStateAvailability let capHasInternalSubject: Bool let defaultWirelessChargingProfile: WirelessChargingProfile? let defaultWiredMinimumCurrentAmps: Double? let defaultWirelessMinimumCurrentAmps: Double? let defaultWiredEstimatedBatteryCapacityWh: Double? let defaultWirelessEstimatedBatteryCapacityWh: Double? init( id: String, name: String, group: String, category: ProfileCategory, icon: ChargedDeviceTemplateIcon, sortOrder: Int, capWiredCharging: Bool, capWirelessCharging: Bool, capWirelessProfiles: [WirelessChargingProfile], capChargingStateAvailability: ChargingStateAvailability, capHasInternalSubject: Bool, defaultWirelessChargingProfile: WirelessChargingProfile? = nil, defaultWiredMinimumCurrentAmps: Double? = nil, defaultWirelessMinimumCurrentAmps: Double? = nil, defaultWiredEstimatedBatteryCapacityWh: Double? = nil, defaultWirelessEstimatedBatteryCapacityWh: Double? = nil ) { self.id = id self.name = name self.group = group self.category = category self.icon = icon self.sortOrder = sortOrder self.capWiredCharging = capWiredCharging self.capWirelessCharging = capWirelessCharging self.capWirelessProfiles = capWirelessProfiles self.capChargingStateAvailability = capChargingStateAvailability self.capHasInternalSubject = capHasInternalSubject self.defaultWirelessChargingProfile = defaultWirelessChargingProfile self.defaultWiredMinimumCurrentAmps = defaultWiredMinimumCurrentAmps self.defaultWirelessMinimumCurrentAmps = defaultWirelessMinimumCurrentAmps self.defaultWiredEstimatedBatteryCapacityWh = defaultWiredEstimatedBatteryCapacityWh self.defaultWirelessEstimatedBatteryCapacityWh = defaultWirelessEstimatedBatteryCapacityWh } var capabilitySummary: String { var components: [String] = [capChargingStateAvailability.title] switch (capWiredCharging, capWirelessCharging) { case (true, true): components.append("Wired + Wireless") case (true, false): components.append("Wired only") case (false, true): components.append("Wireless only") case (false, false): components.append("No transport") } if capWirelessCharging, let primary = defaultWirelessChargingProfile { components.append(primary.title) } return components.joined(separator: " • ") } var wirelessProfilesCSV: String { capWirelessProfiles.map { $0.rawValue }.joined(separator: ",") } static func decodeWirelessProfilesCSV(_ csv: String?) -> [WirelessChargingProfile] { guard let csv, !csv.isEmpty else { return [] } return csv .split(separator: ",") .compactMap { WirelessChargingProfile(rawValue: String($0).trimmingCharacters(in: .whitespaces)) } } } private struct DeviceProfileCatalogDocument: Codable { let profiles: [DeviceProfileDefinition] } struct DeviceProfileCatalog { static let shared = DeviceProfileCatalog() let profiles: [DeviceProfileDefinition] private let profilesByID: [String: DeviceProfileDefinition] private init(bundle: Bundle = .main) { let loaded: [DeviceProfileDefinition] if let resourceURL = bundle.url(forResource: "DeviceProfilesCatalog", withExtension: "json"), let data = try? Data(contentsOf: resourceURL), let document = try? JSONDecoder().decode(DeviceProfileCatalogDocument.self, from: data) { loaded = document.profiles } else { loaded = [] } self.profiles = loaded.sorted { lhs, rhs in if lhs.group != rhs.group { return lhs.group < rhs.group } if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder } return lhs.name < rhs.name } self.profilesByID = Dictionary(uniqueKeysWithValues: self.profiles.map { ($0.id, $0) }) } func profile(id: String?) -> DeviceProfileDefinition? { guard let id else { return nil } return profilesByID[id] } func profiles(for category: ProfileCategory) -> [DeviceProfileDefinition] { profiles.filter { $0.category == category } } } /// Centralizes the autoexclusion rules that turn a `DeviceProfile` into a coherent /// device state. Called from the editor at edit time so impossible combinations are /// not even expressible — instead of being silently corrected at read time. enum DeviceProfileValidator { struct AppliedState: Equatable { var chargingStateAvailability: ChargingStateAvailability var supportsWiredCharging: Bool var supportsWirelessCharging: Bool var wirelessChargingProfile: WirelessChargingProfile var hasInternalSubject: Bool } /// Returns the canonical state for a freshly selected profile. /// Used both when the user picks a profile in the editor and when seeding /// new device defaults from a catalog entry. static func canonicalState(for profile: DeviceProfileDefinition) -> AppliedState { AppliedState( chargingStateAvailability: profile.capChargingStateAvailability, supportsWiredCharging: profile.capWiredCharging, supportsWirelessCharging: profile.capWirelessCharging, wirelessChargingProfile: profile.defaultWirelessChargingProfile ?? profile.capWirelessProfiles.first ?? .genericQi, hasInternalSubject: false ) } /// Coerces a possibly-contradictory state to fit the profile's capabilities. /// Preserves user-set values where they are still allowed; otherwise falls /// back to canonical defaults. static func coerce( state: AppliedState, to profile: DeviceProfileDefinition ) -> AppliedState { var coerced = state coerced.supportsWiredCharging = state.supportsWiredCharging && profile.capWiredCharging coerced.supportsWirelessCharging = state.supportsWirelessCharging && profile.capWirelessCharging if !coerced.supportsWiredCharging && !coerced.supportsWirelessCharging { coerced.supportsWiredCharging = profile.capWiredCharging coerced.supportsWirelessCharging = profile.capWirelessCharging } coerced.chargingStateAvailability = profile.capChargingStateAvailability if !profile.capWirelessProfiles.contains(state.wirelessChargingProfile) { coerced.wirelessChargingProfile = profile.defaultWirelessChargingProfile ?? profile.capWirelessProfiles.first ?? .genericQi } if !profile.capHasInternalSubject { coerced.hasInternalSubject = false } return coerced } /// True when the editor should offer the user a toggle for wired charging. /// (False means the profile forbids wired entirely — hide the row.) static func allowsWiredToggle(_ profile: DeviceProfileDefinition) -> Bool { profile.capWiredCharging } static func allowsWirelessToggle(_ profile: DeviceProfileDefinition) -> Bool { profile.capWirelessCharging } /// True when both transports are permitted — meaning the user may opt out of /// either; otherwise the surviving transport is mandatory. static func allowsTransportChoice(_ profile: DeviceProfileDefinition) -> Bool { profile.capWiredCharging && profile.capWirelessCharging } /// True when there is more than one wireless profile to choose from for this /// catalog entry. Shown as a picker; otherwise hidden (single value implied). static func allowsWirelessProfileChoice(_ profile: DeviceProfileDefinition) -> Bool { profile.capWirelessProfiles.count > 1 } /// True when the profile's `capChargingStateAvailability` is fixed to a single /// state mode — in which case the editor renders a locked badge instead of a picker. static func chargingStateIsLocked(_ profile: DeviceProfileDefinition) -> Bool { profile.capChargingStateAvailability == .onOnly || profile.capChargingStateAvailability == .offOnly } } struct ChargeCheckpointSummary: Identifiable, Hashable { let id: UUID let sessionID: UUID let chargedDeviceID: UUID let powerbankID: UUID? let batteryBarsValue: Int let timestamp: Date let batteryPercent: Double let measuredEnergyWh: Double let currentAmps: Double let voltageVolts: Double? let label: String? var flag: ChargeCheckpointFlag { ChargeCheckpointFlag.fromStoredLabel(label) } var subject: CheckpointSubject { powerbankID == nil ? .chargedDevice : .powerbank } } enum ChargeCheckpointFlag: String, CaseIterable { case initial case intermediate case final var title: String { switch self { case .initial: return "Initial" case .intermediate: return "Intermediate" case .final: return "Final" } } var anchorDescription: String { switch self { case .initial: return "initial checkpoint" case .intermediate: return "intermediate checkpoint" case .final: return "final checkpoint" } } static func fromStoredLabel(_ label: String?) -> ChargeCheckpointFlag { let normalized = label? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() switch normalized { case "initial", "start": return .initial case "final", "end": return .final case "intermediate", nil, "": return .intermediate default: return .intermediate } } } struct ChargeSessionSampleSummary: Identifiable, Hashable { let sessionID: UUID let chargedDeviceID: UUID let bucketIndex: Int let timestamp: Date let averageCurrentAmps: Double let averageVoltageVolts: Double? let averagePowerWatts: Double let measuredEnergyWh: Double let estimatedBatteryPercent: Double? let sampleCount: Int var id: String { "\(sessionID.uuidString)-\(bucketIndex)" } } struct ChargeSessionSummary: Identifiable, Hashable { let id: UUID let chargedDeviceID: UUID let chargedPowerbankID: UUID? let chargerID: UUID? let sourcePowerbankID: UUID? let meterMACAddress: String? let meterName: String? let meterModel: String? let startedAt: Date let endedAt: Date? let lastObservedAt: Date let pausedAt: Date? let status: ChargeSessionStatus let sourceMode: ChargeSessionSourceMode let chargingTransportMode: ChargingTransportMode let chargingStateMode: ChargingStateMode let autoStopEnabled: Bool let measuredEnergyWh: Double let effectiveBatteryEnergyWh: Double? let meterEnergyBaselineWh: Double? let meterDurationBaselineSeconds: Double? let meterLastDurationSeconds: Double? let minimumObservedCurrentAmps: Double? let maximumObservedCurrentAmps: Double? let maximumObservedPowerWatts: Double? let maximumObservedVoltageVolts: Double? let hasObservedChargeFlow: Bool let selectedSourceVoltageVolts: Double? let completionCurrentAmps: Double? let stopThresholdAmps: Double let startBatteryPercent: Double? let endBatteryPercent: Double? let capacityEstimateWh: Double? let wirelessEfficiencyFactor: Double? let usesEstimatedWirelessEfficiency: Bool let shouldWarnAboutLowWirelessEfficiency: Bool let supportsChargingWhileOff: Bool let usedOfflineMeterCounters: Bool let targetBatteryPercent: Double? let targetBatteryAlertTriggeredAt: Date? let requiresCompletionConfirmation: Bool let completionConfirmationRequestedAt: Date? let completionContradictionPercent: Double? let selectedDataGroup: UInt8? let trimStart: Date? let trimEnd: Date? let wasConflictHealed: Bool let checkpoints: [ChargeCheckpointSummary] let aggregatedSamples: [ChargeSessionSampleSummary] var effectiveTrimStart: Date { trimStart ?? startedAt } var effectiveTrimEnd: Date { trimEnd ?? (endedAt ?? lastObservedAt) } var isTrimmed: Bool { trimStart != nil || trimEnd != nil } var effectiveTimeRange: ClosedRange { let start = effectiveTrimStart let end = max(effectiveTrimEnd, start) return start...end } var displayedAggregatedSamples: [ChargeSessionSampleSummary] { guard isTrimmed else { return aggregatedSamples } let range = effectiveTimeRange return aggregatedSamples.filter { range.contains($0.timestamp) } } var sessionKind: ChargeSessionKind { ChargeSessionKind( chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode ) } /// Generalized source slot. `none` when no source is tracked, `charger(id)` for the existing /// charger flow, `powerbank(id)` when a powerbank is supplying power for this session. var source: ChargeSessionSource { if let sourcePowerbankID { return .powerbank(sourcePowerbankID) } if let chargerID { return .charger(chargerID) } return .none } var hasPowerbankSubject: Bool { chargedPowerbankID != nil } var hasPowerbankSource: Bool { sourcePowerbankID != nil } var duration: TimeInterval { (endedAt ?? lastObservedAt).timeIntervalSince(startedAt) } var meterObservedDuration: TimeInterval? { guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else { return nil } guard meterLastDurationSeconds >= meterDurationBaselineSeconds else { return nil } return meterLastDurationSeconds - meterDurationBaselineSeconds } var effectiveDuration: TimeInterval { if isTrimmed { return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0) } // Use timestamp-based duration as primary source; only use meter counter if it's consistent let timestampDuration = duration if let meterDuration = meterObservedDuration { // Allow 5% tolerance for meter counter vs timestamp calculation let tolerance = timestampDuration * 0.05 let lower = timestampDuration - tolerance let upper = timestampDuration + tolerance // If meter duration is within tolerance range, use it (more precise) // Otherwise fall back to timestamp-based duration if meterDuration >= lower && meterDuration <= upper { return meterDuration } } return timestampDuration } var effectiveOrMeasuredEnergyWh: Double { effectiveBatteryEnergyWh ?? measuredEnergyWh } var hasSavableChargeData: Bool { hasObservedChargeFlow || measuredEnergyWh > 0 || (maximumObservedCurrentAmps ?? 0) > 0 || (maximumObservedPowerWatts ?? 0) > 0 || !aggregatedSamples.isEmpty } var batteryDeltaPercent: Double? { guard let startBatteryPercent, let endBatteryPercent, startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil } return endBatteryPercent - startBatteryPercent } var startsFromFlatBattery: Bool { guard let startBatteryPercent else { return false } return startBatteryPercent.isFinite && startBatteryPercent < 0 } var canAutoStop: Bool { autoStopEnabled && stopThresholdAmps > 0 } var isPaused: Bool { status == .paused } var isOpen: Bool { status.isOpen } } enum BatteryLevelPredictionBasis: Hashable { case capacityEstimate case checkpointEnergyMap case typicalChargeCurve var metricLabel: String { switch self { case .capacityEstimate: return "est. capacity" case .checkpointEnergyMap: return "energy map" case .typicalChargeCurve: return "charge curve" } } var explanatoryLabel: String { switch self { case .capacityEstimate: return "estimated capacity" case .checkpointEnergyMap: return "checkpoint energy map" case .typicalChargeCurve: return "typical charge curve" } } } struct BatteryLevelPrediction: Hashable { let predictedPercent: Double let estimatedCapacityWh: Double? let basis: BatteryLevelPredictionBasis let anchorPercent: Double let anchorEnergyWh: Double let anchorDescription: String func energyWh(forPercent percent: Double) -> Double? { guard let estimatedCapacityWh, estimatedCapacityWh > 0 else { return nil } return anchorEnergyWh + ((percent - anchorPercent) / 100) * estimatedCapacityWh } } enum BatteryLevelPredictionTuning { static func inferredVirtualZeroEnergyWh( from anchors: [BatteryLevelPredictionAnchor], estimatedCapacityWh: Double? = nil, historicalReserveEnergyWh: Double? = nil ) -> Double? { let sortedAnchors = anchors .filter { $0.percent > 0 && $0.percent <= 100 && $0.energyWh >= 0 } .sorted { lhs, rhs in if lhs.energyWh != rhs.energyWh { return lhs.energyWh < rhs.energyWh } return lhs.timestamp < rhs.timestamp } guard let firstAnchor = sortedAnchors.first else { return nil } func clampedReserve(_ reserveEnergyWh: Double) -> Double? { guard reserveEnergyWh.isFinite else { return nil } return min(max(reserveEnergyWh, 0), firstAnchor.energyWh) } if let historicalReserveEnergyWh, let reserveEnergyWh = clampedReserve(historicalReserveEnergyWh) { return reserveEnergyWh } if let estimatedCapacityWh, estimatedCapacityWh > 0 { return clampedReserve( firstAnchor.energyWh - ((firstAnchor.percent / 100) * estimatedCapacityWh) ) } var zeroCandidates: [Double] = [] for lowerIndex in sortedAnchors.indices { for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex { let lower = sortedAnchors[lowerIndex] let upper = sortedAnchors[upperIndex] let percentDelta = upper.percent - lower.percent let energyDeltaWh = upper.energyWh - lower.energyWh guard percentDelta >= 3, energyDeltaWh > 0.01 else { continue } let capacityWh = energyDeltaWh / (percentDelta / 100) guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else { continue } zeroCandidates.append(lower.energyWh - ((lower.percent / 100) * capacityWh)) zeroCandidates.append(upper.energyWh - ((upper.percent / 100) * capacityWh)) } } guard !zeroCandidates.isEmpty else { return nil } let sortedCandidates = zeroCandidates.sorted() return clampedReserve(sortedCandidates[sortedCandidates.count / 2]) } static func predictedPercent( anchorPercent: Double, anchorEnergyWh: Double, anchorTimestamp: Date, anchorIsCheckpoint: Bool, effectiveEnergyWh: Double, referenceTimestamp: Date, estimatedCapacityWh: Double ) -> Double { _ = anchorTimestamp _ = anchorIsCheckpoint _ = referenceTimestamp let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0) let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100 return min( 100, max( 0, anchorPercent + rawGainPercent ) ) } static func predictedPercent( anchorPercent: Double, anchorEnergyWh: Double, effectiveEnergyWh: Double, chargeCurve: BatteryChargeCurve, deviationFactor: Double? ) -> Double? { guard let curveAnchorEnergyWh = chargeCurve.energyWh(forPercent: anchorPercent) else { return nil } let sessionEnergyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0) let normalizedEnergyDeltaWh = sessionEnergyDeltaWh / max(deviationFactor ?? 1, 0.05) let projectedCurveEnergyWh = curveAnchorEnergyWh + normalizedEnergyDeltaWh guard let curvePercent = chargeCurve.percent(forEnergyWh: projectedCurveEnergyWh) else { return nil } return min(100, max(anchorPercent, curvePercent)) } static func deviationFactor( anchors: [BatteryLevelPredictionAnchor], chargeCurve: BatteryChargeCurve ) -> Double? { let sortedAnchors = anchors.sorted { lhs, rhs in if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } return lhs.energyWh < rhs.energyWh } var ratios: [Double] = [] for lowerIndex in sortedAnchors.indices { for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex { let lower = sortedAnchors[lowerIndex] let upper = sortedAnchors[upperIndex] let percentDelta = upper.percent - lower.percent let energyDeltaWh = upper.energyWh - lower.energyWh guard percentDelta >= 3, energyDeltaWh > 0.01, let curveLowerEnergyWh = chargeCurve.energyWh(forPercent: lower.percent), let curveUpperEnergyWh = chargeCurve.energyWh(forPercent: upper.percent) else { continue } let curveEnergyDeltaWh = curveUpperEnergyWh - curveLowerEnergyWh guard curveEnergyDeltaWh > 0.01 else { continue } let ratio = energyDeltaWh / curveEnergyDeltaWh guard ratio.isFinite, ratio > 0 else { continue } ratios.append(min(max(ratio, 0.25), 4.0)) } } guard !ratios.isEmpty else { return nil } let sortedRatios = ratios.sorted() return sortedRatios[sortedRatios.count / 2] } } struct CapacityTrendPoint: Identifiable, Hashable { let sessionID: UUID let timestamp: Date let capacityWh: Double let chargingTransportMode: ChargingTransportMode var id: UUID { sessionID } } struct TypicalChargeCurvePoint: Identifiable, Hashable { let percentBin: Int let averageEnergyWh: Double let sampleCount: Int var id: Int { percentBin } } struct BatteryLevelPredictionAnchor: Hashable { let percent: Double let energyWh: Double let timestamp: Date let description: String let isCheckpoint: Bool init( percent: Double, energyWh: Double, timestamp: Date, description: String = "", isCheckpoint: Bool ) { self.percent = percent self.energyWh = energyWh self.timestamp = timestamp self.description = description self.isCheckpoint = isCheckpoint } } struct BatteryChargeCurve { private let points: [(percent: Double, energyWh: Double)] init?(typicalCurvePoints: [TypicalChargeCurvePoint]) { let validPoints = typicalCurvePoints .filter { $0.averageEnergyWh.isFinite && $0.averageEnergyWh >= 0 && $0.percentBin >= 0 && $0.percentBin <= 100 } .sorted { lhs, rhs in lhs.percentBin < rhs.percentBin } var normalizedPoints: [(percent: Double, energyWh: Double)] = [] var runningMaximumEnergyWh = 0.0 for point in validPoints { runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh) normalizedPoints.append( (percent: Double(point.percentBin), energyWh: runningMaximumEnergyWh) ) } guard normalizedPoints.count >= 2 else { return nil } self.points = normalizedPoints } func energyWh(forPercent percent: Double) -> Double? { interpolatedValue( lookup: min(max(percent, 0), 100), key: { $0.percent }, value: { $0.energyWh } ) } func percent(forEnergyWh energyWh: Double) -> Double? { interpolatedValue( lookup: max(energyWh, 0), key: { $0.energyWh }, value: { $0.percent } ) } private func interpolatedValue( lookup: Double, key: ((percent: Double, energyWh: Double)) -> Double, value: ((percent: Double, energyWh: Double)) -> Double ) -> Double? { guard let first = points.first, let last = points.last else { return nil } let firstKey = key(first) let lastKey = key(last) guard lookup >= firstKey, lookup <= lastKey else { return nil } if abs(lookup - firstKey) < 0.000_1 { return value(first) } if abs(lookup - lastKey) < 0.000_1 { return value(last) } guard let upperIndex = points.firstIndex(where: { key($0) >= lookup }), upperIndex > 0 else { return nil } let lower = points[upperIndex - 1] let upper = points[upperIndex] let lowerKey = key(lower) let upperKey = key(upper) let span = upperKey - lowerKey guard span > 0.000_1 else { return value(upper) } let progress = (lookup - lowerKey) / span return value(lower) + ((value(upper) - value(lower)) * progress) } } struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable { let timestamp: Date let powerWatts: Double let currentAmps: Double let voltageVolts: Double var id: TimeInterval { timestamp.timeIntervalSince1970 } } struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable { let index: Int let lowerBoundWatts: Double let upperBoundWatts: Double let count: Int let relativeFrequency: Double var id: Int { index } } enum HistogramResolution: Int, CaseIterable, Identifiable { case x1 = 1 case x2 = 2 case x4 = 4 var id: Int { rawValue } var label: String { switch self { case .x1: return "1×" case .x2: return "2×" case .x4: return "4×" } } } struct ChargerStandbyPowerMeasurementStatistics: Hashable { let sampleCount: Int let observedDuration: TimeInterval let averagePowerWatts: Double let recentAveragePowerWatts: Double let medianPowerWatts: Double let minimumPowerWatts: Double let maximumPowerWatts: Double let standardDeviationPowerWatts: Double let coefficientOfVariation: Double let averageCurrentAmps: Double let averageVoltageVolts: Double let stabilityDeltaWatts: Double let stabilityToleranceWatts: Double let histogram: [ChargerStandbyPowerDistributionBin] var projectedDailyEnergyWh: Double { averagePowerWatts * 24 } var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 } var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 } var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 } var stabilityDeltaMilliwatts: Double { stabilityDeltaWatts * 1000 } var isStable: Bool { sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount && stabilityDeltaWatts <= stabilityToleranceWatts } } struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable { let id: UUID let chargerID: UUID let meterMACAddress: String let meterName: String? let meterModel: String? let startedAt: Date let endedAt: Date let sampleCount: Int let stabilizedAt: Date? let averagePowerWatts: Double let recentAveragePowerWatts: Double let medianPowerWatts: Double let minimumPowerWatts: Double let maximumPowerWatts: Double let standardDeviationPowerWatts: Double let coefficientOfVariation: Double let averageCurrentAmps: Double let averageVoltageVolts: Double let stabilityDeltaWatts: Double let stabilityToleranceWatts: Double /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display. let storedHistogram: [ChargerStandbyPowerDistributionBin] // MARK: - Codable (with migration from legacy powerSamplesWatts) private enum CodingKeys: String, CodingKey { case id, chargerID, meterMACAddress, meterName, meterModel case startedAt, endedAt, sampleCount, stabilizedAt case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts case stabilityDeltaWatts, stabilityToleranceWatts case storedHistogram case powerSamplesWatts // legacy – decode only } init( id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?, startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?, averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double, minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double, coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double, stabilityDeltaWatts: Double, stabilityToleranceWatts: Double, storedHistogram: [ChargerStandbyPowerDistributionBin] ) { self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress self.meterName = meterName; self.meterModel = meterModel self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount self.stabilizedAt = stabilizedAt self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts self.maximumPowerWatts = maximumPowerWatts self.standardDeviationPowerWatts = standardDeviationPowerWatts self.coefficientOfVariation = coefficientOfVariation self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts self.storedHistogram = storedHistogram } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(UUID.self, forKey: .id) chargerID = try c.decode(UUID.self, forKey: .chargerID) meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress) meterName = try c.decodeIfPresent(String.self, forKey: .meterName) meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel) startedAt = try c.decode(Date.self, forKey: .startedAt) endedAt = try c.decode(Date.self, forKey: .endedAt) sampleCount = try c.decode(Int.self, forKey: .sampleCount) stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt) averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts) recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts) medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts) minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts) maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts) standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts) coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation) averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps) averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts) stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts) stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts) let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram) if let decodedBins, !decodedBins.isEmpty { storedHistogram = decodedBins } else { // Migrate from legacy raw samples format let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? [] let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded()))) storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram( for: samples, preferredBinCount: base * HistogramResolution.x4.rawValue ) } } func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) try c.encode(id, forKey: .id) try c.encode(chargerID, forKey: .chargerID) try c.encode(meterMACAddress, forKey: .meterMACAddress) try c.encodeIfPresent(meterName, forKey: .meterName) try c.encodeIfPresent(meterModel, forKey: .meterModel) try c.encode(startedAt, forKey: .startedAt) try c.encode(endedAt, forKey: .endedAt) try c.encode(sampleCount, forKey: .sampleCount) try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt) try c.encode(averagePowerWatts, forKey: .averagePowerWatts) try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts) try c.encode(medianPowerWatts, forKey: .medianPowerWatts) try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts) try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts) try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts) try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation) try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps) try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts) try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts) try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts) try c.encode(storedHistogram, forKey: .storedHistogram) } // MARK: - Computed var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) } var projectedDailyEnergyWh: Double { averagePowerWatts * 24 } var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 } var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 } var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 } var isStable: Bool { stabilizedAt != nil } /// Returns the histogram downsampled to the requested resolution. /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue. func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] { let factor = HistogramResolution.x4.rawValue / resolution.rawValue return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor) } } enum ChargerStandbyPowerMeasurementAnalyzer { static let minimumStableSampleCount = 45 static let recentSampleWindow = 40 static let minimumStabilityToleranceWatts = 0.010 static let relativeStabilityTolerance = 0.05 static func statistics( from samples: [ChargerStandbyPowerSample], startedAt: Date, referenceDate: Date = Date() ) -> ChargerStandbyPowerMeasurementStatistics? { guard !samples.isEmpty else { return nil } let powerValues = samples.map(\.powerWatts).filter(\.isFinite) let currentValues = samples.map(\.currentAmps).filter(\.isFinite) let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite) guard powerValues.isEmpty == false else { return nil } let averagePower = mean(powerValues) let recentWindow = min(recentSampleWindow, max(1, powerValues.count)) let recentAveragePower = mean(Array(powerValues.suffix(recentWindow))) let stabilityDelta = abs(averagePower - recentAveragePower) let stabilityTolerance = max( minimumStabilityToleranceWatts, abs(averagePower) * relativeStabilityTolerance ) let baseBinCount = min(18, max(8, Int(Double(powerValues.count).squareRoot().rounded()))) let liveHistogram = histogram(for: powerValues, preferredBinCount: baseBinCount * HistogramResolution.x4.rawValue) return ChargerStandbyPowerMeasurementStatistics( sampleCount: powerValues.count, observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0), averagePowerWatts: averagePower, recentAveragePowerWatts: recentAveragePower, medianPowerWatts: median(powerValues), minimumPowerWatts: powerValues.min() ?? 0, maximumPowerWatts: powerValues.max() ?? 0, standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower), coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower), averageCurrentAmps: mean(currentValues), averageVoltageVolts: mean(voltageValues), stabilityDeltaWatts: stabilityDelta, stabilityToleranceWatts: stabilityTolerance, histogram: liveHistogram ) } static func measurementSummary( chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?, startedAt: Date, endedAt: Date, samples: [ChargerStandbyPowerSample], stabilizedAt: Date? ) -> ChargerStandbyPowerMeasurementSummary? { guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else { return nil } return ChargerStandbyPowerMeasurementSummary( id: UUID(), chargerID: chargerID, meterMACAddress: meterMACAddress, meterName: meterName, meterModel: meterModel, startedAt: startedAt, endedAt: endedAt, sampleCount: statistics.sampleCount, stabilizedAt: stabilizedAt, averagePowerWatts: statistics.averagePowerWatts, recentAveragePowerWatts: statistics.recentAveragePowerWatts, medianPowerWatts: statistics.medianPowerWatts, minimumPowerWatts: statistics.minimumPowerWatts, maximumPowerWatts: statistics.maximumPowerWatts, standardDeviationPowerWatts: statistics.standardDeviationPowerWatts, coefficientOfVariation: statistics.coefficientOfVariation, averageCurrentAmps: statistics.averageCurrentAmps, averageVoltageVolts: statistics.averageVoltageVolts, stabilityDeltaWatts: statistics.stabilityDeltaWatts, stabilityToleranceWatts: statistics.stabilityToleranceWatts, storedHistogram: statistics.histogram ) } /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1. static func downsample( _ bins: [ChargerStandbyPowerDistributionBin], factor: Int ) -> [ChargerStandbyPowerDistributionBin] { guard factor > 1, !bins.isEmpty else { return bins } let totalCount = bins.reduce(0) { $0 + $1.count } var result: [ChargerStandbyPowerDistributionBin] = [] var inputIndex = 0 var outputIndex = 0 while inputIndex < bins.count { let group = Array(bins[inputIndex.. 0 ? Double(mergedCount) / Double(totalCount) : 0 result.append(ChargerStandbyPowerDistributionBin( index: outputIndex, lowerBoundWatts: group.first!.lowerBoundWatts, upperBoundWatts: group.last!.upperBoundWatts, count: mergedCount, relativeFrequency: relFreq )) inputIndex += factor outputIndex += 1 } return result } static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] { let finiteValues = values.filter(\.isFinite) guard finiteValues.isEmpty == false else { return [] } let minimum = finiteValues.min() ?? 0 let maximum = finiteValues.max() ?? 0 let spread = maximum - minimum let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded()))) guard spread > 0 else { return [ ChargerStandbyPowerDistributionBin( index: 0, lowerBoundWatts: minimum, upperBoundWatts: maximum, count: finiteValues.count, relativeFrequency: 1 ) ] } let safeBinCount = max(1, binCount) let binWidth = spread / Double(safeBinCount) var counts = Array(repeating: 0, count: safeBinCount) for value in finiteValues { let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down)) let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1) counts[safeIndex] += 1 } return counts.enumerated().map { index, count in let lowerBound = minimum + (Double(index) * binWidth) let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth return ChargerStandbyPowerDistributionBin( index: index, lowerBoundWatts: lowerBound, upperBoundWatts: upperBound, count: count, relativeFrequency: Double(count) / Double(finiteValues.count) ) } } private static func mean(_ values: [Double]) -> Double { guard values.isEmpty == false else { return 0 } return values.reduce(0, +) / Double(values.count) } private static func median(_ values: [Double]) -> Double { guard values.isEmpty == false else { return 0 } let sorted = values.sorted() let middleIndex = sorted.count / 2 if sorted.count.isMultiple(of: 2) { return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2 } return sorted[middleIndex] } private static func standardDeviation(_ values: [Double], mean: Double) -> Double { guard values.count > 1 else { return 0 } let variance = values.reduce(0) { partialResult, value in let delta = value - mean return partialResult + (delta * delta) } / Double(values.count) return variance.squareRoot() } private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double { guard abs(mean) > 0.000_001 else { return 0 } return standardDeviation(values, mean: mean) / abs(mean) } } struct ChargedDeviceSummary: Identifiable, Hashable { let id: UUID let qrIdentifier: String let name: String let deviceClass: ChargedDeviceClass let deviceTemplateID: String? let templateDefinition: ChargedDeviceTemplateDefinition? let profileID: String? let hasInternalSubject: Bool let supportsChargingWhileOff: Bool let chargingStateAvailability: ChargingStateAvailability let supportsWiredCharging: Bool let supportsWirelessCharging: Bool let chargerType: ChargerType? let wirelessChargingProfile: WirelessChargingProfile let configuredCompletionCurrents: [ChargeSessionKind: Double] let learnedCompletionCurrents: [ChargeSessionKind: Double] let wirelessChargerEfficiencyFactor: Double? let wiredChargeCompletionCurrentAmps: Double? let wirelessChargeCompletionCurrentAmps: Double? let chargerObservedVoltageSelections: [Double] let chargerIdleCurrentAmps: Double? let chargerEfficiencyFactor: Double? let chargerMaximumPowerWatts: Double? let notes: String? let minimumCurrentAmps: Double? let estimatedBatteryCapacityWh: Double? let wiredMinimumCurrentAmps: Double? let wirelessMinimumCurrentAmps: Double? let wiredEstimatedBatteryCapacityWh: Double? let wirelessEstimatedBatteryCapacityWh: Double? let createdAt: Date let updatedAt: Date let sessions: [ChargeSessionSummary] let capacityHistory: [CapacityTrendPoint] let typicalCurve: [TypicalChargeCurvePoint] let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary] var isCharger: Bool { deviceClass == .charger } /// True when the device's active catalog profile is one of the case-style /// profiles (AirPods case, charging case, …) — i.e. the editor exposes the /// `hasInternalSubject` toggle and the detail UI should surface its state. var supportsInternalSubject: Bool { guard let profileID, let profile = DeviceProfileCatalog.shared.profile(id: profileID) else { return false } return profile.capHasInternalSubject } var kind: ChargedDeviceKind { deviceClass.kind } var identityTitle: String { templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title) } var fallbackIdentitySymbolName: String { isCharger ? kind.symbolName : deviceClass.symbolName } var identityIcon: ChargedDeviceTemplateIcon { templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName) } var identitySymbolName: String { identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName) } var activeSession: ChargeSessionSummary? { sessions.first(where: \.isOpen) } var recentCompletedSessions: [ChargeSessionSummary] { sessions.filter { $0.status == .completed } } var sessionCount: Int { sessions.count } var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? { standbyPowerMeasurements.first } var supportedChargingModes: [ChargingTransportMode] { var modes: [ChargingTransportMode] = [] if supportsWiredCharging { modes.append(.wired) } if supportsWirelessCharging { modes.append(.wireless) } return modes } var supportedChargingStateModes: [ChargingStateMode] { chargingStateAvailability.supportedModes } var hasMultipleChargingTransports: Bool { supportedChargingModes.count > 1 } var hasMultipleChargingStateModes: Bool { supportedChargingStateModes.count > 1 } var showsWirelessProfileDetails: Bool { supportsWirelessCharging && hasMultipleChargingTransports && deviceClass != .watch } var chargingSupportSummary: String { switch (supportsWiredCharging, supportsWirelessCharging) { case (true, true): return "Supports wired and wireless charging." case (true, false): return "Supports wired charging only." case (false, true): return "Supports wireless charging only." case (false, false): return "No charging method configured." } } func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode { if let matchingSession = sessions.first(where: { $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode }) { return matchingSession.chargingStateMode } return chargingStateAvailability.supportedModes.first ?? .on } func sessionKind( for chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode? = nil ) -> ChargeSessionKind { ChargeSessionKind( chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode) ) } func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? { switch chargingTransportMode { case .wired: return wiredEstimatedBatteryCapacityWh case .wireless: return wirelessEstimatedBatteryCapacityWh } } func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? { switch chargingTransportMode { case .wired: return wiredMinimumCurrentAmps case .wireless: return wirelessMinimumCurrentAmps } } func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool { hasMultipleChargingTransports || supportedChargingModes.contains(chargingTransportMode) == false } func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool { hasMultipleChargingStateModes || supportedChargingStateModes.contains(chargingStateMode) == false } func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? { if let explicitCurrent = configuredCompletionCurrents[sessionKind] { return explicitCurrent } switch sessionKind.chargingTransportMode { case .wired: return wiredChargeCompletionCurrentAmps case .wireless: return wirelessChargeCompletionCurrentAmps } } func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? { if let learnedCurrent = learnedCompletionCurrents[sessionKind] { return learnedCurrent } switch sessionKind.chargingTransportMode { case .wired: return wiredMinimumCurrentAmps ?? minimumCurrentAmps case .wireless: return wirelessMinimumCurrentAmps ?? minimumCurrentAmps } } func resolvedCompletionCurrentAmps( for chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode? = nil ) -> Double? { let sessionKind = sessionKind( for: chargingTransportMode, chargingStateMode: chargingStateMode ) return configuredCompletionCurrentAmps(for: sessionKind) ?? learnedCompletionCurrentAmps(for: sessionKind) ?? minimumCurrentAmps(for: chargingTransportMode) ?? minimumCurrentAmps } func batteryLevelPrediction( for session: ChargeSessionSummary, effectiveEnergyWhOverride: Double? = nil, referenceTimestamp: Date? = nil ) -> BatteryLevelPrediction? { let estimatedCapacityWh = session.capacityEstimateWh ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode) ?? estimatedBatteryCapacityWh let effectiveEnergyWh = effectiveEnergyWhOverride ?? session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [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: [BatteryLevelPredictionAnchor]) -> Double? { let candidates = anchorCapacityCandidates(from: anchors) guard !candidates.isEmpty else { return nil } let sortedCandidates = candidates.sorted() return sortedCandidates[sortedCandidates.count / 2] } var anchors: [BatteryLevelPredictionAnchor] = [] if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 { anchors.append( BatteryLevelPredictionAnchor( percent: startBatteryPercent, energyWh: 0, timestamp: session.effectiveTrimStart, description: "session start", isCheckpoint: false ) ) } anchors.append( contentsOf: session.checkpoints .filter { $0.batteryPercent >= 0 } .map { checkpoint in BatteryLevelPredictionAnchor( percent: checkpoint.batteryPercent, energyWh: checkpoint.measuredEnergyWh, timestamp: checkpoint.timestamp, description: checkpoint.flag.anchorDescription, isCheckpoint: true ) } ) if session.startsFromFlatBattery { if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh( from: anchors, estimatedCapacityWh: estimatedCapacityWh, historicalReserveEnergyWh: estimatedFlatReserveEnergyWh(excluding: session.id) ) { anchors.append( BatteryLevelPredictionAnchor( percent: 0, energyWh: virtualZeroEnergyWh, timestamp: session.effectiveTrimStart, description: "estimated flat reserve", isCheckpoint: false ) ) } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }), effectiveEnergyWh < firstCheckpoint.energyWh - 0.05 { return nil } } 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 nil } let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone let inferredCapacityWh = estimatedCapacityWh ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil) let fallbackBasis: BatteryLevelPredictionBasis = estimatedCapacityWh == nil ? .checkpointEnergyMap : .capacityEstimate let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 } let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 } let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first! let predictedPercent: Double let basis: BatteryLevelPredictionBasis if let lowerAnchor, let upperAnchor, upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 { let interpolationProgress = min( max( (effectiveEnergyWh - lowerAnchor.energyWh) / (upperAnchor.energyWh - lowerAnchor.energyWh), 0 ), 1 ) predictedPercent = min( max( lowerAnchor.percent + (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress, 0 ), 100 ) basis = fallbackBasis } else { let chargeCurve = BatteryChargeCurve(typicalCurvePoints: typicalCurve) let curveDeviationFactor = chargeCurve.flatMap { BatteryLevelPredictionTuning.deviationFactor( anchors: sortedAnchors, chargeCurve: $0 ) } let curvePredictedPercent = chargeCurve.flatMap { BatteryLevelPredictionTuning.predictedPercent( anchorPercent: anchor.percent, anchorEnergyWh: anchor.energyWh, effectiveEnergyWh: effectiveEnergyWh, chargeCurve: $0, deviationFactor: curveDeviationFactor ) } if let curvePredictedPercent { predictedPercent = curvePredictedPercent basis = .typicalChargeCurve } else { guard let inferredCapacityWh, inferredCapacityWh > 0 else { return nil } predictedPercent = BatteryLevelPredictionTuning.predictedPercent( anchorPercent: anchor.percent, anchorEnergyWh: anchor.energyWh, anchorTimestamp: anchor.timestamp, anchorIsCheckpoint: anchor.isCheckpoint, effectiveEnergyWh: effectiveEnergyWh, referenceTimestamp: referenceTimestamp ?? session.lastObservedAt, estimatedCapacityWh: inferredCapacityWh ) basis = fallbackBasis } } return BatteryLevelPrediction( predictedPercent: predictedPercent, estimatedCapacityWh: inferredCapacityWh, basis: basis, anchorPercent: anchor.percent, anchorEnergyWh: anchor.energyWh, anchorDescription: anchor.description ) } private func estimatedFlatReserveEnergyWh(excluding excludedSessionID: UUID? = nil) -> Double? { let reserves = sessions.compactMap { session -> Double? in guard session.id != excludedSessionID, session.status == .completed, session.startsFromFlatBattery else { return nil } let anchors = session.checkpoints.map { BatteryLevelPredictionAnchor( percent: $0.batteryPercent, energyWh: $0.measuredEnergyWh, timestamp: $0.timestamp, description: $0.flag.anchorDescription, isCheckpoint: true ) } return BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh( from: anchors, estimatedCapacityWh: session.capacityEstimateWh ) } guard !reserves.isEmpty else { return nil } let sortedReserves = reserves.sorted() return sortedReserves[sortedReserves.count / 2] } func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary { ChargedDeviceSummary( id: id, qrIdentifier: qrIdentifier, name: name, deviceClass: deviceClass, deviceTemplateID: deviceTemplateID, templateDefinition: templateDefinition, profileID: profileID, hasInternalSubject: hasInternalSubject, supportsChargingWhileOff: supportsChargingWhileOff, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, chargerType: chargerType, wirelessChargingProfile: wirelessChargingProfile, configuredCompletionCurrents: configuredCompletionCurrents, learnedCompletionCurrents: learnedCompletionCurrents, wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor, wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps, wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps, chargerObservedVoltageSelections: chargerObservedVoltageSelections, chargerIdleCurrentAmps: chargerIdleCurrentAmps, chargerEfficiencyFactor: chargerEfficiencyFactor, chargerMaximumPowerWatts: chargerMaximumPowerWatts, notes: notes, minimumCurrentAmps: minimumCurrentAmps, estimatedBatteryCapacityWh: estimatedBatteryCapacityWh, wiredMinimumCurrentAmps: wiredMinimumCurrentAmps, wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps, wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh, wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh, createdAt: createdAt, updatedAt: updatedAt, sessions: sessions, capacityHistory: capacityHistory, typicalCurve: typicalCurve, standbyPowerMeasurements: measurements ) } } struct ChargingMonitorSnapshot { let meterMACAddress: String let meterName: String let meterModel: String let observedAt: Date let voltageVolts: Double let currentAmps: Double let powerWatts: Double let selectedDataGroup: UInt8? let meterChargeCounterAh: Double? let meterEnergyCounterWh: Double? let meterRecordingDurationSeconds: TimeInterval? let fallbackStopThresholdAmps: Double } // MARK: - Powerbank enum BatteryLevelReporting: String, CaseIterable, Identifiable, Codable { case percent case bars case fullOnly case none var id: String { rawValue } var title: String { switch self { case .percent: return "Percent" case .bars: return "Bars" case .fullOnly: return "Full only" case .none: return "Not reported" } } var description: String { switch self { case .percent: return "The powerbank reports battery level as 0–100%." case .bars: return "The powerbank reports battery level as discrete bars (e.g. 4 of 4)." case .fullOnly: return "The powerbank has a single LED that lights only when charging completes — there is no signal for any partial level." case .none: return "The powerbank does not report a battery level." } } var allowsCheckpoints: Bool { self != .none } } enum CheckpointSubject: String, Codable, Hashable { case chargedDevice case powerbank var title: String { switch self { case .chargedDevice: return "Device" case .powerbank: return "Powerbank" } } } enum ChargeSessionSource: Hashable { case none case charger(UUID) case powerbank(UUID) var chargerID: UUID? { if case .charger(let id) = self { return id } return nil } var powerbankID: UUID? { if case .powerbank(let id) = self { return id } return nil } var isTracked: Bool { if case .none = self { return false } return true } } struct PowerbankSummary: Identifiable, Hashable { let id: UUID let qrIdentifier: String let name: String let deviceTemplateID: String? let templateDefinition: ChargedDeviceTemplateDefinition? let batteryLevelReporting: BatteryLevelReporting let batteryBarsCount: Int let estimatedBatteryCapacityWh: Double? let apparentCapacityWh: Double? let configuredCompletionCurrentAmps: Double? let learnedCompletionCurrentAmps: Double? let minimumCurrentAmps: Double? let sourceObservedVoltageSelections: [Double] let sourceVoltageMaxCurrents: [Double: Double] let sourceIdleCurrentAmps: Double? let sourceMaximumPowerWatts: Double? let sourceEfficiencyFactor: Double? let notes: String? let createdAt: Date let updatedAt: Date let sessionsAsSubject: [ChargeSessionSummary] let sessionsAsSource: [ChargeSessionSummary] var fallbackIdentitySymbolName: String { "battery.100.bolt" } var identityIcon: ChargedDeviceTemplateIcon { templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName) } var identitySymbolName: String { identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName) } var identityTitle: String { templateDefinition?.name ?? "Powerbank" } /// Open session in which this powerbank participates as either subject or source. var openSession: ChargeSessionSummary? { sessionsAsSubject.first(where: \.isOpen) ?? sessionsAsSource.first(where: \.isOpen) } var totalDeliveredEnergyWh: Double { sessionsAsSource.reduce(0) { $0 + $1.measuredEnergyWh } } var totalReceivedEnergyWh: Double { sessionsAsSubject.reduce(0) { $0 + $1.measuredEnergyWh } } }