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 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 {
let index: Int
let lowerBoundWatts: Double
let upperBoundWatts: Double
let count: Int
let relativeFrequency: Double
var id: Int { index }
}
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
let powerSamplesWatts: [Double]
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
}
var histogram: [ChargerStandbyPowerDistributionBin] {
ChargerStandbyPowerMeasurementAnalyzer.histogram(for: powerSamplesWatts)
}
}
enum ChargerStandbyPowerMeasurementAnalyzer {
static let minimumStableSampleCount = 45
static let recentSampleWindow = 20
static let minimumStabilityToleranceWatts = 0.003
static let relativeStabilityTolerance = 0.01
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
)
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: histogram(for: powerValues)
)
}
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,
powerSamplesWatts: samples.map(\.powerWatts)
)
}
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 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]
let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
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 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
}
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
)
}
func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
ChargedDeviceSummary(
id: id,
qrIdentifier: qrIdentifier,
name: name,
deviceClass: deviceClass,
supportsChargingWhileOff: supportsChargingWhileOff,
chargingStateAvailability: chargingStateAvailability,
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging,
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
}