MAINT-20 part 2 r2: app/main.py comments, lines 4001-4500 (_shadow_settle .. check_output_clamp)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-08 18:06:00 +01:00
co-authored by Claude
parent d298f8aa4f
commit 47e99a37bc
+127 -70
View File
@@ -4027,8 +4027,9 @@ class Controller:
if len(self.inverters) > 1: if len(self.inverters) > 1:
probe = any(_probing.values()) probe = any(_probing.values())
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. # ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
# The anti-windup test inside compute() is `abs(prev_w - actual_w) # The anti-windup test in compute() is `abs(prev_w - actual_w) >
# > saturation_w`, and `prev_w` is the SITE target. # saturation_w` and `prev_w` is the SITE target; one inverter's
# power at N=2 would trip the saturation freeze permanently.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
site_batt = self.site_batt_w site_batt = self.site_batt_w
# SAFETY-103: what each battery can be doing (its reading may be # SAFETY-103: what each battery can be doing (its reading may be
@@ -4085,14 +4086,20 @@ class Controller:
if len(self.inverters) == 1: if len(self.inverters) == 1:
self._refusal_update(decision, loop_prev, site_batt, probe, self._refusal_update(decision, loop_prev, site_batt, probe,
rejudged) rejudged)
# TEL-10 ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT # TEL-10. ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS: `loop_i`
# IS WHAT THE CARD IS. # is `Decision.i_w`, the value BEFORE the clamp, slew and
# AND `loop_reason` IS NOT `self.reason`. # quantisation, so it is NOT `setpoint` and must not be published
# from it; `loop_lead` is how far it stands ahead of the command;
# `loop_reason` is `compute()`'s own word and NOT `self.reason`.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
self.loop_i_w = decision.i_w self.loop_i_w = decision.i_w
self.loop_i_lead = decision.i_w - loop_prev self.loop_i_lead = decision.i_w - loop_prev
self.loop_reason = decision.reason self.loop_reason = decision.reason
# SAFETY-03 ⚠️ READ THE BOOLEANS, NOT `decision.reason`. # SAFETY-03. ⚠️ READ THE BOOLEANS, NOT `decision.reason`: `reason`
# is overwritten by slew-limiting or the saturation freeze after
# the clamp, so a string test is silent on the cases a winding loop
# most often presents as. SAFETY-40: every number comes from THIS
# decision, at clamp time.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
self.loop_clamped = ( self.loop_clamped = (
decision.i_clamped or decision.clamped, decision.i_clamped or decision.clamped,
@@ -4133,8 +4140,11 @@ class Controller:
# stale from this line onwards. Incremented BEFORE resolve() so that the # stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for. # record resolve() builds is stamped with the tick it is for.
self.alloc_tick += 1 self.alloc_tick += 1
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. A METHOD, NOT
# A METHOD, NOT A DICT. # A DICT: the weighting is by ENERGY HEADROOM, so it depends on the
# direction (the sign of the site target, chosen inside `resolve()`;
# see `pack_weights()`). ⚠️ A PREFERENCE, NOT A PERMISSION: a weight
# cannot stop a unit being commanded.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
_live = self.live_units() _live = self.live_units()
while True: while True:
@@ -4142,8 +4152,9 @@ class Controller:
weights=self.pack_weights, tick=self.alloc_tick, weights=self.pack_weights, tick=self.alloc_tick,
reserve=self._held_back()) reserve=self._held_back())
# SAFETY-73: a probe pin stands only while the resolved site target # SAFETY-73: a probe pin stands only while the resolved site target
# asks at least PROBE_W its way. # asks at least PROBE_W its way; below that a sibling would carry
# (DECISIONS/MAIN-orchestration-archive.md) # the opposite direction (circulation), so the pin goes back to the
# closed limit. (DECISIONS/MAIN-orchestration-archive.md)
_void = [k for k, c in enumerate(claims) _void = [k for k, c in enumerate(claims)
if c.source == "refused" and c.kind == "set" if c.source == "refused" and c.kind == "set"
and c.value * resolution.target_w < _ctl.PROBE_W ** 2] and c.value * resolution.target_w < _ctl.PROBE_W ** 2]
@@ -4157,10 +4168,10 @@ class Controller:
self.allocation = resolution.allocation self.allocation = resolution.allocation
if self.modes_on: if self.modes_on:
self._mode_fault_log(resolution) self._mode_fault_log(resolution)
# THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL Both do the same # THE TWO REFUSALS ARE NOT THE SAME REFUSAL: both REPLACE the
# thing - REPLACE the resolution with a 0 W failsafe which is then # resolution with a 0 W failsafe, written like any other cycle; they
# written like any other cycle at `write_setpoint(self.target)` below. # differ in WHO refused, hence two branches and two messages. ⚠️
# AND NEITHER MAY BE LEFT UNGRADED. # NEITHER MAY BE LEFT UNGRADED (test_peakguard.py part 10h).
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
if not resolution.allocated: if not resolution.allocated:
self.log_event("ARBITER: " + resolution.allocation.explain(), self.log_event("ARBITER: " + resolution.allocation.explain(),
@@ -4172,9 +4183,12 @@ class Controller:
scope_errors=resolution.scope_errors, scope_errors=resolution.scope_errors,
allocation=resolution.allocation, allocation=resolution.allocation,
winner_priority=resolution.winner_priority) winner_priority=resolution.winner_priority)
# 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. # 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. ⚠️
# ⚠️ RE-AIMED BY DEV-02, NOT DELETED, AND THE DIFFERENCE MATTERS. # RE-AIMED BY DEV-02, NOT DELETED: a roster entry with no writer would
# (DECISIONS/MAIN-orchestration-archive.md) # have the site target written to the others (over-command by its
# share); "nothing" here means 0 W. Not folded into `live_units()`: a
# dead link is dropped and announced, an undeliverable roster stops the
# site. (DECISIONS/MAIN-orchestration-archive.md)
elif {u for u in resolution.shares if not self._can_write(u)}: elif {u for u in resolution.shares if not self._can_write(u)}:
undeliverable = ", ".join( undeliverable = ", ".join(
sorted(u for u in resolution.shares if not self._can_write(u))) sorted(u for u in resolution.shares if not self._can_write(u)))
@@ -4184,7 +4198,10 @@ class Controller:
f"setpoint entity for {undeliverable} - refusing to command a " f"setpoint entity for {undeliverable} - refusing to command a "
f"partial site", hint=messages.hint("no_setpoint")) f"partial site", hint=messages.hint("no_setpoint"))
# ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE # ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE
# DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. # DEV-02 THAT IS LOAD-BEARING: the write loop commands
# `shares.get(name, 0.0)`, so carrying the split through would
# write the numbers this branch declined, or nothing at all when
# they equal what stands (an unchanged value is not re-sent).
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
resolution = Resolution(0.0, "failsafe", None, False, resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered, resolution.considered,
@@ -4194,8 +4211,9 @@ class Controller:
allocation=resolution.allocation, allocation=resolution.allocation,
winner_priority=resolution.winner_priority) winner_priority=resolution.winner_priority)
# 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its # 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its
# share, which is a real change in what the site can deliver, and an # share, a real change in what the site can deliver, and an operator
# operator who is not told reads a working site. # who is not told reads a working site. Edge-triggered on the SET of
# dropped units (the action log is a log of events, §15).
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
_dropped = tuple(u for u, _why in (self.allocation.dropped _dropped = tuple(u for u, _why in (self.allocation.dropped
if self.allocation else ())) if self.allocation else ()))
@@ -4210,7 +4228,10 @@ class Controller:
hint=messages.hint("units_back")) hint=messages.hint("units_back"))
self._dropped_last = _dropped self._dropped_last = _dropped
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. # 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
# `unmet_w` is the site target the envelopes could not absorb. # `unmet_w` is the site target the envelopes could not absorb; not a
# broken allocation (fusing it with `ok` would let one saturated
# inverter park the site), but the site failing to deliver what the
# loop asked. Edge-triggered on the VALUE.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
_unmet = int(self.allocation.unmet_w if self.allocation else 0) _unmet = int(self.allocation.unmet_w if self.allocation else 0)
if _unmet != self._unmet_last: if _unmet != self._unmet_last:
@@ -4225,24 +4246,27 @@ class Controller:
self._unmet_last = _unmet self._unmet_last = _unmet
if resolution.scope_errors: if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope # A claim reached the arbiter on an actuator its source's scope
# does not permit. (DECISIONS/MAIN-orchestration-archive.md) # does not permit: a BUG in the filing site, not a state to survive
# (a per-inverter constraint in the site list is the `lo > hi` this
# card removes). Loud, every cycle; the resolution is unchanged.
# (DECISIONS/MAIN-orchestration-archive.md)
_LOG.error("A limit was filed on the wrong inverter, which is a " _LOG.error("A limit was filed on the wrong inverter, which is a "
"software fault. %s.", " / ".join(resolution.scope_errors)) "software fault. %s.", " / ".join(resolution.scope_errors))
if resolution.contradiction: if resolution.contradiction:
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log", self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
hint=messages.hint("clash")) hint=messages.hint("clash"))
# SAFETY-02's arming gate Was the number about to go on the wire # SAFETY-02's arming gate: was the number chosen by the grid-following
# chosen by the grid-following loop, or by a strategy that commands # loop, or by a strategy that commands against the meter on purpose?
# against the meter on purpose? # Only the former is expected to agree in sign with P1. ⚠️ THE
# THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not # EXPRESSION LIVES IN watchdog.py, NOT HERE: read loop_is_following()
# tidiness. # before touching the gate. (DECISIONS/MAIN-orchestration-archive.md)
# (DECISIONS/MAIN-orchestration-archive.md)
self.following = loop_is_following(resolution, self.loop_w) self.following = loop_is_following(resolution, self.loop_w)
self.target = resolution.target_w self.target = resolution.target_w
self.reason = resolution.explain() self.reason = resolution.explain()
# UX-08: the same answer in plain words, for the page's "Why" line. # UX-08: the same answer in plain words, for the page's "Why" line;
# `reason` stays the technical record the log and the checks read. # `reason` stays the technical record the log and the checks read.
# (DECISIONS/MAIN-orchestration-archive.md) # UX-10: every key `why_key()` can return has words in
# messages.WHY_WINNER. (DECISIONS/MAIN-orchestration-archive.md)
_why = why_key(resolution.winner, claims) _why = why_key(resolution.winner, claims)
if _why == "inputs missing" and not resolution.contradiction: if _why == "inputs missing" and not resolution.contradiction:
# UX-11: past the hold, say which reading is missing. # UX-11: past the hold, say which reading is missing.
@@ -4253,10 +4277,12 @@ class Controller:
_why, resolution.target_w, resolution.bound_by, _why, resolution.target_w, resolution.bound_by,
contradiction=resolution.contradiction, contradiction=resolution.contradiction,
raised=why_raised(resolution, claims)) raised=why_raised(resolution, claims))
# DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ⚠️ ITS OWN SHARE, NEVER # DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE, NEVER THE SITE TARGET
# THE SITE TARGET. # (handing each unit `self.target` is the ARCH-01 fault: each supplies
# AT N=1 THIS IS BYTE-IDENTICAL. # it in full and the gain at the meter becomes N times the per-unit
# (DECISIONS/MAIN-orchestration-archive.md) # gain). At N=1 this is byte-identical. ⚠️ A unit not in `shares` is
# commanded 0 W, not left standing: an inverter holds its last command
# for ever. (DECISIONS/MAIN-orchestration-archive.md)
self._loop_alarm_input(resolution) self._loop_alarm_input(resolution)
writes = [] writes = []
for inv in self.inverters: for inv in self.inverters:
@@ -4264,13 +4290,19 @@ class Controller:
writes.append(self.write_setpoint(inv.target, unit=inv)) writes.append(self.write_setpoint(inv.target, unit=inv))
await asyncio.gather(*writes) await asyncio.gather(*writes)
# SAFETY-03, and its boundary is the WRITE ⚠️ NOT THE SAME BOUNDARY AS # SAFETY-03, and its boundary is the WRITE. ⚠️ NOT THE SAME BOUNDARY AS
# SAFETY-18's BELOW, THOUGH THE CALLS ABUT. # SAFETY-18's BELOW, THOUGH THE CALLS ABUT: this one reads
# `self.clamp_last`, None until write_setpoint() has run, so it must
# follow the write (test_clamp.py part 6). Two guards, two orderings:
# do not "tidy" them into one rule.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
await self.check_output_clamp() await self.check_output_clamp()
# SAFETY-18, and its boundary is the ASSIGNMENT ⚠️ THE BOUNDARY IS # SAFETY-18, and its boundary is the ASSIGNMENT. ⚠️ THE BOUNDARY IS
# `self.target = resolution.target_w` ABOVE, NOT write_setpoint(). # `self.target = resolution.target_w` ABOVE, NOT write_setpoint(): both
# terms of the conjunction must describe the SAME moment, so moving it
# above the assignment grades the previous cycle's command against this
# cycle's meter (test_freeze.py part 5).
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
await self.check_meter_frozen() await self.check_meter_frozen()
@@ -4280,17 +4312,17 @@ class Controller:
await self.check_price_gap() await self.check_price_gap()
await self.check_fixed_note() await self.check_fixed_note()
# SAFETY-31's alarm, and its boundary is the LATCH, not a number # SAFETY-31's alarm, and its boundary is the LATCH, not a number: it
# Unconstrained where the two above are not: it grades # grades `self.circ_wd.tripped`, advanced near the top of this method.
# `self.circ_wd.tripped`, which was advanced near the top of this # Called on EVERY cycle, not only the one that trips, because its
# method beside SAFETY-02 and which nothing since has touched. # second job is re-raising the alarm after a restart.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
await self.check_circulating() await self.check_circulating()
# SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's It # SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's:
# grades `inv.delivery_wd.tripped`, which was advanced near the top of # it grades `inv.delivery_wd.tripped`, and is called on EVERY cycle
# this method beside SAFETY-02 and SAFETY-31 and which nothing since # because its second job is re-raising the alarm after a restart.
# has touched. (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
await self.check_not_delivering() await self.check_not_delivering()
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------ # --- SAFETY-07's alarm, and it is NOT the engagement ------------------
@@ -4298,31 +4330,41 @@ class Controller:
# method assigned above and nothing since has touched. # method assigned above and nothing since has touched.
await self.check_peak_saturated() await self.check_peak_saturated()
# SAFETY-24's alarm, and it is the OTHER way the money gets spent Same # SAFETY-24's alarm, and it is the OTHER way the money gets spent. Same
# boundary argument as check_peak_saturated(): it grades # boundary argument as check_peak_saturated(); kept as its own method
# `self.peak_guard`, which cycle() assigned above and nothing since has # because the two states are mutually exclusive and want OPPOSITE
# touched. (DECISIONS/MAIN-orchestration-archive.md) # operator actions (saturated: shed load; this: the pack is at its
# floor and the quarter is not being shaved).
# (DECISIONS/MAIN-orchestration-archive.md)
await self.check_peak_floor_limited() await self.check_peak_floor_limited()
# TARIFF-01, and it is LAST on purpose Banking a quarter is # TARIFF-01, and it is LAST on purpose: banking a quarter is
# bookkeeping about a moment that has already happened; a slow SD card # bookkeeping about a moment that has passed, and a slow SD card must
# must never sit between the meter and the setpoint. # never sit between the meter and the setpoint. It grades neither
# (DECISIONS/MAIN-orchestration-archive.md) # `target` nor `standing`. (DECISIONS/MAIN-orchestration-archive.md)
self.peak.sync(self.p1) self.peak.sync(self.p1)
# SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS Every # SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS: every
# guard above has now read `self.target` - the resolved intent - and # guard above has read `self.target`, the resolved intent (SAFETY-18
# SAFETY-18 in particular grades exactly that. # grades exactly that); re-seeding before them would hand each a 0 W no
# decision produced. See `_shadow_settle()`.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
self._shadow_settle(resolution) self._shadow_settle(resolution)
def _shadow_settle(self, resolution) -> None: def _shadow_settle(self, resolution) -> None:
"""SAFETY-10. Record the decision, then re-seed the carried command. """SAFETY-10. Record the decision, then re-seed the carried command
WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. (`self.target`, each `inv.target`) to `shadow.WIRE_W`, in this order
because the second destroys the first's evidence. ⚠️ THE RE-SEED IS A
SAFETY PROPERTY: compute's slew base, the sign watchdogs
(`standing_w()`) and SAFETY-42 read the carried command as a statement
about the HARDWARE; a shadow decision there would latch a trip that
survives into live operation. One log line per CHANGE of decision.
(DECISIONS/MAIN-orchestration-archive.md)""" (DECISIONS/MAIN-orchestration-archive.md)"""
engaged = self.shadow engaged = self.shadow
if engaged != self._shadow_was: if engaged != self._shadow_was:
# ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT (a
# "shadow mode off" line at every start pushed a clamp episode out
# of the 40-row log); coming up IN shadow mode always gets a line.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
first = self._shadow_was is None first = self._shadow_was is None
self._shadow_was = engaged self._shadow_was = engaged
@@ -4352,11 +4394,16 @@ class Controller:
self._shadow_winner = None self._shadow_winner = None
return return
self.shadow_would_w = float(resolution.target_w) self.shadow_would_w = float(resolution.target_w)
# SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. # SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY: all three rungs live in
# `arbitrage.sized_from_meter()`; a rung written at a call site is one
# no check can reach with the other two clear (TEST-16).
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
self.shadow_sized_from_meter = arbitrage.sized_from_meter( self.shadow_sized_from_meter = arbitrage.sized_from_meter(
self.arb, self.arb_headroom_w, winner=resolution.winner) self.arb, self.arb_headroom_w, winner=resolution.winner)
# ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. # ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE (a loop
# target that that wanders by a watt would be the whole log). ⚠️
# SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE: the same winner at the
# same watts with and without the caveat are two different claims.
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
edge = (resolution.winner, round(float(resolution.target_w)), edge = (resolution.winner, round(float(resolution.target_w)),
self.shadow_sized_from_meter) self.shadow_sized_from_meter)
@@ -4373,7 +4420,9 @@ class Controller:
def _loop_alarm_input(self, resolution) -> None: def _loop_alarm_input(self, resolution) -> None:
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided """SAFETY-85. The loop's clamp counts only on a cycle the loop decided
(a rating may cut it). (DECISIONS/MAIN-orchestration-archive.md)""" (a rating may cut it). A battery another owner set to discharge counts
when it gave less than it was told for `saturation_cycles` cycles in a
row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md"""
d = self._loop_decision d = self._loop_decision
if d is None: if d is None:
self._short_run = 0 self._short_run = 0
@@ -4416,8 +4465,11 @@ class Controller:
self.loop_clamp_facts = () self.loop_clamp_facts = ()
def _settling(self, owns, now) -> bool: def _settling(self, owns, now) -> bool:
"""SAFETY-119: does the loop's bound on this cycle count? """SAFETY-119: does the loop's bound on this cycle count? Not within a
(DECISIONS/MAIN-orchestration-archive.md)""" settle (HANDBACK_CYCLES cycles and SHORT_SPAN_S) after another owner
handed the wire back; across all hand-backs the bound is hidden on at
most ROLLING_BOUND_CYCLES cycles in any ROLLING_S.
DECISIONS/SAFETY-121-bound-every-quarter.md"""
def aged(t0, span): def aged(t0, span):
return now is None or t0 is None or now - t0 >= span return now is None or t0 is None or now - t0 >= span
@@ -4444,11 +4496,16 @@ class Controller:
return True return True
async def check_output_clamp(self) -> None: async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle. """SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs
PUBLISHING IS NOT IN THE WRITE PATH. AFTER write_setpoint() (the clamp is arithmetic done inside the write).
(DECISIONS/MAIN-orchestration-archive.md)""" ⚠️ PUBLISHING IS NOT IN THE WRITE PATH: a stalled supervisor must never
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT. delay or drop a frame. ⚠️ THIS IS NOT A TRIP: no claim, no latch, 0 W
# (DECISIONS/MAIN-orchestration-archive.md) is not commanded (§11.2: clamp and alarm); turning it into one is a
deliberate change. (DECISIONS/MAIN-orchestration-archive.md)"""
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT: `clamp_last` is on
# the Inverter, `ClampWatchdog` is one per controller (an anti-flood
# counter), so any unit that clamped is an engagement and the reason
# names it. (DECISIONS/MAIN-orchestration-archive.md)
clamped_units = [inv for inv in self.inverters clamped_units = [inv for inv in self.inverters
if inv.clamp_last is not None and inv.clamp_last.engaged] if inv.clamp_last is not None and inv.clamp_last.engaged]
engaged_write = bool(clamped_units) engaged_write = bool(clamped_units)