Showing 5 changed files with 408 additions and 51 deletions
+96 -21
USB Meter/Model/ChargeInsightsModel.swift
@@ -754,12 +754,44 @@ struct ChargeSessionSummary: Identifiable, Hashable {
754 754
     }
755 755
 }
756 756
 
757
+enum BatteryLevelPredictionBasis: Hashable {
758
+    case capacityEstimate
759
+    case checkpointEnergyMap
760
+
761
+    var metricLabel: String {
762
+        switch self {
763
+        case .capacityEstimate:
764
+            return "est. capacity"
765
+        case .checkpointEnergyMap:
766
+            return "energy map"
767
+        }
768
+    }
769
+
770
+    var explanatoryLabel: String {
771
+        switch self {
772
+        case .capacityEstimate:
773
+            return "estimated capacity"
774
+        case .checkpointEnergyMap:
775
+            return "checkpoint energy map"
776
+        }
777
+    }
778
+}
779
+
757 780
 struct BatteryLevelPrediction: Hashable {
758 781
     let predictedPercent: Double
759
-    let estimatedCapacityWh: Double
782
+    let estimatedCapacityWh: Double?
783
+    let basis: BatteryLevelPredictionBasis
760 784
     let anchorPercent: Double
761 785
     let anchorEnergyWh: Double
762 786
     let anchorDescription: String
787
+
788
+    func energyWh(forPercent percent: Double) -> Double? {
789
+        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
790
+            return nil
791
+        }
792
+
793
+        return anchorEnergyWh + ((percent - anchorPercent) / 100) * estimatedCapacityWh
794
+    }
763 795
 }
764 796
 
765 797
 enum BatteryLevelPredictionTuning {
@@ -1456,10 +1488,6 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1456 1488
             ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1457 1489
             ?? estimatedBatteryCapacityWh
1458 1490
 
1459
-        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1460
-            return nil
1461
-        }
1462
-
1463 1491
         let effectiveEnergyWh = effectiveEnergyWhOverride
1464 1492
             ?? session.effectiveBatteryEnergyWh
1465 1493
             ?? session.measuredEnergyWh
@@ -1472,6 +1500,42 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1472 1500
             let isCheckpoint: Bool
1473 1501
         }
1474 1502
 
1503
+        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
1504
+            var candidates: [Double] = []
1505
+
1506
+            for lowerIndex in anchors.indices {
1507
+                for upperIndex in anchors.indices where upperIndex > lowerIndex {
1508
+                    let lower = anchors[lowerIndex]
1509
+                    let upper = anchors[upperIndex]
1510
+                    let percentDelta = upper.percent - lower.percent
1511
+                    let energyDelta = upper.energyWh - lower.energyWh
1512
+
1513
+                    guard percentDelta >= 3, energyDelta > 0.01 else {
1514
+                        continue
1515
+                    }
1516
+
1517
+                    let capacityWh = energyDelta / (percentDelta / 100)
1518
+                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
1519
+                        continue
1520
+                    }
1521
+
1522
+                    candidates.append(capacityWh)
1523
+                }
1524
+            }
1525
+
1526
+            return candidates
1527
+        }
1528
+
1529
+        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
1530
+            let candidates = anchorCapacityCandidates(from: anchors)
1531
+            guard !candidates.isEmpty else {
1532
+                return nil
1533
+            }
1534
+
1535
+            let sortedCandidates = candidates.sorted()
1536
+            return sortedCandidates[sortedCandidates.count / 2]
1537
+        }
1538
+
1475 1539
         var anchors: [Anchor] = []
1476 1540
 
1477 1541
         if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
@@ -1488,17 +1552,9 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1488 1552
 
1489 1553
         anchors.append(
1490 1554
             contentsOf: session.checkpoints
1491
-                .sorted { lhs, rhs in
1492
-                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1493
-                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1494
-                    }
1495
-                    return lhs.timestamp < rhs.timestamp
1496
-                }
1497
-                .filter { checkpoint in
1498
-                    checkpoint.batteryPercent >= 0
1499
-                }
1555
+                .filter { $0.batteryPercent >= 0 }
1500 1556
                 .map { checkpoint in
1501
-                    return Anchor(
1557
+                    Anchor(
1502 1558
                         percent: checkpoint.batteryPercent,
1503 1559
                         energyWh: checkpoint.measuredEnergyWh,
1504 1560
                         timestamp: checkpoint.timestamp,
@@ -1508,13 +1564,27 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1508 1564
                 }
1509 1565
         )
1510 1566
 
1511
-        guard !anchors.isEmpty else {
1567
+        let sortedAnchors = anchors.sorted { lhs, rhs in
1568
+            if lhs.energyWh != rhs.energyWh {
1569
+                return lhs.energyWh < rhs.energyWh
1570
+            }
1571
+            return lhs.timestamp < rhs.timestamp
1572
+        }
1573
+
1574
+        guard !sortedAnchors.isEmpty else {
1512 1575
             return nil
1513 1576
         }
1514 1577
 
1515
-        let lowerAnchor = anchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
1516
-        let upperAnchor = anchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
1517
-        let anchor = lowerAnchor ?? upperAnchor ?? anchors.first!
1578
+        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
1579
+        let inferredCapacityWh = estimatedCapacityWh
1580
+            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
1581
+        let basis: BatteryLevelPredictionBasis = estimatedCapacityWh == nil
1582
+            ? .checkpointEnergyMap
1583
+            : .capacityEstimate
1584
+
1585
+        let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
1586
+        let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
1587
+        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
1518 1588
 
1519 1589
         let predictedPercent: Double
1520 1590
         if let lowerAnchor,
@@ -1537,6 +1607,10 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1537 1607
                 100
1538 1608
             )
1539 1609
         } else {
1610
+            guard let inferredCapacityWh, inferredCapacityWh > 0 else {
1611
+                return nil
1612
+            }
1613
+
1540 1614
             predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1541 1615
                 anchorPercent: anchor.percent,
1542 1616
                 anchorEnergyWh: anchor.energyWh,
@@ -1544,13 +1618,14 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1544 1618
                 anchorIsCheckpoint: anchor.isCheckpoint,
1545 1619
                 effectiveEnergyWh: effectiveEnergyWh,
1546 1620
                 referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
1547
-                estimatedCapacityWh: estimatedCapacityWh
1621
+                estimatedCapacityWh: inferredCapacityWh
1548 1622
             )
1549 1623
         }
1550 1624
 
1551 1625
         return BatteryLevelPrediction(
1552 1626
             predictedPercent: predictedPercent,
1553
-            estimatedCapacityWh: estimatedCapacityWh,
1627
+            estimatedCapacityWh: inferredCapacityWh,
1628
+            basis: basis,
1554 1629
             anchorPercent: anchor.percent,
1555 1630
             anchorEnergyWh: anchor.energyWh,
1556 1631
             anchorDescription: anchor.description
+61 -8
USB Meter/Model/ChargeInsightsStore.swift
@@ -1850,13 +1850,17 @@ final class ChargeInsightsStore {
1850 1850
     ) -> Double? {
1851 1851
         guard
1852 1852
             let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1853
-            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1854
-            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1855
-            estimatedCapacityWh > 0
1853
+            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1856 1854
         else {
1857 1855
             return nil
1858 1856
         }
1859 1857
 
1858
+        let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(
1859
+            for: session,
1860
+            chargedDevice: chargedDevice
1861
+        )
1862
+        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1863
+        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
1860 1864
         let measuredEnergyWh = effectiveEnergyWhOverride
1861 1865
             ?? effectiveBatteryEnergyWh(
1862 1866
                 rawMeasuredEnergyWh: doubleValue(session, key: "measuredEnergyWh"),
@@ -1871,6 +1875,42 @@ final class ChargeInsightsStore {
1871 1875
             let isCheckpoint: Bool
1872 1876
         }
1873 1877
 
1878
+        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
1879
+            var candidates: [Double] = []
1880
+
1881
+            for lowerIndex in anchors.indices {
1882
+                for upperIndex in anchors.indices where upperIndex > lowerIndex {
1883
+                    let lower = anchors[lowerIndex]
1884
+                    let upper = anchors[upperIndex]
1885
+                    let percentDelta = upper.percent - lower.percent
1886
+                    let energyDelta = upper.energyWh - lower.energyWh
1887
+
1888
+                    guard percentDelta >= 3, energyDelta > 0.01 else {
1889
+                        continue
1890
+                    }
1891
+
1892
+                    let capacityWh = energyDelta / (percentDelta / 100)
1893
+                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
1894
+                        continue
1895
+                    }
1896
+
1897
+                    candidates.append(capacityWh)
1898
+                }
1899
+            }
1900
+
1901
+            return candidates
1902
+        }
1903
+
1904
+        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
1905
+            let candidates = anchorCapacityCandidates(from: anchors)
1906
+            guard !candidates.isEmpty else {
1907
+                return nil
1908
+            }
1909
+
1910
+            let sortedCandidates = candidates.sorted()
1911
+            return sortedCandidates[sortedCandidates.count / 2]
1912
+        }
1913
+
1874 1914
         var anchors: [Anchor] = []
1875 1915
         if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1876 1916
            startBatteryPercent >= 0 {
@@ -1905,13 +1945,22 @@ final class ChargeInsightsStore {
1905 1945
             }
1906 1946
         anchors.append(contentsOf: checkpointAnchors)
1907 1947
 
1908
-        guard !anchors.isEmpty else {
1948
+        let sortedAnchors = anchors.sorted { lhs, rhs in
1949
+            if lhs.energyWh != rhs.energyWh {
1950
+                return lhs.energyWh < rhs.energyWh
1951
+            }
1952
+            return lhs.timestamp < rhs.timestamp
1953
+        }
1954
+
1955
+        guard !sortedAnchors.isEmpty else {
1909 1956
             return optionalDoubleValue(session, key: "endBatteryPercent")
1910 1957
         }
1911 1958
 
1912
-        let lowerAnchor = anchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
1913
-        let upperAnchor = anchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
1914
-        let anchor = lowerAnchor ?? upperAnchor ?? anchors.first!
1959
+        let inferredCapacityWh = estimatedCapacityWh
1960
+            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
1961
+        let lowerAnchor = sortedAnchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
1962
+        let upperAnchor = sortedAnchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
1963
+        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
1915 1964
 
1916 1965
         if let lowerAnchor,
1917 1966
            let upperAnchor,
@@ -1934,6 +1983,10 @@ final class ChargeInsightsStore {
1934 1983
             )
1935 1984
         }
1936 1985
 
1986
+        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
1987
+            return nil
1988
+        }
1989
+
1937 1990
         return BatteryLevelPredictionTuning.predictedPercent(
1938 1991
             anchorPercent: anchor.percent,
1939 1992
             anchorEnergyWh: anchor.energyWh,
@@ -1943,7 +1996,7 @@ final class ChargeInsightsStore {
1943 1996
             referenceTimestamp: referenceTimestamp
1944 1997
                 ?? dateValue(session, key: "lastObservedAt")
1945 1998
                 ?? anchor.timestamp,
1946
-            estimatedCapacityWh: estimatedCapacityWh
1999
+            estimatedCapacityWh: inferredCapacityWh
1947 2000
         )
1948 2001
     }
1949 2002
 
+52 -6
USB Meter/Model/Measurements.swift
@@ -479,6 +479,42 @@ class Measurements : ObservableObject {
479 479
             let isCheckpoint: Bool
480 480
         }
481 481
 
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
+
482 518
         var anchors: [Anchor] = []
483 519
         if let startBatteryPercent = session.startBatteryPercent,
484 520
            startBatteryPercent >= 0 {
@@ -511,12 +547,19 @@ class Measurements : ObservableObject {
511 547
                 }
512 548
         )
513 549
 
514
-        guard !anchors.isEmpty else { return nil }
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 }
515 558
 
516 559
         let effectiveEnergyWh = effectiveBatteryEnergyWh(for: sample, in: session)
517
-        let lowerAnchor = anchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
518
-        let upperAnchor = anchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
519
-        let anchor = lowerAnchor ?? upperAnchor ?? anchors.first!
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!
520 563
 
521 564
         if let lowerAnchor,
522 565
            let upperAnchor,
@@ -539,7 +582,10 @@ class Measurements : ObservableObject {
539 582
             )
540 583
         }
541 584
 
542
-        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
585
+        let inferredCapacityWh = estimatedCapacityWh
586
+            ?? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors)
587
+
588
+        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
543 589
             return nil
544 590
         }
545 591
 
@@ -550,7 +596,7 @@ class Measurements : ObservableObject {
550 596
             anchorIsCheckpoint: anchor.isCheckpoint,
551 597
             effectiveEnergyWh: effectiveEnergyWh,
552 598
             referenceTimestamp: sample.timestamp,
553
-            estimatedCapacityWh: estimatedCapacityWh
599
+            estimatedCapacityWh: inferredCapacityWh
554 600
         )
555 601
     }
556 602
 
+114 -16
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -614,9 +614,7 @@ struct ChargeSessionDetailView: View {
614 614
                             HStack(alignment: .top, spacing: 12) {
615 615
                                 overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
616 616
                             }
617
-                            Text(
618
-                                "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
619
-                            )
617
+                            Text(batteryPredictionExplanation(batteryPrediction))
620 618
                             .font(.caption2)
621 619
                             .foregroundColor(.secondary)
622 620
                         }
@@ -717,8 +715,8 @@ struct ChargeSessionDetailView: View {
717 715
             : nil
718 716
         let etaToFull = etaText(
719 717
             rateWhPerSec: rateWhPerSec,
720
-            remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
721
-            isRelevant: percent < 98
718
+            remainingWh: max((prediction.energyWh(forPercent: 100) ?? displayedEnergyWh) - displayedEnergyWh, 0),
719
+            isRelevant: percent < 98 && prediction.estimatedCapacityWh != nil
722 720
         )
723 721
         let etaToTarget = etaToTargetText(
724 722
             session: session,
@@ -741,14 +739,16 @@ struct ChargeSessionDetailView: View {
741 739
 
742 740
                 Spacer()
743 741
 
744
-                VStack(alignment: .trailing, spacing: 2) {
745
-                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
746
-                        .font(.callout.weight(.bold))
747
-                        .foregroundColor(.orange)
748
-                        .monospacedDigit()
749
-                    Text("est. capacity")
750
-                        .font(.caption2)
751
-                        .foregroundColor(.secondary)
742
+                if let estimatedCapacityWh = prediction.estimatedCapacityWh {
743
+                    VStack(alignment: .trailing, spacing: 2) {
744
+                        Text("\(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
745
+                            .font(.callout.weight(.bold))
746
+                            .foregroundColor(.orange)
747
+                            .monospacedDigit()
748
+                        Text(prediction.basis.metricLabel)
749
+                            .font(.caption2)
750
+                            .foregroundColor(.secondary)
751
+                    }
752 752
                 }
753 753
             }
754 754
 
@@ -1804,7 +1804,9 @@ struct ChargeSessionDetailView: View {
1804 1804
         guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1805 1805
             return nil
1806 1806
         }
1807
-        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1807
+        guard let targetEnergyWh = prediction.energyWh(forPercent: target) else {
1808
+            return nil
1809
+        }
1808 1810
         return etaText(
1809 1811
             rateWhPerSec: rateWhPerSec,
1810 1812
             remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
@@ -1812,6 +1814,14 @@ struct ChargeSessionDetailView: View {
1812 1814
         )
1813 1815
     }
1814 1816
 
1817
+    private func batteryPredictionExplanation(_ prediction: BatteryLevelPrediction) -> String {
1818
+        let anchor = "Anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%"
1819
+        guard let estimatedCapacityWh = prediction.estimatedCapacityWh else {
1820
+            return "\(anchor)."
1821
+        }
1822
+        return "\(anchor) using \(estimatedCapacityWh.format(decimalDigits: 2)) Wh \(prediction.basis.explanatoryLabel)."
1823
+    }
1824
+
1815 1825
     private func formatETA(_ seconds: TimeInterval) -> String {
1816 1826
         let totalMinutes = Int(seconds / 60)
1817 1827
         if totalMinutes < 60 { return "\(totalMinutes)m" }
@@ -2028,7 +2038,8 @@ struct ChargeSessionChartCardView: View {
2028 2038
                     handler: {
2029 2039
                         onSetTrim(nil, nil)
2030 2040
                     }
2031
-                )
2041
+                ),
2042
+                exportAction: sessionCSVExportAction
2032 2043
             )
2033 2044
         case .closed:
2034 2045
             return MeasurementChartRangeSelectorConfiguration(
@@ -2050,11 +2061,98 @@ struct ChargeSessionChartCardView: View {
2050 2061
                     handler: {
2051 2062
                         onSetTrim(nil, nil)
2052 2063
                     }
2053
-                )
2064
+                ),
2065
+                exportAction: sessionCSVExportAction
2054 2066
             )
2055 2067
         }
2056 2068
     }
2057 2069
 
2070
+    private var sessionCSVExportAction: MeasurementChartExportAction {
2071
+        MeasurementChartExportAction(
2072
+            title: "Export CSV",
2073
+            shortTitle: "CSV",
2074
+            systemName: "square.and.arrow.up",
2075
+            tone: .reversible,
2076
+            fileName: sessionCSVFileName,
2077
+            content: sessionCSVContent
2078
+        )
2079
+    }
2080
+
2081
+    private func sessionCSVFileName(for range: ClosedRange<Date>) -> String {
2082
+        let formatter = DateFormatter()
2083
+        formatter.locale = Locale(identifier: "en_US_POSIX")
2084
+        formatter.timeZone = .current
2085
+        formatter.dateFormat = "yyyyMMdd-HHmmss"
2086
+
2087
+        return [
2088
+            "charge-session",
2089
+            formatter.string(from: range.lowerBound),
2090
+            formatter.string(from: range.upperBound)
2091
+        ].joined(separator: "-")
2092
+    }
2093
+
2094
+    private func sessionCSVContent(for range: ClosedRange<Date>) -> String {
2095
+        let samples = session.aggregatedSamples
2096
+            .filter { range.contains($0.timestamp) }
2097
+            .sorted { lhs, rhs in
2098
+                if lhs.bucketIndex != rhs.bucketIndex {
2099
+                    return lhs.bucketIndex < rhs.bucketIndex
2100
+                }
2101
+                return lhs.timestamp < rhs.timestamp
2102
+            }
2103
+
2104
+        let timestampFormatter = ISO8601DateFormatter()
2105
+        timestampFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
2106
+
2107
+        var rows: [[String]] = [
2108
+            [
2109
+                "Timestamp",
2110
+                "Elapsed Seconds",
2111
+                "Voltage (V)",
2112
+                "Current (A)",
2113
+                "Power (W)",
2114
+                "Session Energy (Wh)",
2115
+                "Interval Energy (Wh)",
2116
+                "Battery (%)",
2117
+                "Sample Count"
2118
+            ]
2119
+        ]
2120
+
2121
+        let intervalEnergyBaseline = samples.first?.measuredEnergyWh ?? 0
2122
+        for sample in samples {
2123
+            rows.append([
2124
+                timestampFormatter.string(from: sample.timestamp),
2125
+                formattedCSVNumber(sample.timestamp.timeIntervalSince(session.startedAt), fractionDigits: 3),
2126
+                formattedCSVNumber(sample.averageVoltageVolts, fractionDigits: 6),
2127
+                formattedCSVNumber(sample.averageCurrentAmps, fractionDigits: 6),
2128
+                formattedCSVNumber(sample.averagePowerWatts, fractionDigits: 6),
2129
+                formattedCSVNumber(sample.measuredEnergyWh, fractionDigits: 6),
2130
+                formattedCSVNumber(max(sample.measuredEnergyWh - intervalEnergyBaseline, 0), fractionDigits: 6),
2131
+                formattedCSVNumber(sample.estimatedBatteryPercent, fractionDigits: 3),
2132
+                "\(sample.sampleCount)"
2133
+            ])
2134
+        }
2135
+
2136
+        return rows
2137
+            .map { row in row.map(escapedCSVField).joined(separator: ",") }
2138
+            .joined(separator: "\n")
2139
+    }
2140
+
2141
+    private func formattedCSVNumber(_ value: Double?, fractionDigits: Int) -> String {
2142
+        guard let value, value.isFinite else { return "" }
2143
+        return String(
2144
+            format: "%.\(fractionDigits)f",
2145
+            locale: Locale(identifier: "en_US_POSIX"),
2146
+            value
2147
+        )
2148
+    }
2149
+
2150
+    private func escapedCSVField(_ field: String) -> String {
2151
+        let mustQuote = field.contains(",") || field.contains("\"") || field.contains("\n")
2152
+        guard mustQuote else { return field }
2153
+        return "\"\(field.replacingOccurrences(of: "\"", with: "\"\""))\""
2154
+    }
2155
+
2058 2156
     private func restoreStoredMeasurementsIfNeeded() {
2059 2157
         guard monitoringMeter == nil || session.status.isOpen == false else {
2060 2158
             return
+85 -0
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -7,6 +7,7 @@
7 7
 //
8 8
 
9 9
 import SwiftUI
10
+import UniformTypeIdentifiers
10 11
 
11 12
 private enum PresentTrackingMode: CaseIterable, Hashable {
12 13
     case keepDuration
@@ -74,10 +75,66 @@ struct MeasurementChartResetAction {
74 75
     }
75 76
 }
76 77
 
78
+struct MeasurementChartExportAction {
79
+    let title: String
80
+    let shortTitle: String?
81
+    let systemName: String
82
+    let tone: MeasurementChartSelectorActionTone
83
+    let fileName: (ClosedRange<Date>) -> String
84
+    let content: (ClosedRange<Date>) -> String
85
+
86
+    init(
87
+        title: String,
88
+        shortTitle: String? = nil,
89
+        systemName: String,
90
+        tone: MeasurementChartSelectorActionTone,
91
+        fileName: @escaping (ClosedRange<Date>) -> String,
92
+        content: @escaping (ClosedRange<Date>) -> String
93
+    ) {
94
+        self.title = title
95
+        self.shortTitle = shortTitle
96
+        self.systemName = systemName
97
+        self.tone = tone
98
+        self.fileName = fileName
99
+        self.content = content
100
+    }
101
+}
102
+
77 103
 struct MeasurementChartRangeSelectorConfiguration {
78 104
     let keepAction: MeasurementChartSelectionAction
79 105
     let removeAction: MeasurementChartSelectionAction?
80 106
     let resetAction: MeasurementChartResetAction
107
+    let exportAction: MeasurementChartExportAction?
108
+
109
+    init(
110
+        keepAction: MeasurementChartSelectionAction,
111
+        removeAction: MeasurementChartSelectionAction?,
112
+        resetAction: MeasurementChartResetAction,
113
+        exportAction: MeasurementChartExportAction? = nil
114
+    ) {
115
+        self.keepAction = keepAction
116
+        self.removeAction = removeAction
117
+        self.resetAction = resetAction
118
+        self.exportAction = exportAction
119
+    }
120
+}
121
+
122
+private struct MeasurementChartCSVDocument: FileDocument {
123
+    static var readableContentTypes: [UTType] { [.commaSeparatedText] }
124
+
125
+    var content: String
126
+
127
+    init(content: String) {
128
+        self.content = content
129
+    }
130
+
131
+    init(configuration: ReadConfiguration) throws {
132
+        content = ""
133
+    }
134
+
135
+    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
136
+        FileWrapper(regularFileWithContents: Data(content.utf8))
137
+    }
81 138
 }
82 139
 
83 140
 struct MeasurementChartView: View {
@@ -2402,6 +2459,9 @@ private struct TimeRangeSelectorView: View {
2402 2459
     @Binding var presentTrackingMode: PresentTrackingMode
2403 2460
     @State private var dragState: DragState?
2404 2461
     @State private var showResetConfirmation: Bool = false
2462
+    @State private var isShowingCSVExporter: Bool = false
2463
+    @State private var exportFileName: String = "charge-session"
2464
+    @State private var exportDocument = MeasurementChartCSVDocument(content: "")
2405 2465
 
2406 2466
     private var totalSpan: TimeInterval {
2407 2467
         availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
@@ -2464,6 +2524,18 @@ private struct TimeRangeSelectorView: View {
2464 2524
 
2465 2525
                 Spacer(minLength: 0)
2466 2526
 
2527
+                if let exportAction = configuration.exportAction {
2528
+                    iconButton(
2529
+                        systemName: exportAction.systemName,
2530
+                        tone: exportAction.tone,
2531
+                        action: {
2532
+                            beginCSVExport(exportAction)
2533
+                        }
2534
+                    )
2535
+                    .help(exportAction.title)
2536
+                    .accessibilityLabel(exportAction.title)
2537
+                }
2538
+
2467 2539
                 // Trim/Save actions
2468 2540
                 if !coversFullRange {
2469 2541
                     iconButton(
@@ -2583,6 +2655,12 @@ private struct TimeRangeSelectorView: View {
2583 2655
 
2584 2656
             xAxisLabelsView
2585 2657
         }
2658
+        .fileExporter(
2659
+            isPresented: $isShowingCSVExporter,
2660
+            document: exportDocument,
2661
+            contentType: .commaSeparatedText,
2662
+            defaultFilename: exportFileName
2663
+        ) { _ in }
2586 2664
     }
2587 2665
 
2588 2666
     private func handleView(height: CGFloat) -> some View {
@@ -2640,6 +2718,13 @@ private struct TimeRangeSelectorView: View {
2640 2718
         .accessibilityHint("Toggles how the interval follows the present")
2641 2719
     }
2642 2720
 
2721
+    private func beginCSVExport(_ action: MeasurementChartExportAction) {
2722
+        let exportRange = currentRange
2723
+        exportFileName = action.fileName(exportRange)
2724
+        exportDocument = MeasurementChartCSVDocument(content: action.content(exportRange))
2725
+        isShowingCSVExporter = true
2726
+    }
2727
+
2643 2728
     private func actionButton(
2644 2729
         title: String,
2645 2730
         shortTitle: String? = nil,