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:
@@ -90,7 +90,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
|
||||
vehCharging, vehHvac,
|
||||
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. */
|
||||
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);
|
||||
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);
|
||||
vehRangeTrip = vehiclePane.findViewById(R.id.vehicle_range_trip);
|
||||
vehRangeTripNote = vehiclePane.findViewById(R.id.vehicle_range_trip_note);
|
||||
vehRangeAverage = vehiclePane.findViewById(R.id.vehicle_range_average);
|
||||
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() {
|
||||
@@ -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
|
||||
* also needs a minute or more of driving, which the service has usually collected already.
|
||||
* The range estimates since start and at the 5-minute average, each with its usage in a
|
||||
* 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) {
|
||||
RangeEstimator estimator = RangeEstimator.shared();
|
||||
long monotonicMs = android.os.SystemClock.elapsedRealtime();
|
||||
estimator.add(monotonicMs, 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, monotonicMs), R.string.range_waiting);
|
||||
RangeEstimator.Estimate trip = estimator.sinceStart(remaining);
|
||||
showEstimate(vehRangeTrip, vehRangeTripNote, vehUsageTrip, vehUsageTripNote, trip,
|
||||
trip == null ? "" : getString(R.string.usage_over_distance, trip.distanceKm),
|
||||
R.string.range_trip_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) {
|
||||
value.setText("—");
|
||||
note.setText(whyNot);
|
||||
range.setText("—");
|
||||
rangeNote.setText(whyNot);
|
||||
usage.setText("—");
|
||||
usageNote.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)
|
||||
range.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));
|
||||
rangeNote.setText("");
|
||||
usage.setText(getString(R.string.range_consumption, e.kwhPer100Km));
|
||||
usageNote.setText(basis);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,8 +6,13 @@ 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)
|
||||
* 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)
|
||||
*
|
||||
* "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
|
||||
* is a setting. Every estimate is null when it cannot mean anything — standing still,
|
||||
@@ -24,25 +29,30 @@ final class RangeEstimator {
|
||||
/** 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;
|
||||
|
||||
/** 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
|
||||
|
||||
private static final RangeEstimator SHARED = new RangeEstimator();
|
||||
|
||||
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 {
|
||||
final double rangeKm;
|
||||
final double kwhPer100Km;
|
||||
final double distanceKm;
|
||||
|
||||
Estimate(double rangeKm, double kwhPer100Km) {
|
||||
Estimate(double rangeKm, double kwhPer100Km, double distanceKm) {
|
||||
this.rangeKm = rangeKm;
|
||||
this.kwhPer100Km = kwhPer100Km;
|
||||
this.distanceKm = distanceKm;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,12 +70,19 @@ final class RangeEstimator {
|
||||
|
||||
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. */
|
||||
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)));
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -74,11 +91,30 @@ final class RangeEstimator {
|
||||
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);
|
||||
private void addToTrip(Sample s) {
|
||||
boolean charging = s.speedKmh < 1 && s.powerKw < CHARGING_KW;
|
||||
if (charging || (tripLast != null && s.timeMs - tripLast.timeMs > TRIP_RESET_GAP_MS)) {
|
||||
tripEnergyKwh = 0;
|
||||
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) {
|
||||
@@ -102,8 +138,13 @@ final class RangeEstimator {
|
||||
}
|
||||
if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM || energyKwh <= 0) return null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user