checks / unit (pull_request) Successful in 4m9s
Two blocking findings from the review of 186e13c. The second is why the first
survived, and it is the one this commit is really about.
THE CLAMP. `_clamp_int()` folded both ends of the interval into one
`min(max(...))` and substituted `v` itself for a bound that was infinite. That
reads as "an absent bound cannot bind", and it is not what it does: with
`hi == inf` the expression collapses to `min(max(v, lo), v) == v`, so A MISSING
CEILING SILENTLY DISCARDED THE FLOOR. `_clamp_int(0, 2142, inf)` returned 0. It
reached the shares through all three call sites - the base, the pin clamp and
the post-floor clamp.
The worked failure: bounds `{a: [2000, +inf], b: [-5000, 5000]}` against a
+1000 W site target returned `a=+500, b=+500` - inverter-a commanded at a
quarter of its own 2000 W floor - while THE SAME CASE WITH A FINITE CEILING
correctly REFUSES on sign coherence. So one defect broke the ticket's first
named deliverable ("each share clipped to that unit's UnitBounds interval") and
suppressed a circulating-power refusal that should have fired. Both now behave
identically, and the refusal is the behaviour.
Not reachable on the live install - N=1 is a pass-through, and via main.py the
supervised claim unconditionally files a finite +/-max_w LIMIT at P_SAFETY that
pre_resolve's gate can never filter because nothing outranks P_SAFETY. But
`plan()` and `arbiter.allocate()` are the public envelope contract SAFETY-28 and
DEV-02 are being asked to build on, and `UnitBounds()` DEFAULTS TO
`(-inf, +inf)`, so a one-sided envelope is the ordinary case at N>=2 rather than
an edge, and any caller with one inherited the defect.
Fixed by clamping the two ends independently: cast to int, apply `lo` only if it
is not -inf, apply `hi` only if it is not +inf. `_int_envelope()` already passes
infinities through untouched and needed no change; it is the only other place in
the module that special-cases infinity, and the rest of the tree's `isfinite`
calls are input validation in unrelated modules.
THE RECORD. `UnitPlan.explain()` printed
`inverter-a -> +500 W in [2000, +inf] (weighted share)` - a line asserting a
share is inside an interval that excludes it, in the record SAFETY-28 gates on.
It now ASKS: `within_envelope` checks the share against the interval, and a
violated plan prints `OUTSIDE ... - ENVELOPE VIOLATED, DO NOT WRITE` instead. A
dropped unit is exempt and prints "not commanded" - it was told nothing, its
interval may be a contradiction no number is inside, and a banner there would
cry wolf on the one row that is working.
THE REPLAY THAT COULD NOT SEE IT, WHICH IS THE FINDING THAT MATTERS. The
250 000-tick replay drew every envelope as `B(-uniform(0, RATING),
uniform(0, RATING))` - finite on both ends and containing zero, every time -
while the check it fed claimed "no share ever leaves the envelope it was clamped
into", which reads as a statement about `UnitBounds`. The data could only
exercise a symmetric closed interval around zero. That is why it reported 0
escapes against a live defect while the reviewer's own 120 000 ticks reported
6 254.
The generator now draws from every shape `UnitBounds` can express: finite
straddling zero, half-open above, half-open below, finite excluding zero in each
direction, half-open AND excluding zero, and bare `UnitBounds()`. Over the same
seed and the same 250 000 ticks it draws 166 657 half-open intervals, 55 233
bare ones and 222 082 that exclude zero, and those counts are PRINTED and
asserted so "the replay covers half-open intervals" is a number a reader can see
rather than a sentence the file asserts about itself. The escape check also
dropped its 1 W tolerance: `_int_envelope()` narrows inward, so there is nothing
for slack to absorb except a defect.
GRADED, NOT MERELY APPLIED - the acceptance bar for this round. A/B on one seed
and one tick count, against the defective clamp: THE OLD GENERATOR SEES 0
ESCAPES, THE NEW ONE SEES 39 991. Reverting the fix under the widened generator
takes test_alloc.py RED on four checks (the escape count, the record check, and
both halves of the new half-open block); re-applying it takes it green. The
collapse is now a row in part 12's mutation table, recorded as the mutation that
actually shipped rather than a hypothetical one.
TWO CHECKS RE-AIMED RATHER THAN LOOSENED.
* Part 6's "the sign gate never fires on an ordinary tick" counted refusals over
the whole replay. Envelopes that EXCLUDE zero are precisely what that gate is
for, so the widened generator makes refusals correct there. It now counts an
ORDINARY tick explicitly - no pin, every envelope contains zero - and asserts
0 refusals over 77 533 of them, half-open and bare envelopes among them. That
is stronger than what it replaced, not weaker.
* Part 1's third check summed `sum_w - sum(shares)` over ticks where
`unmet_w == 0`, and `unmet_w` IS that difference - so it was true by
construction and would have gone quiet rather than red. It now measures the
one rounding that is actually left: the site-level `quantisation_w`, totalling
-39.8 W over 137 964 allocations, i.e. -0.0003 W per tick. Largest remainder
against the per-unit alternative is still graded in part 2, where the
counterfactual drifts -125 W over 50 000 ticks at three inverters.
Verified: all 18 suites green in the CI image on a tree with no bytecode cache;
test_alloc.py also re-run through tools/mutation_run.sh, which purges. AC 2 and
AC 3 are untouched by this change - the rig and part 8 use symmetric finite
envelopes, which the old and new clamp treat identically - and part 8 is green.
No hardware touched; admin/goodwe not fetched, not modified, not pushed to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
580 lines
29 KiB
Python
580 lines
29 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
|
|
|
|
@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):
|
|
"""`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") -> 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}", 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)
|