FAILURE MAP
← Case archive

FA-92896 / EV charging session scheduling / Open access

Post-charge idle occupancy fee: unplug minute exclusivity · case 01

Every idle session is billed one extra minute.

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

ROOT CAUSE

The unplug minute is included in the idle interval.

VERIFIED REPAIR

Iterate idle minutes over [start, unplug).

Unsuccessful approach: Shifting both ends drops the first idle minute and still bills the unplug minute.

Case contract

Times are minutes 0..1440 of one day. done is when charging finished (None: never finished, no idle fee). Idle time starts at done+grace and ends at unplug (exclusive). Only minutes in [360, 1320) are billable (night waiver). Fee = ceil(billable/block)*rate cents, then capped at cap when cap is not None (cap 0 waives the fee). Return [billable minutes, fee].

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(plug, done, unplug, grace, rate, cap, block):
    if done is None or unplug <= done:
        return [0, 0]
    start = done + grace
    if unplug <= start:
        return [0, 0]
    billable = 0
    for m in range(start, unplug + 1):
        if 360 <= m < 1320:
            billable += 1
    blocks = -(-billable // block)
    fee = blocks * rate
    if cap is not None:
        fee = min(fee, cap)
    return [billable, fee]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [267, 472, 501, 15, 5, None, 10], [14, 10]],
  ['regression: unplug minute exclusivity (partial repair)', [745, 831, 1323, 0, 40, 1000, 10],
   [489, 1000]],
  ['control 1', [708, 712, 1012, 15, 25, 1000, 5], [285, 1000]],
  ['control 2', [424, 721, 1119, 10, 5, None, 15], [388, 130]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [11, 183, 646, 10, 40, 1000, 15], [286, 800]],
  ['regression: unplug minute exclusivity (partial repair)', [935, 1103, 1359, 15, 25, 0, 15],
   [202, 0]],
  ['control 1', [1149, 1440, 1440, 0, 25, None, 1], [0, 0]],
  ['control 2', [421, 583, 815, 15, 5, 300, 15], [217, 75]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [768, 1112, 1161, 10, 25, 300, 1], [39, 300]],
  ['regression: unplug minute exclusivity (partial repair)', [1142, 1227, 1440, 10, 25, 0, 1],
   [83, 0]],
  ['control 1', [1122, 1422, 1434, 10, 25, 0, 10], [0, 0]],
  ['control 2', [325, 596, 781, 30, 25, None, 15], [155, 275]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [37, 320, 601, 10, 5, None, 10], [241, 125]],
  ['regression: unplug minute exclusivity (partial repair)', [278, 314, 487, 30, 5, 0, 15],
   [127, 0]],
  ['control 1', [887, 906, 899, 15, 5, 1000, 10], [0, 0]],
  ['control 2', [684, 849, 1335, 30, 25, 1000, 10], [441, 1000]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [703, 737, 1228, 0, 25, 1000, 15], [491, 825]],
  ['regression: unplug minute exclusivity (partial repair)', [1177, 1189, 1440, 15, 5, 1000, 5],
   [116, 120]],
  ['control 1', [942, 1011, 983, 15, 25, 300, 10], [0, 0]],
  ['control 2', [306, 373, 739, 10, 25, None, 10], [356, 900]]]]
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: unplugged inside grace[0, 0][0, 0]Passed
boundary: never finished[0, 0][0, 0]Passed
boundary: idle crosses night waiver[20, 200][20, 200]Passed
regression: unplug minute exclusivity[15, 10][14, 10]Failed
regression: unplug minute exclusivity (partial repair)[489, 1000][489, 1000]Passed
control 1[286, 1000][285, 1000]Failed
control 2[389, 130][388, 130]Failed

SHA-256 / 892cb3057a15b5b5d3533406c8bc376b4a5f7a1345d7634c4e6fd644eafec1e4

2 / The unsuccessful fix

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

N = 1
observations = []
def solve(plug, done, unplug, grace, rate, cap, block):
    if done is None or unplug <= done:
        return [0, 0]
    start = done + grace
    if unplug <= start:
        return [0, 0]
    billable = 0
    for m in range(start + 1, unplug + 1):
        if 360 <= m < 1320:
            billable += 1
    blocks = -(-billable // block)
    fee = blocks * rate
    if cap is not None:
        fee = min(fee, cap)
    return [billable, fee]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [267, 472, 501, 15, 5, None, 10], [14, 10]],
  ['regression: unplug minute exclusivity (partial repair)', [745, 831, 1323, 0, 40, 1000, 10],
   [489, 1000]],
  ['control 1', [708, 712, 1012, 15, 25, 1000, 5], [285, 1000]],
  ['control 2', [424, 721, 1119, 10, 5, None, 15], [388, 130]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [11, 183, 646, 10, 40, 1000, 15], [286, 800]],
  ['regression: unplug minute exclusivity (partial repair)', [935, 1103, 1359, 15, 25, 0, 15],
   [202, 0]],
  ['control 1', [1149, 1440, 1440, 0, 25, None, 1], [0, 0]],
  ['control 2', [421, 583, 815, 15, 5, 300, 15], [217, 75]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [768, 1112, 1161, 10, 25, 300, 1], [39, 300]],
  ['regression: unplug minute exclusivity (partial repair)', [1142, 1227, 1440, 10, 25, 0, 1],
   [83, 0]],
  ['control 1', [1122, 1422, 1434, 10, 25, 0, 10], [0, 0]],
  ['control 2', [325, 596, 781, 30, 25, None, 15], [155, 275]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [37, 320, 601, 10, 5, None, 10], [241, 125]],
  ['regression: unplug minute exclusivity (partial repair)', [278, 314, 487, 30, 5, 0, 15],
   [127, 0]],
  ['control 1', [887, 906, 899, 15, 5, 1000, 10], [0, 0]],
  ['control 2', [684, 849, 1335, 30, 25, 1000, 10], [441, 1000]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [703, 737, 1228, 0, 25, 1000, 15], [491, 825]],
  ['regression: unplug minute exclusivity (partial repair)', [1177, 1189, 1440, 15, 5, 1000, 5],
   [116, 120]],
  ['control 1', [942, 1011, 983, 15, 25, 300, 10], [0, 0]],
  ['control 2', [306, 373, 739, 10, 25, None, 10], [356, 900]]]]
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: unplugged inside grace[0, 0][0, 0]Passed
boundary: never finished[0, 0][0, 0]Passed
boundary: idle crosses night waiver[19, 190][20, 200]Failed
regression: unplug minute exclusivity[14, 10][14, 10]Passed
regression: unplug minute exclusivity (partial repair)[488, 1000][489, 1000]Failed
control 1[285, 1000][285, 1000]Passed
control 2[388, 130][388, 130]Passed

SHA-256 / 81c56c7d78fd954e9b69b3be4f501e74c34a20f4a3f0ee7044467d983ceef0da

3 / The verified repair

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

N = 1
observations = []
def solve(plug, done, unplug, grace, rate, cap, block):
    if done is None or unplug <= done:
        return [0, 0]
    start = done + grace
    if unplug <= start:
        return [0, 0]
    billable = 0
    for m in range(start, unplug):
        if 360 <= m < 1320:
            billable += 1
    blocks = -(-billable // block)
    fee = blocks * rate
    if cap is not None:
        fee = min(fee, cap)
    return [billable, fee]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [267, 472, 501, 15, 5, None, 10], [14, 10]],
  ['regression: unplug minute exclusivity (partial repair)', [745, 831, 1323, 0, 40, 1000, 10],
   [489, 1000]],
  ['control 1', [708, 712, 1012, 15, 25, 1000, 5], [285, 1000]],
  ['control 2', [424, 721, 1119, 10, 5, None, 15], [388, 130]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [11, 183, 646, 10, 40, 1000, 15], [286, 800]],
  ['regression: unplug minute exclusivity (partial repair)', [935, 1103, 1359, 15, 25, 0, 15],
   [202, 0]],
  ['control 1', [1149, 1440, 1440, 0, 25, None, 1], [0, 0]],
  ['control 2', [421, 583, 815, 15, 5, 300, 15], [217, 75]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [768, 1112, 1161, 10, 25, 300, 1], [39, 300]],
  ['regression: unplug minute exclusivity (partial repair)', [1142, 1227, 1440, 10, 25, 0, 1],
   [83, 0]],
  ['control 1', [1122, 1422, 1434, 10, 25, 0, 10], [0, 0]],
  ['control 2', [325, 596, 781, 30, 25, None, 15], [155, 275]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [37, 320, 601, 10, 5, None, 10], [241, 125]],
  ['regression: unplug minute exclusivity (partial repair)', [278, 314, 487, 30, 5, 0, 15],
   [127, 0]],
  ['control 1', [887, 906, 899, 15, 5, 1000, 10], [0, 0]],
  ['control 2', [684, 849, 1335, 30, 25, 1000, 10], [441, 1000]]],
 [['boundary: unplugged inside grace', [600, 700, 710, 15, 25, None, 5], [0, 0]],
  ['boundary: never finished', [600, None, 900, 0, 25, None, 5], [0, 0]],
  ['boundary: idle crosses night waiver', [1200, 1300, 1400, 0, 10, None, 1], [20, 200]],
  ['regression: unplug minute exclusivity', [703, 737, 1228, 0, 25, 1000, 15], [491, 825]],
  ['regression: unplug minute exclusivity (partial repair)', [1177, 1189, 1440, 15, 5, 1000, 5],
   [116, 120]],
  ['control 1', [942, 1011, 983, 15, 25, 300, 10], [0, 0]],
  ['control 2', [306, 373, 739, 10, 25, None, 10], [356, 900]]]]
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: unplugged inside grace[0, 0][0, 0]Passed
boundary: never finished[0, 0][0, 0]Passed
boundary: idle crosses night waiver[20, 200][20, 200]Passed
regression: unplug minute exclusivity[14, 10][14, 10]Passed
regression: unplug minute exclusivity (partial repair)[489, 1000][489, 1000]Passed
control 1[285, 1000][285, 1000]Passed
control 2[388, 130][388, 130]Passed

SHA-256 / f0f80a2214b848624d7dce5e34f227a095b446f1b76ece837c90fd8eef854bd0

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:50.151696+00:00.

Case digest / 8e5c55bf82499fedf0383dcfdc2afda5a8176d39ba08dd07f5ddd4b3ee0acde5