// // 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 { 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() } 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() } 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) lastEnergyCounterValue = nil lastEnergyGroupID = nil accumulatedEnergyValue = 0 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) lastEnergyCounterValue = nil lastEnergyGroupID = nil accumulatedEnergyValue = 0 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) } lastEnergyCounterValue = nil lastEnergyGroupID = nil accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0 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) } lastEnergyCounterValue = nil lastEnergyGroupID = nil accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0 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 } } 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) } }