ALLOC-01: allocate the site grid target, sum-invariant, never broadcast
checks / unit (pull_request) Successful in 4m6s

ARB-03 left `allocate()` as a documented seam that passed through at N<=1 and
DECLINED at N>=2, with the reasoning recorded at the line: the obvious
placeholder - hand the site target to each inverter - IS the ARCH-01 fault, and
"a placeholder that cannot be told apart from the real thing by any guard we
have is worse than no placeholder". This fills the seam.

WHY A BROADCAST IS A FAULT AND NOT AN INEFFICIENCY. The control primitive is
meter emulation: the master reports a FABRICATED grid reading and the inverter
regulates itself to it, so one number means "the battery moves X W" to this
add-on and "the grid is at X W" to the inverter - an identity that holds only at
N=1. Hand two inverters the same target and each supplies it IN FULL, so the
loop gain at the meter becomes N times the per-unit gain. Reproduced on
admin/goodwe's two-master rig against this tree: -400 W export overshoot at
gain 0.6, and at gain 1.0 - which config.yaml accepts as its documented maximum
- a permanent 4000 W peak-to-peak limit cycle, commands alternating
1000/2000/1000/0 W, with NOT ONE GUARD FIRING. The sign watchdog is evaluated on
every cycle and never trips, because the disagreement flips on alternate ticks
and never holds its 180 s window.

WHAT WAS BUILT. `app/allocator.py`, host-side and strictly above the driver
seam. It consumes intervals and a number and nothing else - no claims, no
priorities, and no measurement, because an allocator that read P1 would be a
SECOND integrator on the site measurement.

  * ENVELOPE CLAMP, from ARB-03 stage 1's `UnitBounds`, read PER TICK. `plan()`
    is a pure function and the module holds no state, so there is nowhere for a
    construction-time envelope to be cached.
  * WATER-FILL by weight, with the surplus of a full unit moving to the others.
    Weights express PREFERENCE, envelopes express PERMISSION: a zero weight is
    honoured while another unit has headroom and overridden when it does not,
    because the SUM is the safety property and stranding capacity behind a
    comfort choice manufactures a standing error at the meter.
  * LARGEST-REMAINDER ROUNDING. The target is rounded ONCE, at the top; every
    sum below that line is integer arithmetic, so it cannot drift. Measured: a
    per-unit round() drifts -125 W over 50 000 ticks at three inverters.
  * THE ALLOCATION RECORD, with `unmet_w`, `dropped` and a per-unit reason.
    `ok` is one boolean for SAFETY-28 to gate on, and `unmet_w` is deliberately
    NOT fused into it - a saturated inverter must not park the whole site.

THE STABILITY PROPERTY IS STRUCTURAL. With sum(a_i) = 1 the gain at the meter is
sum(a_i * g_i), a convex combination of the per-unit gains, so it lies in
[min g_i, max g_i] FOR ANY SPLIT. That is what makes the weights a comfort
choice rather than a tuning parameter, and it is why a bad split cannot
destabilise the site. Graded directly, and measured end to end: 90/10, 50/50 and
100/0 give a bit-identical aggregate trace at the meter, and a gain sweep to 1.0
at every split has a flat tail at all 84 combinations.

THE TWO REFUSALS main.py NOW MAKES, AND WHY THERE ARE TWO. `allocated=False` no
longer means "no allocator"; it means no safe split EXISTS - a mis-routed claim,
an empty envelope set, or a split that could only be made by commanding one pack
to charge while another discharges. Separately, and this guard is the price of
making allocate() succeed rather than something inherited, main.py refuses to
act on a valid split it cannot DELIVER: there is one `setpoint_entity`, so
writing the site target to the one inverter it can reach would over-command that
inverter by exactly the other's share. Commanding a partial site is a worse
instruction than commanding nothing. DEV-02 gives it the second writer.

scope_violations() IS NOW CONSUMED, AND AS A REFUSAL. ARB-03 left it logging
without changing the resolution. A mis-routed claim means the number about to be
divided may itself be wrong, and dividing a number that may be wrong is worse
than not dividing it - the same answer clause 3 already gives a contradiction.
It is NOT promoted into an envelope: a unit-scoped claim that landed on the site
names no unit, so promoting it would mean guessing one, or applying one
inverter's rating to a sibling that does not have it.

AT N<=1 IT IS A PASS-THROUGH, BYTE-IDENTICAL: the share is the site target, as a
float, unrounded and unclamped. All 17 existing suites replay unedited.

test_peakguard.py part 10h and test_arbiter.py part 7 were RE-AIMED, not
deleted. PR #40's reviewer proved that refusal branch was unreachable and
untested once; removing its trigger without giving it a new one would have
rebuilt the hole. Part 10h now drives three real cycles - a deliverable split, a
dead sibling, and every master stale - and grades all three.

KNOWN AND NOT CLOSED HERE: at a roster of two the loop's `actual_w` is one
inverter's power used as a site measurement, because the add-on binds a single
`inverter_power` sensor. That is the SENSING half of the same N=1 identity this
card removes from the COMMANDING half, and it makes the aggregate trace
split-dependent below gain 1.0. Measured with its counterfactual in test_alloc.py
part 8 and tools/alloc_sim.py --splits. It belongs to DEV-02.

Verified: all 18 suites green in the CI image. 16 mutations run through
tools/mutation_run.sh on a purged tree; 13 red, and the 3 survivors are recorded
in test_alloc.py part 12 with the reason each cannot be caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-02 10:42:33 +02:00
co-authored by Claude Opus 5
parent df88b323f5
commit 186e13ceca
9 changed files with 2205 additions and 95 deletions
+112 -22
View File
@@ -291,6 +291,18 @@ class Controller:
# than re-derived a cycle later. Starts False: nothing is on the wire.
self.following = False
self.reason = "starting"
# ALLOC-01. The allocation record from the last resolution, or None
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
# IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads
# `shares` off it to drive a second inverter. It is REPLACED every cycle
# and never merged - a record from a tick whose envelopes have moved is
# the cached envelope this card exists to forbid, wearing a different
# hat.
self.allocation = None
# What the last cycle's allocator dropped, so the log line above is
# edge-triggered. Starts as the empty tuple - nothing has been dropped
# yet and nothing has been said about it.
self._dropped_last = ()
self.grid = self.soc = self.batt = None
self.peak_fc = None
self.cheap = False
@@ -451,6 +463,31 @@ class Controller:
"""
return actuator_for(source, self.unit, self.units)
def live_units(self) -> tuple:
"""Which inverters may be commanded THIS TICK, for the allocator.
⚠️ THE ALLOCATOR DROPS WHAT THIS DOES NOT LIST, and the survivors then
carry the whole site target. That is a real change in what the site can
deliver, so it is reported rather than absorbed - `Allocation.dropped`
carries it and the cycle logs the transition below. A wrong answer here
is a site target divided among fewer inverters than exist, which is why
it is a method with a reason and not a field somebody can set.
⚠️ AND IT IS ANSWERED PER TICK, NEVER CACHED. A master goes stale
between one cycle and the next; an envelope read once at construction
over-allocates into a unit that cannot take it.
Today the roster is one inverter and THIS CONTROLLER IS THAT
INVERTER'S ONLY LINK, so "its master is stale" has no expression
separate from "this controller is blind" - and that case never reaches
the allocator with anything to divide, because `safety` files a SITE set
at P_SAFETY and every share resolves to 0 W. So the whole roster is
live, and saying that here is what makes the seam real rather than
notional. DEV-02 gives each inverter its own link and its own freshness,
and replaces the body of this method with them.
"""
return self.units
async def cycle(self) -> None:
"""Gather claims, resolve precedence, write the winner.
@@ -844,36 +881,89 @@ class Controller:
# de-assert when control is stopped.
self.loop_clamped = (False, "")
resolution = resolve(claims, units=self.units)
# ⚠️ N>=2 WITHOUT AN ALLOCATOR IS A REFUSAL TO BROADCAST, NOT A REFUSAL
# TO WRITE, and the difference is the next four lines: the resolution is
# REPLACED by a 0 W failsafe and then written like any other cycle at
resolution = resolve(claims, units=self.units, live=self.live_units())
self.allocation = resolution.allocation
# --- 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. Nothing is skipped. 0 W is the
# fail-toward-inaction direction - the setpoint is commanded to zero
# rather than left wherever the last cycle put it.
# fail-toward-inaction direction: the setpoint is commanded to zero
# rather than left wherever the last cycle put it. They differ in WHO
# refused and therefore in what an operator should do about it, so they
# are two branches with two messages and not one with a vague one.
#
# This branch is unreachable while the roster holds one unit. It is here
# because the alternative default is the fault ARCH-01 was filed about:
# hand the site target to each inverter and each supplies it IN FULL, so
# the loop gain at the meter becomes N times the per-unit gain - a
# permanent +-2 kW limit cycle at the gain config.yaml documents as its
# maximum, with every watchdog in §11.2 reporting healthy. `allocate()`
# declines rather than invent a split; this is what declining costs.
# ⚠️ AND NEITHER MAY BE LEFT UNGRADED. PR #40's reviewer replaced the
# first of these with `if False:` and the whole 17-file suite stayed
# green, because at the shipped roster of one it was unreachable.
# test_peakguard.py part 10h grades both, on real cycles.
#
# ⚠️ AND BEING UNREACHABLE IS NOT A REASON TO LEAVE IT UNGRADED. It was:
# PR #40's reviewer replaced this `if` with `if False:` and the whole
# 17-file suite stayed green, so the branch was one refactor away from
# being deleted as dead code. test_peakguard.py part 10h now drives a
# real two-inverter cycle and grades all four halves - the 0 W, the
# `failsafe` winner, the log line, and the write that follows it.
# 1. THE ALLOCATOR REFUSED. Before ALLOC-01 this fired on every N>=2
# cycle, because there was no allocator and the obvious placeholder -
# hand the site target to each inverter - IS the ARCH-01 fault. There
# is an allocator now and that trigger is gone; the branch is not,
# because refusing is still right when no safe split exists at all: a
# mis-routed claim (so the number to divide may itself be wrong), no
# inverter left in the envelope set, or a split that could only be
# made by commanding one pack to charge while another discharges.
# `allocator.plan()` carries the argument for each.
if not resolution.allocated:
self.log_event(f"ARBITER: no allocator for {len(self.units)} "
f"inverters - refusing to broadcast the site target")
self.log_event("ARBITER: " + resolution.allocation.explain())
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
unit_bounds=resolution.unit_bounds,
shares={}, allocated=False,
scope_errors=resolution.scope_errors)
scope_errors=resolution.scope_errors,
allocation=resolution.allocation,
winner_priority=resolution.winner_priority)
# 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT.
# ⚠️ THIS GUARD IS THE PRICE OF MAKING allocate() WORK, and without
# it ALLOC-01 would be a REGRESSION rather than a fix. There is ONE
# `setpoint_entity` and ONE inverter-power sensor in this add-on, so
# this controller can put a number on exactly one inverter. Give it a
# roster of two and a valid split, and writing `self.target` - the
# SITE target - to its single entity would hand one inverter the
# whole site's number while a second sits uncommanded: not the
# broadcast fault, but the same shape of over-command, and the
# allocator's own arithmetic says the share is half that. Commanding
# a partial site is a worse instruction than commanding nothing, and
# "nothing" here means 0 W and says so.
#
# ⚠️ AT THE SHIPPED ROSTER OF ONE THIS IS BYTE-IDENTICAL TO BEFORE.
# `shares` is `{inverter-a: target_w}` - §6.2's N=1 identity, the
# pass-through - so the set below is empty and nothing happens.
# DEV-02 gives this controller a writer per inverter and deletes the
# branch; until it does, the branch is what stops ALLOC-01 shipping a
# half-driven site.
elif set(resolution.shares) - {self.unit}:
undeliverable = ", ".join(sorted(set(resolution.shares) - {self.unit}))
self.log_event(
f"ARBITER: the site target was allocated across "
f"{len(resolution.shares)} inverters and this controller drives "
f"only {self.unit} - no writer for {undeliverable}, refusing to "
f"command a partial site (DEV-02)")
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
unit_bounds=resolution.unit_bounds,
shares=resolution.shares, allocated=True,
scope_errors=resolution.scope_errors,
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. Edge-triggered on
# the SET of dropped units, not level-triggered, because the action
# log is a log of events (§15) and a stale link would otherwise write
# a line every second for as long as it lasts.
_dropped = tuple(u for u, _why in (self.allocation.dropped
if self.allocation else ()))
if _dropped != self._dropped_last:
for _u, _why in (self.allocation.dropped if self.allocation else ()):
self.log_event(f"ALLOCATOR: {_u} dropped from the split "
f"({_why}) - the remaining inverter(s) now carry "
f"its share of the site target")
if self._dropped_last and not _dropped:
self.log_event("ALLOCATOR: every inverter is back in the split")
self._dropped_last = _dropped
if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope does
# not permit. It is a BUG in the filing site, not a state to survive: