OPT-15: price-run fixes (quarter edge, charge-end overshoot, refusal probe)
checks / unit (pull_request) Successful in 18m49s
infra-pin / fresh (pull_request) Successful in 1s

F1: with battery modes on, the peak guard projects the quarter `now` is in
even before its first P1 sample (PeakTracker.project(roll=True)), and only
a measured stop latches the period. The edge tick no longer reads "no
seconds left", so a charge is not stopped and latched at a quarter edge.

F2: after a run edge the loop is held at E until the meter shows the step
(EDGE_HOLD_S, longer while the battery still moves). The replay gained a
lag model (dead time, reading age, P1 debounce, 1 s cycles) that shows
TEST-23's overshoot without the hold.

F3: a charge refuser's SAFETY-73 probe inside a cheap run is a charger
pinned at PROBE_W, the others rest, so the probe actually runs.

Modes off stays byte-identical to 5a4cdc2 (new late-P1 identity scenarios).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:22:32 +02:00
co-authored by Claude Opus 5.5
parent 5a4cdc273e
commit fae6681e19
10 changed files with 763 additions and 58 deletions
+14 -1
View File
@@ -776,9 +776,14 @@ class PeakTracker:
return r.peak_w / self.cap_w
@staticmethod
def project(averager, now: datetime) -> Projection:
def project(averager, now: datetime, roll: bool = False) -> Projection:
"""End-of-quarter average if the last sample held for the rest of it.
`roll` (OPT-15, battery modes only): when `now` is past the open block
because no sample has come since the boundary, project the quarter
`now` is in, crediting the held sample as `add()` will (`max_hold_s`).
Without it that tick reads "no seconds left" and measures nothing.
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
block. `elapsed_w` divides by the seconds actually covered (what the
meter has shown so far); `projected_w` divides by the full 900 s and
@@ -798,6 +803,14 @@ class PeakTracker:
elapsed_wall = max(0.0, (now - start).total_seconds())
elapsed = averager.elapsed_s
acc = averager.partial_ws
if roll and elapsed_wall >= QUARTER_S:
t = now.timestamp()
block = int(t // QUARTER_S) * QUARTER_S
start = datetime.fromtimestamp(block, timezone.utc)
held_end = float(averager.held_t) + float(averager.max_hold_s)
elapsed = max(0.0, min(t, held_end) - block)
elapsed_wall = t - block
acc = max(averager.held_net_w, 0.0) * elapsed
remaining = max(0.0, QUARTER_S - elapsed_wall)
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
# was `getattr(averager, "_last_net", 0.0)`: a private name with a