- "Speed, 5-min average" card under Speed: time-weighted mean speed, standing included, from the same samples as the other averages. - The top bar is one compact line (title and subtitle side by side). Version, update check and About moved from it to an App card on the Settings tab. - The Overview no longer scrolls in the car's window: checked on the emulator at 1778x640, the layout height the car reports, after trimming the page's padding and the tyre card's height. - The capacity hint says "Overview" instead of "Vehicle tab". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
207 lines
8.5 KiB
Java
207 lines
8.5 KiB
Java
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.
|
|
*
|
|
* since start: remaining kWh / (energy used / distance driven since the car was switched on)
|
|
* average: remaining kWh / (energy used / distance driven over the last 5 minutes)
|
|
*
|
|
* "Since start" is a trip like the car's own trip computer: it begins with the first sample
|
|
* after the head unit wakes (or after charging), so it includes standing with the climate on.
|
|
* A gap of {@link #TRIP_RESET_GAP_MS} without samples is the car having been off, and starts
|
|
* a new trip; so does charging.
|
|
*
|
|
* Remaining energy is SOC x usable capacity: the car publishes no pack energy, so the capacity
|
|
* is a setting. An estimate is null when it cannot mean anything — not moved yet, too little
|
|
* data — rather than a misleading number. Net regeneration is shown as it is: negative usage.
|
|
*
|
|
* 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. Times are on the monotonic clock
|
|
* (SystemClock.elapsedRealtime): the head unit sets its wall clock from GPS, and a jump back
|
|
* would make every new sample look out of order. 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;
|
|
/** A gap between samples longer than this (app paused, car off) is not integrated. */
|
|
static final long MAX_GAP_MS = 60_000L;
|
|
|
|
/** No sample for this long: the car was off, and the next sample starts a new trip. */
|
|
static final long TRIP_RESET_GAP_MS = 10 * 60_000L;
|
|
/** Standing still and taking in more than this: charging, which ends the trip. */
|
|
static final double CHARGING_KW = -1.0;
|
|
|
|
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, the consumption it is based on, and the distance behind it. */
|
|
static final class Estimate {
|
|
final double rangeKm;
|
|
final double kwhPer100Km;
|
|
final double distanceKm;
|
|
|
|
Estimate(double rangeKm, double kwhPer100Km, double distanceKm) {
|
|
this.rangeKm = rangeKm;
|
|
this.kwhPer100Km = kwhPer100Km;
|
|
this.distanceKm = distanceKm;
|
|
}
|
|
}
|
|
|
|
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<>();
|
|
|
|
// The trip since start: running totals, so it is not limited to the 5-minute window.
|
|
private double tripEnergyKwh;
|
|
private double tripDistanceKm;
|
|
private Sample tripLast;
|
|
|
|
/** 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
|
|
Sample sample = new Sample(timeMs, powerKw, Math.max(0f, speedKmh));
|
|
addToTrip(sample);
|
|
samples.addLast(sample);
|
|
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;
|
|
}
|
|
|
|
private void addToTrip(Sample s) {
|
|
boolean charging = s.speedKmh < 1 && s.powerKw < CHARGING_KW;
|
|
if (charging || (tripLast != null && s.timeMs - tripLast.timeMs > TRIP_RESET_GAP_MS)) {
|
|
tripEnergyKwh = 0;
|
|
tripDistanceKm = 0;
|
|
tripLast = null;
|
|
}
|
|
if (charging) return; // the trip starts again after the charge
|
|
if (tripLast != null) {
|
|
long dt = s.timeMs - tripLast.timeMs;
|
|
if (dt > 0 && dt <= MAX_GAP_MS) {
|
|
double hours = dt / 3_600_000.0;
|
|
tripEnergyKwh += (tripLast.powerKw + s.powerKw) / 2 * hours;
|
|
tripDistanceKm += (tripLast.speedKmh + s.speedKmh) / 2 * hours;
|
|
}
|
|
}
|
|
tripLast = s;
|
|
}
|
|
|
|
/** Net energy taken from the pack since start, in kWh; null before the trip's first sample. */
|
|
synchronized Double energySinceStart() {
|
|
return tripLast == null ? null : tripEnergyKwh;
|
|
}
|
|
|
|
/** Range at the consumption since start; null until the trip has some distance behind it. */
|
|
synchronized Estimate sinceStart(Double remainingKwh) {
|
|
if (remainingKwh == null || tripDistanceKm < MIN_DISTANCE_KM) return null;
|
|
return estimate(remainingKwh, tripEnergyKwh, tripDistanceKm);
|
|
}
|
|
|
|
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) return null;
|
|
return estimate(remainingKwh, energyKwh, distanceKm);
|
|
}
|
|
|
|
/**
|
|
* Net regeneration (downhill, recuperating) is a real reading: the usage is negative, and
|
|
* the range is unbounded rather than negative.
|
|
*/
|
|
private static Estimate estimate(double remainingKwh, double energyKwh, double distanceKm) {
|
|
double kwhPerKm = energyKwh / distanceKm;
|
|
double rangeKm = kwhPerKm > 0 ? remainingKwh / kwhPerKm : Double.POSITIVE_INFINITY;
|
|
return new Estimate(rangeKm, kwhPerKm * 100, distanceKm);
|
|
}
|
|
|
|
/**
|
|
* Mean battery power over the last five minutes, time-weighted, standing included; null with
|
|
* less than a minute of samples. Positive is drawn from the pack, as the Power tile shows it.
|
|
*/
|
|
synchronized Double averagePowerKw(long nowMs) {
|
|
double energyKwh = 0;
|
|
long covered = 0;
|
|
Sample previous = null;
|
|
for (Sample s : samples) {
|
|
if (s.timeMs < nowMs - WINDOW_MS) continue;
|
|
if (previous != null) {
|
|
long dt = s.timeMs - previous.timeMs;
|
|
if (dt > 0 && dt <= MAX_GAP_MS) {
|
|
energyKwh += (previous.powerKw + s.powerKw) / 2 * (dt / 3_600_000.0);
|
|
covered += dt;
|
|
}
|
|
}
|
|
previous = s;
|
|
}
|
|
if (covered < MIN_WINDOW_MS) return null;
|
|
return energyKwh / (covered / 3_600_000.0);
|
|
}
|
|
|
|
/** Mean speed over the last five minutes, time-weighted, standing included; null below a minute. */
|
|
synchronized Double averageSpeedKmh(long nowMs) {
|
|
double distanceKm = 0;
|
|
long covered = 0;
|
|
Sample previous = null;
|
|
for (Sample s : samples) {
|
|
if (s.timeMs < nowMs - WINDOW_MS) continue;
|
|
if (previous != null) {
|
|
long dt = s.timeMs - previous.timeMs;
|
|
if (dt > 0 && dt <= MAX_GAP_MS) {
|
|
distanceKm += (previous.speedKmh + s.speedKmh) / 2 * (dt / 3_600_000.0);
|
|
covered += dt;
|
|
}
|
|
}
|
|
previous = s;
|
|
}
|
|
if (covered < MIN_WINDOW_MS) return null;
|
|
return distanceKm / (covered / 3_600_000.0);
|
|
}
|
|
|
|
synchronized void clear() {
|
|
samples.clear();
|
|
tripEnergyKwh = 0;
|
|
tripDistanceKm = 0;
|
|
tripLast = null;
|
|
}
|
|
}
|