// // 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 { 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() 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 temperatureSum: Double = 0 private var rssiSum: Double = 0 private func resetPendingAggregation() { pendingBucketSecond = nil pendingBucketTimestamp = nil itemsInSum = 0 powerSum = 0 voltageSum = 0 currentSum = 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) self.temperature.addPoint(timestamp: pendingBucketTimestamp, value: temperatureSum / itemsInSum) 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 } 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) { 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) { 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 temperatureSum += temperature rssiSum += rssi return } flushPendingValues() pendingBucketSecond = valuesTimestamp pendingBucketTimestamp = timestamp itemsInSum = 1 powerSum = power voltageSum = voltage currentSum = current temperatureSum = temperature 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 ) } }