At N>=2 a cheap-window maintenance charge files a site `hi` (`charge`, P_MAINT_SHAPE) equal to what the charging pack's own `hi` places, so the other pack never has to give against it. The charge is sized to the quarter's headroom under the peak aim so it yields to the peak guard, and to 0 W under a site safety stop. N=1 byte-identical (safety84_identity). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
777 lines
39 KiB
Python
777 lines
39 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"
|
|
|
|
# OPT-11: why a unit's MODE layer was not applied this tick. Only the first is
|
|
# a fault; the other two are the rule working.
|
|
MODE_FAULT = "mode range leaves out 0 W"
|
|
MODE_RELEASED = "mode released by a higher claim"
|
|
MODE_OUTRANKED = "hard limit leaves no room for the mode"
|
|
|
|
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
|
|
# OPT-11: ((unit, why), ...) for each unit whose mode range left out 0 W
|
|
# this tick. That mode was dropped (see `unit_interval()`); main.py logs it
|
|
# as a software fault. Empty on every tick without a mode.
|
|
mode_faults: tuple = ()
|
|
|
|
@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 power split on hand is from tick {self.tick!r}, "
|
|
f"not this tick {tick!r}, and an old split cannot allow a "
|
|
f"write")
|
|
if not self.ok:
|
|
return f"no power split was possible 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 unit_interval(b, deciding_priority=None):
|
|
"""OPT-11. `(lo, hi, note)`: the interval a unit is allocated into.
|
|
|
|
The hard limits (`b.lo`, `b.hi`) always apply. The unit's MODE range
|
|
(`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is
|
|
above the mode's own priority (arbiter._unit_modes() passes None when the
|
|
modes hold, and the yielding limit's priority when they give way). `note` says why the
|
|
mode did not apply, or is None when it did or there is none:
|
|
|
|
* MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction
|
|
and never force one, so this is a software fault and the mode is dropped.
|
|
It is what keeps REFUSE_SIGN unreachable from a mode: the fill below
|
|
moves every free unit away from 0 in ONE direction, and it starts from 0
|
|
only while every interval holds 0.
|
|
* MODE_RELEASED: a higher claim (the peak floor) decided the target.
|
|
* MODE_OUTRANKED: the hard limits and the mode do not overlap. The hard
|
|
limits win, as they always do.
|
|
|
|
A unit without mode fields (`mode_priority` absent or None) gets its hard
|
|
limits, unchanged: that is every unit when battery modes are off.
|
|
"""
|
|
lo, hi = b.lo, b.hi
|
|
prio = getattr(b, "mode_priority", None)
|
|
if prio is None:
|
|
return lo, hi, None
|
|
mlo, mhi = b.mode_lo, b.mode_hi
|
|
if not (mlo <= 0.0 <= mhi):
|
|
return lo, hi, MODE_FAULT
|
|
if deciding_priority is not None and deciding_priority > prio:
|
|
return lo, hi, MODE_RELEASED
|
|
nlo, nhi = max(lo, mlo), min(hi, mhi)
|
|
if nlo > nhi:
|
|
return lo, hi, MODE_OUTRANKED
|
|
return nlo, nhi, None
|
|
|
|
|
|
class _Narrowed:
|
|
"""A unit's bounds with its interval replaced by `unit_interval()`'s."""
|
|
__slots__ = ("lo", "hi")
|
|
|
|
def __init__(self, lo, hi):
|
|
self.lo, self.hi = lo, hi
|
|
|
|
|
|
def pin_w(b):
|
|
"""The watts a unit's own SET places once held inside its hard limits,
|
|
as `plan()` places a standing pin, or None when the unit has no SET."""
|
|
pv = getattr(b, "pinned_w", None)
|
|
if getattr(b, "gate", None) is None or pv is None:
|
|
return None
|
|
lo, hi = _int_envelope(b)
|
|
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
|
|
|
|
|
|
def hi_w(b):
|
|
"""A unit's upper hard limit as `plan()` narrows it to whole W (SAFETY-84)."""
|
|
return float(_int_envelope(b)[1])
|
|
|
|
|
|
def loop_bound(bounds, live=None):
|
|
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
|
|
|
|
The sum, over live units that do not contradict themselves, of each unit's
|
|
interval with its mode applied (`unit_interval()` with no higher claim). A
|
|
pinned unit adds the span from 0 to its pin, so a probe or a maintenance
|
|
pin stays reachable. Every term holds 0, so the sum does too.
|
|
|
|
`compute()` keeps the site target inside this, so the loop never asks for
|
|
watts no battery may give: the allocator would report them as unmet and the
|
|
saturation freeze would park the target on them (SAFETY-73 section 1).
|
|
Pure, like `plan()`: it reads numbers and intervals and nothing else.
|
|
"""
|
|
lo_sum, hi_sum = 0.0, 0.0
|
|
for u in sorted(bounds):
|
|
b = bounds[u]
|
|
if live is not None and u not in live:
|
|
continue
|
|
if getattr(b, "contradiction", False):
|
|
continue
|
|
p = pin_w(b)
|
|
if p is not None:
|
|
lo_sum += min(0.0, p)
|
|
hi_sum += max(0.0, p)
|
|
continue
|
|
lo, hi, _ = unit_interval(b)
|
|
lo, hi = _int_envelope(_Narrowed(lo, hi))
|
|
if lo > hi:
|
|
continue
|
|
lo_sum += min(0.0, lo)
|
|
hi_sum += max(0.0, hi)
|
|
return lo_sum, hi_sum
|
|
|
|
|
|
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,
|
|
deciding_priority=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.
|
|
⚠️ A MAPPING, ALWAYS - never a function. OPT-09 lets `resolve()`
|
|
accept a callable of the site target, because an energy-headroom
|
|
weighting is direction-dependent and the direction is stage 2's
|
|
answer; `resolve()` calls it and hands the RESULT here. Keeping
|
|
that out of this module is what keeps `plan()` a pure function of
|
|
numbers and intervals: a callback invoked inside the fill would
|
|
put a caller's code, and whatever it reads, back into the module
|
|
whose whole discipline is that it reads nothing.
|
|
`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.
|
|
`deciding_priority` OPT-11: the priority that decided `target_w`
|
|
(`Resolution.deciding_priority`). Each unit's mode range applies
|
|
only at or below it - see `unit_interval()`. None applies them.
|
|
`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 = []
|
|
mode_faults = []
|
|
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
|
|
# OPT-11: the mode layer, when it applies, narrows the hard limits.
|
|
# A unit with no mode gets exactly its hard limits, as before.
|
|
nlo, nhi, note = unit_interval(b, deciding_priority)
|
|
if note == MODE_FAULT:
|
|
mode_faults.append((u, MODE_FAULT))
|
|
lo, hi = _int_envelope(_Narrowed(nlo, nhi))
|
|
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, mode_faults=tuple(mode_faults))
|
|
|
|
# --- 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,
|
|
mode_faults=tuple(mode_faults))
|