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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-18 10:01:11 +02:00
co-authored by Claude Opus 5.5
parent a5db5604d3
commit 2a0c4c9675
8 changed files with 169 additions and 50 deletions
+5
View File
@@ -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 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 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. `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: - 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 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 the batch, and answers 200 with what it dropped; the app sends only values within the
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 69 versionCode 70
versionName "2.9.0" versionName "2.10.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // 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. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -90,7 +90,8 @@ public class MainActivity extends AppCompatActivity {
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer, private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
vehCharging, vehHvac, vehCharging, vehHvac,
vehTireFl, vehTireFr, vehTireRl, vehTireRr, 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. */ /** Usable pack capacity for the range estimates; the car does not publish it. */
static final String USABLE_KWH_KEY = "usable_kwh"; 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); vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr);
vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl); vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl);
vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr); vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr);
vehRangePower = vehiclePane.findViewById(R.id.vehicle_range_power); vehRangeTrip = vehiclePane.findViewById(R.id.vehicle_range_trip);
vehRangePowerNote = vehiclePane.findViewById(R.id.vehicle_range_power_note); vehRangeTripNote = vehiclePane.findViewById(R.id.vehicle_range_trip_note);
vehRangeAverage = vehiclePane.findViewById(R.id.vehicle_range_average); vehRangeAverage = vehiclePane.findViewById(R.id.vehicle_range_average);
vehRangeAverageNote = vehiclePane.findViewById(R.id.vehicle_range_average_note); 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() { 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 * The range estimates since start and at the 5-minute average, each with its usage in a
* also needs a minute or more of driving, which the service has usually collected already. * 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) { private void refreshRangeEstimates(EnergySnapshot s) {
RangeEstimator estimator = RangeEstimator.shared(); RangeEstimator estimator = RangeEstimator.shared();
long monotonicMs = android.os.SystemClock.elapsedRealtime(); long monotonicMs = android.os.SystemClock.elapsedRealtime();
estimator.add(monotonicMs, s.getBatteryPowerKw(), s.getSpeedKmh()); estimator.add(monotonicMs, s.getBatteryPowerKw(), s.getSpeedKmh());
Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh()); Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh());
showEstimate(vehRangePower, vehRangePowerNote, RangeEstimator.Estimate trip = estimator.sinceStart(remaining);
RangeEstimator.instant(remaining, s.getBatteryPowerKw(), s.getSpeedKmh()), showEstimate(vehRangeTrip, vehRangeTripNote, vehUsageTrip, vehUsageTripNote, trip,
R.string.range_not_driving); trip == null ? "" : getString(R.string.usage_over_distance, trip.distanceKm),
showEstimate(vehRangeAverage, vehRangeAverageNote, R.string.range_trip_waiting);
estimator.average(remaining, monotonicMs), R.string.range_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) { if (e == null) {
value.setText("—"); range.setText("—");
note.setText(whyNot); rangeNote.setText(whyNot);
usage.setText("—");
usageNote.setText(whyNot);
return; return;
} }
// Coasting at speed costs almost nothing, which would read as thousands of km. // 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)); : 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);
} }
/** /**
@@ -6,9 +6,14 @@ import java.util.Iterator;
/** /**
* Estimated range from the energy left in the pack and how fast it is being used. * Estimated range from the energy left in the pack and how fast it is being used.
* *
* instant: remaining kWh / (power now / speed now) * 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) * 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 * 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, * is a setting. Every estimate is null when it cannot mean anything — standing still,
* regenerating, too little data — rather than a misleading number. * regenerating, too little data — rather than a misleading number.
@@ -24,25 +29,30 @@ final class RangeEstimator {
/** Less than this much driving in the window is too little to average. */ /** Less than this much driving in the window is too little to average. */
static final long MIN_WINDOW_MS = 60_000L; static final long MIN_WINDOW_MS = 60_000L;
static final double MIN_DISTANCE_KM = 0.2; 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. */ /** A gap between samples longer than this (app paused, car off) is not integrated. */
static final long MAX_GAP_MS = 60_000L; 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 static final double DEFAULT_USABLE_KWH = 50.8; // MG4 51 kWh battery
private static final RangeEstimator SHARED = new RangeEstimator(); private static final RangeEstimator SHARED = new RangeEstimator();
static RangeEstimator shared() { return SHARED; } 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 { static final class Estimate {
final double rangeKm; final double rangeKm;
final double kwhPer100Km; final double kwhPer100Km;
final double distanceKm;
Estimate(double rangeKm, double kwhPer100Km) { Estimate(double rangeKm, double kwhPer100Km, double distanceKm) {
this.rangeKm = rangeKm; this.rangeKm = rangeKm;
this.kwhPer100Km = kwhPer100Km; this.kwhPer100Km = kwhPer100Km;
this.distanceKm = distanceKm;
} }
} }
@@ -60,12 +70,19 @@ final class RangeEstimator {
private final ArrayDeque<Sample> samples = new ArrayDeque<>(); private final ArrayDeque<Sample> 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. */ /** Power in kW, positive leaving the battery; speed in km/h. Unread values are skipped. */
synchronized void add(long timeMs, Float powerKw, Float speedKmh) { synchronized void add(long timeMs, Float powerKw, Float speedKmh) {
if (powerKw == null || speedKmh == null || !Float.isFinite(powerKw) || !Float.isFinite(speedKmh)) return; if (powerKw == null || speedKmh == null || !Float.isFinite(powerKw) || !Float.isFinite(speedKmh)) return;
Sample last = samples.peekLast(); Sample last = samples.peekLast();
if (last != null && timeMs <= last.timeMs) return; // out of order or duplicate tick 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(); 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; return Math.max(0, Math.min(100, socPercent)) / 100.0 * usableKwh;
} }
static Estimate instant(Double remainingKwh, Float powerKw, Float speedKmh) { private void addToTrip(Sample s) {
if (remainingKwh == null || powerKw == null || speedKmh == null) return null; boolean charging = s.speedKmh < 1 && s.powerKw < CHARGING_KW;
if (speedKmh < MIN_SPEED_KMH || powerKw <= 0) return null; if (charging || (tripLast != null && s.timeMs - tripLast.timeMs > TRIP_RESET_GAP_MS)) {
double kwhPerKm = powerKw.doubleValue() / speedKmh.doubleValue(); tripEnergyKwh = 0;
return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100); 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) { 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; if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM || energyKwh <= 0) return null;
double kwhPerKm = energyKwh / distanceKm; 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;
}
} }
@@ -12,9 +12,9 @@
<TextView android:id="@+id/vehicle_range" 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" 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>
<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"> <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:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_trip" 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_trip" 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" /> <TextView android:id="@+id/vehicle_range_trip_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 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"> <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:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_average" android:textAppearance="@style/Text.EV.Secondary" />
@@ -30,6 +30,22 @@
<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" /> <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>
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:baselineAligned="false">
<Space android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" android:layout_marginEnd="10dp" />
<Space android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" android:layout_marginEnd="10dp" />
<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_usage_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_usage_trip" 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_usage_trip_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_usage_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_usage_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_usage_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>
<Space android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" android:layout_marginEnd="10dp" />
<Space android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" android:layout_marginEnd="10dp" />
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="16dp" /> <Space android:layout_width="1dp" android:layout_height="16dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false"> <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:orientation="vertical" android:layout_marginEnd="16dp" android:padding="20dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="20dp" android:background="@drawable/bg_glass">
+13 -3
View File
@@ -12,15 +12,25 @@
<TextView android:id="@+id/vehicle_range" 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" 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>
<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"> <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:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_trip" 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_trip" 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" /> <TextView android:id="@+id/vehicle_range_trip_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 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"> <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: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" 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" /> <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 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_usage_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_usage_trip" 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_usage_trip_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_usage_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_usage_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_usage_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 android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_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_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" />
+6 -2
View File
@@ -31,12 +31,16 @@
<string name="vehicle_row_soc">Charge (SOC)</string> <string name="vehicle_row_soc">Charge (SOC)</string>
<string name="vehicle_row_range">Range</string> <string name="vehicle_row_range">Range</string>
<string name="vehicle_row_power">Power</string> <string name="vehicle_row_power">Power</string>
<string name="vehicle_row_range_power">Range at current power</string> <string name="vehicle_row_range_trip">Range since start</string>
<string name="vehicle_row_range_average">Range at 5-min average</string> <string name="vehicle_row_range_average">Range at 5-min average</string>
<string name="vehicle_row_usage_trip">Usage since start</string>
<string name="vehicle_row_usage_average">Usage, 5-min average</string>
<string name="range_consumption">%1$.1f kWh/100 km</string> <string name="range_consumption">%1$.1f kWh/100 km</string>
<string name="range_over">&gt; %1$d 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_waiting">needs a few minutes of driving</string>
<string name="range_not_driving">only while driving</string> <string name="range_trip_waiting">once the car has moved</string>
<string name="usage_over_distance">over %1$.1f km</string>
<string name="usage_last_5_min">over the last 5 minutes</string>
<string name="hint_usable_capacity">Usable battery capacity</string> <string name="hint_usable_capacity">Usable battery capacity</string>
<string name="btn_save_capacity">Save 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="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>
@@ -3,6 +3,7 @@ package com.evsuite.abrp;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test; import org.junit.Test;
@@ -19,20 +20,49 @@ public class RangeEstimatorTest {
} }
@Test @Test
public void instantRangeFromPowerAndSpeed() { public void sinceStartCoversTheWholeTripNotJustFiveMinutes() {
// 15 kW at 100 km/h = 15 kWh/100 km; 25.4 kWh left = 169.3 km. RangeEstimator r = new RangeEstimator();
RangeEstimator.Estimate e = RangeEstimator.instant(25.4, 15f, 100f); // 10 minutes at 30 kW then 10 minutes at 10 kW, both at 100 km/h: 20 kWh/100 km overall.
assertNotNull(e); for (long t = 0; t <= 600_000; t += 15_000) r.add(t, 30f, 100f);
assertEquals(15.0, e.kwhPer100Km, 1e-9); for (long t = 615_000; t <= 1_200_000; t += 15_000) r.add(t, 10f, 100f);
assertEquals(169.333, e.rangeKm, 0.001); 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 @Test
public void noInstantEstimateWhenStandingRegeneratingOrUnread() { public void standingWithTheClimateOnCountsTowardsTheTrip() {
assertNull(RangeEstimator.instant(25.4, 2f, 3f)); // not really moving RangeEstimator r = new RangeEstimator();
assertNull(RangeEstimator.instant(25.4, -10f, 60f)); // regenerating for (long t = 0; t <= 120_000; t += 15_000) r.add(t, 3f, 0f); // warming up
assertNull(RangeEstimator.instant(25.4, null, 60f)); // power unread for (long t = 135_000; t <= 735_000; t += 15_000) r.add(t, 15f, 60f);
assertNull(RangeEstimator.instant(null, 15f, 60f)); // SOC unread 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 @Test