// // 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) -> [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..= 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 struct Anchor { let percent: Double let energyWh: Double let timestamp: Date let isCheckpoint: Bool } func anchorCapacityCandidates(from anchors: [Anchor]) -> [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: [Anchor]) -> Double? { let candidates = anchorCapacityCandidates(from: anchors) guard !candidates.isEmpty else { return nil } let sortedCandidates = candidates.sorted() return sortedCandidates[sortedCandidates.count / 2] } var anchors: [Anchor] = [] if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 { anchors.append( Anchor( percent: startBatteryPercent, energyWh: 0, timestamp: session.effectiveTrimStart, 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 { Anchor( percent: $0.batteryPercent, energyWh: $0.measuredEnergyWh, timestamp: $0.timestamp, isCheckpoint: true ) } ) 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 effectiveEnergyWh = effectiveBatteryEnergyWh(for: sample, in: session) 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) { 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) { 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 } } }