diff --git a/app/build.gradle b/app/build.gradle index 09ee60c..a04f404 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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. diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index 74e1a33..dac4284 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -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); diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index fb7d9d6..57b4aa1 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -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 { diff --git a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java new file mode 100644 index 0000000..120db9a --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java @@ -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 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 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(); } +} diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml index 676aa76..ff3cb6b 100644 --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml @@ -57,5 +57,18 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml index da37318..1222f28 100644 --- a/app/src/main/res/layout/pane_consumption.xml +++ b/app/src/main/res/layout/pane_consumption.xml @@ -32,7 +32,12 @@ - + + + + + + diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml index f4cc6c7..6ce8956 100644 --- a/app/src/main/res/layout/pane_vehicle.xml +++ b/app/src/main/res/layout/pane_vehicle.xml @@ -54,6 +54,16 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b92d940..b99000d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,6 +31,17 @@ Charge (SOC) Range Power + Range at current power + Range at 5-min average + %1$.1f kWh/100 km + > %1$d km + needs a few minutes of driving + only while driving + 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. + Usable capacity saved + Enter a value between 10 and 150 kWh Speed Odometer Battery Temperature diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java new file mode 100644 index 0000000..6b73e59 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java @@ -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)); + } +}