Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistory.swift
T
ScarriffleandClaude Opus 5 f289d02c37 Der Akku bekommt ein Gedächtnis
Verlauf über 24 Stunden, sieben Tage und dreißig Tage, aufgezeichnet alle fünf
Minuten. Als Säulen und nicht als Linie: eine Linie verbindet auch dort, wo
nichts gemessen wurde, und ein zugeklappter Rechner hatte keinen Ladestand —
eine Linie darüber behauptet einen.

Dazu Segelmodus und Hitzeschutz als reine Regeln im Helfer. Beide haben zwei
Schwellen, eine zum Anhalten und eine zum Weitermachen. Ohne diesen Abstand
hinge der Akku am Limit und würde bei jedem verlorenen Prozent nachgeladen —
hunderte winzige Ladevorgänge am Tag, und genau die altern ihn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:06:06 +02:00

187 lines
7.0 KiB
Swift

import Foundation
import Observation
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "BatteryHistory")
/// Ein Messpunkt des Akkus.
///
/// Vier Zahlen, mehr nicht. Was sich nicht zeichnen lässt, gehört nicht in
/// einen Verlauf — Zyklen und Gesundheit ändern sich in Monaten und stehen
/// besser als aktueller Wert da.
public struct BatterySample: Equatable, Sendable, Codable {
public let at: Date
/// 0 bis 1.
public let charge: Double
/// Zellentemperatur, falls der SMC sie hergibt.
public let celsius: Double?
/// Negativ heißt Entladung.
public let watts: Double?
public init(at: Date, charge: Double, celsius: Double?, watts: Double?) {
self.at = at
self.charge = charge
self.celsius = celsius
self.watts = watts
}
}
/// Über welchen Zeitraum der Verlauf gezeigt wird.
public enum HistoryWindow: String, CaseIterable, Sendable {
case day, week, month
public var duration: TimeInterval {
switch self {
case .day: 86_400
case .week: 7 * 86_400
case .month: 30 * 86_400
}
}
/// Wie viele Säulen die Anzeige bekommt.
///
/// Nicht ein Punkt je Messung: über einen Monat wären das über achttausend,
/// und mehr Punkte als Bildpunkte zu zeichnen kostet Zeit, ohne etwas zu
/// zeigen.
public var buckets: Int {
switch self {
case .day: 48 // halbstündlich
case .week: 56 // dreistündlich
case .month: 60 // halbtägig
}
}
}
/// Die Regeln des Verlaufs — ohne Uhr, ohne Dateisystem.
public enum BatteryHistoryRules {
/// Wie oft gemessen wird.
///
/// Fünf Minuten reichen: der Ladestand eines Akkus ändert sich nicht
/// sprunghaft, und ein Punkt je Minute wäre die zwölffache Datei für
/// dieselbe Kurve.
public static let interval: TimeInterval = 300
/// Wie lange Messpunkte aufgehoben werden.
///
/// Genau so lang wie das längste Fenster. Alles darüber hinaus wäre eine
/// Datei, die wächst, ohne dass es je jemand ansieht.
public static let maximumAge: TimeInterval = HistoryWindow.month.duration
/// Ob jetzt ein neuer Messpunkt fällig ist.
public static func shouldRecord(last: Date?, now: Date,
interval: TimeInterval = interval) -> Bool {
guard let last else { return true }
return now.timeIntervalSince(last) >= interval
}
/// Was zu alt geworden ist, fällt heraus.
public static func pruned(_ samples: [BatterySample], now: Date,
maximumAge: TimeInterval = maximumAge) -> [BatterySample] {
let cutoff = now.addingTimeInterval(-maximumAge)
return samples.filter { $0.at >= cutoff }
}
/// Fasst die Messpunkte eines Fensters zu Säulen zusammen.
///
/// Der Mittelwert je Zeitabschnitt, nicht der erste oder letzte Wert: ein
/// einzelner Ausreißer soll die Säule nicht bestimmen. Abschnitte ohne
/// Messung bleiben `nil` — ein zugeklappter Rechner hat dort keinen
/// Ladestand gehabt, und eine durchgezogene Linie darüber wäre erfunden.
public static func buckets(_ samples: [BatterySample],
window: HistoryWindow,
now: Date) -> [Double?] {
let start = now.addingTimeInterval(-window.duration)
let width = window.duration / Double(window.buckets)
var sums = [Double](repeating: 0, count: window.buckets)
var counts = [Int](repeating: 0, count: window.buckets)
for sample in samples where sample.at >= start && sample.at <= now {
let offset = sample.at.timeIntervalSince(start)
let index = min(Int(offset / width), window.buckets - 1)
sums[index] += sample.charge
counts[index] += 1
}
return (0..<window.buckets).map { counts[$0] == 0 ? nil : sums[$0] / Double(counts[$0]) }
}
/// Höchst-, Tiefst- und Mittelwert eines Fensters. `nil`, wenn nichts vorliegt.
public static func summary(_ samples: [BatterySample], window: HistoryWindow,
now: Date) -> (low: Double, high: Double, average: Double)? {
let start = now.addingTimeInterval(-window.duration)
let inWindow = samples.filter { $0.at >= start && $0.at <= now }
guard !inWindow.isEmpty else { return nil }
let charges = inWindow.map(\.charge)
return (charges.min()!, charges.max()!,
charges.reduce(0, +) / Double(charges.count))
}
}
/// Der Verlauf, aufgezeichnet und abgelegt.
@MainActor
@Observable
public final class BatteryHistoryStore {
public private(set) var samples: [BatterySample] = []
@ObservationIgnored private let fileURL: URL
@ObservationIgnored private var lastRecorded: Date?
private struct Archive: Codable {
var version = 1
var samples: [BatterySample]
}
public init(directory: URL? = nil) {
let folder = directory ?? Self.defaultDirectory
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
fileURL = folder.appendingPathComponent("battery.json")
load()
lastRecorded = samples.last?.at
}
public static var defaultDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Onyx/Metrics", isDirectory: true)
}
/// Nimmt eine Messung entgegen — und legt sie nur ab, wenn sie fällig ist.
public func record(_ reading: BatteryReading, now: Date = Date()) {
guard BatteryHistoryRules.shouldRecord(last: lastRecorded, now: now) else { return }
lastRecorded = now
samples.append(BatterySample(at: now, charge: reading.charge,
celsius: reading.celsius, watts: reading.watts))
samples = BatteryHistoryRules.pruned(samples, now: now)
save()
}
public func buckets(_ window: HistoryWindow, now: Date = Date()) -> [Double?] {
BatteryHistoryRules.buckets(samples, window: window, now: now)
}
public func summary(_ window: HistoryWindow,
now: Date = Date()) -> (low: Double, high: Double, average: Double)? {
BatteryHistoryRules.summary(samples, window: window, now: now)
}
private func load() {
guard let data = try? Data(contentsOf: fileURL) else { return }
guard let archive = try? JSONDecoder().decode(Archive.self, from: data),
archive.version == 1 else {
log.notice("Unbekannte Fassung des Akkuverlaufs — er beginnt neu")
return
}
samples = archive.samples.sorted { $0.at < $1.at }
}
private func save() {
do {
try JSONEncoder().encode(Archive(samples: samples)).write(to: fileURL, options: .atomic)
} catch {
log.error("Verlauf nicht schreibbar: \(error.localizedDescription, privacy: .public)")
}
}
}