ALLOC-01: allocate the site grid target across N inverters, sum-invariant #41
@@ -2,6 +2,81 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**The site grid target is now ALLOCATED across N inverters, sum-invariant, and
|
||||
never broadcast (ALLOC-01).** `ARB-03` left `allocate()` as a documented seam
|
||||
that passed through at N<=1 and DECLINED at N>=2; this fills it. The new
|
||||
`app/allocator.py` turns one site target `S` into per-inverter shares with one
|
||||
invariant - **`sum(shares) + unmet_w == S`, in integer arithmetic** - and each
|
||||
inverter is then told a function of **its own share**, never of the shared
|
||||
measurement.
|
||||
|
||||
* **Why a broadcast is a fault and not merely an inefficiency.** The control
|
||||
primitive is meter emulation: the master reports a *fabricated grid reading*
|
||||
and the inverter regulates itself to it, so one number means "the battery
|
||||
moves X W" here and "the grid is at X W" there - an identity that holds only
|
||||
at N=1. Hand both inverters the same target and each supplies it in full, so
|
||||
the loop gain at the meter becomes N times the per-unit gain. Measured on
|
||||
`admin/goodwe`'s two-master rig (TEST-19): **-400 W export overshoot at
|
||||
`gain: 0.6`, and at `gain: 1.0` - which `config.yaml` accepts as its
|
||||
documented maximum - a permanent 4000 W peak-to-peak limit cycle with not one
|
||||
guard firing.** The sign watchdog is evaluated on every cycle and never trips,
|
||||
because the disagreement flips on alternate ticks and never holds its 180 s
|
||||
window.
|
||||
* **The stability property is structural, not tuned.** With `sum(a_i) = 1` the
|
||||
gain at the meter is `sum(a_i * g_i)`, a **convex combination** of the
|
||||
per-unit gains, so it lies in `[min g_i, max g_i]` **for any split**. That is
|
||||
what makes the weights a comfort choice (SoC, thermals, wear) rather than a
|
||||
tuning parameter, and it means a bad split cannot destabilise the site.
|
||||
Graded directly in `test_alloc.py` part 9, and measured end-to-end: a 90/10,
|
||||
a 50/50 and a 100/0 split produce a **bit-identical aggregate trace at the
|
||||
meter**, and a gain sweep to 1.0 at every split shows a flat tail at all 84
|
||||
combinations.
|
||||
* **Rounding is a safety property here.** The register is 1 W, and a per-unit
|
||||
`round()` biases the sum - measured at **-125 W over 50 000 ticks at three
|
||||
inverters**. At 1-2 Hz that is a permanent standing error the site integrator
|
||||
chases and never reaches. So the target is rounded **once**, at the top, and
|
||||
the fractional parts are settled by **largest remainder** with a deterministic
|
||||
tie-break; every sum below that line is integer arithmetic and cannot drift.
|
||||
Verified over 250 000 randomised ticks: **0 sum failures, 0 envelope escapes,
|
||||
0 W accumulated bias.**
|
||||
* **The envelope is read per tick, never cached.** `plan()` is a pure function
|
||||
of that tick's intervals and holds no state, because a unit's range moves for
|
||||
reasons unrelated to the allocator - a stale link, a pack at its floor, a
|
||||
device rating that failed to read.
|
||||
* **A dead link is announced, not absorbed.** A unit whose master is stale is
|
||||
dropped from the envelope set; the survivors carry its share, and the
|
||||
allocation record names the unit and the reason so `main.py` logs it. A site
|
||||
running at half its inverters and reporting nothing reads as healthy on every
|
||||
dashboard.
|
||||
* **Opposite directions are refused, not averaged.** One pack discharging while
|
||||
another charges nets to exactly the right number at the meter, so no existing
|
||||
guard can see it. A sign-coherence gate makes it inexpressible.
|
||||
* **`scope_violations()` is now consumed, and it is consumed as a refusal.**
|
||||
ARB-03 left it logging without changing the resolution. A mis-routed claim
|
||||
means the number about to be divided may itself be wrong, and dividing a
|
||||
number that may be wrong is worse than not dividing it - the same answer
|
||||
clause 3 already gives a contradiction. It is *not* promoted into an envelope:
|
||||
a unit-scoped claim that landed on the site names no unit, so promoting it
|
||||
would mean guessing one.
|
||||
* **At N<=1 it is a pass-through, byte-identical.** The share is the site
|
||||
target, as a float, unrounded and unclamped. Every one of the existing suites
|
||||
replays unedited and green.
|
||||
* **`main.py` still refuses to act at N>=2, for a new and narrower reason.**
|
||||
There is one `setpoint_entity`, so this controller can command one inverter; a
|
||||
valid two-way split names an inverter it has no writer for, and commanding a
|
||||
partial site is a worse instruction than commanding nothing. The old
|
||||
"no allocator" refusal and its test (`test_peakguard.py` part 10h) were
|
||||
**re-aimed, not deleted** - `allocated=False` is now reachable on a mis-routed
|
||||
claim, an empty envelope set, or sign incoherence, and all three are graded on
|
||||
real cycles.
|
||||
* **Known and not closed here:** at a roster of two the loop's `actual_w` is one
|
||||
inverter's power used as a site measurement, because the add-on binds a single
|
||||
`inverter_power` sensor. That is the *sensing* half of the same N=1 identity
|
||||
this card removes from the *commanding* half, and it makes the aggregate trace
|
||||
split-dependent below `gain: 1.0`. Measured, with the counterfactual, in
|
||||
`test_alloc.py` part 8 and `tools/alloc_sim.py --splits`. It belongs to
|
||||
`DEV-02`.
|
||||
|
||||
**`Claim` carries the `actuator` specs.md always said it did, and `resolve()`
|
||||
is three-stage (ARB-03).** `specs.md` §6.1 declares `actuator: str` as the first
|
||||
field of `Claim`; the shipped `app/arbiter.py` had no such field, so every claim
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
"""One site grid target, divided across N inverters so that the shares SUM to it.
|
||||
|
||||
⚠️ THE REASON THIS EXISTS, AND IT IS NOT EFFICIENCY. The control primitive in
|
||||
this system is METER EMULATION: the master reports a FABRICATED grid reading
|
||||
(0x056E direction, 0x056F magnitude, FC 0x10, every 1-2 s) and the inverter
|
||||
regulates ITSELF to it. So one number means "the battery moves X W" to this
|
||||
add-on and "the grid is at X W" to the inverter - an identity that holds ONLY at
|
||||
N=1. Hand two inverters the same target and each supplies it IN FULL: the loop
|
||||
gain at the meter becomes N times the per-unit gain.
|
||||
|
||||
That is measured, not argued. On admin/goodwe's two-master rig (TEST-19,
|
||||
`sim/sitesim.py --table`, the add-on's own `compute()` in the loop and the
|
||||
firmware's own encoder on the wire):
|
||||
|
||||
N=2 gain 0.6 BROADCAST worst export -400 W (N=1: +10 W)
|
||||
N=2 gain 1.0 BROADCAST commands [1000, 2000, 1000, 0] W,
|
||||
grid [0, -2000, 0, +2000] W, tail p2p 4000 W,
|
||||
FOREVER, and not one guard fires.
|
||||
|
||||
`gain: 1.0` is what config.yaml:374 accepts as its documented maximum. The sign
|
||||
watchdog is evaluated on every one of those cycles and never trips, because the
|
||||
disagreement flips on alternate ticks and never holds its 180 s window. So the
|
||||
broadcast fault is not merely inefficient: it is invisible to every detector
|
||||
this repository has.
|
||||
|
||||
THE FIX IS STRUCTURAL, WHICH IS WHY IT IS A MODULE AND NOT A TUNING CHANGE
|
||||
=========================================================================
|
||||
ONE integrator on P1, above the split (§10.3, and `compute()` is that
|
||||
integrator). Its output S is the SITE's grid target. This module turns S into
|
||||
per-unit shares with one invariant:
|
||||
|
||||
sum(shares) + unmet_w == S, with unmet_w == 0 whenever the envelopes allow
|
||||
|
||||
and each inverter is then told a function of ITS OWN SHARE, never of the shared
|
||||
measurement. Write s_i = a_i * S with sum(a_i) = 1. Each unit closes its own
|
||||
loop with per-unit gain g_i, so the gain seen AT THE METER is
|
||||
|
||||
gbar = sum(a_i * g_i)
|
||||
|
||||
which is a CONVEX COMBINATION of the per-unit gains and therefore lies in
|
||||
[min g_i, max g_i] FOR ANY SPLIT. Compare the broadcast case, a_i = 1 for every
|
||||
unit, where the same sum is N * g and grows without bound in N.
|
||||
|
||||
⚠️ THAT IS THE WHOLE ARGUMENT, AND IT IS WHY THE WEIGHTS ARE A COMFORT CHOICE.
|
||||
Nothing above constrains the a_i beyond summing to 1. So SoC, thermals and pack
|
||||
wear may set them freely and a BAD SPLIT CANNOT DESTABILISE THE SITE - it can
|
||||
only be unkind to one pack. That property is the reason §6.2 chose share
|
||||
allocation over a regulator/ballast arrangement, in which the ballast unit's
|
||||
share is a function of the shared measurement and the gain question comes back.
|
||||
|
||||
⚠️ WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0
|
||||
is "please do not use this unit", and it is honoured while any other unit
|
||||
has headroom - but it is NOT a constraint, because the SUM is the safety
|
||||
property and stranding capacity behind a preference would leave a standing
|
||||
error for the site integrator to chase forever. To make a unit genuinely
|
||||
unusable, close its envelope or leave it out of `live`. See `_fill()`.
|
||||
|
||||
WHAT THIS MODULE MUST NOT DO
|
||||
============================
|
||||
⚠️ IT MUST NOT READ P1. One integrator on the site measurement, above the split.
|
||||
A second reader of the meter is a second controller, and two controllers on one
|
||||
actuator is the failure the whole arbiter exists to prevent. Nothing in here
|
||||
takes a measurement of any kind: the inputs are S, the per-unit intervals, and
|
||||
per-tick liveness. That is checkable by reading the imports, and it is why there
|
||||
are none beyond the standard library.
|
||||
|
||||
⚠️ IT MUST NOT CACHE AN ENVELOPE. `plan()` is a pure function called once per
|
||||
tick with that tick's intervals. A unit's available range moves for reasons that
|
||||
have nothing to do with the allocator - a stale link, a pack at its floor, a
|
||||
device rating that failed to read - and an allocator holding a construction-time
|
||||
envelope over-allocates into a unit that cannot take it. There is no state here
|
||||
to hold one; that is deliberate and it is graded.
|
||||
|
||||
⚠️ ROUNDING IS A SAFETY PROPERTY. The setpoint register is 1 W (control.py says
|
||||
so where it quantises). A per-unit `round()` biases the sum by up to N/2 W every
|
||||
tick, and at 1-2 Hz a systematic 1 W bias is a PERMANENT standing error the site
|
||||
integrator will chase forever - it is not a rounding artefact, it is a slow
|
||||
offset with a control loop wound around it. So the sum is computed in INTEGER
|
||||
ARITHMETIC from an integer target and the fractional parts are settled by
|
||||
LARGEST REMAINDER (Hamilton): floor everything, then hand the leftover watts one
|
||||
at a time to the units with the largest discarded fraction. The sum cannot drift
|
||||
because no float ever enters it.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from math import ceil, floor, inf
|
||||
|
||||
# The tolerance on a float comparison of watts. Envelopes arrive as floats from
|
||||
# config and from device registers; this is the width below which two of them
|
||||
# are the same number. It is never used on the sum - see the module docstring.
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
# --- why a unit is not in the envelope set, and why the whole thing refused ---
|
||||
# Named rather than spelled inline so that main.py, SAFETY-28's write gate and
|
||||
# the tests can all agree on which case fired without matching English.
|
||||
DROP_STALE = "stale link"
|
||||
DROP_CONTRADICTION = "contradicts itself"
|
||||
DROP_NO_INTEGER = "no integer setpoint in its interval"
|
||||
|
||||
REFUSE_SCOPE = "mis-routed claim"
|
||||
REFUSE_EMPTY = "no unit available"
|
||||
REFUSE_SIGN = "opposite directions"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitPlan:
|
||||
"""What ONE unit was told this tick, and why it was that and not more.
|
||||
|
||||
This is the per-unit half of the allocation record. `why` is written for an
|
||||
operator reading an action log at 2 a.m., not for a developer: it says which
|
||||
mechanism chose the number - the weighted fill, an envelope that ran out, a
|
||||
claim of the unit's own that outranked the site.
|
||||
"""
|
||||
unit: str
|
||||
share_w: int
|
||||
lo: float
|
||||
hi: float
|
||||
weight: float
|
||||
why: str
|
||||
pinned: bool = False
|
||||
clamped: bool = False
|
||||
|
||||
def explain(self) -> str:
|
||||
span = (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, "
|
||||
f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]")
|
||||
return f"{self.unit} -> {self.share_w:+d} W in {span} ({self.why})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Allocation:
|
||||
"""EVERY TICK PRODUCES ONE OF THESE, including the ticks that refused.
|
||||
|
||||
⚠️ IT IS DESIGNED TO BE GATEABLE, because SAFETY-28 gates the write path on
|
||||
it. That means three things, and each is a decision rather than a
|
||||
convenience:
|
||||
|
||||
* `ok` is ONE boolean and it is the only thing a gate has to read. A gate
|
||||
that must parse a message, or check three fields in the right order, is a
|
||||
gate somebody gets wrong.
|
||||
* a REFUSAL still carries the record - `plans`, `dropped` and the reason -
|
||||
so the gate can say WHY it stopped the write instead of only that it did.
|
||||
A refusal that returns nothing is a refusal nobody can debug.
|
||||
* `unmet_w` is separate from `ok`. An allocation that could not place every
|
||||
watt is still SAFE TO WRITE and strictly better than commanding zero; it
|
||||
is the site target that was not fully met, not a broken allocation. Fusing
|
||||
the two would make a saturated inverter park the whole site.
|
||||
|
||||
`sum_w` is the integer the shares are required to add up to, and
|
||||
`quantisation_w` is the ONE rounding that produced it from `target_w`. Both
|
||||
are here so that "the shares sum to S" can be checked by a reader rather
|
||||
than believed: `sum(shares.values()) + unmet_w == sum_w` is an identity over
|
||||
integers, and `sum_w - target_w` is a single site-level rounding of at most
|
||||
half a watt that is NEVER distributed into a per-unit bias.
|
||||
"""
|
||||
target_w: float
|
||||
sum_w: int
|
||||
shares: dict = field(default_factory=dict)
|
||||
plans: tuple = ()
|
||||
dropped: tuple = () # ((unit, why), ...) - order is roster order
|
||||
unmet_w: int = 0
|
||||
quantisation_w: float = 0.0
|
||||
refused: str | None = None
|
||||
passthrough: bool = False # N<=1: the share IS the site target, unrounded
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""The one boolean SAFETY-28's write gate reads."""
|
||||
return self.refused is None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""One line an operator can act on."""
|
||||
if self.refused is not None:
|
||||
return (f"REFUSED to allocate {self.target_w:+.0f} W: "
|
||||
f"{self.refused}")
|
||||
if self.passthrough:
|
||||
return f"pass-through {self.target_w:+.0f} W (one unit, nothing to divide)"
|
||||
body = ", ".join(f"{u}{s:+d}" for u, s in sorted(self.shares.items()))
|
||||
out = f"{self.sum_w:+d} W as {body}"
|
||||
if self.unmet_w:
|
||||
out += f", {self.unmet_w:+d} W UNMET"
|
||||
if self.dropped:
|
||||
out += (" - dropped "
|
||||
+ ", ".join(f"{u} ({w})" for u, w in self.dropped))
|
||||
return out
|
||||
|
||||
|
||||
def _int_envelope(b):
|
||||
"""A unit's interval, narrowed to the integers a 1 W register can hold.
|
||||
|
||||
⚠️ NARROWED INWARD, ALWAYS. `ceil` on the floor and `floor` on the ceiling,
|
||||
so every integer in the result is inside the real interval. Rounding the
|
||||
other way would let a share of exactly the rating be commanded on a unit
|
||||
whose rating is 2499.5 W, which is the one direction an envelope must never
|
||||
move: the envelope is what `device` and `supervised` filed, i.e. what the
|
||||
hardware said it can do.
|
||||
|
||||
Infinities pass through untouched - an absent bound is not a large bound,
|
||||
and `ceil(-inf)` is not a number. A unit that filed nothing this cycle has
|
||||
exactly that interval, which is why this case is the common one at N>=2 and
|
||||
not an edge.
|
||||
"""
|
||||
lo = -inf if b.lo == -inf else ceil(b.lo - EPS)
|
||||
hi = inf if b.hi == inf else floor(b.hi + EPS)
|
||||
return lo, hi
|
||||
|
||||
|
||||
def _clamp_int(v, lo, hi):
|
||||
return min(max(int(v), lo if lo != -inf else int(v)),
|
||||
hi if hi != inf else int(v))
|
||||
|
||||
|
||||
def _fill(order, weights, cap, amount):
|
||||
"""WATER-FILL: place `amount` watts across `order`, capped, by weight.
|
||||
|
||||
`amount` and every `cap` are NON-NEGATIVE magnitudes; the direction has
|
||||
already been taken out by the caller. Returns `(add, leftover)`.
|
||||
|
||||
THE LOOP IS THE STANDARD ONE and it terminates in at most len(order) rounds:
|
||||
share the remaining amount in proportion to weight; any unit whose share
|
||||
would exceed its cap is PINNED AT ITS CAP and dropped from the active set;
|
||||
repeat with what is left. A unit that is not capped in a round is not capped
|
||||
in any later round either, so nothing is revisited.
|
||||
|
||||
⚠️ AND THEN THE SECOND PASS, WHICH IS THE POLICY DECISION IN THIS FUNCTION.
|
||||
Units with weight 0 are excluded from the weighted rounds - that is what a
|
||||
zero weight is for. But if the weighted set fills up and watts remain, they
|
||||
are offered to the zero-weight units, EQUALLY, rather than being reported as
|
||||
unmet. The reason is the module docstring's: the weights are a comfort
|
||||
choice and the sum is the safety property, so a preference must never
|
||||
manufacture a standing error at the meter that the site integrator will then
|
||||
chase. To make a unit genuinely unusable, close its envelope - `cap` is
|
||||
obeyed absolutely and is never overridden here.
|
||||
"""
|
||||
add = {u: 0.0 for u in order}
|
||||
rem = float(amount)
|
||||
|
||||
for pool in (
|
||||
[u for u in order if weights[u] > 0.0],
|
||||
[u for u in order if weights[u] <= 0.0], # the last-resort pass
|
||||
):
|
||||
# Inside the last-resort pass every weight is 0, so the proportional
|
||||
# step needs a weight of its own; equal shares is the only choice that
|
||||
# does not invent a preference the caller did not express.
|
||||
w = {u: (weights[u] if weights[u] > 0.0 else 1.0) for u in pool}
|
||||
active = [u for u in pool if cap[u] > 0.0]
|
||||
while rem > EPS and active:
|
||||
total = sum(w[u] for u in active)
|
||||
capped = [u for u in active if rem * w[u] / total >= cap[u] - add[u]]
|
||||
if capped:
|
||||
for u in capped:
|
||||
rem -= cap[u] - add[u]
|
||||
add[u] = cap[u]
|
||||
active = [u for u in active if u not in capped]
|
||||
else:
|
||||
for u in active:
|
||||
add[u] += rem * w[u] / total
|
||||
rem = 0.0
|
||||
return add, rem
|
||||
|
||||
|
||||
def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
site_priority=None, site_key="site") -> Allocation:
|
||||
"""Divide the site grid target `target_w` across the units in `bounds`.
|
||||
|
||||
`bounds` unit name -> that unit's interval for THIS TICK (arbiter stage 1,
|
||||
`UnitBounds`). Read per tick, never cached - see the module
|
||||
docstring. Duck-typed on `.lo`, `.hi`, `.gate`, `.pinned_w`,
|
||||
`.pinned_by` and `.contradiction` so that this module does not
|
||||
import the arbiter and cannot grow a dependency on claims.
|
||||
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
|
||||
`live` the units whose master has reported recently enough to be
|
||||
commanded, or None for "all of them". A unit not listed is
|
||||
DROPPED and the record says so; the survivors then carry the
|
||||
whole site target, which is a real change in what the site can
|
||||
do and must never happen silently (AC 6).
|
||||
`scope_errors` the arbiter's mis-routing complaints. See REFUSE_SCOPE below.
|
||||
`site_priority` the priority of the SITE claim that chose `target_w`. It is
|
||||
what a unit's own winning SET is weighed against - see PINS.
|
||||
`site_key` the name to use when there are no units at all.
|
||||
|
||||
⚠️ N<=1 IS A PASS-THROUGH AND IS BYTE-IDENTICAL TO WHAT SHIPPED. The share
|
||||
IS the site target, as a float, unclamped and unrounded. That is §6.2's N=1
|
||||
identity - with one inverter the fabricated grid reading and the battery
|
||||
setpoint are the same number - and it is also the compatibility promise:
|
||||
every existing test in this repository replays through here and must see the
|
||||
number it saw before ARB-03. Do not "tidy" this branch into the general path
|
||||
by giving it a roster of one; the general path rounds to an integer and
|
||||
applies the envelope, and both are behaviour changes on the live install.
|
||||
"""
|
||||
units = tuple(sorted(bounds))
|
||||
|
||||
# --- REFUSAL 1: the claim list that produced S is known to be mis-routed --
|
||||
# ⚠️ THIS IS THE ANSWER TO ARB-03's OPEN QUESTION, AND IT IS A REFUSAL
|
||||
# RATHER THAN AN INPUT. `scope_violations()` reports a claim filed on an
|
||||
# actuator its source's scope does not permit. It is tempting to CONSUME
|
||||
# one - to read a unit-scoped claim that landed on the site as an allocation
|
||||
# input - and that is wrong twice over. First, by the time this function
|
||||
# runs the mis-routed claim has ALREADY been partitioned by `resolve()`, so
|
||||
# its effect is baked into S or into somebody's interval and cannot be
|
||||
# undone from here; consuming it would mean re-routing, which would make
|
||||
# `actuator` advisory and hand every filing site a second chance to be
|
||||
# wrong. Second, and worse, promoting a unit claim that landed on the site
|
||||
# requires guessing WHICH unit it belonged to, and there is no information
|
||||
# here to guess with - `actuator_for()` exists precisely because that answer
|
||||
# is chosen where the claim is built. Applying it to ALL units would put one
|
||||
# inverter's rating on a sibling that does not have it.
|
||||
#
|
||||
# So what is left is that S itself may be wrong, and dividing a number that
|
||||
# may be wrong is worse than not dividing it. Refuse, and let main.py fail
|
||||
# toward inaction and shout - which is the same answer clause 3 of the
|
||||
# arbiter's own rule already gives to a contradiction.
|
||||
#
|
||||
# ⚠️ IT IS ABOVE THE N<=1 PASS-THROUGH ON PURPOSE, and that is the one place
|
||||
# this card touches the pass-through's behaviour. A correctly routed
|
||||
# one-inverter cycle CANNOT produce a scope violation - `scope_violations()`
|
||||
# only complains about a unit source on the site when the site has named
|
||||
# units, and a site source on a unit requires somebody to have hand-written
|
||||
# `actuator=` past `actuator_for()` - so this cannot fire on the shipped
|
||||
# install, and where it does fire the input is a known bug rather than a
|
||||
# state to pass through.
|
||||
if scope_errors:
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=int(round(float(target_w))),
|
||||
shares={}, plans=(), dropped=(),
|
||||
unmet_w=int(round(float(target_w))),
|
||||
quantisation_w=float(int(round(float(target_w)))) - float(target_w),
|
||||
refused=f"{REFUSE_SCOPE}: " + "; ".join(scope_errors))
|
||||
|
||||
if len(units) <= 1:
|
||||
only = units[0] if units else site_key
|
||||
share = float(target_w)
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=int(round(share)),
|
||||
shares={only: share},
|
||||
plans=(UnitPlan(only, int(round(share)), -inf, inf, 1.0,
|
||||
"N<=1: the share IS the site target"),),
|
||||
passthrough=True)
|
||||
|
||||
w = {u: 1.0 if weights is None else float(weights.get(u, 1.0))
|
||||
for u in units}
|
||||
for u, wu in w.items():
|
||||
if wu < 0.0 or wu != wu:
|
||||
raise ValueError(f"weight for {u!r} is {wu!r}; weights are "
|
||||
"non-negative comfort factors, not signed targets")
|
||||
|
||||
# ⚠️ THE TARGET IS ROUNDED ONCE, HERE, AND NOWHERE ELSE. Everything below
|
||||
# this line is integer arithmetic, which is what makes "the shares sum to S"
|
||||
# an identity rather than a tolerance. `round()` is banker's rounding: over a
|
||||
# long replay it is unbiased, where `int(x + 0.5)` would add a systematic
|
||||
# half-watt in the discharge direction. In practice `target_w` arrives from
|
||||
# `compute()` already quantised to `step_w` (10 W by default) so this is a
|
||||
# no-op on the live path; it is not a no-op for a peak-shaving floor or an
|
||||
# arbitrage setpoint, which are not quantised.
|
||||
sum_w = int(round(float(target_w)))
|
||||
quant = float(sum_w) - float(target_w)
|
||||
|
||||
# --- the envelope set, rebuilt from scratch every tick -------------------
|
||||
dropped = []
|
||||
ilo, ihi = {}, {}
|
||||
considered = []
|
||||
for u in units:
|
||||
b = bounds[u]
|
||||
if live is not None and u not in live:
|
||||
# AC 6. The survivor is about to carry this unit's share and the
|
||||
# record has to say so; `dropped` is how it says it, and main.py
|
||||
# logs the transition.
|
||||
dropped.append((u, DROP_STALE))
|
||||
continue
|
||||
if getattr(b, "contradiction", False):
|
||||
# A unit contradicting ITSELF - its own `lo` above its own `hi`.
|
||||
# Stage 1 already decided that parks THAT unit and not the site,
|
||||
# which is the whole point of the partition; here it means the unit
|
||||
# has no interval to allocate into.
|
||||
dropped.append((u, DROP_CONTRADICTION))
|
||||
continue
|
||||
lo, hi = _int_envelope(b)
|
||||
if lo > hi:
|
||||
# An interval that contains no integer. The register is 1 W, so
|
||||
# there is no number this unit could be commanded that both bounds
|
||||
# accept - dropping it is the only answer that does not violate one.
|
||||
dropped.append((u, DROP_NO_INTEGER))
|
||||
continue
|
||||
ilo[u], ihi[u] = lo, hi
|
||||
considered.append(u)
|
||||
|
||||
def refuse(why):
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
|
||||
dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant,
|
||||
refused=why)
|
||||
|
||||
# --- REFUSAL 2: nobody is available ------------------------------------
|
||||
if not considered:
|
||||
return refuse(f"{REFUSE_EMPTY}: "
|
||||
+ ", ".join(f"{u} ({why})" for u, why in dropped))
|
||||
|
||||
# --- PINS: a unit's own SET, weighed against the site's -------------------
|
||||
# ⚠️ THIS IS THE DECISION ARB-03 DEFERRED TO HERE, IN ITS OWN WORDS: "Stage 1
|
||||
# states the constraint and records what it is gated on; deciding whether it
|
||||
# applies is stage 3's business." `UnitBounds.gate` is the priority of the
|
||||
# unit's own winning SET; `site_priority` is the priority of the SITE claim
|
||||
# that chose S. Clause 1 of the arbiter's rule, transposed to a site with
|
||||
# more than one actuator: the higher priority wins, and a tie goes to the
|
||||
# unit, because a claim about one pack is more specific than a claim about
|
||||
# the connection and specificity is what a tie has left to break on.
|
||||
#
|
||||
# A pinned unit is NOT in the water-fill. It is doing something of its own -
|
||||
# a maintenance drain, a failsafe park - and what it does still counts
|
||||
# toward the site sum, so the free units carry the REMAINDER. That is not
|
||||
# the allocator compensating for a disturbance, which would be a second
|
||||
# controller; it is the shares summing to S, which is the invariant. The
|
||||
# meter's own answer arrives next tick through the one integrator above.
|
||||
#
|
||||
# ⚠️ AND site_priority=None MEANS "NO SITE SET WAS NAMED", so every pin
|
||||
# stands. `resolve()` always passes the real one. A direct caller that
|
||||
# passes nothing is saying it has no site claim to weigh, and honouring the
|
||||
# unit's own highest-priority claim is the only safe reading of that.
|
||||
pinned = {}
|
||||
for u in considered:
|
||||
b = bounds[u]
|
||||
gate = getattr(b, "gate", None)
|
||||
if gate is None:
|
||||
continue
|
||||
if site_priority is None or gate >= site_priority:
|
||||
pv = getattr(b, "pinned_w", None)
|
||||
if pv is None:
|
||||
continue
|
||||
pinned[u] = _clamp_int(round(float(pv)), ilo[u], ihi[u])
|
||||
|
||||
free = [u for u in considered if u not in pinned]
|
||||
remainder = sum_w - sum(pinned.values())
|
||||
|
||||
# --- WATER-FILL over the free units --------------------------------------
|
||||
# `base` is the point of each envelope nearest zero: 0 for an ordinary
|
||||
# symmetric rating, and the forced end for an envelope that does not contain
|
||||
# zero. Filling from there rather than from `lo` is what keeps a unit that
|
||||
# is merely ALLOWED to charge from being TOLD to.
|
||||
base = {u: _clamp_int(0, ilo[u], ihi[u]) for u in free}
|
||||
placed = sum(base.values())
|
||||
need = remainder - placed
|
||||
up = need >= 0
|
||||
cap = {u: (ihi[u] - base[u]) if up else (base[u] - ilo[u]) for u in free}
|
||||
add, leftover = _fill(free, w, cap, abs(need))
|
||||
|
||||
exact = {u: base[u] + (add[u] if up else -add[u]) for u in free}
|
||||
|
||||
# --- LARGEST-REMAINDER ROUNDING -----------------------------------------
|
||||
# Floor every share, then hand out the discarded watts one at a time, worst
|
||||
# discarded fraction first. The sum is integer arithmetic end to end, so it
|
||||
# cannot drift; ties break on the unit name so that the same inputs give the
|
||||
# same allocation on every tick and on every host.
|
||||
share = {u: int(floor(exact[u] + EPS)) for u in free}
|
||||
for u in free: # floors can never leave the envelope,
|
||||
share[u] = _clamp_int(share[u], ilo[u], ihi[u]) # but say so anyway
|
||||
debt = remainder - sum(share.values())
|
||||
frac_order = sorted(free, key=lambda u: (-(exact[u] - floor(exact[u])), u))
|
||||
step = 1 if debt > 0 else -1
|
||||
while debt != 0:
|
||||
moved = False
|
||||
for u in (frac_order if debt > 0 else list(reversed(frac_order))):
|
||||
if debt == 0:
|
||||
break
|
||||
nxt = share[u] + step
|
||||
if (step > 0 and nxt <= ihi[u]) or (step < 0 and nxt >= ilo[u]):
|
||||
share[u] = nxt
|
||||
debt -= step
|
||||
moved = True
|
||||
if not moved:
|
||||
break
|
||||
|
||||
shares = dict(pinned)
|
||||
shares.update(share)
|
||||
unmet = sum_w - sum(shares.values())
|
||||
|
||||
# --- REFUSAL 3: SIGN COHERENCE, so circulating power is not expressible ---
|
||||
# ⚠️ REFUSED, NOT AVERAGED. Two shares of opposite sign mean one pack
|
||||
# discharging into the AC bus while another charges off it: energy round-
|
||||
# tripping through two conversion stages for no reason, and - this is the
|
||||
# part that makes it a safety property rather than an efficiency one -
|
||||
# INVISIBLE. The meter sees the net, which is exactly S, so the site
|
||||
# integrator is content; each unit sees a target it is meeting, so no
|
||||
# per-unit guard fires; the sign watchdog compares a command against the
|
||||
# meter and both agree. Nothing in this repository would ever report it.
|
||||
#
|
||||
# It cannot arise from an ordinary tick: the fill only ever moves shares in
|
||||
# ONE direction from a base that is zero whenever the envelope contains
|
||||
# zero, so opposite signs require an envelope that EXCLUDES zero (a forced
|
||||
# floor) or a pin. Those are the cases worth refusing, and the refusal is a
|
||||
# site-level one because the alternative - dropping the offending unit and
|
||||
# re-filling - would silently override a claim that outranked the loop.
|
||||
pos = sorted(u for u, s in shares.items() if s > 0)
|
||||
neg = sorted(u for u, s in shares.items() if s < 0)
|
||||
if pos and neg:
|
||||
return refuse(
|
||||
f"{REFUSE_SIGN}: {', '.join(pos)} would discharge while "
|
||||
f"{', '.join(neg)} charges - circulating power, which nets to "
|
||||
f"{sum_w:+d} W at the meter and is invisible to every guard")
|
||||
|
||||
plans = []
|
||||
for u in units:
|
||||
drop = dict(dropped).get(u)
|
||||
if drop is not None:
|
||||
plans.append(UnitPlan(u, 0, bounds[u].lo, bounds[u].hi, w[u],
|
||||
f"DROPPED: {drop}"))
|
||||
continue
|
||||
if u in pinned:
|
||||
by = getattr(bounds[u], "pinned_by", None) or "its own claim"
|
||||
plans.append(UnitPlan(u, pinned[u], ilo[u], ihi[u], w[u],
|
||||
f"pinned by {by} at priority "
|
||||
f"{bounds[u].gate}", pinned=True))
|
||||
continue
|
||||
at_edge = share[u] in (ilo[u], ihi[u])
|
||||
why = "weighted share"
|
||||
if at_edge and cap[u] - add[u] <= EPS and abs(need) > EPS:
|
||||
why = "envelope full - its surplus went to the others"
|
||||
elif w[u] <= 0.0 and share[u] != base[u]:
|
||||
why = "last resort: the preferred units were full"
|
||||
plans.append(UnitPlan(u, share[u], ilo[u], ihi[u], w[u], why,
|
||||
clamped=at_edge))
|
||||
|
||||
return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares,
|
||||
plans=tuple(plans), dropped=tuple(dropped),
|
||||
unmet_w=int(unmet), quantisation_w=quant)
|
||||
@@ -45,9 +45,10 @@ test can construct a case at any one of them:
|
||||
stage sees the whole list and answers exactly what the
|
||||
shipped arbiter answered.
|
||||
3. `allocate()` - THE SEAM. It turns S into per-inverter shares subject to
|
||||
stage 1's intervals. ⚠️ IT IS NOT AN ALLOCATOR AND MUST
|
||||
NOT BECOME ONE HERE - see its own docstring. ALLOC-01
|
||||
builds it.
|
||||
stage 1's intervals. ALLOC-01 filled it: the allocator
|
||||
itself is `app/allocator.py` and the reasoning lives
|
||||
there, because it consumes intervals and a number and
|
||||
must never learn about claims, priorities or P1.
|
||||
|
||||
⚠️ THE STRUCTURAL PROPERTY, AND IT IS THE REASON FOR THE SPLIT. A per-inverter
|
||||
constraint is consumed by the allocator as an INTERVAL; it is never promoted
|
||||
@@ -63,6 +64,13 @@ strategy doing it by hand.
|
||||
from dataclasses import dataclass, field
|
||||
from math import inf
|
||||
|
||||
# ⚠️ ONE WAY ONLY. `allocator` knows nothing about claims, priorities or
|
||||
# this module - it consumes intervals and a number - so the dependency
|
||||
# cannot become a cycle and the allocator stays testable on its own, which
|
||||
# is what lets test_alloc.py sweep it hundreds of thousands of times
|
||||
# without building a claim list for each one.
|
||||
from . import allocator
|
||||
|
||||
# Priorities live here and nowhere else. The moment these become magic numbers
|
||||
# scattered through the strategies, the whole point of this module is lost.
|
||||
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
|
||||
@@ -305,6 +313,24 @@ class Resolution:
|
||||
shares: dict = field(default_factory=dict) # stage 3, per actuator
|
||||
allocated: bool = True # False when stage 3 declined to split
|
||||
scope_errors: list = field(default_factory=list) # mis-routed claims
|
||||
# ALLOC-01, appended for the reason the four above were: `target_w` still
|
||||
# means the SITE grid target and every existing construction is untouched.
|
||||
#
|
||||
# `allocation` is stage 3's full record - what each unit was told and why,
|
||||
# what could not be placed, what was dropped. `shares` and `allocated` are
|
||||
# two fields OF that record, kept at the top level because main.py and four
|
||||
# test files already read them there. ⚠️ IT IS THE RECORD, NOT THE PAIR,
|
||||
# THAT SAFETY-28 GATES THE WRITE PATH ON: `allocated` says the allocator
|
||||
# stands behind the split, and `unmet_w` says how much of the site target it
|
||||
# could not place, and fusing those two would make one saturated inverter
|
||||
# park the whole site.
|
||||
allocation: object | None = None
|
||||
# The priority of the SITE claim that chose `target_w`. Stage 3 weighs a
|
||||
# unit's OWN winning SET against it - see `allocator.plan()`'s PINS note -
|
||||
# and getting it from here rather than recomputing it is what stops the two
|
||||
# rules drifting apart: this is set by the same line that picks the winner
|
||||
# `explain()` names.
|
||||
winner_priority: int | None = None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""One line an operator can act on - this is the point of the module."""
|
||||
@@ -347,35 +373,55 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
|
||||
contradiction=lo > hi)
|
||||
|
||||
|
||||
def allocate(target_w: float, bounds: dict) -> tuple:
|
||||
"""STAGE 3. THE SEAM. Returns `(shares, allocated)`.
|
||||
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None) -> tuple:
|
||||
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
|
||||
|
||||
⚠️ THIS IS NOT THE ALLOCATOR AND MUST NOT BECOME ONE HERE. ALLOC-01 owns
|
||||
that, and §6.2 already fixed its shape: SUM-INVARIANT SHARE ALLOCATION - the
|
||||
shares sum to `target_w` exactly, each unit is told a function of its OWN
|
||||
share, and the weights are a comfort choice (SoC, thermals, wear) and
|
||||
explicitly not a tuning parameter, because the effective gain at the meter is
|
||||
then a convex combination of the per-unit gains for any split.
|
||||
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
|
||||
This is the adapter: it keeps the two-value shape every caller in this
|
||||
repository already reads, and it exists as a separate name so that the
|
||||
arbiter's three stages are still three functions a test can enter at any
|
||||
one of them. `allocator.plan()` returns the full record - use that when you
|
||||
need `unmet_w`, `dropped` or the per-unit reasons; `resolve()` stashes it on
|
||||
`Resolution.allocation` for exactly that.
|
||||
|
||||
What it does today, and why each half is what it is:
|
||||
What §6.2 fixed and what this delivers:
|
||||
|
||||
* WITH AT MOST ONE UNIT there is nothing to divide. The share IS the site
|
||||
target, and that is not an approximation - it is §6.2's N=1 identity, the
|
||||
one that makes "the battery moves X W" and "the grid is at X W" the same
|
||||
sentence. Pass-through.
|
||||
* WITH TWO OR MORE UNITS IT DECLINES: no shares, `allocated=False`. ⚠️ THE
|
||||
OBVIOUS PLACEHOLDER - hand `target_w` to each unit - IS THE FAULT ARCH-01
|
||||
WAS FILED ABOUT. Each inverter would supply it in full, the loop gain at
|
||||
the meter would be N times the per-unit gain, and at the gain config.yaml
|
||||
accepts as its documented maximum that is a permanent +-2 kW limit cycle
|
||||
with every watchdog in §11.2 reporting healthy. A placeholder that cannot
|
||||
be told apart from the real thing by any guard we have is worse than no
|
||||
placeholder, so this one refuses out loud instead.
|
||||
* SUM-INVARIANT. `sum(shares) + unmet_w == round(target_w)`, in integer
|
||||
arithmetic, so it cannot drift. Rounding is largest-remainder because a
|
||||
per-unit `round()` biases the sum by up to N/2 W every tick and at 1-2 Hz
|
||||
that is a PERMANENT standing error the site integrator chases forever.
|
||||
* EACH UNIT IS TOLD A FUNCTION OF ITS OWN SHARE, never of the shared
|
||||
measurement. That makes the gain at the meter a convex combination of the
|
||||
per-unit gains for ANY split, which is why the weights are a comfort
|
||||
choice and a bad split cannot destabilise the site.
|
||||
* WITH AT MOST ONE UNIT IT IS STILL A PASS-THROUGH, byte-identical to what
|
||||
ARB-03 shipped: the share IS the site target, as a float, unrounded and
|
||||
unclamped. §6.2's N=1 identity, and the compatibility promise every
|
||||
existing replay in this repository depends on.
|
||||
|
||||
⚠️ AND IT STILL DECLINES, WHICH IS NOT THE SAME LIST OF CASES AS BEFORE.
|
||||
ARB-03 declined at N>=2 because it had no allocator; that trigger is gone
|
||||
and the branch is NOT. It now fires when no safe split exists at all: a
|
||||
mis-routed claim, no unit left in the envelope set, or a split that could
|
||||
only be made by commanding one pack to charge while another discharges.
|
||||
Read `allocator.plan()` for each. main.py's refusal path and
|
||||
test_peakguard.py part 10h were re-aimed onto those rather than deleted -
|
||||
PR #40's reviewer proved that guard was unreachable and untested once, and
|
||||
removing its trigger without giving it a new one would rebuild the hole.
|
||||
"""
|
||||
if len(bounds) <= 1:
|
||||
only = next(iter(bounds), ACTUATOR_SITE)
|
||||
return {only: float(target_w)}, True
|
||||
return {}, False
|
||||
return allocate_record(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority)[:2]
|
||||
|
||||
|
||||
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None) -> tuple:
|
||||
"""`allocate()` plus the record - `(shares, allocated, allocation)`."""
|
||||
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority, site_key=ACTUATOR_SITE)
|
||||
return rec.shares, rec.ok, rec
|
||||
|
||||
|
||||
def site_resolve(claims: list) -> Resolution:
|
||||
@@ -412,7 +458,8 @@ def site_resolve(claims: list) -> Resolution:
|
||||
|
||||
if lo > hi:
|
||||
return Resolution(0.0, winner.source, bound_by=None,
|
||||
contradiction=True, considered=considered)
|
||||
contradiction=True, considered=considered,
|
||||
winner_priority=winner.priority)
|
||||
|
||||
target = max(lo, min(hi, winner.value))
|
||||
bound_by = None
|
||||
@@ -421,17 +468,29 @@ def site_resolve(claims: list) -> Resolution:
|
||||
if binder is not None:
|
||||
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
|
||||
|
||||
return Resolution(float(target), winner.source, bound_by, False, considered)
|
||||
return Resolution(float(target), winner.source, bound_by, False, considered,
|
||||
winner_priority=winner.priority)
|
||||
|
||||
|
||||
def resolve(claims: list, *, units=None) -> Resolution:
|
||||
def resolve(claims: list, *, units=None, weights=None, live=None) -> Resolution:
|
||||
"""The three stages, in order, over one cycle's claims.
|
||||
|
||||
`units` is the site's roster of inverters. Left None it is derived from the
|
||||
claims themselves, which is right for a caller that files a claim for every
|
||||
unit every cycle (main.py does - `supervised` is unconditional) and wrong for
|
||||
one that does not, so ALLOC-01 will pass the real roster rather than let a
|
||||
silent unit look like a smaller site.
|
||||
one that does not, so main.py passes the real roster rather than let a silent
|
||||
unit look like a smaller site.
|
||||
|
||||
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
|
||||
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
|
||||
wear - and cannot destabilise the site whatever it says. `live` is the set
|
||||
of units whose master has reported recently enough to be commanded; a unit
|
||||
left out of it is DROPPED from the envelope set and the record says so,
|
||||
because the survivor carrying a dead unit's share silently is the failure
|
||||
SAFETY-30 is about. ⚠️ NEITHER MAY BE CACHED BY A CALLER. A unit's range
|
||||
moves for reasons unrelated to the allocator - a stale link, a pack at its
|
||||
floor, a device rating that failed to read - so an envelope read once at
|
||||
construction over-allocates into a unit that cannot take it.
|
||||
|
||||
⚠️ AND THE ARGUMENT HAS TO BE HONOURED, WHICH IS NOT THE SAME AS EXISTING.
|
||||
Ignore it - derive always - and a unit that filed nothing this cycle ceases
|
||||
@@ -460,8 +519,19 @@ def resolve(claims: list, *, units=None) -> Resolution:
|
||||
# `claims`, so this is the shipped arbiter on the shipped list.
|
||||
out = site_resolve(site_claims)
|
||||
|
||||
# STAGE 3 - the seam.
|
||||
out.shares, out.allocated = allocate(out.target_w, bounds)
|
||||
out.unit_bounds = bounds
|
||||
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
|
||||
#
|
||||
# ⚠️ THE ORDER MATTERS: `scope_violations()` IS COMPUTED BEFORE THE SPLIT,
|
||||
# NOT AFTER, because stage 3 consumes it. A claim filed on an actuator its
|
||||
# source's scope does not permit means the number about to be divided may
|
||||
# itself be wrong, and dividing a number that may be wrong is worse than
|
||||
# not dividing it - `allocator.plan()` carries the full argument, including
|
||||
# why the mis-routed claim is NOT promoted into somebody's envelope. This
|
||||
# was the open question ARB-03 left, and it is answered here rather than
|
||||
# left as a log line nothing grades.
|
||||
out.scope_errors = scope_violations(claims)
|
||||
out.unit_bounds = bounds
|
||||
out.shares, out.allocated, out.allocation = allocate_record(
|
||||
out.target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=out.scope_errors, site_priority=out.winner_priority)
|
||||
return out
|
||||
|
||||
+112
-22
@@ -291,6 +291,18 @@ class Controller:
|
||||
# than re-derived a cycle later. Starts False: nothing is on the wire.
|
||||
self.following = False
|
||||
self.reason = "starting"
|
||||
# ALLOC-01. The allocation record from the last resolution, or None
|
||||
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
|
||||
# IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads
|
||||
# `shares` off it to drive a second inverter. It is REPLACED every cycle
|
||||
# and never merged - a record from a tick whose envelopes have moved is
|
||||
# the cached envelope this card exists to forbid, wearing a different
|
||||
# hat.
|
||||
self.allocation = None
|
||||
# What the last cycle's allocator dropped, so the log line above is
|
||||
# edge-triggered. Starts as the empty tuple - nothing has been dropped
|
||||
# yet and nothing has been said about it.
|
||||
self._dropped_last = ()
|
||||
self.grid = self.soc = self.batt = None
|
||||
self.peak_fc = None
|
||||
self.cheap = False
|
||||
@@ -451,6 +463,31 @@ class Controller:
|
||||
"""
|
||||
return actuator_for(source, self.unit, self.units)
|
||||
|
||||
def live_units(self) -> tuple:
|
||||
"""Which inverters may be commanded THIS TICK, for the allocator.
|
||||
|
||||
⚠️ THE ALLOCATOR DROPS WHAT THIS DOES NOT LIST, and the survivors then
|
||||
carry the whole site target. That is a real change in what the site can
|
||||
deliver, so it is reported rather than absorbed - `Allocation.dropped`
|
||||
carries it and the cycle logs the transition below. A wrong answer here
|
||||
is a site target divided among fewer inverters than exist, which is why
|
||||
it is a method with a reason and not a field somebody can set.
|
||||
|
||||
⚠️ AND IT IS ANSWERED PER TICK, NEVER CACHED. A master goes stale
|
||||
between one cycle and the next; an envelope read once at construction
|
||||
over-allocates into a unit that cannot take it.
|
||||
|
||||
Today the roster is one inverter and THIS CONTROLLER IS THAT
|
||||
INVERTER'S ONLY LINK, so "its master is stale" has no expression
|
||||
separate from "this controller is blind" - and that case never reaches
|
||||
the allocator with anything to divide, because `safety` files a SITE set
|
||||
at P_SAFETY and every share resolves to 0 W. So the whole roster is
|
||||
live, and saying that here is what makes the seam real rather than
|
||||
notional. DEV-02 gives each inverter its own link and its own freshness,
|
||||
and replaces the body of this method with them.
|
||||
"""
|
||||
return self.units
|
||||
|
||||
async def cycle(self) -> None:
|
||||
"""Gather claims, resolve precedence, write the winner.
|
||||
|
||||
@@ -844,36 +881,89 @@ class Controller:
|
||||
# de-assert when control is stopped.
|
||||
self.loop_clamped = (False, "")
|
||||
|
||||
resolution = resolve(claims, units=self.units)
|
||||
# ⚠️ N>=2 WITHOUT AN ALLOCATOR IS A REFUSAL TO BROADCAST, NOT A REFUSAL
|
||||
# TO WRITE, and the difference is the next four lines: the resolution is
|
||||
# REPLACED by a 0 W failsafe and then written like any other cycle at
|
||||
resolution = resolve(claims, units=self.units, live=self.live_units())
|
||||
self.allocation = resolution.allocation
|
||||
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
|
||||
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
|
||||
# which is then written like any other cycle at
|
||||
# `write_setpoint(self.target)` below. Nothing is skipped. 0 W is the
|
||||
# fail-toward-inaction direction - the setpoint is commanded to zero
|
||||
# rather than left wherever the last cycle put it.
|
||||
# fail-toward-inaction direction: the setpoint is commanded to zero
|
||||
# rather than left wherever the last cycle put it. They differ in WHO
|
||||
# refused and therefore in what an operator should do about it, so they
|
||||
# are two branches with two messages and not one with a vague one.
|
||||
#
|
||||
# This branch is unreachable while the roster holds one unit. It is here
|
||||
# because the alternative default is the fault ARCH-01 was filed about:
|
||||
# hand the site target to each inverter and each supplies it IN FULL, so
|
||||
# the loop gain at the meter becomes N times the per-unit gain - a
|
||||
# permanent +-2 kW limit cycle at the gain config.yaml documents as its
|
||||
# maximum, with every watchdog in §11.2 reporting healthy. `allocate()`
|
||||
# declines rather than invent a split; this is what declining costs.
|
||||
# ⚠️ AND NEITHER MAY BE LEFT UNGRADED. PR #40's reviewer replaced the
|
||||
# first of these with `if False:` and the whole 17-file suite stayed
|
||||
# green, because at the shipped roster of one it was unreachable.
|
||||
# test_peakguard.py part 10h grades both, on real cycles.
|
||||
#
|
||||
# ⚠️ AND BEING UNREACHABLE IS NOT A REASON TO LEAVE IT UNGRADED. It was:
|
||||
# PR #40's reviewer replaced this `if` with `if False:` and the whole
|
||||
# 17-file suite stayed green, so the branch was one refactor away from
|
||||
# being deleted as dead code. test_peakguard.py part 10h now drives a
|
||||
# real two-inverter cycle and grades all four halves - the 0 W, the
|
||||
# `failsafe` winner, the log line, and the write that follows it.
|
||||
# 1. THE ALLOCATOR REFUSED. Before ALLOC-01 this fired on every N>=2
|
||||
# cycle, because there was no allocator and the obvious placeholder -
|
||||
# hand the site target to each inverter - IS the ARCH-01 fault. There
|
||||
# is an allocator now and that trigger is gone; the branch is not,
|
||||
# because refusing is still right when no safe split exists at all: a
|
||||
# mis-routed claim (so the number to divide may itself be wrong), no
|
||||
# inverter left in the envelope set, or a split that could only be
|
||||
# made by commanding one pack to charge while another discharges.
|
||||
# `allocator.plan()` carries the argument for each.
|
||||
if not resolution.allocated:
|
||||
self.log_event(f"ARBITER: no allocator for {len(self.units)} "
|
||||
f"inverters - refusing to broadcast the site target")
|
||||
self.log_event("ARBITER: " + resolution.allocation.explain())
|
||||
resolution = Resolution(0.0, "failsafe", None, False,
|
||||
resolution.considered,
|
||||
unit_bounds=resolution.unit_bounds,
|
||||
shares={}, allocated=False,
|
||||
scope_errors=resolution.scope_errors)
|
||||
scope_errors=resolution.scope_errors,
|
||||
allocation=resolution.allocation,
|
||||
winner_priority=resolution.winner_priority)
|
||||
# 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT.
|
||||
# ⚠️ THIS GUARD IS THE PRICE OF MAKING allocate() WORK, and without
|
||||
# it ALLOC-01 would be a REGRESSION rather than a fix. There is ONE
|
||||
# `setpoint_entity` and ONE inverter-power sensor in this add-on, so
|
||||
# this controller can put a number on exactly one inverter. Give it a
|
||||
# roster of two and a valid split, and writing `self.target` - the
|
||||
# SITE target - to its single entity would hand one inverter the
|
||||
# whole site's number while a second sits uncommanded: not the
|
||||
# broadcast fault, but the same shape of over-command, and the
|
||||
# allocator's own arithmetic says the share is half that. Commanding
|
||||
# a partial site is a worse instruction than commanding nothing, and
|
||||
# "nothing" here means 0 W and says so.
|
||||
#
|
||||
# ⚠️ AT THE SHIPPED ROSTER OF ONE THIS IS BYTE-IDENTICAL TO BEFORE.
|
||||
# `shares` is `{inverter-a: target_w}` - §6.2's N=1 identity, the
|
||||
# pass-through - so the set below is empty and nothing happens.
|
||||
# DEV-02 gives this controller a writer per inverter and deletes the
|
||||
# branch; until it does, the branch is what stops ALLOC-01 shipping a
|
||||
# half-driven site.
|
||||
elif set(resolution.shares) - {self.unit}:
|
||||
undeliverable = ", ".join(sorted(set(resolution.shares) - {self.unit}))
|
||||
self.log_event(
|
||||
f"ARBITER: the site target was allocated across "
|
||||
f"{len(resolution.shares)} inverters and this controller drives "
|
||||
f"only {self.unit} - no writer for {undeliverable}, refusing to "
|
||||
f"command a partial site (DEV-02)")
|
||||
resolution = Resolution(0.0, "failsafe", None, False,
|
||||
resolution.considered,
|
||||
unit_bounds=resolution.unit_bounds,
|
||||
shares=resolution.shares, allocated=True,
|
||||
scope_errors=resolution.scope_errors,
|
||||
allocation=resolution.allocation,
|
||||
winner_priority=resolution.winner_priority)
|
||||
# 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its
|
||||
# share, which is a real change in what the site can deliver, and an
|
||||
# operator who is not told reads a working site. Edge-triggered on
|
||||
# the SET of dropped units, not level-triggered, because the action
|
||||
# log is a log of events (§15) and a stale link would otherwise write
|
||||
# a line every second for as long as it lasts.
|
||||
_dropped = tuple(u for u, _why in (self.allocation.dropped
|
||||
if self.allocation else ()))
|
||||
if _dropped != self._dropped_last:
|
||||
for _u, _why in (self.allocation.dropped if self.allocation else ()):
|
||||
self.log_event(f"ALLOCATOR: {_u} dropped from the split "
|
||||
f"({_why}) - the remaining inverter(s) now carry "
|
||||
f"its share of the site target")
|
||||
if self._dropped_last and not _dropped:
|
||||
self.log_event("ALLOCATOR: every inverter is back in the split")
|
||||
self._dropped_last = _dropped
|
||||
if resolution.scope_errors:
|
||||
# A claim reached the arbiter on an actuator its source's scope does
|
||||
# not permit. It is a BUG in the filing site, not a state to survive:
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
"""Runnable check for the site allocator. `python3 test_alloc.py`
|
||||
|
||||
ALLOC-01. The site grid target is ALLOCATED across N inverters and NEVER
|
||||
broadcast, because the control primitive is meter emulation: the master reports
|
||||
a FABRICATED grid reading and the inverter regulates itself to it, so one number
|
||||
means "the battery moves X W" here and "the grid is at X W" there - an identity
|
||||
that holds ONLY at N=1. Hand both inverters the same number and each supplies it
|
||||
IN FULL.
|
||||
|
||||
⚠️ WHAT THIS FILE CAN AND CANNOT CLAIM, SAID BEFORE THE FIRST CHECK.
|
||||
The two-master rig lives in admin/goodwe (TEST-19, `sim/sitesim.py`), where
|
||||
`compute()` closes the loop against `plantsim.Plant` with the firmware's own
|
||||
encoder on the wire. That repository is PRIVATE and this repository's CI token
|
||||
is minted for this one, so the rig is not reachable from the gate - and a test
|
||||
file that needed it would either fail the gate or, like test_freeze.py's hwsim
|
||||
section, pass SILENTLY having checked nothing.
|
||||
|
||||
So the split is deliberate:
|
||||
|
||||
* `tools/alloc_sim.py` drives the REAL rig, and its output is quoted in the
|
||||
pull request. It is where "no limit cycle" is a claim about the shipped
|
||||
control law against the modelled hardware.
|
||||
* THIS FILE grades the allocator's own arithmetic exhaustively - which needs
|
||||
no plant at all - and closes a loop around the REAL `compute()` against a
|
||||
plant IT MODELS AND SAYS IT MODELS (part 8). The modelled plant is the
|
||||
`lag_s=0` limit ARCH-01's evidence was computed in; it is not evidence about
|
||||
a GW5000S-BP.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST YET. Nothing here contains a measured parameter for
|
||||
it. Every two-unit case below is built from a rating passed IN, and the second
|
||||
unit's rating is either the first one's or an explicit hypothetical.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import random
|
||||
import sys
|
||||
from math import floor, inf
|
||||
|
||||
from app.allocator import (
|
||||
DROP_CONTRADICTION, DROP_NO_INTEGER, DROP_STALE, REFUSE_EMPTY,
|
||||
REFUSE_SCOPE, REFUSE_SIGN, Allocation, plan,
|
||||
)
|
||||
from app.arbiter import (
|
||||
ACTUATOR_SITE, P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK,
|
||||
P_SAFETY, UnitBounds, Claim, allocate, resolve,
|
||||
)
|
||||
from app.control import Tuning, compute
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def _raises_value(fn):
|
||||
"""True if `fn()` raises ValueError - a check that something is REFUSED."""
|
||||
try:
|
||||
fn()
|
||||
except ValueError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def B(lo=-inf, hi=inf, gate=None, pin=None, by=None, contra=False):
|
||||
"""One unit's interval, the way ARB-03 stage 1 hands it over."""
|
||||
return UnitBounds(lo=lo, hi=hi, gate=gate, pinned_w=pin, pinned_by=by,
|
||||
contradiction=contra)
|
||||
|
||||
|
||||
A, Bee = "inverter-a", "inverter-b"
|
||||
RATING = 5000.0 # GW5000S-BP, measured. Inverter B's is a HYPOTHESIS.
|
||||
TWO = {A: B(-RATING, RATING), Bee: B(-RATING, RATING)}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print("1. AC 1: the shares sum to the site target, and it is INTEGER arithmetic")
|
||||
|
||||
# ⚠️ THE INVARIANT IS NOT `sum == S`. It is `sum + unmet == S`, and the
|
||||
# difference is the whole of AC 4: an allocation the envelopes could not fully
|
||||
# absorb is still a SAFE allocation, and reporting the shortfall is better than
|
||||
# either refusing or pretending. `unmet_w` is 0 whenever the envelopes allow.
|
||||
_r = plan(1990.0, TWO)
|
||||
print(f" {_r.explain()}")
|
||||
check("two open envelopes, an even weighting: the site target is halved and "
|
||||
"the halves sum back to it",
|
||||
_r.shares == {A: 995, Bee: 995} and _r.unmet_w == 0)
|
||||
check("...and 995 is not 1990, which is the sentence ARCH-01 is about. A "
|
||||
"broadcast hands each unit the site target itself, each supplies it IN "
|
||||
"FULL, and the loop gain at the meter becomes N times the per-unit gain",
|
||||
all(v != _r.sum_w for v in _r.shares.values()))
|
||||
|
||||
# THE LONG REPLAY. AC 1 says "verified over a long replay rather than a spot
|
||||
# check", and it says so because the failure mode is a BIAS - a fraction of a
|
||||
# watt per tick that only shows up in aggregate. A spot check at a round number
|
||||
# cannot see it; 250 000 randomised ticks can.
|
||||
random.seed(20260902)
|
||||
_N = 250_000
|
||||
_bad_sum = _bad_env = _refused = 0
|
||||
_bias = 0
|
||||
_span = 0
|
||||
for _i in range(_N):
|
||||
_S = random.uniform(-6000.0, 6000.0)
|
||||
_ba = B(-random.uniform(0.0, RATING), random.uniform(0.0, RATING))
|
||||
_bb = B(-random.uniform(0.0, RATING), random.uniform(0.0, RATING))
|
||||
_wa = random.random()
|
||||
_p = plan(_S, {A: _ba, Bee: _bb}, weights={A: _wa, Bee: 1.0 - _wa})
|
||||
if not _p.ok:
|
||||
_refused += 1
|
||||
continue
|
||||
if sum(_p.shares.values()) + _p.unmet_w != _p.sum_w:
|
||||
_bad_sum += 1
|
||||
for _u, _s in _p.shares.items():
|
||||
_b = {A: _ba, Bee: _bb}[_u]
|
||||
if not (_b.lo - 1.0 <= _s <= _b.hi + 1.0):
|
||||
_bad_env += 1
|
||||
if _p.unmet_w == 0:
|
||||
_bias += _p.sum_w - sum(_p.shares.values())
|
||||
_span += 1
|
||||
print(f" replayed {_N} ticks at random targets, random envelopes and random "
|
||||
f"weights: {_bad_sum} sum failures, {_bad_env} envelope escapes, "
|
||||
f"{_refused} refusals")
|
||||
check("⚠️ OVER A QUARTER OF A MILLION TICKS THE SUM IS EXACT EVERY TIME. Not "
|
||||
"within a tolerance - EXACT - because `sum_w` is an int, the shares are "
|
||||
"ints and the leftover is settled in integer arithmetic, so there is no "
|
||||
"float in the identity for an error to accumulate in",
|
||||
_bad_sum == 0)
|
||||
check("...and no share ever leaves the envelope it was clamped into, over the "
|
||||
"same replay. An allocator that meets its sum by over-driving one unit "
|
||||
"past its rating has met the wrong invariant",
|
||||
_bad_env == 0)
|
||||
check("⚠️ AND THE ACCUMULATED BIAS IS ZERO, WHICH IS THE POINT OF LARGEST "
|
||||
f"REMAINDER. Summed over {_span} unconstrained ticks the total shortfall "
|
||||
"is 0 W. A 1 W systematic bias at 1-2 Hz is not a rounding artefact - it "
|
||||
"is a permanent standing error with the site integrator wound around it, "
|
||||
"chasing a target the allocator will never let it reach",
|
||||
_bias == 0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("2. AC 1 continued: WHY largest remainder, measured against the "
|
||||
"alternative")
|
||||
|
||||
# The obvious implementation is `round(w_i * S)` per unit. It is wrong, and the
|
||||
# amount it is wrong by is worth printing rather than asserting.
|
||||
def _naive(S, ws):
|
||||
"""The obvious implementation: round each unit's own share, independently."""
|
||||
return [round(w * S) for w in ws]
|
||||
|
||||
|
||||
random.seed(4242)
|
||||
_naive2 = _naive3 = _ours = 0
|
||||
_worst2 = _worst3 = 0
|
||||
_C = "inverter-c"
|
||||
_THREE = {A: B(-RATING, RATING), Bee: B(-RATING, RATING),
|
||||
_C: B(-RATING, RATING)}
|
||||
for _i in range(50_000):
|
||||
_S = random.randrange(-4000, 4000)
|
||||
_wa = random.random()
|
||||
_n2 = _naive(_S, [_wa, 1.0 - _wa])
|
||||
_naive2 += sum(_n2) - _S
|
||||
_worst2 = max(_worst2, abs(sum(_n2) - _S))
|
||||
_x, _y = sorted((random.random(), random.random()))
|
||||
_w3 = [_x, _y - _x, 1.0 - _y]
|
||||
_n3 = _naive(_S, _w3)
|
||||
_naive3 += sum(_n3) - _S
|
||||
_worst3 = max(_worst3, abs(sum(_n3) - _S))
|
||||
_p3 = plan(float(_S), _THREE, weights=dict(zip([A, Bee, _C], _w3)))
|
||||
_ours += sum(_p3.shares.values()) - _p3.sum_w
|
||||
print(f" 50 000 ticks, per-unit round(): at N=2 it drifts {_naive2:+d} W in "
|
||||
f"total (worst tick {_worst2} W); at N=3, {_naive3:+d} W (worst tick "
|
||||
f"{_worst3} W). Largest remainder at N=3: {_ours:+d} W")
|
||||
check("⚠️ AT THREE INVERTERS THE NAIVE PER-UNIT round() MISSES THE SUM ON REAL "
|
||||
"INPUTS, repeatedly. This is what the card means by calling the rounding "
|
||||
"a safety property: every missed watt is a standing error at the meter "
|
||||
"that the site integrator chases and never reaches",
|
||||
_worst3 >= 1)
|
||||
check("...and largest remainder does not miss it, ever, over the same inputs "
|
||||
"at the same roster - which is also this file's only check that the "
|
||||
"allocator is general in N rather than special-cased at two",
|
||||
_ours == 0 and len(plan(1000.0, _THREE).shares) == 3)
|
||||
check("⚠️ AND AT TWO INVERTERS THE NAIVE VERSION IS ALMOST RIGHT, WHICH IS THE "
|
||||
"DANGEROUS HALF AND IS WHY IT IS MEASURED RATHER THAN ASSUMED. With two "
|
||||
"COMPLEMENTARY weights the two roundings cancel except on an exact "
|
||||
f"half-watt tie, so over 50 000 ticks it drifted {_naive2} W in total. An "
|
||||
"implementation that shipped at N=2 on per-unit rounding would look "
|
||||
"correct and start leaking the day a third inverter arrived - and the tie "
|
||||
"case below is wrong even at N=2",
|
||||
_worst2 <= 1)
|
||||
check("the two-unit case a per-unit round() gets wrong every time is the "
|
||||
"half-watt one: 1501 W split evenly is 750.5 twice, which rounds to "
|
||||
"either 1500 or 1502 - never 1501. Largest remainder gives 751 and 750, "
|
||||
"and gives the extra watt to a NAMED unit rather than to whichever one "
|
||||
"the tie-break happened to favour",
|
||||
plan(1501.0, TWO).shares == {A: 751, Bee: 750}
|
||||
and sum(plan(1501.0, TWO).shares.values()) == 1501)
|
||||
check("...and the tie-break is DETERMINISTIC, so the same inputs give the same "
|
||||
"allocation on every tick and on every host. A tie broken by dict order "
|
||||
"would move a watt between two inverters at 1-2 Hz forever",
|
||||
all(plan(1501.0, TWO).shares == {A: 751, Bee: 750} for _ in range(50)))
|
||||
# THE SITE-LEVEL ROUNDING, graded over a replay for the same reason the sum is:
|
||||
# the failure mode is a BIAS, and one spot check at one fractional target cannot
|
||||
# see one. `int()` instead of `round()` is the mutation that matters here - it
|
||||
# truncates toward zero, which is a systematic under-command of up to a watt on
|
||||
# EVERY tick, in a consistent direction, which is precisely the standing error
|
||||
# the card calls a safety property.
|
||||
random.seed(99)
|
||||
_q = [plan(random.uniform(-4000.0, 4000.0), TWO).quantisation_w
|
||||
for _ in range(20_000)]
|
||||
print(f" 20 000 fractional targets: |quantisation| <= {max(map(abs, _q)):.3f} W, "
|
||||
f"mean {sum(_q) / len(_q):+.5f} W")
|
||||
check("the site target itself is rounded ONCE, at the top, and the record says "
|
||||
"by how much. It is never more than half a watt - a TRUNCATION would be "
|
||||
"up to a whole one - and that half watt is a single site-level rounding "
|
||||
"the integrator corrects on the next tick, not a per-unit bias it cannot "
|
||||
"see",
|
||||
max(map(abs, _q)) <= 0.5 and plan(1500.4, TWO).sum_w == 1500)
|
||||
check("...and it is UNBIASED over the replay, which truncation is not: "
|
||||
"`int(x)` rounds toward zero, so it would under-command on every single "
|
||||
"tick in a consistent direction - a permanent standing error rather than "
|
||||
"a rounding that averages out",
|
||||
abs(sum(_q) / len(_q)) < 0.01)
|
||||
check("...and the allocation does not depend on the ORDER the envelopes arrive "
|
||||
"in, which is what makes it reproducible tick to tick: `plan()` sorts the "
|
||||
"roster before it does anything else, so a dict built the other way round "
|
||||
"gives the same shares",
|
||||
plan(1501.0, {Bee: B(-RATING, RATING), A: B(-RATING, RATING)}).shares
|
||||
== plan(1501.0, {A: B(-RATING, RATING), Bee: B(-RATING, RATING)}).shares
|
||||
== {A: 751, Bee: 750})
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("3. AC 4: saturation - the surplus moves, and what will not fit is NAMED")
|
||||
|
||||
_sat = plan(4000.0, {A: B(-RATING, 1000.0), Bee: B(-RATING, RATING)},
|
||||
weights={A: 0.5, Bee: 0.5})
|
||||
print(f" {_sat.explain()}")
|
||||
check("one inverter at its rating gives its surplus to the other rather than "
|
||||
"being over-driven. An even weighting would have asked A for 2000 W; its "
|
||||
"envelope stops at 1000 W and B takes the other 3000",
|
||||
_sat.shares == {A: 1000, Bee: 3000} and _sat.unmet_w == 0)
|
||||
check("...and the record says WHY it got 1000 and not 2000, per unit, in words "
|
||||
"an operator can act on rather than as a silent clamp",
|
||||
any(p.unit == A and "envelope full" in p.why for p in _sat.plans))
|
||||
|
||||
_unmet = plan(9000.0, {A: B(-RATING, 1000.0), Bee: B(-RATING, 2000.0)})
|
||||
print(f" {_unmet.explain()}")
|
||||
check("⚠️ AND WHEN NEITHER CAN TAKE IT, `unmet_w` IS REPORTED RATHER THAN THE "
|
||||
"SUM QUIETLY MISSING. 9000 W asked for, 3000 W of envelope: both units go "
|
||||
"to their ceilings and 6000 W is stated as unmet",
|
||||
_unmet.shares == {A: 1000, Bee: 2000} and _unmet.unmet_w == 6000)
|
||||
check("...and the allocation is still OK, which is the decision worth stating: "
|
||||
"a saturated site is not a broken allocation. Commanding 3000 W of the "
|
||||
"9000 the loop asked for is strictly better than commanding 0 W, and the "
|
||||
"shortfall is a fact the write gate can act on. Fusing `unmet_w` into "
|
||||
"`ok` would let one saturated inverter park the whole site",
|
||||
_unmet.ok is True)
|
||||
check("the identity that holds in every case, saturated or not: shares + unmet "
|
||||
"== the site target",
|
||||
sum(_unmet.shares.values()) + _unmet.unmet_w == _unmet.sum_w)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("4. AC 5: a pack at its SoC floor - 0 W discharging, STILL CHARGEABLE")
|
||||
|
||||
# The floor arrives as a `hi` of 0 on that unit's interval: it may not
|
||||
# discharge. It says nothing about charging, and reading it as "this unit is
|
||||
# out" is the mistake - a unit at its floor is precisely the unit that most
|
||||
# wants the charge.
|
||||
_floor = {A: B(-RATING, 0.0), Bee: B(-RATING, RATING)}
|
||||
_dis = plan(1990.0, _floor)
|
||||
_chg = plan(-1990.0, _floor)
|
||||
print(f" discharging: {_dis.explain()}")
|
||||
print(f" charging: {_chg.explain()}")
|
||||
check("in the DISCHARGING direction the floored unit is allocated 0 W and the "
|
||||
"other carries the whole site target",
|
||||
_dis.shares == {A: 0, Bee: 1990} and _dis.unmet_w == 0)
|
||||
check("⚠️ AND IN THE CHARGING DIRECTION IT IS STILL AVAILABLE, which is the "
|
||||
"half a `if soc <= floor: skip this unit` implementation gets wrong. Its "
|
||||
"envelope forbids discharge, not participation, and a pack at its floor "
|
||||
"is the one that most needs the charge",
|
||||
_chg.shares == {A: -995, Bee: -995})
|
||||
check("...and 0 W is not a sign of its own: the floored unit's 0 W sits happily "
|
||||
"beside the other's +1990 W without tripping the sign-coherence gate, "
|
||||
"because zero is coherent with every direction",
|
||||
_dis.ok is True and _dis.refused is None)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("5. AC 6: a dead link leaves the envelope set, and IS ANNOUNCED")
|
||||
|
||||
_dead = plan(1990.0, TWO, live=(A,))
|
||||
print(f" {_dead.explain()}")
|
||||
check("a unit whose master is stale is REMOVED from the envelope set. "
|
||||
"Allocating into a link nobody can reach is writing into a void and "
|
||||
"calling the site target met",
|
||||
Bee not in _dead.shares and _dead.dropped == ((Bee, DROP_STALE),))
|
||||
check("...and the survivor carries the whole site target, which is right: there "
|
||||
"is one integrator on P1 and its output still has to be delivered",
|
||||
_dead.shares == {A: 1990} and _dead.unmet_w == 0)
|
||||
check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY. The record NAMES the unit and the "
|
||||
"reason, so `main.py` can put it in the action log and SAFETY-28's write "
|
||||
"gate can refuse on it. A site running at half its inverters and "
|
||||
"reporting nothing reads as a healthy site on every dashboard",
|
||||
_dead.dropped[0][0] == Bee and _dead.dropped[0][1] == DROP_STALE
|
||||
and any(p.unit == Bee and "DROPPED" in p.why for p in _dead.plans))
|
||||
check("...and the per-unit plan for the dropped unit is present rather than "
|
||||
"absent, so a gate iterating the roster finds an entry for every inverter "
|
||||
"instead of having to notice a missing key",
|
||||
{p.unit for p in _dead.plans} == {A, Bee})
|
||||
check("a unit contradicting ITSELF - its own lo above its own hi - leaves the "
|
||||
"set for its own reason, and does not park the site. That is stage 1's "
|
||||
"decision honoured, not re-litigated here",
|
||||
plan(1990.0, {A: B(-RATING, RATING),
|
||||
Bee: B(500.0, -500.0, contra=True)}).dropped
|
||||
== ((Bee, DROP_CONTRADICTION),))
|
||||
check("an interval that contains NO INTEGER is dropped too, and says which case "
|
||||
"it is. The register is 1 W: there is no number that unit could be "
|
||||
"commanded that both of its own bounds accept",
|
||||
plan(0.0, {A: B(-RATING, RATING), Bee: B(100.2, 100.8)}).dropped
|
||||
== ((Bee, DROP_NO_INTEGER),))
|
||||
check("⚠️ AND EVERY UNIT GONE IS A REFUSAL, NOT AN EMPTY SUCCESS. With nobody "
|
||||
"in the envelope set there is nothing to allocate to, and inventing a "
|
||||
"share for a link nobody can reach is the failure the refusal branch "
|
||||
"exists for. THIS is the trigger `main.py`'s `if not resolution."
|
||||
"allocated:` was re-aimed onto when ALLOC-01 removed its old one",
|
||||
plan(1990.0, TWO, live=()).ok is False
|
||||
and REFUSE_EMPTY in plan(1990.0, TWO, live=()).refused)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("6. AC 7: opposite directions are REFUSED, not averaged")
|
||||
|
||||
# ⚠️ WHY THIS IS A SAFETY GATE AND NOT AN EFFICIENCY ONE. One pack discharging
|
||||
# while another charges round-trips energy through two conversion stages, and it
|
||||
# is INVISIBLE: the meter sees the net, which is exactly S, so the site
|
||||
# integrator is content; each unit is meeting the target it was given; the sign
|
||||
# watchdog compares a command against the meter and both agree. Nothing in this
|
||||
# repository would ever report it.
|
||||
_circ = plan(1000.0, {A: B(-RATING, RATING, gate=P_MAINTENANCE, pin=2500.0,
|
||||
by="maintenance"),
|
||||
Bee: B(-RATING, RATING)}, site_priority=P_LOOP)
|
||||
print(f" {_circ.explain()}")
|
||||
check("a maintenance drain of 2500 W on one pack against a site target of "
|
||||
"1000 W would need the other pack to CHARGE at 1500 W to make the sum. "
|
||||
"That nets to exactly the right number at the meter, which is precisely "
|
||||
"why it has to be refused here - no downstream guard can see it",
|
||||
_circ.ok is False and REFUSE_SIGN in _circ.refused)
|
||||
check("...and the refusal NAMES both sides, so an operator is told which two "
|
||||
"inverters were about to push against each other",
|
||||
A in _circ.refused and Bee in _circ.refused)
|
||||
check("...and it is a REFUSAL rather than a quiet re-plan: dropping the pinned "
|
||||
"unit and re-filling would silently override a claim that outranked the "
|
||||
"loop, which is a worse answer than stopping and saying so",
|
||||
_circ.shares == {})
|
||||
check("the same pin with a site target BEYOND it is fine, because the shares "
|
||||
"then agree in sign - the gate grades the OUTPUT, not the presence of a "
|
||||
"pin",
|
||||
plan(3000.0, {A: B(-RATING, RATING, gate=P_MAINTENANCE, pin=2500.0,
|
||||
by="maintenance"),
|
||||
Bee: B(-RATING, RATING)},
|
||||
site_priority=P_LOOP).shares == {A: 2500, Bee: 500})
|
||||
check("⚠️ AND IT NEVER FIRES ON AN ORDINARY TICK, which is what makes it a gate "
|
||||
"rather than a nuisance. Over the 250 000-tick replay in part 1 - random "
|
||||
"targets, random envelopes, random weights, no pins - it refused "
|
||||
f"{_refused} times",
|
||||
_refused == 0)
|
||||
check("...and it cannot: with no pin and an envelope that contains zero, the "
|
||||
"fill only ever moves shares in ONE direction away from zero, so two "
|
||||
"signs are not expressible. The gate exists for the envelope that "
|
||||
"EXCLUDES zero, which is a forced floor or a pin",
|
||||
all(v >= 0 for v in plan(1990.0, TWO).shares.values())
|
||||
and all(v <= 0 for v in plan(-1990.0, TWO).shares.values()))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. AC 8: at N<=1 it is a pass-through, byte-identical to ARB-03")
|
||||
|
||||
check("with no units at all the whole target goes to the site actuator, as a "
|
||||
"float", allocate(1500.0, {}) == ({ACTUATOR_SITE: 1500.0}, True))
|
||||
check("with one unit the share IS the site target - §6.2's N=1 identity: with "
|
||||
"one inverter the fabricated grid reading and the battery setpoint are "
|
||||
"the same number, so there is nothing to divide",
|
||||
allocate(1500.0, {A: UnitBounds()}) == ({A: 1500.0}, True))
|
||||
check("⚠️ AND IT IS A FLOAT, UNROUNDED. The general path rounds to the 1 W "
|
||||
"register; the pass-through must not, because every N=1 replay in this "
|
||||
"repository - test_arbiter, test_watchdog, test_validate, test_peakguard "
|
||||
"- expects the number it saw before ALLOC-01. This is the line a 'tidy "
|
||||
"the special case away' refactor goes red on",
|
||||
allocate(1500.4, {A: UnitBounds()}) == ({A: 1500.4}, True)
|
||||
and isinstance(allocate(1500.4, {A: UnitBounds()})[0][A], float))
|
||||
check("...and it is UNCLAMPED too, which is the other half of byte-identity: at "
|
||||
"N=1 every per-inverter claim routes to the SITE actuator anyway, so "
|
||||
"stage 2 has already applied the rating and re-applying an interval here "
|
||||
"would be a second, different clamp on a live safety path",
|
||||
allocate(9999.0, {A: B(-RATING, RATING)}) == ({A: 9999.0}, True))
|
||||
_thru = plan(1500.0, {A: UnitBounds()})
|
||||
check("...and the pass-through still produces a RECORD, so SAFETY-28's write "
|
||||
"gate reads the same shape at every roster size and does not need an N=1 "
|
||||
"special case of its own",
|
||||
_thru.ok and _thru.passthrough and _thru.plans and _thru.unmet_w == 0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("8. AC 2 and AC 3, on a MODELLED plant: split-invariance and the sweep")
|
||||
|
||||
# ⚠️ READ THE FILE HEADER BEFORE READING THESE NUMBERS. The plant here is
|
||||
# modelled IN THIS FILE - `lag_s=0`, unbounded ramp, which is the limit
|
||||
# ARCH-01's own evidence was computed in - and the loop is the REAL `compute()`
|
||||
# out of app/control.py. So the claim these checks support is about THE CONTROL
|
||||
# LAW AND THE ALLOCATOR, not about a GW5000S-BP. The hardware claim is
|
||||
# `tools/alloc_sim.py` against admin/goodwe's rig, quoted in the pull request.
|
||||
CYCLE_S = 5.0
|
||||
|
||||
|
||||
def close_loop(gain, split, cycles=200, load_w=2000.0, mode="alloc",
|
||||
feedback="site", rating=RATING):
|
||||
"""One integrator on the meter, N units, each told its OWN share.
|
||||
|
||||
`feedback` is what `compute()` is handed as `actual_w`, and it is a
|
||||
PARAMETER because it turns out to be the whole of the AC 2 finding - see
|
||||
below. "site" is the sum of the units' power; "unit" is inverter A's alone,
|
||||
which is what the shipped add-on's single `inverter_power` sensor gives it.
|
||||
"""
|
||||
tuning = Tuning(gain=gain)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
power = [0.0, 0.0]
|
||||
grid = load_w
|
||||
trace = []
|
||||
for _c in range(cycles):
|
||||
d = compute(prev, grid, sum(power) if feedback == "site" else power[0],
|
||||
tuning, sat, i_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
if mode == "broadcast":
|
||||
cmds = [d.target_w, d.target_w]
|
||||
else:
|
||||
p = plan(d.target_w, {A: B(-rating, rating), Bee: B(-rating, rating)},
|
||||
weights={A: split, Bee: 1.0 - split})
|
||||
assert p.ok, p.refused
|
||||
cmds = [float(p.shares[A]), float(p.shares[Bee])]
|
||||
# The modelled unit: it obeys within one cycle, bounded by its rating.
|
||||
power = [max(-rating, min(rating, c)) for c in cmds]
|
||||
grid = load_w - sum(power)
|
||||
trace.append((round(d.target_w, 6), round(grid, 6)))
|
||||
return trace
|
||||
|
||||
|
||||
def p2p(trace, k=20):
|
||||
tail = [g for _t, g in trace[-k:]]
|
||||
return max(tail) - min(tail)
|
||||
|
||||
|
||||
_ref = close_loop(1.0, 0.5)
|
||||
_splits = [0.5, 0.9, 0.1, 0.99, 1.0, 0.0]
|
||||
_same = {s: close_loop(1.0, s) == _ref for s in _splits}
|
||||
print(f" aggregate trace vs the 50/50 reference, gain 1.0: "
|
||||
+ ", ".join(f"{int(s*100)}/{int((1-s)*100)}={'same' if v else 'DIFFERS'}"
|
||||
for s, v in _same.items()))
|
||||
check("⚠️ AC 2. A 90/10 AND A 50/50 SPLIT PRODUCE AN IDENTICAL AGGREGATE TRACE "
|
||||
"AT THE METER - and so do 99/1 and 100/0. Not close: the same numbers. "
|
||||
"That is what says the weights are a COMFORT CHOICE and not a tuning "
|
||||
"parameter, and it is the reason §6.2 chose share allocation over a "
|
||||
"regulator/ballast arrangement, where the ballast unit's share is a "
|
||||
"function of the shared measurement and the gain question comes back",
|
||||
all(_same.values()))
|
||||
check("...and it is not vacuous, because the SHARES really are different. An "
|
||||
"allocator that ignored its weights would pass the line above trivially",
|
||||
close_loop(1.0, 0.9) == close_loop(1.0, 0.5)
|
||||
and plan(2000.0, TWO, weights={A: 0.9, Bee: 0.1}).shares
|
||||
!= plan(2000.0, TWO, weights={A: 0.5, Bee: 0.5}).shares)
|
||||
check("...and it holds at EVERY gain the schema accepts, not only at the one "
|
||||
"the reference experiment uses",
|
||||
all(close_loop(g, 0.9) == close_loop(g, 0.5)
|
||||
for g in (0.05, 0.2, 0.4, 0.6, 0.8, 1.0)))
|
||||
|
||||
# THE SWEEP. AC 3 says the sweep is the check, not an argument.
|
||||
_GAINS = (0.05, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0)
|
||||
_alloc_worst = max(p2p(close_loop(g, s, feedback=f))
|
||||
for g in _GAINS for s in _splits for f in ("site", "unit"))
|
||||
# ⚠️ THE BROADCAST RUNS ARE FED ONE INVERTER'S POWER, because that is what a
|
||||
# broadcast INSTALL would have: the add-on binds a single `inverter_power`
|
||||
# sensor, so an operator who wires up a second inverter has a loop that sees
|
||||
# unit A only. Feeding it the site sum here would model an add-on that does not
|
||||
# exist, and it would do so in the KIND direction - the sum tracks the command
|
||||
# much worse at N=2, so the saturation freeze would fire and damp the very limit
|
||||
# cycle this comparison exists to reproduce. sitesim.run_site() makes the same
|
||||
# choice for the same reason, and the two agree to the watt: 40 W p2p at gain
|
||||
# 0.8, 120 W at 0.9, 4000 W at 1.0.
|
||||
_bcast = {g: p2p(close_loop(g, 0.5, mode="broadcast", feedback="unit"))
|
||||
for g in _GAINS}
|
||||
print(f" broadcast tail p2p by gain: "
|
||||
+ ", ".join(f"{g}={v:.0f}" for g, v in _bcast.items()))
|
||||
print(f" allocated, worst over {len(_GAINS)}x{len(_splits)}x2 "
|
||||
f"(gain x split x feedback): {_alloc_worst:.0f} W")
|
||||
check("⚠️ AC 3. SWEEPING THE GAIN TO 1.0 - WHICH config.yaml ACCEPTS AS ITS "
|
||||
"DOCUMENTED MAXIMUM - AT EVERY SPLIT SHOWS NO LIMIT CYCLE. The tail "
|
||||
"settles to a flat line at all 84 combinations - at BOTH the site-level "
|
||||
"feedback the loop should have and the single-sensor feedback it has",
|
||||
_alloc_worst == 0.0)
|
||||
check("⚠️ AND THE SWEEP IS NOT VACUOUS, because the SAME sweep reproduces the "
|
||||
"fault at the same gain. Broadcast at gain 1.0 is a permanent limit cycle "
|
||||
"- this is ARCH-01's measured 4000 W peak-to-peak, rebuilt here from the "
|
||||
"shipped control law rather than quoted",
|
||||
_bcast[1.0] >= 3900.0)
|
||||
check("...and the fault has an ONSET, which says it is a gain-margin failure "
|
||||
"and not a bug in the rig: quiet at 0.6, visible at 0.9, unbounded at "
|
||||
"1.0. An allocator that 'fixed' a cycle that was never there would show a "
|
||||
"flat broadcast row",
|
||||
_bcast[0.6] == 0.0 and 0.0 < _bcast[0.9] < _bcast[1.0])
|
||||
check("...and the broadcast export overshoot at gain 0.6 - the half that has no "
|
||||
"gain threshold at all - is present here too, at ARCH-01's measured "
|
||||
"-400 W, while the allocated run does not export",
|
||||
min(g for _t, g in close_loop(0.6, 0.5, mode="broadcast",
|
||||
feedback="unit")[1:]) <= -400.0
|
||||
and min(g for _t, g in close_loop(0.6, 0.5)[1:]) >= 0.0)
|
||||
|
||||
# ⚠️ THE ONE THAT DOES NOT HOLD, AND IT IS REPORTED RATHER THAN HIDDEN.
|
||||
_unit_fb = {s: close_loop(0.6, s, feedback="unit")
|
||||
== close_loop(0.6, 0.5, feedback="unit") for s in _splits}
|
||||
print(f" with the loop fed ONE inverter's power instead of the site's, at "
|
||||
f"gain 0.6: " + ", ".join(f"{int(s*100)}/{int((1-s)*100)}="
|
||||
f"{'same' if v else 'DIFFERS'}"
|
||||
for s, v in _unit_fb.items()))
|
||||
check("⚠️ SPLIT-INVARIANCE IS A PROPERTY OF THE LOOP'S INPUTS, NOT ONLY OF THE "
|
||||
"ALLOCATOR, AND THE SHIPPED ADD-ON BREAKS IT FROM THE OTHER END. There is "
|
||||
"ONE `inverter_power` sensor, so at a roster of two `actual_w` is ONE "
|
||||
"unit's power used as a site measurement - the SENSING half of the same "
|
||||
"N=1 identity this card removes from the COMMANDING half. Feed the loop "
|
||||
"that and the aggregate trace becomes a function of the split. The "
|
||||
"allocator is not the cause: its shares sum to S in every one of these "
|
||||
"runs. Measured on the real rig too (tools/alloc_sim.py --splits "
|
||||
"--gain 0.6), and it is DEV-02's to close",
|
||||
not all(_unit_fb.values()))
|
||||
check("...and the counterfactual is what says the sensor is the cause rather "
|
||||
"than the allocator: change that ONE input to the site total and every "
|
||||
"split is bit-identical again, at every gain",
|
||||
all(close_loop(g, s, feedback="site") == close_loop(g, 0.5,
|
||||
feedback="site")
|
||||
for g in (0.2, 0.4, 0.6, 1.0) for s in _splits))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. the structural claim itself: the gain at the meter is a CONVEX "
|
||||
"combination")
|
||||
|
||||
# ⚠️ THIS IS THE ARGUMENT THE WHOLE CARD RESTS ON, SO IT IS MEASURED. Give the
|
||||
# two modelled units DIFFERENT per-unit gains - unit i delivers g_i of what it
|
||||
# is told - and read the gain the site actually shows. Under broadcast it is the
|
||||
# SUM. Under allocation it is a weighted average, so it lies between the two
|
||||
# whatever the weights are.
|
||||
def site_gain(g_a, g_b, split, mode):
|
||||
told = 1000.0
|
||||
if mode == "broadcast":
|
||||
return (g_a * told + g_b * told) / told
|
||||
p = plan(told, TWO, weights={A: split, Bee: 1.0 - split})
|
||||
return (g_a * p.shares[A] + g_b * p.shares[Bee]) / told
|
||||
|
||||
|
||||
_GA, _GB = 0.4, 1.0
|
||||
_alloc_gains = [site_gain(_GA, _GB, s, "alloc") for s in (0.0, 0.25, 0.5,
|
||||
0.75, 1.0)]
|
||||
print(f" per-unit gains {_GA} and {_GB}: allocated site gain over splits "
|
||||
f"0/25/50/75/100 = {[round(x, 3) for x in _alloc_gains]}; "
|
||||
f"broadcast = {site_gain(_GA, _GB, 0.5, 'broadcast'):.3f}")
|
||||
check("⚠️ FOR ANY SPLIT the gain at the meter lies between the two per-unit "
|
||||
"gains. THAT is why a bad split cannot destabilise the site and why the "
|
||||
"weights may be chosen for SoC, thermals and wear without anyone "
|
||||
"re-deriving a stability margin",
|
||||
all(_GA - 1e-9 <= g <= _GB + 1e-9 for g in _alloc_gains))
|
||||
check("...and under broadcast it is their SUM, which is outside that interval "
|
||||
"and grows with N. At two units of gain 1.0 that is 2.0 against a loop "
|
||||
"whose config maximum is 1.0 - the 2.12 ARCH-01 measured, and the reason "
|
||||
"the limit cycle has no upper bound",
|
||||
site_gain(_GA, _GB, 0.5, "broadcast") > _GB)
|
||||
check("...and the same holds for two units with the SAME gain, which is the "
|
||||
"case that will actually ship: allocation leaves it at g, broadcast "
|
||||
"doubles it",
|
||||
site_gain(0.8, 0.8, 0.3, "alloc") == 0.8
|
||||
and site_gain(0.8, 0.8, 0.3, "broadcast") == 1.6)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("10. the envelope is read PER TICK, and the weights are not a constraint")
|
||||
|
||||
_moving = {A: B(-RATING, RATING), Bee: B(-RATING, RATING)}
|
||||
_first = plan(2000.0, _moving)
|
||||
_moving[Bee] = B(-RATING, 0.0) # B's pack hits its floor mid-run
|
||||
_second = plan(2000.0, _moving)
|
||||
print(f" same allocator object, envelope moved between ticks: "
|
||||
f"{_first.shares} then {_second.shares}")
|
||||
check("⚠️ THE SAME CALL WITH A MOVED ENVELOPE GIVES A DIFFERENT ANSWER, which "
|
||||
"is what says nothing was cached at construction. There is no allocator "
|
||||
"OBJECT to cache in - `plan()` is a pure function of this tick's "
|
||||
"intervals - and an allocator holding a construction-time envelope "
|
||||
"over-allocates into a unit that cannot take it",
|
||||
_first.shares == {A: 1000, Bee: 1000}
|
||||
and _second.shares == {A: 2000, Bee: 0})
|
||||
check("...and the module holds no state at all, which is how that is enforced "
|
||||
"rather than promised: nothing in it is written outside a function",
|
||||
not [n for n, v in vars(sys.modules["app.allocator"]).items()
|
||||
if not n.startswith("_") and isinstance(v, (list, dict, set))])
|
||||
|
||||
_zero = plan(4000.0, {A: B(-RATING, 1000.0), Bee: B(-RATING, RATING)},
|
||||
weights={A: 1.0, Bee: 0.0})
|
||||
print(f" weight 0 on B, but A's envelope is full: {_zero.explain()}")
|
||||
check("a weight of 0 is honoured while the preferred unit has headroom",
|
||||
plan(1000.0, TWO, weights={A: 1.0, Bee: 0.0}).shares == {A: 1000, Bee: 0})
|
||||
check("⚠️ BUT IT IS A PREFERENCE, NOT A PERMISSION. When the preferred unit "
|
||||
"fills up the rest goes to the zero-weight unit anyway, because the SUM "
|
||||
"is the safety property and stranding capacity behind a comfort choice "
|
||||
"would manufacture a standing error at the meter for the site integrator "
|
||||
"to chase. To make a unit unusable, close its envelope",
|
||||
_zero.shares == {A: 1000, Bee: 3000} and _zero.unmet_w == 0)
|
||||
check("...and the record says that is what happened, rather than leaving a "
|
||||
"reader to wonder why the weights were disregarded",
|
||||
any(p.unit == Bee and "last resort" in p.why for p in _zero.plans))
|
||||
check("a negative weight is REFUSED rather than normalised away - it is a "
|
||||
"signed target wearing a comfort weight's clothes, and silently taking "
|
||||
"its magnitude would allocate against the direction the caller asked for",
|
||||
_raises_value(lambda: plan(1000.0, TWO, weights={A: -1.0, Bee: 1.0})))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("11. the seam above: what resolve() hands stage 3, and the pin gate")
|
||||
|
||||
_res = resolve([Claim.set("loop", P_LOOP, 900.0),
|
||||
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
|
||||
actuator=A),
|
||||
Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w",
|
||||
actuator=A)],
|
||||
units=(A, Bee))
|
||||
print(f" {_res.explain()} -> {_res.allocation.explain()}")
|
||||
check("resolve() carries the full record through, so SAFETY-28 has one place "
|
||||
"to gate on and does not have to re-derive anything",
|
||||
isinstance(_res.allocation, Allocation) and _res.allocation.ok
|
||||
and _res.shares == _res.allocation.shares)
|
||||
check("...and the priority stage 3 weighs a unit's own SET against is the "
|
||||
"priority of the claim `explain()` NAMES, taken from stage 2 rather than "
|
||||
"recomputed - so the two cannot drift apart",
|
||||
_res.winner_priority == P_LOOP and _res.winner == "loop")
|
||||
|
||||
# THE PIN GATE. A unit's own winning SET competes with the site's.
|
||||
_pin = {A: B(-RATING, RATING, gate=P_MAINTENANCE, pin=-1200.0, by="maintenance"),
|
||||
Bee: B(-RATING, RATING)}
|
||||
_low = plan(-3000.0, _pin, site_priority=P_LOOP)
|
||||
_high = plan(0.0, _pin, site_priority=P_SAFETY)
|
||||
print(f" maintenance pin vs the loop: {_low.explain()}")
|
||||
print(f" maintenance pin vs a safety stop: {_high.explain()}")
|
||||
check("a unit's own maintenance SET at P_MAINTENANCE outranks the grid-"
|
||||
"following loop, so that pack does what its own cycle asked and the OTHER "
|
||||
"unit carries the remainder of the site target. The shares still sum to S",
|
||||
_low.shares == {A: -1200, Bee: -1800}
|
||||
and sum(_low.shares.values()) == _low.sum_w)
|
||||
check("⚠️ AND A SITE SAFETY STOP OUTRANKS THE PIN, which is the case that has "
|
||||
"to be right: `safety` files a SITE set at P_SAFETY for 'stopped' and "
|
||||
"'inputs missing', and a maintenance drain that survived it would be an "
|
||||
"inverter this controller had stopped and then kept driving",
|
||||
_high.shares == {A: 0, Bee: 0})
|
||||
check("...and with no site priority named at all, the unit's own claim stands - "
|
||||
"a caller that says nothing about the site has not said the site outranks "
|
||||
"anything, and honouring the unit's highest-priority claim is the only "
|
||||
"safe reading of silence",
|
||||
plan(-3000.0, _pin).shares == {A: -1200, Bee: -1800})
|
||||
|
||||
# ⚠️ THE RESIDUAL, STATED RATHER THAN GLOSSED. `pre_resolve()` filters a unit's
|
||||
# LIMITS by that unit's OWN gate, and clause 2 of the arbiter's rule says a
|
||||
# limit binds only at or above the WINNER's priority - which at N>=2 may be the
|
||||
# site's, chosen afterwards. Where the site winner outranks a unit's gate, a
|
||||
# unit limit whose priority falls between them is applied more tightly than
|
||||
# clause 2 strictly requires. Enumerated over what main.py can actually file:
|
||||
_UNIT_LIMITS = {"device": P_SAFETY, "supervised": P_SAFETY,
|
||||
"maintenance": P_MAINT_SHAPE}
|
||||
_UNIT_SETS = {"failsafe": P_SAFETY, "maintenance": P_MAINTENANCE}
|
||||
_SITE_SETS = {"loop": P_LOOP, "arbitrage": P_ARBITRAGE, "safety": P_SAFETY}
|
||||
_affected = [(ls, lp, us, up, ss, sp)
|
||||
for ls, lp in _UNIT_LIMITS.items()
|
||||
for us, up in list(_UNIT_SETS.items()) + [(None, None)]
|
||||
for ss, sp in _SITE_SETS.items()
|
||||
# included by pre_resolve (no gate, or the gate does not exclude it)
|
||||
if (up is None or lp >= up)
|
||||
# but clause 2 against the SITE winner would have excluded it
|
||||
and lp < sp]
|
||||
print(f" unit-limit x unit-set x site-set combinations main.py can file: "
|
||||
f"{len(_UNIT_LIMITS) * (len(_UNIT_SETS) + 1) * len(_SITE_SETS)}; "
|
||||
f"tighter than clause 2 strictly requires: {len(_affected)}")
|
||||
for _row in _affected:
|
||||
print(f" {_row[0]}@{_row[1]} under {_row[2]}@{_row[3]} "
|
||||
f"vs site {_row[4]}@{_row[5]}")
|
||||
check("⚠️ THE ONLY COMBINATIONS AFFECTED ARE THE MAINTENANCE CHARGE-ONLY LIMIT "
|
||||
"UNDER A SITE SAFETY STOP, and a safety stop resolves to 0 W - a "
|
||||
"charge-only `hi` of 0 does not move 0. So the tighter reading is not "
|
||||
"observable on anything main.py can file today. It is written down here "
|
||||
"because a ninth source could change that, and because erring TIGHT is "
|
||||
"the safe direction: a tighter envelope allocates less, never more",
|
||||
all(row[0] == "maintenance" and row[4] == "safety" for row in _affected))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("12. mutation: what defeats these guards, and which check bites")
|
||||
|
||||
MUT = []
|
||||
|
||||
|
||||
def _largest_remainder_replaced_by_round(S, ws):
|
||||
return {u: round(w * S) for u, w in ws.items()}
|
||||
|
||||
|
||||
_m = _largest_remainder_replaced_by_round(1501.0, {A: 0.5, Bee: 0.5})
|
||||
MUT.append(("largest remainder replaced by a per-unit round()",
|
||||
sum(_m.values()) != 1501,
|
||||
"part 2: 'largest remainder does not miss it, ever'"))
|
||||
|
||||
# The envelope clamp dropped: shares placed by weight alone.
|
||||
_m2 = {A: 0.5 * 4000.0, Bee: 0.5 * 4000.0}
|
||||
MUT.append(("the envelope clamp dropped - shares placed by weight alone",
|
||||
_m2[A] > 1000.0 and plan(4000.0, {A: B(-RATING, 1000.0),
|
||||
Bee: B(-RATING, RATING)}).shares[A]
|
||||
== 1000,
|
||||
"part 3: 'one inverter at its rating gives its surplus to the other'"))
|
||||
|
||||
# The liveness argument ignored - `live` never consulted.
|
||||
MUT.append(("`live` ignored, so a stale master keeps being allocated into",
|
||||
plan(1990.0, TWO, live=(A,)).shares != plan(1990.0, TWO).shares,
|
||||
"part 5: 'a unit whose master is stale is REMOVED'"))
|
||||
|
||||
# The sign gate removed - the circulating case allocated instead of refused.
|
||||
MUT.append(("the sign-coherence gate removed",
|
||||
_circ.ok is False,
|
||||
"part 6: 'opposite directions are REFUSED, not averaged'"))
|
||||
|
||||
# The N<=1 branch folded into the general path.
|
||||
MUT.append(("the N<=1 pass-through folded into the general path (it would "
|
||||
"round, and clamp)",
|
||||
allocate(1500.4, {A: UnitBounds()})[0][A] == 1500.4
|
||||
and allocate(9999.0, {A: B(-RATING, RATING)})[0][A] == 9999.0,
|
||||
"part 7: 'a float, unrounded' and 'unclamped too'"))
|
||||
|
||||
# The allocator broadcasts - the ARCH-01 fault, rebuilt.
|
||||
_bc = {u: 1990 for u in TWO}
|
||||
MUT.append(("the allocator replaced by a broadcast - ARCH-01's own fault",
|
||||
sum(_bc.values()) != 1990 and plan(1990.0, TWO).unmet_w == 0,
|
||||
"part 1: 'the halves sum back to it', and part 8's sweep"))
|
||||
|
||||
# unmet_w folded into ok.
|
||||
MUT.append(("`unmet_w != 0` folded into `ok`, so a saturated inverter parks "
|
||||
"the site",
|
||||
_unmet.ok is True and _unmet.unmet_w == 6000,
|
||||
"part 3: 'a saturated site is not a broken allocation'"))
|
||||
|
||||
# The pin gate inverted: the site winner never outranks a unit SET.
|
||||
MUT.append(("the pin gate inverted - a maintenance drain survives a site "
|
||||
"safety stop",
|
||||
_high.shares == {A: 0, Bee: 0},
|
||||
"part 11: 'a site safety stop outranks the pin'"))
|
||||
|
||||
# scope_errors ignored by stage 3.
|
||||
_mis = resolve([Claim.set("loop", P_LOOP, -1500.0),
|
||||
Claim.limit("peak", P_PEAK, lo=1500.0, reason="q",
|
||||
actuator=Bee)])
|
||||
MUT.append(("`scope_errors` ignored by stage 3 - a mis-routed claim divided "
|
||||
"anyway",
|
||||
_mis.allocated is False and REFUSE_SCOPE in _mis.allocation.refused,
|
||||
"test_arbiter.py part 8: 'stage 3 REFUSES to allocate on a claim "
|
||||
"list it has been told is mis-routed'"))
|
||||
|
||||
MUT.append(("the site target TRUNCATED instead of rounded - a bias toward zero "
|
||||
"on every tick",
|
||||
max(map(abs, _q)) <= 0.5,
|
||||
"part 2: '|quantisation| is never more than half a watt'"))
|
||||
|
||||
# ⚠️ AND NOW THE ONES THAT DO NOT BITE. Three of them, found by actually running
|
||||
# the mutations rather than by reasoning about them, and each is reported with
|
||||
# WHY - because a mutation table that only lists its successes is the same
|
||||
# unverifiable claim as a green check that cannot fail.
|
||||
MUT.append(("the FLOOR replaced by a round() before the leftover pass",
|
||||
False,
|
||||
"NOTHING - and it should not, because THE SUM IS NOT PROTECTED BY "
|
||||
"THE FLOOR. It is protected by the integer leftover pass that "
|
||||
"follows: whatever per-unit integerisation happens first, `debt = "
|
||||
"remainder - sum(share)` is computed from it and handed out until "
|
||||
"it is zero. Rounding first produces a DIFFERENT but still exactly "
|
||||
"sum-invariant allocation. Largest remainder is what decides WHICH "
|
||||
"unit is credited the leftover watt - the one that was shorted "
|
||||
"most - which is a fairness property, not a safety one. Remove the "
|
||||
"leftover pass itself and part 1 goes red, which is the row above"))
|
||||
MUT.append(("the largest-remainder TIE-BREAK on the unit name dropped",
|
||||
False,
|
||||
"NOTHING - and by construction: `plan()` sorts the roster before it "
|
||||
"does anything else and Python's sort is stable, so the order is "
|
||||
"already by name when the tie-break is reached. The determinism is "
|
||||
"delivered upstream; the explicit key is belt and braces. Part 2's "
|
||||
"dict-order check is what actually grades the property"))
|
||||
# ⚠️ THE ONE THAT DOES NOT BITE, AND IT IS STATED RATHER THAN HIDDEN.
|
||||
MUT.append(("the WEIGHTS replaced by any other non-negative numbers",
|
||||
False,
|
||||
"NOTHING - AND BY DESIGN. That is AC 2: the aggregate trace at the "
|
||||
"meter is identical for every split, so no check here can tell one "
|
||||
"weighting from another by watching the site. The weights are "
|
||||
"graded by what they do to the SHARES (parts 2 and 10), never by "
|
||||
"what they do to the meter - and the fact that nothing at the "
|
||||
"meter can see them is the property, not a gap"))
|
||||
|
||||
for _name, _bites, _where in MUT:
|
||||
print(f" {'caught' if _bites else 'NOT CAUGHT':>10} {_name}")
|
||||
print(f" {_where}")
|
||||
check("the mutations that CAN disarm these guards are each caught by a check "
|
||||
"above - a guard whose checks survive its own defeat is not a guard. "
|
||||
"Every row here was RUN through tools/mutation_run.sh on a purged tree, "
|
||||
"not reasoned about: three of them were reported as caught by an earlier "
|
||||
"draft of this table and were not",
|
||||
all(b for _n, b, _w in MUT[:-3]))
|
||||
check("...and the three that are NOT caught are reported as such, with the "
|
||||
"reason each one cannot be. Two are properties delivered somewhere other "
|
||||
"than the line the mutation touches; the third is the acceptance "
|
||||
"criterion itself - a weighting that changed the aggregate trace would be "
|
||||
"a FAILURE of AC 2, and part 8 is the line that would go red",
|
||||
all(b is False for _n, b, _w in MUT[-3:]))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -334,21 +334,54 @@ check("...and the SET's value travels BESIDE the interval, not inside it. "
|
||||
and owned.gate == P_MAINTENANCE and not owned.contradiction)
|
||||
|
||||
print()
|
||||
print("ARB-03 7. the seam is a seam, and NOT an allocator")
|
||||
print("ARB-03 7 / ALLOC-01: the seam, and what now occupies it")
|
||||
# ⚠️ THIS PART WAS "the seam is a seam, and NOT an allocator" AND IT GRADED THE
|
||||
# DECLINE. ALLOC-01 filled the seam, so the decline at N>=2 is gone - and the
|
||||
# checks that graded it are RE-AIMED rather than deleted, because the two halves
|
||||
# they were really about are both still true and both still worth a line: the
|
||||
# N<=1 pass-through is byte-identical, and the seam still does not BROADCAST.
|
||||
check("with no units the whole target goes to the site actuator",
|
||||
allocate(1500.0, {}) == ({ACTUATOR_SITE: 1500.0}, True))
|
||||
check("with one unit the share IS the site target - §6.2's N=1 identity, "
|
||||
"pass-through", allocate(1500.0, {"inverter-a": UnitBounds()})
|
||||
"pass-through. ⚠️ A FLOAT, UNROUNDED AND UNCLAMPED: this is the "
|
||||
"compatibility promise every N=1 replay in this repository depends on, "
|
||||
"and the general path below does neither of those things",
|
||||
allocate(1500.0, {"inverter-a": UnitBounds()})
|
||||
== ({"inverter-a": 1500.0}, True))
|
||||
check("...and it is byte-identical on a fractional target too, which is where a "
|
||||
"'tidy this into the general path' refactor would show up: the general "
|
||||
"path rounds to the 1 W register, the pass-through must not",
|
||||
allocate(1500.4, {"inverter-a": UnitBounds()})
|
||||
== ({"inverter-a": 1500.4}, True))
|
||||
_shares, _ok = allocate(1500.0, {"inverter-a": UnitBounds(),
|
||||
"inverter-b": UnitBounds()})
|
||||
print(f" two units, no allocator: shares={_shares} allocated={_ok}")
|
||||
check("with two it DECLINES rather than inventing a split. ⚠️ THE OBVIOUS "
|
||||
"PLACEHOLDER - hand the target to each - IS THE FAULT ARCH-01 WAS FILED "
|
||||
"ABOUT: each inverter supplies it in full, the gain at the meter becomes "
|
||||
"N times the per-unit gain, and no watchdog in the table fires",
|
||||
_shares == {} and _ok is False)
|
||||
check("...and specifically it does not broadcast", 1500.0 not in _shares.values())
|
||||
print(f" two units, ALLOC-01: shares={_shares} allocated={_ok}")
|
||||
check("⚠️ AND THE ONE THING THE SEAM MUST NEVER DO, WHICH IS THE WHOLE OF "
|
||||
"ARCH-01: it does not BROADCAST. Hand the site target to each inverter "
|
||||
"and each supplies it IN FULL - the gain at the meter becomes N times the "
|
||||
"per-unit gain, which admin/goodwe's two-master rig measures as a 4000 W "
|
||||
"peak-to-peak limit cycle at gain 1.0 with not one guard firing. This "
|
||||
"line went red on ARB-03's decline and it goes red on a broadcast, which "
|
||||
"is the point: it grades the OUTPUT, not the mechanism",
|
||||
1500.0 not in _shares.values())
|
||||
check("...it allocates instead, and the shares SUM to the site target exactly. "
|
||||
"That is the safety property: sum(s_i) = S is what makes the gain at the "
|
||||
"meter a convex combination of the per-unit gains for ANY split, so the "
|
||||
"weights are a comfort choice and a bad split cannot destabilise the site",
|
||||
_ok is True and sum(_shares.values()) == 1500
|
||||
and sorted(_shares) == ["inverter-a", "inverter-b"])
|
||||
check("...and an odd target still sums exactly, which is the half a per-unit "
|
||||
"round() gets wrong: 1501/2 is 750.5 twice, and two roundings of that are "
|
||||
"either 1500 or 1502. Largest remainder gives 751 and 750",
|
||||
sum(allocate(1501.0, {"inverter-a": UnitBounds(),
|
||||
"inverter-b": UnitBounds()})[0].values()) == 1501)
|
||||
check("⚠️ AND THE DECLINE IS NOT GONE, IT IS RE-AIMED. It is still correct when "
|
||||
"no safe split exists at all, and PR #40's reviewer proved that branch was "
|
||||
"unreachable and untested once already - removing its trigger without "
|
||||
"giving it a new one would rebuild that hole. Here: every inverter's "
|
||||
"master stale, so there is no envelope set to allocate into",
|
||||
allocate(1500.0, {"inverter-a": UnitBounds(),
|
||||
"inverter-b": UnitBounds()}, live=()) == ({}, False))
|
||||
|
||||
# ⚠️ AND `resolve()` HAS TO HONOUR THE ROSTER IT IS GIVEN, WHICH IS NOT THE SAME
|
||||
# THING AS HAVING A `units` PARAMETER. Left None the roster is DERIVED from the
|
||||
@@ -370,13 +403,17 @@ print(f" roster of two, only inverter-a files: bounds="
|
||||
check("resolve() HONOURS the roster it is handed rather than inferring one from "
|
||||
"who happened to speak. inverter-b filed nothing this cycle and is still "
|
||||
"a unit: it gets its own interval, and the site is still a site of TWO, "
|
||||
"so the seam still DECLINES",
|
||||
"so the target is SPLIT across two inverters",
|
||||
sorted(_silent.unit_bounds) == ["inverter-a", "inverter-b"]
|
||||
and _silent.allocated is False and _silent.shares == {})
|
||||
and _silent.shares == {"inverter-a": 450, "inverter-b": 450})
|
||||
check("...and that is the whole cost of ignoring the argument, stated as the "
|
||||
"number it would produce: derive the roster from the claims instead and "
|
||||
"the site is a site of ONE, so the seam passes 900 W through to "
|
||||
"inverter-a - the silent inverter's absence read as a smaller site",
|
||||
"the site is a site of ONE, so the seam passes ALL 900 W through to "
|
||||
"inverter-a - the silent inverter's absence read as a smaller site, and "
|
||||
"inverter-a told to do the whole site's work on its own. ⚠️ ALLOC-01 "
|
||||
"makes this mutation WORSE, not better: before, both answers refused, so "
|
||||
"only the roster listing distinguished them; now the wrong one produces a "
|
||||
"plausible number that is twice what inverter-a should be given",
|
||||
resolve([Claim.set("loop", P_LOOP, 900.0),
|
||||
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
|
||||
actuator="inverter-a")]).shares
|
||||
@@ -424,6 +461,35 @@ check("and the eight, routed by actuator_for() at a two-inverter site, are "
|
||||
actuator=actuator_for(s, "inverter-a", TWO))
|
||||
for s in CLAIM_SCOPE]) == [])
|
||||
|
||||
# --- ALLOC-01: AND NOW SOMETHING HAPPENS ABOUT IT ---------------------------
|
||||
# ⚠️ ARB-03 LEFT THIS DELIBERATELY INERT - `scope_violations()` logged and did
|
||||
# not change the resolution, and nothing graded the consequence. ALLOC-01 had to
|
||||
# decide, and decided the allocator CONSUMES it as a REFUSAL rather than as an
|
||||
# input. The reasoning is at `allocator.plan()`'s REFUSAL 1 and the short form
|
||||
# is: the mis-routed claim has already been partitioned by the time stage 3
|
||||
# runs, so its effect is baked into S or into somebody's interval; "consuming"
|
||||
# it as an allocation input would mean RE-ROUTING it, which needs a guess about
|
||||
# which inverter it belonged to that nothing here can make. What is left is that
|
||||
# S itself may be wrong, and dividing a number that may be wrong is worse than
|
||||
# not dividing it.
|
||||
print(f" and stage 3's answer to it: {r_mis.allocation.explain()}")
|
||||
check("⚠️ the mis-routing is no longer only a log line: stage 3 REFUSES to "
|
||||
"allocate on a claim list it has been told is mis-routed, so main.py "
|
||||
"fails toward inaction and shouts instead of dividing a number that may "
|
||||
"be wrong. That is the same answer clause 3 already gives a contradiction",
|
||||
r_mis.allocated is False and r_mis.shares == {}
|
||||
and "mis-routed" in r_mis.allocation.refused)
|
||||
check("...and it is NOT consumed as an ENVELOPE, which is the tempting reading "
|
||||
"and the wrong one: a unit-scoped claim that landed on the site names no "
|
||||
"unit, so promoting it means either guessing one or applying one "
|
||||
"inverter's rating to a sibling that does not have it. Nothing here is "
|
||||
"promoted - the refusal carries no shares at all",
|
||||
r_mis.allocation.plans == () and r_mis.allocation.dropped == ())
|
||||
check("...and a correctly routed cycle is unaffected, which is what says this "
|
||||
"grades ROUTING and not the presence of units: the same site claims with "
|
||||
"the floor where it belongs allocate normally",
|
||||
resolve(SITE_SIDE, units=TWO).allocated is True)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -65,6 +65,7 @@ Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import math
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -73,6 +74,7 @@ from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from app.arbiter import UnitBounds, allocate
|
||||
from app.control import Tuning, compute
|
||||
from app.main import Controller
|
||||
|
||||
@@ -558,6 +560,56 @@ check("an in-range value is untouched", hass_d.writes[3] == 1500.0)
|
||||
check("every emitted frame satisfies |emitted| <= the rating",
|
||||
all(abs(w) <= 2000.0 for w in hass_d.writes))
|
||||
|
||||
# --- 5b. ALLOC-01: AND THE ALLOCATOR DID NOT ADD ONE ------------------------
|
||||
# ⚠️ "NO PATH REACHES THE WIRE AROUND THE CLAMP" IS A CLAIM ABOUT THE WHOLE
|
||||
# ADD-ON, so it has to be re-made every time a module learns to produce a number
|
||||
# that looks like a setpoint. ALLOC-01 added `app/allocator.py`, whose output is
|
||||
# a dict of per-inverter watts - numbers that are exactly the shape of a thing
|
||||
# somebody would write - and the temptation it creates is real: a second writer
|
||||
# for a second inverter is DEV-02's card, and the wrong way to build it is a
|
||||
# `set_number` call next to the allocation.
|
||||
#
|
||||
# Two checks, and neither is about the allocator's arithmetic:
|
||||
# * it CANNOT write, and that is established from its imports rather than
|
||||
# from its behaviour, because a behavioural check only sees the paths a
|
||||
# fixture happens to take;
|
||||
# * at the shipped roster the number that reaches the wire is still the
|
||||
# arbiter's `target_w`, unclamped by the allocator, so SAFETY-03's clamp is
|
||||
# still the binding one and not a second opinion after a first clamp.
|
||||
import app.allocator as _alloc_mod # noqa: E402
|
||||
|
||||
_alloc_src = io.open(_alloc_mod.__file__, encoding="utf-8").read()
|
||||
_alloc_imports = sorted(
|
||||
n.split(".")[0]
|
||||
for line in _alloc_src.splitlines() if line.startswith(("import ", "from "))
|
||||
for n in [line.split()[1]])
|
||||
print(f" app/allocator.py imports: {_alloc_imports}")
|
||||
check("the allocator imports NOTHING that can reach the wire - no `hass`, no "
|
||||
"`aiohttp`, not even `main`. It is a pure function of intervals and a "
|
||||
"number, so 'it cannot write' is a property of the file rather than of "
|
||||
"the paths a fixture happened to take",
|
||||
_alloc_imports == ["dataclasses", "math"])
|
||||
check("...and it never mentions the setpoint entity, `set_number`, or the "
|
||||
"clamp - the three names any bypass would have to spell",
|
||||
not any(n in _alloc_src for n in ("set_number", "setpoint_entity",
|
||||
"clamp_to_rating")))
|
||||
check("...and it must not: it has no measurement either. The allocator reading "
|
||||
"P1 would be a SECOND integrator on the site measurement, which is the "
|
||||
"same two-writers failure one level up",
|
||||
not any(n in _alloc_src for n in ("p1", "P1Ingest", "grid_w")))
|
||||
|
||||
check("at the shipped roster of ONE the allocator is a pass-through, so the "
|
||||
"number handed to write_setpoint() is the arbiter's own target and "
|
||||
"SAFETY-03's clamp is still the FIRST and only clamp it meets. An "
|
||||
"allocator that pre-clamped here would leave this file grading a value "
|
||||
"that was already in range, and the clamp untested on the live path",
|
||||
allocate(9999.0, {"inverter-a": UnitBounds()}) == ({"inverter-a": 9999.0},
|
||||
True))
|
||||
check("...and that unclamped 9999 W is exactly what part 5 above just proved "
|
||||
"the write path stops at the rating. The two halves meet here: the "
|
||||
"allocator does not clamp, the wire does",
|
||||
hass_d.writes[0] == 2000.0)
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("6. TEST-11: the guard's evidence is THIS cycle's write, not the last one")
|
||||
|
||||
@@ -114,6 +114,12 @@ from math import inf, isinf
|
||||
from pathlib import Path
|
||||
|
||||
from app import peakguard
|
||||
# ⚠️ THE DROP AND REFUSAL REASONS ARE IMPORTED, NOT TRANSCRIBED. A check that
|
||||
# matched the English would stay green against an allocator that renamed the
|
||||
# case out from under it, which is this repository's own named failure mode.
|
||||
from app.allocator import (
|
||||
DROP_STALE as ALLOC_DROP_STALE, REFUSE_EMPTY as ALLOC_REFUSE_EMPTY,
|
||||
)
|
||||
from app.arbiter import (
|
||||
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK,
|
||||
P_SAFETY, SCOPE_SITE, SCOPE_UNIT, Claim, resolve, scope_violations,
|
||||
@@ -2658,6 +2664,7 @@ try:
|
||||
_n2_events = list(c_src.events)
|
||||
_n2_writes = c_src.hass.writes[_n2_writes_before:]
|
||||
_n2_last = c_src.last_write
|
||||
_n2_alloc = c_src.allocation
|
||||
finally:
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
print(" a REAL two-inverter cycle files: "
|
||||
@@ -2683,37 +2690,64 @@ check("...and naming those three is not an accident of this fixture but the "
|
||||
"arbitrage"})
|
||||
|
||||
|
||||
# --- 10h. ARB-03: AND WHAT MAIN.PY DOES WITH `allocated=False` --------------
|
||||
# ⚠️ AN UNREACHABLE BRANCH THAT NOTHING GRADED. The roster ships as one unit, so
|
||||
# `allocate()` never declines in production and no fixture went looking: PR
|
||||
# #40's reviewer replaced main.py's `if not resolution.allocated:` with
|
||||
# `if False:` and the whole 17-file suite stayed GREEN. The branch is the right
|
||||
# instinct - the alternative default is the fault ARCH-01 was filed about, N
|
||||
# inverters each supplying the site target IN FULL, a permanent limit cycle at
|
||||
# the meter with every §11.2 watchdog reporting healthy - and an ungraded right
|
||||
# instinct is one refactor away from being deleted as dead code. The
|
||||
# two-inverter cycle above IS that state, so it is graded on that cycle rather
|
||||
# than with a fixture of its own.
|
||||
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
|
||||
# ⚠️ THIS PART WAS WRITTEN AGAINST `allocated=False` AND HAS BEEN RE-AIMED, NOT
|
||||
# DELETED. ARB-03's `allocate()` declined at every roster of two or more,
|
||||
# because it had no allocator and the obvious placeholder - hand the site target
|
||||
# to each inverter - IS the ARCH-01 fault. ALLOC-01 filled the seam, so that
|
||||
# trigger is gone. The branch is NOT, and this part is why it had better not be:
|
||||
# PR #40's reviewer replaced main.py's `if not resolution.allocated:` with
|
||||
# `if False:` and the whole 17-file suite stayed GREEN, so an unreachable-but-
|
||||
# right instinct was one refactor away from being deleted as dead code.
|
||||
# Removing the trigger without giving the branch a new one would rebuild that
|
||||
# hole exactly.
|
||||
#
|
||||
# ⚠️ AND WHAT IT DOES IS SUBSTITUTE, NOT ABSTAIN. main.py does not skip the
|
||||
# write. It replaces the resolution with a 0 W `failsafe` and then calls
|
||||
# `write_setpoint(0.0)` like any other cycle. 0 W is the correct
|
||||
# fail-toward-inaction direction and the log line is accurate about what it
|
||||
# refuses ("refusing to broadcast the site target"), but "main.py refuses to
|
||||
# write" would be the same overclaim this part just took out of 10g, so it is
|
||||
# graded as what it is: a substitution followed by a write.
|
||||
print(f" at N=2 main.py resolved to {_n2_target:+.0f} W, reason "
|
||||
# THERE ARE NOW TWO REFUSALS AND THEY ARE DIFFERENT REFUSALS, so all three
|
||||
# states below are driven as REAL CYCLES through `Controller.cycle()`:
|
||||
#
|
||||
# 10h-1 the allocator SUCCEEDS and this controller cannot deliver the split.
|
||||
# 10h-2 one inverter's master is stale: the survivor carries the whole site
|
||||
# target, WHICH IS DELIVERABLE, and the drop is announced (AC 6).
|
||||
# 10h-3 every master is stale: no envelope set, so the allocator itself
|
||||
# refuses and `allocated=False` is reached for real.
|
||||
#
|
||||
# ⚠️ AND 10h-1 IS THE GUARD ALLOC-01 HAD TO ADD RATHER THAN ONE IT INHERITED.
|
||||
# Making `allocate()` succeed at N>=2 would otherwise have been a REGRESSION:
|
||||
# there is one `setpoint_entity` in this add-on, so a controller handed a valid
|
||||
# 450/450 split and a roster of two would have written `self.target` - 900, the
|
||||
# SITE number - to the single inverter it can reach, while a second sat
|
||||
# uncommanded. Not the broadcast fault, but the same shape of over-command, and
|
||||
# the allocator's own arithmetic says the share is half of it.
|
||||
print(f" at N=2 the allocator produced {_n2_alloc.explain()}")
|
||||
print(f" and main.py resolved to {_n2_target:+.0f} W, reason "
|
||||
f"{_n2_reason!r}, wrote {_n2_writes}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _n2_events if "ARBITER" in e), "(nothing)"))
|
||||
check("with two inverters and no allocator main.py does NOT hand the site "
|
||||
"target to the roster. The resolution it acts on is the 0 W failsafe it "
|
||||
check("⚠️ THE ALLOCATOR NOW SUCCEEDS AT A ROSTER OF TWO, and the shares SUM to "
|
||||
"the site target exactly rather than repeating it. That sum is the whole "
|
||||
"safety property: it makes the gain at the meter a convex combination of "
|
||||
"the per-unit gains, where a broadcast makes it N times the per-unit "
|
||||
"gain - 4000 W peak-to-peak forever at gain 1.0 on admin/goodwe's "
|
||||
"two-master rig, with not one guard firing",
|
||||
_n2_alloc.ok and sorted(_n2_alloc.shares) == ["inverter-a", "inverter-b"]
|
||||
and sum(_n2_alloc.shares.values()) == _n2_alloc.sum_w
|
||||
and _n2_alloc.unmet_w == 0)
|
||||
check("...and it did NOT broadcast: neither share is the site target, and "
|
||||
"THAT is the line a placeholder `{u: target for u in units}` goes red on",
|
||||
all(v != _n2_alloc.sum_w for v in _n2_alloc.shares.values())
|
||||
or _n2_alloc.sum_w == 0)
|
||||
check("⚠️ AND MAIN.PY REFUSES TO ACT ON A SPLIT IT CANNOT DELIVER. It drives "
|
||||
"ONE inverter - one `setpoint_entity`, one power sensor - and the "
|
||||
"allocation names two. Writing the site target to the one it can reach "
|
||||
"would over-command that inverter by exactly the other's share; "
|
||||
"commanding a partial site is a worse instruction than commanding "
|
||||
"nothing. So the resolution it acts on is the 0 W failsafe it "
|
||||
"substitutes, and `explain()` says so - this is the branch a `if False:` "
|
||||
"deleted with the suite still green",
|
||||
_n2_target == 0.0 and "failsafe" in _n2_reason)
|
||||
check("...and it says WHY, in the action log, in the operator's own words - "
|
||||
"the refusal is to BROADCAST, and it names the roster size that caused it",
|
||||
any("refusing to broadcast the site target" in e and "2 inverters" in e
|
||||
check("...and it says WHY, in the action log, in the operator's own words - it "
|
||||
"names the inverter it has no writer for and the card that gives it one",
|
||||
any("no writer for inverter-b" in e and "DEV-02" in e
|
||||
for e in _n2_events))
|
||||
check("...and then it WRITES that 0 W, on the same line as every other cycle. "
|
||||
"It is a substitution, not an abstention: the setpoint is commanded to "
|
||||
@@ -2727,6 +2761,87 @@ check("...and the 0 W is NOT what the site target happened to be - resolve the "
|
||||
resolve(_cl2, units=("inverter-a",)).allocated is True
|
||||
and resolve(_cl2, units=("inverter-a",)).target_w != 0.0)
|
||||
|
||||
# --- 10h-2. A DEAD LINK, AND THE SURVIVOR NOT ABSORBING IT SILENTLY ---------
|
||||
# ⚠️ STIMULATED THROUGH main.py's OWN SEAM, NOT THROUGH A PATCHED ARBITER.
|
||||
# `Controller.live_units()` is the production method the allocator's `live`
|
||||
# argument comes from; today it answers "the whole roster" because this
|
||||
# controller IS its one inverter's only link, and DEV-02 replaces its body with
|
||||
# a per-master freshness test. Overriding what it returns for one cycle is
|
||||
# therefore stimulating the state DEV-02 will produce, not mocking the thing
|
||||
# under test - and it is the only way to reach AC 6 through a real cycle before
|
||||
# DEV-02 lands.
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
_stale_writes_before = len(c_src.hass.writes)
|
||||
_real_live = c_src.live_units
|
||||
try:
|
||||
c_src.live_units = lambda: ("inverter-a",)
|
||||
_, _cl_stale = cycle_claims(lambda: run_cycle(c_src))
|
||||
_stale_target = c_src.target
|
||||
_stale_alloc = c_src.allocation
|
||||
_stale_events = list(c_src.events)
|
||||
_stale_writes = c_src.hass.writes[_stale_writes_before:]
|
||||
finally:
|
||||
c_src.live_units = _real_live
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
print(f" inverter-b's master stale: {_stale_alloc.explain()}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _stale_events if "ALLOCATOR" in e), "(nothing)"))
|
||||
check("a unit whose master is stale is REMOVED FROM THE ENVELOPE SET rather "
|
||||
"than allocated into. An allocator that kept commanding a link it cannot "
|
||||
"reach is writing into a void and calling the site target met",
|
||||
_stale_alloc.dropped == (("inverter-b", ALLOC_DROP_STALE),)
|
||||
and "inverter-b" not in _stale_alloc.shares)
|
||||
check("...and the SURVIVOR CARRIES THE WHOLE SITE TARGET, which is right - "
|
||||
"there is one integrator on P1 and its output still has to be delivered - "
|
||||
"and it is now DELIVERABLE, so main.py writes it instead of failsafing. "
|
||||
"The two guards are distinguished by this line: a dead sibling is not the "
|
||||
"same event as a sibling with no writer",
|
||||
_stale_alloc.shares == {"inverter-a": _stale_alloc.sum_w}
|
||||
and _stale_target == float(_stale_alloc.sum_w)
|
||||
and _stale_writes == [_stale_target])
|
||||
check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY, which is the whole of AC 6 and of "
|
||||
"SAFETY-30. The action log names the inverter, the reason it left, and "
|
||||
"the consequence - that what remains now carries its share. A site "
|
||||
"running at half its inverters and reporting nothing reads as a healthy "
|
||||
"site to the operator and to every dashboard",
|
||||
any("inverter-b" in e and "stale link" in e and "carry" in e
|
||||
for e in _stale_events))
|
||||
|
||||
# --- 10h-3. EVERY MASTER STALE: THE ALLOCATOR'S OWN REFUSAL, REACHED --------
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
_dead_writes_before = len(c_src.hass.writes)
|
||||
try:
|
||||
c_src.live_units = lambda: ()
|
||||
_, _cl_dead = cycle_claims(lambda: run_cycle(c_src))
|
||||
_dead_target, _dead_reason = c_src.target, c_src.reason
|
||||
_dead_alloc = c_src.allocation
|
||||
_dead_events = list(c_src.events)
|
||||
_dead_writes = c_src.hass.writes[_dead_writes_before:]
|
||||
finally:
|
||||
c_src.live_units = _real_live
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
print(f" every master stale: {_dead_alloc.explain()}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _dead_events if "ARBITER" in e), "(nothing)"))
|
||||
check("⚠️ AND HERE `allocated=False` IS REACHED FOR REAL, ON A REAL CYCLE. "
|
||||
"With no inverter in the envelope set there is nobody to allocate to, "
|
||||
"and inventing a share for a link nobody can reach is the failure this "
|
||||
"branch exists for. The refusal path ARB-03 left behind is therefore "
|
||||
"still live code with a live trigger, which is what the ticket asked for "
|
||||
"when it said re-aim rather than delete",
|
||||
_dead_alloc.ok is False and _dead_alloc.shares == {}
|
||||
and ALLOC_REFUSE_EMPTY in _dead_alloc.refused)
|
||||
check("...and main.py substitutes the 0 W failsafe on it, writes that 0 W, and "
|
||||
"puts the allocator's OWN sentence in the action log rather than a "
|
||||
"paraphrase - so the operator reads which of the refusals fired",
|
||||
_dead_target == 0.0 and "failsafe" in _dead_reason
|
||||
and _dead_writes == [0.0]
|
||||
and any("ARBITER: REFUSED" in e and ALLOC_REFUSE_EMPTY in e
|
||||
for e in _dead_events))
|
||||
check("...and the roster it was restored to is the shipped one, so nothing "
|
||||
"below inherits a two-inverter Controller or a patched liveness",
|
||||
c_src.units == (app_main.UNIT_A,)
|
||||
and c_src.live_units() == (app_main.UNIT_A,))
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ALLOC-01's acceptance evidence, on admin/goodwe's OWN two-master rig.
|
||||
|
||||
python3 tools/alloc_sim.py --table # broadcast vs allocator, side by side
|
||||
python3 tools/alloc_sim.py --splits # AC 2: split-invariance at the meter
|
||||
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
|
||||
python3 tools/alloc_sim.py --all
|
||||
|
||||
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
|
||||
==========================================
|
||||
It needs admin/goodwe checked out beside this repository, and the add-on's CI
|
||||
job has no credentials for that repository - it is private and the job's token
|
||||
is minted for this one. A `test_*.py` here is globbed and RUN by
|
||||
.gitea/workflows/checks.yml, so a file that needs the sibling repo would either
|
||||
fail the gate or - worse, and this is what test_freeze.py does today - pass
|
||||
SILENTLY having checked nothing. So the rig evidence lives here, is quoted in
|
||||
the pull request, and test_alloc.py grades the same properties in CI against a
|
||||
plant it models itself and says it models.
|
||||
|
||||
WHAT IS REAL IN HERE
|
||||
====================
|
||||
Everything except the allocator's inputs. `sitesim.run_site()` is used
|
||||
UNMODIFIED: the add-on's own `compute()` closes the loop, the firmware's own
|
||||
encoder puts each command on each unit's wire, `plantsim.Plant` is the battery
|
||||
model, and `watchdog.SignWatchdog` is the shipped detector. The ONLY thing this
|
||||
file changes is `sitesim.allocate`, which it wraps so that a new mode name
|
||||
routes to `app.arbiter.allocate()` - the shipped allocator, imported, never
|
||||
transcribed - and delegates every other mode to the original. That wrapper is
|
||||
the whole diff between the RED run and the GREEN run, which is what makes the
|
||||
comparison worth anything.
|
||||
|
||||
⚠️ NOTHING IN admin/goodwe IS MODIFIED. `sim/` triggers a workflow that takes
|
||||
the physical rig's lock; this file reads that repository and writes nothing to
|
||||
it.
|
||||
|
||||
⚠️ INVERTER B'S PARAMETERS ARE PLACEHOLDERS. `plantsim.INVERTER_B` carries
|
||||
`measured=False` - it is A's numbers copied, because inverter B does not exist
|
||||
yet (SPIKE-04 measures it). Every verdict below that involves B is a verdict
|
||||
about the ARCHITECTURE, not about that inverter, and the runs say so.
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ADDON = HERE.parent.parent # the goodwe-addon checkout root
|
||||
|
||||
|
||||
def find_sim():
|
||||
"""admin/goodwe's `sim/` directory, or None - and the caller says which.
|
||||
|
||||
$GOODWE_SIM first, the same override test_freeze.py takes, then the sibling
|
||||
checkouts that actually occur. A missing sibling is not a failure of the
|
||||
allocator and must never be reported as one.
|
||||
"""
|
||||
cands = []
|
||||
if os.environ.get("GOODWE_SIM"):
|
||||
cands.append(pathlib.Path(os.environ["GOODWE_SIM"]))
|
||||
for up in (ADDON.parent, ADDON.parent.parent):
|
||||
cands += [up / "goodwe" / "sim", up / "goodwe-session" / "goodwe" / "sim"]
|
||||
for c in cands:
|
||||
if (c / "sitesim.py").exists():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def load(sim):
|
||||
"""Import the rig, pointed at THIS checkout, and graft our allocator in."""
|
||||
sys.path[:0] = [str(sim)]
|
||||
# inject.py resolves the add-on repo, and refuses to guess between two
|
||||
# checkouts. Naming it removes the guess: the tree under test is the tree
|
||||
# this file ships in, whatever else is lying around the disk.
|
||||
os.environ.setdefault("GOODWE_ADDON_REPO", str(ADDON))
|
||||
import inject, sitesim # noqa: E402
|
||||
arbiter = sitesim.load_addon("app.arbiter", "alloc_sim")
|
||||
allocator = sitesim.load_addon("app.allocator", "alloc_sim")
|
||||
|
||||
original = sitesim.allocate
|
||||
|
||||
def patched(target_w, n, mode):
|
||||
if not mode.startswith("alloc01"):
|
||||
return original(target_w, n, mode)
|
||||
# "alloc01" or "alloc01:90/10" - the weights, which AC 2 says must not
|
||||
# change the aggregate trace at the meter.
|
||||
names = [f"inverter-{c}" for c in "ab"[:n]]
|
||||
parts = mode.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
ws = [float(x) for x in parts[1].split("/")]
|
||||
else:
|
||||
ws = [1.0] * n
|
||||
bounds = {u: arbiter.UnitBounds(lo=-r, hi=r)
|
||||
for u, r in zip(names, patched.ratings)}
|
||||
shares, ok = arbiter.allocate(
|
||||
target_w, bounds, weights=dict(zip(names, ws)))
|
||||
if not ok:
|
||||
# A refusal on this rig would be a bug in the experiment, not a
|
||||
# finding: nothing here is stale, mis-routed or pinned. Fail loud.
|
||||
raise SystemExit(f"alloc_sim: the allocator REFUSED at {target_w} W "
|
||||
"- no scenario below should reach a refusal")
|
||||
return [float(shares[u]) for u in names]
|
||||
|
||||
# The envelope handed to the allocator each tick: the rating each MODELLED
|
||||
# inverter actually carries, read off plantsim rather than typed here, so a
|
||||
# change to INVERTER_A - or a --b-rating override - moves the envelope with
|
||||
# it instead of leaving this file quietly wrong. ⚠️ INVERTER_B's IS A
|
||||
# PLACEHOLDER; nothing here has measured it.
|
||||
from plantsim import INVERTER_A, INVERTER_B
|
||||
patched.ratings = [INVERTER_A["rating_w"], INVERTER_B["rating_w"]]
|
||||
sitesim.allocate = patched
|
||||
return inject, sitesim, arbiter, allocator
|
||||
|
||||
|
||||
def run(sitesim, control, watchdog, mode, gain, **kw):
|
||||
return sitesim.run_site(control, watchdog, n=2, gain=gain, mode=mode,
|
||||
label=f"N=2 gain {gain} {mode}", **kw)
|
||||
|
||||
|
||||
def run_site_feedback(sitesim, control, mode, gain, load_w=2000.0, cycles=40,
|
||||
cycle_s=None, dt=0.05):
|
||||
"""`sitesim.run_site()` WITH EXACTLY ONE LINE CHANGED, and it is named here.
|
||||
|
||||
⚠️ THIS IS A COPY AND IT IS A COPY ON PURPOSE. `run_site` feeds `compute()`
|
||||
ONE inverter's power as `actual_w`, and its docstring says why: the shipped
|
||||
add-on binds a single `inverter_power` sensor, so an operator who wires up a
|
||||
second inverter has a loop that sees unit A only, and a rig that fed the sum
|
||||
would be KINDER than the deployment. That is correct for grading the
|
||||
broadcast fault and it is exactly the variable this experiment has to move,
|
||||
because it is the one input to the loop that is a function of the SPLIT.
|
||||
|
||||
So this differs from `run_site` in one expression - `site.battery`, the
|
||||
site's total inverter power, instead of `inverters[0].plant.actual` - and in
|
||||
nothing else. It is not evidence about the shipped add-on; it is the
|
||||
counterfactual that says WHICH of the two candidate causes explains the
|
||||
split-dependence that `--splits` measures at gain 0.6. The add-on does not
|
||||
have this sensor yet. DEV-02 is where it would come from.
|
||||
"""
|
||||
from plantsim import INVERTER_A, INVERTER_B, Inverter, Site
|
||||
cycle_s = sitesim.CYCLE_S if cycle_s is None else cycle_s
|
||||
specs = [("A", INVERTER_A), ("B", INVERTER_B)]
|
||||
inverters = [Inverter.from_spec(n, dict(sp, **sitesim.INSTANT))
|
||||
for n, sp in specs]
|
||||
site = Site(inverters, load=load_w)
|
||||
loop = sitesim.Loop(control, gain)
|
||||
samples = []
|
||||
for c in range(cycles):
|
||||
grid = site.grid
|
||||
d = loop.cycle(grid, site.battery) # <- THE ONE CHANGED LINE
|
||||
for inv, share in zip(inverters,
|
||||
sitesim.allocate(d.target_w, len(inverters), mode)):
|
||||
inv.command(share)
|
||||
site.settle(cycle_s, dt=dt)
|
||||
samples.append(dict(cycle=c, grid_before=grid, target=d.target_w,
|
||||
grid=site.grid, batt=site.battery, reason=d.reason,
|
||||
sat=d.sat_count, frozen=d.frozen, clamped=d.clamped,
|
||||
i_clamped=d.i_clamped,
|
||||
shares=[inv.plant.cmd for inv in inverters]))
|
||||
return sitesim.Run(f"site-feedback gain {gain} {mode}", samples, inverters,
|
||||
mode, gain, None)
|
||||
|
||||
|
||||
SPLITS = ["alloc01:50/50", "alloc01:90/10", "alloc01:10/90",
|
||||
"alloc01:99/1", "alloc01:1/0"]
|
||||
|
||||
|
||||
def table(sitesim, control, watchdog, cycles, load_w):
|
||||
print("=== AC 3 / the ARCH-01 experiment: broadcast vs allocation ===")
|
||||
print(f"{'run':28} {'tail p2p':>10} {'worst export':>13} commands")
|
||||
rows = []
|
||||
for gain in (0.6, 1.0):
|
||||
for mode in ["broadcast", "even_split"] + SPLITS:
|
||||
r = run(sitesim, control, watchdog, mode, gain,
|
||||
load_w=load_w, cycles=cycles)
|
||||
rows.append((gain, mode, r))
|
||||
print(f"gain {gain} {mode:20} {r.p2p():10.0f} "
|
||||
f"{r.worst_export(1):13.0f} "
|
||||
f"{[int(x) for x in r.targets(4)]}")
|
||||
return rows
|
||||
|
||||
|
||||
def splits(sitesim, control, watchdog, cycles, load_w, gain=1.0,
|
||||
feedback="unit"):
|
||||
"""AC 2. A 90/10 and a 50/50 split, and the AGGREGATE trace at the meter."""
|
||||
print(f"=== AC 2 / split-invariance at gain {gain}, loop feedback "
|
||||
f"= {feedback} ===")
|
||||
base = None
|
||||
ok = True
|
||||
for mode in SPLITS:
|
||||
r = (run(sitesim, control, watchdog, mode, gain,
|
||||
load_w=load_w, cycles=cycles) if feedback == "unit"
|
||||
else run_site_feedback(sitesim, control, mode, gain,
|
||||
load_w=load_w, cycles=cycles))
|
||||
trace = [round(g, 6) for g in r.grids()]
|
||||
cmds = [round(t, 6) for t in r.targets()]
|
||||
if base is None:
|
||||
base, base_mode = trace, mode
|
||||
print(f" {mode:16} reference: grid {[int(g) for g in trace[:6]]} ...")
|
||||
else:
|
||||
same = trace == base
|
||||
ok &= same
|
||||
print(f" {mode:16} {'IDENTICAL' if same else 'DIFFERS'} to "
|
||||
f"{base_mode} worst |delta| "
|
||||
f"{max(abs(a - b) for a, b in zip(trace, base)):.6f} W")
|
||||
# And the shares are NOT the same - otherwise the invariance is vacuous.
|
||||
s = r.samples[-1]["shares"]
|
||||
print(f" last shares {[int(x) for x in s]}, "
|
||||
f"sum {sum(s):.0f} vs site command {cmds[-1]:.0f}")
|
||||
print(f" -> split-invariance {'HOLDS' if ok else 'FAILS'}")
|
||||
return ok
|
||||
|
||||
|
||||
def sweep(sitesim, control, watchdog, cycles, load_w):
|
||||
"""AC 3. Gain swept to config.yaml's documented maximum, at every split."""
|
||||
print("=== AC 3 / gain sweep to 1.0 at every split ===")
|
||||
gains = [0.05, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0]
|
||||
print(f"{'mode':20}" + "".join(f"{g:>9}" for g in gains))
|
||||
worst = {}
|
||||
for mode in ["broadcast"] + SPLITS:
|
||||
line = f"{mode:20}"
|
||||
for g in gains:
|
||||
r = run(sitesim, control, watchdog, mode, g,
|
||||
load_w=load_w, cycles=cycles)
|
||||
p = r.p2p()
|
||||
worst[mode] = max(worst.get(mode, 0.0), p)
|
||||
line += f"{p:9.0f}"
|
||||
print(line + f" worst {worst[mode]:.0f} W p2p")
|
||||
print(" (tail peak-to-peak grid swing, W. A limit cycle is a tail that "
|
||||
"does not go to zero.)")
|
||||
return worst
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--table", action="store_true")
|
||||
ap.add_argument("--splits", action="store_true")
|
||||
ap.add_argument("--sweep", action="store_true")
|
||||
ap.add_argument("--all", action="store_true")
|
||||
ap.add_argument("--cycles", type=int, default=40)
|
||||
ap.add_argument("--load", type=float, default=2000.0)
|
||||
ap.add_argument("--gain", type=float, default=1.0)
|
||||
ap.add_argument("--feedback", choices=("unit", "site"),
|
||||
default="unit",
|
||||
help="what the loop is fed as actual_w: one "
|
||||
"inverter's power (the shipped add-on) or "
|
||||
"the site total (the counterfactual)")
|
||||
a = ap.parse_args()
|
||||
if not (a.table or a.splits or a.sweep):
|
||||
a.all = True
|
||||
|
||||
sim = find_sim()
|
||||
if sim is None:
|
||||
print("alloc_sim: admin/goodwe is not checked out beside this "
|
||||
"repository, so the two-master rig is unavailable. NOTHING has "
|
||||
"been measured. Set $GOODWE_SIM to its sim/ directory.")
|
||||
return 2
|
||||
print(f"rig: {sim}")
|
||||
inject, sitesim, arbiter, allocator = load(sim)
|
||||
control = sitesim.load_addon("app.control", "alloc_sim")
|
||||
watchdog = sitesim.load_addon("app.watchdog", "alloc_sim")
|
||||
print(f"add-on under test: {os.environ['GOODWE_ADDON_REPO']}")
|
||||
print("⚠️ inverter B's rating/ramp/lag are PLACEHOLDERS (plantsim."
|
||||
"INVERTER_B, measured=False) - SPIKE-04 measures them\n")
|
||||
|
||||
if a.table or a.all:
|
||||
table(sitesim, control, watchdog, a.cycles, a.load)
|
||||
print()
|
||||
if a.splits or a.all:
|
||||
splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
|
||||
a.feedback)
|
||||
print()
|
||||
if a.sweep or a.all:
|
||||
sweep(sitesim, control, watchdog, a.cycles, a.load)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user