USB-Meter / USB Meter / Model / Measurements.swift
Newer Older
662 lines | 22.444kb
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(
315
        from session: ChargeSessionSummary
316
    ) {
317
        guard power.points.isEmpty,
318
              voltage.points.isEmpty,
319
              current.points.isEmpty,
320
              temperature.points.isEmpty,
321
              energy.points.isEmpty,
322
              rssi.points.isEmpty else {
323
            return
324
        }
325

            
326
        let sortedSamples = session.aggregatedSamples.sorted { lhs, rhs in
327
            if lhs.bucketIndex != rhs.bucketIndex {
328
                return lhs.bucketIndex < rhs.bucketIndex
329
            }
330
            return lhs.timestamp < rhs.timestamp
331
        }
332

            
333
        guard !sortedSamples.isEmpty else { return }
334

            
335
        resetPendingAggregation()
336

            
337
        power.replacePoints(restoredPoints(from: sortedSamples) { sample in
338
            sample.averagePowerWatts
339
        })
340
        current.replacePoints(restoredPoints(from: sortedSamples) { sample in
341
            sample.averageCurrentAmps
342
        })
343
        voltage.replacePoints(restoredPoints(from: sortedSamples) { sample in
344
            sample.averageVoltageVolts
345
        })
346
        energy.replacePoints(restoredPoints(from: sortedSamples) { sample in
347
            sample.measuredEnergyWh
348
        })
349
        temperature.resetSeries()
350
        rssi.resetSeries()
351
        lastEnergyCounterValue = nil
352
        lastEnergyGroupID = nil
353
        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
354
        self.objectWillChange.send()
355
    }
356

            
357
    private func restoredPoints(
358
        from samples: [ChargeSessionSampleSummary],
359
        value: (ChargeSessionSampleSummary) -> Double?
360
    ) -> [Measurement.Point] {
361
        var restored: [Measurement.Point] = []
362
        var previousSample: ChargeSessionSampleSummary?
363

            
364
        for sample in samples {
365
            guard let pointValue = value(sample) else { continue }
366

            
367
            if let previousSample,
368
               sample.bucketIndex - previousSample.bucketIndex > 1 {
369
                restored.append(
370
                    Measurement.Point(
371
                        id: restored.count,
372
                        timestamp: sample.timestamp,
373
                        value: restored.last?.value ?? pointValue,
374
                        kind: .discontinuity
375
                    )
376
                )
377
            }
378

            
379
            restored.append(
380
                Measurement.Point(
381
                    id: restored.count,
382
                    timestamp: sample.timestamp,
383
                    value: pointValue,
384
                    kind: .sample
385
                )
386
            )
387
            previousSample = sample
388
        }
389

            
390
        return restored
391
    }
392

            
Bogdan Timofte authored 2 months ago
393
    func resetSeries() {
394
        power.resetSeries()
395
        voltage.resetSeries()
396
        current.resetSeries()
Bogdan Timofte authored 2 months ago
397
        temperature.resetSeries()
Bogdan Timofte authored 2 months ago
398
        energy.resetSeries()
Bogdan Timofte authored 2 months ago
399
        rssi.resetSeries()
Bogdan Timofte authored 2 months ago
400
        resetPendingAggregation()
Bogdan Timofte authored 2 months ago
401
        lastEnergyCounterValue = nil
402
        lastEnergyGroupID = nil
403
        accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
404
        self.objectWillChange.send()
405
    }
406

            
407
    func reset() {
408
        resetSeries()
409
    }
Bogdan Timofte authored 2 months ago
410

            
411
    func remove(at idx: Int) {
412
        power.removeValue(index: idx)
413
        voltage.removeValue(index: idx)
414
        current.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
415
        temperature.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
416
        energy.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
417
        rssi.removeValue(index: idx)
Bogdan Timofte authored a month ago
418
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
419
        self.objectWillChange.send()
420
    }
421

            
Bogdan Timofte authored 2 months ago
422
    func trim(before cutoff: Date) {
Bogdan Timofte authored 2 months ago
423
        flushPendingValues()
Bogdan Timofte authored 2 months ago
424
        power.trim(before: cutoff)
425
        voltage.trim(before: cutoff)
426
        current.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
427
        temperature.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
428
        energy.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
429
        rssi.trim(before: cutoff)
Bogdan Timofte authored a month ago
430
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
431
        self.objectWillChange.send()
432
    }
433

            
434
    func keepOnly(in range: ClosedRange<Date>) {
435
        flushPendingValues()
436
        power.filterSamples { range.contains($0) }
437
        voltage.filterSamples { range.contains($0) }
438
        current.filterSamples { range.contains($0) }
439
        temperature.filterSamples { range.contains($0) }
440
        energy.filterSamples { range.contains($0) }
441
        rssi.filterSamples { range.contains($0) }
Bogdan Timofte authored a month ago
442
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
443
        self.objectWillChange.send()
444
    }
445

            
446
    func removeValues(in range: ClosedRange<Date>) {
447
        flushPendingValues()
448
        power.filterSamples { !range.contains($0) }
449
        voltage.filterSamples { !range.contains($0) }
450
        current.filterSamples { !range.contains($0) }
451
        temperature.filterSamples { !range.contains($0) }
452
        energy.filterSamples { !range.contains($0) }
453
        rssi.filterSamples { !range.contains($0) }
Bogdan Timofte authored a month ago
454
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
455
        self.objectWillChange.send()
456
    }
457

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

            
461
        if pendingBucketSecond == valuesTimestamp {
462
            pendingBucketTimestamp = timestamp
Bogdan Timofte authored 2 months ago
463
            itemsInSum += 1
Bogdan Timofte authored 2 months ago
464
            powerSum += power
Bogdan Timofte authored 2 months ago
465
            voltageSum += voltage
466
            currentSum += current
Bogdan Timofte authored 2 months ago
467
            temperatureSum += temperature
468
            rssiSum += rssi
Bogdan Timofte authored 2 months ago
469
            return
Bogdan Timofte authored 2 months ago
470
        }
Bogdan Timofte authored 2 months ago
471

            
472
        flushPendingValues()
473

            
474
        pendingBucketSecond = valuesTimestamp
475
        pendingBucketTimestamp = timestamp
476
        itemsInSum = 1
477
        powerSum = power
478
        voltageSum = voltage
479
        currentSum = current
Bogdan Timofte authored 2 months ago
480
        temperatureSum = temperature
481
        rssiSum = rssi
Bogdan Timofte authored 2 months ago
482
    }
483

            
484
    func markDiscontinuity(at timestamp: Date) {
485
        flushPendingValues()
486
        power.addDiscontinuity(timestamp: timestamp)
487
        voltage.addDiscontinuity(timestamp: timestamp)
488
        current.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
489
        temperature.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
490
        energy.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
491
        rssi.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
492
        self.objectWillChange.send()
Bogdan Timofte authored 2 months ago
493
    }
Bogdan Timofte authored 2 months ago
494

            
Bogdan Timofte authored 2 months ago
495
    func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
496
        if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
497
            let delta = value - lastEnergyCounterValue
498
            if delta > energyResetEpsilon {
499
                accumulatedEnergyValue += delta
Bogdan Timofte authored a month ago
500
            } else if delta < -energyResetEpsilon {
501
                energy.addDiscontinuity(timestamp: timestamp)
502
                accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
503
            }
504
        }
505

            
506
        energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue)
507
        lastEnergyCounterValue = value
508
        lastEnergyGroupID = groupID
509
        self.objectWillChange.send()
510
    }
511

            
Bogdan Timofte authored 2 months ago
512
    func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
513
        if shouldFlushPendingValues {
514
            flushPendingValues()
515
        }
516
        return power.samplePoints.count
517
    }
518

            
519
    func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
520
        if shouldFlushPendingValues {
521
            flushPendingValues()
522
        }
523

            
524
        let samplePoints = power.samplePoints
525
        guard limit > 0, samplePoints.count > limit else {
526
            return samplePoints
527
        }
528

            
529
        return Array(samplePoints.suffix(limit))
530
    }
531

            
532
    func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
533
        let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues)
534
        guard !points.isEmpty else { return nil }
535

            
536
        let sum = points.reduce(0) { partialResult, point in
537
            partialResult + point.value
538
        }
539

            
540
        return sum / Double(points.count)
541
    }
Bogdan Timofte authored a month ago
542

            
543
    func energyProjectionSnapshot(flushPendingValues shouldFlushPendingValues: Bool = true) -> EnergyProjectionSnapshot? {
544
        if shouldFlushPendingValues {
545
            flushPendingValues()
546
        }
547

            
548
        let samplePoints = energy.samplePoints
549
        guard !samplePoints.isEmpty else { return nil }
550

            
551
        let accumulatedEnergy = samplePoints.last?.value ?? 0
552
        var observedDuration: TimeInterval = 0
553
        var previousSample: Measurement.Point?
554

            
555
        for point in energy.points {
556
            if point.isDiscontinuity {
557
                previousSample = nil
558
                continue
559
            }
560

            
561
            if let previousSample {
562
                observedDuration += max(0, point.timestamp.timeIntervalSince(previousSample.timestamp))
563
            }
564

            
565
            previousSample = point
566
        }
567

            
568
        let averagePower: Double?
569
        if observedDuration > 0, accumulatedEnergy.isFinite {
570
            averagePower = accumulatedEnergy / (observedDuration / 3600)
571
        } else {
572
            averagePower = nil
573
        }
574

            
575
        return EnergyProjectionSnapshot(
576
            accumulatedEnergy: accumulatedEnergy,
577
            observedDuration: observedDuration,
578
            sampleCount: samplePoints.count,
579
            averagePower: averagePower
580
        )
581
    }
582

            
583
    func energyProjectionVariants(flushPendingValues shouldFlushPendingValues: Bool = true) -> [EnergyProjectionVariant] {
584
        if shouldFlushPendingValues {
585
            flushPendingValues()
586
        }
587

            
588
        let contiguousSamples = latestContiguousEnergySamples()
589
        guard contiguousSamples.count >= 2 else { return [] }
590

            
591
        let latestTimestamp = contiguousSamples.last?.timestamp ?? Date()
592
        let windowCandidates: [(duration: TimeInterval, title: String, id: String)] = [
593
            (60, "Last 1 Minute", "last-1m"),
594
            (5 * 60, "Last 5 Minutes", "last-5m"),
595
            (15 * 60, "Last 15 Minutes", "last-15m"),
596
            (60 * 60, "Last 1 Hour", "last-1h"),
597
            (6 * 60 * 60, "Last 6 Hours", "last-6h")
598
        ]
599

            
600
        var variants: [EnergyProjectionVariant] = []
601

            
602
        for candidate in windowCandidates {
603
            let cutoff = latestTimestamp.addingTimeInterval(-candidate.duration)
604
            guard
605
                let startIndex = contiguousSamples.lastIndex(where: { $0.timestamp <= cutoff }),
606
                startIndex < contiguousSamples.count - 1
607
            else {
608
                continue
609
            }
610

            
611
            let relevantSamples = Array(contiguousSamples[startIndex...])
612
            if let variant = projectionVariant(
613
                id: candidate.id,
614
                title: candidate.title,
615
                samples: relevantSamples
616
            ) {
617
                variants.append(variant)
618
            }
619
        }
620

            
621
        if let fullBufferVariant = projectionVariant(
622
            id: "full-buffer",
623
            title: "Whole Buffer",
624
            samples: contiguousSamples
625
        ) {
626
            variants.append(fullBufferVariant)
627
        }
628

            
629
        return variants
630
    }
631

            
632
    private func latestContiguousEnergySamples() -> [Measurement.Point] {
633
        let latestSegment = energy.points.split(whereSeparator: \.isDiscontinuity).last ?? []
634
        return latestSegment.filter(\.isSample)
635
    }
636

            
637
    private func projectionVariant(
638
        id: String,
639
        title: String,
640
        samples: [Measurement.Point]
641
    ) -> EnergyProjectionVariant? {
642
        guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
643

            
644
        let observedDuration = lastSample.timestamp.timeIntervalSince(firstSample.timestamp)
645
        guard observedDuration > 0 else { return nil }
646

            
647
        let accumulatedEnergy = lastSample.value - firstSample.value
648
        guard accumulatedEnergy >= 0, accumulatedEnergy.isFinite else { return nil }
649

            
650
        let averagePower = accumulatedEnergy / (observedDuration / 3600)
651
        guard averagePower.isFinite else { return nil }
652

            
653
        return EnergyProjectionVariant(
654
            id: id,
655
            title: title,
656
            observedDuration: observedDuration,
657
            accumulatedEnergy: accumulatedEnergy,
658
            sampleCount: samples.count,
659
            averagePower: averagePower
660
        )
661
    }
Bogdan Timofte authored 2 months ago
662
}