fix(battery): Stop remaining-time jumping up when toggling ANC

A mode's own drain-rate bucket starts empty until it accumulates history,
so toggling ANC into an unlearned mode fell straight through to Apple's
optimistic spec rating while the mode just left showed its worse measured
rate. Result: enabling ANC could make the displayed time jump up ~1h.

Fill an empty ANC bucket at display time with the less-optimistic of the
mode-agnostic UNKNOWN reading and a sibling mode's learned rate, scaled by
the ratio of the two modes' rated drain. Scoped to spec'd models and
device-supported modes; picks the best-evidenced sibling, tie-broken by
closest rated drain then recency. No persistence or UI change.

The existing spec ceiling and display clamp still backstop the borrowed rate.
This commit is contained in:
Matthias Urhahn
2026-07-07 22:23:01 +02:00
committed by Matthias Urhahn
parent e06dc933b8
commit 31f4d4aafa
2 changed files with 311 additions and 8 deletions
@@ -28,6 +28,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
/**
* Learns each device's battery drain rate from observed levels over time and turns it into a
@@ -569,9 +570,58 @@ class BatteryEstimator @Inject constructor(
private fun learnedRate(profileId: ProfileId, device: PodDevice, bucket: String, slot: Slot): Float? {
val profile = storedProfileFor(profileId, device) ?: return null
return (profile.rates[rateKey(bucket, slot)] ?: profile.rates[rateKey(MODE_UNKNOWN, slot)])?.fractionPerHour
// Exact per-mode learning always wins — a real measurement for THIS mode.
profile.rates[rateKey(bucket, slot)]?.validFractionPerHour()?.let { return it }
// Empty bucket: fill it conservatively with the less-optimistic (faster-draining) of the
// mode-agnostic UNKNOWN reading and the spec-scaled sibling reading, so toggling ANC into an
// unlearned mode never inflates the estimate past a real sibling measurement.
val unknown = profile.rates[rateKey(MODE_UNKNOWN, slot)]?.validFractionPerHour()
val sibling = siblingScaledRate(profile, device, bucket, slot)
return listOfNotNull(unknown, sibling).maxOrNull()
}
/**
* Fills an empty ANC bucket by borrowing another mode's learned rate, scaled to this mode by the
* ratio of the two modes' rated drain (`predicted = sibling × specRate(current)/specRate(sibling)`).
* Keeps the estimate continuous across an ANC toggle instead of jumping to the optimistic spec.
*
* Only fires when:
* - the current bucket is a real ANC mode (an UNKNOWN / mode-not-known reading keeps its
* conservative spec-min behaviour), and
* - the model publishes ratings for both modes (without a rating there's no [effectiveRate] ceiling
* or display clamp, so a borrowed rate could over-promise unbounded), and
* - the sibling mode is one the device reports as supported (ignore stale keys for modes this
* hardware can't use), falling back to all modes only when the supported list is unavailable.
*
* Among the candidates the best-evidenced one wins, tie-broken by closest rated drain (best
* physical predictor) then recency. Returns null when nothing trustworthy is available; the caller
* merges the result conservatively with the UNKNOWN reading.
*/
private fun siblingScaledRate(profile: DrainProfile, device: PodDevice, bucket: String, slot: Slot): Float? {
if (bucket == MODE_UNKNOWN) return null
val targetSpec = device.specRate(bucket) ?: return null
val supported = device.ancMode?.supported?.map { it.name }?.takeIf { it.isNotEmpty() }
val best = AapSetting.AncMode.Value.entries
.map { it.name }
.filter { it != bucket && (supported == null || it in supported) }
.mapNotNull { sib ->
val siblingSpec = device.specRate(sib) ?: return@mapNotNull null
val learned = profile.rates[rateKey(sib, slot)]
?.takeIf { it.fractionPerHour.isFinite() && it.fractionPerHour > 0f }
?: return@mapNotNull null
learned to siblingSpec
}
.maxWithOrNull(
compareBy<Pair<DrainProfile.LearnedRate, Float>> { it.first.updateCount }
.thenBy { -abs(targetSpec - it.second) }
.thenBy { it.first.updatedAt },
) ?: return null
return (best.first.fractionPerHour * (targetSpec / best.second)).takeIf { it.isFinite() && it > 0f }
}
private fun DrainProfile.LearnedRate.validFractionPerHour(): Float? =
fractionPerHour.takeIf { it.isFinite() && it > 0f }
private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slot: Slot): Float? =
storedProfileFor(profileId, device)?.chargeRates[slot.name]?.fractionPerHour
@@ -39,6 +39,8 @@ class BatteryEstimatorTest : BaseTest() {
estimateEnabled: Boolean = true,
worn: Boolean = false,
systemConnected: Boolean = false,
ancMode: AapSetting.AncMode.Value? = null,
ancSupported: List<AapSetting.AncMode.Value> = AapSetting.AncMode.Value.entries,
): PodDevice {
val state = when {
optimized -> ChargingState.CHARGING_OPTIMIZED
@@ -49,14 +51,19 @@ class BatteryEstimatorTest : BaseTest() {
if (left != null) put(BatteryType.LEFT, Battery(BatteryType.LEFT, left, state))
if (right != null) put(BatteryType.RIGHT, Battery(BatteryType.RIGHT, right, state))
}
val settings = if (worn) {
mapOf<kotlin.reflect.KClass<out AapSetting>, AapSetting>(
AapSetting.EarDetection::class to AapSetting.EarDetection(
val settings = buildMap<kotlin.reflect.KClass<out AapSetting>, AapSetting> {
if (worn) put(
AapSetting.EarDetection::class,
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
)
),
)
} else emptyMap()
if (ancMode != null) put(
AapSetting.AncMode::class,
AapSetting.AncMode(current = ancMode, supported = ancSupported),
)
}
return PodDevice(
profileId = profileId,
ble = null,
@@ -554,6 +561,251 @@ class BatteryEstimatorTest : BaseTest() {
result["p1"].shouldNotBeNull().left.shouldNotBeNull().source shouldBe BatteryEstimate.Source.SPEC
}
@Test
fun `an empty ANC bucket borrows the sibling rate instead of jumping to spec`() = runTest(UnconfinedTestDispatcher()) {
// Pro 2: OFF learned (5h-equivalent), user toggles to ON whose bucket is empty. Both modes
// rate at 6h so the scale is 1 — ON reuses OFF's measured 0.20/hr (300 min) rather than the
// optimistic 6h spec (360). This is the +1h "ANC increases battery" paradox, removed.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 300
}
@Test
fun `an empty bucket prefers the more conservative of UNKNOWN and the sibling`() = runTest(UnconfinedTestDispatcher()) {
// A mode-agnostic UNKNOWN reading (0.12/hr, optimistic) AND a real OFF sibling (0.20/hr) both
// exist while ON is empty. The estimate takes the less-optimistic of the two so a toggle can't
// inflate past the sibling: 0.20/hr -> 300, not the 360 the optimistic UNKNOWN would clamp to.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"UNKNOWN/LEFT" to learned(0.12f), "UNKNOWN/RIGHT" to learned(0.12f),
"OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 300
}
@Test
fun `a borrowed sibling rate is scaled by the modes' rated drain`() = runTest(UnconfinedTestDispatcher()) {
// AirPods Pro (gen1) rates ANC on at 4.5h, off at 5h. An empty ON bucket borrows the OFF
// learned 0.20/hr and scales it by (1/4.5)/(1/5) == 1.111 -> 0.222/hr -> 270 min (4.5h). ANC
// on shows LESS than OFF's 300 min, the physically correct direction.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 270
}
@Test
fun `sibling scaling works in the inverse direction too`() = runTest(UnconfinedTestDispatcher()) {
// gen1 Pro: only ON learned (0.30/hr). An empty OFF bucket borrows it scaled by
// (1/5)/(1/4.5) == 0.9 -> 0.27/hr -> 222 min. OFF drains slower than the measured ON, correct.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("ON/LEFT" to learned(0.30f), "ON/RIGHT" to learned(0.30f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.OFF))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 222
}
@Test
fun `an empty bucket with no sibling still falls back to spec`() = runTest(UnconfinedTestDispatcher()) {
// Nothing learned in any mode -> the fallback can't fire, the model rating seeds as before.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))))
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `a populated current bucket is never overridden by a sibling`() = runTest(UnconfinedTestDispatcher()) {
// Real ON data (0.30/hr) exists alongside OFF (0.20/hr). The current mode's own measurement
// wins outright -> 200 min; a genuine per-mode difference is preserved, not flattened.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"ON/LEFT" to learned(0.30f), "ON/RIGHT" to learned(0.30f),
"OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 200
}
@Test
fun `the best-evidenced sibling is chosen`() = runTest(UnconfinedTestDispatcher()) {
// ON empty; OFF (0.20/hr, 1 update) and TRANSPARENCY (0.40/hr, 5 updates) both available and
// same-rated (all non-off modes rate 6h on a Pro 2, so scale 1). The higher-evidence
// TRANSPARENCY rate wins -> 150 min, not the 300 the thinner OFF rate would give.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"OFF/LEFT" to learned(0.20f, updateCount = 1), "OFF/RIGHT" to learned(0.20f, updateCount = 1),
"TRANSPARENCY/LEFT" to learned(0.40f, updateCount = 5), "TRANSPARENCY/RIGHT" to learned(0.40f, updateCount = 5),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesRemaining shouldBe 150
}
@Test
fun `equal-evidence siblings tie-break on closest rated drain`() = runTest(UnconfinedTestDispatcher()) {
// gen1 Pro rates ON and TRANSPARENCY at 4.5h but OFF at 5h. With equal evidence, the sibling
// whose rating is closest to ON (TRANSPARENCY, identical rating) is the better predictor and
// wins over OFF: 0.40/hr -> 150. Had OFF (0.20/hr) won, scaling would give 0.222/hr -> 270.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"OFF/LEFT" to learned(0.20f, updateCount = 3), "OFF/RIGHT" to learned(0.20f, updateCount = 3),
"TRANSPARENCY/LEFT" to learned(0.40f, updateCount = 3), "TRANSPARENCY/RIGHT" to learned(0.40f, updateCount = 3),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesRemaining shouldBe 150
}
@Test
fun `equal-evidence equal-rated siblings tie-break on recency`() = runTest(UnconfinedTestDispatcher()) {
// TRANSPARENCY and ADAPTIVE both rate identically to ON (4.5h) with equal evidence — only
// recency separates them. The newer ADAPTIVE (0.50/hr) wins over the older TRANSPARENCY
// (0.40/hr): 0.50/hr -> 120, not 150.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"TRANSPARENCY/LEFT" to learned(0.40f, updateCount = 2, updatedAt = now.minusSeconds(3600)),
"TRANSPARENCY/RIGHT" to learned(0.40f, updateCount = 2, updatedAt = now.minusSeconds(3600)),
"ADAPTIVE/LEFT" to learned(0.50f, updateCount = 2, updatedAt = now),
"ADAPTIVE/RIGHT" to learned(0.50f, updateCount = 2, updatedAt = now),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesRemaining shouldBe 120
}
@Test
fun `the UNKNOWN bucket does not borrow sibling rates`() = runTest(UnconfinedTestDispatcher()) {
// BLE-only (mode not known) keeps its conservative spec-min behaviour: a real OFF sibling is
// NOT borrowed, the estimate stays on the 6h rating (360), not OFF's 300.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `a model without ratings does not borrow a sibling rate`() = runTest(UnconfinedTestDispatcher()) {
// Beats Fit Pro has ANC but no published battery rating -> no spec ceiling to clamp a borrowed
// rate, so the fallback is skipped entirely and nothing over-promises (no estimate at all).
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.BEATS_FIT_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
) shouldBe emptyMap()
}
@Test
fun `an unsupported sibling mode is not borrowed`() = runTest(UnconfinedTestDispatcher()) {
// A stale ADAPTIVE key exists, but the device only reports OFF/ON as supported -> the stale
// key is ignored, no other sibling has data, so the estimate stays on spec (360).
val stored = mapOf("p1" to DrainProfile(rates = mapOf("ADAPTIVE/LEFT" to learned(0.20f), "ADAPTIVE/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(
listOf(
device(
"p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2,
ancMode = AapSetting.AncMode.Value.ON,
ancSupported = listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON),
)
)
),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `the in-case runtime projection also borrows a sibling rate`() = runTest(UnconfinedTestDispatcher()) {
// Charging (no live drain) in an empty ON bucket: the "if used now" projection borrows the OFF
// sibling (0.20/hr) instead of spec. At 50% that's 0.50 / 0.20 * 60 == 150.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 150
}
@Test
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> {
@@ -577,9 +829,10 @@ class BatteryEstimatorTest : BaseTest() {
estimator.estimates.value.containsKey("p1") shouldBe false
}
private fun learned(rate: Float) = DrainProfile.LearnedRate(
private fun learned(rate: Float, updateCount: Int = 1, updatedAt: Instant = now) = DrainProfile.LearnedRate(
fractionPerHour = rate,
sampleCount = 5,
updatedAt = now,
updateCount = updateCount,
updatedAt = updatedAt,
)
}