1 contributor
//
// Measurements.swift
// USB Meter
//
// Created by Bogdan Timofte on 07/05/2020.
// Copyright © 2020 Bogdan Timofte. All rights reserved.
//
import Foundation
import CoreGraphics
class Measurements : ObservableObject {
private static let restoredSampleDiscontinuityThreshold: TimeInterval = 90
struct EnergyProjectionSnapshot {
let accumulatedEnergy: Double
let observedDuration: TimeInterval
let sampleCount: Int
let averagePower: Double?
var projectedDailyEnergy: Double? {
projectedEnergy(forHours: 24)
}
var projectedMonthlyEnergy: Double? {
projectedEnergy(forHours: 24 * 30)
}
var projectedYearlyEnergy: Double? {
projectedEnergy(forHours: 24 * 365)
}
private func projectedEnergy(forHours hours: Double) -> Double? {
guard let averagePower, averagePower.isFinite else { return nil }
return averagePower * hours
}
}
struct EnergyProjectionVariant: Identifiable {
let id: String
let title: String
let observedDuration: TimeInterval
let accumulatedEnergy: Double
let sampleCount: Int
let averagePower: Double
var projectedMonthlyEnergy: Double {
averagePower * 24 * 30
}
var projectedYearlyEnergy: Double {
averagePower * 24 * 365
}
}
class Measurement : ObservableObject {
struct Point : Identifiable , Hashable {
enum Kind: Hashable {
case sample
case discontinuity
}
var id : Int
var timestamp: Date
var value: Double
var kind: Kind = .sample
var isSample: Bool {
kind == .sample
}
var isDiscontinuity: Bool {
kind == .discontinuity
}
func point() -> CGPoint {
return CGPoint(x: timestamp.timeIntervalSince1970, y: value)
}
}
var points: [Point] = []
var context = ChartContext()
var samplePoints: [Point] {
points.filter { $0.isSample }
}
func points(in range: ClosedRange<Date>) -> [Point] {
guard !points.isEmpty else { return [] }
let startIndex = indexOfFirstPoint(onOrAfter: range.lowerBound)
let endIndex = indexOfFirstPoint(after: range.upperBound)
guard startIndex < endIndex else { return [] }
return Array(points[startIndex..<endIndex])
}
private func rebuildContext() {
context.reset()
for point in points where point.isSample {
context.include(point: point.point())
}
}
private func appendPoint(timestamp: Date, value: Double, kind: Point.Kind) {
let newPoint = Measurement.Point(id: points.count, timestamp: timestamp, value: value, kind: kind)
points.append(newPoint)
if newPoint.isSample {
context.include(point: newPoint.point())
}
self.objectWillChange.send()
}
func removeValue(index: Int) {
guard points.indices.contains(index) else { return }
points.remove(at: index)
for index in points.indices {
points[index].id = index
}
rebuildContext()
self.objectWillChange.send()
}
func addPoint(timestamp: Date, value: Double) {
appendPoint(timestamp: timestamp, value: value, kind: .sample)
}
func addDiscontinuity(timestamp: Date) {
guard !points.isEmpty else { return }
guard points.last?.isDiscontinuity == false else { return }
appendPoint(timestamp: timestamp, value: points.last?.value ?? 0, kind: .discontinuity)
}
func resetSeries() {
points.removeAll()
context.reset()
self.objectWillChange.send()
}
func replacePoints(_ points: [Point]) {
self.points = points.enumerated().map { index, point in
Point(
id: index,
timestamp: point.timestamp,
value: point.value,
kind: point.kind
)
}
rebuildContext()
self.objectWillChange.send()
}
func trim(before cutoff: Date) {
points = points
.filter { $0.timestamp >= cutoff }
.enumerated()
.map { index, point in
Measurement.Point(id: index, timestamp: point.timestamp, value: point.value, kind: point.kind)
}
rebuildContext()
self.objectWillChange.send()
}
func filterSamples(keeping shouldKeepSampleAt: (Date) -> Bool) {
let originalSamples = samplePoints
guard !originalSamples.isEmpty else { return }
var rebuiltPoints: [Point] = []
var lastKeptSampleIndex: Int?
for (sampleIndex, sample) in originalSamples.enumerated() where shouldKeepSampleAt(sample.timestamp) {
if let lastKeptSampleIndex {
let hasRemovedSamplesBetween = sampleIndex - lastKeptSampleIndex > 1
let previousSample = originalSamples[lastKeptSampleIndex]
let originalHadDiscontinuityBetween = points.contains { point in
point.isDiscontinuity &&
point.timestamp > previousSample.timestamp &&
point.timestamp <= sample.timestamp
}
if hasRemovedSamplesBetween || originalHadDiscontinuityBetween {
rebuiltPoints.append(
Point(
id: rebuiltPoints.count,
timestamp: sample.timestamp,
value: rebuiltPoints.last?.value ?? sample.value,
kind: .discontinuity
)
)
}
}
rebuiltPoints.append(
Point(
id: rebuiltPoints.count,
timestamp: sample.timestamp,
value: sample.value,
kind: .sample
)
)
lastKeptSampleIndex = sampleIndex
}
points = rebuiltPoints
rebuildContext()
self.objectWillChange.send()
}
func alignCounterToStartAtZero() {
guard let firstSampleIndex = points.firstIndex(where: \.isSample) else {
if !points.isEmpty {
resetSeries()
}
return
}
let baselineValue = points[firstSampleIndex].value
points = points[firstSampleIndex...]
.enumerated()
.map { index, point in
Point(
id: index,
timestamp: point.timestamp,
value: point.value - baselineValue,
kind: point.kind
)
}
rebuildContext()
self.objectWillChange.send()
}
private func indexOfFirstPoint(onOrAfter date: Date) -> Int {
var lowerBound = 0
var upperBound = points.count
while lowerBound < upperBound {
let midIndex = (lowerBound + upperBound) / 2
if points[midIndex].timestamp < date {
lowerBound = midIndex + 1
} else {
upperBound = midIndex
}
}
return lowerBound
}
private func indexOfFirstPoint(after date: Date) -> Int {
var lowerBound = 0
var upperBound = points.count
while lowerBound < upperBound {
let midIndex = (lowerBound + upperBound) / 2
if points[midIndex].timestamp <= date {
lowerBound = midIndex + 1
} else {
upperBound = midIndex
}
}
return lowerBound
}
}
@Published var power = Measurement()
@Published var voltage = Measurement()
@Published var current = Measurement()
@Published var temperature = Measurement()
@Published var energy = Measurement()
@Published var rssi = Measurement()
@Published var batteryPercent = Measurement()
let averagePowerSampleOptions: [Int] = [5, 10, 20, 50, 100, 250]
private var pendingBucketSecond: Int?
private var pendingBucketTimestamp: Date?
private let energyResetEpsilon = 0.0005
private var lastEnergyCounterValue: Double?
private var lastEnergyGroupID: UInt8?
private var accumulatedEnergyValue: Double = 0
private var itemsInSum: Double = 0
private var powerSum: Double = 0
private var voltageSum: Double = 0
private var currentSum: Double = 0
private var temperatureItemsInSum: Double = 0
private var temperatureSum: Double = 0
private var rssiSum: Double = 0
private func resetPendingAggregation() {
pendingBucketSecond = nil
pendingBucketTimestamp = nil
itemsInSum = 0
powerSum = 0
voltageSum = 0
currentSum = 0
temperatureItemsInSum = 0
temperatureSum = 0
rssiSum = 0
}
private func flushPendingValues() {
guard let pendingBucketTimestamp, itemsInSum > 0 else { return }
self.power.addPoint(timestamp: pendingBucketTimestamp, value: powerSum / itemsInSum)
self.voltage.addPoint(timestamp: pendingBucketTimestamp, value: voltageSum / itemsInSum)
self.current.addPoint(timestamp: pendingBucketTimestamp, value: currentSum / itemsInSum)
if temperatureItemsInSum > 0 {
self.temperature.addPoint(timestamp: pendingBucketTimestamp, value: temperatureSum / temperatureItemsInSum)
}
self.rssi.addPoint(timestamp: pendingBucketTimestamp, value: rssiSum / itemsInSum)
resetPendingAggregation()
self.objectWillChange.send()
}
private func realignEnergyBufferStart() {
energy.alignCounterToStartAtZero()
lastEnergyCounterValue = nil
lastEnergyGroupID = nil
accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
}
@discardableResult
func restorePersistedChargeSessionSamplesIfNeeded(
from session: ChargeSessionSummary,
replacingLiveBufferIfNeeded: Bool = false
) -> Bool {
let hasExistingBuffer =
power.points.isEmpty == false ||
voltage.points.isEmpty == false ||
current.points.isEmpty == false ||
temperature.points.isEmpty == false ||
energy.points.isEmpty == false ||
rssi.points.isEmpty == false ||
batteryPercent.points.isEmpty == false
restoreTrace(
"measurements-restore-start session=\(session.id.uuidString) status=\(session.status.rawValue) persistedSamples=\(session.aggregatedSamples.count) replaceLive=\(replacingLiveBufferIfNeeded) existingBuffer=\(hasExistingBuffer) existingCounts=p:\(power.points.count),v:\(voltage.points.count),c:\(current.points.count),t:\(temperature.points.count),e:\(energy.points.count),r:\(rssi.points.count)"
)
guard hasExistingBuffer == false || replacingLiveBufferIfNeeded else {
restoreTrace("measurements-restore-skip session=\(session.id.uuidString) reason=live-buffer-not-replaced")
return false
}
let sortedSamples = session.aggregatedSamples.sorted { lhs, rhs in
if lhs.bucketIndex != rhs.bucketIndex {
return lhs.bucketIndex < rhs.bucketIndex
}
return lhs.timestamp < rhs.timestamp
}
guard !sortedSamples.isEmpty else {
restoreTrace("measurements-restore-skip session=\(session.id.uuidString) reason=no-persisted-samples")
return false
}
let preservedEnergyCounterValue = lastEnergyCounterValue
let preservedEnergyGroupID = lastEnergyGroupID
let persistedRangeUpperBound = sortedSamples.last?.timestamp
if hasExistingBuffer {
flushPendingValues()
}
resetPendingAggregation()
let restoredPowerPoints = restoredPoints(from: sortedSamples) { sample in
sample.averagePowerWatts
}
let restoredCurrentPoints = restoredPoints(from: sortedSamples) { sample in
sample.averageCurrentAmps
}
let restoredVoltagePoints = restoredPoints(from: sortedSamples) { sample in
sample.averageVoltageVolts
}
let restoredEnergyPoints = restoredPoints(from: sortedSamples) { sample in
sample.measuredEnergyWh
}
let restoredBatteryPercentPoints = restoredPoints(from: sortedSamples) { sample in
sample.estimatedBatteryPercent ?? estimatedBatteryPercent(for: sample, in: session)
}
let mergedPowerPoints = mergedRestoredPoints(
restored: restoredPowerPoints,
existing: power.points,
persistedRangeUpperBound: persistedRangeUpperBound
)
let mergedCurrentPoints = mergedRestoredPoints(
restored: restoredCurrentPoints,
existing: current.points,
persistedRangeUpperBound: persistedRangeUpperBound
)
let mergedVoltagePoints = mergedRestoredPoints(
restored: restoredVoltagePoints,
existing: voltage.points,
persistedRangeUpperBound: persistedRangeUpperBound
)
let mergedEnergyPoints = mergedRestoredPoints(
restored: restoredEnergyPoints,
existing: energy.points,
persistedRangeUpperBound: persistedRangeUpperBound
)
let mergedBatteryPercentPoints = mergedRestoredPoints(
restored: restoredBatteryPercentPoints,
existing: batteryPercent.points,
persistedRangeUpperBound: persistedRangeUpperBound
)
let preservedRssiTail = preservedTailPoints(
from: rssi.points,
after: persistedRangeUpperBound
)
restoreTrace(
"measurements-restore-merge session=\(session.id.uuidString) restored=p:\(restoredPowerPoints.count),v:\(restoredVoltagePoints.count),c:\(restoredCurrentPoints.count),e:\(restoredEnergyPoints.count) discontinuities=p:\(restoredPowerPoints.filter(\.isDiscontinuity).count),v:\(restoredVoltagePoints.filter(\.isDiscontinuity).count),c:\(restoredCurrentPoints.filter(\.isDiscontinuity).count),e:\(restoredEnergyPoints.filter(\.isDiscontinuity).count) merged=p:\(mergedPowerPoints.count),v:\(mergedVoltagePoints.count),c:\(mergedCurrentPoints.count),e:\(mergedEnergyPoints.count) tails=r:\(preservedRssiTail.count) upperBound=\(persistedRangeUpperBound?.description ?? "nil")"
)
power.replacePoints(mergedPowerPoints)
current.replacePoints(mergedCurrentPoints)
voltage.replacePoints(mergedVoltagePoints)
energy.replacePoints(mergedEnergyPoints)
batteryPercent.replacePoints(mergedBatteryPercentPoints)
temperature.resetSeries()
rssi.replacePoints(preservedRssiTail)
lastEnergyCounterValue = hasExistingBuffer ? preservedEnergyCounterValue : nil
lastEnergyGroupID = hasExistingBuffer ? preservedEnergyGroupID : nil
accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
self.objectWillChange.send()
restoreTrace(
"measurements-restore-complete session=\(session.id.uuidString) counts=p:\(power.samplePoints.count),v:\(voltage.samplePoints.count),c:\(current.samplePoints.count),t:\(temperature.samplePoints.count),e:\(energy.samplePoints.count),r:\(rssi.samplePoints.count) accumulatedEnergy=\(accumulatedEnergyValue)"
)
return true
}
private func restoredPoints(
from samples: [ChargeSessionSampleSummary],
value: (ChargeSessionSampleSummary) -> Double?
) -> [Measurement.Point] {
var restored: [Measurement.Point] = []
var previousSample: ChargeSessionSampleSummary?
for sample in samples {
guard let pointValue = value(sample) else { continue }
if let previousSample,
sample.timestamp.timeIntervalSince(previousSample.timestamp) > Self.restoredSampleDiscontinuityThreshold {
restored.append(
Measurement.Point(
id: restored.count,
timestamp: sample.timestamp,
value: restored.last?.value ?? pointValue,
kind: .discontinuity
)
)
}
restored.append(
Measurement.Point(
id: restored.count,
timestamp: sample.timestamp,
value: pointValue,
kind: .sample
)
)
previousSample = sample
}
return restored
}
private func estimatedBatteryPercent(
for sample: ChargeSessionSampleSummary,
in session: ChargeSessionSummary
) -> Double? {
let estimatedCapacityWh = session.capacityEstimateWh
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 }
.sorted { lhs, rhs in
if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
return lhs.measuredEnergyWh < rhs.measuredEnergyWh
}
return lhs.timestamp < rhs.timestamp
}
.map {
BatteryLevelPredictionAnchor(
percent: $0.batteryPercent,
energyWh: $0.measuredEnergyWh,
timestamp: $0.timestamp,
description: $0.flag.anchorDescription,
isCheckpoint: true
)
}
)
let effectiveEnergyWh = effectiveBatteryEnergyWh(for: sample, in: session)
if session.startsFromFlatBattery {
if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
from: anchors,
estimatedCapacityWh: estimatedCapacityWh
) {
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 lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
if let lowerAnchor,
let upperAnchor,
upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
let interpolationProgress = min(
max(
(effectiveEnergyWh - lowerAnchor.energyWh) /
(upperAnchor.energyWh - lowerAnchor.energyWh),
0
),
1
)
return min(
max(
lowerAnchor.percent +
(upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
0
),
100
)
}
let inferredCapacityWh = estimatedCapacityWh
?? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors)
guard let inferredCapacityWh, inferredCapacityWh > 0 else {
return nil
}
return BatteryLevelPredictionTuning.predictedPercent(
anchorPercent: anchor.percent,
anchorEnergyWh: anchor.energyWh,
anchorTimestamp: anchor.timestamp,
anchorIsCheckpoint: anchor.isCheckpoint,
effectiveEnergyWh: effectiveEnergyWh,
referenceTimestamp: sample.timestamp,
estimatedCapacityWh: inferredCapacityWh
)
}
private func effectiveBatteryEnergyWh(
for sample: ChargeSessionSampleSummary,
in session: ChargeSessionSummary
) -> Double {
switch session.chargingTransportMode {
case .wired:
return sample.measuredEnergyWh
case .wireless:
if let factor = session.wirelessEfficiencyFactor, factor > 0 {
return sample.measuredEnergyWh * factor
}
if let sessionEffectiveEnergyWh = session.effectiveBatteryEnergyWh,
session.measuredEnergyWh > 0 {
return sample.measuredEnergyWh * (sessionEffectiveEnergyWh / session.measuredEnergyWh)
}
return sample.measuredEnergyWh
}
}
private func mergedRestoredPoints(
restored: [Measurement.Point],
existing: [Measurement.Point],
persistedRangeUpperBound: Date?
) -> [Measurement.Point] {
var merged = restored
let preservedTail = preservedTailPoints(from: existing, after: persistedRangeUpperBound)
guard preservedTail.isEmpty == false else {
return merged
}
if let tailFirst = preservedTail.first,
tailFirst.isSample,
let lastRestoredSample = merged.last(where: \.isSample),
lastRestoredSample.timestamp < tailFirst.timestamp {
merged.append(
Measurement.Point(
id: merged.count,
timestamp: tailFirst.timestamp,
value: merged.last?.value ?? tailFirst.value,
kind: .discontinuity
)
)
}
merged.append(contentsOf: preservedTail.enumerated().map { offset, point in
Measurement.Point(
id: merged.count + offset,
timestamp: point.timestamp,
value: point.value,
kind: point.kind
)
})
return merged
}
private func preservedTailPoints(
from existing: [Measurement.Point],
after persistedRangeUpperBound: Date?
) -> [Measurement.Point] {
guard let persistedRangeUpperBound else {
return existing
}
let tail = existing.filter { $0.timestamp > persistedRangeUpperBound }
guard tail.isEmpty == false else {
return []
}
if let firstSampleIndex = tail.firstIndex(where: \.isSample) {
return Array(tail[firstSampleIndex...])
}
return []
}
func resetSeries() {
power.resetSeries()
voltage.resetSeries()
current.resetSeries()
temperature.resetSeries()
energy.resetSeries()
rssi.resetSeries()
batteryPercent.resetSeries()
resetPendingAggregation()
lastEnergyCounterValue = nil
lastEnergyGroupID = nil
accumulatedEnergyValue = 0
self.objectWillChange.send()
}
func reset() {
resetSeries()
}
func remove(at idx: Int) {
power.removeValue(index: idx)
voltage.removeValue(index: idx)
current.removeValue(index: idx)
temperature.removeValue(index: idx)
energy.removeValue(index: idx)
rssi.removeValue(index: idx)
batteryPercent.removeValue(index: idx)
realignEnergyBufferStart()
self.objectWillChange.send()
}
func trim(before cutoff: Date) {
flushPendingValues()
power.trim(before: cutoff)
voltage.trim(before: cutoff)
current.trim(before: cutoff)
temperature.trim(before: cutoff)
energy.trim(before: cutoff)
rssi.trim(before: cutoff)
batteryPercent.trim(before: cutoff)
realignEnergyBufferStart()
self.objectWillChange.send()
}
func keepOnly(in range: ClosedRange<Date>) {
flushPendingValues()
power.filterSamples { range.contains($0) }
voltage.filterSamples { range.contains($0) }
current.filterSamples { range.contains($0) }
temperature.filterSamples { range.contains($0) }
energy.filterSamples { range.contains($0) }
rssi.filterSamples { range.contains($0) }
batteryPercent.filterSamples { range.contains($0) }
realignEnergyBufferStart()
self.objectWillChange.send()
}
func removeValues(in range: ClosedRange<Date>) {
flushPendingValues()
power.filterSamples { !range.contains($0) }
voltage.filterSamples { !range.contains($0) }
current.filterSamples { !range.contains($0) }
temperature.filterSamples { !range.contains($0) }
energy.filterSamples { !range.contains($0) }
rssi.filterSamples { !range.contains($0) }
batteryPercent.filterSamples { !range.contains($0) }
realignEnergyBufferStart()
self.objectWillChange.send()
}
func addValues(timestamp: Date, power: Double, voltage: Double, current: Double, temperature: Double?, rssi: Double) {
let valuesTimestamp = timestamp.timeIntervalSinceReferenceDate.intValue
if pendingBucketSecond == valuesTimestamp {
pendingBucketTimestamp = timestamp
itemsInSum += 1
powerSum += power
voltageSum += voltage
currentSum += current
if let temperature {
temperatureItemsInSum += 1
temperatureSum += temperature
}
rssiSum += rssi
return
}
flushPendingValues()
pendingBucketSecond = valuesTimestamp
pendingBucketTimestamp = timestamp
itemsInSum = 1
powerSum = power
voltageSum = voltage
currentSum = current
if let temperature {
temperatureItemsInSum = 1
temperatureSum = temperature
} else {
temperatureItemsInSum = 0
temperatureSum = 0
}
rssiSum = rssi
}
func markDiscontinuity(at timestamp: Date) {
flushPendingValues()
power.addDiscontinuity(timestamp: timestamp)
voltage.addDiscontinuity(timestamp: timestamp)
current.addDiscontinuity(timestamp: timestamp)
temperature.addDiscontinuity(timestamp: timestamp)
energy.addDiscontinuity(timestamp: timestamp)
rssi.addDiscontinuity(timestamp: timestamp)
batteryPercent.addDiscontinuity(timestamp: timestamp)
self.objectWillChange.send()
}
func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
let delta = value - lastEnergyCounterValue
if delta > energyResetEpsilon {
accumulatedEnergyValue += delta
} else if delta < -energyResetEpsilon {
energy.addDiscontinuity(timestamp: timestamp)
accumulatedEnergyValue = 0
}
}
energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue)
lastEnergyCounterValue = value
lastEnergyGroupID = groupID
self.objectWillChange.send()
}
func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
if shouldFlushPendingValues {
flushPendingValues()
}
return power.samplePoints.count
}
func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
if shouldFlushPendingValues {
flushPendingValues()
}
let samplePoints = power.samplePoints
guard limit > 0, samplePoints.count > limit else {
return samplePoints
}
return Array(samplePoints.suffix(limit))
}
func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues)
guard !points.isEmpty else { return nil }
let sum = points.reduce(0) { partialResult, point in
partialResult + point.value
}
return sum / Double(points.count)
}
func energyProjectionSnapshot(flushPendingValues shouldFlushPendingValues: Bool = true) -> EnergyProjectionSnapshot? {
if shouldFlushPendingValues {
flushPendingValues()
}
let samplePoints = energy.samplePoints
guard !samplePoints.isEmpty else { return nil }
let accumulatedEnergy = samplePoints.last?.value ?? 0
var observedDuration: TimeInterval = 0
var previousSample: Measurement.Point?
for point in energy.points {
if point.isDiscontinuity {
previousSample = nil
continue
}
if let previousSample {
observedDuration += max(0, point.timestamp.timeIntervalSince(previousSample.timestamp))
}
previousSample = point
}
let averagePower: Double?
if observedDuration > 0, accumulatedEnergy.isFinite {
averagePower = accumulatedEnergy / (observedDuration / 3600)
} else {
averagePower = nil
}
return EnergyProjectionSnapshot(
accumulatedEnergy: accumulatedEnergy,
observedDuration: observedDuration,
sampleCount: samplePoints.count,
averagePower: averagePower
)
}
func energyProjectionVariants(flushPendingValues shouldFlushPendingValues: Bool = true) -> [EnergyProjectionVariant] {
if shouldFlushPendingValues {
flushPendingValues()
}
let contiguousSamples = latestContiguousEnergySamples()
guard contiguousSamples.count >= 2 else { return [] }
let latestTimestamp = contiguousSamples.last?.timestamp ?? Date()
let windowCandidates: [(duration: TimeInterval, title: String, id: String)] = [
(60, "Last 1 Minute", "last-1m"),
(5 * 60, "Last 5 Minutes", "last-5m"),
(15 * 60, "Last 15 Minutes", "last-15m"),
(60 * 60, "Last 1 Hour", "last-1h"),
(6 * 60 * 60, "Last 6 Hours", "last-6h")
]
var variants: [EnergyProjectionVariant] = []
for candidate in windowCandidates {
let cutoff = latestTimestamp.addingTimeInterval(-candidate.duration)
guard
let startIndex = contiguousSamples.lastIndex(where: { $0.timestamp <= cutoff }),
startIndex < contiguousSamples.count - 1
else {
continue
}
let relevantSamples = Array(contiguousSamples[startIndex...])
if let variant = projectionVariant(
id: candidate.id,
title: candidate.title,
samples: relevantSamples
) {
variants.append(variant)
}
}
if let fullBufferVariant = projectionVariant(
id: "full-buffer",
title: "Whole Buffer",
samples: contiguousSamples
) {
variants.append(fullBufferVariant)
}
return variants
}
private func latestContiguousEnergySamples() -> [Measurement.Point] {
let latestSegment = energy.points.split(whereSeparator: \.isDiscontinuity).last ?? []
return latestSegment.filter(\.isSample)
}
private func projectionVariant(
id: String,
title: String,
samples: [Measurement.Point]
) -> EnergyProjectionVariant? {
guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
let observedDuration = lastSample.timestamp.timeIntervalSince(firstSample.timestamp)
guard observedDuration > 0 else { return nil }
let accumulatedEnergy = lastSample.value - firstSample.value
guard accumulatedEnergy >= 0, accumulatedEnergy.isFinite else { return nil }
let averagePower = accumulatedEnergy / (observedDuration / 3600)
guard averagePower.isFinite else { return nil }
return EnergyProjectionVariant(
id: id,
title: title,
observedDuration: observedDuration,
accumulatedEnergy: accumulatedEnergy,
sampleCount: samples.count,
averagePower: averagePower
)
}
}
extension Measurements.Measurement.Point: TimeSeriesChartPointRepresentable {
var chartPointID: Int {
id
}
var chartTimestamp: Date {
timestamp
}
var chartValue: Double {
value
}
var chartPointKind: TimeSeriesChartPointKind {
switch kind {
case .sample:
return .sample
case .discontinuity:
return .discontinuity
}
}
}