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:
probe = any(_probing.values())
# ⚠️ `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)
# > saturation_w`, and `prev_w` is the SITE target.
# The anti-windup test in compute() is `abs(prev_w - actual_w) >
# 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)
site_batt = self.site_batt_w
# SAFETY-103: what each battery can be doing (its reading may be
@@ -4085,14 +4086,20 @@ class Controller:
if len(self.inverters) == 1:
self._refusal_update(decision, loop_prev, site_batt, probe,
rejudged)
# TEL-10 ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT
# IS WHAT THE CARD IS.
# AND `loop_reason` IS NOT `self.reason`.
# TEL-10. ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS: `loop_i`
# is `Decision.i_w`, the value BEFORE the clamp, slew and
# 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)
self.loop_i_w = decision.i_w
self.loop_i_lead = decision.i_w - loop_prev
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)
self.loop_clamped = (
decision.i_clamped or decision.clamped,
@@ -4133,8 +4140,11 @@ class Controller:
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
self.alloc_tick += 1
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD.
# A METHOD, NOT A DICT.
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. A METHOD, NOT
# 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)
_live = self.live_units()
while True:
@@ -4142,8 +4152,9 @@ class Controller:
weights=self.pack_weights, tick=self.alloc_tick,
reserve=self._held_back())
# SAFETY-73: a probe pin stands only while the resolved site target
# asks at least PROBE_W its way.
# (DECISIONS/MAIN-orchestration-archive.md)
# asks at least PROBE_W its way; below that a sibling would carry
# 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)
if c.source == "refused" and c.kind == "set"
and c.value * resolution.target_w < _ctl.PROBE_W ** 2]
@@ -4157,10 +4168,10 @@ class Controller:
self.allocation = resolution.allocation
if self.modes_on:
self._mode_fault_log(resolution)
# THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL Both do the same
# thing - REPLACE the resolution with a 0 W failsafe which is then
# written like any other cycle at `write_setpoint(self.target)` below.
# AND NEITHER MAY BE LEFT UNGRADED.
# THE TWO REFUSALS ARE NOT THE SAME REFUSAL: both REPLACE the
# resolution with a 0 W failsafe, written like any other cycle; they
# differ in WHO refused, hence two branches and two messages. ⚠️
# NEITHER MAY BE LEFT UNGRADED (test_peakguard.py part 10h).
# (DECISIONS/MAIN-orchestration-archive.md)
if not resolution.allocated:
self.log_event("ARBITER: " + resolution.allocation.explain(),
@@ -4172,9 +4183,12 @@ class Controller:
scope_errors=resolution.scope_errors,
allocation=resolution.allocation,
winner_priority=resolution.winner_priority)
# 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT.
# ⚠️ RE-AIMED BY DEV-02, NOT DELETED, AND THE DIFFERENCE MATTERS.
# (DECISIONS/MAIN-orchestration-archive.md)
# 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. ⚠️
# RE-AIMED BY DEV-02, NOT DELETED: a roster entry with no writer would
# 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)}:
undeliverable = ", ".join(
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"partial site", hint=messages.hint("no_setpoint"))
# ⚠️ `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)
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
@@ -4194,8 +4211,9 @@ class Controller:
allocation=resolution.allocation,
winner_priority=resolution.winner_priority)
# 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
# operator who is not told reads a working site.
# share, a real change in what the site can deliver, and an operator
# 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)
_dropped = tuple(u for u, _why in (self.allocation.dropped
if self.allocation else ()))
@@ -4210,7 +4228,10 @@ class Controller:
hint=messages.hint("units_back"))
self._dropped_last = _dropped
# 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)
_unmet = int(self.allocation.unmet_w if self.allocation else 0)
if _unmet != self._unmet_last:
@@ -4225,24 +4246,27 @@ class Controller:
self._unmet_last = _unmet
if resolution.scope_errors:
# 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 "
"software fault. %s.", " / ".join(resolution.scope_errors))
if resolution.contradiction:
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
hint=messages.hint("clash"))
# SAFETY-02's arming gate Was the number about to go on the wire
# chosen by the grid-following loop, or by a strategy that commands
# against the meter on purpose?
# THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not
# tidiness.
# (DECISIONS/MAIN-orchestration-archive.md)
# SAFETY-02's arming gate: was the number chosen by the grid-following
# loop, or by a strategy that commands against the meter on purpose?
# Only the former is expected to agree in sign with P1. ⚠️ THE
# EXPRESSION LIVES IN watchdog.py, NOT HERE: read loop_is_following()
# before touching the gate. (DECISIONS/MAIN-orchestration-archive.md)
self.following = loop_is_following(resolution, self.loop_w)
self.target = resolution.target_w
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.
# (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)
if _why == "inputs missing" and not resolution.contradiction:
# UX-11: past the hold, say which reading is missing.
@@ -4253,10 +4277,12 @@ class Controller:
_why, resolution.target_w, resolution.bound_by,
contradiction=resolution.contradiction,
raised=why_raised(resolution, claims))
# DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ⚠️ ITS OWN SHARE, NEVER
# THE SITE TARGET.
# AT N=1 THIS IS BYTE-IDENTICAL.
# (DECISIONS/MAIN-orchestration-archive.md)
# DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE, NEVER THE SITE TARGET
# (handing each unit `self.target` is the ARCH-01 fault: each supplies
# it in full and the gain at the meter becomes N times the per-unit
# 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)
writes = []
for inv in self.inverters:
@@ -4264,13 +4290,19 @@ class Controller:
writes.append(self.write_setpoint(inv.target, unit=inv))
await asyncio.gather(*writes)
# SAFETY-03, and its boundary is the WRITE ⚠️ NOT THE SAME BOUNDARY AS
# SAFETY-18's BELOW, THOUGH THE CALLS ABUT.
# SAFETY-03, and its boundary is the WRITE. ⚠️ NOT THE SAME BOUNDARY AS
# 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)
await self.check_output_clamp()
# SAFETY-18, and its boundary is the ASSIGNMENT ⚠️ THE BOUNDARY IS
# `self.target = resolution.target_w` ABOVE, NOT write_setpoint().
# SAFETY-18, and its boundary is the ASSIGNMENT. ⚠️ THE BOUNDARY IS
# `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)
await self.check_meter_frozen()
@@ -4280,17 +4312,17 @@ class Controller:
await self.check_price_gap()
await self.check_fixed_note()
# SAFETY-31's alarm, and its boundary is the LATCH, not a number
# Unconstrained where the two above are not: it grades
# `self.circ_wd.tripped`, which was advanced near the top of this
# method beside SAFETY-02 and which nothing since has touched.
# SAFETY-31's alarm, and its boundary is the LATCH, not a number: it
# grades `self.circ_wd.tripped`, advanced near the top of this method.
# Called on EVERY cycle, not only the one that trips, because its
# second job is re-raising the alarm after a restart.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.check_circulating()
# SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's It
# grades `inv.delivery_wd.tripped`, which was advanced near the top of
# this method beside SAFETY-02 and SAFETY-31 and which nothing since
# has touched. (DECISIONS/MAIN-orchestration-archive.md)
# SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's:
# it grades `inv.delivery_wd.tripped`, and is called on EVERY cycle
# because its second job is re-raising the alarm after a restart.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.check_not_delivering()
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
@@ -4298,31 +4330,41 @@ class Controller:
# method assigned above and nothing since has touched.
await self.check_peak_saturated()
# SAFETY-24's alarm, and it is the OTHER way the money gets spent Same
# boundary argument as check_peak_saturated(): it grades
# `self.peak_guard`, which cycle() assigned above and nothing since has
# touched. (DECISIONS/MAIN-orchestration-archive.md)
# SAFETY-24's alarm, and it is the OTHER way the money gets spent. Same
# boundary argument as check_peak_saturated(); kept as its own method
# because the two states are mutually exclusive and want OPPOSITE
# 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()
# TARIFF-01, and it is LAST on purpose Banking a quarter is
# bookkeeping about a moment that has already happened; a slow SD card
# must never sit between the meter and the setpoint.
# (DECISIONS/MAIN-orchestration-archive.md)
# TARIFF-01, and it is LAST on purpose: banking a quarter is
# bookkeeping about a moment that has passed, and a slow SD card must
# never sit between the meter and the setpoint. It grades neither
# `target` nor `standing`. (DECISIONS/MAIN-orchestration-archive.md)
self.peak.sync(self.p1)
# 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
# SAFETY-18 in particular grades exactly that.
# SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS: every
# guard above has read `self.target`, the resolved intent (SAFETY-18
# grades exactly that); re-seeding before them would hand each a 0 W no
# decision produced. See `_shadow_settle()`.
# (DECISIONS/MAIN-orchestration-archive.md)
self._shadow_settle(resolution)
def _shadow_settle(self, resolution) -> None:
"""SAFETY-10. Record the decision, then re-seed the carried command.
WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING.
"""SAFETY-10. Record the decision, then re-seed the carried command
(`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)"""
engaged = self.shadow
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)
first = self._shadow_was is None
self._shadow_was = engaged
@@ -4352,11 +4394,16 @@ class Controller:
self._shadow_winner = None
return
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)
self.shadow_sized_from_meter = arbitrage.sized_from_meter(
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)
edge = (resolution.winner, round(float(resolution.target_w)),
self.shadow_sized_from_meter)
@@ -4373,7 +4420,9 @@ class Controller:
def _loop_alarm_input(self, resolution) -> None:
"""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
if d is None:
self._short_run = 0
@@ -4416,8 +4465,11 @@ class Controller:
self.loop_clamp_facts = ()
def _settling(self, owns, now) -> bool:
"""SAFETY-119: does the loop's bound on this cycle count?
(DECISIONS/MAIN-orchestration-archive.md)"""
"""SAFETY-119: does the loop's bound on this cycle count? Not within a
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):
return now is None or t0 is None or now - t0 >= span
@@ -4444,11 +4496,16 @@ class Controller:
return True
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
PUBLISHING IS NOT IN THE WRITE PATH.
(DECISIONS/MAIN-orchestration-archive.md)"""
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT.
# (DECISIONS/MAIN-orchestration-archive.md)
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs
AFTER write_setpoint() (the clamp is arithmetic done inside the write).
⚠️ PUBLISHING IS NOT IN THE WRITE PATH: a stalled supervisor must never
delay or drop a frame. ⚠️ THIS IS NOT A TRIP: no claim, no latch, 0 W
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
if inv.clamp_last is not None and inv.clamp_last.engaged]
engaged_write = bool(clamped_units)