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
+524
View File
@@ -0,0 +1,524 @@
"""One site grid target, divided across N inverters so that the shares SUM to it.
⚠️ THE REASON THIS EXISTS, AND IT IS NOT EFFICIENCY. The control primitive in
this system is METER EMULATION: the master reports a FABRICATED grid reading
(0x056E direction, 0x056F magnitude, FC 0x10, every 1-2 s) 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: the loop
gain at the meter becomes N times the per-unit gain.
That is measured, not argued. On admin/goodwe's two-master rig (TEST-19,
`sim/sitesim.py --table`, the add-on's own `compute()` in the loop and the
firmware's own encoder on the wire):
N=2 gain 0.6 BROADCAST worst export -400 W (N=1: +10 W)
N=2 gain 1.0 BROADCAST commands [1000, 2000, 1000, 0] W,
grid [0, -2000, 0, +2000] W, tail p2p 4000 W,
FOREVER, and not one guard fires.
`gain: 1.0` is what config.yaml:374 accepts as its documented maximum. The sign
watchdog is evaluated on every one of those cycles and never trips, because the
disagreement flips on alternate ticks and never holds its 180 s window. So the
broadcast fault is not merely inefficient: it is invisible to every detector
this repository has.
THE FIX IS STRUCTURAL, WHICH IS WHY IT IS A MODULE AND NOT A TUNING CHANGE
=========================================================================
ONE integrator on P1, above the split (§10.3, and `compute()` is that
integrator). Its output S is the SITE's grid target. This module turns S into
per-unit shares with one invariant:
sum(shares) + unmet_w == S, with unmet_w == 0 whenever the envelopes allow
and each inverter is then told a function of ITS OWN SHARE, never of the shared
measurement. Write s_i = a_i * S with sum(a_i) = 1. Each unit closes its own
loop with per-unit gain g_i, so the gain seen AT THE METER is
gbar = sum(a_i * g_i)
which is a CONVEX COMBINATION of the per-unit gains and therefore lies in
[min g_i, max g_i] FOR ANY SPLIT. Compare the broadcast case, a_i = 1 for every
unit, where the same sum is N * g and grows without bound in N.
⚠️ THAT IS THE WHOLE ARGUMENT, AND IT IS WHY THE WEIGHTS ARE A COMFORT CHOICE.
Nothing above constrains the a_i beyond summing to 1. So SoC, thermals and pack
wear may set them freely and a BAD SPLIT CANNOT DESTABILISE THE SITE - it can
only be unkind to one pack. That property is the reason §6.2 chose share
allocation over a regulator/ballast arrangement, in which the ballast unit's
share is a function of the shared measurement and the gain question comes back.
⚠️ WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0
is "please do not use this unit", and it is honoured while any other unit
has headroom - but it is NOT a constraint, because the SUM is the safety
property and stranding capacity behind a preference would leave a standing
error for the site integrator to chase forever. To make a unit genuinely
unusable, close its envelope or leave it out of `live`. See `_fill()`.
WHAT THIS MODULE MUST NOT DO
============================
⚠️ IT MUST NOT READ P1. One integrator on the site measurement, above the split.
A second reader of the meter is a second controller, and two controllers on one
actuator is the failure the whole arbiter exists to prevent. Nothing in here
takes a measurement of any kind: the inputs are S, the per-unit intervals, and
per-tick liveness. That is checkable by reading the imports, and it is why there
are none beyond the standard library.
⚠️ IT MUST NOT CACHE AN ENVELOPE. `plan()` is a pure function called once per
tick with that tick's intervals. A unit's available range moves for reasons that
have nothing to do with the allocator - a stale link, a pack at its floor, a
device rating that failed to read - and an allocator holding a construction-time
envelope over-allocates into a unit that cannot take it. There is no state here
to hold one; that is deliberate and it is graded.
⚠️ ROUNDING IS A SAFETY PROPERTY. The setpoint register is 1 W (control.py says
so where it quantises). A per-unit `round()` biases the sum by up to N/2 W every
tick, and at 1-2 Hz a systematic 1 W bias is a PERMANENT standing error the site
integrator will chase forever - it is not a rounding artefact, it is a slow
offset with a control loop wound around it. So the sum is computed in INTEGER
ARITHMETIC from an integer target and the fractional parts are settled by
LARGEST REMAINDER (Hamilton): floor everything, then hand the leftover watts one
at a time to the units with the largest discarded fraction. The sum cannot drift
because no float ever enters it.
"""
from dataclasses import dataclass, field
from math import ceil, floor, inf
# The tolerance on a float comparison of watts. Envelopes arrive as floats from
# config and from device registers; this is the width below which two of them
# are the same number. It is never used on the sum - see the module docstring.
EPS = 1e-9
# --- why a unit is not in the envelope set, and why the whole thing refused ---
# Named rather than spelled inline so that main.py, SAFETY-28's write gate and
# the tests can all agree on which case fired without matching English.
DROP_STALE = "stale link"
DROP_CONTRADICTION = "contradicts itself"
DROP_NO_INTEGER = "no integer setpoint in its interval"
REFUSE_SCOPE = "mis-routed claim"
REFUSE_EMPTY = "no unit available"
REFUSE_SIGN = "opposite directions"
@dataclass(frozen=True)
class UnitPlan:
"""What ONE unit was told this tick, and why it was that and not more.
This is the per-unit half of the allocation record. `why` is written for an
operator reading an action log at 2 a.m., not for a developer: it says which
mechanism chose the number - the weighted fill, an envelope that ran out, a
claim of the unit's own that outranked the site.
"""
unit: str
share_w: int
lo: float
hi: float
weight: float
why: str
pinned: bool = False
clamped: bool = False
def explain(self) -> str:
span = (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, "
f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]")
return f"{self.unit} -> {self.share_w:+d} W in {span} ({self.why})"
@dataclass(frozen=True)
class Allocation:
"""EVERY TICK PRODUCES ONE OF THESE, including the ticks that refused.
⚠️ IT IS DESIGNED TO BE GATEABLE, because SAFETY-28 gates the write path on
it. That means three things, and each is a decision rather than a
convenience:
* `ok` is ONE boolean and it is the only thing a gate has to read. A gate
that must parse a message, or check three fields in the right order, is a
gate somebody gets wrong.
* a REFUSAL still carries the record - `plans`, `dropped` and the reason -
so the gate can say WHY it stopped the write instead of only that it did.
A refusal that returns nothing is a refusal nobody can debug.
* `unmet_w` is separate from `ok`. An allocation that could not place every
watt is still SAFE TO WRITE and strictly better than commanding zero; it
is the site target that was not fully met, not a broken allocation. Fusing
the two would make a saturated inverter park the whole site.
`sum_w` is the integer the shares are required to add up to, and
`quantisation_w` is the ONE rounding that produced it from `target_w`. Both
are here so that "the shares sum to S" can be checked by a reader rather
than believed: `sum(shares.values()) + unmet_w == sum_w` is an identity over
integers, and `sum_w - target_w` is a single site-level rounding of at most
half a watt that is NEVER distributed into a per-unit bias.
"""
target_w: float
sum_w: int
shares: dict = field(default_factory=dict)
plans: tuple = ()
dropped: tuple = () # ((unit, why), ...) - order is roster order
unmet_w: int = 0
quantisation_w: float = 0.0
refused: str | None = None
passthrough: bool = False # N<=1: the share IS the site target, unrounded
@property
def ok(self) -> bool:
"""The one boolean SAFETY-28's write gate reads."""
return self.refused is None
def explain(self) -> str:
"""One line an operator can act on."""
if self.refused is not None:
return (f"REFUSED to allocate {self.target_w:+.0f} W: "
f"{self.refused}")
if self.passthrough:
return f"pass-through {self.target_w:+.0f} W (one unit, nothing to divide)"
body = ", ".join(f"{u}{s:+d}" for u, s in sorted(self.shares.items()))
out = f"{self.sum_w:+d} W as {body}"
if self.unmet_w:
out += f", {self.unmet_w:+d} W UNMET"
if self.dropped:
out += (" - dropped "
+ ", ".join(f"{u} ({w})" for u, w in self.dropped))
return out
def _int_envelope(b):
"""A unit's interval, narrowed to the integers a 1 W register can hold.
⚠️ NARROWED INWARD, ALWAYS. `ceil` on the floor and `floor` on the ceiling,
so every integer in the result is inside the real interval. Rounding the
other way would let a share of exactly the rating be commanded on a unit
whose rating is 2499.5 W, which is the one direction an envelope must never
move: the envelope is what `device` and `supervised` filed, i.e. what the
hardware said it can do.
Infinities pass through untouched - an absent bound is not a large bound,
and `ceil(-inf)` is not a number. A unit that filed nothing this cycle has
exactly that interval, which is why this case is the common one at N>=2 and
not an edge.
"""
lo = -inf if b.lo == -inf else ceil(b.lo - EPS)
hi = inf if b.hi == inf else floor(b.hi + EPS)
return lo, hi
def _clamp_int(v, lo, hi):
return min(max(int(v), lo if lo != -inf else int(v)),
hi if hi != inf else int(v))
def _fill(order, weights, cap, amount):
"""WATER-FILL: place `amount` watts across `order`, capped, by weight.
`amount` and every `cap` are NON-NEGATIVE magnitudes; the direction has
already been taken out by the caller. Returns `(add, leftover)`.
THE LOOP IS THE STANDARD ONE and it terminates in at most len(order) rounds:
share the remaining amount in proportion to weight; any unit whose share
would exceed its cap is PINNED AT ITS CAP and dropped from the active set;
repeat with what is left. A unit that is not capped in a round is not capped
in any later round either, so nothing is revisited.
⚠️ AND THEN THE SECOND PASS, WHICH IS THE POLICY DECISION IN THIS FUNCTION.
Units with weight 0 are excluded from the weighted rounds - that is what a
zero weight is for. But if the weighted set fills up and watts remain, they
are offered to the zero-weight units, EQUALLY, rather than being reported as
unmet. The reason is the module docstring's: the weights are a comfort
choice and the sum is the safety property, so a preference must never
manufacture a standing error at the meter that the site integrator will then
chase. To make a unit genuinely unusable, close its envelope - `cap` is
obeyed absolutely and is never overridden here.
"""
add = {u: 0.0 for u in order}
rem = float(amount)
for pool in (
[u for u in order if weights[u] > 0.0],
[u for u in order if weights[u] <= 0.0], # the last-resort pass
):
# Inside the last-resort pass every weight is 0, so the proportional
# step needs a weight of its own; equal shares is the only choice that
# does not invent a preference the caller did not express.
w = {u: (weights[u] if weights[u] > 0.0 else 1.0) for u in pool}
active = [u for u in pool if cap[u] > 0.0]
while rem > EPS and active:
total = sum(w[u] for u in active)
capped = [u for u in active if rem * w[u] / total >= cap[u] - add[u]]
if capped:
for u in capped:
rem -= cap[u] - add[u]
add[u] = cap[u]
active = [u for u in active if u not in capped]
else:
for u in active:
add[u] += rem * w[u] / total
rem = 0.0
return add, rem
def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
site_priority=None, site_key="site") -> Allocation:
"""Divide the site grid target `target_w` across the units in `bounds`.
`bounds` unit name -> that unit's interval for THIS TICK (arbiter stage 1,
`UnitBounds`). Read per tick, never cached - see the module
docstring. Duck-typed on `.lo`, `.hi`, `.gate`, `.pinned_w`,
`.pinned_by` and `.contradiction` so that this module does not
import the arbiter and cannot grow a dependency on claims.
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
`live` the units whose master has reported recently enough to be
commanded, or None for "all of them". A unit not listed is
DROPPED and the record says so; the survivors then carry the
whole site target, which is a real change in what the site can
do and must never happen silently (AC 6).
`scope_errors` the arbiter's mis-routing complaints. See REFUSE_SCOPE below.
`site_priority` the priority of the SITE claim that chose `target_w`. It is
what a unit's own winning SET is weighed against - see PINS.
`site_key` the name to use when there are no units at all.
⚠️ N<=1 IS A PASS-THROUGH AND IS BYTE-IDENTICAL TO WHAT SHIPPED. The share
IS the site target, as a float, unclamped and unrounded. That is §6.2's N=1
identity - with one inverter the fabricated grid reading and the battery
setpoint are the same number - and it is also the compatibility promise:
every existing test in this repository replays through here and must see the
number it saw before ARB-03. Do not "tidy" this branch into the general path
by giving it a roster of one; the general path rounds to an integer and
applies the envelope, and both are behaviour changes on the live install.
"""
units = tuple(sorted(bounds))
# --- REFUSAL 1: the claim list that produced S is known to be mis-routed --
# ⚠️ THIS IS THE ANSWER TO ARB-03's OPEN QUESTION, AND IT IS A REFUSAL
# RATHER THAN AN INPUT. `scope_violations()` reports a claim filed on an
# actuator its source's scope does not permit. It is tempting to CONSUME
# one - to read a unit-scoped claim that landed on the site as an allocation
# input - and that is wrong twice over. First, by the time this function
# runs the mis-routed claim has ALREADY been partitioned by `resolve()`, so
# its effect is baked into S or into somebody's interval and cannot be
# undone from here; consuming it would mean re-routing, which would make
# `actuator` advisory and hand every filing site a second chance to be
# wrong. Second, and worse, promoting a unit claim that landed on the site
# requires guessing WHICH unit it belonged to, and there is no information
# here to guess with - `actuator_for()` exists precisely because that answer
# is chosen where the claim is built. Applying it to ALL units would put one
# inverter's rating on a sibling that does not have it.
#
# So what is left is that S itself may be wrong, and dividing a number that
# may be wrong is worse than not dividing it. Refuse, and let main.py fail
# toward inaction and shout - which is the same answer clause 3 of the
# arbiter's own rule already gives to a contradiction.
#
# ⚠️ IT IS ABOVE THE N<=1 PASS-THROUGH ON PURPOSE, and that is the one place
# this card touches the pass-through's behaviour. A correctly routed
# one-inverter cycle CANNOT produce a scope violation - `scope_violations()`
# only complains about a unit source on the site when the site has named
# units, and a site source on a unit requires somebody to have hand-written
# `actuator=` past `actuator_for()` - so this cannot fire on the shipped
# install, and where it does fire the input is a known bug rather than a
# state to pass through.
if scope_errors:
return Allocation(
target_w=float(target_w), sum_w=int(round(float(target_w))),
shares={}, plans=(), dropped=(),
unmet_w=int(round(float(target_w))),
quantisation_w=float(int(round(float(target_w)))) - float(target_w),
refused=f"{REFUSE_SCOPE}: " + "; ".join(scope_errors))
if len(units) <= 1:
only = units[0] if units else site_key
share = float(target_w)
return Allocation(
target_w=float(target_w), sum_w=int(round(share)),
shares={only: share},
plans=(UnitPlan(only, int(round(share)), -inf, inf, 1.0,
"N<=1: the share IS the site target"),),
passthrough=True)
w = {u: 1.0 if weights is None else float(weights.get(u, 1.0))
for u in units}
for u, wu in w.items():
if wu < 0.0 or wu != wu:
raise ValueError(f"weight for {u!r} is {wu!r}; weights are "
"non-negative comfort factors, not signed targets")
# ⚠️ THE TARGET IS ROUNDED ONCE, HERE, AND NOWHERE ELSE. Everything below
# this line is integer arithmetic, which is what makes "the shares sum to S"
# an identity rather than a tolerance. `round()` is banker's rounding: over a
# long replay it is unbiased, where `int(x + 0.5)` would add a systematic
# half-watt in the discharge direction. In practice `target_w` arrives from
# `compute()` already quantised to `step_w` (10 W by default) so this is a
# no-op on the live path; it is not a no-op for a peak-shaving floor or an
# arbitrage setpoint, which are not quantised.
sum_w = int(round(float(target_w)))
quant = float(sum_w) - float(target_w)
# --- the envelope set, rebuilt from scratch every tick -------------------
dropped = []
ilo, ihi = {}, {}
considered = []
for u in units:
b = bounds[u]
if live is not None and u not in live:
# AC 6. The survivor is about to carry this unit's share and the
# record has to say so; `dropped` is how it says it, and main.py
# logs the transition.
dropped.append((u, DROP_STALE))
continue
if getattr(b, "contradiction", False):
# A unit contradicting ITSELF - its own `lo` above its own `hi`.
# Stage 1 already decided that parks THAT unit and not the site,
# which is the whole point of the partition; here it means the unit
# has no interval to allocate into.
dropped.append((u, DROP_CONTRADICTION))
continue
lo, hi = _int_envelope(b)
if lo > hi:
# An interval that contains no integer. The register is 1 W, so
# there is no number this unit could be commanded that both bounds
# accept - dropping it is the only answer that does not violate one.
dropped.append((u, DROP_NO_INTEGER))
continue
ilo[u], ihi[u] = lo, hi
considered.append(u)
def refuse(why):
return Allocation(
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant,
refused=why)
# --- REFUSAL 2: nobody is available ------------------------------------
if not considered:
return refuse(f"{REFUSE_EMPTY}: "
+ ", ".join(f"{u} ({why})" for u, why in dropped))
# --- PINS: a unit's own SET, weighed against the site's -------------------
# ⚠️ THIS IS THE DECISION ARB-03 DEFERRED TO HERE, IN ITS OWN WORDS: "Stage 1
# states the constraint and records what it is gated on; deciding whether it
# applies is stage 3's business." `UnitBounds.gate` is the priority of the
# unit's own winning SET; `site_priority` is the priority of the SITE claim
# that chose S. Clause 1 of the arbiter's rule, transposed to a site with
# more than one actuator: the higher priority wins, and a tie goes to the
# unit, because a claim about one pack is more specific than a claim about
# the connection and specificity is what a tie has left to break on.
#
# A pinned unit is NOT in the water-fill. It is doing something of its own -
# a maintenance drain, a failsafe park - and what it does still counts
# toward the site sum, so the free units carry the REMAINDER. That is not
# the allocator compensating for a disturbance, which would be a second
# controller; it is the shares summing to S, which is the invariant. The
# meter's own answer arrives next tick through the one integrator above.
#
# ⚠️ AND site_priority=None MEANS "NO SITE SET WAS NAMED", so every pin
# stands. `resolve()` always passes the real one. A direct caller that
# passes nothing is saying it has no site claim to weigh, and honouring the
# unit's own highest-priority claim is the only safe reading of that.
pinned = {}
for u in considered:
b = bounds[u]
gate = getattr(b, "gate", None)
if gate is None:
continue
if site_priority is None or gate >= site_priority:
pv = getattr(b, "pinned_w", None)
if pv is None:
continue
pinned[u] = _clamp_int(round(float(pv)), ilo[u], ihi[u])
free = [u for u in considered if u not in pinned]
remainder = sum_w - sum(pinned.values())
# --- WATER-FILL over the free units --------------------------------------
# `base` is the point of each envelope nearest zero: 0 for an ordinary
# symmetric rating, and the forced end for an envelope that does not contain
# zero. Filling from there rather than from `lo` is what keeps a unit that
# is merely ALLOWED to charge from being TOLD to.
base = {u: _clamp_int(0, ilo[u], ihi[u]) for u in free}
placed = sum(base.values())
need = remainder - placed
up = need >= 0
cap = {u: (ihi[u] - base[u]) if up else (base[u] - ilo[u]) for u in free}
add, leftover = _fill(free, w, cap, abs(need))
exact = {u: base[u] + (add[u] if up else -add[u]) for u in free}
# --- LARGEST-REMAINDER ROUNDING -----------------------------------------
# Floor every share, then hand out the discarded watts one at a time, worst
# discarded fraction first. The sum is integer arithmetic end to end, so it
# cannot drift; ties break on the unit name so that the same inputs give the
# same allocation on every tick and on every host.
share = {u: int(floor(exact[u] + EPS)) for u in free}
for u in free: # floors can never leave the envelope,
share[u] = _clamp_int(share[u], ilo[u], ihi[u]) # but say so anyway
debt = remainder - sum(share.values())
frac_order = sorted(free, key=lambda u: (-(exact[u] - floor(exact[u])), u))
step = 1 if debt > 0 else -1
while debt != 0:
moved = False
for u in (frac_order if debt > 0 else list(reversed(frac_order))):
if debt == 0:
break
nxt = share[u] + step
if (step > 0 and nxt <= ihi[u]) or (step < 0 and nxt >= ilo[u]):
share[u] = nxt
debt -= step
moved = True
if not moved:
break
shares = dict(pinned)
shares.update(share)
unmet = sum_w - sum(shares.values())
# --- REFUSAL 3: SIGN COHERENCE, so circulating power is not expressible ---
# ⚠️ REFUSED, NOT AVERAGED. Two shares of opposite sign mean one pack
# discharging into the AC bus while another charges off it: energy round-
# tripping through two conversion stages for no reason, and - this is the
# part that makes it a safety property rather than an efficiency one -
# INVISIBLE. The meter sees the net, which is exactly S, so the site
# integrator is content; each unit sees a target it is meeting, so no
# per-unit guard fires; the sign watchdog compares a command against the
# meter and both agree. Nothing in this repository would ever report it.
#
# It cannot arise from an ordinary tick: the fill only ever moves shares in
# ONE direction from a base that is zero whenever the envelope contains
# zero, so opposite signs require an envelope that EXCLUDES zero (a forced
# floor) or a pin. Those are the cases worth refusing, and the refusal is a
# site-level one because the alternative - dropping the offending unit and
# re-filling - would silently override a claim that outranked the loop.
pos = sorted(u for u, s in shares.items() if s > 0)
neg = sorted(u for u, s in shares.items() if s < 0)
if pos and neg:
return refuse(
f"{REFUSE_SIGN}: {', '.join(pos)} would discharge while "
f"{', '.join(neg)} charges - circulating power, which nets to "
f"{sum_w:+d} W at the meter and is invisible to every guard")
plans = []
for u in units:
drop = dict(dropped).get(u)
if drop is not None:
plans.append(UnitPlan(u, 0, bounds[u].lo, bounds[u].hi, w[u],
f"DROPPED: {drop}"))
continue
if u in pinned:
by = getattr(bounds[u], "pinned_by", None) or "its own claim"
plans.append(UnitPlan(u, pinned[u], ilo[u], ihi[u], w[u],
f"pinned by {by} at priority "
f"{bounds[u].gate}", pinned=True))
continue
at_edge = share[u] in (ilo[u], ihi[u])
why = "weighted share"
if at_edge and cap[u] - add[u] <= EPS and abs(need) > EPS:
why = "envelope full - its surplus went to the others"
elif w[u] <= 0.0 and share[u] != base[u]:
why = "last resort: the preferred units were full"
plans.append(UnitPlan(u, share[u], ilo[u], ihi[u], w[u], why,
clamped=at_edge))
return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares,
plans=tuple(plans), dropped=tuple(dropped),
unmet_w=int(unmet), quantisation_w=quant)
+106 -36
View File
@@ -45,9 +45,10 @@ test can construct a case at any one of them:
stage sees the whole list and answers exactly what the
shipped arbiter answered.
3. `allocate()` - THE SEAM. It turns S into per-inverter shares subject to
stage 1's intervals. ⚠️ IT IS NOT AN ALLOCATOR AND MUST
NOT BECOME ONE HERE - see its own docstring. ALLOC-01
builds it.
stage 1's intervals. ALLOC-01 filled it: the allocator
itself is `app/allocator.py` and the reasoning lives
there, because it consumes intervals and a number and
must never learn about claims, priorities or P1.
⚠️ THE STRUCTURAL PROPERTY, AND IT IS THE REASON FOR THE SPLIT. A per-inverter
constraint is consumed by the allocator as an INTERVAL; it is never promoted
@@ -63,6 +64,13 @@ strategy doing it by hand.
from dataclasses import dataclass, field
from math import inf
# ⚠️ ONE WAY ONLY. `allocator` knows nothing about claims, priorities or
# this module - it consumes intervals and a number - so the dependency
# cannot become a cycle and the allocator stays testable on its own, which
# is what lets test_alloc.py sweep it hundreds of thousands of times
# without building a claim list for each one.
from . import allocator
# Priorities live here and nowhere else. The moment these become magic numbers
# scattered through the strategies, the whole point of this module is lost.
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
@@ -305,6 +313,24 @@ class Resolution:
shares: dict = field(default_factory=dict) # stage 3, per actuator
allocated: bool = True # False when stage 3 declined to split
scope_errors: list = field(default_factory=list) # mis-routed claims
# ALLOC-01, appended for the reason the four above were: `target_w` still
# means the SITE grid target and every existing construction is untouched.
#
# `allocation` is stage 3's full record - what each unit was told and why,
# what could not be placed, what was dropped. `shares` and `allocated` are
# two fields OF that record, kept at the top level because main.py and four
# test files already read them there. ⚠️ IT IS THE RECORD, NOT THE PAIR,
# THAT SAFETY-28 GATES THE WRITE PATH ON: `allocated` says the allocator
# stands behind the split, and `unmet_w` says how much of the site target it
# could not place, and fusing those two would make one saturated inverter
# park the whole site.
allocation: object | None = None
# The priority of the SITE claim that chose `target_w`. Stage 3 weighs a
# unit's OWN winning SET against it - see `allocator.plan()`'s PINS note -
# and getting it from here rather than recomputing it is what stops the two
# rules drifting apart: this is set by the same line that picks the winner
# `explain()` names.
winner_priority: int | None = None
def explain(self) -> str:
"""One line an operator can act on - this is the point of the module."""
@@ -347,35 +373,55 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
contradiction=lo > hi)
def allocate(target_w: float, bounds: dict) -> tuple:
"""STAGE 3. THE SEAM. Returns `(shares, allocated)`.
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None) -> tuple:
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
⚠️ THIS IS NOT THE ALLOCATOR AND MUST NOT BECOME ONE HERE. ALLOC-01 owns
that, and §6.2 already fixed its shape: SUM-INVARIANT SHARE ALLOCATION - the
shares sum to `target_w` exactly, each unit is told a function of its OWN
share, and the weights are a comfort choice (SoC, thermals, wear) and
explicitly not a tuning parameter, because the effective gain at the meter is
then a convex combination of the per-unit gains for any split.
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
This is the adapter: it keeps the two-value shape every caller in this
repository already reads, and it exists as a separate name so that the
arbiter's three stages are still three functions a test can enter at any
one of them. `allocator.plan()` returns the full record - use that when you
need `unmet_w`, `dropped` or the per-unit reasons; `resolve()` stashes it on
`Resolution.allocation` for exactly that.
What it does today, and why each half is what it is:
What §6.2 fixed and what this delivers:
* WITH AT MOST ONE UNIT there is nothing to divide. The share IS the site
target, and that is not an approximation - it is §6.2's N=1 identity, the
one that makes "the battery moves X W" and "the grid is at X W" the same
sentence. Pass-through.
* WITH TWO OR MORE UNITS IT DECLINES: no shares, `allocated=False`. ⚠️ THE
OBVIOUS PLACEHOLDER - hand `target_w` to each unit - IS THE FAULT ARCH-01
WAS FILED ABOUT. Each inverter would supply it in full, the loop gain at
the meter would be N times the per-unit gain, and at the gain config.yaml
accepts as its documented maximum that is a permanent +-2 kW limit cycle
with every watchdog in §11.2 reporting healthy. A placeholder that cannot
be told apart from the real thing by any guard we have is worse than no
placeholder, so this one refuses out loud instead.
* SUM-INVARIANT. `sum(shares) + unmet_w == round(target_w)`, in integer
arithmetic, so it cannot drift. Rounding is largest-remainder because a
per-unit `round()` biases the sum by up to N/2 W every tick and at 1-2 Hz
that is a PERMANENT standing error the site integrator chases forever.
* EACH UNIT IS TOLD A FUNCTION OF ITS OWN SHARE, never of the shared
measurement. That makes the gain at the meter a convex combination of the
per-unit gains for ANY split, which is why the weights are a comfort
choice and a bad split cannot destabilise the site.
* WITH AT MOST ONE UNIT IT IS STILL A PASS-THROUGH, byte-identical to what
ARB-03 shipped: the share IS the site target, as a float, unrounded and
unclamped. §6.2's N=1 identity, and the compatibility promise every
existing replay in this repository depends on.
⚠️ AND IT STILL DECLINES, WHICH IS NOT THE SAME LIST OF CASES AS BEFORE.
ARB-03 declined at N>=2 because it had no allocator; that trigger is gone
and the branch is NOT. It now fires when no safe split exists at all: a
mis-routed claim, no unit left in the envelope set, or a split that could
only be made by commanding one pack to charge while another discharges.
Read `allocator.plan()` for each. main.py's refusal path and
test_peakguard.py part 10h were re-aimed onto those rather than deleted -
PR #40's reviewer proved that guard was unreachable and untested once, and
removing its trigger without giving it a new one would rebuild the hole.
"""
if len(bounds) <= 1:
only = next(iter(bounds), ACTUATOR_SITE)
return {only: float(target_w)}, True
return {}, False
return allocate_record(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority)[:2]
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None) -> tuple:
"""`allocate()` plus the record - `(shares, allocated, allocation)`."""
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority, site_key=ACTUATOR_SITE)
return rec.shares, rec.ok, rec
def site_resolve(claims: list) -> Resolution:
@@ -412,7 +458,8 @@ def site_resolve(claims: list) -> Resolution:
if lo > hi:
return Resolution(0.0, winner.source, bound_by=None,
contradiction=True, considered=considered)
contradiction=True, considered=considered,
winner_priority=winner.priority)
target = max(lo, min(hi, winner.value))
bound_by = None
@@ -421,17 +468,29 @@ def site_resolve(claims: list) -> Resolution:
if binder is not None:
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
return Resolution(float(target), winner.source, bound_by, False, considered)
return Resolution(float(target), winner.source, bound_by, False, considered,
winner_priority=winner.priority)
def resolve(claims: list, *, units=None) -> Resolution:
def resolve(claims: list, *, units=None, weights=None, live=None) -> Resolution:
"""The three stages, in order, over one cycle's claims.
`units` is the site's roster of inverters. Left None it is derived from the
claims themselves, which is right for a caller that files a claim for every
unit every cycle (main.py does - `supervised` is unconditional) and wrong for
one that does not, so ALLOC-01 will pass the real roster rather than let a
silent unit look like a smaller site.
one that does not, so main.py passes the real roster rather than let a silent
unit look like a smaller site.
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
wear - and cannot destabilise the site whatever it says. `live` is the set
of units whose master has reported recently enough to be commanded; a unit
left out of it is DROPPED from the envelope set and the record says so,
because the survivor carrying a dead unit's share silently is the failure
SAFETY-30 is about. ⚠️ NEITHER MAY BE CACHED BY A CALLER. A unit's range
moves for reasons unrelated to the allocator - a stale link, a pack at its
floor, a device rating that failed to read - so an envelope read once at
construction over-allocates into a unit that cannot take it.
⚠️ AND THE ARGUMENT HAS TO BE HONOURED, WHICH IS NOT THE SAME AS EXISTING.
Ignore it - derive always - and a unit that filed nothing this cycle ceases
@@ -460,8 +519,19 @@ def resolve(claims: list, *, units=None) -> Resolution:
# `claims`, so this is the shipped arbiter on the shipped list.
out = site_resolve(site_claims)
# STAGE 3 - the seam.
out.shares, out.allocated = allocate(out.target_w, bounds)
out.unit_bounds = bounds
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
#
# ⚠️ THE ORDER MATTERS: `scope_violations()` IS COMPUTED BEFORE THE SPLIT,
# NOT AFTER, because stage 3 consumes it. A claim filed on an actuator its
# source's scope does not permit 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 - `allocator.plan()` carries the full argument, including
# why the mis-routed claim is NOT promoted into somebody's envelope. This
# was the open question ARB-03 left, and it is answered here rather than
# left as a log line nothing grades.
out.scope_errors = scope_violations(claims)
out.unit_bounds = bounds
out.shares, out.allocated, out.allocation = allocate_record(
out.target_w, bounds, weights=weights, live=live,
scope_errors=out.scope_errors, site_priority=out.winner_priority)
return out
+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: