SAFETY-04: exactly zero is its own case in the freeze tie-break
`min(moved, i_w) if i_w > 0 else max(moved, i_w)` files i_w == 0.0 under rising-only, so the first push toward charging from exactly zero was blocked permanently - the S-1 deadlock again, mirrored in sign. main.py resets i_w to exactly 0.0 on every stop and every reseed, so it is a normal state. Zero is now handled explicitly and both directions are allowed: nothing is wound, so "may not wind further" has no referent, and a first step from zero is bounded by the gain, the output clamp and the slew limit like any other. Measured before the fix, at i_w == 0.0 and frozen: 12 800 of 25 920 ticks held the integrator and 8 304 of those changed the emitted command, worst case abandoning a 2 kW charge into a 4 kW export. Note this is NOT the same as the reported symptom: at prev_w == 0 the command holds at 0 W either way, because the output freeze forbids starting a charge while saturated, and that rule is release/1.0's and unchanged. There is now a test asserting it deliberately. Tests. The durable part is a property rather than more points: over 13 041 frozen states the integrator may be held ONLY by a correction pushing it further from zero on the side it already sits, and any other hold fails. Both signs at exactly 0.0. Mirrors added everywhere the suite tested one direction of two - freeze wind/unwind while charging, i_w=-100, the export-direction runaway, the negative clamp and slew. DOCS: the cycles-vs-seconds deviation is now written down as a deviation - the "> 10 s" criterion is not met as literally written, a cycle is one CHANGED meter reading, and there is no guaranteed wall-clock window. test_control.py: 43 -> 55 checks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
This commit is contained in:
co-authored by
Claude Opus 5
parent
7123aa00a4
commit
53d301b920
@@ -156,10 +156,29 @@ def compute(
|
||||
# has been in service on real hardware. It is applied here as well
|
||||
# because the requirement is that the INTEGRATOR stop accumulating, not
|
||||
# only the command.
|
||||
if not frozen:
|
||||
#
|
||||
# ⚠️ EXACTLY ZERO IS ITS OWN CASE, and it must be handled explicitly
|
||||
# rather than falling into one of the two branches. "May not wind
|
||||
# further in the direction it is already pushing" has no referent at
|
||||
# zero: nothing is wound, and neither direction is "further". Writing
|
||||
# this as `if i_w > 0 ... else ...` silently files zero under
|
||||
# rising-only and permanently blocks the first push toward charging -
|
||||
# the same deadlock as the shrink-only encoding above, mirrored in sign,
|
||||
# and reachable because main.py resets i_w to exactly 0.0 on every stop
|
||||
# and every reseed. Measured before the fix: 12 800 of 25 920 frozen
|
||||
# ticks at i_w == 0.0 held the integrator, 8 304 of them changing the
|
||||
# emitted command, worst case abandoning a 2 kW charge into a 4 kW
|
||||
# export.
|
||||
#
|
||||
# Freezing at zero would also be pointless: the freeze exists to stop
|
||||
# accumulation running away, and a first step from zero is bounded by
|
||||
# the gain, the output clamp and the slew limit like any other.
|
||||
if not frozen or i_w == 0.0:
|
||||
i_w = moved
|
||||
elif i_w > 0:
|
||||
i_w = min(moved, i_w)
|
||||
else:
|
||||
i_w = min(moved, i_w) if i_w > 0 else max(moved, i_w)
|
||||
i_w = max(moved, i_w)
|
||||
|
||||
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
||||
# bound is not. It is what makes the worst-case unwind time finite and
|
||||
|
||||
Reference in New Issue
Block a user