USB-Meter / USB Meter / Model / Measurements.swift
Newer Older
816 lines | 28.836kb
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
    private static let restoredSampleDiscontinuityThreshold: TimeInterval = 90
14

            
Bogdan Timofte authored a month ago
15
    struct EnergyProjectionSnapshot {
16
        let accumulatedEnergy: Double
17
        let observedDuration: TimeInterval
18
        let sampleCount: Int
19
        let averagePower: Double?
20

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

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

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

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

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

            
47
        var projectedMonthlyEnergy: Double {
48
            averagePower * 24 * 30
49
        }
50

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

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

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

            
68
            var isSample: Bool {
69
                kind == .sample
70
            }
71

            
72
            var isDiscontinuity: Bool {
73
                kind == .discontinuity
74
            }
75

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

            
81
        var points: [Point] = []
82
        var context = ChartContext()
83

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
139
        func replacePoints(_ points: [Point]) {
140
            self.points = points.enumerated().map { index, point in
141
                Point(
142
                    id: index,
143
                    timestamp: point.timestamp,
144
                    value: point.value,
145
                    kind: point.kind
146
                )
147
            }
148
            rebuildContext()
149
            self.objectWillChange.send()
150
        }
151

            
Bogdan Timofte authored 2 months ago
152
        func trim(before cutoff: Date) {
153
            points = points
154
                .filter { $0.timestamp >= cutoff }
155
                .enumerated()
156
                .map { index, point in
Bogdan Timofte authored 2 months ago
157
                    Measurement.Point(id: index, timestamp: point.timestamp, value: point.value, kind: point.kind)
Bogdan Timofte authored 2 months ago
158
                }
Bogdan Timofte authored 2 months ago
159
            rebuildContext()
Bogdan Timofte authored 2 months ago
160
            self.objectWillChange.send()
161
        }
Bogdan Timofte authored 2 months ago
162

            
163
        func filterSamples(keeping shouldKeepSampleAt: (Date) -> Bool) {
164
            let originalSamples = samplePoints
165
            guard !originalSamples.isEmpty else { return }
166

            
167
            var rebuiltPoints: [Point] = []
168
            var lastKeptSampleIndex: Int?
169

            
170
            for (sampleIndex, sample) in originalSamples.enumerated() where shouldKeepSampleAt(sample.timestamp) {
171
                if let lastKeptSampleIndex {
172
                    let hasRemovedSamplesBetween = sampleIndex - lastKeptSampleIndex > 1
173
                    let previousSample = originalSamples[lastKeptSampleIndex]
174
                    let originalHadDiscontinuityBetween = points.contains { point in
175
                        point.isDiscontinuity &&
176
                        point.timestamp > previousSample.timestamp &&
177
                        point.timestamp <= sample.timestamp
178
                    }
179

            
180
                    if hasRemovedSamplesBetween || originalHadDiscontinuityBetween {
181
                        rebuiltPoints.append(
182
                            Point(
183
                                id: rebuiltPoints.count,
184
                                timestamp: sample.timestamp,
185
                                value: rebuiltPoints.last?.value ?? sample.value,
186
                                kind: .discontinuity
187
                            )
188
                        )
189
                    }
190
                }
191

            
192
                rebuiltPoints.append(
193
                    Point(
194
                        id: rebuiltPoints.count,
195
                        timestamp: sample.timestamp,
196
                        value: sample.value,
197
                        kind: .sample
198
                    )
199
                )
200
                lastKeptSampleIndex = sampleIndex
201
            }
202

            
203
            points = rebuiltPoints
204
            rebuildContext()
205
            self.objectWillChange.send()
206
        }
207

            
Bogdan Timofte authored a month ago
208
        func alignCounterToStartAtZero() {
209
            guard let firstSampleIndex = points.firstIndex(where: \.isSample) else {
210
                if !points.isEmpty {
211
                    resetSeries()
212
                }
213
                return
214
            }
215

            
216
            let baselineValue = points[firstSampleIndex].value
217
            points = points[firstSampleIndex...]
218
                .enumerated()
219
                .map { index, point in
220
                    Point(
221
                        id: index,
222
                        timestamp: point.timestamp,
223
                        value: point.value - baselineValue,
224
                        kind: point.kind
225
                    )
226
                }
227
            rebuildContext()
228
            self.objectWillChange.send()
229
        }
230

            
Bogdan Timofte authored 2 months ago
231
        private func indexOfFirstPoint(onOrAfter date: Date) -> Int {
232
            var lowerBound = 0
233
            var upperBound = points.count
234

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

            
244
            return lowerBound
245
        }
246

            
247
        private func indexOfFirstPoint(after date: Date) -> Int {
248
            var lowerBound = 0
249
            var upperBound = points.count
250

            
251
            while lowerBound < upperBound {
252
                let midIndex = (lowerBound + upperBound) / 2
253
                if points[midIndex].timestamp <= date {
254
                    lowerBound = midIndex + 1
255
                } else {
256
                    upperBound = midIndex
257
                }
258
            }
259

            
260
            return lowerBound
261
        }
Bogdan Timofte authored 2 months ago
262
    }
263

            
264
    @Published var power = Measurement()
265
    @Published var voltage = Measurement()
266
    @Published var current = Measurement()
Bogdan Timofte authored 2 months ago
267
    @Published var temperature = Measurement()
Bogdan Timofte authored 2 months ago
268
    @Published var energy = Measurement()
Bogdan Timofte authored 2 months ago
269
    @Published var rssi = Measurement()
270

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

            
Bogdan Timofte authored 2 months ago
273
    private var pendingBucketSecond: Int?
274
    private var pendingBucketTimestamp: Date?
Bogdan Timofte authored 2 months ago
275
    private let energyResetEpsilon = 0.0005
276
    private var lastEnergyCounterValue: Double?
277
    private var lastEnergyGroupID: UInt8?
278
    private var accumulatedEnergyValue: Double = 0
Bogdan Timofte authored 2 months ago
279

            
280
    private var itemsInSum: Double = 0
281
    private var powerSum: Double = 0
282
    private var voltageSum: Double = 0
283
    private var currentSum: Double = 0
Bogdan Timofte authored a month ago
284
    private var temperatureItemsInSum: Double = 0
Bogdan Timofte authored 2 months ago
285
    private var temperatureSum: Double = 0
286
    private var rssiSum: Double = 0
Bogdan Timofte authored 2 months ago
287

            
Bogdan Timofte authored 2 months ago
288
    private func resetPendingAggregation() {
289
        pendingBucketSecond = nil
290
        pendingBucketTimestamp = nil
Bogdan Timofte authored 2 months ago
291
        itemsInSum = 0
292
        powerSum = 0
293
        voltageSum = 0
294
        currentSum = 0
Bogdan Timofte authored a month ago
295
        temperatureItemsInSum = 0
Bogdan Timofte authored 2 months ago
296
        temperatureSum = 0
297
        rssiSum = 0
Bogdan Timofte authored 2 months ago
298
    }
299

            
300
    private func flushPendingValues() {
301
        guard let pendingBucketTimestamp, itemsInSum > 0 else { return }
302
        self.power.addPoint(timestamp: pendingBucketTimestamp, value: powerSum / itemsInSum)
303
        self.voltage.addPoint(timestamp: pendingBucketTimestamp, value: voltageSum / itemsInSum)
304
        self.current.addPoint(timestamp: pendingBucketTimestamp, value: currentSum / itemsInSum)
Bogdan Timofte authored a month ago
305
        if temperatureItemsInSum > 0 {
306
            self.temperature.addPoint(timestamp: pendingBucketTimestamp, value: temperatureSum / temperatureItemsInSum)
307
        }
Bogdan Timofte authored 2 months ago
308
        self.rssi.addPoint(timestamp: pendingBucketTimestamp, value: rssiSum / itemsInSum)
Bogdan Timofte authored 2 months ago
309
        resetPendingAggregation()
Bogdan Timofte authored 2 months ago
310
        self.objectWillChange.send()
311
    }
Bogdan Timofte authored 2 months ago
312

            
Bogdan Timofte authored a month ago
313
    private func realignEnergyBufferStart() {
314
        energy.alignCounterToStartAtZero()
315
        lastEnergyCounterValue = nil
316
        lastEnergyGroupID = nil
317
        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
318
    }
319

            
Bogdan Timofte authored a month ago
320
    @discardableResult
Bogdan Timofte authored a month ago
321
    func restorePersistedChargeSessionSamplesIfNeeded(
Bogdan Timofte authored a month ago
322
        from session: ChargeSessionSummary,
323
        replacingLiveBufferIfNeeded: Bool = false
Bogdan Timofte authored a month ago
324
    ) -> Bool {
Bogdan Timofte authored a month ago
325
        let hasExistingBuffer =
326
            power.points.isEmpty == false ||
327
            voltage.points.isEmpty == false ||
328
            current.points.isEmpty == false ||
329
            temperature.points.isEmpty == false ||
330
            energy.points.isEmpty == false ||
331
            rssi.points.isEmpty == false
332

            
Bogdan Timofte authored a month ago
333
        restoreTrace(
334
            "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)"
335
        )
336

            
Bogdan Timofte authored a month ago
337
        guard hasExistingBuffer == false || replacingLiveBufferIfNeeded else {
Bogdan Timofte authored a month ago
338
            restoreTrace("measurements-restore-skip session=\(session.id.uuidString) reason=live-buffer-not-replaced")
339
            return false
Bogdan Timofte authored a month ago
340
        }
341

            
342
        let sortedSamples = session.aggregatedSamples.sorted { lhs, rhs in
343
            if lhs.bucketIndex != rhs.bucketIndex {
344
                return lhs.bucketIndex < rhs.bucketIndex
345
            }
346
            return lhs.timestamp < rhs.timestamp
347
        }
348

            
Bogdan Timofte authored a month ago
349
        guard !sortedSamples.isEmpty else {
350
            restoreTrace("measurements-restore-skip session=\(session.id.uuidString) reason=no-persisted-samples")
351
            return false
352
        }
Bogdan Timofte authored a month ago
353

            
Bogdan Timofte authored a month ago
354
        let preservedEnergyCounterValue = lastEnergyCounterValue
355
        let preservedEnergyGroupID = lastEnergyGroupID
356
        let persistedRangeUpperBound = sortedSamples.last?.timestamp
357
        if hasExistingBuffer {
358
            flushPendingValues()
359
        }
360

            
Bogdan Timofte authored a month ago
361
        resetPendingAggregation()
362

            
Bogdan Timofte authored a month ago
363
        let restoredPowerPoints = restoredPoints(from: sortedSamples) { sample in
364
            sample.averagePowerWatts
365
        }
366
        let restoredCurrentPoints = restoredPoints(from: sortedSamples) { sample in
367
            sample.averageCurrentAmps
368
        }
369
        let restoredVoltagePoints = restoredPoints(from: sortedSamples) { sample in
370
            sample.averageVoltageVolts
371
        }
372
        let restoredEnergyPoints = restoredPoints(from: sortedSamples) { sample in
373
            sample.measuredEnergyWh
374
        }
375

            
376
        let mergedPowerPoints = mergedRestoredPoints(
377
            restored: restoredPowerPoints,
Bogdan Timofte authored a month ago
378
            existing: power.points,
379
            persistedRangeUpperBound: persistedRangeUpperBound
Bogdan Timofte authored a month ago
380
        )
381
        let mergedCurrentPoints = mergedRestoredPoints(
382
            restored: restoredCurrentPoints,
Bogdan Timofte authored a month ago
383
            existing: current.points,
384
            persistedRangeUpperBound: persistedRangeUpperBound
Bogdan Timofte authored a month ago
385
        )
386
        let mergedVoltagePoints = mergedRestoredPoints(
387
            restored: restoredVoltagePoints,
Bogdan Timofte authored a month ago
388
            existing: voltage.points,
389
            persistedRangeUpperBound: persistedRangeUpperBound
Bogdan Timofte authored a month ago
390
        )
391
        let mergedEnergyPoints = mergedRestoredPoints(
392
            restored: restoredEnergyPoints,
Bogdan Timofte authored a month ago
393
            existing: energy.points,
394
            persistedRangeUpperBound: persistedRangeUpperBound
395
        )
Bogdan Timofte authored a month ago
396
        let preservedRssiTail = preservedTailPoints(
397
            from: rssi.points,
398
            after: persistedRangeUpperBound
Bogdan Timofte authored a month ago
399
        )
Bogdan Timofte authored a month ago
400

            
401
        restoreTrace(
Bogdan Timofte authored a month ago
402
            "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")"
Bogdan Timofte authored a month ago
403
        )
404

            
405
        power.replacePoints(mergedPowerPoints)
406
        current.replacePoints(mergedCurrentPoints)
407
        voltage.replacePoints(mergedVoltagePoints)
408
        energy.replacePoints(mergedEnergyPoints)
Bogdan Timofte authored a month ago
409
        temperature.resetSeries()
Bogdan Timofte authored a month ago
410
        rssi.replacePoints(preservedRssiTail)
411

            
Bogdan Timofte authored a month ago
412
        lastEnergyCounterValue = hasExistingBuffer ? preservedEnergyCounterValue : nil
413
        lastEnergyGroupID = hasExistingBuffer ? preservedEnergyGroupID : nil
Bogdan Timofte authored a month ago
414
        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
415
        self.objectWillChange.send()
Bogdan Timofte authored a month ago
416
        restoreTrace(
417
            "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)"
418
        )
419
        return true
Bogdan Timofte authored a month ago
420
    }
421

            
422
    private func restoredPoints(
423
        from samples: [ChargeSessionSampleSummary],
424
        value: (ChargeSessionSampleSummary) -> Double?
425
    ) -> [Measurement.Point] {
426
        var restored: [Measurement.Point] = []
427
        var previousSample: ChargeSessionSampleSummary?
428

            
429
        for sample in samples {
430
            guard let pointValue = value(sample) else { continue }
431

            
432
            if let previousSample,
Bogdan Timofte authored a month ago
433
               sample.timestamp.timeIntervalSince(previousSample.timestamp) > Self.restoredSampleDiscontinuityThreshold {
Bogdan Timofte authored a month ago
434
                restored.append(
435
                    Measurement.Point(
436
                        id: restored.count,
437
                        timestamp: sample.timestamp,
438
                        value: restored.last?.value ?? pointValue,
439
                        kind: .discontinuity
440
                    )
441
                )
442
            }
443

            
444
            restored.append(
445
                Measurement.Point(
446
                    id: restored.count,
447
                    timestamp: sample.timestamp,
448
                    value: pointValue,
449
                    kind: .sample
450
                )
451
            )
452
            previousSample = sample
453
        }
454

            
455
        return restored
456
    }
457

            
Bogdan Timofte authored a month ago
458
    private func mergedRestoredPoints(
459
        restored: [Measurement.Point],
460
        existing: [Measurement.Point],
461
        persistedRangeUpperBound: Date?
462
    ) -> [Measurement.Point] {
463
        var merged = restored
464
        let preservedTail = preservedTailPoints(from: existing, after: persistedRangeUpperBound)
465

            
466
        guard preservedTail.isEmpty == false else {
467
            return merged
468
        }
469

            
470
        if let tailFirst = preservedTail.first,
471
           tailFirst.isSample,
472
           let lastRestoredSample = merged.last(where: \.isSample),
473
           lastRestoredSample.timestamp < tailFirst.timestamp {
474
            merged.append(
475
                Measurement.Point(
476
                    id: merged.count,
477
                    timestamp: tailFirst.timestamp,
478
                    value: merged.last?.value ?? tailFirst.value,
479
                    kind: .discontinuity
480
                )
481
            )
482
        }
483

            
484
        merged.append(contentsOf: preservedTail.enumerated().map { offset, point in
485
            Measurement.Point(
486
                id: merged.count + offset,
487
                timestamp: point.timestamp,
488
                value: point.value,
489
                kind: point.kind
490
            )
491
        })
492
        return merged
493
    }
494

            
495
    private func preservedTailPoints(
496
        from existing: [Measurement.Point],
497
        after persistedRangeUpperBound: Date?
498
    ) -> [Measurement.Point] {
499
        guard let persistedRangeUpperBound else {
500
            return existing
501
        }
502

            
503
        let tail = existing.filter { $0.timestamp > persistedRangeUpperBound }
504
        guard tail.isEmpty == false else {
505
            return []
506
        }
507

            
508
        if let firstSampleIndex = tail.firstIndex(where: \.isSample) {
509
            return Array(tail[firstSampleIndex...])
510
        }
511

            
512
        return []
513
    }
514

            
Bogdan Timofte authored 2 months ago
515
    func resetSeries() {
516
        power.resetSeries()
517
        voltage.resetSeries()
518
        current.resetSeries()
Bogdan Timofte authored 2 months ago
519
        temperature.resetSeries()
Bogdan Timofte authored 2 months ago
520
        energy.resetSeries()
Bogdan Timofte authored 2 months ago
521
        rssi.resetSeries()
Bogdan Timofte authored 2 months ago
522
        resetPendingAggregation()
Bogdan Timofte authored 2 months ago
523
        lastEnergyCounterValue = nil
524
        lastEnergyGroupID = nil
525
        accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
526
        self.objectWillChange.send()
527
    }
528

            
529
    func reset() {
530
        resetSeries()
531
    }
Bogdan Timofte authored 2 months ago
532

            
533
    func remove(at idx: Int) {
534
        power.removeValue(index: idx)
535
        voltage.removeValue(index: idx)
536
        current.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
537
        temperature.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
538
        energy.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
539
        rssi.removeValue(index: idx)
Bogdan Timofte authored a month ago
540
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
541
        self.objectWillChange.send()
542
    }
543

            
Bogdan Timofte authored 2 months ago
544
    func trim(before cutoff: Date) {
Bogdan Timofte authored 2 months ago
545
        flushPendingValues()
Bogdan Timofte authored 2 months ago
546
        power.trim(before: cutoff)
547
        voltage.trim(before: cutoff)
548
        current.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
549
        temperature.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
550
        energy.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
551
        rssi.trim(before: cutoff)
Bogdan Timofte authored a month ago
552
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
553
        self.objectWillChange.send()
554
    }
555

            
556
    func keepOnly(in range: ClosedRange<Date>) {
557
        flushPendingValues()
558
        power.filterSamples { range.contains($0) }
559
        voltage.filterSamples { range.contains($0) }
560
        current.filterSamples { range.contains($0) }
561
        temperature.filterSamples { range.contains($0) }
562
        energy.filterSamples { range.contains($0) }
563
        rssi.filterSamples { range.contains($0) }
Bogdan Timofte authored a month ago
564
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
565
        self.objectWillChange.send()
566
    }
567

            
568
    func removeValues(in range: ClosedRange<Date>) {
569
        flushPendingValues()
570
        power.filterSamples { !range.contains($0) }
571
        voltage.filterSamples { !range.contains($0) }
572
        current.filterSamples { !range.contains($0) }
573
        temperature.filterSamples { !range.contains($0) }
574
        energy.filterSamples { !range.contains($0) }
575
        rssi.filterSamples { !range.contains($0) }
Bogdan Timofte authored a month ago
576
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
577
        self.objectWillChange.send()
578
    }
579

            
Bogdan Timofte authored a month ago
580
    func addValues(timestamp: Date, power: Double, voltage: Double, current: Double, temperature: Double?, rssi: Double) {
Bogdan Timofte authored 2 months ago
581
        let valuesTimestamp = timestamp.timeIntervalSinceReferenceDate.intValue
Bogdan Timofte authored 2 months ago
582

            
583
        if pendingBucketSecond == valuesTimestamp {
584
            pendingBucketTimestamp = timestamp
Bogdan Timofte authored 2 months ago
585
            itemsInSum += 1
Bogdan Timofte authored 2 months ago
586
            powerSum += power
Bogdan Timofte authored 2 months ago
587
            voltageSum += voltage
588
            currentSum += current
Bogdan Timofte authored a month ago
589
            if let temperature {
590
                temperatureItemsInSum += 1
591
                temperatureSum += temperature
592
            }
Bogdan Timofte authored 2 months ago
593
            rssiSum += rssi
Bogdan Timofte authored 2 months ago
594
            return
Bogdan Timofte authored 2 months ago
595
        }
Bogdan Timofte authored 2 months ago
596

            
597
        flushPendingValues()
598

            
599
        pendingBucketSecond = valuesTimestamp
600
        pendingBucketTimestamp = timestamp
601
        itemsInSum = 1
602
        powerSum = power
603
        voltageSum = voltage
604
        currentSum = current
Bogdan Timofte authored a month ago
605
        if let temperature {
606
            temperatureItemsInSum = 1
607
            temperatureSum = temperature
608
        } else {
609
            temperatureItemsInSum = 0
610
            temperatureSum = 0
611
        }
Bogdan Timofte authored 2 months ago
612
        rssiSum = rssi
Bogdan Timofte authored 2 months ago
613
    }
614

            
615
    func markDiscontinuity(at timestamp: Date) {
616
        flushPendingValues()
617
        power.addDiscontinuity(timestamp: timestamp)
618
        voltage.addDiscontinuity(timestamp: timestamp)
619
        current.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
620
        temperature.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
621
        energy.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
622
        rssi.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
623
        self.objectWillChange.send()
Bogdan Timofte authored 2 months ago
624
    }
Bogdan Timofte authored 2 months ago
625

            
Bogdan Timofte authored 2 months ago
626
    func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
627
        if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
628
            let delta = value - lastEnergyCounterValue
629
            if delta > energyResetEpsilon {
630
                accumulatedEnergyValue += delta
Bogdan Timofte authored a month ago
631
            } else if delta < -energyResetEpsilon {
632
                energy.addDiscontinuity(timestamp: timestamp)
633
                accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
634
            }
635
        }
636

            
637
        energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue)
638
        lastEnergyCounterValue = value
639
        lastEnergyGroupID = groupID
640
        self.objectWillChange.send()
641
    }
642

            
Bogdan Timofte authored 2 months ago
643
    func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
644
        if shouldFlushPendingValues {
645
            flushPendingValues()
646
        }
647
        return power.samplePoints.count
648
    }
649

            
650
    func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
651
        if shouldFlushPendingValues {
652
            flushPendingValues()
653
        }
654

            
655
        let samplePoints = power.samplePoints
656
        guard limit > 0, samplePoints.count > limit else {
657
            return samplePoints
658
        }
659

            
660
        return Array(samplePoints.suffix(limit))
661
    }
662

            
663
    func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
664
        let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues)
665
        guard !points.isEmpty else { return nil }
666

            
667
        let sum = points.reduce(0) { partialResult, point in
668
            partialResult + point.value
669
        }
670

            
671
        return sum / Double(points.count)
672
    }
Bogdan Timofte authored a month ago
673

            
674
    func energyProjectionSnapshot(flushPendingValues shouldFlushPendingValues: Bool = true) -> EnergyProjectionSnapshot? {
675
        if shouldFlushPendingValues {
676
            flushPendingValues()
677
        }
678

            
679
        let samplePoints = energy.samplePoints
680
        guard !samplePoints.isEmpty else { return nil }
681

            
682
        let accumulatedEnergy = samplePoints.last?.value ?? 0
683
        var observedDuration: TimeInterval = 0
684
        var previousSample: Measurement.Point?
685

            
686
        for point in energy.points {
687
            if point.isDiscontinuity {
688
                previousSample = nil
689
                continue
690
            }
691

            
692
            if let previousSample {
693
                observedDuration += max(0, point.timestamp.timeIntervalSince(previousSample.timestamp))
694
            }
695

            
696
            previousSample = point
697
        }
698

            
699
        let averagePower: Double?
700
        if observedDuration > 0, accumulatedEnergy.isFinite {
701
            averagePower = accumulatedEnergy / (observedDuration / 3600)
702
        } else {
703
            averagePower = nil
704
        }
705

            
706
        return EnergyProjectionSnapshot(
707
            accumulatedEnergy: accumulatedEnergy,
708
            observedDuration: observedDuration,
709
            sampleCount: samplePoints.count,
710
            averagePower: averagePower
711
        )
712
    }
713

            
714
    func energyProjectionVariants(flushPendingValues shouldFlushPendingValues: Bool = true) -> [EnergyProjectionVariant] {
715
        if shouldFlushPendingValues {
716
            flushPendingValues()
717
        }
718

            
719
        let contiguousSamples = latestContiguousEnergySamples()
720
        guard contiguousSamples.count >= 2 else { return [] }
721

            
722
        let latestTimestamp = contiguousSamples.last?.timestamp ?? Date()
723
        let windowCandidates: [(duration: TimeInterval, title: String, id: String)] = [
724
            (60, "Last 1 Minute", "last-1m"),
725
            (5 * 60, "Last 5 Minutes", "last-5m"),
726
            (15 * 60, "Last 15 Minutes", "last-15m"),
727
            (60 * 60, "Last 1 Hour", "last-1h"),
728
            (6 * 60 * 60, "Last 6 Hours", "last-6h")
729
        ]
730

            
731
        var variants: [EnergyProjectionVariant] = []
732

            
733
        for candidate in windowCandidates {
734
            let cutoff = latestTimestamp.addingTimeInterval(-candidate.duration)
735
            guard
736
                let startIndex = contiguousSamples.lastIndex(where: { $0.timestamp <= cutoff }),
737
                startIndex < contiguousSamples.count - 1
738
            else {
739
                continue
740
            }
741

            
742
            let relevantSamples = Array(contiguousSamples[startIndex...])
743
            if let variant = projectionVariant(
744
                id: candidate.id,
745
                title: candidate.title,
746
                samples: relevantSamples
747
            ) {
748
                variants.append(variant)
749
            }
750
        }
751

            
752
        if let fullBufferVariant = projectionVariant(
753
            id: "full-buffer",
754
            title: "Whole Buffer",
755
            samples: contiguousSamples
756
        ) {
757
            variants.append(fullBufferVariant)
758
        }
759

            
760
        return variants
761
    }
762

            
763
    private func latestContiguousEnergySamples() -> [Measurement.Point] {
764
        let latestSegment = energy.points.split(whereSeparator: \.isDiscontinuity).last ?? []
765
        return latestSegment.filter(\.isSample)
766
    }
767

            
768
    private func projectionVariant(
769
        id: String,
770
        title: String,
771
        samples: [Measurement.Point]
772
    ) -> EnergyProjectionVariant? {
773
        guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
774

            
775
        let observedDuration = lastSample.timestamp.timeIntervalSince(firstSample.timestamp)
776
        guard observedDuration > 0 else { return nil }
777

            
778
        let accumulatedEnergy = lastSample.value - firstSample.value
779
        guard accumulatedEnergy >= 0, accumulatedEnergy.isFinite else { return nil }
780

            
781
        let averagePower = accumulatedEnergy / (observedDuration / 3600)
782
        guard averagePower.isFinite else { return nil }
783

            
784
        return EnergyProjectionVariant(
785
            id: id,
786
            title: title,
787
            observedDuration: observedDuration,
788
            accumulatedEnergy: accumulatedEnergy,
789
            sampleCount: samples.count,
790
            averagePower: averagePower
791
        )
792
    }
Bogdan Timofte authored 2 months ago
793
}
Bogdan Timofte authored a month ago
794

            
795
extension Measurements.Measurement.Point: TimeSeriesChartPointRepresentable {
796
    var chartPointID: Int {
797
        id
798
    }
799

            
800
    var chartTimestamp: Date {
801
        timestamp
802
    }
803

            
804
    var chartValue: Double {
805
        value
806
    }
807

            
808
    var chartPointKind: TimeSeriesChartPointKind {
809
        switch kind {
810
        case .sample:
811
            return .sample
812
        case .discontinuity:
813
            return .discontinuity
814
        }
815
    }
816
}