SAFETY-04: clamp the integrator, not only the output
The loop was in velocity form: the accumulator WAS the commanded power, so "clamp the integrator" and "clamp the output" were the same line of code and could not be set apart. This makes the accumulator an explicit carried value (`i_w`), bounds it with its own `integrator_max_w`, and keeps the output clamp where it was. Killing either mechanism now still leaves the other holding - which is the point of the ticket, and what the new regression test asserts. Freeze semantics: while saturated the integrator may unwind but not wind further. A strict freeze would strand the command at whatever it reached, because the condition that releases it is the inverter tracking again, and not tracking is exactly what saturation means. The integrator is re-seeded from the arbiter's actual output whenever the loop did not get what it asked for, so entering any failsafe (all of which resolve to 0 W) zeroes it, and the first cycle after release does not dump the stale period as power. test_control.py: 24 -> 33 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
5184a2cfc2
commit
37bac79ad8
@@ -70,6 +70,7 @@ class Controller:
|
||||
step_w=int(opts.get("step_w", 10)),
|
||||
saturation_w=float(opts.get("saturation_w", 500)),
|
||||
saturation_cycles=int(opts.get("saturation_cycles", 3)),
|
||||
integrator_max_w=float(opts.get("integrator_max_w", 3000)),
|
||||
)
|
||||
self.maint = Maintenance(
|
||||
MaintConfig(
|
||||
@@ -89,6 +90,8 @@ class Controller:
|
||||
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
|
||||
self.target = 0.0
|
||||
self.sat_count = 0
|
||||
self.i_w = 0.0 # the loop's integrator, carried between cycles
|
||||
self.loop_w = None # what the loop asked for last cycle, or None
|
||||
self.reason = "starting"
|
||||
self.grid = self.soc = self.batt = None
|
||||
self.peak_fc = None
|
||||
@@ -231,17 +234,36 @@ class Controller:
|
||||
# wish. If something outranked the loop, that is what the hardware
|
||||
# actually did, and the controller must track reality or it jumps
|
||||
# the moment it regains control.
|
||||
#
|
||||
# ⚠️ The integrator has to track the same reality, and it is no
|
||||
# longer prev_w, so it needs saying out loud: if the arbiter did not
|
||||
# give the loop what it asked for last cycle, the loop's
|
||||
# accumulated error belongs to a command that never happened.
|
||||
# Re-seed from what the hardware was actually told. This is the
|
||||
# failsafe case too - every layer-1 stop resolves to 0 W, so
|
||||
# entering failsafe re-seeds the integrator to zero and the first
|
||||
# cycle after release starts from zero instead of dumping the whole
|
||||
# stale period as power.
|
||||
if self.loop_w is None or self.target != self.loop_w:
|
||||
self.i_w = self.target
|
||||
decision = compute(
|
||||
prev_w=self.target,
|
||||
grid_w=self.grid,
|
||||
actual_w=self.batt,
|
||||
tuning=self.tuning,
|
||||
sat_count=self.sat_count,
|
||||
i_w=self.i_w,
|
||||
)
|
||||
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs {self.batt:.0f} W)")
|
||||
self.sat_count = decision.sat_count
|
||||
self.i_w = decision.i_w
|
||||
self.loop_w = decision.target_w
|
||||
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason))
|
||||
else:
|
||||
# Stopped or blind: no accumulation may survive the outage.
|
||||
self.i_w = 0.0
|
||||
self.loop_w = None
|
||||
|
||||
resolution = resolve(claims)
|
||||
if resolution.contradiction:
|
||||
|
||||
Reference in New Issue
Block a user