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 }
}
}
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<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
)
}
/// 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..<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)
}
}
// MARK: - Consumption Monitor
struct ConsumptionMonitorSample: Identifiable, Codable, Hashable {
var id: Int { bucketIndex }
let bucketIndex: Int
let timestamp: Date
let averagePowerWatts: Double
let averageCurrentAmps: Double
let averageVoltageVolts: Double
let sampleCount: Int
let cumulativeEnergyWh: Double
}
struct ConsumptionMonitorSessionSummary: Identifiable, Codable, Hashable {
let id: UUID
let chargedDeviceID: UUID
let meterMACAddress: String
let meterName: String?
let meterModel: String?
let startedAt: Date
var endedAt: Date?
var samples: [ConsumptionMonitorSample]
var isOpen: Bool { endedAt == nil }
var duration: TimeInterval { (endedAt ?? Date()).timeIntervalSince(startedAt) }
var totalEnergyWh: Double { samples.last?.cumulativeEnergyWh ?? 0 }
var sampleCount: Int { samples.count }
var averagePowerWatts: Double {
guard !samples.isEmpty else { return 0 }
return samples.map(\.averagePowerWatts).reduce(0, +) / Double(samples.count)
}
var minimumPowerWatts: Double { samples.map(\.averagePowerWatts).min() ?? 0 }
var maximumPowerWatts: Double { samples.map(\.averagePowerWatts).max() ?? 0 }
var averageCurrentAmps: Double {
guard !samples.isEmpty else { return 0 }
return samples.map(\.averageCurrentAmps).reduce(0, +) / Double(samples.count)
}
var averageVoltageVolts: Double {
guard !samples.isEmpty else { return 0 }
return samples.map(\.averageVoltageVolts).reduce(0, +) / Double(samples.count)
}
var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
}
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]
let consumptionSessions: [ConsumptionMonitorSessionSummary]
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,
consumptionSessions: consumptionSessions
)
}
func withConsumptionSessions(_ sessions: [ConsumptionMonitorSessionSummary]) -> 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: self.sessions,
capacityHistory: capacityHistory,
typicalCurve: typicalCurve,
standbyPowerMeasurements: standbyPowerMeasurements,
consumptionSessions: sessions
)
}
}
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 }
}
}