1 contributor
//
// ChargeInsightsModel.swift
// USB Meter
//
// Created by Codex on 10/04/2026.
//
import Foundation
enum ChargedDeviceKind: String, Identifiable {
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 {
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"
}
}
}
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 {
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."
}
}
}
struct ChargeCheckpointSummary: Identifiable, Hashable {
let id: UUID
let sessionID: UUID
let chargedDeviceID: UUID
let timestamp: Date
let batteryPercent: Double
let measuredEnergyWh: Double
let measuredChargeAh: Double
let currentAmps: Double
let voltageVolts: Double?
let label: String?
}
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 measuredChargeAh: 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 measuredChargeAh: Double
let meterEnergyBaselineWh: Double?
let meterChargeBaselineAh: Double?
let meterDurationBaselineSeconds: Double?
let meterLastDurationSeconds: Double?
let minimumObservedCurrentAmps: Double?
let maximumObservedCurrentAmps: Double?
let maximumObservedPowerWatts: Double?
let maximumObservedVoltageVolts: Double?
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 checkpoints: [ChargeCheckpointSummary]
let aggregatedSamples: [ChargeSessionSampleSummary]
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 {
meterObservedDuration ?? duration
}
var effectiveOrMeasuredEnergyWh: Double {
effectiveBatteryEnergyWh ?? measuredEnergyWh
}
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 averageChargeAh: Double
let sampleCount: Int
var id: Int { percentBin }
}
struct ChargedDeviceSummary: Identifiable, Hashable {
let id: UUID
let qrIdentifier: String
let name: String
let deviceClass: ChargedDeviceClass
let supportsChargingWhileOff: Bool
let chargingStateAvailability: ChargingStateAvailability
let supportsWiredCharging: Bool
let supportsWirelessCharging: Bool
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]
var isCharger: Bool {
deviceClass == .charger
}
var kind: ChargedDeviceKind {
deviceClass.kind
}
var identityTitle: String {
isCharger ? kind.title : deviceClass.title
}
var identitySymbolName: String {
isCharger ? kind.symbolName : deviceClass.symbolName
}
var activeSession: ChargeSessionSummary? {
sessions.first(where: \.isOpen)
}
var recentCompletedSessions: [ChargeSessionSummary] {
sessions.filter { $0.status == .completed }
}
var sessionCount: Int {
sessions.count
}
var supportedChargingModes: [ChargingTransportMode] {
var modes: [ChargingTransportMode] = []
if supportsWiredCharging {
modes.append(.wired)
}
if supportsWirelessCharging {
modes.append(.wireless)
}
return modes
}
var supportedChargingStateModes: [ChargingStateMode] {
chargingStateAvailability.supportedModes
}
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 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.startedAt,
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
let trimmedLabel = checkpoint.label?.trimmingCharacters(in: .whitespacesAndNewlines)
return Anchor(
percent: checkpoint.batteryPercent,
energyWh: checkpoint.measuredEnergyWh,
timestamp: checkpoint.timestamp,
description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint",
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
)
}
}
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
}