@@ -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 |
@@ -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 |
|
@@ -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 |
|
@@ -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 |
@@ -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, |