mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
feat(battery): Model charge taper per band, base health on listening-only drain
- Replace the single linear charge rate with a three-band model (bulk / taper / trickle) matching lithium CC/CV charging: each band learns its own rate, the ETA walks the remaining bands, and the spec seed gets a taper haircut for the slow bands — no more over-promising above 80% - Base the battery-health figure exclusively on drain observed while the pod is worn, audio is playing, AND this device is the system's audio sink; idle wear previously diluted health upward against Apple's listening ratings - Listening segments are flushed for persistence the moment their gate breaks (playback stop, docking, transport flip) instead of being discarded with the cleared window - The time-remaining estimate keeps learning from all usage — actual current drain, idle included, is the right basis for "how long will they last"
This commit is contained in:
+2
-2
@@ -558,7 +558,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
drainProfilesFlow.value = mapOf(
|
||||
testAddress to DrainProfile(
|
||||
model = PodModel.AIRPODS_PRO2.name,
|
||||
rates = mapOf(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
|
||||
fractionPerHour = 1f / 3f,
|
||||
sampleCount = 10,
|
||||
@@ -585,7 +585,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
drainProfilesFlow.value = mapOf(
|
||||
testAddress to DrainProfile(
|
||||
model = PodModel.AIRPODS_PRO2.name,
|
||||
rates = mapOf(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
|
||||
fractionPerHour = 1f / 3f,
|
||||
sampleCount = 10,
|
||||
|
||||
@@ -6,6 +6,7 @@ import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState.Battery
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState.BatteryType
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState.ChargingState
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
@@ -36,6 +37,8 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
optimized: Boolean = false,
|
||||
model: PodModel? = null,
|
||||
estimateEnabled: Boolean = true,
|
||||
worn: Boolean = false,
|
||||
systemConnected: Boolean = false,
|
||||
): PodDevice {
|
||||
val state = when {
|
||||
optimized -> ChargingState.CHARGING_OPTIMIZED
|
||||
@@ -46,12 +49,21 @@ 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(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
)
|
||||
)
|
||||
} else emptyMap()
|
||||
return PodDevice(
|
||||
profileId = profileId,
|
||||
ble = null,
|
||||
aap = AapPodState(batteries = batteries),
|
||||
aap = AapPodState(batteries = batteries, settings = settings),
|
||||
profileModel = model,
|
||||
batteryEstimateEnabled = estimateEnabled,
|
||||
isSystemConnected = systemConnected,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,6 +71,7 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
emissions: List<List<PodDevice>>,
|
||||
stored: Map<String, DrainProfile> = emptyMap(),
|
||||
clockMs: List<Long> = List(emissions.size) { it * 4 * 60_000L },
|
||||
musicActive: List<Boolean> = List(emissions.size) { false },
|
||||
): BatteryEstimator {
|
||||
val deviceMonitor = mockk<DeviceMonitor> {
|
||||
every { devices } returns flowOf(*emissions.toTypedArray())
|
||||
@@ -71,7 +84,10 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
every { elapsedRealtime() } returnsMany clockMs
|
||||
every { now() } returns now
|
||||
}
|
||||
return BatteryEstimator(deviceMonitor, drainStore, timeSource)
|
||||
val audioManager = mockk<android.media.AudioManager> {
|
||||
every { isMusicActive } returnsMany musicActive
|
||||
}
|
||||
return BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,11 +286,36 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
@Test
|
||||
fun `the quick-charge rating seeds an ETA on the very first charge`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Nothing measured, nothing stored — Apple's "5 minutes = ~1 hour of listening" claim
|
||||
// (2.0/hr for a Pro 2) answers at once: 50% missing at 2.0/hr == 15 min.
|
||||
// (2.0/hr for a Pro 2) seeds the bands with the taper haircut: 30% of bulk at 2.0/hr (9m)
|
||||
// + taper at 1.0/hr (6m) + trickle at 0.6/hr (10m) == 25 min.
|
||||
val result = collectEstimate(
|
||||
estimator(listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2))))
|
||||
)
|
||||
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 15
|
||||
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 25
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `learned band rates shape the ETA through the taper`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// At 85% the linear scalar (1.2/hr) would claim 8m; the learned bands know the taper is
|
||||
// slower: 5% of taper at 1.0/hr (3m) + trickle at 0.6/hr (10m) == 13m.
|
||||
val bands = mapOf(
|
||||
"BULK" to learned(2.0f),
|
||||
"TAPER" to learned(1.0f),
|
||||
"TRICKLE" to learned(0.6f),
|
||||
)
|
||||
val stored = mapOf(
|
||||
"p1" to DrainProfile(
|
||||
chargeRates = mapOf("LEFT" to learned(1.2f), "RIGHT" to learned(1.2f)),
|
||||
chargeBands = mapOf("LEFT" to bands, "RIGHT" to bands),
|
||||
)
|
||||
)
|
||||
val result = collectEstimate(
|
||||
estimator(
|
||||
emissions = listOf(listOf(device("p1", left = 0.85f, right = 0.85f, charging = true, model = PodModel.AIRPODS_PRO2))),
|
||||
stored = stored,
|
||||
)
|
||||
)
|
||||
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 13
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -345,7 +386,7 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `charge rates are persisted`() = runTest(UnconfinedTestDispatcher()) {
|
||||
fun `charge rates and band rates are persisted`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val drainStore = mockk<BatteryDrainStore> {
|
||||
every { profiles } returns MutableStateFlow(emptyMap())
|
||||
coEvery { save(any(), any()) } returns Unit
|
||||
@@ -359,12 +400,122 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
|
||||
every { now() } returns now
|
||||
}
|
||||
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
|
||||
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
|
||||
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager)
|
||||
|
||||
estimator.monitor().collect {}
|
||||
|
||||
coVerify {
|
||||
drainStore.save("p1", match { it.chargeRates.containsKey("LEFT") && it.chargeRates.containsKey("RIGHT") })
|
||||
drainStore.save("p1", match {
|
||||
it.chargeRates.containsKey("LEFT") && it.chargeRates.containsKey("RIGHT") &&
|
||||
it.chargeBands["LEFT"]?.containsKey("BULK") == true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worn playing segments feed the listening rates`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Steady discharge while worn, playing, and system-connected: learned into BOTH the
|
||||
// general rates and the health-grade listening rates.
|
||||
val emissions = (0 until 5).map { i ->
|
||||
val level = 0.80f - i * 0.01f
|
||||
listOf(device("p1", left = level, right = level, worn = true, systemConnected = true))
|
||||
}
|
||||
val drainStore = mockk<BatteryDrainStore> {
|
||||
every { profiles } returns MutableStateFlow(emptyMap())
|
||||
coEvery { save(any(), any()) } returns Unit
|
||||
}
|
||||
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
|
||||
val timeSource = mockk<TimeSource> {
|
||||
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
|
||||
every { now() } returns now
|
||||
}
|
||||
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns true }
|
||||
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
|
||||
|
||||
coVerify {
|
||||
drainStore.save("p1", match {
|
||||
it.listeningRates.containsKey("UNKNOWN/LEFT") && it.rates.containsKey("UNKNOWN/LEFT")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle wear does not feed the listening rates`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Worn and connected but nothing playing: general rates learn, listening rates stay empty.
|
||||
val emissions = (0 until 5).map { i ->
|
||||
val level = 0.80f - i * 0.01f
|
||||
listOf(device("p1", left = level, right = level, worn = true, systemConnected = true))
|
||||
}
|
||||
val drainStore = mockk<BatteryDrainStore> {
|
||||
every { profiles } returns MutableStateFlow(emptyMap())
|
||||
coEvery { save(any(), any()) } returns Unit
|
||||
}
|
||||
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
|
||||
val timeSource = mockk<TimeSource> {
|
||||
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
|
||||
every { now() } returns now
|
||||
}
|
||||
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
|
||||
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
|
||||
|
||||
coVerify {
|
||||
drainStore.save("p1", match { it.rates.containsKey("UNKNOWN/LEFT") && it.listeningRates.isEmpty() })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playback on another sink does not feed the listening rates`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Music is playing but this device is NOT the system's audio sink (phone speaker, car):
|
||||
// treating it as pod listening would poison health.
|
||||
val emissions = (0 until 5).map { i ->
|
||||
val level = 0.80f - i * 0.01f
|
||||
listOf(device("p1", left = level, right = level, worn = true, systemConnected = false))
|
||||
}
|
||||
val drainStore = mockk<BatteryDrainStore> {
|
||||
every { profiles } returns MutableStateFlow(emptyMap())
|
||||
coEvery { save(any(), any()) } returns Unit
|
||||
}
|
||||
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
|
||||
val timeSource = mockk<TimeSource> {
|
||||
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 4 * 60_000L }
|
||||
every { now() } returns now
|
||||
}
|
||||
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns true }
|
||||
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
|
||||
|
||||
coVerify {
|
||||
drainStore.save("p1", match { it.rates.containsKey("UNKNOWN/LEFT") && it.listeningRates.isEmpty() })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a listening segment is flushed when playback stops`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// 1-minute cadence: fit persists at the 4th sample (t=3), further drops are inside the
|
||||
// persistence cooldown — then playback stops. The closed segment must be flushed and
|
||||
// persisted anyway, not silently discarded with the cleared window.
|
||||
val worn = (0 until 5).map { i ->
|
||||
listOf(device("p1", left = 0.80f - i * 0.01f, right = 0.80f - i * 0.01f, worn = true, systemConnected = true))
|
||||
}
|
||||
val after = listOf(listOf(device("p1", left = 0.75f, right = 0.75f, worn = true, systemConnected = true)))
|
||||
val emissions = worn + after
|
||||
val drainStore = mockk<BatteryDrainStore> {
|
||||
every { profiles } returns MutableStateFlow(emptyMap())
|
||||
coEvery { save(any(), any()) } returns Unit
|
||||
}
|
||||
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(*emissions.toTypedArray()) }
|
||||
val timeSource = mockk<TimeSource> {
|
||||
every { elapsedRealtime() } returnsMany emissions.indices.map { it * 60_000L }
|
||||
every { now() } returns now
|
||||
}
|
||||
val audioManager = mockk<android.media.AudioManager> {
|
||||
every { isMusicActive } returnsMany listOf(true, true, true, true, true, false)
|
||||
}
|
||||
BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager).monitor().collect {}
|
||||
|
||||
// Two listening persists: the cadence one mid-segment, and the forced flush at gate-off.
|
||||
coVerify(atLeast = 2) {
|
||||
drainStore.save("p1", match { it.listeningRates.containsKey("UNKNOWN/LEFT") })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +568,8 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
every { elapsedRealtime() } returns 0L
|
||||
every { now() } returns now
|
||||
}
|
||||
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
|
||||
val audioManager = mockk<android.media.AudioManager> { every { isMusicActive } returns false }
|
||||
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource, audioManager)
|
||||
|
||||
estimator.reset("p1")
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class BatteryHealthTest : BaseTest() {
|
||||
@Test
|
||||
fun `health is the ratio of rated to learned drain`() {
|
||||
// Pro 2 is rated 6h (0.1667/hr); a pod that only manages 3h (0.3333/hr) is at ~50%.
|
||||
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
||||
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class BatteryHealthTest : BaseTest() {
|
||||
// A replaced right earbud (or single-pod listening habits) makes the sides genuinely
|
||||
// diverge — each pod gets its own figure instead of one masking the other.
|
||||
val profile = DrainProfile(
|
||||
rates = mapOf(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to rate(1f / 3f), // 3h of a 6h rating -> 50%
|
||||
"UNKNOWN/RIGHT" to rate(1f / 6f), // full rated life -> 100%
|
||||
)
|
||||
@@ -44,7 +44,7 @@ class BatteryHealthTest : BaseTest() {
|
||||
@Test
|
||||
fun `health is capped at 100`() {
|
||||
// Idle-heavy usage drains slower than the listening rating — never report over-health.
|
||||
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
|
||||
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 100
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class BatteryHealthTest : BaseTest() {
|
||||
// so a single gentle idle session can't inflate the figure and one hard session can't
|
||||
// tank it.
|
||||
val profile = DrainProfile(
|
||||
rates = mapOf(
|
||||
listeningRates = mapOf(
|
||||
"UNKNOWN/LEFT" to rate(1f / 6f),
|
||||
"ON/LEFT" to rate(1f / 3f),
|
||||
"OFF/LEFT" to rate(1f / 1.5f),
|
||||
@@ -66,14 +66,14 @@ class BatteryHealthTest : BaseTest() {
|
||||
@Test
|
||||
fun `rates without enough accumulated sessions are ignored`() {
|
||||
val profile = DrainProfile(
|
||||
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
|
||||
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
|
||||
)
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `models without a rating have no health`() {
|
||||
val profile = DrainProfile(rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
||||
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
|
||||
BatteryHealth.estimate(profile, PodModel.UNKNOWN).shouldBeNull()
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ class BatteryHealthTest : BaseTest() {
|
||||
fun `rates learned on different hardware are ignored`() {
|
||||
val profile = DrainProfile(
|
||||
model = PodModel.AIRPODS_PRO.name,
|
||||
rates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
|
||||
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
|
||||
)
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
|
||||
}
|
||||
@@ -95,7 +95,7 @@ class BatteryHealthTest : BaseTest() {
|
||||
@Test
|
||||
fun `malformed bucket keys and broken rates are skipped`() {
|
||||
val profile = DrainProfile(
|
||||
rates = mapOf(
|
||||
listeningRates = mapOf(
|
||||
"GARBAGE/LEFT" to rate(1f / 3f), // unrecognized bucket
|
||||
"UNKNOWN" to rate(1f / 3f), // no slot at all
|
||||
"UNKNOWN/" to rate(1f / 3f), // blank slot
|
||||
@@ -112,14 +112,14 @@ class BatteryHealthTest : BaseTest() {
|
||||
fun `mode-specific rates are judged against their own rating`() {
|
||||
// AirPods 4 ANC: 4h with ANC on, 5h off. A 2h runtime learned with ANC ON is 50% of the
|
||||
// ON rating — not 40% of the OFF one.
|
||||
val profile = DrainProfile(rates = mapOf("ON/LEFT" to rate(0.5f)))
|
||||
val profile = DrainProfile(listeningRates = mapOf("ON/LEFT" to rate(0.5f)))
|
||||
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `headset slot yields a headset figure`() {
|
||||
// AirPods Max rated 20h; managing only 10h -> 50%.
|
||||
val profile = DrainProfile(rates = mapOf("ON/HEADSET" to rate(0.1f)))
|
||||
val profile = DrainProfile(listeningRates = mapOf("ON/HEADSET" to rate(0.1f)))
|
||||
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX).shouldNotBeNull()
|
||||
health.headset shouldBe 50
|
||||
health.left shouldBe null
|
||||
|
||||
@@ -165,18 +165,74 @@ class DrainModelTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `minutesUntilFull divides the missing fraction by the rate`() {
|
||||
// 40% missing at 1.2/hr -> 0.4 / 1.2 * 60 = 20 minutes. A fraction, never a percent.
|
||||
DrainModel.minutesUntilFull(0.60f, 1.2f) shouldBe 20
|
||||
// Uniform 1.2/hr across all bands: 40% missing -> 0.4 / 1.2 * 60 = 20 minutes.
|
||||
// A fraction, never a percent.
|
||||
DrainModel.minutesUntilFull(0.60f) { 1.2f } shouldBe 20
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minutesUntilFull suppresses the trickle zone`() {
|
||||
DrainModel.minutesUntilFull(0.98f, 1.2f).shouldBeNull()
|
||||
fun `minutesUntilFull walks the remaining bands at their own rates`() {
|
||||
// At 85%: 5% of taper at 1.0/hr (3m) + 10% of trickle at 0.6/hr (10m) = 13m. The bulk
|
||||
// band is already behind and must not contribute.
|
||||
val rates = mapOf(
|
||||
DrainModel.ChargeBand.BULK to 2.0f,
|
||||
DrainModel.ChargeBand.TAPER to 1.0f,
|
||||
DrainModel.ChargeBand.TRICKLE to 0.6f,
|
||||
)
|
||||
DrainModel.minutesUntilFull(0.85f) { rates[it] } shouldBe 13
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minutesUntilFull needs a rate for every remaining band`() {
|
||||
// Bulk known but the taper band has no basis -> no honest ETA.
|
||||
DrainModel.minutesUntilFull(0.50f) { band ->
|
||||
if (band == DrainModel.ChargeBand.BULK) 2.0f else null
|
||||
}.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minutesUntilFull suppresses the near-full sliver`() {
|
||||
DrainModel.minutesUntilFull(0.995f) { 1.2f }.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minutesUntilFull rejects a non-positive rate`() {
|
||||
DrainModel.minutesUntilFull(0.60f, 0f).shouldBeNull()
|
||||
DrainModel.minutesUntilFull(0.60f) { 0f }.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `band fits only use samples inside the band`() {
|
||||
// Bulk samples rise fast (2.4/hr), then the taper crawls: two in-band taper points
|
||||
// 24 minutes apart -> 0.25/hr... below the taper floor? floor = 0.25 * 0.5 = 0.125, ok.
|
||||
val samples = listOf(
|
||||
DrainSample(0L, 0.60f),
|
||||
DrainSample(5 * 60_000L, 0.70f),
|
||||
DrainSample(10 * 60_000L, 0.80f),
|
||||
DrainSample(34 * 60_000L, 0.90f),
|
||||
)
|
||||
val taper = DrainModel.chargeBandSlopeFractionPerHour(samples, DrainModel.ChargeBand.TAPER)
|
||||
taper.shouldNotBeNull()
|
||||
taper shouldBe (0.25f plusOrMinus 0.01f)
|
||||
// The bulk fit must not be dragged down by the slow taper points beyond its range.
|
||||
val bulk = DrainModel.chargeBandSlopeFractionPerHour(samples, DrainModel.ChargeBand.BULK)
|
||||
bulk.shouldNotBeNull()
|
||||
(bulk > 1.0f) shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a narrow band accepts a two-point fit but the bulk band does not`() {
|
||||
val twoTaperPoints = listOf(
|
||||
DrainSample(0L, 0.80f),
|
||||
DrainSample(12 * 60_000L, 0.90f), // 0.5/hr
|
||||
)
|
||||
DrainModel.chargeBandSlopeFractionPerHour(twoTaperPoints, DrainModel.ChargeBand.TAPER)
|
||||
.shouldNotBeNull()
|
||||
val twoBulkPoints = listOf(
|
||||
DrainSample(0L, 0.40f),
|
||||
DrainSample(12 * 60_000L, 0.50f),
|
||||
)
|
||||
DrainModel.chargeBandSlopeFractionPerHour(twoBulkPoints, DrainModel.ChargeBand.BULK)
|
||||
.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+20
@@ -28,6 +28,8 @@ class DrainProfileSerializationTest : BaseTest() {
|
||||
|
||||
profile.model shouldBe null
|
||||
profile.chargeRates shouldBe emptyMap()
|
||||
profile.chargeBands shouldBe emptyMap()
|
||||
profile.listeningRates shouldBe emptyMap()
|
||||
profile.rates.getValue("UNKNOWN/LEFT").updateCount shouldBe 1
|
||||
}
|
||||
|
||||
@@ -51,6 +53,24 @@ class DrainProfileSerializationTest : BaseTest() {
|
||||
updatedAt = Instant.ofEpochMilli(1700000000000L),
|
||||
)
|
||||
),
|
||||
chargeBands = mapOf(
|
||||
"LEFT" to mapOf(
|
||||
"TAPER" to DrainProfile.LearnedRate(
|
||||
fractionPerHour = 0.9f,
|
||||
sampleCount = 3,
|
||||
updateCount = 2,
|
||||
updatedAt = Instant.ofEpochMilli(1700000000000L),
|
||||
)
|
||||
)
|
||||
),
|
||||
listeningRates = mapOf(
|
||||
"ON/LEFT" to DrainProfile.LearnedRate(
|
||||
fractionPerHour = 0.24f,
|
||||
sampleCount = 7,
|
||||
updateCount = 3,
|
||||
updatedAt = Instant.ofEpochMilli(1700000000000L),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
json.decodeFromString<DrainProfile>(json.encodeToString(DrainProfile.serializer(), profile)) shouldBe profile
|
||||
|
||||
Reference in New Issue
Block a user