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:
2026-10-17 22:33:10 +02:00
co-authored by Claude Opus 5.5
parent 5f6cf1e901
commit a53ea589b2
9 changed files with 296 additions and 4 deletions
@@ -0,0 +1,85 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import org.junit.Test;
/** Range estimates from power and speed — pure JVM. */
public class RangeEstimatorTest {
private static final double USABLE = 50.8;
@Test
public void remainingEnergyIsSocTimesUsableCapacity() {
assertEquals(25.4, RangeEstimator.remainingKwh(50f, USABLE), 1e-9);
assertEquals(50.8, RangeEstimator.remainingKwh(120f, USABLE), 1e-9); // clamped
assertNull(RangeEstimator.remainingKwh(null, USABLE));
}
@Test
public void instantRangeFromPowerAndSpeed() {
// 15 kW at 100 km/h = 15 kWh/100 km; 25.4 kWh left = 169.3 km.
RangeEstimator.Estimate e = RangeEstimator.instant(25.4, 15f, 100f);
assertNotNull(e);
assertEquals(15.0, e.kwhPer100Km, 1e-9);
assertEquals(169.333, e.rangeKm, 0.001);
}
@Test
public void noInstantEstimateWhenStandingRegeneratingOrUnread() {
assertNull(RangeEstimator.instant(25.4, 2f, 3f)); // not really moving
assertNull(RangeEstimator.instant(25.4, -10f, 60f)); // regenerating
assertNull(RangeEstimator.instant(25.4, null, 60f)); // power unread
assertNull(RangeEstimator.instant(null, 15f, 60f)); // SOC unread
}
@Test
public void averageIntegratesEnergyOverDistance() {
RangeEstimator r = new RangeEstimator();
// 4 minutes at a steady 20 kW and 100 km/h, sampled every 2 s: 20 kWh/100 km.
for (long t = 0; t <= 240_000; t += 2_000) r.add(t, 20f, 100f);
RangeEstimator.Estimate e = r.average(25.4, 240_000);
assertNotNull(e);
assertEquals(20.0, e.kwhPer100Km, 1e-6);
assertEquals(127.0, e.rangeKm, 1e-6);
}
@Test
public void averageMixesTheServiceAndScreenRates() {
RangeEstimator r = new RangeEstimator();
// A slow 15 s service tick for two minutes, then the 2 s screen tick: still 20 kWh/100 km.
for (long t = 0; t < 120_000; t += 15_000) r.add(t, 20f, 100f);
for (long t = 120_000; t <= 240_000; t += 2_000) r.add(t, 20f, 100f);
assertEquals(20.0, r.average(25.4, 240_000).kwhPer100Km, 1e-6);
}
@Test
public void averageOnlyCoversTheLastFiveMinutes() {
RangeEstimator r = new RangeEstimator();
for (long t = 0; t <= 300_000; t += 2_000) r.add(t, 40f, 100f); // thirsty start
for (long t = 302_000; t <= 900_000; t += 2_000) r.add(t, 10f, 100f); // then economical
assertEquals(10.0, r.average(25.4, 900_000).kwhPer100Km, 1e-6);
}
@Test
public void noAverageWithTooLittleDataOrDistanceOrAcrossAGap() {
RangeEstimator r = new RangeEstimator();
for (long t = 0; t <= 30_000; t += 2_000) r.add(t, 20f, 100f);
assertNull("30 s is too little", r.average(25.4, 30_000));
RangeEstimator parked = new RangeEstimator();
for (long t = 0; t <= 240_000; t += 2_000) parked.add(t, 1f, 0f);
assertNull("no distance", parked.average(25.4, 240_000));
RangeEstimator gap = new RangeEstimator();
gap.add(0, 20f, 100f);
gap.add(200_000, 20f, 100f); // the app was closed in between
assertNull("a gap is not integrated", gap.average(25.4, 200_000));
RangeEstimator downhill = new RangeEstimator();
for (long t = 0; t <= 240_000; t += 2_000) downhill.add(t, -5f, 80f);
assertNull("net regeneration", downhill.average(25.4, 240_000));
}
}