USB-Meter / USB Meter / Model / Measurements.swift
1 contributor
816 lines | 28.836kb
//
//  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()

    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

        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 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 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)
        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 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()
        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)
        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)
        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) }
        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) }
        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)
        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
        }
    }
}