USB-Meter / USB Meter / Model / Measurements.swift
Newer Older
1002 lines | 36.138kb
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()
Bogdan Timofte authored a month ago
270
    @Published var batteryPercent = Measurement()
Bogdan Timofte authored 2 months ago
271

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
363
        resetPendingAggregation()
364

            
Bogdan Timofte authored a month ago
365
        let restoredPowerPoints = restoredPoints(from: sortedSamples) { sample in
366
            sample.averagePowerWatts
367
        }
368
        let restoredCurrentPoints = restoredPoints(from: sortedSamples) { sample in
369
            sample.averageCurrentAmps
370
        }
371
        let restoredVoltagePoints = restoredPoints(from: sortedSamples) { sample in
372
            sample.averageVoltageVolts
373
        }
374
        let restoredEnergyPoints = restoredPoints(from: sortedSamples) { sample in
375
            sample.measuredEnergyWh
376
        }
Bogdan Timofte authored a month ago
377
        let restoredBatteryPercentPoints = restoredPoints(from: sortedSamples) { sample in
378
            sample.estimatedBatteryPercent ?? estimatedBatteryPercent(for: sample, in: session)
379
        }
Bogdan Timofte authored a month ago
380

            
381
        let mergedPowerPoints = mergedRestoredPoints(
382
            restored: restoredPowerPoints,
Bogdan Timofte authored a month ago
383
            existing: power.points,
384
            persistedRangeUpperBound: persistedRangeUpperBound
Bogdan Timofte authored a month ago
385
        )
386
        let mergedCurrentPoints = mergedRestoredPoints(
387
            restored: restoredCurrentPoints,
Bogdan Timofte authored a month ago
388
            existing: current.points,
389
            persistedRangeUpperBound: persistedRangeUpperBound
Bogdan Timofte authored a month ago
390
        )
391
        let mergedVoltagePoints = mergedRestoredPoints(
392
            restored: restoredVoltagePoints,
Bogdan Timofte authored a month ago
393
            existing: voltage.points,
394
            persistedRangeUpperBound: persistedRangeUpperBound
Bogdan Timofte authored a month ago
395
        )
396
        let mergedEnergyPoints = mergedRestoredPoints(
397
            restored: restoredEnergyPoints,
Bogdan Timofte authored a month ago
398
            existing: energy.points,
399
            persistedRangeUpperBound: persistedRangeUpperBound
400
        )
Bogdan Timofte authored a month ago
401
        let mergedBatteryPercentPoints = mergedRestoredPoints(
402
            restored: restoredBatteryPercentPoints,
403
            existing: batteryPercent.points,
404
            persistedRangeUpperBound: persistedRangeUpperBound
405
        )
Bogdan Timofte authored a month ago
406
        let preservedRssiTail = preservedTailPoints(
407
            from: rssi.points,
408
            after: persistedRangeUpperBound
Bogdan Timofte authored a month ago
409
        )
Bogdan Timofte authored a month ago
410

            
411
        restoreTrace(
Bogdan Timofte authored a month ago
412
            "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
413
        )
414

            
415
        power.replacePoints(mergedPowerPoints)
416
        current.replacePoints(mergedCurrentPoints)
417
        voltage.replacePoints(mergedVoltagePoints)
418
        energy.replacePoints(mergedEnergyPoints)
Bogdan Timofte authored a month ago
419
        batteryPercent.replacePoints(mergedBatteryPercentPoints)
Bogdan Timofte authored a month ago
420
        temperature.resetSeries()
Bogdan Timofte authored a month ago
421
        rssi.replacePoints(preservedRssiTail)
422

            
Bogdan Timofte authored a month ago
423
        lastEnergyCounterValue = hasExistingBuffer ? preservedEnergyCounterValue : nil
424
        lastEnergyGroupID = hasExistingBuffer ? preservedEnergyGroupID : nil
Bogdan Timofte authored a month ago
425
        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
426
        self.objectWillChange.send()
Bogdan Timofte authored a month ago
427
        restoreTrace(
428
            "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)"
429
        )
430
        return true
Bogdan Timofte authored a month ago
431
    }
432

            
433
    private func restoredPoints(
434
        from samples: [ChargeSessionSampleSummary],
435
        value: (ChargeSessionSampleSummary) -> Double?
436
    ) -> [Measurement.Point] {
437
        var restored: [Measurement.Point] = []
438
        var previousSample: ChargeSessionSampleSummary?
439

            
440
        for sample in samples {
441
            guard let pointValue = value(sample) else { continue }
442

            
443
            if let previousSample,
Bogdan Timofte authored a month ago
444
               sample.timestamp.timeIntervalSince(previousSample.timestamp) > Self.restoredSampleDiscontinuityThreshold {
Bogdan Timofte authored a month ago
445
                restored.append(
446
                    Measurement.Point(
447
                        id: restored.count,
448
                        timestamp: sample.timestamp,
449
                        value: restored.last?.value ?? pointValue,
450
                        kind: .discontinuity
451
                    )
452
                )
453
            }
454

            
455
            restored.append(
456
                Measurement.Point(
457
                    id: restored.count,
458
                    timestamp: sample.timestamp,
459
                    value: pointValue,
460
                    kind: .sample
461
                )
462
            )
463
            previousSample = sample
464
        }
465

            
466
        return restored
467
    }
468

            
Bogdan Timofte authored a month ago
469
    private func estimatedBatteryPercent(
470
        for sample: ChargeSessionSampleSummary,
471
        in session: ChargeSessionSummary
472
    ) -> Double? {
473
        let estimatedCapacityWh = session.capacityEstimateWh
474

            
Bogdan Timofte authored a month ago
475
        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
Bogdan Timofte authored a month ago
476
            var candidates: [Double] = []
477

            
478
            for lowerIndex in anchors.indices {
479
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
480
                    let lower = anchors[lowerIndex]
481
                    let upper = anchors[upperIndex]
482
                    let percentDelta = upper.percent - lower.percent
483
                    let energyDelta = upper.energyWh - lower.energyWh
484

            
485
                    guard percentDelta >= 3, energyDelta > 0.01 else {
486
                        continue
487
                    }
488

            
489
                    let capacityWh = energyDelta / (percentDelta / 100)
490
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
491
                        continue
492
                    }
493

            
494
                    candidates.append(capacityWh)
495
                }
496
            }
497

            
498
            return candidates
499
        }
500

            
Bogdan Timofte authored a month ago
501
        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
Bogdan Timofte authored a month ago
502
            let candidates = anchorCapacityCandidates(from: anchors)
503
            guard !candidates.isEmpty else {
504
                return nil
505
            }
506

            
507
            let sortedCandidates = candidates.sorted()
508
            return sortedCandidates[sortedCandidates.count / 2]
509
        }
510

            
Bogdan Timofte authored a month ago
511
        var anchors: [BatteryLevelPredictionAnchor] = []
Bogdan Timofte authored a month ago
512
        if let startBatteryPercent = session.startBatteryPercent,
513
           startBatteryPercent >= 0 {
514
            anchors.append(
Bogdan Timofte authored a month ago
515
                BatteryLevelPredictionAnchor(
Bogdan Timofte authored a month ago
516
                    percent: startBatteryPercent,
517
                    energyWh: 0,
518
                    timestamp: session.effectiveTrimStart,
Bogdan Timofte authored a month ago
519
                    description: "session start",
Bogdan Timofte authored a month ago
520
                    isCheckpoint: false
521
                )
522
            )
523
        }
524

            
525
        anchors.append(
526
            contentsOf: session.checkpoints
527
                .filter { $0.batteryPercent >= 0 }
528
                .sorted { lhs, rhs in
529
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
530
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
531
                    }
532
                    return lhs.timestamp < rhs.timestamp
533
                }
534
                .map {
Bogdan Timofte authored a month ago
535
                    BatteryLevelPredictionAnchor(
Bogdan Timofte authored a month ago
536
                        percent: $0.batteryPercent,
537
                        energyWh: $0.measuredEnergyWh,
538
                        timestamp: $0.timestamp,
Bogdan Timofte authored a month ago
539
                        description: $0.flag.anchorDescription,
Bogdan Timofte authored a month ago
540
                        isCheckpoint: true
541
                    )
542
                }
543
        )
544

            
Bogdan Timofte authored a month ago
545
        let effectiveEnergyWh = effectiveBatteryEnergyWh(for: sample, in: session)
546

            
547
        if session.startsFromFlatBattery {
548
            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
549
                from: anchors,
550
                estimatedCapacityWh: estimatedCapacityWh
551
            ) {
552
                anchors.append(
553
                    BatteryLevelPredictionAnchor(
554
                        percent: 0,
555
                        energyWh: virtualZeroEnergyWh,
556
                        timestamp: session.effectiveTrimStart,
557
                        description: "estimated flat reserve",
558
                        isCheckpoint: false
559
                    )
560
                )
561
            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
562
                      effectiveEnergyWh < firstCheckpoint.energyWh - 0.05 {
563
                return nil
564
            }
565
        }
566

            
Bogdan Timofte authored a month ago
567
        let sortedAnchors = anchors.sorted { lhs, rhs in
568
            if lhs.energyWh != rhs.energyWh {
569
                return lhs.energyWh < rhs.energyWh
570
            }
571
            return lhs.timestamp < rhs.timestamp
572
        }
573

            
574
        guard !sortedAnchors.isEmpty else { return nil }
Bogdan Timofte authored a month ago
575

            
Bogdan Timofte authored a month ago
576
        let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
577
        let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
578
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
Bogdan Timofte authored a month ago
579

            
580
        if let lowerAnchor,
581
           let upperAnchor,
582
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
583
            let interpolationProgress = min(
584
                max(
585
                    (effectiveEnergyWh - lowerAnchor.energyWh) /
586
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
587
                    0
588
                ),
589
                1
590
            )
591
            return min(
592
                max(
593
                    lowerAnchor.percent +
594
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
595
                    0
596
                ),
597
                100
598
            )
599
        }
600

            
Bogdan Timofte authored a month ago
601
        let inferredCapacityWh = estimatedCapacityWh
602
            ?? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors)
603

            
604
        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
Bogdan Timofte authored a month ago
605
            return nil
606
        }
607

            
608
        return BatteryLevelPredictionTuning.predictedPercent(
609
            anchorPercent: anchor.percent,
610
            anchorEnergyWh: anchor.energyWh,
611
            anchorTimestamp: anchor.timestamp,
612
            anchorIsCheckpoint: anchor.isCheckpoint,
613
            effectiveEnergyWh: effectiveEnergyWh,
614
            referenceTimestamp: sample.timestamp,
Bogdan Timofte authored a month ago
615
            estimatedCapacityWh: inferredCapacityWh
Bogdan Timofte authored a month ago
616
        )
617
    }
618

            
619
    private func effectiveBatteryEnergyWh(
620
        for sample: ChargeSessionSampleSummary,
621
        in session: ChargeSessionSummary
622
    ) -> Double {
623
        switch session.chargingTransportMode {
624
        case .wired:
625
            return sample.measuredEnergyWh
626
        case .wireless:
627
            if let factor = session.wirelessEfficiencyFactor, factor > 0 {
628
                return sample.measuredEnergyWh * factor
629
            }
630
            if let sessionEffectiveEnergyWh = session.effectiveBatteryEnergyWh,
631
               session.measuredEnergyWh > 0 {
632
                return sample.measuredEnergyWh * (sessionEffectiveEnergyWh / session.measuredEnergyWh)
633
            }
634
            return sample.measuredEnergyWh
635
        }
636
    }
637

            
Bogdan Timofte authored a month ago
638
    private func mergedRestoredPoints(
639
        restored: [Measurement.Point],
640
        existing: [Measurement.Point],
641
        persistedRangeUpperBound: Date?
642
    ) -> [Measurement.Point] {
643
        var merged = restored
644
        let preservedTail = preservedTailPoints(from: existing, after: persistedRangeUpperBound)
645

            
646
        guard preservedTail.isEmpty == false else {
647
            return merged
648
        }
649

            
650
        if let tailFirst = preservedTail.first,
651
           tailFirst.isSample,
652
           let lastRestoredSample = merged.last(where: \.isSample),
653
           lastRestoredSample.timestamp < tailFirst.timestamp {
654
            merged.append(
655
                Measurement.Point(
656
                    id: merged.count,
657
                    timestamp: tailFirst.timestamp,
658
                    value: merged.last?.value ?? tailFirst.value,
659
                    kind: .discontinuity
660
                )
661
            )
662
        }
663

            
664
        merged.append(contentsOf: preservedTail.enumerated().map { offset, point in
665
            Measurement.Point(
666
                id: merged.count + offset,
667
                timestamp: point.timestamp,
668
                value: point.value,
669
                kind: point.kind
670
            )
671
        })
672
        return merged
673
    }
674

            
675
    private func preservedTailPoints(
676
        from existing: [Measurement.Point],
677
        after persistedRangeUpperBound: Date?
678
    ) -> [Measurement.Point] {
679
        guard let persistedRangeUpperBound else {
680
            return existing
681
        }
682

            
683
        let tail = existing.filter { $0.timestamp > persistedRangeUpperBound }
684
        guard tail.isEmpty == false else {
685
            return []
686
        }
687

            
688
        if let firstSampleIndex = tail.firstIndex(where: \.isSample) {
689
            return Array(tail[firstSampleIndex...])
690
        }
691

            
692
        return []
693
    }
694

            
Bogdan Timofte authored 2 months ago
695
    func resetSeries() {
696
        power.resetSeries()
697
        voltage.resetSeries()
698
        current.resetSeries()
Bogdan Timofte authored 2 months ago
699
        temperature.resetSeries()
Bogdan Timofte authored 2 months ago
700
        energy.resetSeries()
Bogdan Timofte authored 2 months ago
701
        rssi.resetSeries()
Bogdan Timofte authored a month ago
702
        batteryPercent.resetSeries()
Bogdan Timofte authored 2 months ago
703
        resetPendingAggregation()
Bogdan Timofte authored 2 months ago
704
        lastEnergyCounterValue = nil
705
        lastEnergyGroupID = nil
706
        accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
707
        self.objectWillChange.send()
708
    }
709

            
710
    func reset() {
711
        resetSeries()
712
    }
Bogdan Timofte authored 2 months ago
713

            
714
    func remove(at idx: Int) {
715
        power.removeValue(index: idx)
716
        voltage.removeValue(index: idx)
717
        current.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
718
        temperature.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
719
        energy.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
720
        rssi.removeValue(index: idx)
Bogdan Timofte authored a month ago
721
        batteryPercent.removeValue(index: idx)
Bogdan Timofte authored a month ago
722
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
723
        self.objectWillChange.send()
724
    }
725

            
Bogdan Timofte authored 2 months ago
726
    func trim(before cutoff: Date) {
Bogdan Timofte authored 2 months ago
727
        flushPendingValues()
Bogdan Timofte authored 2 months ago
728
        power.trim(before: cutoff)
729
        voltage.trim(before: cutoff)
730
        current.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
731
        temperature.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
732
        energy.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
733
        rssi.trim(before: cutoff)
Bogdan Timofte authored a month ago
734
        batteryPercent.trim(before: cutoff)
Bogdan Timofte authored a month ago
735
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
736
        self.objectWillChange.send()
737
    }
738

            
739
    func keepOnly(in range: ClosedRange<Date>) {
740
        flushPendingValues()
741
        power.filterSamples { range.contains($0) }
742
        voltage.filterSamples { range.contains($0) }
743
        current.filterSamples { range.contains($0) }
744
        temperature.filterSamples { range.contains($0) }
745
        energy.filterSamples { range.contains($0) }
746
        rssi.filterSamples { range.contains($0) }
Bogdan Timofte authored a month ago
747
        batteryPercent.filterSamples { range.contains($0) }
Bogdan Timofte authored a month ago
748
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
749
        self.objectWillChange.send()
750
    }
751

            
752
    func removeValues(in range: ClosedRange<Date>) {
753
        flushPendingValues()
754
        power.filterSamples { !range.contains($0) }
755
        voltage.filterSamples { !range.contains($0) }
756
        current.filterSamples { !range.contains($0) }
757
        temperature.filterSamples { !range.contains($0) }
758
        energy.filterSamples { !range.contains($0) }
759
        rssi.filterSamples { !range.contains($0) }
Bogdan Timofte authored a month ago
760
        batteryPercent.filterSamples { !range.contains($0) }
Bogdan Timofte authored a month ago
761
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
762
        self.objectWillChange.send()
763
    }
764

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

            
768
        if pendingBucketSecond == valuesTimestamp {
769
            pendingBucketTimestamp = timestamp
Bogdan Timofte authored 2 months ago
770
            itemsInSum += 1
Bogdan Timofte authored 2 months ago
771
            powerSum += power
Bogdan Timofte authored 2 months ago
772
            voltageSum += voltage
773
            currentSum += current
Bogdan Timofte authored a month ago
774
            if let temperature {
775
                temperatureItemsInSum += 1
776
                temperatureSum += temperature
777
            }
Bogdan Timofte authored 2 months ago
778
            rssiSum += rssi
Bogdan Timofte authored 2 months ago
779
            return
Bogdan Timofte authored 2 months ago
780
        }
Bogdan Timofte authored 2 months ago
781

            
782
        flushPendingValues()
783

            
784
        pendingBucketSecond = valuesTimestamp
785
        pendingBucketTimestamp = timestamp
786
        itemsInSum = 1
787
        powerSum = power
788
        voltageSum = voltage
789
        currentSum = current
Bogdan Timofte authored a month ago
790
        if let temperature {
791
            temperatureItemsInSum = 1
792
            temperatureSum = temperature
793
        } else {
794
            temperatureItemsInSum = 0
795
            temperatureSum = 0
796
        }
Bogdan Timofte authored 2 months ago
797
        rssiSum = rssi
Bogdan Timofte authored 2 months ago
798
    }
799

            
800
    func markDiscontinuity(at timestamp: Date) {
801
        flushPendingValues()
802
        power.addDiscontinuity(timestamp: timestamp)
803
        voltage.addDiscontinuity(timestamp: timestamp)
804
        current.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
805
        temperature.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
806
        energy.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
807
        rssi.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored a month ago
808
        batteryPercent.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
809
        self.objectWillChange.send()
Bogdan Timofte authored 2 months ago
810
    }
Bogdan Timofte authored 2 months ago
811

            
Bogdan Timofte authored 2 months ago
812
    func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
813
        if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
814
            let delta = value - lastEnergyCounterValue
815
            if delta > energyResetEpsilon {
816
                accumulatedEnergyValue += delta
Bogdan Timofte authored a month ago
817
            } else if delta < -energyResetEpsilon {
818
                energy.addDiscontinuity(timestamp: timestamp)
819
                accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
820
            }
821
        }
822

            
823
        energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue)
824
        lastEnergyCounterValue = value
825
        lastEnergyGroupID = groupID
826
        self.objectWillChange.send()
827
    }
828

            
Bogdan Timofte authored 2 months ago
829
    func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
830
        if shouldFlushPendingValues {
831
            flushPendingValues()
832
        }
833
        return power.samplePoints.count
834
    }
835

            
836
    func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
837
        if shouldFlushPendingValues {
838
            flushPendingValues()
839
        }
840

            
841
        let samplePoints = power.samplePoints
842
        guard limit > 0, samplePoints.count > limit else {
843
            return samplePoints
844
        }
845

            
846
        return Array(samplePoints.suffix(limit))
847
    }
848

            
849
    func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
850
        let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues)
851
        guard !points.isEmpty else { return nil }
852

            
853
        let sum = points.reduce(0) { partialResult, point in
854
            partialResult + point.value
855
        }
856

            
857
        return sum / Double(points.count)
858
    }
Bogdan Timofte authored a month ago
859

            
860
    func energyProjectionSnapshot(flushPendingValues shouldFlushPendingValues: Bool = true) -> EnergyProjectionSnapshot? {
861
        if shouldFlushPendingValues {
862
            flushPendingValues()
863
        }
864

            
865
        let samplePoints = energy.samplePoints
866
        guard !samplePoints.isEmpty else { return nil }
867

            
868
        let accumulatedEnergy = samplePoints.last?.value ?? 0
869
        var observedDuration: TimeInterval = 0
870
        var previousSample: Measurement.Point?
871

            
872
        for point in energy.points {
873
            if point.isDiscontinuity {
874
                previousSample = nil
875
                continue
876
            }
877

            
878
            if let previousSample {
879
                observedDuration += max(0, point.timestamp.timeIntervalSince(previousSample.timestamp))
880
            }
881

            
882
            previousSample = point
883
        }
884

            
885
        let averagePower: Double?
886
        if observedDuration > 0, accumulatedEnergy.isFinite {
887
            averagePower = accumulatedEnergy / (observedDuration / 3600)
888
        } else {
889
            averagePower = nil
890
        }
891

            
892
        return EnergyProjectionSnapshot(
893
            accumulatedEnergy: accumulatedEnergy,
894
            observedDuration: observedDuration,
895
            sampleCount: samplePoints.count,
896
            averagePower: averagePower
897
        )
898
    }
899

            
900
    func energyProjectionVariants(flushPendingValues shouldFlushPendingValues: Bool = true) -> [EnergyProjectionVariant] {
901
        if shouldFlushPendingValues {
902
            flushPendingValues()
903
        }
904

            
905
        let contiguousSamples = latestContiguousEnergySamples()
906
        guard contiguousSamples.count >= 2 else { return [] }
907

            
908
        let latestTimestamp = contiguousSamples.last?.timestamp ?? Date()
909
        let windowCandidates: [(duration: TimeInterval, title: String, id: String)] = [
910
            (60, "Last 1 Minute", "last-1m"),
911
            (5 * 60, "Last 5 Minutes", "last-5m"),
912
            (15 * 60, "Last 15 Minutes", "last-15m"),
913
            (60 * 60, "Last 1 Hour", "last-1h"),
914
            (6 * 60 * 60, "Last 6 Hours", "last-6h")
915
        ]
916

            
917
        var variants: [EnergyProjectionVariant] = []
918

            
919
        for candidate in windowCandidates {
920
            let cutoff = latestTimestamp.addingTimeInterval(-candidate.duration)
921
            guard
922
                let startIndex = contiguousSamples.lastIndex(where: { $0.timestamp <= cutoff }),
923
                startIndex < contiguousSamples.count - 1
924
            else {
925
                continue
926
            }
927

            
928
            let relevantSamples = Array(contiguousSamples[startIndex...])
929
            if let variant = projectionVariant(
930
                id: candidate.id,
931
                title: candidate.title,
932
                samples: relevantSamples
933
            ) {
934
                variants.append(variant)
935
            }
936
        }
937

            
938
        if let fullBufferVariant = projectionVariant(
939
            id: "full-buffer",
940
            title: "Whole Buffer",
941
            samples: contiguousSamples
942
        ) {
943
            variants.append(fullBufferVariant)
944
        }
945

            
946
        return variants
947
    }
948

            
949
    private func latestContiguousEnergySamples() -> [Measurement.Point] {
950
        let latestSegment = energy.points.split(whereSeparator: \.isDiscontinuity).last ?? []
951
        return latestSegment.filter(\.isSample)
952
    }
953

            
954
    private func projectionVariant(
955
        id: String,
956
        title: String,
957
        samples: [Measurement.Point]
958
    ) -> EnergyProjectionVariant? {
959
        guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
960

            
961
        let observedDuration = lastSample.timestamp.timeIntervalSince(firstSample.timestamp)
962
        guard observedDuration > 0 else { return nil }
963

            
964
        let accumulatedEnergy = lastSample.value - firstSample.value
965
        guard accumulatedEnergy >= 0, accumulatedEnergy.isFinite else { return nil }
966

            
967
        let averagePower = accumulatedEnergy / (observedDuration / 3600)
968
        guard averagePower.isFinite else { return nil }
969

            
970
        return EnergyProjectionVariant(
971
            id: id,
972
            title: title,
973
            observedDuration: observedDuration,
974
            accumulatedEnergy: accumulatedEnergy,
975
            sampleCount: samples.count,
976
            averagePower: averagePower
977
        )
978
    }
Bogdan Timofte authored 2 months ago
979
}
Bogdan Timofte authored a month ago
980

            
981
extension Measurements.Measurement.Point: TimeSeriesChartPointRepresentable {
982
    var chartPointID: Int {
983
        id
984
    }
985

            
986
    var chartTimestamp: Date {
987
        timestamp
988
    }
989

            
990
    var chartValue: Double {
991
        value
992
    }
993

            
994
    var chartPointKind: TimeSeriesChartPointKind {
995
        switch kind {
996
        case .sample:
997
            return .sample
998
        case .discontinuity:
999
            return .discontinuity
1000
        }
1001
    }
1002
}