Fix consumption tracking while charging
This commit is contained in:
@@ -4,6 +4,47 @@ All notable changes to this project are documented here. Format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
||||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.2.33] - 2026-10-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The SOC total no longer loses its meaning after a charge. The consumption counters
|
||||||
|
decided whether the car was charging from `EV_CHARGE_PORT_CONNECTED` alone, and this
|
||||||
|
vehicle's VHAL answers `false` to it with the cable in — the documented reason
|
||||||
|
`ChargingSignal` exists. A charge therefore read as a drive, and every percent gained
|
||||||
|
was booked as negative consumption against the total.
|
||||||
|
- The charging screen and the consumption counters now share one answer, in the pure
|
||||||
|
`ChargingState`, so they cannot drift apart again. A charge is the vendor status saying
|
||||||
|
so, or power flowing into the pack with the port agreeing or the car standing still —
|
||||||
|
a stationary car cannot regenerate.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The stored SOC column is zeroed once on upgrade. Distance, energy and time never
|
||||||
|
consulted the charge state, are unaffected by the bug, and are kept.
|
||||||
|
|
||||||
|
## [2.2.32] - 2026-10-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Consumption now disagrees with neither itself nor the car. Lifetime was integrated from
|
||||||
|
net DC power while the day and trip counters took either the vehicle's own cumulative
|
||||||
|
kWh register or a fallback that discarded regeneration — three sources, three answers,
|
||||||
|
and the fallback read high because recovered energy never subtracted. Every period is
|
||||||
|
now integrated from one step of DriveHub_Dort's algorithm: trapezoidal net DC power,
|
||||||
|
trapezoidal calibrated speed, and compensated (Kahan) accumulation.
|
||||||
|
- Integration intervals are measured on the monotonic clock. The head unit's wall clock
|
||||||
|
jumps when it picks up GPS or NTP time, which previously either dropped intervals or
|
||||||
|
invented hours of driving from a single forward jump.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Consumption counters start from zero on this release. The stored totals were produced by
|
||||||
|
the mixed algorithms above and no factor converts them into what is now measured, so the
|
||||||
|
old store is discarded rather than carried forward as a plausible-looking total.
|
||||||
|
- `ConsumptionMath` holds the integration as a pure, unit-tested class, per the repository's
|
||||||
|
rule that logic lives outside the Android classes.
|
||||||
|
|
||||||
## [2.2.18] - 2026-09-30
|
## [2.2.18] - 2026-09-30
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
7b02485c155facba0af522861cee88760c62ecdcc366c4d885d473595d2750b2 FatihsMG4-unstable-2.2.33.apk
|
||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.evsuite.abrp"
|
applicationId "com.evsuite.abrp"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 48
|
versionCode 50
|
||||||
versionName "2.2.31"
|
versionName "2.2.33"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the pack taking energy right now? Pure, unit-tested, and the single answer the
|
||||||
|
* charging screen and the consumption counters both use.
|
||||||
|
*
|
||||||
|
* They used to answer it separately: the screen with a composite test, the counters with
|
||||||
|
* the raw {@code EV_CHARGE_PORT_CONNECTED} property alone. That property is optional in
|
||||||
|
* AAOS and this vehicle's VHAL answers {@code false} on a plugged-in car — see
|
||||||
|
* {@link ChargingSignal}, which exists for the same reason. So the counters read a charge
|
||||||
|
* as a drive, SOC climbed instead of falling, and every rising percent was booked as
|
||||||
|
* negative consumption against the lifetime total.
|
||||||
|
*/
|
||||||
|
final class ChargingState {
|
||||||
|
|
||||||
|
/** Charge in progress, as the vendor charging-status property reports it. */
|
||||||
|
private static final int STATUS_AC_CHARGING = 1;
|
||||||
|
private static final int STATUS_DC_CHARGING = 10;
|
||||||
|
|
||||||
|
/** Below this (i.e. more negative than) the pack is taking energy, not reading noise. */
|
||||||
|
static final float CHARGING_POWER_KW = -0.3f;
|
||||||
|
|
||||||
|
/** At or under this the car is standing still; same dead band the integrator uses. */
|
||||||
|
private static final float STANDSTILL_KMH = ConsumptionMath.SPEED_DEAD_BAND_KMH;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param status vendor charging status, null when unreadable
|
||||||
|
* @param portConnected charge port property, null when unreadable — and false on this
|
||||||
|
* car even when the cable is in, which is why it cannot stand alone
|
||||||
|
* @param powerKw battery power, negative into the pack, null when unreadable
|
||||||
|
* @param speedKmh road speed, null when unreadable
|
||||||
|
*/
|
||||||
|
static boolean isCharging(Integer status, Boolean portConnected, Float powerKw, Float speedKmh) {
|
||||||
|
if (status != null && (status == STATUS_AC_CHARGING || status == STATUS_DC_CHARGING)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (powerKw == null || powerKw >= CHARGING_POWER_KW) return false;
|
||||||
|
|
||||||
|
// Power is flowing in. Either the port agrees, or the car is not moving — and a
|
||||||
|
// stationary car cannot regenerate, so inbound power at a standstill is a charge.
|
||||||
|
if (Boolean.TRUE.equals(portConnected)) return true;
|
||||||
|
return speedKmh == null || Math.abs(speedKmh) < STANDSTILL_KMH;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChargingState() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One integration step of distance, energy, time and SOC — pure, Android-free, unit-tested.
|
||||||
|
*
|
||||||
|
* This is DriveHub_Dort's algorithm, which has proven stable on this car over months, and
|
||||||
|
* it is now the *only* algorithm: every period (lifetime, day, trip A, trip B) is fed from
|
||||||
|
* the same step. Earlier builds integrated the lifetime counter from net DC power while
|
||||||
|
* feeding the trip and day counters from either the vehicle's own cumulative kWh register
|
||||||
|
* or a regeneration-discarding gross fallback. Three sources, three answers, none of them
|
||||||
|
* agreeing with the car's dashboard — the counters were not measuring the same quantity.
|
||||||
|
*
|
||||||
|
* What the vehicle reports is net DC battery power: positive out of the pack, negative in.
|
||||||
|
* Integrating it as-is means regeneration subtracts, which is exactly what the car's own
|
||||||
|
* kWh figure does. Discarding the negative half (the old {@code max(0, kW)} fallback) is
|
||||||
|
* what made our number read high.
|
||||||
|
*/
|
||||||
|
final class ConsumptionMath {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longest gap we will still integrate across, in hours (6 minutes).
|
||||||
|
*
|
||||||
|
* A head unit that slept, a service that was killed, or a car that sat parked overnight
|
||||||
|
* all show up as one enormous dt. Bridging it would invent a drive that never happened,
|
||||||
|
* so the gap is dropped and the next sample starts a fresh interval.
|
||||||
|
*/
|
||||||
|
static final double MAX_GAP_HOURS = 0.1d;
|
||||||
|
|
||||||
|
/** Below this the speed signal is noise around a standstill, not motion. */
|
||||||
|
static final float SPEED_DEAD_BAND_KMH = 2.5f;
|
||||||
|
|
||||||
|
/** A reading past this is a decode artefact, not power the pack could ever deliver. */
|
||||||
|
static final float MAX_PLAUSIBLE_KW = 3000f;
|
||||||
|
|
||||||
|
/** Result of one interval. All fields are increments, never totals. */
|
||||||
|
static final class Step {
|
||||||
|
final double km, kwh, hours, socDrop;
|
||||||
|
|
||||||
|
Step(double km, double kwh, double hours, double socDrop) {
|
||||||
|
this.km = km;
|
||||||
|
this.kwh = kwh;
|
||||||
|
this.hours = hours;
|
||||||
|
this.socDrop = socDrop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integrates one interval, or returns null when the interval carries no usable data.
|
||||||
|
*
|
||||||
|
* Every input is nullable because a failed vehicle read is not a zero: a missing power
|
||||||
|
* reading suppresses the energy term for this interval only, and leaves distance and
|
||||||
|
* time — which do not depend on it — intact.
|
||||||
|
*
|
||||||
|
* @param hours elapsed time, from a monotonic clock
|
||||||
|
* @param charging whether the charge port is connected; SOC gained on a charger is not
|
||||||
|
* consumption and must not offset the drive's SOC drop
|
||||||
|
*/
|
||||||
|
static Step integrate(double hours,
|
||||||
|
Float previousSpeedKmh, Float speedKmh,
|
||||||
|
Float previousPowerKw, Float powerKw,
|
||||||
|
Float previousSocPercent, Float socPercent,
|
||||||
|
boolean charging) {
|
||||||
|
if (!(hours > 0d) || hours > MAX_GAP_HOURS) return null;
|
||||||
|
|
||||||
|
// Distance: trapezoidal speed across the interval. The calibration factors correct
|
||||||
|
// the head unit's speed signal against the odometer at motorway speeds, where the
|
||||||
|
// reported value runs slightly low.
|
||||||
|
float current = speedKmh != null ? Math.abs(speedKmh)
|
||||||
|
: (previousSpeedKmh != null ? Math.abs(previousSpeedKmh) : 0f);
|
||||||
|
float previous = previousSpeedKmh != null ? Math.abs(previousSpeedKmh) : current;
|
||||||
|
float effectiveKmh = (previous + current) * 0.5f;
|
||||||
|
if (effectiveKmh < SPEED_DEAD_BAND_KMH) effectiveKmh = 0f;
|
||||||
|
else if (effectiveKmh > 90f) effectiveKmh *= 1.0035f;
|
||||||
|
else if (effectiveKmh > 30f) effectiveKmh *= 1.0015f;
|
||||||
|
double km = effectiveKmh * hours;
|
||||||
|
|
||||||
|
// Energy: trapezoidal net DC power, regeneration included with its own sign.
|
||||||
|
double kwh = 0d;
|
||||||
|
if (powerKw != null && Math.abs(powerKw) <= MAX_PLAUSIBLE_KW) {
|
||||||
|
if (current == 0f && powerKw < 0f) {
|
||||||
|
// Standing still with power flowing in is charging or pre-conditioning
|
||||||
|
// recovery, not a drive crediting itself energy it never spent.
|
||||||
|
kwh = 0d;
|
||||||
|
} else {
|
||||||
|
double effectiveKw = previousPowerKw != null && Math.abs(previousPowerKw) <= MAX_PLAUSIBLE_KW
|
||||||
|
? (previousPowerKw + powerKw) * 0.5d
|
||||||
|
: powerKw;
|
||||||
|
kwh = effectiveKw * hours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double socDrop = (!charging && previousSocPercent != null && socPercent != null)
|
||||||
|
? previousSocPercent - socPercent
|
||||||
|
: 0d;
|
||||||
|
|
||||||
|
return new Step(km, kwh, hours, socDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConsumptionMath() { }
|
||||||
|
}
|
||||||
@@ -2,13 +2,21 @@ package com.evsuite.abrp;
|
|||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
|
||||||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||||||
import com.evsuite.hardware.FirmwareInfo;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
/** Persistent, read-only trip/lifetime integration patterned after DriveHub_Dort. */
|
/**
|
||||||
|
* Persistent, read-only trip/lifetime integration.
|
||||||
|
*
|
||||||
|
* All five periods are integrated from one {@link ConsumptionMath} step, so they can only
|
||||||
|
* ever disagree by what they cover, never by how they were measured. The counters live in
|
||||||
|
* {@link KahanSum} accumulators in memory and reach storage on a 30-second throttle; the
|
||||||
|
* store is new in 2.2.32, which is what resets the mixed-algorithm history inherited from
|
||||||
|
* earlier builds.
|
||||||
|
*/
|
||||||
final class ConsumptionTracker {
|
final class ConsumptionTracker {
|
||||||
enum Period { LIFETIME, WEEK, MONTH, TRIP_A, TRIP_B }
|
enum Period { LIFETIME, WEEK, MONTH, TRIP_A, TRIP_B }
|
||||||
|
|
||||||
@@ -19,7 +27,17 @@ final class ConsumptionTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String PREFS = "consumption_counters_v1";
|
/**
|
||||||
|
* Bumped from v1 deliberately. The old store holds totals accumulated under three
|
||||||
|
* different algorithms; there is no factor that converts them into what this one
|
||||||
|
* measures, so they are discarded rather than carried forward as a plausible lie.
|
||||||
|
*/
|
||||||
|
private static final String PREFS = "consumption_counters_v2";
|
||||||
|
private static final String LEGACY_PREFS = "consumption_counters_v1";
|
||||||
|
|
||||||
|
/** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */
|
||||||
|
private static final long PERSIST_INTERVAL_MS = 30_000L;
|
||||||
|
|
||||||
private static ConsumptionTracker instance;
|
private static ConsumptionTracker instance;
|
||||||
static synchronized ConsumptionTracker get(Context context) {
|
static synchronized ConsumptionTracker get(Context context) {
|
||||||
if (instance == null) instance = new ConsumptionTracker(context.getApplicationContext());
|
if (instance == null) instance = new ConsumptionTracker(context.getApplicationContext());
|
||||||
@@ -27,112 +45,117 @@ final class ConsumptionTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private final SharedPreferences prefs;
|
private final SharedPreferences prefs;
|
||||||
private Totals lifetime, tripA, tripB;
|
private final Counter lifetime = new Counter();
|
||||||
private long lastMs;
|
private final Counter tripA = new Counter();
|
||||||
private Float lastSpeed, lastPower, lastSoc, lastVehicleConsumedKwh;
|
private final Counter tripB = new Counter();
|
||||||
|
private final Counter today = new Counter();
|
||||||
|
private LocalDate todayDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monotonic. {@code EnergySnapshot.timestampMs} is wall clock, and the head unit's
|
||||||
|
* clock jumps when it picks up GPS or NTP time — backwards, in which case intervals
|
||||||
|
* were silently dropped, or forwards, in which case one jump invented hours of driving.
|
||||||
|
*/
|
||||||
|
private long lastElapsedMs;
|
||||||
|
private long lastPersistMs;
|
||||||
|
private Float lastSpeed, lastPower, lastSoc;
|
||||||
|
|
||||||
private ConsumptionTracker(Context context) {
|
private ConsumptionTracker(Context context) {
|
||||||
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
lifetime = load("life"); tripA = load("a"); tripB = load("b");
|
discardLegacyStore(context);
|
||||||
migrateCalendarCounters();
|
repairSocColumn();
|
||||||
|
lifetime.load(prefs, "life");
|
||||||
|
tripA.load(prefs, "a");
|
||||||
|
tripB.load(prefs, "b");
|
||||||
|
todayDate = LocalDate.now();
|
||||||
|
today.load(prefs, dayKey(todayDate));
|
||||||
|
pruneOldDays();
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized void sample(EnergySnapshot s) {
|
synchronized void sample(EnergySnapshot s) {
|
||||||
migrateSwi69DistanceScale(s.getFirmware());
|
long now = SystemClock.elapsedRealtime();
|
||||||
long now = s.getTimestampMs();
|
long previous = lastElapsedMs;
|
||||||
if (lastMs > 0 && now > lastMs) {
|
lastElapsedMs = now;
|
||||||
double hours = (now - lastMs) / 3_600_000d;
|
|
||||||
// Never bridge a process pause/sleep into invented driving data.
|
|
||||||
if (hours <= 0.1d) {
|
|
||||||
Float speed = s.getSpeedKmh();
|
Float speed = s.getSpeedKmh();
|
||||||
Float power = s.getBatteryPowerKw();
|
Float power = s.getBatteryPowerKw();
|
||||||
double km = speed == null ? 0d : Math.max(0d,
|
Float soc = s.getSocPercent();
|
||||||
((lastSpeed == null ? speed : lastSpeed) + speed) * 0.5d) * hours;
|
boolean charging = ChargingState.isCharging(
|
||||||
// DriveHub_Dort lifetime algorithm: trapezoidal speed, the same dead band
|
s.getChargingStatus(), s.getChargePortConnected(), power, speed);
|
||||||
// and calibration factors, and net DC power including regeneration.
|
|
||||||
float currentSpeed = speed != null ? Math.abs(speed)
|
double hours = previous > 0L ? (now - previous) / 3_600_000d : 0d;
|
||||||
: (lastSpeed == null ? 0f : Math.abs(lastSpeed));
|
ConsumptionMath.Step step = ConsumptionMath.integrate(
|
||||||
float lifetimeSpeed = lastSpeed == null ? currentSpeed
|
hours, lastSpeed, speed, lastPower, power, lastSoc, soc, charging);
|
||||||
: (Math.abs(lastSpeed) + currentSpeed) * 0.5f;
|
|
||||||
if (lifetimeSpeed < 2.5f) lifetimeSpeed = 0f;
|
// Only a real reading becomes the next interval's trapezoid partner; a failed read
|
||||||
else if (lifetimeSpeed > 90f) lifetimeSpeed *= 1.0035f;
|
// must not erase the last known value and turn the next interval into a step change.
|
||||||
else if (lifetimeSpeed > 30f) lifetimeSpeed *= 1.0015f;
|
if (speed != null) lastSpeed = speed;
|
||||||
double lifetimeKm = lifetimeSpeed * hours;
|
if (power != null) lastPower = power;
|
||||||
double lifetimeKwh = 0d;
|
if (soc != null) lastSoc = soc;
|
||||||
if (power != null) {
|
|
||||||
if (currentSpeed == 0f && power < 0f) {
|
if (step == null) return;
|
||||||
lifetimeKwh = 0d;
|
|
||||||
} else {
|
rollDayIfNeeded();
|
||||||
double effectiveKw = lastPower == null ? power : (lastPower + power) * 0.5d;
|
lifetime.add(step);
|
||||||
lifetimeKwh = effectiveKw * hours;
|
tripA.add(step);
|
||||||
}
|
tripB.add(step);
|
||||||
}
|
today.add(step);
|
||||||
Float vehicleConsumed = s.getVehicleConsumedKwh();
|
|
||||||
double kwh;
|
if (now - lastPersistMs >= PERSIST_INTERVAL_MS) persist();
|
||||||
if (vehicleConsumed != null && lastVehicleConsumedKwh != null
|
|
||||||
&& vehicleConsumed >= lastVehicleConsumedKwh
|
|
||||||
&& vehicleConsumed - lastVehicleConsumedKwh <= 5f) {
|
|
||||||
kwh = vehicleConsumed - lastVehicleConsumedKwh;
|
|
||||||
} else {
|
|
||||||
// Gross draw fallback: regeneration/charging must not make consumption
|
|
||||||
// look artificially low when the OEM cumulative counter is unavailable.
|
|
||||||
double effectiveKw = power == null ? 0d
|
|
||||||
: ((lastPower == null ? power : lastPower) + power) * 0.5d;
|
|
||||||
kwh = Math.max(0d, effectiveKw) * hours;
|
|
||||||
}
|
|
||||||
double dsoc = (lastSoc == null || s.getSocPercent() == null ||
|
|
||||||
Boolean.TRUE.equals(s.getChargePortConnected())) ? 0d : lastSoc - s.getSocPercent();
|
|
||||||
lifetime = add(lifetime, lifetimeKm, lifetimeKwh, hours, dsoc);
|
|
||||||
String dayKey = "day_" + LocalDate.now();
|
|
||||||
SharedPreferences.Editor daily = prefs.edit();
|
|
||||||
save(daily, dayKey, add(load(dayKey), km, kwh, hours, dsoc));
|
|
||||||
daily.commit();
|
|
||||||
pruneOldDays();
|
|
||||||
tripA = add(tripA, km, kwh, hours, dsoc);
|
|
||||||
tripB = add(tripB, km, kwh, hours, dsoc);
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastMs = now; lastSpeed = s.getSpeedKmh(); lastPower = s.getBatteryPowerKw();
|
|
||||||
lastSoc = s.getSocPercent();
|
|
||||||
lastVehicleConsumedKwh = s.getVehicleConsumedKwh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized Totals totals(Period period) {
|
synchronized Totals totals(Period period) {
|
||||||
switch (period) {
|
switch (period) {
|
||||||
case WEEK: return rollingDays(7); case MONTH: return rollingDays(30); case TRIP_A: return tripA;
|
case WEEK: return rollingDays(7);
|
||||||
case TRIP_B: return tripB; default: return lifetime;
|
case MONTH: return rollingDays(30);
|
||||||
|
case TRIP_A: return tripA.snapshot();
|
||||||
|
case TRIP_B: return tripB.snapshot();
|
||||||
|
default: return lifetime.snapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized void reset(Period period) {
|
synchronized void reset(Period period) {
|
||||||
if (period == Period.TRIP_A) tripA = zero();
|
if (period == Period.TRIP_A) tripA.reset();
|
||||||
else if (period == Period.TRIP_B) tripB = zero();
|
else if (period == Period.TRIP_B) tripB.reset();
|
||||||
else return;
|
else return;
|
||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized void persist() {
|
synchronized void persist() {
|
||||||
|
lastPersistMs = SystemClock.elapsedRealtime();
|
||||||
SharedPreferences.Editor e = prefs.edit();
|
SharedPreferences.Editor e = prefs.edit();
|
||||||
save(e, "life", lifetime);
|
lifetime.save(e, "life");
|
||||||
save(e, "a", tripA); save(e, "b", tripB);
|
tripA.save(e, "a");
|
||||||
|
tripB.save(e, "b");
|
||||||
|
today.save(e, dayKey(todayDate));
|
||||||
e.commit();
|
e.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Totals rollingDays(int days) {
|
|
||||||
Totals result = zero();
|
|
||||||
LocalDate today = LocalDate.now();
|
|
||||||
for (int i = 0; i < days; i++) {
|
|
||||||
Totals day = load("day_" + today.minusDays(i));
|
|
||||||
result = add(result, day.km, day.kwh, day.hours, day.soc);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized Totals totalsForDay(LocalDate date) {
|
synchronized Totals totalsForDay(LocalDate date) {
|
||||||
if (date == null || date.isAfter(LocalDate.now()) || date.isBefore(LocalDate.now().minusMonths(4)))
|
if (date == null || date.isAfter(LocalDate.now()) || date.isBefore(LocalDate.now().minusMonths(4)))
|
||||||
return zero();
|
return zero();
|
||||||
return load("day_" + date);
|
if (date.equals(todayDate)) return today.snapshot();
|
||||||
|
return loadTotals(dayKey(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flushes the day that just ended and opens the new one. */
|
||||||
|
private void rollDayIfNeeded() {
|
||||||
|
LocalDate date = LocalDate.now();
|
||||||
|
if (date.equals(todayDate)) return;
|
||||||
|
persist();
|
||||||
|
todayDate = date;
|
||||||
|
today.load(prefs, dayKey(date));
|
||||||
|
pruneOldDays();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Totals rollingDays(int days) {
|
||||||
|
double km = 0, kwh = 0, hours = 0, soc = 0;
|
||||||
|
LocalDate today_ = LocalDate.now();
|
||||||
|
for (int i = 0; i < days; i++) {
|
||||||
|
Totals day = totalsForDay(today_.minusDays(i));
|
||||||
|
km += day.km; kwh += day.kwh; hours += day.hours; soc += day.soc;
|
||||||
|
}
|
||||||
|
return new Totals(km, kwh, hours, soc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pruneOldDays() {
|
private void pruneOldDays() {
|
||||||
@@ -151,42 +174,85 @@ final class ConsumptionTracker {
|
|||||||
if (editor != null) editor.commit();
|
if (editor != null) editor.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preserve anything gathered by 2.2.24 by treating its just-created counter as today. */
|
/**
|
||||||
private void migrateCalendarCounters() {
|
* Zeroes the SOC column once, for 2.2.33.
|
||||||
if (prefs.getBoolean("rolling_days_migrated", false)) return;
|
*
|
||||||
Totals old = load("week");
|
* Until then a charge was detected from the charge-port property alone, which this
|
||||||
SharedPreferences.Editor e = prefs.edit();
|
* vehicle answers {@code false} to even when the cable is in, so every percent gained
|
||||||
if (old.km != 0 || old.kwh != 0 || old.hours != 0 || old.soc != 0)
|
* on a charger was booked as negative consumption. Only this column is affected —
|
||||||
save(e, "day_" + LocalDate.now(), old);
|
* distance, energy and time never consulted the charge state — so the rest of the
|
||||||
e.putBoolean("rolling_days_migrated", true).commit();
|
* store is sound and is kept.
|
||||||
pruneOldDays();
|
*/
|
||||||
}
|
private void repairSocColumn() {
|
||||||
|
if (prefs.getBoolean("soc_charge_detection_v2", false)) return;
|
||||||
/** 2.2.27 and earlier multiplied SWI69's already-km/h speed by 3.6. Repair distance only. */
|
|
||||||
private void migrateSwi69DistanceScale(FirmwareInfo.Gen firmware) {
|
|
||||||
if (firmware != FirmwareInfo.Gen.SWI69 || prefs.getBoolean("swi69_distance_v2", false)) return;
|
|
||||||
SharedPreferences.Editor editor = prefs.edit();
|
SharedPreferences.Editor editor = prefs.edit();
|
||||||
for (java.util.Map.Entry<String, ?> entry : prefs.getAll().entrySet()) {
|
for (String key : prefs.getAll().keySet()) {
|
||||||
String key = entry.getKey();
|
if (key.endsWith("_soc")) editor.putLong(key, Double.doubleToRawLongBits(0d));
|
||||||
if (!key.endsWith("_km") || !(entry.getValue() instanceof Long)) continue;
|
|
||||||
double oldKm = Double.longBitsToDouble((Long) entry.getValue());
|
|
||||||
editor.putLong(key, Double.doubleToRawLongBits(oldKm / 3.6d));
|
|
||||||
}
|
}
|
||||||
editor.putBoolean("swi69_distance_v2", true).commit();
|
editor.putBoolean("soc_charge_detection_v2", true).commit();
|
||||||
lifetime = load("life"); tripA = load("a"); tripB = load("b");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deletes the pre-2.2.32 store once, so its mixed-algorithm totals cannot be read back. */
|
||||||
|
private void discardLegacyStore(Context context) {
|
||||||
|
if (prefs.getBoolean("legacy_v1_discarded", false)) return;
|
||||||
|
try {
|
||||||
|
context.deleteSharedPreferences(LEGACY_PREFS);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// An older platform without the API: the v1 file is simply never read again.
|
||||||
|
}
|
||||||
|
prefs.edit().putBoolean("legacy_v1_discarded", true).commit();
|
||||||
|
}
|
||||||
|
|
||||||
private Totals load(String key) { return new Totals(bits(key,"km"), bits(key,"kwh"), bits(key,"h"), bits(key,"soc")); }
|
private static String dayKey(LocalDate date) { return "day_" + date; }
|
||||||
private double bits(String key, String field) { return Double.longBitsToDouble(prefs.getLong(key + "_" + field, 0L)); }
|
|
||||||
private static void save(SharedPreferences.Editor e, String key, Totals t) {
|
private Totals loadTotals(String key) {
|
||||||
e.putLong(key+"_km", Double.doubleToRawLongBits(t.km));
|
return new Totals(bits(key, "km"), bits(key, "kwh"), bits(key, "h"), bits(key, "soc"));
|
||||||
e.putLong(key+"_kwh", Double.doubleToRawLongBits(t.kwh));
|
|
||||||
e.putLong(key+"_h", Double.doubleToRawLongBits(t.hours));
|
|
||||||
e.putLong(key+"_soc", Double.doubleToRawLongBits(t.soc));
|
|
||||||
}
|
}
|
||||||
private static Totals add(Totals t, double km, double kwh, double h, double soc) {
|
|
||||||
return new Totals(t.km + km, t.kwh + kwh, t.hours + h, t.soc + soc);
|
private double bits(String key, String field) {
|
||||||
|
return Double.longBitsToDouble(prefs.getLong(key + "_" + field, 0L));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Totals zero() { return new Totals(0, 0, 0, 0); }
|
private static Totals zero() { return new Totals(0, 0, 0, 0); }
|
||||||
|
|
||||||
|
/** The four compensated accumulators that make up one period. */
|
||||||
|
private static final class Counter {
|
||||||
|
private final KahanSum km = new KahanSum();
|
||||||
|
private final KahanSum kwh = new KahanSum();
|
||||||
|
private final KahanSum hours = new KahanSum();
|
||||||
|
private final KahanSum soc = new KahanSum();
|
||||||
|
|
||||||
|
void add(ConsumptionMath.Step step) {
|
||||||
|
km.add(step.km);
|
||||||
|
kwh.add(step.kwh);
|
||||||
|
hours.add(step.hours);
|
||||||
|
soc.add(step.socDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
void load(SharedPreferences prefs, String key) {
|
||||||
|
km.setTotal(read(prefs, key, "km"));
|
||||||
|
kwh.setTotal(read(prefs, key, "kwh"));
|
||||||
|
hours.setTotal(read(prefs, key, "h"));
|
||||||
|
soc.setTotal(read(prefs, key, "soc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void save(SharedPreferences.Editor e, String key) {
|
||||||
|
e.putLong(key + "_km", Double.doubleToRawLongBits(km.get()));
|
||||||
|
e.putLong(key + "_kwh", Double.doubleToRawLongBits(kwh.get()));
|
||||||
|
e.putLong(key + "_h", Double.doubleToRawLongBits(hours.get()));
|
||||||
|
e.putLong(key + "_soc", Double.doubleToRawLongBits(soc.get()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
km.reset(); kwh.reset(); hours.reset(); soc.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
Totals snapshot() {
|
||||||
|
return new Totals(km.get(), kwh.get(), hours.get(), soc.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double read(SharedPreferences prefs, String key, String field) {
|
||||||
|
return Double.longBitsToDouble(prefs.getLong(key + "_" + field, 0L));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neumaier–Kahan compensated summation.
|
||||||
|
*
|
||||||
|
* Consumption is integrated from thousands of sub-watt-hour slices per drive. A plain
|
||||||
|
* {@code sum += x} loses the low bits of every one of them, and the error is one-signed,
|
||||||
|
* so it accumulates rather than cancelling. Ported from DriveHub_Dort, where this is what
|
||||||
|
* keeps the lifetime counter honest over months.
|
||||||
|
*/
|
||||||
|
final class KahanSum {
|
||||||
|
|
||||||
|
private double sum;
|
||||||
|
private double compensation;
|
||||||
|
|
||||||
|
void add(double input) {
|
||||||
|
double y = input - compensation;
|
||||||
|
double t = sum + y;
|
||||||
|
compensation = (t - sum) - y;
|
||||||
|
sum = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get() {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the total and the compensation term. */
|
||||||
|
void reset() {
|
||||||
|
sum = 0d;
|
||||||
|
compensation = 0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seeds the total from persistent storage; the compensation term starts clean. */
|
||||||
|
void setTotal(double total) {
|
||||||
|
sum = total;
|
||||||
|
compensation = 0d;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -893,9 +893,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private void refreshCharging(EnergySnapshot s) {
|
private void refreshCharging(EnergySnapshot s) {
|
||||||
if (chargeStatus == null) return;
|
if (chargeStatus == null) return;
|
||||||
Integer code = s.getChargingStatus();
|
Integer code = s.getChargingStatus();
|
||||||
boolean charging = (code != null && (code == 1 || code == 10)) ||
|
boolean charging = ChargingState.isCharging(
|
||||||
(Boolean.TRUE.equals(s.getChargePortConnected()) && s.getBatteryPowerKw() != null
|
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
|
||||||
&& s.getBatteryPowerKw() < -0.2f);
|
|
||||||
long now = s.getTimestampMs();
|
long now = s.getTimestampMs();
|
||||||
if (charging && chargeStartMs == 0) { chargeStartMs = now; chargedKwh = 0; }
|
if (charging && chargeStartMs == 0) { chargeStartMs = now; chargedKwh = 0; }
|
||||||
if (!charging) { chargeStartMs = 0; lastChargeMs = 0; chargedKwh = 0; }
|
if (!charging) { chargeStartMs = 0; lastChargeMs = 0; chargedKwh = 0; }
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class ChargingStateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void vendorStatusIsConclusiveOnItsOwn() {
|
||||||
|
// The port says false and the power is unreadable: status still decides.
|
||||||
|
assertTrue(ChargingState.isCharging(1, Boolean.FALSE, null, null));
|
||||||
|
assertTrue(ChargingState.isCharging(10, Boolean.FALSE, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anIdleStatusDoesNotByItselfRuleChargingOut() {
|
||||||
|
// Status 0 with the cable in and power flowing in is still a charge; the vendor
|
||||||
|
// code is one signal among several, not a veto.
|
||||||
|
assertTrue(ChargingState.isCharging(0, Boolean.TRUE, -7f, 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void inboundPowerAtAStandstillIsACharge() {
|
||||||
|
// This is the case the old port-only test missed: plugged in, port reports false.
|
||||||
|
assertTrue(ChargingState.isCharging(null, Boolean.FALSE, -7f, 0f));
|
||||||
|
assertTrue(ChargingState.isCharging(null, null, -45f, 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void inboundPowerWhileMovingIsRegenerationNotCharging() {
|
||||||
|
assertFalse(ChargingState.isCharging(null, Boolean.FALSE, -20f, 60f));
|
||||||
|
assertFalse(ChargingState.isCharging(null, null, -20f, 60f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void thePortStillCountsWhenItDoesAnswer() {
|
||||||
|
assertTrue(ChargingState.isCharging(null, Boolean.TRUE, -7f, 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void outboundPowerIsNeverACharge() {
|
||||||
|
assertFalse(ChargingState.isCharging(null, Boolean.TRUE, 20f, 60f));
|
||||||
|
assertFalse(ChargingState.isCharging(0, Boolean.TRUE, 2f, 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void noiseAroundZeroIsNotACharge() {
|
||||||
|
assertFalse(ChargingState.isCharging(null, Boolean.TRUE, -0.1f, 0f));
|
||||||
|
assertFalse(ChargingState.isCharging(null, Boolean.TRUE, 0f, 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void unreadablePowerWithoutAStatusIsNotAssumedToBeACharge() {
|
||||||
|
assertFalse(ChargingState.isCharging(null, Boolean.TRUE, null, 0f));
|
||||||
|
assertFalse(ChargingState.isCharging(null, null, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void unreadableSpeedWithInboundPowerFallsBackToCharging() {
|
||||||
|
// Conservative on purpose: without a speed we cannot tell a charge from regen, and
|
||||||
|
// treating it as a charge only suppresses a SOC delta. The opposite choice invents
|
||||||
|
// negative consumption, which is the bug this class was written to stop.
|
||||||
|
assertTrue(ChargingState.isCharging(null, null, -7f, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void creepingSpeedIsStillAStandstill() {
|
||||||
|
// Same dead band the integrator uses, so the two cannot disagree about motion.
|
||||||
|
assertTrue(ChargingState.isCharging(null, null, -7f, 1f));
|
||||||
|
assertFalse(ChargingState.isCharging(null, null, -7f, 10f));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class ConsumptionMathTest {
|
||||||
|
|
||||||
|
/** One minute, comfortably inside {@link ConsumptionMath#MAX_GAP_HOURS}. */
|
||||||
|
private static final double DT = 1d / 60d;
|
||||||
|
|
||||||
|
/** Float inputs widen to double exactly, but the arithmetic is done in float. */
|
||||||
|
private static final double TOLERANCE = 1e-6;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void nonPositiveOrOversizedIntervalsAreDropped() {
|
||||||
|
assertNull(ConsumptionMath.integrate(0d, 50f, 50f, 20f, 20f, 80f, 79f, false));
|
||||||
|
assertNull(ConsumptionMath.integrate(-1d, 50f, 50f, 20f, 20f, 80f, 79f, false));
|
||||||
|
// A process pause must not be bridged into invented driving data.
|
||||||
|
assertNull(ConsumptionMath.integrate(ConsumptionMath.MAX_GAP_HOURS + 0.001d,
|
||||||
|
50f, 50f, 20f, 20f, 80f, 79f, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void distanceIsTheTrapezoidOfTheTwoSpeeds() {
|
||||||
|
// 20 and 40 km/h average to 30, which sits on the uncalibrated side of the band.
|
||||||
|
ConsumptionMath.Step step =
|
||||||
|
ConsumptionMath.integrate(DT, 20f, 40f, null, null, null, null, false);
|
||||||
|
assertEquals(30d * DT, step.km, TOLERANCE);
|
||||||
|
assertEquals(DT, step.hours, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void speedBelowTheDeadBandContributesNoDistance() {
|
||||||
|
ConsumptionMath.Step step =
|
||||||
|
ConsumptionMath.integrate(DT, 2f, 2f, null, null, null, null, false);
|
||||||
|
assertEquals(0d, step.km, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void motorwayAndMidRangeSpeedsAreCalibrated() {
|
||||||
|
ConsumptionMath.Step mid =
|
||||||
|
ConsumptionMath.integrate(DT, 50f, 50f, null, null, null, null, false);
|
||||||
|
assertEquals(50d * 1.0015d * DT, mid.km, TOLERANCE);
|
||||||
|
|
||||||
|
ConsumptionMath.Step motorway =
|
||||||
|
ConsumptionMath.integrate(DT, 120f, 120f, null, null, null, null, false);
|
||||||
|
assertEquals(120d * 1.0035d * DT, motorway.km, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void energyIsTheTrapezoidOfNetPowerAndRegenerationSubtracts() {
|
||||||
|
ConsumptionMath.Step drawing =
|
||||||
|
ConsumptionMath.integrate(DT, 60f, 60f, 10f, 20f, null, null, false);
|
||||||
|
assertEquals(15d * DT, drawing.kwh, TOLERANCE);
|
||||||
|
|
||||||
|
// Regeneration keeps its sign: this is what the car's own kWh figure does, and
|
||||||
|
// discarding it is what used to make our number read high.
|
||||||
|
ConsumptionMath.Step regenerating =
|
||||||
|
ConsumptionMath.integrate(DT, 60f, 60f, -20f, -20f, null, null, false);
|
||||||
|
assertEquals(-20d * DT, regenerating.kwh, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void standstillPowerFlowingInIsNotCreditedToTheDrive() {
|
||||||
|
ConsumptionMath.Step step =
|
||||||
|
ConsumptionMath.integrate(DT, 0f, 0f, -7f, -7f, null, null, false);
|
||||||
|
assertEquals(0d, step.kwh, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void standstillPowerFlowingOutStillCounts() {
|
||||||
|
// Parked with the climate running is real consumption.
|
||||||
|
ConsumptionMath.Step step =
|
||||||
|
ConsumptionMath.integrate(DT, 0f, 0f, 2f, 2f, null, null, false);
|
||||||
|
assertEquals(2d * DT, step.kwh, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anImplausiblePowerReadingSuppressesEnergyButNotDistance() {
|
||||||
|
ConsumptionMath.Step step = ConsumptionMath.integrate(
|
||||||
|
DT, 60f, 60f, 20f, ConsumptionMath.MAX_PLAUSIBLE_KW + 1f, null, null, false);
|
||||||
|
assertEquals(0d, step.kwh, TOLERANCE);
|
||||||
|
assertEquals(60d * 1.0015d * DT, step.km, TOLERANCE);
|
||||||
|
assertEquals(DT, step.hours, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aMissingPowerReadingSuppressesEnergyOnlyForThatInterval() {
|
||||||
|
ConsumptionMath.Step step =
|
||||||
|
ConsumptionMath.integrate(DT, 60f, 60f, 20f, null, null, null, false);
|
||||||
|
assertEquals(0d, step.kwh, TOLERANCE);
|
||||||
|
assertEquals(DT, step.hours, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aMissingSpeedReadingFallsBackToThePreviousOne() {
|
||||||
|
ConsumptionMath.Step step =
|
||||||
|
ConsumptionMath.integrate(DT, 60f, null, 20f, 20f, null, null, false);
|
||||||
|
assertEquals(60d * 1.0015d * DT, step.km, TOLERANCE);
|
||||||
|
assertEquals(20d * DT, step.kwh, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void reverseIsDistanceTravelled() {
|
||||||
|
ConsumptionMath.Step step =
|
||||||
|
ConsumptionMath.integrate(DT, -10f, -10f, null, null, null, null, false);
|
||||||
|
assertEquals(10d * DT, step.km, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void socDropIsCountedOnlyOffTheCharger() {
|
||||||
|
ConsumptionMath.Step driving =
|
||||||
|
ConsumptionMath.integrate(DT, 60f, 60f, 20f, 20f, 80f, 79.5f, false);
|
||||||
|
assertEquals(0.5d, driving.socDrop, TOLERANCE);
|
||||||
|
|
||||||
|
ConsumptionMath.Step charging =
|
||||||
|
ConsumptionMath.integrate(DT, 0f, 0f, -40f, -40f, 50f, 52f, true);
|
||||||
|
assertEquals(0d, charging.socDrop, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aMissingSocEndpointYieldsNoDrop() {
|
||||||
|
ConsumptionMath.Step step =
|
||||||
|
ConsumptionMath.integrate(DT, 60f, 60f, 20f, 20f, null, 79f, false);
|
||||||
|
assertEquals(0d, step.socDrop, TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aSteadyDriveIntegratesToTheExpectedAverage() {
|
||||||
|
// 90 km/h at 15 kW for an hour: ~16.6 kWh/100 km, a plausible MG4 motorway figure.
|
||||||
|
KahanSum km = new KahanSum();
|
||||||
|
KahanSum kwh = new KahanSum();
|
||||||
|
for (int i = 0; i < 3600; i++) {
|
||||||
|
ConsumptionMath.Step step = ConsumptionMath.integrate(
|
||||||
|
1d / 3600d, 90f, 90f, 15f, 15f, null, null, false);
|
||||||
|
km.add(step.km);
|
||||||
|
kwh.add(step.kwh);
|
||||||
|
}
|
||||||
|
assertEquals(90d * 1.0015d, km.get(), 1e-4);
|
||||||
|
assertEquals(15d, kwh.get(), 1e-9);
|
||||||
|
assertEquals(16.64d, kwh.get() * 100d / km.get(), 0.01d);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void regenerationOffsetsTheDrawItFollows() {
|
||||||
|
// Ten minutes pulling 30 kW, then ten minutes recovering 6 kW: the net is what the
|
||||||
|
// dashboard shows, and the old gross-only fallback reported 5.0 kWh for this.
|
||||||
|
KahanSum kwh = new KahanSum();
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
kwh.add(ConsumptionMath.integrate(DT, 60f, 60f, 30f, 30f, null, null, false).kwh);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
kwh.add(ConsumptionMath.integrate(DT, 60f, 60f, -6f, -6f, null, null, false).kwh);
|
||||||
|
}
|
||||||
|
assertEquals(4d, kwh.get(), 1e-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user