USB-Meter / USB Meter / Model / Measurements.swift
Newer Older
567 lines | 19.266kb
Bogdan Timofte authored 2 months ago
1
//
2
//  Measurements.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 07/05/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
import Foundation
10
import CoreGraphics
11

            
12
class Measurements : ObservableObject {
Bogdan Timofte authored a month ago
13
    struct EnergyProjectionSnapshot {
14
        let accumulatedEnergy: Double
15
        let observedDuration: TimeInterval
16
        let sampleCount: Int
17
        let averagePower: Double?
18

            
19
        var projectedDailyEnergy: Double? {
20
            projectedEnergy(forHours: 24)
21
        }
22

            
23
        var projectedMonthlyEnergy: Double? {
24
            projectedEnergy(forHours: 24 * 30)
25
        }
26

            
27
        var projectedYearlyEnergy: Double? {
28
            projectedEnergy(forHours: 24 * 365)
29
        }
30

            
31
        private func projectedEnergy(forHours hours: Double) -> Double? {
32
            guard let averagePower, averagePower.isFinite else { return nil }
33
            return averagePower * hours
34
        }
35
    }
36

            
37
    struct EnergyProjectionVariant: Identifiable {
38
        let id: String
39
        let title: String
40
        let observedDuration: TimeInterval
41
        let accumulatedEnergy: Double
42
        let sampleCount: Int
43
        let averagePower: Double
44

            
45
        var projectedMonthlyEnergy: Double {
46
            averagePower * 24 * 30
47
        }
48

            
49
        var projectedYearlyEnergy: Double {
50
            averagePower * 24 * 365
51
        }
52
    }
Bogdan Timofte authored 2 months ago
53

            
54
    class Measurement : ObservableObject {
55
        struct Point : Identifiable , Hashable {
Bogdan Timofte authored 2 months ago
56
            enum Kind: Hashable {
57
                case sample
58
                case discontinuity
59
            }
60

            
Bogdan Timofte authored 2 months ago
61
            var id : Int
62
            var timestamp: Date
63
            var value: Double
Bogdan Timofte authored 2 months ago
64
            var kind: Kind = .sample
65

            
66
            var isSample: Bool {
67
                kind == .sample
68
            }
69

            
70
            var isDiscontinuity: Bool {
71
                kind == .discontinuity
72
            }
73

            
Bogdan Timofte authored 2 months ago
74
            func point() -> CGPoint {
75
                return CGPoint(x: timestamp.timeIntervalSince1970, y: value)
76
            }
77
        }
78

            
79
        var points: [Point] = []
80
        var context = ChartContext()
81

            
Bogdan Timofte authored 2 months ago
82
        var samplePoints: [Point] {
83
            points.filter { $0.isSample }
84
        }
85

            
Bogdan Timofte authored 2 months ago
86
        func points(in range: ClosedRange<Date>) -> [Point] {
87
            guard !points.isEmpty else { return [] }
88

            
89
            let startIndex = indexOfFirstPoint(onOrAfter: range.lowerBound)
90
            let endIndex = indexOfFirstPoint(after: range.upperBound)
91
            guard startIndex < endIndex else { return [] }
92
            return Array(points[startIndex..<endIndex])
93
        }
94

            
Bogdan Timofte authored 2 months ago
95
        private func rebuildContext() {
96
            context.reset()
97
            for point in points where point.isSample {
98
                context.include(point: point.point())
99
            }
100
        }
101

            
102
        private func appendPoint(timestamp: Date, value: Double, kind: Point.Kind) {
103
            let newPoint = Measurement.Point(id: points.count, timestamp: timestamp, value: value, kind: kind)
104
            points.append(newPoint)
105
            if newPoint.isSample {
106
                context.include(point: newPoint.point())
107
            }
108
            self.objectWillChange.send()
109
        }
110

            
Bogdan Timofte authored 2 months ago
111
        func removeValue(index: Int) {
Bogdan Timofte authored 2 months ago
112
            guard points.indices.contains(index) else { return }
Bogdan Timofte authored 2 months ago
113
            points.remove(at: index)
Bogdan Timofte authored 2 months ago
114
            for index in points.indices {
115
                points[index].id = index
Bogdan Timofte authored 2 months ago
116
            }
Bogdan Timofte authored 2 months ago
117
            rebuildContext()
Bogdan Timofte authored 2 months ago
118
            self.objectWillChange.send()
119
        }
120

            
121
        func addPoint(timestamp: Date, value: Double) {
Bogdan Timofte authored 2 months ago
122
            appendPoint(timestamp: timestamp, value: value, kind: .sample)
123
        }
124

            
125
        func addDiscontinuity(timestamp: Date) {
126
            guard !points.isEmpty else { return }
127
            guard points.last?.isDiscontinuity == false else { return }
128
            appendPoint(timestamp: timestamp, value: points.last?.value ?? 0, kind: .discontinuity)
Bogdan Timofte authored 2 months ago
129
        }
130

            
Bogdan Timofte authored 2 months ago
131
        func resetSeries() {
Bogdan Timofte authored 2 months ago
132
            points.removeAll()
133
            context.reset()
134
            self.objectWillChange.send()
135
        }
Bogdan Timofte authored 2 months ago
136

            
137
        func trim(before cutoff: Date) {
138
            points = points
139
                .filter { $0.timestamp >= cutoff }
140
                .enumerated()
141
                .map { index, point in
Bogdan Timofte authored 2 months ago
142
                    Measurement.Point(id: index, timestamp: point.timestamp, value: point.value, kind: point.kind)
Bogdan Timofte authored 2 months ago
143
                }
Bogdan Timofte authored 2 months ago
144
            rebuildContext()
Bogdan Timofte authored 2 months ago
145
            self.objectWillChange.send()
146
        }
Bogdan Timofte authored 2 months ago
147

            
148
        func filterSamples(keeping shouldKeepSampleAt: (Date) -> Bool) {
149
            let originalSamples = samplePoints
150
            guard !originalSamples.isEmpty else { return }
151

            
152
            var rebuiltPoints: [Point] = []
153
            var lastKeptSampleIndex: Int?
154

            
155
            for (sampleIndex, sample) in originalSamples.enumerated() where shouldKeepSampleAt(sample.timestamp) {
156
                if let lastKeptSampleIndex {
157
                    let hasRemovedSamplesBetween = sampleIndex - lastKeptSampleIndex > 1
158
                    let previousSample = originalSamples[lastKeptSampleIndex]
159
                    let originalHadDiscontinuityBetween = points.contains { point in
160
                        point.isDiscontinuity &&
161
                        point.timestamp > previousSample.timestamp &&
162
                        point.timestamp <= sample.timestamp
163
                    }
164

            
165
                    if hasRemovedSamplesBetween || originalHadDiscontinuityBetween {
166
                        rebuiltPoints.append(
167
                            Point(
168
                                id: rebuiltPoints.count,
169
                                timestamp: sample.timestamp,
170
                                value: rebuiltPoints.last?.value ?? sample.value,
171
                                kind: .discontinuity
172
                            )
173
                        )
174
                    }
175
                }
176

            
177
                rebuiltPoints.append(
178
                    Point(
179
                        id: rebuiltPoints.count,
180
                        timestamp: sample.timestamp,
181
                        value: sample.value,
182
                        kind: .sample
183
                    )
184
                )
185
                lastKeptSampleIndex = sampleIndex
186
            }
187

            
188
            points = rebuiltPoints
189
            rebuildContext()
190
            self.objectWillChange.send()
191
        }
192

            
Bogdan Timofte authored a month ago
193
        func alignCounterToStartAtZero() {
194
            guard let firstSampleIndex = points.firstIndex(where: \.isSample) else {
195
                if !points.isEmpty {
196
                    resetSeries()
197
                }
198
                return
199
            }
200

            
201
            let baselineValue = points[firstSampleIndex].value
202
            points = points[firstSampleIndex...]
203
                .enumerated()
204
                .map { index, point in
205
                    Point(
206
                        id: index,
207
                        timestamp: point.timestamp,
208
                        value: point.value - baselineValue,
209
                        kind: point.kind
210
                    )
211
                }
212
            rebuildContext()
213
            self.objectWillChange.send()
214
        }
215

            
Bogdan Timofte authored 2 months ago
216
        private func indexOfFirstPoint(onOrAfter date: Date) -> Int {
217
            var lowerBound = 0
218
            var upperBound = points.count
219

            
220
            while lowerBound < upperBound {
221
                let midIndex = (lowerBound + upperBound) / 2
222
                if points[midIndex].timestamp < date {
223
                    lowerBound = midIndex + 1
224
                } else {
225
                    upperBound = midIndex
226
                }
227
            }
228

            
229
            return lowerBound
230
        }
231

            
232
        private func indexOfFirstPoint(after date: Date) -> Int {
233
            var lowerBound = 0
234
            var upperBound = points.count
235

            
236
            while lowerBound < upperBound {
237
                let midIndex = (lowerBound + upperBound) / 2
238
                if points[midIndex].timestamp <= date {
239
                    lowerBound = midIndex + 1
240
                } else {
241
                    upperBound = midIndex
242
                }
243
            }
244

            
245
            return lowerBound
246
        }
Bogdan Timofte authored 2 months ago
247
    }
248

            
249
    @Published var power = Measurement()
250
    @Published var voltage = Measurement()
251
    @Published var current = Measurement()
Bogdan Timofte authored 2 months ago
252
    @Published var temperature = Measurement()
Bogdan Timofte authored 2 months ago
253
    @Published var energy = Measurement()
Bogdan Timofte authored 2 months ago
254
    @Published var rssi = Measurement()
255

            
256
    let averagePowerSampleOptions: [Int] = [5, 10, 20, 50, 100, 250]
Bogdan Timofte authored 2 months ago
257

            
Bogdan Timofte authored 2 months ago
258
    private var pendingBucketSecond: Int?
259
    private var pendingBucketTimestamp: Date?
Bogdan Timofte authored 2 months ago
260
    private let energyResetEpsilon = 0.0005
261
    private var lastEnergyCounterValue: Double?
262
    private var lastEnergyGroupID: UInt8?
263
    private var accumulatedEnergyValue: Double = 0
Bogdan Timofte authored 2 months ago
264

            
265
    private var itemsInSum: Double = 0
266
    private var powerSum: Double = 0
267
    private var voltageSum: Double = 0
268
    private var currentSum: Double = 0
Bogdan Timofte authored 2 months ago
269
    private var temperatureSum: Double = 0
270
    private var rssiSum: Double = 0
Bogdan Timofte authored 2 months ago
271

            
Bogdan Timofte authored 2 months ago
272
    private func resetPendingAggregation() {
273
        pendingBucketSecond = nil
274
        pendingBucketTimestamp = nil
Bogdan Timofte authored 2 months ago
275
        itemsInSum = 0
276
        powerSum = 0
277
        voltageSum = 0
278
        currentSum = 0
Bogdan Timofte authored 2 months ago
279
        temperatureSum = 0
280
        rssiSum = 0
Bogdan Timofte authored 2 months ago
281
    }
282

            
283
    private func flushPendingValues() {
284
        guard let pendingBucketTimestamp, itemsInSum > 0 else { return }
285
        self.power.addPoint(timestamp: pendingBucketTimestamp, value: powerSum / itemsInSum)
286
        self.voltage.addPoint(timestamp: pendingBucketTimestamp, value: voltageSum / itemsInSum)
287
        self.current.addPoint(timestamp: pendingBucketTimestamp, value: currentSum / itemsInSum)
Bogdan Timofte authored 2 months ago
288
        self.temperature.addPoint(timestamp: pendingBucketTimestamp, value: temperatureSum / itemsInSum)
289
        self.rssi.addPoint(timestamp: pendingBucketTimestamp, value: rssiSum / itemsInSum)
Bogdan Timofte authored 2 months ago
290
        resetPendingAggregation()
Bogdan Timofte authored 2 months ago
291
        self.objectWillChange.send()
292
    }
Bogdan Timofte authored 2 months ago
293

            
Bogdan Timofte authored a month ago
294
    private func realignEnergyBufferStart() {
295
        energy.alignCounterToStartAtZero()
296
        lastEnergyCounterValue = nil
297
        lastEnergyGroupID = nil
298
        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
299
    }
300

            
Bogdan Timofte authored 2 months ago
301
    func resetSeries() {
302
        power.resetSeries()
303
        voltage.resetSeries()
304
        current.resetSeries()
Bogdan Timofte authored 2 months ago
305
        temperature.resetSeries()
Bogdan Timofte authored 2 months ago
306
        energy.resetSeries()
Bogdan Timofte authored 2 months ago
307
        rssi.resetSeries()
Bogdan Timofte authored 2 months ago
308
        resetPendingAggregation()
Bogdan Timofte authored 2 months ago
309
        lastEnergyCounterValue = nil
310
        lastEnergyGroupID = nil
311
        accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
312
        self.objectWillChange.send()
313
    }
314

            
315
    func reset() {
316
        resetSeries()
317
    }
Bogdan Timofte authored 2 months ago
318

            
319
    func remove(at idx: Int) {
320
        power.removeValue(index: idx)
321
        voltage.removeValue(index: idx)
322
        current.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
323
        temperature.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
324
        energy.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
325
        rssi.removeValue(index: idx)
Bogdan Timofte authored a month ago
326
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
327
        self.objectWillChange.send()
328
    }
329

            
Bogdan Timofte authored 2 months ago
330
    func trim(before cutoff: Date) {
Bogdan Timofte authored 2 months ago
331
        flushPendingValues()
Bogdan Timofte authored 2 months ago
332
        power.trim(before: cutoff)
333
        voltage.trim(before: cutoff)
334
        current.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
335
        temperature.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
336
        energy.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
337
        rssi.trim(before: cutoff)
Bogdan Timofte authored a month ago
338
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
339
        self.objectWillChange.send()
340
    }
341

            
342
    func keepOnly(in range: ClosedRange<Date>) {
343
        flushPendingValues()
344
        power.filterSamples { range.contains($0) }
345
        voltage.filterSamples { range.contains($0) }
346
        current.filterSamples { range.contains($0) }
347
        temperature.filterSamples { range.contains($0) }
348
        energy.filterSamples { range.contains($0) }
349
        rssi.filterSamples { range.contains($0) }
Bogdan Timofte authored a month ago
350
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
351
        self.objectWillChange.send()
352
    }
353

            
354
    func removeValues(in range: ClosedRange<Date>) {
355
        flushPendingValues()
356
        power.filterSamples { !range.contains($0) }
357
        voltage.filterSamples { !range.contains($0) }
358
        current.filterSamples { !range.contains($0) }
359
        temperature.filterSamples { !range.contains($0) }
360
        energy.filterSamples { !range.contains($0) }
361
        rssi.filterSamples { !range.contains($0) }
Bogdan Timofte authored a month ago
362
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
363
        self.objectWillChange.send()
364
    }
365

            
Bogdan Timofte authored 2 months ago
366
    func addValues(timestamp: Date, power: Double, voltage: Double, current: Double, temperature: Double, rssi: Double) {
Bogdan Timofte authored 2 months ago
367
        let valuesTimestamp = timestamp.timeIntervalSinceReferenceDate.intValue
Bogdan Timofte authored 2 months ago
368

            
369
        if pendingBucketSecond == valuesTimestamp {
370
            pendingBucketTimestamp = timestamp
Bogdan Timofte authored 2 months ago
371
            itemsInSum += 1
Bogdan Timofte authored 2 months ago
372
            powerSum += power
Bogdan Timofte authored 2 months ago
373
            voltageSum += voltage
374
            currentSum += current
Bogdan Timofte authored 2 months ago
375
            temperatureSum += temperature
376
            rssiSum += rssi
Bogdan Timofte authored 2 months ago
377
            return
Bogdan Timofte authored 2 months ago
378
        }
Bogdan Timofte authored 2 months ago
379

            
380
        flushPendingValues()
381

            
382
        pendingBucketSecond = valuesTimestamp
383
        pendingBucketTimestamp = timestamp
384
        itemsInSum = 1
385
        powerSum = power
386
        voltageSum = voltage
387
        currentSum = current
Bogdan Timofte authored 2 months ago
388
        temperatureSum = temperature
389
        rssiSum = rssi
Bogdan Timofte authored 2 months ago
390
    }
391

            
392
    func markDiscontinuity(at timestamp: Date) {
393
        flushPendingValues()
394
        power.addDiscontinuity(timestamp: timestamp)
395
        voltage.addDiscontinuity(timestamp: timestamp)
396
        current.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
397
        temperature.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
398
        energy.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
399
        rssi.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
400
        self.objectWillChange.send()
Bogdan Timofte authored 2 months ago
401
    }
Bogdan Timofte authored 2 months ago
402

            
Bogdan Timofte authored 2 months ago
403
    func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
404
        if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
405
            let delta = value - lastEnergyCounterValue
406
            if delta > energyResetEpsilon {
407
                accumulatedEnergyValue += delta
408
            }
409
        }
410

            
411
        energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue)
412
        lastEnergyCounterValue = value
413
        lastEnergyGroupID = groupID
414
        self.objectWillChange.send()
415
    }
416

            
Bogdan Timofte authored 2 months ago
417
    func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
418
        if shouldFlushPendingValues {
419
            flushPendingValues()
420
        }
421
        return power.samplePoints.count
422
    }
423

            
424
    func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
425
        if shouldFlushPendingValues {
426
            flushPendingValues()
427
        }
428

            
429
        let samplePoints = power.samplePoints
430
        guard limit > 0, samplePoints.count > limit else {
431
            return samplePoints
432
        }
433

            
434
        return Array(samplePoints.suffix(limit))
435
    }
436

            
437
    func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
438
        let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues)
439
        guard !points.isEmpty else { return nil }
440

            
441
        let sum = points.reduce(0) { partialResult, point in
442
            partialResult + point.value
443
        }
444

            
445
        return sum / Double(points.count)
446
    }
Bogdan Timofte authored a month ago
447

            
448
    func energyProjectionSnapshot(flushPendingValues shouldFlushPendingValues: Bool = true) -> EnergyProjectionSnapshot? {
449
        if shouldFlushPendingValues {
450
            flushPendingValues()
451
        }
452

            
453
        let samplePoints = energy.samplePoints
454
        guard !samplePoints.isEmpty else { return nil }
455

            
456
        let accumulatedEnergy = samplePoints.last?.value ?? 0
457
        var observedDuration: TimeInterval = 0
458
        var previousSample: Measurement.Point?
459

            
460
        for point in energy.points {
461
            if point.isDiscontinuity {
462
                previousSample = nil
463
                continue
464
            }
465

            
466
            if let previousSample {
467
                observedDuration += max(0, point.timestamp.timeIntervalSince(previousSample.timestamp))
468
            }
469

            
470
            previousSample = point
471
        }
472

            
473
        let averagePower: Double?
474
        if observedDuration > 0, accumulatedEnergy.isFinite {
475
            averagePower = accumulatedEnergy / (observedDuration / 3600)
476
        } else {
477
            averagePower = nil
478
        }
479

            
480
        return EnergyProjectionSnapshot(
481
            accumulatedEnergy: accumulatedEnergy,
482
            observedDuration: observedDuration,
483
            sampleCount: samplePoints.count,
484
            averagePower: averagePower
485
        )
486
    }
487

            
488
    func energyProjectionVariants(flushPendingValues shouldFlushPendingValues: Bool = true) -> [EnergyProjectionVariant] {
489
        if shouldFlushPendingValues {
490
            flushPendingValues()
491
        }
492

            
493
        let contiguousSamples = latestContiguousEnergySamples()
494
        guard contiguousSamples.count >= 2 else { return [] }
495

            
496
        let latestTimestamp = contiguousSamples.last?.timestamp ?? Date()
497
        let windowCandidates: [(duration: TimeInterval, title: String, id: String)] = [
498
            (60, "Last 1 Minute", "last-1m"),
499
            (5 * 60, "Last 5 Minutes", "last-5m"),
500
            (15 * 60, "Last 15 Minutes", "last-15m"),
501
            (60 * 60, "Last 1 Hour", "last-1h"),
502
            (6 * 60 * 60, "Last 6 Hours", "last-6h")
503
        ]
504

            
505
        var variants: [EnergyProjectionVariant] = []
506

            
507
        for candidate in windowCandidates {
508
            let cutoff = latestTimestamp.addingTimeInterval(-candidate.duration)
509
            guard
510
                let startIndex = contiguousSamples.lastIndex(where: { $0.timestamp <= cutoff }),
511
                startIndex < contiguousSamples.count - 1
512
            else {
513
                continue
514
            }
515

            
516
            let relevantSamples = Array(contiguousSamples[startIndex...])
517
            if let variant = projectionVariant(
518
                id: candidate.id,
519
                title: candidate.title,
520
                samples: relevantSamples
521
            ) {
522
                variants.append(variant)
523
            }
524
        }
525

            
526
        if let fullBufferVariant = projectionVariant(
527
            id: "full-buffer",
528
            title: "Whole Buffer",
529
            samples: contiguousSamples
530
        ) {
531
            variants.append(fullBufferVariant)
532
        }
533

            
534
        return variants
535
    }
536

            
537
    private func latestContiguousEnergySamples() -> [Measurement.Point] {
538
        let latestSegment = energy.points.split(whereSeparator: \.isDiscontinuity).last ?? []
539
        return latestSegment.filter(\.isSample)
540
    }
541

            
542
    private func projectionVariant(
543
        id: String,
544
        title: String,
545
        samples: [Measurement.Point]
546
    ) -> EnergyProjectionVariant? {
547
        guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
548

            
549
        let observedDuration = lastSample.timestamp.timeIntervalSince(firstSample.timestamp)
550
        guard observedDuration > 0 else { return nil }
551

            
552
        let accumulatedEnergy = lastSample.value - firstSample.value
553
        guard accumulatedEnergy >= 0, accumulatedEnergy.isFinite else { return nil }
554

            
555
        let averagePower = accumulatedEnergy / (observedDuration / 3600)
556
        guard averagePower.isFinite else { return nil }
557

            
558
        return EnergyProjectionVariant(
559
            id: id,
560
            title: title,
561
            observedDuration: observedDuration,
562
            accumulatedEnergy: accumulatedEnergy,
563
            sampleCount: samples.count,
564
            averagePower: averagePower
565
        )
566
    }
Bogdan Timofte authored 2 months ago
567
}