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. * * 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. An estimate is null when it cannot mean anything — not moved yet, too little * data — rather than a misleading number. Net regeneration is shown as it is: negative usage. * * 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. Times are on the monotonic clock * (SystemClock.elapsedRealtime): the head unit sets its wall clock from GPS, and a jump back * would make every new sample look out of order. 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; /** 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, 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, double distanceKm) { this.rangeKm = rangeKm; this.kwhPer100Km = kwhPer100Km; this.distanceKm = distanceKm; } } 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<>(); // 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 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(); } 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; } 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; } /** Net energy taken from the pack since start, in kWh; null before the trip's first sample. */ synchronized Double energySinceStart() { return tripLast == null ? null : tripEnergyKwh; } /** 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) return null; return estimate(remainingKwh, tripEnergyKwh, tripDistanceKm); } 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) return null; return estimate(remainingKwh, energyKwh, distanceKm); } /** * Net regeneration (downhill, recuperating) is a real reading: the usage is negative, and * the range is unbounded rather than negative. */ private static Estimate estimate(double remainingKwh, double energyKwh, double distanceKm) { double kwhPerKm = energyKwh / distanceKm; double rangeKm = kwhPerKm > 0 ? remainingKwh / kwhPerKm : Double.POSITIVE_INFINITY; return new Estimate(rangeKm, kwhPerKm * 100, distanceKm); } /** * Mean battery power over the last five minutes, time-weighted, standing included; null with * less than a minute of samples. Positive is drawn from the pack, as the Power tile shows it. */ synchronized Double averagePowerKw(long nowMs) { double energyKwh = 0; long covered = 0; Sample previous = null; for (Sample s : samples) { if (s.timeMs < nowMs - WINDOW_MS) continue; if (previous != null) { long dt = s.timeMs - previous.timeMs; if (dt > 0 && dt <= MAX_GAP_MS) { energyKwh += (previous.powerKw + s.powerKw) / 2 * (dt / 3_600_000.0); covered += dt; } } previous = s; } if (covered < MIN_WINDOW_MS) return null; return energyKwh / (covered / 3_600_000.0); } /** Mean speed over the last five minutes, time-weighted, standing included; null below a minute. */ synchronized Double averageSpeedKmh(long nowMs) { double distanceKm = 0; long covered = 0; Sample previous = null; for (Sample s : samples) { if (s.timeMs < nowMs - WINDOW_MS) continue; if (previous != null) { long dt = s.timeMs - previous.timeMs; if (dt > 0 && dt <= MAX_GAP_MS) { distanceKm += (previous.speedKmh + s.speedKmh) / 2 * (dt / 3_600_000.0); covered += dt; } } previous = s; } if (covered < MIN_WINDOW_MS) return null; return distanceKm / (covered / 3_600_000.0); } synchronized void clear() { samples.clear(); tripEnergyKwh = 0; tripDistanceKm = 0; tripLast = null; } }