diff --git a/SPECS.md b/SPECS.md
index d5e5754..42912a4 100644
--- a/SPECS.md
+++ b/SPECS.md
@@ -112,6 +112,8 @@ 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.1: net regeneration (recuperating downhill) shows as negative usage, and
+ the range as "> 999 km" with "while recuperating", instead of "needs a few minutes of driving".
- 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
diff --git a/app/build.gradle b/app/build.gradle
index 02e8036..791eb08 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
- versionCode 70
- versionName "2.10.0"
+ versionCode 71
+ versionName "2.10.1"
// 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 883dbe4..6b36f54 100644
--- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
+++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
@@ -1041,7 +1041,7 @@ public class MainActivity extends AppCompatActivity {
// Coasting at speed costs almost nothing, which would read as thousands of km.
range.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999)
: String.format(java.util.Locale.US, "%.0f km", e.rangeKm));
- rangeNote.setText("");
+ rangeNote.setText(e.kwhPer100Km <= 0 ? getString(R.string.range_recuperating) : "");
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 58af824..c3aa60d 100644
--- a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
+++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
@@ -15,8 +15,8 @@ import java.util.Iterator;
* 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,
- * regenerating, too little data — rather than a misleading number.
+ * 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
@@ -112,9 +112,8 @@ final class RangeEstimator {
/** 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);
+ if (remainingKwh == null || tripDistanceKm < MIN_DISTANCE_KM) return null;
+ return estimate(remainingKwh, tripEnergyKwh, tripDistanceKm);
}
synchronized Estimate average(Double remainingKwh, long nowMs) {
@@ -136,9 +135,18 @@ final class RangeEstimator {
}
previous = s;
}
- if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM || energyKwh <= 0) return null;
+ 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;
- return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100, distanceKm);
+ double rangeKm = kwhPerKm > 0 ? remainingKwh / kwhPerKm : Double.POSITIVE_INFINITY;
+ return new Estimate(rangeKm, kwhPerKm * 100, distanceKm);
}
synchronized void clear() {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d96f844..26b8b1c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -38,6 +38,7 @@
%1$.1f kWh/100 km
> %1$d km
needs a few minutes of driving
+ while recuperating
once the car has moved
over %1$.1f km
over the last 5 minutes
diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
index 50104c0..cb442da 100644
--- a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
+++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
@@ -110,6 +110,9 @@ public class RangeEstimatorTest {
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));
+ RangeEstimator.Estimate regen = downhill.average(25.4, 240_000);
+ assertNotNull("net regeneration is a reading", regen);
+ assertEquals(-6.25, regen.kwhPer100Km, 0.01);
+ assertTrue(Double.isInfinite(regen.rangeKm));
}
}