SAFETY-12: the loop thinks on a clock, not only when the number moves
run_control() ran cycle() only when the meter value differed from the previous poll. A meter that repeats a value therefore ran no control cycles at all - while the heartbeat kept re-emitting the last command and the firmware kept putting it on the wire every 2 s. Layer 1 stayed fed and every indicator read healthy, so a command computed from an old reading went on being executed through whatever the load did next. TEL-05 measured it: 70 s at a literally constant 350.0 W, 14 arrivals, zero cycles. cycle_floor_s (default 15 s) is a deadline on the GAP between cycles, reset by every cycle whichever trigger fired - not a second scheduler. On a healthy meter it never elapses, so the cycle rate and the commanded sequence are unchanged; test_loop.py asserts that by driving the real loop twice on one binary (a huge cycle_floor_s reproduces the old behaviour) and diffing the writes. 15 s is bracketed: above the ~11 s worst gap on a merely-unlucky healthy meter (~5 s arrivals, 1 s detection, one dropped poll retried on the next tick), below meter_max_age_s and the firmware's 30 s ingest veto, and equal to stale_input_s. A floor cycle runs the full law. Freshness is already decided upstream - past meter_max_age_s the reading is None, which is a P_SAFETY 0 W claim - and the integrator advances per cycle, not per second, so bounding the gap bounds the accumulation. Not a freeze detector: nothing here decides whether a repeated reading is true. DOCS.md's "accepted deviation" passage is qualified rather than deleted - the window is now bounded at 3 x cycle_floor_s instead of open-ended, and its "nothing accumulates while it is stalled" argument is called out as having covered windup and not staleness. Same correction in control.py's comment. 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
e2e331ea54
commit
88cd97cc7a
@@ -95,19 +95,23 @@ def compute(
|
||||
# lets slew be larger than saturation_w.
|
||||
#
|
||||
# The spec states this window twice and differently: "> 10 s" (§11.2) and
|
||||
# "3 samples" (§10.3). This counts CYCLES, and a cycle is not a unit of
|
||||
# time: run_control() calls cycle() only when the meter value CHANGES
|
||||
# (`if self.grid != last_grid`), so three cycles is three distinct meter
|
||||
# readings and nothing more. At the reference P1's ~5 s update rate that is
|
||||
# usually ~15 s, but there is no upper bound on it - a meter that repeats a
|
||||
# value stalls the counter.
|
||||
# "3 samples" (§10.3). This counts CYCLES, and a cycle is still not a unit
|
||||
# of time - but it is now a BOUNDED one. run_control() runs a cycle on a
|
||||
# changed meter value OR on `cycle_floor_s` elapsing, whichever comes first
|
||||
# (SAFETY-12), so three cycles is at most 3 x cycle_floor_s = 45 s at the
|
||||
# defaults, and usually ~15 s at the reference P1's ~5 s update rate.
|
||||
#
|
||||
# That is a detection-latency limit, not a windup hazard: the same
|
||||
# condition that stalls the counter stalls the whole loop, so nothing
|
||||
# accumulates in the meantime either. If a wall-clock window is ever
|
||||
# required, it belongs in Controller (which has a clock) and not here.
|
||||
# ponytail: this function is worth keeping clockless; the ceiling is that
|
||||
# saturation_cycles cannot express a guaranteed number of seconds.
|
||||
# ⚠️ THIS COMMENT USED TO CLAIM there was no upper bound and that it did not
|
||||
# matter, "because the same condition that stalls the counter stalls the
|
||||
# whole loop, so nothing accumulates in the meantime". The first clause was
|
||||
# true and the second was the bug: a stalled loop does not stop commanding,
|
||||
# it goes on emitting its last command through the heartbeat. Windup was
|
||||
# never the hazard; staleness was.
|
||||
#
|
||||
# The wall-clock window lives in Controller, which has a clock, exactly as
|
||||
# this comment always said it should. ponytail: this function stays
|
||||
# clockless; the remaining ceiling is that saturation_cycles expresses a
|
||||
# bound in seconds rather than an exact number of them.
|
||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
||||
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
||||
frozen = sat_count >= tuning.saturation_cycles
|
||||
|
||||
@@ -342,11 +342,52 @@ class Controller:
|
||||
last_grid = object()
|
||||
heartbeat = float(self.o.get("heartbeat_s", 10))
|
||||
last_beat = 0.0
|
||||
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not a
|
||||
# second scheduler running alongside the arrival trigger. Every cycle
|
||||
# resets it, whichever trigger fired, so on a healthy changing meter it
|
||||
# never elapses and not one existing cycle moves - no beat frequency
|
||||
# between the two triggers, no doubled cycle rate, and therefore no
|
||||
# change to the per-cycle gain the tuning was measured against.
|
||||
#
|
||||
# Why it has to exist: `self.grid != last_grid` alone means a meter that
|
||||
# repeats a value runs NO control cycles at all, while the heartbeat
|
||||
# below keeps re-emitting the last command and the firmware keeps
|
||||
# putting it on the wire - so every indicator reads healthy while the
|
||||
# loop has not evaluated and the load may have moved underneath it.
|
||||
# Measured on the rig during TEL-05: 70 s at a literally constant
|
||||
# 350.0 W, 14 arrivals, zero cycles.
|
||||
#
|
||||
# This is NOT a freeze detector, and deliberately so - see
|
||||
# HomeWizardLocalSource.unchanged_s for why one cannot be built here (at
|
||||
# the converged -10 W target a 1 Wh register needs ~6 minutes to move,
|
||||
# so any freeze threshold false-trips at the exact operating point we
|
||||
# run at). Nothing here decides whether a repeated reading is true. It
|
||||
# only stops "the number is the same" from meaning "do not think".
|
||||
floor = float(self.o.get("cycle_floor_s", 15))
|
||||
last_cycle = None
|
||||
while not self.stopping:
|
||||
await self.read_inputs()
|
||||
# A cycle is one meter update, exactly as on the reference install.
|
||||
if self.grid != last_grid:
|
||||
now_mono = asyncio.get_running_loop().time()
|
||||
# A cycle is one meter update, exactly as on the reference install -
|
||||
# or the floor elapsing, whichever comes first.
|
||||
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
|
||||
if self.grid != last_grid or overdue:
|
||||
last_grid = self.grid
|
||||
last_cycle = now_mono
|
||||
# ⚠️ The FULL cycle, not a degraded one. A floor cycle runs the
|
||||
# same law on the same inputs as an arrival cycle, because the
|
||||
# freshness question is already answered upstream and answered
|
||||
# harder: past `meter_max_age_s` (30 s) p1.net_w is None, which
|
||||
# is "inputs missing", which becomes a P_SAFETY 0 W claim. So a
|
||||
# floor cycle can only ever run on a reading this system already
|
||||
# calls fresh - it cannot run the PI on a 60 s old figure,
|
||||
# because at 60 s there is no figure. And the integrator
|
||||
# advances per CYCLE, not per second (control.py), so bounding
|
||||
# the gap bounds the accumulation: at most one extra
|
||||
# gain x error per floor interval, under the same
|
||||
# integrator_max_w as every other cycle. A second, gentler
|
||||
# control law for floor cycles would be a second set of
|
||||
# dynamics nobody can test on this hardware.
|
||||
await self.cycle()
|
||||
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
|
||||
# longer than ~30 s as "the controller is gone" and zeroes the
|
||||
|
||||
Reference in New Issue
Block a user