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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 9 additions & 65 deletions app/src/main/java/com/dzid26/teslable/health/HealthSummary.kt
Original file line number Diff line number Diff line change
@@ -1,45 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-only
package com.dzid26.teslable.health

import com.dzid26.teslable.core.health.ChargeSession
import com.dzid26.teslable.core.health.EnergyDeltaEstimator
import com.dzid26.teslable.core.health.HealthFusion
import com.dzid26.teslable.core.health.MIN_SWING_PERCENT
import com.dzid26.teslable.core.health.RatedRangeEstimator
import com.dzid26.teslable.core.health.chargeSessions
import com.dzid26.teslable.core.history.BatterySample
import kotlin.math.roundToInt

// Battery-health glue: turns the recorded raw samples into the numbers the car
// view shows. All estimation lives in `core/health`; this file only selects
// inputs, counts sessions for the learning gate, and flags thin data.
// view shows. All estimation and session segmentation live in `core/health`;
// this file only selects inputs, counts sessions for the learning gate, and
// flags thin data.

/** Confidence in the estimates, gated on how many charge sessions were seen. */
enum class HealthConfidence { LEARNING, LOW, MEDIUM, HIGH }

/** One charge session: a run of consecutive charging samples. */
data class ChargeSession(
val startPercent: Float,
val endPercent: Float,
val energyAddedKwh: Float?,
val milesAddedRated: Float?,
) {
val swingPercent: Float get() = endPercent - startPercent

/** The rated constant this session measured: kWh per rated mile. */
val kwhPerMile: Float?
get() {
val energy = energyAddedKwh ?: return null
val miles = milesAddedRated?.takeIf { it > 0f } ?: return null
return energy / miles
}

/** The full-range scale the session implies: miles added over its swing. */
val fullRangeMiles: Float?
get() {
val miles = milesAddedRated?.takeIf { it > 0f } ?: return null
if (swingPercent < MIN_SWING_PERCENT) return null
return miles / (swingPercent / 100f)
}
}

/**
* The health card's data. [sohPercent] is null until the car's factory range
* (and capacity) are configured, so the card never invents a baseline.
Expand All @@ -64,28 +42,6 @@ data class HealthSummary(
val qualityNote: String?,
)

/**
* Charge sessions in [samples]: each run of consecutive charging samples. A run
* still in progress counts, so the learning gate moves while charging.
*/
fun chargeSessions(samples: List<BatterySample>): List<ChargeSession> {
val sessions = mutableListOf<ChargeSession>()
var start: BatterySample? = null
var last: BatterySample? = null
for (sample in samples) {
if (sample.isCharging) {
if (start == null) start = sample
last = sample
} else if (start != null && last != null) {
sessions += session(start, last)
start = null
last = null
}
}
if (start != null && last != null) sessions += session(start, last)
return sessions
}

/**
* Builds the card's data from one car's samples. [epaRatedRangeMiles] and
* [newCapacityKwh] are the car's factory figures; while they are unknown the
Expand Down Expand Up @@ -161,17 +117,6 @@ fun healthSummary(
)
}

private fun session(
start: BatterySample,
last: BatterySample,
): ChargeSession =
ChargeSession(
startPercent = start.batteryLevel.toFloat(),
endPercent = last.batteryLevel.toFloat(),
energyAddedKwh = last.chargeEnergyAdded?.takeIf { it > 0f },
milesAddedRated = last.chargeMilesAddedRated?.takeIf { it > 0f },
)

private fun confidenceFor(sessions: Int): HealthConfidence =
when {
sessions < LEARNING_SESSIONS -> HealthConfidence.LEARNING
Expand All @@ -197,16 +142,15 @@ private fun qualityNote(
}
}

/** The benchmark product needs about twenty sessions for a verdict; we do too. */
/** Sessions before the "Learning" label gives way to low confidence. */
private const val LEARNING_SESSIONS = 3
private const val MEDIUM_SESSIONS = 10

/** The benchmark product needs about twenty sessions for a verdict; high confidence waits for as many. */
private const val HIGH_SESSIONS = 20

/** A charge swing under this widens the energy-delta error. */
private const val QUALITY_SWING_PERCENT = 20f

/** Below this swing the estimator itself refuses to compute a capacity. */
private const val MIN_SWING_PERCENT = 5f

/** Rated-range extrapolation gets noisy below this SOC. */
private const val QUALITY_RANGE_SOC = 30
2 changes: 2 additions & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ kotlin {
dependencies {
implementation(libs.wire.runtime)
testImplementation(libs.junit)
// New tests use kotlin.test so they can move to commonTest (ADR-0007).
testImplementation(kotlin("test"))
}

wire {
Expand Down
115 changes: 115 additions & 0 deletions core/src/main/kotlin/com/dzid26/teslable/core/health/ChargeSession.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: AGPL-3.0-only
package com.dzid26.teslable.core.health

import com.dzid26.teslable.core.history.BatterySample

/**
* One charge session as the history saw it: from its first to its last charging
* sample (see [chargeSessions] for how sessions are cut).
*
* [energyAddedKwh] and [milesAddedRated] are what the car added between those two
* samples, so they always pair with [swingPercent]. Each is null when the car
* reported nothing or the counter did not grow.
*/
data class ChargeSession(
val startPercent: Float,
val endPercent: Float,
val energyAddedKwh: Float?,
val milesAddedRated: Float?,
) {
val swingPercent: Float get() = endPercent - startPercent

/** The rated constant this session measured: kWh per rated mile. */
val kwhPerMile: Float?
get() {
val energy = energyAddedKwh ?: return null
val miles = milesAddedRated?.takeIf { it > 0f } ?: return null
return energy / miles
}

/** The full-range scale the session implies: miles added over its swing. */
val fullRangeMiles: Float?
get() {
val miles = milesAddedRated?.takeIf { it > 0f } ?: return null
if (swingPercent < MIN_SWING_PERCENT) return null
return miles / (swingPercent / 100f)
}
}

/**
* Splits [samples], one car's history oldest first, into charge sessions.
*
* Sessions are cut by `chargingState`:
* - A session is a run of "Charging" or "Starting" samples.
* - "Stopped" and "NoPower" samples are a pause. A pause shorter than ten minutes,
* counted from the last charging sample to the one that resumes charging, does
* not end the session. A pause of ten minutes or longer does, and the resuming
* sample opens the next session.
* - "Complete", "Disconnected" and any other state (a missing, "Unknown" or
* "Calibrating" one included) end the session.
* - Silence is not a pause: when the app saw nothing for a while (the link
* dropped), the session continues across the gap.
*
* A session still in progress counts, so the learning gate moves while charging.
*
* The car reports `charge_energy_added` and `charge_miles_added_rated` as running
* totals since it began the session, and the first sample seen is often not that
* start (the app was opened or came into range mid-charge). Each session therefore
* carries the growth of those totals from its first to its last sample, which pairs
* with the SOC swing between the same two samples. A total missing from the first
* sample counts as zero there. A total that goes down inside the session was
* restarted by the car (a stale value from the previous session on a "Starting"
* sample, say); the session then carries the last value.
*/
fun chargeSessions(samples: List<BatterySample>): List<ChargeSession> {
val runs = mutableListOf<MutableList<BatterySample>>()
var current: MutableList<BatterySample>? = null
var paused = false
for (sample in samples) {
when (sample.chargingState) {
"Charging", "Starting" -> {
val pausedTooLong =
paused && current != null && sample.timestampMillis - current.last().timestampMillis >= MAX_PAUSE_MILLIS
if (current == null || pausedTooLong) {
current = mutableListOf()
runs.add(current)
}
current.add(sample)
paused = false
}

"Stopped", "NoPower" -> paused = true

else -> {
current = null
paused = false
}
}
}
return runs.map { it.toSession() }
}

private fun List<BatterySample>.toSession(): ChargeSession =
ChargeSession(
startPercent = first().batteryLevel.toFloat(),
endPercent = last().batteryLevel.toFloat(),
energyAddedKwh = counterGrowth { it.chargeEnergyAdded },
milesAddedRated = counterGrowth { it.chargeMilesAddedRated },
)

/**
* How far one of the car's running totals grew from the first to the last sample
* of a run. Null when the last sample has no value or the total did not grow.
*/
private fun List<BatterySample>.counterGrowth(counter: (BatterySample) -> Float?): Float? {
val end = counter(last()) ?: return null
val start = counter(first()) ?: 0f
val restarted = mapNotNull(counter).zipWithNext().any { (before, after) -> after < before }
return (if (restarted) end else end - start).takeIf { it > 0f }
}

/** Below this swing the estimator itself refuses to compute a capacity. */
const val MIN_SWING_PERCENT = 5f

/** A pause (Stopped or NoPower) shorter than this does not end a session. */
private const val MAX_PAUSE_MILLIS = 10 * 60_000L
Loading
Loading