mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
committed by
Matthias Urhahn
parent
e06dc933b8
commit
31f4d4aafa
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user