mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -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:
@@ -1,5 +1,6 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import android.media.AudioManager
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -45,6 +46,7 @@ class BatteryEstimator @Inject constructor(
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val drainStore: BatteryDrainStore,
|
||||
private val timeSource: TimeSource,
|
||||
private val audioManager: AudioManager,
|
||||
) {
|
||||
|
||||
private val _estimates = MutableStateFlow<Map<ProfileId, BatteryEstimate>>(emptyMap())
|
||||
@@ -71,8 +73,11 @@ class BatteryEstimator @Inject constructor(
|
||||
* (drain <-> charge) obviously invalidates it, and so does an AAP <-> BLE source change —
|
||||
* the granularity jump (1% vs 10%) between transports would read as a fake level step.
|
||||
*/
|
||||
fun matches(direction: Direction, source: DataSource): Boolean =
|
||||
this.direction == direction && this.source == source
|
||||
|
||||
fun realign(direction: Direction, source: DataSource) {
|
||||
if (this.direction != direction || this.source != source) samples.clear()
|
||||
if (!matches(direction, source)) samples.clear()
|
||||
this.direction = direction
|
||||
this.source = source
|
||||
}
|
||||
@@ -91,6 +96,15 @@ class BatteryEstimator @Inject constructor(
|
||||
var modeBucket: String = MODE_UNKNOWN
|
||||
val slots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
|
||||
|
||||
/**
|
||||
* Parallel drain windows fed ONLY while the pod is worn, audio is playing, and this device
|
||||
* is the system's audio sink — pure listening segments, the basis for battery health.
|
||||
*/
|
||||
val listeningSlots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
|
||||
|
||||
/** Fit + sample count of a just-closed listening segment, persisted on the next pass. */
|
||||
val pendingListeningFits: MutableMap<Slot, Pair<Float, Int>> = mutableMapOf()
|
||||
|
||||
/** Smoothed displayed minutes, per pod. */
|
||||
val lastMinutes: MutableMap<Slot, Int> = mutableMapOf()
|
||||
var lastUpdateMs: Long? = null
|
||||
@@ -114,6 +128,8 @@ class BatteryEstimator @Inject constructor(
|
||||
|
||||
fun resetWindow() {
|
||||
clearSlots()
|
||||
listeningSlots.values.forEach { it.clear() }
|
||||
pendingListeningFits.clear()
|
||||
lastMinutes.clear()
|
||||
lastRiseMs.clear()
|
||||
sessionBaseline.clear()
|
||||
@@ -169,15 +185,18 @@ class BatteryEstimator @Inject constructor(
|
||||
// Drop estimates for profiles no longer live/unambiguous/enabled this emission (offline gating).
|
||||
next.keys.retainAll(unambiguous.keys)
|
||||
|
||||
// Sampled once per emission — the gate for health-grade "listening" segments.
|
||||
val musicActive = audioManager.isMusicActive
|
||||
|
||||
for ((profileId, device) in unambiguous) {
|
||||
val estimate = updateTracker(profileId, device)
|
||||
val estimate = updateTracker(profileId, device, musicActive)
|
||||
if (estimate != null) next[profileId] = estimate else next.remove(profileId)
|
||||
}
|
||||
|
||||
_estimates.value = next
|
||||
}
|
||||
|
||||
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice): BatteryEstimate? {
|
||||
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice, musicActive: Boolean): BatteryEstimate? {
|
||||
val tracker = trackers.getOrPut(profileId) { DeviceTracker() }
|
||||
val nowMs = timeSource.elapsedRealtime()
|
||||
val bucket = device.modeBucket()
|
||||
@@ -254,6 +273,36 @@ class BatteryEstimator @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health-grade listening window: only pure segments count — the pod worn, audio playing,
|
||||
// and this device the audio sink (isMusicActive alone would count phone-speaker
|
||||
// playback). The moment the gate breaks, the finished segment's fit is captured for
|
||||
// persistence and the window cleared; mixed idle/listening samples would flatten the
|
||||
// slope and re-dilute health.
|
||||
val listening = charging != true && reading != null &&
|
||||
musicActive && device.isSystemConnected && device.wornForSlot(slot)
|
||||
val listeningHistory = tracker.listeningSlots.getValue(slot)
|
||||
if (listening) {
|
||||
val (fraction, source) = reading!!
|
||||
// An AAP<->BLE flip mid-listening still ends a PURE segment — flush it rather
|
||||
// than letting realign silently discard it.
|
||||
if (!listeningHistory.matches(SlotHistory.Direction.DRAIN, source)) {
|
||||
captureListeningSegment(tracker, slot)
|
||||
}
|
||||
listeningHistory.realign(SlotHistory.Direction.DRAIN, source)
|
||||
val last = listeningHistory.lastFraction
|
||||
when {
|
||||
last == null -> listeningHistory.record(DrainSample(nowMs, fraction))
|
||||
fraction > last + EPSILON -> { // reseat mid-listening → fresh segment
|
||||
listeningHistory.clear()
|
||||
listeningHistory.record(DrainSample(nowMs, fraction))
|
||||
}
|
||||
fraction < last - EPSILON -> listeningHistory.record(DrainSample(nowMs, fraction))
|
||||
else -> Unit
|
||||
}
|
||||
} else {
|
||||
captureListeningSegment(tracker, slot)
|
||||
}
|
||||
}
|
||||
|
||||
persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false)
|
||||
@@ -345,24 +394,58 @@ class BatteryEstimator @Inject constructor(
|
||||
): Int? {
|
||||
if (device.liveChargingOptimized(slot)) return null // held below full — an ETA would mislead
|
||||
val history = tracker.slots.getValue(slot)
|
||||
val live = if (history.direction == SlotHistory.Direction.CHARGE) {
|
||||
DrainModel.chargeSlopeFractionPerHour(history.toList())
|
||||
} else null
|
||||
// Rate preference mirrors the drain side: measured, then learned, then Apple's published
|
||||
// quick-charge claim ("5 minutes in the case = ~1 hour of listening") — so an ETA exists
|
||||
// even on the very first charge.
|
||||
val rate = live
|
||||
?: learnedChargeRate(profileId, device, slot)
|
||||
?: device.model.batterySpec?.chargeFractionPerHour
|
||||
?: return null
|
||||
val ring = if (history.direction == SlotHistory.Direction.CHARGE) history.toList() else emptyList()
|
||||
val liveScalar = if (ring.isNotEmpty()) DrainModel.chargeSlopeFractionPerHour(ring) else null
|
||||
val learnedScalar = learnedChargeRate(profileId, device, slot)
|
||||
val specRate = device.model.batterySpec?.chargeFractionPerHour
|
||||
|
||||
// Per band: this session's in-band fit, then the learned band rate, then the scalar
|
||||
// fallbacks, then Apple's quick-charge claim with the band's taper haircut (the claim
|
||||
// measures the bulk phase; scalars already average what was actually observed).
|
||||
fun rateFor(band: DrainModel.ChargeBand): Float? =
|
||||
(if (ring.isNotEmpty()) DrainModel.chargeBandSlopeFractionPerHour(ring, band) else null)
|
||||
?: learnedChargeBand(profileId, device, slot, band)
|
||||
?: liveScalar
|
||||
?: learnedScalar
|
||||
?: specRate?.let { it * band.specMultiplier }
|
||||
|
||||
val currentBand = DrainModel.ChargeBand.entries.firstOrNull { fraction < it.to }
|
||||
?: DrainModel.ChargeBand.TRICKLE
|
||||
val stallRate = rateFor(currentBand) ?: return null
|
||||
val lastRise = tracker.lastRiseMs[slot] ?: return null
|
||||
val step = if (device.liveReading(slot)?.second == DataSource.AAP) STEP_AAP else STEP_BLE
|
||||
if (nowMs - lastRise > DrainModel.chargeStallThresholdMs(rate, step)) return null
|
||||
if (nowMs - lastRise > DrainModel.chargeStallThresholdMs(stallRate, step)) return null
|
||||
|
||||
return DrainModel.minutesUntilFull(fraction, rate)
|
||||
return DrainModel.minutesUntilFull(fraction, ::rateFor)
|
||||
}
|
||||
|
||||
private fun learnedChargeBand(
|
||||
profileId: ProfileId,
|
||||
device: PodDevice,
|
||||
slot: Slot,
|
||||
band: DrainModel.ChargeBand,
|
||||
): Float? = storedProfileFor(profileId, device)?.chargeBands[slot.name]?.get(band.name)?.fractionPerHour
|
||||
|
||||
/**
|
||||
* Closes [slot]'s current listening segment: a valid fit is queued for persistence (the next
|
||||
* persist pass writes it, bypassing cadence) and the window cleared either way.
|
||||
*/
|
||||
private fun captureListeningSegment(tracker: DeviceTracker, slot: Slot) {
|
||||
val history = tracker.listeningSlots.getValue(slot)
|
||||
if (history.size == 0) return
|
||||
DrainModel.slopeFractionPerHour(history.toList())?.let {
|
||||
tracker.pendingListeningFits[slot] = it to history.size
|
||||
}
|
||||
history.clear()
|
||||
}
|
||||
|
||||
/** Whether the pod in [slot] is being worn — per-pod for buds, whole-device for headsets. */
|
||||
private fun PodDevice.wornForSlot(slot: Slot): Boolean = when (slot) {
|
||||
Slot.LEFT -> isLeftInEar
|
||||
Slot.RIGHT -> isRightInEar
|
||||
Slot.HEADSET -> isBeingWorn
|
||||
} == true
|
||||
|
||||
/**
|
||||
* Persists each pod's live drain or charge rate (whichever direction its window currently
|
||||
* tracks), at most once per [PERSIST_INTERVAL_MS] (mirrors the cache's periodic-save cadence)
|
||||
@@ -386,44 +469,100 @@ class BatteryEstimator @Inject constructor(
|
||||
var chargeRates = existing.chargeRates
|
||||
var changed = false
|
||||
|
||||
for (slot in Slot.entries) {
|
||||
val history = tracker.slots.getValue(slot)
|
||||
val isCharge = history.direction == SlotHistory.Direction.CHARGE
|
||||
// Same model-aware plausibility gate as display, so an implausibly fast fit isn't learned.
|
||||
val liveRate = if (isCharge) {
|
||||
DrainModel.chargeSlopeFractionPerHour(history.toList())
|
||||
} else {
|
||||
DrainModel.slopeFractionPerHour(history.toList())?.takeIf { plausibleForModel(it, spec) }
|
||||
} ?: continue
|
||||
var chargeBands = existing.chargeBands
|
||||
var listeningRates = existing.listeningRates
|
||||
|
||||
val key = if (isCharge) chargeRateKey(slot) else rateKey(bucket, slot)
|
||||
val lastPersist = tracker.lastPersistAtMs[key]
|
||||
if (!force && lastPersist != null && nowMs - lastPersist < PERSIST_INTERVAL_MS) continue
|
||||
tracker.lastPersistAtMs[key] = nowMs
|
||||
|
||||
// Blend against the rate stored when this session began, captured once, so a single long
|
||||
// session's repeated writes can't dominate prior history by re-blending their own output.
|
||||
// The captured updateCount keeps a whole session counting as ONE accumulated update.
|
||||
val stored = if (isCharge) chargeRates[slot.name] else rates[key]
|
||||
if (!tracker.sessionBaseline.containsKey(key)) {
|
||||
tracker.sessionBaseline[key] = stored?.fractionPerHour
|
||||
tracker.sessionBaselineCounts[key] = stored?.updateCount ?: 0
|
||||
// Blend against the rate stored when this session began, captured once per key, so a single
|
||||
// long session's repeated writes can't dominate prior history by re-blending their own
|
||||
// output. The captured updateCount keeps a whole session counting as ONE accumulated update.
|
||||
fun blended(cadenceKey: String, stored: DrainProfile.LearnedRate?, fit: Float, samples: Int): DrainProfile.LearnedRate {
|
||||
if (!tracker.sessionBaseline.containsKey(cadenceKey)) {
|
||||
tracker.sessionBaseline[cadenceKey] = stored?.fractionPerHour
|
||||
tracker.sessionBaselineCounts[cadenceKey] = stored?.updateCount ?: 0
|
||||
}
|
||||
val learned = DrainProfile.LearnedRate(
|
||||
fractionPerHour = DrainModel.blendRate(tracker.sessionBaseline[key], liveRate),
|
||||
sampleCount = history.size,
|
||||
updateCount = (tracker.sessionBaselineCounts[key] ?: 0) + 1,
|
||||
return DrainProfile.LearnedRate(
|
||||
fractionPerHour = DrainModel.blendRate(tracker.sessionBaseline[cadenceKey], fit),
|
||||
sampleCount = samples,
|
||||
updateCount = (tracker.sessionBaselineCounts[cadenceKey] ?: 0) + 1,
|
||||
updatedAt = timeSource.now(),
|
||||
)
|
||||
if (isCharge) chargeRates = chargeRates + (slot.name to learned) else rates = rates + (key to learned)
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(learned.fractionPerHour)}/hr" }
|
||||
}
|
||||
|
||||
// True at most once per PERSIST_INTERVAL_MS per key (bypassed on [force] or [always]).
|
||||
fun cadenceOk(cadenceKey: String, always: Boolean = false): Boolean {
|
||||
val last = tracker.lastPersistAtMs[cadenceKey]
|
||||
if (!always && !force && last != null && nowMs - last < PERSIST_INTERVAL_MS) return false
|
||||
tracker.lastPersistAtMs[cadenceKey] = nowMs
|
||||
return true
|
||||
}
|
||||
|
||||
for (slot in Slot.entries) {
|
||||
val history = tracker.slots.getValue(slot)
|
||||
|
||||
if (history.direction == SlotHistory.Direction.CHARGE) {
|
||||
// Whole-session scalar — the fallback basis and the stall-threshold reference.
|
||||
DrainModel.chargeSlopeFractionPerHour(history.toList())?.let { fit ->
|
||||
val key = chargeRateKey(slot)
|
||||
if (cadenceOk(key)) {
|
||||
chargeRates = chargeRates + (slot.name to blended(key, chargeRates[slot.name], fit, history.size))
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting charge rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||
}
|
||||
}
|
||||
// Per-band rates — charging is CC/CV, each regime learns its own speed.
|
||||
for (band in DrainModel.ChargeBand.entries) {
|
||||
val fit = DrainModel.chargeBandSlopeFractionPerHour(history.toList(), band) ?: continue
|
||||
val key = "${chargeRateKey(slot)}/${band.name}"
|
||||
if (!cadenceOk(key)) continue
|
||||
val stored = chargeBands[slot.name]?.get(band.name)
|
||||
val slotBands = chargeBands[slot.name].orEmpty() + (band.name to blended(key, stored, fit, history.size))
|
||||
chargeBands = chargeBands + (slot.name to slotBands)
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting charge band for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||
}
|
||||
} else {
|
||||
// Same model-aware plausibility gate as display, so an implausibly fast fit isn't learned.
|
||||
DrainModel.slopeFractionPerHour(history.toList())
|
||||
?.takeIf { plausibleForModel(it, spec) }
|
||||
?.let { fit ->
|
||||
val key = rateKey(bucket, slot)
|
||||
if (cadenceOk(key)) {
|
||||
rates = rates + (key to blended(key, rates[key], fit, history.size))
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listening rates (health basis): a just-closed segment persists immediately — it would
|
||||
// be lost otherwise, the window is already cleared. An ongoing window follows cadence.
|
||||
val pending = tracker.pendingListeningFits.remove(slot)
|
||||
val (fit, samples) = when {
|
||||
pending != null -> pending
|
||||
else -> DrainModel.slopeFractionPerHour(tracker.listeningSlots.getValue(slot).toList())
|
||||
?.let { it to tracker.listeningSlots.getValue(slot).size } ?: (null to 0)
|
||||
}
|
||||
if (fit != null && plausibleForModel(fit, spec)) {
|
||||
val key = rateKey(bucket, slot)
|
||||
val cadenceKey = "LISTEN/$key"
|
||||
if (cadenceOk(cadenceKey, always = pending != null)) {
|
||||
listeningRates = listeningRates + (key to blended(cadenceKey, listeningRates[key], fit, samples))
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting listening rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
drainStore.save(
|
||||
profileId,
|
||||
existing.copy(model = device.model.name, rates = rates, chargeRates = chargeRates),
|
||||
existing.copy(
|
||||
model = device.model.name,
|
||||
rates = rates,
|
||||
chargeRates = chargeRates,
|
||||
chargeBands = chargeBands,
|
||||
listeningRates = listeningRates,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import kotlin.math.roundToInt
|
||||
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
|
||||
*
|
||||
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
|
||||
* genuinely diverge, and a combined figure would mask a failing pod. Within a pod, the MEDIAN of its
|
||||
* qualifying learned rates is used rather than the best or worst: sessions where the pod idled
|
||||
* (in-ear, nothing playing) drain slower than the listening rating and would pull a "best" pick to a
|
||||
* meaningless 100%, while call-heavy or cold sessions drain faster and would drag a "worst" pick
|
||||
* into false doom. The median lands between both confounds. It remains an estimate — label it as
|
||||
* such in the UI.
|
||||
* genuinely diverge, and a combined figure would mask a failing pod. Only [DrainProfile.listeningRates]
|
||||
* feed it (segments where the pod was worn AND audio was playing on this device), because Apple's
|
||||
* ratings are listening figures — general rates include idle wear and would flatter health. Within a
|
||||
* pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping remaining
|
||||
* confounds (volume, calls, cold) in either direction. It remains an estimate — label it as such in
|
||||
* the UI.
|
||||
*/
|
||||
object BatteryHealth {
|
||||
|
||||
@@ -43,7 +43,7 @@ object BatteryHealth {
|
||||
}
|
||||
|
||||
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
|
||||
val ratios = profile.rates.mapNotNull { (key, rate) ->
|
||||
val ratios = profile.listeningRates.mapNotNull { (key, rate) ->
|
||||
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
|
||||
// future-format data and must not feed a health figure.
|
||||
val parts = key.split('/')
|
||||
|
||||
@@ -59,11 +59,17 @@ object DrainModel {
|
||||
const val CHARGE_RATE_MAX = 4.0f
|
||||
|
||||
/**
|
||||
* Above this level the "until charged" estimate is suppressed: the final trickle phase is far
|
||||
* slower than the linear bulk of the curve, so a linear fit would show a perpetually-imminent
|
||||
* finish. The firmware flips the charging flag off at 100% anyway.
|
||||
* Above this level the "until charged" estimate is suppressed. The trickle band models the slow
|
||||
* tail, so suppression only covers the last sliver where the firmware is about to flip the
|
||||
* charging flag off at 100% anyway.
|
||||
*/
|
||||
const val NEAR_FULL_SUPPRESS = 0.97f
|
||||
const val NEAR_FULL_SUPPRESS = 0.99f
|
||||
|
||||
/** Narrow bands (10% wide) accept a two-point fit — a full BLE band is exactly two ticks. */
|
||||
const val MIN_SAMPLES_CHARGE_NARROW = 2
|
||||
|
||||
/** Minimum rise WITHIN a band before its fit is trusted (half a narrow band). */
|
||||
const val MIN_BAND_RISE = 0.05f
|
||||
|
||||
/** [chargeStallThresholdMs] never goes below this, however fast the rate claims to be. */
|
||||
const val CHARGE_STALL_FLOOR_MS = 10 * 60_000L
|
||||
@@ -112,15 +118,62 @@ object DrainModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes until [levelFraction] reaches full at [chargeFractionPerHour], or null when the rate
|
||||
* is non-positive, the level is already in the trickle zone ([NEAR_FULL_SUPPRESS]), or the
|
||||
* result is implausible.
|
||||
* The three regimes of a lithium charge. Constant-current bulk is fast and roughly linear;
|
||||
* above ~80% the charger switches to constant-voltage and the intake tapers, ending in a slow
|
||||
* trickle. One linear rate over-promises badly above 80%, so each band learns its own rate.
|
||||
*
|
||||
* [specMultiplier] scales the spec-derived seed for the band: Apple's quick-charge claims
|
||||
* ("5 minutes = ~1 hour of listening") measure the bulk phase, so seeding the taper/trickle
|
||||
* bands from them needs a haircut. Applied ONLY to the spec seed — measured or learned rates
|
||||
* already reflect where they were observed.
|
||||
*/
|
||||
fun minutesUntilFull(levelFraction: Float, chargeFractionPerHour: Float): Int? {
|
||||
if (chargeFractionPerHour <= 0f || !levelFraction.isFinite() || levelFraction < 0f) return null
|
||||
enum class ChargeBand(val from: Float, val to: Float, val specMultiplier: Float) {
|
||||
BULK(0.0f, 0.8f, 1.0f),
|
||||
TAPER(0.8f, 0.9f, 0.5f),
|
||||
TRICKLE(0.9f, 1.0f, 0.3f),
|
||||
}
|
||||
|
||||
/**
|
||||
* Least-squares charge rate fitted ONLY to the recent samples inside [band], or null when the
|
||||
* band lacks coverage. Narrow bands accept two points (a full BLE band is exactly two ticks);
|
||||
* the wide bulk band keeps the regular sample requirement.
|
||||
*/
|
||||
fun chargeBandSlopeFractionPerHour(samples: List<DrainSample>, band: ChargeBand): Float? {
|
||||
val newestMs = samples.lastOrNull()?.atElapsedMs ?: return null
|
||||
val recent = samples.filter {
|
||||
newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS && it.fraction in band.from..band.to
|
||||
}
|
||||
val minSamples = if (band == ChargeBand.BULK) MIN_SAMPLES_CHARGE else MIN_SAMPLES_CHARGE_NARROW
|
||||
if (recent.size < minSamples) return null
|
||||
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
|
||||
if (recent.last().fraction - recent.first().fraction < MIN_BAND_RISE) return null
|
||||
|
||||
val rate = regressionSlopePerHour(recent) ?: return null
|
||||
// The taper/trickle bands are legitimately slower than any plausible bulk rate.
|
||||
val floor = CHARGE_RATE_MIN * band.specMultiplier
|
||||
return rate.takeIf { it.isFinite() && it >= floor && it <= CHARGE_RATE_MAX }
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes until [levelFraction] reaches full, walking the remaining [ChargeBand]s at
|
||||
* [rateForBand]'s per-band rates (partial current band + all bands above it). Null when the
|
||||
* level is already in the suppression sliver ([NEAR_FULL_SUPPRESS]), any needed band has no
|
||||
* usable rate, or the result is implausible.
|
||||
*/
|
||||
fun minutesUntilFull(levelFraction: Float, rateForBand: (ChargeBand) -> Float?): Int? {
|
||||
if (!levelFraction.isFinite() || levelFraction < 0f) return null
|
||||
if (levelFraction >= NEAR_FULL_SUPPRESS) return null
|
||||
val minutes = ((1f - levelFraction) / chargeFractionPerHour * 60.0).roundToInt()
|
||||
return minutes.takeIf { it in 1..MAX_MINUTES }
|
||||
|
||||
var totalMinutes = 0.0
|
||||
for (band in ChargeBand.entries) {
|
||||
val start = maxOf(levelFraction, band.from)
|
||||
val missing = band.to - start
|
||||
if (missing <= 0f) continue
|
||||
val rate = rateForBand(band) ?: return null
|
||||
if (rate <= 0f) return null
|
||||
totalMinutes += missing / rate * 60.0
|
||||
}
|
||||
return totalMinutes.roundToInt().takeIf { it in 1..MAX_MINUTES }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,19 @@ data class DrainProfile(
|
||||
@SerialName("model") val model: String? = null,
|
||||
@SerialName("rates") val rates: Map<String, LearnedRate> = emptyMap(),
|
||||
@SerialName("chargeRates") val chargeRates: Map<String, LearnedRate> = emptyMap(),
|
||||
/**
|
||||
* Per-band charge rates, `"<slot>" -> "<band>" -> rate` with band names from
|
||||
* [DrainModel.ChargeBand]. Charging is nonlinear (fast bulk, slow taper/trickle), so each
|
||||
* regime learns its own rate; [chargeRates] stays as the whole-session scalar fallback.
|
||||
*/
|
||||
@SerialName("chargeBands") val chargeBands: Map<String, Map<String, LearnedRate>> = emptyMap(),
|
||||
/**
|
||||
* Drain rates learned ONLY while the pod was worn and audio was actually playing on this
|
||||
* device — same `"<bucket>/<slot>"` keys as [rates]. Apple's battery ratings are listening
|
||||
* figures, so the battery-health estimate compares against these; the general [rates]
|
||||
* (which include idle wear) keep powering the time-remaining estimate.
|
||||
*/
|
||||
@SerialName("listeningRates") val listeningRates: Map<String, LearnedRate> = emptyMap(),
|
||||
) {
|
||||
@Serializable
|
||||
data class LearnedRate(
|
||||
|
||||
+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