App 2.5.0: range estimates from current and 5-minute average power
The Vehicle tab gets two tiles next to the car's own range: - Range at current power: remaining energy / (power / speed) right now. - Range at 5-min average: remaining energy / (energy used / distance driven) over the last 5 minutes, integrated time-weighted from both the service tick (15 s) and the open screen (2 s), so it is ready as soon as the app opens. Each shows the consumption it is based on (kWh/100 km). Remaining energy is SOC x usable capacity, because the car publishes no pack energy; the capacity is a new setting in the Consumption tab (default 50.8 kWh, the 51 kWh battery). Estimates read "—" with the reason when they cannot mean anything: standing still, regenerating, too little driving, or across a gap in the samples; over 999 km shows "> 999 km". RangeEstimator is Android-free and has 7 tests. Also fixes the battery card's note, which still said the SoH goes to MongoDB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Iterator;
|
||||
|
||||
/**
|
||||
* Estimated range from the energy left in the pack and how fast it is being used.
|
||||
*
|
||||
* instant: remaining kWh / (power now / speed now)
|
||||
* average: remaining kWh / (energy used / distance driven over the last 5 minutes)
|
||||
*
|
||||
* Remaining energy is SOC x usable capacity: the car publishes no pack energy, so the capacity
|
||||
* is a setting. Every estimate is null when it cannot mean anything — standing still,
|
||||
* regenerating, too little data — rather than a misleading number.
|
||||
*
|
||||
* Fed by both the service tick (15 s) and the open Vehicle page (2 s); the integration is
|
||||
* time-weighted, so the two rates mix correctly. Free of Android types, unit-tested.
|
||||
*/
|
||||
final class RangeEstimator {
|
||||
|
||||
static final long WINDOW_MS = 5 * 60_000L;
|
||||
/** Less than this much driving in the window is too little to average. */
|
||||
static final long MIN_WINDOW_MS = 60_000L;
|
||||
static final double MIN_DISTANCE_KM = 0.2;
|
||||
/** Below this the car is not really moving, and power / speed blows up. */
|
||||
static final double MIN_SPEED_KMH = 5;
|
||||
/** A gap between samples longer than this (app paused, car off) is not integrated. */
|
||||
static final long MAX_GAP_MS = 60_000L;
|
||||
|
||||
static final double DEFAULT_USABLE_KWH = 50.8; // MG4 51 kWh battery
|
||||
|
||||
private static final RangeEstimator SHARED = new RangeEstimator();
|
||||
|
||||
static RangeEstimator shared() { return SHARED; }
|
||||
|
||||
/** An estimate: the range and the consumption it is based on. */
|
||||
static final class Estimate {
|
||||
final double rangeKm;
|
||||
final double kwhPer100Km;
|
||||
|
||||
Estimate(double rangeKm, double kwhPer100Km) {
|
||||
this.rangeKm = rangeKm;
|
||||
this.kwhPer100Km = kwhPer100Km;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Sample {
|
||||
final long timeMs;
|
||||
final double powerKw;
|
||||
final double speedKmh;
|
||||
|
||||
Sample(long timeMs, double powerKw, double speedKmh) {
|
||||
this.timeMs = timeMs;
|
||||
this.powerKw = powerKw;
|
||||
this.speedKmh = speedKmh;
|
||||
}
|
||||
}
|
||||
|
||||
private final ArrayDeque<Sample> samples = new ArrayDeque<>();
|
||||
|
||||
/** Power in kW, positive leaving the battery; speed in km/h. Unread values are skipped. */
|
||||
synchronized void add(long timeMs, Float powerKw, Float speedKmh) {
|
||||
if (powerKw == null || speedKmh == null || !Float.isFinite(powerKw) || !Float.isFinite(speedKmh)) return;
|
||||
Sample last = samples.peekLast();
|
||||
if (last != null && timeMs <= last.timeMs) return; // out of order or duplicate tick
|
||||
samples.addLast(new Sample(timeMs, powerKw, Math.max(0f, speedKmh)));
|
||||
while (!samples.isEmpty() && samples.peekFirst().timeMs < timeMs - WINDOW_MS) samples.removeFirst();
|
||||
}
|
||||
|
||||
static Double remainingKwh(Float socPercent, double usableKwh) {
|
||||
if (socPercent == null || !Float.isFinite(socPercent) || usableKwh <= 0) return null;
|
||||
return Math.max(0, Math.min(100, socPercent)) / 100.0 * usableKwh;
|
||||
}
|
||||
|
||||
static Estimate instant(Double remainingKwh, Float powerKw, Float speedKmh) {
|
||||
if (remainingKwh == null || powerKw == null || speedKmh == null) return null;
|
||||
if (speedKmh < MIN_SPEED_KMH || powerKw <= 0) return null;
|
||||
double kwhPerKm = powerKw.doubleValue() / speedKmh.doubleValue();
|
||||
return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100);
|
||||
}
|
||||
|
||||
synchronized Estimate average(Double remainingKwh, long nowMs) {
|
||||
if (remainingKwh == null) return null;
|
||||
double energyKwh = 0, distanceKm = 0;
|
||||
long covered = 0;
|
||||
Sample previous = null;
|
||||
for (Iterator<Sample> it = samples.iterator(); it.hasNext(); ) {
|
||||
Sample s = it.next();
|
||||
if (s.timeMs < nowMs - WINDOW_MS) continue;
|
||||
if (previous != null) {
|
||||
long dt = s.timeMs - previous.timeMs;
|
||||
if (dt > 0 && dt <= MAX_GAP_MS) {
|
||||
double hours = dt / 3_600_000.0;
|
||||
energyKwh += (previous.powerKw + s.powerKw) / 2 * hours;
|
||||
distanceKm += (previous.speedKmh + s.speedKmh) / 2 * hours;
|
||||
covered += dt;
|
||||
}
|
||||
}
|
||||
previous = s;
|
||||
}
|
||||
if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM || energyKwh <= 0) return null;
|
||||
double kwhPerKm = energyKwh / distanceKm;
|
||||
return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100);
|
||||
}
|
||||
|
||||
synchronized void clear() { samples.clear(); }
|
||||
}
|
||||
Reference in New Issue
Block a user