1 contributor
//
// 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 }
}
}
struct ChargeCheckpointSummary: Identifiable, Hashable {
let id: UUID
let sessionID: UUID
let chargedDeviceID: UUID
let timestamp: Date
let batteryPercent: Double
let measuredEnergyWh: Double
let currentAmps: Double
let voltageVolts: Double?
let label: String?
var flag: ChargeCheckpointFlag {
ChargeCheckpointFlag.fromStoredLabel(label)
}
}
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 sampleCount: Int
var id: String {
"\(sessionID.uuidString)-\(bucketIndex)"
}
}
struct ChargeSessionSummary: Identifiable, Hashable {
let id: UUID
let chargedDeviceID: UUID
let chargerID: 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<Date> {
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
)
}
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 canAutoStop: Bool {
autoStopEnabled && stopThresholdAmps > 0
}
var isPaused: Bool {
status == .paused
}
var isOpen: Bool {
status.isOpen
}
}
struct BatteryLevelPrediction: Hashable {
let predictedPercent: Double
let estimatedCapacityWh: Double
let anchorPercent: Double
let anchorEnergyWh: Double
let anchorDescription: String
}
enum BatteryLevelPredictionTuning {
static let checkpointSettleDuration: TimeInterval = 10 * 60
static func predictedPercent(
anchorPercent: Double,
anchorEnergyWh: Double,
anchorTimestamp: Date,
anchorIsCheckpoint: Bool,
effectiveEnergyWh: Double,
referenceTimestamp: Date,
estimatedCapacityWh: Double
) -> Double {
let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
let stabilizedGainPercent: Double
if anchorIsCheckpoint {
let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
stabilizedGainPercent = rawGainPercent * settleProgress
} else {
stabilizedGainPercent = rawGainPercent
}
return min(
100,
max(
0,
anchorPercent + stabilizedGainPercent
)
)
}
}
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 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..<min(inputIndex + factor, bins.count)])
let mergedCount = group.reduce(0) { $0 + $1.count }
let relFreq = totalCount > 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 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 lastAssociatedMeterMAC: String?
let createdAt: Date
let updatedAt: Date
let sessions: [ChargeSessionSummary]
let capacityHistory: [CapacityTrendPoint]
let typicalCurve: [TypicalChargeCurvePoint]
let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
var isCharger: Bool {
deviceClass == .charger
}
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
) -> BatteryLevelPrediction? {
let estimatedCapacityWh = session.capacityEstimateWh
?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
?? estimatedBatteryCapacityWh
guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
return nil
}
let effectiveEnergyWh = effectiveEnergyWhOverride
?? session.effectiveBatteryEnergyWh
?? session.measuredEnergyWh
struct Anchor {
let percent: Double
let energyWh: Double
let timestamp: Date
let description: String
let isCheckpoint: Bool
}
var anchors: [Anchor] = []
if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
anchors.append(
Anchor(
percent: startBatteryPercent,
energyWh: 0,
timestamp: session.effectiveTrimStart,
description: "session start",
isCheckpoint: false
)
)
}
anchors.append(
contentsOf: session.checkpoints
.sorted { lhs, rhs in
if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
return lhs.measuredEnergyWh < rhs.measuredEnergyWh
}
return lhs.timestamp < rhs.timestamp
}
.filter { checkpoint in
checkpoint.batteryPercent >= 0
}
.map { checkpoint in
return Anchor(
percent: checkpoint.batteryPercent,
energyWh: checkpoint.measuredEnergyWh,
timestamp: checkpoint.timestamp,
description: checkpoint.flag.anchorDescription,
isCheckpoint: true
)
}
)
guard !anchors.isEmpty else {
return nil
}
let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
let anchor = eligibleAnchors.last ?? anchors.first!
let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
anchorPercent: anchor.percent,
anchorEnergyWh: anchor.energyWh,
anchorTimestamp: anchor.timestamp,
anchorIsCheckpoint: anchor.isCheckpoint,
effectiveEnergyWh: effectiveEnergyWh,
referenceTimestamp: session.lastObservedAt,
estimatedCapacityWh: estimatedCapacityWh
)
return BatteryLevelPrediction(
predictedPercent: predictedPercent,
estimatedCapacityWh: estimatedCapacityWh,
anchorPercent: anchor.percent,
anchorEnergyWh: anchor.energyWh,
anchorDescription: anchor.description
)
}
func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
ChargedDeviceSummary(
id: id,
qrIdentifier: qrIdentifier,
name: name,
deviceClass: deviceClass,
deviceTemplateID: deviceTemplateID,
templateDefinition: templateDefinition,
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,
lastAssociatedMeterMAC: lastAssociatedMeterMAC,
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
}