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
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 61
versionName "2.4.3"
versionCode 62
versionName "2.5.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.
@@ -155,6 +155,8 @@ public class AbrpUploadService extends Service {
private void doUpload() {
long sampleMs = System.currentTimeMillis();
EnergySnapshot vehicle = energyReader.read(sampleMs);
// Keeps the 5-minute range average going while the app is in the background.
RangeEstimator.shared().add(sampleMs, vehicle.getBatteryPowerKw(), vehicle.getSpeedKmh());
consumptionTracker.sample(vehicle);
chargeSessionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
@@ -89,7 +89,13 @@ public class MainActivity extends AppCompatActivity {
private EnergyTelemetryReader vehicleReader;
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
vehCharging, vehHvac,
vehTireFl, vehTireFr, vehTireRl, vehTireRr;
vehTireFl, vehTireFr, vehTireRl, vehTireRr,
vehRangePower, vehRangePowerNote, vehRangeAverage, vehRangeAverageNote;
/** Usable pack capacity for the range estimates; the car does not publish it. */
static final String USABLE_KWH_KEY = "usable_kwh";
private TextInputLayout usableCapacityLayout;
private TextInputEditText usableCapacityInput;
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
@@ -921,6 +927,41 @@ 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);
vehRangeAverage = vehiclePane.findViewById(R.id.vehicle_range_average);
vehRangeAverageNote = vehiclePane.findViewById(R.id.vehicle_range_average_note);
}
private double usableKwh() {
return prefs.getFloat(USABLE_KWH_KEY, (float) RangeEstimator.DEFAULT_USABLE_KWH);
}
/**
* 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.
*/
private void refreshRangeEstimates(EnergySnapshot s, long nowMs) {
RangeEstimator estimator = RangeEstimator.shared();
estimator.add(nowMs, 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, nowMs), R.string.range_waiting);
}
private void showEstimate(TextView value, TextView note, RangeEstimator.Estimate e, int whyNot) {
if (e == null) {
value.setText("—");
note.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)
: String.format(java.util.Locale.US, "%.0f km", e.rangeKm));
note.setText(getString(R.string.range_consumption, e.kwhPer100Km));
}
/**
@@ -948,6 +989,7 @@ public class MainActivity extends AppCompatActivity {
vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
vehCharging.setText(chargingLabel(s));
refreshRangeEstimates(s, System.currentTimeMillis());
vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa"));
vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa"));
@@ -979,6 +1021,10 @@ public class MainActivity extends AppCompatActivity {
if (savedSoh != null) consumptionSohInput.setText(String.format(
java.util.Locale.US, "%.1f", savedSoh));
consumptionPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh());
usableCapacityLayout = consumptionPane.findViewById(R.id.usable_capacity_layout);
usableCapacityInput = consumptionPane.findViewById(R.id.usable_capacity_input);
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", usableKwh()));
consumptionPane.findViewById(R.id.usable_capacity_save).setOnClickListener(v -> saveUsableCapacity());
bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME);
bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK);
bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH);
@@ -998,6 +1044,19 @@ public class MainActivity extends AppCompatActivity {
});
}
private void saveUsableCapacity() {
try {
float value = Float.parseFloat(textOf(usableCapacityInput).replace(',', '.'));
if (!(value >= 10f && value <= 150f)) throw new NumberFormatException();
prefs.edit().putFloat(USABLE_KWH_KEY, value).apply();
usableCapacityLayout.setError(null);
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", value));
Toast.makeText(this, R.string.usable_capacity_saved, Toast.LENGTH_SHORT).show();
} catch (NumberFormatException e) {
usableCapacityLayout.setError(getString(R.string.usable_capacity_invalid));
}
}
private void saveSoh() {
String raw = textOf(consumptionSohInput).replace(',', '.');
try {
@@ -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(); }
}
@@ -57,5 +57,18 @@
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="10dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_power" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_power_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_average_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
+6 -1
View File
@@ -32,7 +32,12 @@
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_soh_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Save SoH value"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="This manual value is stored on the device and added to the lifetime record in MongoDB." android:textAppearance="@style/Text.EV.Secondary"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="This manual value is stored on the device and sent to your server with the daily totals." android:textAppearance="@style/Text.EV.Secondary"/>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/usable_capacity_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:hint="@string/hint_usable_capacity" app:suffixText="kWh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/usable_capacity_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/usable_capacity_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/btn_save_capacity"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/helper_usable_capacity" android:textAppearance="@style/Text.EV.Secondary"/>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="DAILY LOG · LAST 4 MONTHS" android:textAppearance="@style/Text.EV.Eyebrow"/>
+10
View File
@@ -54,6 +54,16 @@
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_power" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_power_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_average_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
</GridLayout>
</LinearLayout>
</ScrollView>
+11
View File
@@ -31,6 +31,17 @@
<string name="vehicle_row_soc">Charge (SOC)</string>
<string name="vehicle_row_range">Range</string>
<string name="vehicle_row_power">Power</string>
<string name="vehicle_row_range_power">Range at current power</string>
<string name="vehicle_row_range_average">Range at 5-min average</string>
<string name="range_consumption">%1$.1f kWh/100 km</string>
<string name="range_over">&gt; %1$d km</string>
<string name="range_waiting">needs a few minutes of driving</string>
<string name="range_not_driving">only while driving</string>
<string name="hint_usable_capacity">Usable battery capacity</string>
<string name="btn_save_capacity">Save capacity</string>
<string name="helper_usable_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.</string>
<string name="usable_capacity_saved">Usable capacity saved</string>
<string name="usable_capacity_invalid">Enter a value between 10 and 150 kWh</string>
<string name="vehicle_row_speed">Speed</string>
<string name="vehicle_row_odometer">Odometer</string>
<string name="vehicle_row_batt_temp">Battery Temperature</string>
@@ -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));
}
}