FAILURE MAP
← Case archive

FA-93041 / EV charging session scheduling / Open access

Ready-by departure latest start: duration rounding · case 01

The session plan ends a minute before the energy is actually delivered.

Verified by executionVariant 1 · 7 checks per implementationDownload source bundle ↓JSON ↗

ROOT CAUSE

Charging duration is floored instead of rounded up.

VERIFIED REPAIR

Round the duration up to whole minutes.

Unsuccessful approach: Nearest rounding still truncates fractional minutes below one half.

Case contract

Minutes. The battery must receive need_wh; the charger draws rate_w at eff_pct percent efficiency, so duration = ceil(need_wh*6000/(rate_w*eff_pct)) minutes. Latest start = depart - buffer_min - duration, rounded down to a 15-minute boundary. If that is before now, start now with status 'late'. need_wh <= 0 returns [None, now, 'done']; rate_w or eff_pct <= 0 returns [None, None, 'no-power']. Return [start, finish, status].

Why this case matters

Depot, workplace and public EV chargers schedule sessions against prices, circuit limits and departure deadlines; a wrong decision silently strands a driver or overloads a feeder.

1 / The failure

Exit 1
"""Failure Map reference implementation. Python standard library only."""
import json

N = 1
observations = []
def solve(now, depart, need_wh, rate_w, eff_pct, buffer_min):
    if need_wh <= 0:
        return [None, now, 'done']
    if rate_w <= 0 or eff_pct <= 0:
        return [None, None, 'no-power']
    dur = need_wh * 6000 // (rate_w * eff_pct)
    latest = depart - buffer_min - dur
    latest -= latest % 15
    if latest < now:
        return [now, now + dur, 'late']
    return [latest, latest + dur, 'ok']
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [153, 434, 2000, 11000, 85, 30], [390, 403, 'ok']],
  ['regression: duration rounding (partial repair)', [387, 1026, 30000, 11000, 95, 30],
   [810, 983, 'ok']],
  ['control 1', [117, 637, 11000, 11000, 95, 10], [555, 619, 'ok']],
  ['control 2', [448, 1142, 0, 3700, 85, 0], [None, 448, 'done']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [351, 1109, 18000, 3700, 95, 15], [780, 1088, 'ok']],
  ['regression: duration rounding (partial repair)', [20, 911, 30000, 7400, 100, 15],
   [645, 889, 'ok']],
  ['control 1', [416, 1003, 5000, 0, 85, 15], [None, None, 'no-power']],
  ['control 2', [221, 503, 5000, 0, 85, 30], [None, None, 'no-power']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [159, 777, 11000, 3700, 100, 30], [555, 734, 'ok']],
  ['regression: duration rounding (partial repair)', [81, 103, 5000, 3700, 100, 10],
   [81, 163, 'late']],
  ['control 1', [381, 632, 18000, 3700, 95, 30], [381, 689, 'late']],
  ['control 2', [19, 584, 30000, 11000, 90, 15], [375, 557, 'ok']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [496, 868, 11000, 3700, 95, 30], [645, 833, 'ok']],
  ['regression: duration rounding (partial repair)', [342, 397, 18000, 11000, 95, 10],
   [342, 446, 'late']],
  ['control 1', [66, 229, 11000, 0, 90, 15], [None, None, 'no-power']],
  ['control 2', [44, 377, 2000, 0, 100, 0], [None, None, 'no-power']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [522, 1168, 30000, 3700, 85, 30], [555, 1128, 'ok']],
  ['regression: duration rounding (partial repair)', [341, 315, 5000, 3700, 100, 30],
   [341, 423, 'late']],
  ['control 1', [342, 397, 18000, 11000, 95, 10], [342, 446, 'late']],
  ['control 2', [577, 1400, 11000, 7400, 85, 15], [1275, 1380, 'ok']]]]
for label, args, expected in fixtures[N-1]:
    check(label, solve(*args), expected)
print(json.dumps({"observations": observations, "passed": all(x["passed"] for x in observations)}, ensure_ascii=False))
raise SystemExit(0 if all(x["passed"] for x in observations) else 1)
Boundary fixtureActualExpectedOutcome
boundary: nothing to charge[None, 100, 'done'][None, 100, 'done']Passed
boundary: latest start exactly now[330, 390, 'ok'][330, 390, 'ok']Passed
boundary: no charger power[None, None, 'no-power'][None, None, 'no-power']Passed
regression: duration rounding[390, 402, 'ok'][390, 403, 'ok']Failed
regression: duration rounding (partial repair)[810, 982, 'ok'][810, 983, 'ok']Failed
control 1[555, 618, 'ok'][555, 619, 'ok']Failed
control 2[None, 448, 'done'][None, 448, 'done']Passed

SHA-256 / ba5393b2035dc529dcc962f5ef5f48b52043245cae991f9d74b3097df4992fdf

2 / The unsuccessful fix

Exit 1
"""Failure Map reference implementation. Python standard library only."""
import json

N = 1
observations = []
def solve(now, depart, need_wh, rate_w, eff_pct, buffer_min):
    if need_wh <= 0:
        return [None, now, 'done']
    if rate_w <= 0 or eff_pct <= 0:
        return [None, None, 'no-power']
    dur = round(need_wh * 6000 / (rate_w * eff_pct))
    latest = depart - buffer_min - dur
    latest -= latest % 15
    if latest < now:
        return [now, now + dur, 'late']
    return [latest, latest + dur, 'ok']
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [153, 434, 2000, 11000, 85, 30], [390, 403, 'ok']],
  ['regression: duration rounding (partial repair)', [387, 1026, 30000, 11000, 95, 30],
   [810, 983, 'ok']],
  ['control 1', [117, 637, 11000, 11000, 95, 10], [555, 619, 'ok']],
  ['control 2', [448, 1142, 0, 3700, 85, 0], [None, 448, 'done']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [351, 1109, 18000, 3700, 95, 15], [780, 1088, 'ok']],
  ['regression: duration rounding (partial repair)', [20, 911, 30000, 7400, 100, 15],
   [645, 889, 'ok']],
  ['control 1', [416, 1003, 5000, 0, 85, 15], [None, None, 'no-power']],
  ['control 2', [221, 503, 5000, 0, 85, 30], [None, None, 'no-power']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [159, 777, 11000, 3700, 100, 30], [555, 734, 'ok']],
  ['regression: duration rounding (partial repair)', [81, 103, 5000, 3700, 100, 10],
   [81, 163, 'late']],
  ['control 1', [381, 632, 18000, 3700, 95, 30], [381, 689, 'late']],
  ['control 2', [19, 584, 30000, 11000, 90, 15], [375, 557, 'ok']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [496, 868, 11000, 3700, 95, 30], [645, 833, 'ok']],
  ['regression: duration rounding (partial repair)', [342, 397, 18000, 11000, 95, 10],
   [342, 446, 'late']],
  ['control 1', [66, 229, 11000, 0, 90, 15], [None, None, 'no-power']],
  ['control 2', [44, 377, 2000, 0, 100, 0], [None, None, 'no-power']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [522, 1168, 30000, 3700, 85, 30], [555, 1128, 'ok']],
  ['regression: duration rounding (partial repair)', [341, 315, 5000, 3700, 100, 30],
   [341, 423, 'late']],
  ['control 1', [342, 397, 18000, 11000, 95, 10], [342, 446, 'late']],
  ['control 2', [577, 1400, 11000, 7400, 85, 15], [1275, 1380, 'ok']]]]
for label, args, expected in fixtures[N-1]:
    check(label, solve(*args), expected)
print(json.dumps({"observations": observations, "passed": all(x["passed"] for x in observations)}, ensure_ascii=False))
raise SystemExit(0 if all(x["passed"] for x in observations) else 1)
Boundary fixtureActualExpectedOutcome
boundary: nothing to charge[None, 100, 'done'][None, 100, 'done']Passed
boundary: latest start exactly now[330, 390, 'ok'][330, 390, 'ok']Passed
boundary: no charger power[None, None, 'no-power'][None, None, 'no-power']Passed
regression: duration rounding[390, 403, 'ok'][390, 403, 'ok']Passed
regression: duration rounding (partial repair)[810, 982, 'ok'][810, 983, 'ok']Failed
control 1[555, 618, 'ok'][555, 619, 'ok']Failed
control 2[None, 448, 'done'][None, 448, 'done']Passed

SHA-256 / 342c881ff2478717fdee3e8df377855843de2cab29a3921039412dfb4b38c2a2

3 / The verified repair

Exit 0
"""Failure Map reference implementation. Python standard library only."""
import json

N = 1
observations = []
def solve(now, depart, need_wh, rate_w, eff_pct, buffer_min):
    if need_wh <= 0:
        return [None, now, 'done']
    if rate_w <= 0 or eff_pct <= 0:
        return [None, None, 'no-power']
    dur = -(-need_wh * 6000 // (rate_w * eff_pct))
    latest = depart - buffer_min - dur
    latest -= latest % 15
    if latest < now:
        return [now, now + dur, 'late']
    return [latest, latest + dur, 'ok']
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [153, 434, 2000, 11000, 85, 30], [390, 403, 'ok']],
  ['regression: duration rounding (partial repair)', [387, 1026, 30000, 11000, 95, 30],
   [810, 983, 'ok']],
  ['control 1', [117, 637, 11000, 11000, 95, 10], [555, 619, 'ok']],
  ['control 2', [448, 1142, 0, 3700, 85, 0], [None, 448, 'done']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [351, 1109, 18000, 3700, 95, 15], [780, 1088, 'ok']],
  ['regression: duration rounding (partial repair)', [20, 911, 30000, 7400, 100, 15],
   [645, 889, 'ok']],
  ['control 1', [416, 1003, 5000, 0, 85, 15], [None, None, 'no-power']],
  ['control 2', [221, 503, 5000, 0, 85, 30], [None, None, 'no-power']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [159, 777, 11000, 3700, 100, 30], [555, 734, 'ok']],
  ['regression: duration rounding (partial repair)', [81, 103, 5000, 3700, 100, 10],
   [81, 163, 'late']],
  ['control 1', [381, 632, 18000, 3700, 95, 30], [381, 689, 'late']],
  ['control 2', [19, 584, 30000, 11000, 90, 15], [375, 557, 'ok']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [496, 868, 11000, 3700, 95, 30], [645, 833, 'ok']],
  ['regression: duration rounding (partial repair)', [342, 397, 18000, 11000, 95, 10],
   [342, 446, 'late']],
  ['control 1', [66, 229, 11000, 0, 90, 15], [None, None, 'no-power']],
  ['control 2', [44, 377, 2000, 0, 100, 0], [None, None, 'no-power']]],
 [['boundary: nothing to charge', [100, 400, 0, 0, 90, 10], [None, 100, 'done']],
  ['boundary: latest start exactly now', [300, 390, 7400, 7400, 100, 0], [330, 390, 'ok']],
  ['boundary: no charger power', [100, 400, 5000, 0, 90, 10], [None, None, 'no-power']],
  ['regression: duration rounding', [522, 1168, 30000, 3700, 85, 30], [555, 1128, 'ok']],
  ['regression: duration rounding (partial repair)', [341, 315, 5000, 3700, 100, 30],
   [341, 423, 'late']],
  ['control 1', [342, 397, 18000, 11000, 95, 10], [342, 446, 'late']],
  ['control 2', [577, 1400, 11000, 7400, 85, 15], [1275, 1380, 'ok']]]]
for label, args, expected in fixtures[N-1]:
    check(label, solve(*args), expected)
print(json.dumps({"observations": observations, "passed": all(x["passed"] for x in observations)}, ensure_ascii=False))
raise SystemExit(0 if all(x["passed"] for x in observations) else 1)
Boundary fixtureActualExpectedOutcome
boundary: nothing to charge[None, 100, 'done'][None, 100, 'done']Passed
boundary: latest start exactly now[330, 390, 'ok'][330, 390, 'ok']Passed
boundary: no charger power[None, None, 'no-power'][None, None, 'no-power']Passed
regression: duration rounding[390, 403, 'ok'][390, 403, 'ok']Passed
regression: duration rounding (partial repair)[810, 983, 'ok'][810, 983, 'ok']Passed
control 1[555, 619, 'ok'][555, 619, 'ok']Passed
control 2[None, 448, 'done'][None, 448, 'done']Passed

SHA-256 / 1155ebc3e157395e64102ff15f69c889b8f7d861c3862e9fb0712695445b4332

Verification & scope

Deterministic stipulated toy contract for teaching; no claim of conformance with any standard, vendor protocol or production controller. This reproducer isolates one failure mechanism. Results cover the supplied fixtures. Variants within a family share a test contract and should remain grouped when constructing evaluation splits. Related mechanisms with a shared evaluation_group must also remain together; these controlled models are not independent production incidents.

Observations recorded using Python 3.12.14 at 2026-09-29T14:51:51.634051+00:00.

Case digest / 041464a78440d0032f62b69da51ec916ce00d6dbf818e8213db695a7dc8c9c15