Fix consumption tracking while charging

This commit is contained in:
TCFDONMEZ
2026-10-02 11:15:22 +03:00
parent ecbee4980c
commit dbbfb9f803
10 changed files with 643 additions and 119 deletions
@@ -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);
}
}