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