Files
goodwe-addon/goodwe_controller/app/allocator.py
T
Claude 591c35ed01
checks / unit (pull_request) Successful in 4m19s
SAFETY-28: gate the write path on the allocation record, not on key count
specs §1.5 makes "any configuration in which two writers can reach one
actuator" a non-goal. That non-goal was enforced by counting config keys:
validate.py's ACTUATOR_WRITE_KEYS = ("setpoint_entity",), consumed at rule 1,
with a comment stating the guarantee outright - "there is exactly one such
key, so a second writer is not expressible in the options schema at all".

That was true and it graded THE SHAPE OF THE OPTIONS SCHEMA, not the writes.
Add CFG-04's per-inverter config - a roster of inverters each with its own
setpoint_entity - and there are two real writers with len() of that tuple
still equal to 1. The check stays green while the property it protects is
gone. sim/inject.py's two_writers scenario asserted on the same tuple, so it
went green too. That is this repository's named failure mode: a check that
observes something other than what it claims to observe.

Demonstrated rather than argued. test_validate.py part 2 now constructs the
hazardous per-inverter config, asserts that the OLD proxy is still green on
it, and then asserts the new check refuses it. Against release/1.0's
validator the updated sim scenario reports: "validate_startup_config()
ACCEPTED a configuration in which 3 declarations reach the ONE actuator
number.goodwe_setpoint ... the old proxy reports 1 write key(s) on this same
config and is therefore GREEN on it".

THE WRITE PATH IS NOW GATED ON THE RECORD
=========================================
Controller.write_refusal() asks Allocation.authorises() whether the single
allocator placed exactly this number, for this unit, on this tick.
write_setpoint() refuses anything else. A second writer with arithmetic of
its own cannot satisfy that - its number appears in no record - so it has to
file a Claim and go through resolve(), which is the one allocator. "Exactly
one allocator per shared resource" stops being a convention somebody can
forget and becomes something the write path checks.

The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in place of
the number nothing stands behind. Returning would leave the inverter latched
on its last command with the ESP32 heartbeat still being fed - holding an
unauthorised setpoint quietly, which is the outcome the gate exists to stop.
AC 1 is "refused at the write path, not merely logged", and test_clamp.py
part 7a grades the device's writes, not the log.

The gate sits BELOW the non-finite coercion and ABOVE the clamp. Below,
because a nan is loss of input and rule 1 has already turned it into 0 W.
Above, because the question is who chose this number, and the answer is only
meaningful about the number that was asked for - gating the CLAMPED value
would refuse exactly the cycles SAFETY-03 exists for, since a clamped write
differs from the allocator's share by construction. Mutation M7 moves the
gate below the clamp and turns nine checks red, part 6c among them.

A STALE RECORD CANNOT BE REPLAYED
=================================
Every Allocation now carries the `tick` it was made for. It is handed IN by
the caller - plan(..., tick=) - rather than counted inside the allocator,
because test_alloc.py part 10 grades that the module holds no state at all,
and a serial number incremented in there would be the cached-across-ticks
thing the envelope rule forbids wearing a different hat.

Controller.alloc_tick advances ONCE PER RESOLUTION, not once per loop
iteration, and both halves are load-bearing:

  * a new allocation makes the previous record stale immediately, so a record
    whose envelopes have moved cannot authorise a write. That is the replay
    half (AC 2), graded by capturing a real record, running another real
    cycle, putting the old one back and observing refusal.
  * the "inputs missing, not yet stale" hold returns BEFORE the allocator
    runs, exactly so one dropped meter reading does not zero the inverter,
    and the heartbeat re-affirms self.target through this same gate while it
    does. A tick incremented per loop iteration would refuse that heartbeat
    and turn the hold into the outage it exists to prevent. Graded.

An UNSTAMPED record (tick None) authorises nothing: "I cannot say when I was
made" fails closed rather than reading as tick zero.

THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE, AND HERE IS WHY
=============================================================
write_setpoint() is also how layer 2 commands 0 W. cycle()'s two refusal
branches substitute a 0 W failsafe and write it like any other cycle;
shutdown() and the operator's stop button do the same. EVERY ONE OF THOSE
RUNS WHILE THE ALLOCATOR HAS REFUSED - ok is False and there are no shares -
so a gate that asked the allocator's permission to command zero would block
the safety write behind the very component whose failure the safety write
answers. A gate that can do that is worse than the hole it closes.

Resolved by authorising 0 W unconditionally, and on the VALUE rather than on
a failsafe=True flag: a flag is an assertion by the caller, which is exactly
what a second writer would pass; the value is a fact about what will be
written. The argument that this is safe and not merely convenient is that the
hazard §1.5 names is N-times loop gain from BROADCAST - handing every inverter
the same target so each supplies it in full (allocator.py's opening argument;
TEST-19 measured a permanent 4000 W p2p limit cycle at gain 1.0 with no guard
firing). At S = 0 the broadcast and the correct allocation COINCIDE: a_i = 1
for every unit gives every unit 0 W, which is exactly its share. There is no
over-command to make at zero. Said at the line, in write_refusal().

test_clamp.py part 7c drives it end to end through the real cycle() and the
real REFUSE_EMPTY branch: the allocator refuses every tick, the device still
sees 0 W, and the action log says ARBITER: REFUSED rather than WRITE REFUSED
so the two failures stay distinguishable to whoever has to debug them.

unmet_w IS STILL NOT ok
=======================
The gate reads ok and the unit's share and never unmet_w. allocator.py:179
says why: an allocation that could not place every watt is still safe to
write, and fusing them would let one saturated inverter park the whole site.
Graded on a record with 3800 W unmet and every share legal - mutation M5 adds
`or self.unmet_w` to the gate and that check goes red.

THE CONFIG CHECK, REPLACED RATHER THAN DELETED
==============================================
actuator_writers() counts WRITERS PER ACTUATOR instead of option keys. It
walks the options TREE rather than looking for a container called `inverters`,
because CFG-04 has not chosen that name yet and a census that guessed wrong
would go green while two writers existed - the same defect one step along.
Entity ids are folded to lower case so a second writer cannot be smuggled in
as Number.X versus number.x.

Two inverters with two entities are ACCEPTED - that is what the per-inverter
roster is for, and a validator that refuses the feature it guards is one
somebody switches off. Two declarations aimed at ONE entity are refused,
naming the actuator and every writer pointed at it. That includes the case an
author will meet first - keeping setpoint_entity as A's and listing A again -
because "they happen to be the same entity, so it is fine" is precisely the
reasoning that makes two writers invisible.

ACTUATOR_WRITE_KEYS survives ONLY as the enumeration a census needs, with its
guarantee comment deleted; nothing asserts on its length any more. Keeping the
name is also what stops admin/goodwe's current two_writers scenario failing on
an AttributeError between the two merges.

WHAT NEITHER CHECK OBSERVES, SAID OUT LOUD
==========================================
The census sees CONFIGURED writers. A second write path added in CODE - a
hass.set_number() call next to some new strategy - declares nothing and is
invisible to it, exactly as it was invisible to the tuple. That half is not
closed at startup and cannot be; it is closed at runtime by the record gate,
which does not care what the options look like or who called the method. The
two checks are deliberately different kinds, and only the runtime one survives
a schema this file has not seen yet.

EVIDENCE
========
* Full 18-file suite green on a purged tree (tools/mutation_run.sh).
* Verified by breaking it - seven mutations, each on a purged tree, each
  reverted and re-verified:
    M1 remove the gate                -> test_clamp red (4 checks)
    M2 make the zero exit an amnesty  -> test_clamp red (12 checks)
    M3 drop the tick comparison       -> test_clamp red (5 checks)
    M4 delete the census refusal      -> test_validate red (3 checks)
    M5 fuse unmet_w into ok           -> test_clamp red (1 check)
    M6 freeze the tick on the live path -> test_clamp red (2 checks)
    M7 move the gate below the clamp  -> test_clamp red (9 checks)
* AC 5: no existing check was weakened. The two edits to test_clamp.py are
  fixtures, not assertions - parts 5 and 6c call write_setpoint() directly
  with a number nothing allocated, which is now refused, so they say where
  their number came from. Every assertion in both parts is unchanged, and
  part 7f asserts that across part 3's sixty real cycles the gate refused
  zero writes.
* admin/goodwe's TEST-19 norig scenarios (planner_binding, two_master_plant,
  two_master_broadcast) still pass against this tree - they import the
  add-on's control law across the repo boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:56:48 +02:00

655 lines
34 KiB
Python

"""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
dropped: bool = False # not commanded at all - see `within_envelope`
@property
def span(self) -> str:
return (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, "
f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]")
@property
def within_envelope(self) -> bool:
"""Is the number this unit was told ACTUALLY inside the interval?
⚠️ THIS IS ASKED, NOT ASSUMED. The record is what SAFETY-28 gates on,
and a record that describes a share it did not check is worth less than
no record: the half-open clamp defect shipped a line reading
`inverter-a -> +500 W in [2000, +inf] (weighted share)` - the record
stating, in the same breath, both the floor and the share below it, and
agreeing with itself. The clamp is fixed, so this should now be true on
every plan the allocator builds; that is exactly why it is cheap to
assert rather than to trust.
A DROPPED unit is always within: it was told nothing. Its interval may
be a contradiction (`lo > hi`), which no number is inside, and printing
that as a violation would cry wolf on the one case that is already
reported by name.
"""
if self.dropped:
return True
return self.lo <= self.share_w <= self.hi
def explain(self) -> str:
if self.dropped:
return (f"{self.unit} -> not commanded, envelope {self.span} "
f"({self.why})")
if not self.within_envelope:
# Unreachable as the allocator stands. If it ever is reachable
# again, the log says so in the line an operator is already reading
# rather than in a line nobody wrote.
return (f"{self.unit} -> {self.share_w:+d} W OUTSIDE {self.span} "
f"({self.why}) - ENVELOPE VIOLATED, DO NOT WRITE")
return f"{self.unit} -> {self.share_w:+d} W in {self.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
# SAFETY-28. WHICH TICK PRODUCED THIS RECORD, as an opaque token minted by
# the caller - `Controller.alloc_tick`, incremented once per resolution.
#
# ⚠️ IT IS AN INPUT, NOT A COUNTER THIS MODULE KEEPS. `plan()` is a pure
# function and part 10 of test_alloc.py grades that by asserting the module
# holds no state at all; a serial number incremented in here would be
# exactly the cached-across-ticks thing the envelope rule forbids, wearing a
# different hat. So the tick is handed in and handed back out, and the
# freshness question is answered by whoever owns the clock.
#
# ⚠️ AND IT DEFAULTS TO None, WHICH THE WRITE GATE REFUSES. A record built
# without a tick is not "tick zero", it is a record that cannot say when it
# was made - and the gate fails closed on it. Every test in this repository
# that calls `plan()` for its arithmetic gets None and does not care;
# main.py stamps every record it puts on the write path.
tick: int | None = None
@property
def ok(self) -> bool:
"""The one boolean SAFETY-28's write gate reads."""
return self.refused is None
def authorises(self, unit: str, value: float, tick: int | None) -> str | None:
"""Why `unit` may NOT be written `value` on tick `tick`, or None if it may.
⚠️ THIS IS THE QUESTION SAFETY-28 REPLACED A CONFIG-SHAPE PROXY WITH, and
it lives on the record rather than in main.py so that the record is the
thing that answers it. Before this, "two writers can never reach one
actuator" was enforced by `len(validate.ACTUATOR_WRITE_KEYS) == 1` -
true, but a statement about the OPTIONS SCHEMA. Add a per-inverter
config (CFG-04) and there are two real writers with that length still 1:
the check stays green while the property it protects is gone.
Asking the record instead makes the property mechanical. A number
reaches the wire only if the single allocator placed it, on THIS tick,
for THIS unit. A second writer with its own arithmetic cannot produce
one, because it does not appear in a record; and it cannot borrow an old
one, because the tick has moved on.
⚠️ `unmet_w` IS NOT CONSULTED AND MUST NOT BE. An allocation that could
not place every watt is still SAFE TO WRITE - see this class's own
docstring. Fusing the two would let one saturated inverter park the
whole site, which is a worse outcome than the hole this gate closes.
⚠️ NOR IS 0 W ITS BUSINESS. The caller answers that one before it asks
this, and the reason is at `Controller.write_refusal()`: a gate that can
block the failsafe is worse than the hole it closes.
"""
if tick is None or self.tick is None or self.tick != tick:
return (f"the allocation record on hand was made on tick "
f"{self.tick!r} and this is tick {tick!r} - a record whose "
f"envelopes have moved cannot authorise a write")
if not self.ok:
return f"the allocator refused this tick ({self.explain()})"
if unit not in self.shares:
return (f"{unit} has no share in this tick's allocation "
f"({self.explain()})")
share = self.shares[unit]
if value != share:
return (f"{value:+.0f} W is not {unit}'s share of this tick's "
f"allocation, which is {share:+.0f} W ({self.explain()})")
# ⚠️ THE RECORD IS ASKED WHETHER IT AGREES WITH ITSELF. `within_envelope`
# exists because the half-open clamp defect once shipped a plan reading
# `inverter-a -> +500 W in [2000, +inf]` - stating the floor and a share
# below it in one breath - and `UnitPlan.explain()` renders that as
# ENVELOPE VIOLATED, DO NOT WRITE. This is the DO NOT WRITE.
#
# Unreachable at the shipped roster of one, where the pass-through's
# interval is (-inf, +inf) and every number is inside it. Graded anyway,
# on a constructed record, because the branch PR #40's reviewer replaced
# with `if False:` was unreachable too and the whole suite stayed green.
for p in self.plans:
if p.unit == unit and not p.within_envelope:
return (f"the record contradicts itself: {p.explain()}")
return 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):
"""`v` as an int, pulled inside `[lo, hi]`. EACH END IS APPLIED ON ITS OWN.
⚠️ THE TWO ENDS ARE INDEPENDENT AND MUST BE TREATED SO. An earlier form
substituted `v` itself for a missing bound and fed both into one
`min(max(...))`, which reads as "an absent bound cannot bind" but is not
what it does: with `hi == inf` the whole expression collapses to
`min(max(v, lo), v) == v`, so a MISSING UPPER BOUND SILENTLY DISCARDED THE
LOWER ONE. `_clamp_int(0, 2142, inf)` returned 0 - a share below a 2142 W
floor the unit itself filed - and every caller here inherited it.
So: skip the end that is infinite, apply the end that is not, and never let
one end's absence reach the other. `UnitBounds()` defaults to
`(-inf, +inf)`, and this helper is the whole of the ticket's first
deliverable ("each share clipped to that unit's UnitBounds interval"), so a
one-sided envelope is the ordinary case, not an edge.
"""
v = int(v)
if lo != -inf:
v = max(v, lo)
if hi != inf:
v = min(v, hi)
return 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", tick=None) -> 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.
`tick` an opaque token identifying the tick this record is FOR, handed
straight back on the record. SAFETY-28's write gate compares it
against the tick it is writing on, so that a record cannot be
replayed after its envelopes have moved. It is an input rather
than something this module counts, for the reason on the field.
⚠️ 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), tick=tick)
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, tick=tick)
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, tick=tick)
# --- 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}", dropped=True))
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, tick=tick)