From 05e8e7181cbbf5a6e93c57f3ed8cae5c7484ad59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Sch=C3=A4dlich?= Date: Wed, 8 Jul 2026 22:45:44 +0200 Subject: [PATCH] Fix battery estimate anchoring on a stale, multi-cycle-old peak (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WHOOP MG that tops up short of full every day never trips the near-full anchor (rule 1), so the discharge fit fell back to scanning the SoC buffer's global max — which, across many partial-top-up cycles, can sit several days back. The endpoints-only fit then nets across every undetected intermediate top-up in between, diluting the actual recent drain rate into something far flatter than reality (reported "9% = ~3 days" when the true recent drain was closer to 6h left). Bound the max search to at most the last two charge-step cycles so it stays anchored to how the strap is being used right now. Buffers with 0 or 1 charge-steps search from the start exactly as before, so #8 and #919 are unaffected — verified against all existing fixtures plus a new multi-cycle regression test. Ported the same fix to the Kotlin twin to keep both platforms behaviour-identical. Co-Authored-By: Claude Sonnet 5 --- .../StrandAnalytics/BatteryEstimator.swift | 18 ++++++++++--- .../BatteryEstimatorTests.swift | 22 +++++++++++++++ .../com/noop/analytics/BatteryEstimator.kt | 17 +++++++++--- .../noop/analytics/BatteryEstimatorTest.kt | 27 +++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/BatteryEstimator.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/BatteryEstimator.swift index 8491f7292f..c62e5d1f76 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/BatteryEstimator.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/BatteryEstimator.swift @@ -128,15 +128,27 @@ public enum BatteryEstimator { } // 1b. #919: with no near-full (>=90%) charge to anchor on - common on a 12-day WHOOP 5.0 that rarely - // tops past 90% between charges - anchor at the buffer's HIGHEST SoC (the top of the most recent + // tops past 90% between charges - anchor at the HIGHEST SoC (the top of the most recent // discharge) rather than the oldest reading, which can sit below a later charge and net to a // NON-discharge window (drop < 0 -> stuck on `rated`). The max is >= every later reading, so the // window can only discharge; the >=minDropPct gate still rejects a flat run. Preserves #8: its // buffer starts at the max, so this stays index 0 there. Last occurrence of the max (>=), for // parity with the Kotlin twin. + // #99: that max search used to scan the WHOLE buffer, so a strap that tops up short of full + // every day (never tripping rule 1) could anchor on a peak several CYCLES back, netting the fit + // across multiple undetected intermediate top-ups and flattening the slope into something that + // no longer reflects how the strap is actually draining "today" - e.g. reporting days left off + // a week-old segment while it's actually burning through a charge in hours. Bounding the search + // to at most the last two charge-step cycles keeps it anchored to the CURRENT usage pattern; a + // buffer with 0 or 1 charge-steps searches from the start exactly as before (#8, #919 unaffected). if startIdx == 0 { - var maxIdx = 0 - for i in sorted.indices where sorted[i].soc >= sorted[maxIdx].soc { maxIdx = i } + var chargeStepIdxs: [Int] = [] + for i in 1.. sorted[i - 1].soc + chargeStepPct { + chargeStepIdxs.append(i) + } + let searchFloor = chargeStepIdxs.count >= 2 ? chargeStepIdxs[chargeStepIdxs.count - 2] : 0 + var maxIdx = searchFloor + for i in searchFloor..= sorted[maxIdx].soc { maxIdx = i } startIdx = maxIdx } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTests.swift index 17b7a06652..e8f5b96531 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTests.swift @@ -64,6 +64,28 @@ final class BatteryEstimatorTests: XCTestCase { XCTAssertEqual(e.remainingHours, 44, accuracy: 1e-6) // 52->44 = 8pp over 8h = 1 %/h; 44 / 1 } + func testOldPeakDoesNotFlattenARecentFastDrain() { + // #99: a WHOOP MG that tops up short of full every day (never trips the near-full rule), reported + // "9% = ~3 days" when the real drain that day was ~1.5 %/h. Ten daily cycles of 30pp/20h (1.5 %/h), + // each day's peak a little lower than the last (so the GLOBAL max sits all the way back on day + // one), then today: a quick top-up to 25% and a 4h tail down to 9%. The old whole-buffer max scan + // anchored on day one's peak and netted the fit across nine unseen intermediate top-ups, diluting + // 1.5 %/h into ~0.3 %/h and reporting ~29h. Bounding the search to the last two cycles instead + // anchors on YESTERDAY's peak (34%), fitting today's actual 1.5 %/h and landing on the honest 6h. + var samples: [(ts: Int, soc: Double)] = [] + var t = 0 + for peak in stride(from: 70.0, through: 34.0, by: -4.0) { + samples.append((t, peak)); t += 20 * h + samples.append((t, peak - 30)); t += 1 * h + } + samples.append((t, 25)); t += 4 * h + samples.append((t, 9)) + let e = BatteryEstimator.estimate(samples: samples, ratedHours: BatteryEstimator.ratedLifeHoursWhoop5)! + XCTAssertEqual(e.source, .measured) + XCTAssertEqual(e.currentSoc, 9, accuracy: 1e-6) + XCTAssertEqual(e.remainingHours, 6, accuracy: 1e-6) // 9 / 1.5 %/h, yesterday's real slope + } + func testNearFullChargeStillResetsTheRun() { // The guard must NOT change a genuine near-full charge: discharge 100->20, charge back to 95 (>=90, // near-full), then 95->85 over 5h is 2 %/h. The run still resets on the near-full charge, source diff --git a/android/app/src/main/java/com/noop/analytics/BatteryEstimator.kt b/android/app/src/main/java/com/noop/analytics/BatteryEstimator.kt index d128b83075..22bdcdb9d3 100644 --- a/android/app/src/main/java/com/noop/analytics/BatteryEstimator.kt +++ b/android/app/src/main/java/com/noop/analytics/BatteryEstimator.kt @@ -125,14 +125,25 @@ object BatteryEstimator { } // 1b. #919: with no near-full (>=90%) charge to anchor on - common on a 12-day WHOOP 5.0 that rarely - // tops past 90% between charges - anchor at the buffer's HIGHEST SoC (the top of the most recent + // tops past 90% between charges - anchor at the HIGHEST SoC (the top of the most recent // discharge) rather than the oldest reading, which can sit below a later charge and net to a // NON-discharge window (drop < 0 -> stuck on rated). The max is >= every later reading, so the // window can only discharge; the >=minDropPct gate still rejects a flat run. Preserves #8: its // buffer starts at the max, so this stays index 0 there. Last occurrence of the max (>=). + // #99: that max search used to scan the WHOLE buffer, so a strap that tops up short of full + // every day (never tripping rule 1) could anchor on a peak several CYCLES back, netting the fit + // across multiple undetected intermediate top-ups and flattening the slope into something that + // no longer reflects how the strap is actually draining "today". Bounding the search to at most + // the last two charge-step cycles keeps it anchored to the CURRENT usage pattern; a buffer with + // 0 or 1 charge-steps searches from the start exactly as before (#8, #919 unaffected). if (startIdx == 0) { - var maxIdx = 0 - for (i in sorted.indices) if (sorted[i].second >= sorted[maxIdx].second) maxIdx = i + val chargeStepIdxs = mutableListOf() + for (i in 1 until sorted.size) { + if (sorted[i].second > sorted[i - 1].second + chargeStepPct) chargeStepIdxs.add(i) + } + val searchFloor = if (chargeStepIdxs.size >= 2) chargeStepIdxs[chargeStepIdxs.size - 2] else 0 + var maxIdx = searchFloor + for (i in searchFloor until sorted.size) if (sorted[i].second >= sorted[maxIdx].second) maxIdx = i startIdx = maxIdx } diff --git a/android/app/src/test/java/com/noop/analytics/BatteryEstimatorTest.kt b/android/app/src/test/java/com/noop/analytics/BatteryEstimatorTest.kt index ddc74790d5..b993185460 100644 --- a/android/app/src/test/java/com/noop/analytics/BatteryEstimatorTest.kt +++ b/android/app/src/test/java/com/noop/analytics/BatteryEstimatorTest.kt @@ -65,6 +65,33 @@ class BatteryEstimatorTest { assertEquals(44.0, e.remainingHours, 1e-6) // 52->44 = 8pp over 8h = 1 %/h; 44 / 1 } + @Test fun oldPeakDoesNotFlattenARecentFastDrain() { + // #99: a WHOOP MG that tops up short of full every day (never trips the near-full rule), reported + // "9% = ~3 days" when the real drain that day was ~1.5 %/h. Ten daily cycles of 30pp/20h (1.5 %/h), + // each day's peak a little lower than the last (so the GLOBAL max sits all the way back on day + // one), then today: a quick top-up to 25% and a 4h tail down to 9%. The old whole-buffer max scan + // anchored on day one's peak and netted the fit across nine unseen intermediate top-ups, diluting + // 1.5 %/h into ~0.3 %/h and reporting ~29h. Bounding the search to the last two cycles instead + // anchors on YESTERDAY's peak (34%), fitting today's actual 1.5 %/h and landing on the honest 6h. + val r = mutableListOf>() + var t = 0L + var peak = 70.0 + while (peak >= 34.0) { + r.add(t to peak) + t += 20 * h + r.add(t to peak - 30.0) + t += 1 * h + peak -= 4.0 + } + r.add(t to 25.0) + t += 4 * h + r.add(t to 9.0) + val e = BatteryEstimator.estimate(r, BatteryEstimator.ratedLifeHoursWhoop5)!! + assertEquals(BatteryEstimator.Source.MEASURED, e.source) + assertEquals(9.0, e.currentSoc, 1e-6) + assertEquals(6.0, e.remainingHours, 1e-6) // 9 / 1.5 %/h, yesterday's real slope + } + @Test fun nearFullChargeStillResetsTheRun() { // The guard must NOT change a genuine near-full charge: discharge 100->20, charge back to 95 (>=90, // near-full), then 95->85 over 5h is 2 %/h. The run still resets on the near-full charge, source