USB-Meter / USB Meter / Model / Measurements.swift
Newer Older
986 lines | 35.231kb
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 a month 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 a month 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 a month 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 a month 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 a month 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 a month 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

            
475
        struct Anchor {
476
            let percent: Double
477
            let energyWh: Double
478
            let timestamp: Date
479
            let isCheckpoint: Bool
480
        }
481

            
Bogdan Timofte authored a month ago
482
        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
483
            var candidates: [Double] = []
484

            
485
            for lowerIndex in anchors.indices {
486
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
487
                    let lower = anchors[lowerIndex]
488
                    let upper = anchors[upperIndex]
489
                    let percentDelta = upper.percent - lower.percent
490
                    let energyDelta = upper.energyWh - lower.energyWh
491

            
492
                    guard percentDelta >= 3, energyDelta > 0.01 else {
493
                        continue
494
                    }
495

            
496
                    let capacityWh = energyDelta / (percentDelta / 100)
497
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
498
                        continue
499
                    }
500

            
501
                    candidates.append(capacityWh)
502
                }
503
            }
504

            
505
            return candidates
506
        }
507

            
508
        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
509
            let candidates = anchorCapacityCandidates(from: anchors)
510
            guard !candidates.isEmpty else {
511
                return nil
512
            }
513

            
514
            let sortedCandidates = candidates.sorted()
515
            return sortedCandidates[sortedCandidates.count / 2]
516
        }
517

            
Bogdan Timofte authored a month ago
518
        var anchors: [Anchor] = []
519
        if let startBatteryPercent = session.startBatteryPercent,
520
           startBatteryPercent >= 0 {
521
            anchors.append(
522
                Anchor(
523
                    percent: startBatteryPercent,
524
                    energyWh: 0,
525
                    timestamp: session.effectiveTrimStart,
526
                    isCheckpoint: false
527
                )
528
            )
529
        }
530

            
531
        anchors.append(
532
            contentsOf: session.checkpoints
533
                .filter { $0.batteryPercent >= 0 }
534
                .sorted { lhs, rhs in
535
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
536
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
537
                    }
538
                    return lhs.timestamp < rhs.timestamp
539
                }
540
                .map {
541
                    Anchor(
542
                        percent: $0.batteryPercent,
543
                        energyWh: $0.measuredEnergyWh,
544
                        timestamp: $0.timestamp,
545
                        isCheckpoint: true
546
                    )
547
                }
548
        )
549

            
Bogdan Timofte authored a month ago
550
        let sortedAnchors = anchors.sorted { lhs, rhs in
551
            if lhs.energyWh != rhs.energyWh {
552
                return lhs.energyWh < rhs.energyWh
553
            }
554
            return lhs.timestamp < rhs.timestamp
555
        }
556

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

            
559
        let effectiveEnergyWh = effectiveBatteryEnergyWh(for: sample, in: session)
Bogdan Timofte authored a month ago
560
        let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
561
        let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
562
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
Bogdan Timofte authored a month ago
563

            
564
        if let lowerAnchor,
565
           let upperAnchor,
566
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
567
            let interpolationProgress = min(
568
                max(
569
                    (effectiveEnergyWh - lowerAnchor.energyWh) /
570
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
571
                    0
572
                ),
573
                1
574
            )
575
            return min(
576
                max(
577
                    lowerAnchor.percent +
578
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
579
                    0
580
                ),
581
                100
582
            )
583
        }
584

            
Bogdan Timofte authored a month ago
585
        let inferredCapacityWh = estimatedCapacityWh
586
            ?? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors)
587

            
588
        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
Bogdan Timofte authored a month ago
589
            return nil
590
        }
591

            
592
        return BatteryLevelPredictionTuning.predictedPercent(
593
            anchorPercent: anchor.percent,
594
            anchorEnergyWh: anchor.energyWh,
595
            anchorTimestamp: anchor.timestamp,
596
            anchorIsCheckpoint: anchor.isCheckpoint,
597
            effectiveEnergyWh: effectiveEnergyWh,
598
            referenceTimestamp: sample.timestamp,
Bogdan Timofte authored a month ago
599
            estimatedCapacityWh: inferredCapacityWh
Bogdan Timofte authored a month ago
600
        )
601
    }
602

            
603
    private func effectiveBatteryEnergyWh(
604
        for sample: ChargeSessionSampleSummary,
605
        in session: ChargeSessionSummary
606
    ) -> Double {
607
        switch session.chargingTransportMode {
608
        case .wired:
609
            return sample.measuredEnergyWh
610
        case .wireless:
611
            if let factor = session.wirelessEfficiencyFactor, factor > 0 {
612
                return sample.measuredEnergyWh * factor
613
            }
614
            if let sessionEffectiveEnergyWh = session.effectiveBatteryEnergyWh,
615
               session.measuredEnergyWh > 0 {
616
                return sample.measuredEnergyWh * (sessionEffectiveEnergyWh / session.measuredEnergyWh)
617
            }
618
            return sample.measuredEnergyWh
619
        }
620
    }
621

            
Bogdan Timofte authored a month ago
622
    private func mergedRestoredPoints(
623
        restored: [Measurement.Point],
624
        existing: [Measurement.Point],
625
        persistedRangeUpperBound: Date?
626
    ) -> [Measurement.Point] {
627
        var merged = restored
628
        let preservedTail = preservedTailPoints(from: existing, after: persistedRangeUpperBound)
629

            
630
        guard preservedTail.isEmpty == false else {
631
            return merged
632
        }
633

            
634
        if let tailFirst = preservedTail.first,
635
           tailFirst.isSample,
636
           let lastRestoredSample = merged.last(where: \.isSample),
637
           lastRestoredSample.timestamp < tailFirst.timestamp {
638
            merged.append(
639
                Measurement.Point(
640
                    id: merged.count,
641
                    timestamp: tailFirst.timestamp,
642
                    value: merged.last?.value ?? tailFirst.value,
643
                    kind: .discontinuity
644
                )
645
            )
646
        }
647

            
648
        merged.append(contentsOf: preservedTail.enumerated().map { offset, point in
649
            Measurement.Point(
650
                id: merged.count + offset,
651
                timestamp: point.timestamp,
652
                value: point.value,
653
                kind: point.kind
654
            )
655
        })
656
        return merged
657
    }
658

            
659
    private func preservedTailPoints(
660
        from existing: [Measurement.Point],
661
        after persistedRangeUpperBound: Date?
662
    ) -> [Measurement.Point] {
663
        guard let persistedRangeUpperBound else {
664
            return existing
665
        }
666

            
667
        let tail = existing.filter { $0.timestamp > persistedRangeUpperBound }
668
        guard tail.isEmpty == false else {
669
            return []
670
        }
671

            
672
        if let firstSampleIndex = tail.firstIndex(where: \.isSample) {
673
            return Array(tail[firstSampleIndex...])
674
        }
675

            
676
        return []
677
    }
678

            
Bogdan Timofte authored 2 months ago
679
    func resetSeries() {
680
        power.resetSeries()
681
        voltage.resetSeries()
682
        current.resetSeries()
Bogdan Timofte authored 2 months ago
683
        temperature.resetSeries()
Bogdan Timofte authored a month ago
684
        energy.resetSeries()
Bogdan Timofte authored 2 months ago
685
        rssi.resetSeries()
Bogdan Timofte authored a month ago
686
        batteryPercent.resetSeries()
Bogdan Timofte authored 2 months ago
687
        resetPendingAggregation()
Bogdan Timofte authored a month ago
688
        lastEnergyCounterValue = nil
689
        lastEnergyGroupID = nil
690
        accumulatedEnergyValue = 0
Bogdan Timofte authored 2 months ago
691
        self.objectWillChange.send()
692
    }
693

            
694
    func reset() {
695
        resetSeries()
696
    }
Bogdan Timofte authored 2 months ago
697

            
698
    func remove(at idx: Int) {
699
        power.removeValue(index: idx)
700
        voltage.removeValue(index: idx)
701
        current.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
702
        temperature.removeValue(index: idx)
Bogdan Timofte authored a month ago
703
        energy.removeValue(index: idx)
Bogdan Timofte authored 2 months ago
704
        rssi.removeValue(index: idx)
Bogdan Timofte authored a month ago
705
        batteryPercent.removeValue(index: idx)
Bogdan Timofte authored a month ago
706
        realignEnergyBufferStart()
Bogdan Timofte authored 2 months ago
707
        self.objectWillChange.send()
708
    }
709

            
Bogdan Timofte authored 2 months ago
710
    func trim(before cutoff: Date) {
Bogdan Timofte authored 2 months ago
711
        flushPendingValues()
Bogdan Timofte authored 2 months ago
712
        power.trim(before: cutoff)
713
        voltage.trim(before: cutoff)
714
        current.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
715
        temperature.trim(before: cutoff)
Bogdan Timofte authored a month ago
716
        energy.trim(before: cutoff)
Bogdan Timofte authored 2 months ago
717
        rssi.trim(before: cutoff)
Bogdan Timofte authored a month ago
718
        batteryPercent.trim(before: cutoff)
Bogdan Timofte authored a month ago
719
        realignEnergyBufferStart()
Bogdan Timofte authored a month ago
720
        self.objectWillChange.send()
721
    }
722

            
723
    func keepOnly(in range: ClosedRange<Date>) {
724
        flushPendingValues()
725
        power.filterSamples { range.contains($0) }
726
        voltage.filterSamples { range.contains($0) }
727
        current.filterSamples { range.contains($0) }
728
        temperature.filterSamples { range.contains($0) }
729
        energy.filterSamples { range.contains($0) }
730
        rssi.filterSamples { range.contains($0) }
Bogdan Timofte authored a month ago
731
        batteryPercent.filterSamples { range.contains($0) }
Bogdan Timofte authored a month ago
732
        realignEnergyBufferStart()
Bogdan Timofte authored a month ago
733
        self.objectWillChange.send()
734
    }
735

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

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

            
752
        if pendingBucketSecond == valuesTimestamp {
753
            pendingBucketTimestamp = timestamp
Bogdan Timofte authored 2 months ago
754
            itemsInSum += 1
Bogdan Timofte authored 2 months ago
755
            powerSum += power
Bogdan Timofte authored 2 months ago
756
            voltageSum += voltage
757
            currentSum += current
Bogdan Timofte authored a month ago
758
            if let temperature {
759
                temperatureItemsInSum += 1
760
                temperatureSum += temperature
761
            }
Bogdan Timofte authored 2 months ago
762
            rssiSum += rssi
Bogdan Timofte authored 2 months ago
763
            return
Bogdan Timofte authored 2 months ago
764
        }
Bogdan Timofte authored 2 months ago
765

            
766
        flushPendingValues()
767

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

            
784
    func markDiscontinuity(at timestamp: Date) {
785
        flushPendingValues()
786
        power.addDiscontinuity(timestamp: timestamp)
787
        voltage.addDiscontinuity(timestamp: timestamp)
788
        current.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
789
        temperature.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored a month ago
790
        energy.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
791
        rssi.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored a month ago
792
        batteryPercent.addDiscontinuity(timestamp: timestamp)
Bogdan Timofte authored 2 months ago
793
        self.objectWillChange.send()
Bogdan Timofte authored 2 months ago
794
    }
Bogdan Timofte authored 2 months ago
795

            
Bogdan Timofte authored a month ago
796
    func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
797
        if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
798
            let delta = value - lastEnergyCounterValue
799
            if delta > energyResetEpsilon {
800
                accumulatedEnergyValue += delta
Bogdan Timofte authored a month ago
801
            } else if delta < -energyResetEpsilon {
802
                energy.addDiscontinuity(timestamp: timestamp)
803
                accumulatedEnergyValue = 0
Bogdan Timofte authored a month ago
804
            }
805
        }
806

            
807
        energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue)
808
        lastEnergyCounterValue = value
809
        lastEnergyGroupID = groupID
810
        self.objectWillChange.send()
811
    }
812

            
Bogdan Timofte authored 2 months ago
813
    func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
814
        if shouldFlushPendingValues {
815
            flushPendingValues()
816
        }
817
        return power.samplePoints.count
818
    }
819

            
820
    func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
821
        if shouldFlushPendingValues {
822
            flushPendingValues()
823
        }
824

            
825
        let samplePoints = power.samplePoints
826
        guard limit > 0, samplePoints.count > limit else {
827
            return samplePoints
828
        }
829

            
830
        return Array(samplePoints.suffix(limit))
831
    }
832

            
833
    func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
834
        let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues)
835
        guard !points.isEmpty else { return nil }
836

            
837
        let sum = points.reduce(0) { partialResult, point in
838
            partialResult + point.value
839
        }
840

            
841
        return sum / Double(points.count)
842
    }
Bogdan Timofte authored a month ago
843

            
844
    func energyProjectionSnapshot(flushPendingValues shouldFlushPendingValues: Bool = true) -> EnergyProjectionSnapshot? {
845
        if shouldFlushPendingValues {
846
            flushPendingValues()
847
        }
848

            
849
        let samplePoints = energy.samplePoints
850
        guard !samplePoints.isEmpty else { return nil }
851

            
852
        let accumulatedEnergy = samplePoints.last?.value ?? 0
853
        var observedDuration: TimeInterval = 0
854
        var previousSample: Measurement.Point?
855

            
856
        for point in energy.points {
857
            if point.isDiscontinuity {
858
                previousSample = nil
859
                continue
860
            }
861

            
862
            if let previousSample {
863
                observedDuration += max(0, point.timestamp.timeIntervalSince(previousSample.timestamp))
864
            }
865

            
866
            previousSample = point
867
        }
868

            
869
        let averagePower: Double?
870
        if observedDuration > 0, accumulatedEnergy.isFinite {
871
            averagePower = accumulatedEnergy / (observedDuration / 3600)
872
        } else {
873
            averagePower = nil
874
        }
875

            
876
        return EnergyProjectionSnapshot(
877
            accumulatedEnergy: accumulatedEnergy,
878
            observedDuration: observedDuration,
879
            sampleCount: samplePoints.count,
880
            averagePower: averagePower
881
        )
882
    }
883

            
884
    func energyProjectionVariants(flushPendingValues shouldFlushPendingValues: Bool = true) -> [EnergyProjectionVariant] {
885
        if shouldFlushPendingValues {
886
            flushPendingValues()
887
        }
888

            
889
        let contiguousSamples = latestContiguousEnergySamples()
890
        guard contiguousSamples.count >= 2 else { return [] }
891

            
892
        let latestTimestamp = contiguousSamples.last?.timestamp ?? Date()
893
        let windowCandidates: [(duration: TimeInterval, title: String, id: String)] = [
894
            (60, "Last 1 Minute", "last-1m"),
895
            (5 * 60, "Last 5 Minutes", "last-5m"),
896
            (15 * 60, "Last 15 Minutes", "last-15m"),
897
            (60 * 60, "Last 1 Hour", "last-1h"),
898
            (6 * 60 * 60, "Last 6 Hours", "last-6h")
899
        ]
900

            
901
        var variants: [EnergyProjectionVariant] = []
902

            
903
        for candidate in windowCandidates {
904
            let cutoff = latestTimestamp.addingTimeInterval(-candidate.duration)
905
            guard
906
                let startIndex = contiguousSamples.lastIndex(where: { $0.timestamp <= cutoff }),
907
                startIndex < contiguousSamples.count - 1
908
            else {
909
                continue
910
            }
911

            
912
            let relevantSamples = Array(contiguousSamples[startIndex...])
913
            if let variant = projectionVariant(
914
                id: candidate.id,
915
                title: candidate.title,
916
                samples: relevantSamples
917
            ) {
918
                variants.append(variant)
919
            }
920
        }
921

            
922
        if let fullBufferVariant = projectionVariant(
923
            id: "full-buffer",
924
            title: "Whole Buffer",
925
            samples: contiguousSamples
926
        ) {
927
            variants.append(fullBufferVariant)
928
        }
929

            
930
        return variants
931
    }
932

            
933
    private func latestContiguousEnergySamples() -> [Measurement.Point] {
934
        let latestSegment = energy.points.split(whereSeparator: \.isDiscontinuity).last ?? []
935
        return latestSegment.filter(\.isSample)
936
    }
937

            
938
    private func projectionVariant(
939
        id: String,
940
        title: String,
941
        samples: [Measurement.Point]
942
    ) -> EnergyProjectionVariant? {
943
        guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
944

            
945
        let observedDuration = lastSample.timestamp.timeIntervalSince(firstSample.timestamp)
946
        guard observedDuration > 0 else { return nil }
947

            
948
        let accumulatedEnergy = lastSample.value - firstSample.value
949
        guard accumulatedEnergy >= 0, accumulatedEnergy.isFinite else { return nil }
950

            
951
        let averagePower = accumulatedEnergy / (observedDuration / 3600)
952
        guard averagePower.isFinite else { return nil }
953

            
954
        return EnergyProjectionVariant(
955
            id: id,
956
            title: title,
957
            observedDuration: observedDuration,
958
            accumulatedEnergy: accumulatedEnergy,
959
            sampleCount: samples.count,
960
            averagePower: averagePower
961
        )
962
    }
Bogdan Timofte authored 2 months ago
963
}
Bogdan Timofte authored a month ago
964

            
965
extension Measurements.Measurement.Point: TimeSeriesChartPointRepresentable {
966
    var chartPointID: Int {
967
        id
968
    }
969

            
970
    var chartTimestamp: Date {
971
        timestamp
972
    }
973

            
974
    var chartValue: Double {
975
        value
976
    }
977

            
978
    var chartPointKind: TimeSeriesChartPointKind {
979
        switch kind {
980
        case .sample:
981
            return .sample
982
        case .discontinuity:
983
            return .discontinuity
984
        }
985
    }
986
}