Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.count where sorted[i].soc > 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.count where sorted[i].soc >= sorted[maxIdx].soc { maxIdx = i }
startIdx = maxIdx
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions android/app/src/main/java/com/noop/analytics/BatteryEstimator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>()
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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pair<Long, Double>>()
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
Expand Down