USB-Meter / USB Meter / Model / Measurements.swift
Newer Older
756 lines | 25.758kb
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

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

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

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

            
165
            var rebuiltPoints: [Point] = []
166
            var lastKeptSampleIndex: Int?
167

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

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

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

            
201
            points = rebuiltPoints
202
            rebuildContext()
203
            self.objectWillChange.send()
204
        }
205

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

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

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

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

            
242
            return lowerBound
243
        }
244

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

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

            
258
            return lowerBound
259
        }
Bogdan Timofte authored 2 months ago
260
    }
261

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

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

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

            
278
    private var itemsInSum: Double = 0
279
    private var powerSum: Double = 0
280
    private var voltageSum: Double = 0
281
    private var currentSum: Double = 0
Bogdan Timofte authored 2 months ago
282
    private var temperatureSum: Double = 0
283
    private var rssiSum: Double = 0
Bogdan Timofte authored 2 months ago
284

            
Bogdan Timofte authored 2 months ago
285
    private func resetPendingAggregation() {
286
        pendingBucketSecond = nil
287
        pendingBucketTimestamp = nil
Bogdan Timofte authored 2 months ago
288
        itemsInSum = 0
289
        powerSum = 0
290
        voltageSum = 0
291
        currentSum = 0
Bogdan Timofte authored 2 months ago
292
        temperatureSum = 0
293
        rssiSum = 0
Bogdan Timofte authored 2 months ago
294
    }
295

            
296
    private func flushPendingValues() {
297
        guard let pendingBucketTimestamp, itemsInSum > 0 else { return }
298
        self.power.addPoint(timestamp: pendingBucketTimestamp, value: powerSum / itemsInSum)
299
        self.voltage.addPoint(timestamp: pendingBucketTimestamp, value: voltageSum / itemsInSum)
300
        self.current.addPoint(timestamp: pendingBucketTimestamp, value: currentSum / itemsInSum)
Bogdan Timofte authored 2 months ago
301
        self.temperature.addPoint(timestamp: pendingBucketTimestamp, value: temperatureSum / itemsInSum)
302
        self.rssi.addPoint(timestamp: pendingBucketTimestamp, value: rssiSum / itemsInSum)
Bogdan Timofte authored 2 months ago
303
        resetPendingAggregation()
Bogdan Timofte authored 2 months ago
304
        self.objectWillChange.send()
305
    }
Bogdan Timofte authored 2 months ago
306

            
Bogdan Timofte authored a month ago
307
    private func realignEnergyBufferStart() {
308
        energy.alignCounterToStartAtZero()
309
        lastEnergyCounterValue = nil
310
        lastEnergyGroupID = nil
311
        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
312
    }
313

            
Bogdan Timofte authored a month ago
314
    func restorePersistedChargeSessionSamplesIfNeeded(
Bogdan Timofte authored a month ago
315
        from session: ChargeSessionSummary,
316
        replacingLiveBufferIfNeeded: Bool = false
Bogdan Timofte authored a month ago
317
    ) {
Bogdan Timofte authored a month ago
318
        let hasExistingBuffer =
319
            power.points.isEmpty == false ||
320
            voltage.points.isEmpty == false ||
321
            current.points.isEmpty == false ||
322
            temperature.points.isEmpty == false ||
323
            energy.points.isEmpty == false ||
324
            rssi.points.isEmpty == false
325

            
326
        guard hasExistingBuffer == false || replacingLiveBufferIfNeeded else {
Bogdan Timofte authored a month ago
327
            return
328
        }
329

            
330
        let sortedSamples = session.aggregatedSamples.sorted { lhs, rhs in
331
            if lhs.bucketIndex != rhs.bucketIndex {
332
                return lhs.bucketIndex < rhs.bucketIndex
333
            }
334
            return lhs.timestamp < rhs.timestamp
335
        }
336

            
337
        guard !sortedSamples.isEmpty else { return }
338

            
Bogdan Timofte authored a month ago
339
        let preservedEnergyCounterValue = lastEnergyCounterValue
340
        let preservedEnergyGroupID = lastEnergyGroupID
341
        let persistedRangeUpperBound = sortedSamples.last?.timestamp
342
        if hasExistingBuffer {
343
            flushPendingValues()
344
        }
345

            
Bogdan Timofte authored a month ago
346
        resetPendingAggregation()
347

            
Bogdan Timofte authored a month ago
348
        power.replacePoints(mergedRestoredPoints(
349
            restored: restoredPoints(from: sortedSamples) { sample in
350
                sample.averagePowerWatts
351
            },
352
            existing: power.points,
353
            persistedRangeUpperBound: persistedRangeUpperBound
354
        ))
355
        current.replacePoints(mergedRestoredPoints(
356
            restored: restoredPoints(from: sortedSamples) { sample in
357
                sample.averageCurrentAmps
358
            },
359
            existing: current.points,
360
            persistedRangeUpperBound: persistedRangeUpperBound
361
        ))
362
        voltage.replacePoints(mergedRestoredPoints(
363
            restored: restoredPoints(from: sortedSamples) { sample in
364
                sample.averageVoltageVolts
365
            },
366
            existing: voltage.points,
367
            persistedRangeUpperBound: persistedRangeUpperBound
368
        ))
369
        energy.replacePoints(mergedRestoredPoints(
370
            restored: restoredPoints(from: sortedSamples) { sample in
371
                sample.measuredEnergyWh
372
            },
373
            existing: energy.points,
374
            persistedRangeUpperBound: persistedRangeUpperBound
375
        ))
376
        temperature.replacePoints(
377
            preservedTailPoints(
378
                from: temperature.points,
379
                after: persistedRangeUpperBound
380
            )
381
        )
382
        rssi.replacePoints(
383
            preservedTailPoints(
384
                from: rssi.points,
385
                after: persistedRangeUpperBound
386
            )
387
        )
388
        lastEnergyCounterValue = hasExistingBuffer ? preservedEnergyCounterValue : nil
389
        lastEnergyGroupID = hasExistingBuffer ? preservedEnergyGroupID : nil
Bogdan Timofte authored a month ago
390
        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
391
        self.objectWillChange.send()
392
    }
393

            
394
    private func restoredPoints(
395
        from samples: [ChargeSessionSampleSummary],
396
        value: (ChargeSessionSampleSummary) -> Double?
397
    ) -> [Measurement.Point] {
398
        var restored: [Measurement.Point] = []
399
        var previousSample: ChargeSessionSampleSummary?
400

            
401
        for sample in samples {
402
            guard let pointValue = value(sample) else { continue }
403

            
404
            if let previousSample,
405
               sample.bucketIndex - previousSample.bucketIndex > 1 {
406
                restored.append(
407
                    Measurement.Point(
408
                        id: restored.count,
409
                        timestamp: sample.timestamp,
410
                        value: restored.last?.value ?? pointValue,
411
                        kind: .discontinuity
412
                    )
413
                )
414
            }
415

            
416
            restored.append(
417
                Measurement.Point(
418
                    id: restored.count,
419
                    timestamp: sample.timestamp,
420
                    value: pointValue,
421
                    kind: .sample
422
                )
423
            )
424
            previousSample = sample
425
        }
426

            
427
        return restored
428
    }
429

            
Bogdan Timofte authored a month ago
430
    private func mergedRestoredPoints(
431
        restored: [Measurement.Point],
432
        existing: [Measurement.Point],
433
        persistedRangeUpperBound: Date?
434
    ) -> [Measurement.Point] {
435
        var merged = restored
436
        let preservedTail = preservedTailPoints(from: existing, after: persistedRangeUpperBound)
437

            
438
        guard preservedTail.isEmpty == false else {
439
            return merged
440
        }
441

            
442
        if let tailFirst = preservedTail.first,
443
           tailFirst.isSample,
444
           let lastRestoredSample = merged.last(where: \.isSample),
445
           lastRestoredSample.timestamp < tailFirst.timestamp {
446
            merged.append(
447
                Measurement.Point(
448
                    id: merged.count,
449
                    timestamp: tailFirst.timestamp,
450
                    value: merged.last?.value ?? tailFirst.value,
451
                    kind: .discontinuity
452
                )
453
            )
454
        }
455

            
456
        merged.append(contentsOf: preservedTail.enumerated().map { offset, point in
457
            Measurement.Point(
458
                id: merged.count + offset,
459
                timestamp: point.timestamp,
460
                value: point.value,
461
                kind: point.kind
462
            )
463
        })
464
        return merged
465
    }
466

            
467
    private func preservedTailPoints(
468
        from existing: [Measurement.Point],
469
        after persistedRangeUpperBound: Date?
470
    ) -> [Measurement.Point] {
471
        guard let persistedRangeUpperBound else {
472
            return existing
473
        }
474

            
475
        let tail = existing.filter { $0.timestamp > persistedRangeUpperBound }
476
        guard tail.isEmpty == false else {
477
            return []
478
        }
479

            
480
        if let firstSampleIndex = tail.firstIndex(where: \.isSample) {
481
            return Array(tail[firstSampleIndex...])
482
        }
483

            
484
        return []
485
    }
486

            
Bogdan Timofte authored 2 months ago
487
    func resetSeries() {
488
        power.resetSeries()
489
        voltage.resetSeries()
490
        current.resetSeries()
Bogdan Timofte authored 2 months ago
491
        temperature.resetSeries()
Bogdan Timofte authored 2 months ago
492
        energy.resetSeries()
Bogdan Timofte authored 2 months ago
493
        rssi.resetSeries()
Bogdan Timofte authored 2 months ago
494
        resetPendingAggregation()
Bogdan Timofte authored 2 months ago
495
        lastEnergyCounterValue = nil
496
        lastEnergyGroupID = nil
497
        accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
498
        self.objectWillChange.send()
499
    }
500

            
501
    func reset() {
502
        resetSeries()
503
    }
Bogdan Timofte authored 2 months ago
504

            
505
    func remove(at idx: Int) {
506
        power.removeValue(index: idx)
507
        voltage.removeValue(index: idx)
508
        current.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
509
        temperature.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
510
        energy.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
511
        rssi.removeValue(index: idx)
Bogdan Timofte authored a month ago
512
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
513
        self.objectWillChange.send()
514
    }
515

            
Bogdan Timofte authored 2 months ago
516
    func trim(before cutoff: Date) {
Bogdan Timofte authored 2 months ago
517
        flushPendingValues()
Bogdan Timofte authored 2 months ago
518
        power.trim(before: cutoff)
519
        voltage.trim(before: cutoff)
520
        current.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
521
        temperature.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
522
        energy.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
523
        rssi.trim(before: cutoff)
Bogdan Timofte authored a month ago
524
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
525
        self.objectWillChange.send()
526
    }
527

            
528
    func keepOnly(in range: ClosedRange<Date>) {
529
        flushPendingValues()
530
        power.filterSamples { range.contains($0) }
531
        voltage.filterSamples { range.contains($0) }
532
        current.filterSamples { range.contains($0) }
533
        temperature.filterSamples { range.contains($0) }
534
        energy.filterSamples { range.contains($0) }
535
        rssi.filterSamples { range.contains($0) }
Bogdan Timofte authored a month ago
536
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
537
        self.objectWillChange.send()
538
    }
539

            
540
    func removeValues(in range: ClosedRange<Date>) {
541
        flushPendingValues()
542
        power.filterSamples { !range.contains($0) }
543
        voltage.filterSamples { !range.contains($0) }
544
        current.filterSamples { !range.contains($0) }
545
        temperature.filterSamples { !range.contains($0) }
546
        energy.filterSamples { !range.contains($0) }
547
        rssi.filterSamples { !range.contains($0) }
Bogdan Timofte authored a month ago
548
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
549
        self.objectWillChange.send()
550
    }
551

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

            
555
        if pendingBucketSecond == valuesTimestamp {
556
            pendingBucketTimestamp = timestamp
Bogdan Timofte authored 2 months ago
557
            itemsInSum += 1
Bogdan Timofte authored 2 months ago
558
            powerSum += power
Bogdan Timofte authored 2 months ago
559
            voltageSum += voltage
560
            currentSum += current
Bogdan Timofte authored 2 months ago
561
            temperatureSum += temperature
562
            rssiSum += rssi
Bogdan Timofte authored 2 months ago
563
            return
Bogdan Timofte authored 2 months ago
564
        }
Bogdan Timofte authored 2 months ago
565

            
566
        flushPendingValues()
567

            
568
        pendingBucketSecond = valuesTimestamp
569
        pendingBucketTimestamp = timestamp
570
        itemsInSum = 1
571
        powerSum = power
572
        voltageSum = voltage
573
        currentSum = current
Bogdan Timofte authored 2 months ago
574
        temperatureSum = temperature
575
        rssiSum = rssi
Bogdan Timofte authored 2 months ago
576
    }
577

            
578
    func markDiscontinuity(at timestamp: Date) {
579
        flushPendingValues()
580
        power.addDiscontinuity(timestamp: timestamp)
581
        voltage.addDiscontinuity(timestamp: timestamp)
582
        current.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
583
        temperature.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
584
        energy.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
585
        rssi.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
586
        self.objectWillChange.send()
Bogdan Timofte authored 2 months ago
587
    }
Bogdan Timofte authored 2 months ago
588

            
Bogdan Timofte authored 2 months ago
589
    func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
590
        if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
591
            let delta = value - lastEnergyCounterValue
592
            if delta > energyResetEpsilon {
593
                accumulatedEnergyValue += delta
Bogdan Timofte authored a month ago
594
            } else if delta < -energyResetEpsilon {
595
                energy.addDiscontinuity(timestamp: timestamp)
596
                accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
597
            }
598
        }
599

            
600
        energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue)
601
        lastEnergyCounterValue = value
602
        lastEnergyGroupID = groupID
603
        self.objectWillChange.send()
604
    }
605

            
Bogdan Timofte authored 2 months ago
606
    func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
607
        if shouldFlushPendingValues {
608
            flushPendingValues()
609
        }
610
        return power.samplePoints.count
611
    }
612

            
613
    func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
614
        if shouldFlushPendingValues {
615
            flushPendingValues()
616
        }
617

            
618
        let samplePoints = power.samplePoints
619
        guard limit > 0, samplePoints.count > limit else {
620
            return samplePoints
621
        }
622

            
623
        return Array(samplePoints.suffix(limit))
624
    }
625

            
626
    func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
627
        let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues)
628
        guard !points.isEmpty else { return nil }
629

            
630
        let sum = points.reduce(0) { partialResult, point in
631
            partialResult + point.value
632
        }
633

            
634
        return sum / Double(points.count)
635
    }
Bogdan Timofte authored a month ago
636

            
637
    func energyProjectionSnapshot(flushPendingValues shouldFlushPendingValues: Bool = true) -> EnergyProjectionSnapshot? {
638
        if shouldFlushPendingValues {
639
            flushPendingValues()
640
        }
641

            
642
        let samplePoints = energy.samplePoints
643
        guard !samplePoints.isEmpty else { return nil }
644

            
645
        let accumulatedEnergy = samplePoints.last?.value ?? 0
646
        var observedDuration: TimeInterval = 0
647
        var previousSample: Measurement.Point?
648

            
649
        for point in energy.points {
650
            if point.isDiscontinuity {
651
                previousSample = nil
652
                continue
653
            }
654

            
655
            if let previousSample {
656
                observedDuration += max(0, point.timestamp.timeIntervalSince(previousSample.timestamp))
657
            }
658

            
659
            previousSample = point
660
        }
661

            
662
        let averagePower: Double?
663
        if observedDuration > 0, accumulatedEnergy.isFinite {
664
            averagePower = accumulatedEnergy / (observedDuration / 3600)
665
        } else {
666
            averagePower = nil
667
        }
668

            
669
        return EnergyProjectionSnapshot(
670
            accumulatedEnergy: accumulatedEnergy,
671
            observedDuration: observedDuration,
672
            sampleCount: samplePoints.count,
673
            averagePower: averagePower
674
        )
675
    }
676

            
677
    func energyProjectionVariants(flushPendingValues shouldFlushPendingValues: Bool = true) -> [EnergyProjectionVariant] {
678
        if shouldFlushPendingValues {
679
            flushPendingValues()
680
        }
681

            
682
        let contiguousSamples = latestContiguousEnergySamples()
683
        guard contiguousSamples.count >= 2 else { return [] }
684

            
685
        let latestTimestamp = contiguousSamples.last?.timestamp ?? Date()
686
        let windowCandidates: [(duration: TimeInterval, title: String, id: String)] = [
687
            (60, "Last 1 Minute", "last-1m"),
688
            (5 * 60, "Last 5 Minutes", "last-5m"),
689
            (15 * 60, "Last 15 Minutes", "last-15m"),
690
            (60 * 60, "Last 1 Hour", "last-1h"),
691
            (6 * 60 * 60, "Last 6 Hours", "last-6h")
692
        ]
693

            
694
        var variants: [EnergyProjectionVariant] = []
695

            
696
        for candidate in windowCandidates {
697
            let cutoff = latestTimestamp.addingTimeInterval(-candidate.duration)
698
            guard
699
                let startIndex = contiguousSamples.lastIndex(where: { $0.timestamp <= cutoff }),
700
                startIndex < contiguousSamples.count - 1
701
            else {
702
                continue
703
            }
704

            
705
            let relevantSamples = Array(contiguousSamples[startIndex...])
706
            if let variant = projectionVariant(
707
                id: candidate.id,
708
                title: candidate.title,
709
                samples: relevantSamples
710
            ) {
711
                variants.append(variant)
712
            }
713
        }
714

            
715
        if let fullBufferVariant = projectionVariant(
716
            id: "full-buffer",
717
            title: "Whole Buffer",
718
            samples: contiguousSamples
719
        ) {
720
            variants.append(fullBufferVariant)
721
        }
722

            
723
        return variants
724
    }
725

            
726
    private func latestContiguousEnergySamples() -> [Measurement.Point] {
727
        let latestSegment = energy.points.split(whereSeparator: \.isDiscontinuity).last ?? []
728
        return latestSegment.filter(\.isSample)
729
    }
730

            
731
    private func projectionVariant(
732
        id: String,
733
        title: String,
734
        samples: [Measurement.Point]
735
    ) -> EnergyProjectionVariant? {
736
        guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
737

            
738
        let observedDuration = lastSample.timestamp.timeIntervalSince(firstSample.timestamp)
739
        guard observedDuration > 0 else { return nil }
740

            
741
        let accumulatedEnergy = lastSample.value - firstSample.value
742
        guard accumulatedEnergy >= 0, accumulatedEnergy.isFinite else { return nil }
743

            
744
        let averagePower = accumulatedEnergy / (observedDuration / 3600)
745
        guard averagePower.isFinite else { return nil }
746

            
747
        return EnergyProjectionVariant(
748
            id: id,
749
            title: title,
750
            observedDuration: observedDuration,
751
            accumulatedEnergy: accumulatedEnergy,
752
            sampleCount: samples.count,
753
            averagePower: averagePower
754
        )
755
    }
Bogdan Timofte authored 2 months ago
756
}