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
@@ -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 {