From 2a0c4c9675ce6137c42f62ac801db0f94241066b Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 10:01:11 +0200 Subject: [PATCH] App 2.10.0: range since start, usage in its own cards "Range since start" replaces the range at current power: remaining energy over the consumption since the car was switched on, standing with the climate on included. A trip starts again after 10 minutes without samples (the car was off) or when charging. The kWh/100 km for both estimates moved out of the range tiles into two cards of their own, directly below them; the overview still fits the full-screen 1920x720 display without scrolling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 5 ++ app/build.gradle | 4 +- .../java/com/evsuite/abrp/MainActivity.java | 43 ++++++++---- .../java/com/evsuite/abrp/RangeEstimator.java | 69 +++++++++++++++---- .../main/res/layout-w1000dp/pane_vehicle.xml | 22 +++++- app/src/main/res/layout/pane_vehicle.xml | 16 ++++- app/src/main/res/values/strings.xml | 8 ++- .../com/evsuite/abrp/RangeEstimatorTest.java | 52 +++++++++++--- 8 files changed, 169 insertions(+), 50 deletions(-) diff --git a/SPECS.md b/SPECS.md index 433bd62..d5e5754 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,6 +112,11 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.10.0: "Range since start" replaces the range at current power. It uses + the consumption since the car was switched on, including standing with the climate on, and a + new trip starts after 10 minutes without samples or when charging. Usage (kWh/100 km) for both + estimates moved into cards of their own under the range tiles, which fit on the full-screen + 1920x720 overview without scrolling. - 2026-10-18 — Code review fixes. App 2.9.0, server 2.2.0, Grafana token: - The server drops a bad value (or a sample without a plausible time) instead of rejecting the batch, and answers 200 with what it dropped; the app sends only values within the diff --git a/app/build.gradle b/app/build.gradle index 3abff06..02e8036 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 69 - versionName "2.9.0" + versionCode 70 + versionName "2.10.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 1d27277..883dbe4 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -90,7 +90,8 @@ public class MainActivity extends AppCompatActivity { private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer, vehCharging, vehHvac, vehTireFl, vehTireFr, vehTireRl, vehTireRr, - vehRangePower, vehRangePowerNote, vehRangeAverage, vehRangeAverageNote; + vehRangeTrip, vehRangeTripNote, vehRangeAverage, vehRangeAverageNote, + vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote; /** Usable pack capacity for the range estimates; the car does not publish it. */ static final String USABLE_KWH_KEY = "usable_kwh"; @@ -995,10 +996,14 @@ public class MainActivity extends AppCompatActivity { vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr); vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl); vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr); - vehRangePower = vehiclePane.findViewById(R.id.vehicle_range_power); - vehRangePowerNote = vehiclePane.findViewById(R.id.vehicle_range_power_note); + vehRangeTrip = vehiclePane.findViewById(R.id.vehicle_range_trip); + vehRangeTripNote = vehiclePane.findViewById(R.id.vehicle_range_trip_note); vehRangeAverage = vehiclePane.findViewById(R.id.vehicle_range_average); vehRangeAverageNote = vehiclePane.findViewById(R.id.vehicle_range_average_note); + vehUsageTrip = vehiclePane.findViewById(R.id.vehicle_usage_trip); + vehUsageTripNote = vehiclePane.findViewById(R.id.vehicle_usage_trip_note); + vehUsageAverage = vehiclePane.findViewById(R.id.vehicle_usage_average); + vehUsageAverageNote = vehiclePane.findViewById(R.id.vehicle_usage_average_note); } private double usableKwh() { @@ -1006,31 +1011,39 @@ public class MainActivity extends AppCompatActivity { } /** - * The two range estimates. Both need the car to be moving on battery power; the average - * also needs a minute or more of driving, which the service has usually collected already. + * The range estimates since start and at the 5-minute average, each with its usage in a + * card below it. Since start needs the car to have moved; the average needs a minute or more + * of driving, which the service has usually collected already. */ private void refreshRangeEstimates(EnergySnapshot s) { RangeEstimator estimator = RangeEstimator.shared(); long monotonicMs = android.os.SystemClock.elapsedRealtime(); estimator.add(monotonicMs, s.getBatteryPowerKw(), s.getSpeedKmh()); Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh()); - showEstimate(vehRangePower, vehRangePowerNote, - RangeEstimator.instant(remaining, s.getBatteryPowerKw(), s.getSpeedKmh()), - R.string.range_not_driving); - showEstimate(vehRangeAverage, vehRangeAverageNote, - estimator.average(remaining, monotonicMs), R.string.range_waiting); + RangeEstimator.Estimate trip = estimator.sinceStart(remaining); + showEstimate(vehRangeTrip, vehRangeTripNote, vehUsageTrip, vehUsageTripNote, trip, + trip == null ? "" : getString(R.string.usage_over_distance, trip.distanceKm), + R.string.range_trip_waiting); + showEstimate(vehRangeAverage, vehRangeAverageNote, vehUsageAverage, vehUsageAverageNote, + estimator.average(remaining, monotonicMs), getString(R.string.usage_last_5_min), + R.string.range_waiting); } - private void showEstimate(TextView value, TextView note, RangeEstimator.Estimate e, int whyNot) { + private void showEstimate(TextView range, TextView rangeNote, TextView usage, TextView usageNote, + RangeEstimator.Estimate e, String basis, int whyNot) { if (e == null) { - value.setText("—"); - note.setText(whyNot); + range.setText("—"); + rangeNote.setText(whyNot); + usage.setText("—"); + usageNote.setText(whyNot); return; } // Coasting at speed costs almost nothing, which would read as thousands of km. - value.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999) + range.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999) : String.format(java.util.Locale.US, "%.0f km", e.rangeKm)); - note.setText(getString(R.string.range_consumption, e.kwhPer100Km)); + rangeNote.setText(""); + usage.setText(getString(R.string.range_consumption, e.kwhPer100Km)); + usageNote.setText(basis); } /** diff --git a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java index 351bd1f..58af824 100644 --- a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java +++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java @@ -6,8 +6,13 @@ 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) + * 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. Every estimate is null when it cannot mean anything — standing still, @@ -24,25 +29,30 @@ final class RangeEstimator { /** 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; + /** 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 and the consumption it is based on. */ + /** 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) { + Estimate(double rangeKm, double kwhPer100Km, double distanceKm) { this.rangeKm = rangeKm; this.kwhPer100Km = kwhPer100Km; + this.distanceKm = distanceKm; } } @@ -60,12 +70,19 @@ final class RangeEstimator { private final ArrayDeque 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 - samples.addLast(new Sample(timeMs, powerKw, Math.max(0f, speedKmh))); + 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(); } @@ -74,11 +91,30 @@ final class RangeEstimator { 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); + 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; + } + + /** 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 || tripEnergyKwh <= 0) return null; + double kwhPerKm = tripEnergyKwh / tripDistanceKm; + return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100, tripDistanceKm); } synchronized Estimate average(Double remainingKwh, long nowMs) { @@ -102,8 +138,13 @@ final class RangeEstimator { } if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM || energyKwh <= 0) return null; double kwhPerKm = energyKwh / distanceKm; - return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100); + return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100, distanceKm); } - synchronized void clear() { samples.clear(); } + synchronized void clear() { + samples.clear(); + tripEnergyKwh = 0; + tripDistanceKm = 0; + tripLast = null; + } } diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml index 16ee22d..3ec4f97 100644 --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml @@ -12,9 +12,9 @@ - - - + + + @@ -30,6 +30,22 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml index f2cbfd7..707b73b 100644 --- a/app/src/main/res/layout/pane_vehicle.xml +++ b/app/src/main/res/layout/pane_vehicle.xml @@ -12,15 +12,25 @@ - - - + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2cf188..d96f844 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,12 +31,16 @@ Charge (SOC) Range Power - Range at current power + Range since start Range at 5-min average + Usage since start + Usage, 5-min average %1$.1f kWh/100 km > %1$d km needs a few minutes of driving - only while driving + once the car has moved + over %1$.1f km + over the last 5 minutes Usable battery capacity Save capacity Used for the range estimates on the Vehicle tab. The 51 kWh MG4 battery has 50.8 kWh usable, the 64 kWh one 61.7, the 77 kWh one 74.4. diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java index 6b73e59..50104c0 100644 --- a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java +++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java @@ -3,6 +3,7 @@ package com.evsuite.abrp; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -19,20 +20,49 @@ public class RangeEstimatorTest { } @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); + public void sinceStartCoversTheWholeTripNotJustFiveMinutes() { + RangeEstimator r = new RangeEstimator(); + // 10 minutes at 30 kW then 10 minutes at 10 kW, both at 100 km/h: 20 kWh/100 km overall. + for (long t = 0; t <= 600_000; t += 15_000) r.add(t, 30f, 100f); + for (long t = 615_000; t <= 1_200_000; t += 15_000) r.add(t, 10f, 100f); + RangeEstimator.Estimate trip = r.sinceStart(25.4); + assertNotNull(trip); + assertEquals(20.0, trip.kwhPer100Km, 0.2); // plus the 15 s step between the two + assertEquals(127.0, trip.rangeKm, 1.0); + assertEquals(33.3, trip.distanceKm, 0.1); + // The 5-minute average only sees the economical end. + assertEquals(10.0, r.average(25.4, 1_200_000).kwhPer100Km, 0.1); } @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 + public void standingWithTheClimateOnCountsTowardsTheTrip() { + RangeEstimator r = new RangeEstimator(); + for (long t = 0; t <= 120_000; t += 15_000) r.add(t, 3f, 0f); // warming up + for (long t = 135_000; t <= 735_000; t += 15_000) r.add(t, 15f, 60f); + assertTrue(r.sinceStart(25.4).kwhPer100Km > 15.0); + } + + @Test + public void aLongGapOrChargingStartsANewTrip() { + RangeEstimator r = new RangeEstimator(); + for (long t = 0; t <= 600_000; t += 15_000) r.add(t, 30f, 100f); + long later = 600_000 + RangeEstimator.TRIP_RESET_GAP_MS + 1; // the car was off + for (long t = later; t <= later + 600_000; t += 15_000) r.add(t, 10f, 100f); + assertEquals(10.0, r.sinceStart(25.4).kwhPer100Km, 0.1); + + long charge = later + 615_000; + r.add(charge, -7f, 0f); // plugged in + assertNull(r.sinceStart(25.4)); + } + + @Test + public void noSinceStartBeforeAnyDistanceOrWithoutSoc() { + RangeEstimator r = new RangeEstimator(); + assertNull(r.sinceStart(25.4)); + for (long t = 0; t <= 60_000; t += 15_000) r.add(t, 2f, 0f); + assertNull(r.sinceStart(25.4)); // not moved yet + for (long t = 75_000; t <= 300_000; t += 15_000) r.add(t, 15f, 60f); + assertNull(r.sinceStart(null)); } @Test