Merge pull request 'ALLOC-01: allocate the site grid target across N inverters, sum-invariant' (#41) from ALLOC-01 into release/1.0
This commit was merged in pull request #41.
This commit is contained in:
@@ -2,6 +2,111 @@
|
||||
|
||||
## 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**,
|
||||
and the one rounding that is left - the single site-level `quantisation_w`,
|
||||
never distributed into a per-unit bias - totals **-39.8 W over 137 964
|
||||
allocations, i.e. -0.0003 W per tick.**
|
||||
* **Each share is clipped to that unit's interval, and THE TWO ENDS OF THE
|
||||
INTERVAL ARE APPLIED INDEPENDENTLY.** `UnitBounds()` defaults to
|
||||
`(-inf, +inf)`, so a one-sided envelope is the ordinary case at N>=2 rather
|
||||
than an edge. The first cut of this work folded both ends into one
|
||||
`min(max(v, lo), hi)` and substituted `v` itself for a missing bound, which
|
||||
reads as "an absent bound cannot bind" but is not what it does: with
|
||||
`hi == inf` the expression collapses to `min(max(v, lo), v) == v`, so **a
|
||||
missing ceiling silently discarded the floor.** `_clamp_int(0, 2142, inf)`
|
||||
returned 0. The worked failure it caused: a unit with a 2000 W floor and no
|
||||
ceiling, beside a symmetric unit, against a +1000 W site target, was allocated
|
||||
**+500 W - a quarter of its own floor** - and the sign-coherence refusal that
|
||||
the same case with a finite ceiling correctly raises was **suppressed** with
|
||||
it. Fixed by clamping each end on its own, and `UnitPlan.explain()` can no
|
||||
longer print a share as being inside an interval that excludes it: that line
|
||||
now reads `OUTSIDE ... - ENVELOPE VIOLATED, DO NOT WRITE`.
|
||||
* **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.
|
||||
* **And the replay that missed it was widened, so the fix is graded rather than
|
||||
merely applied.** The 250 000-tick replay drew every envelope as
|
||||
`B(-uniform(0, rating), uniform(0, rating))` - **finite on both ends and
|
||||
containing zero, every time** - while the check it fed claimed "no share ever
|
||||
leaves the envelope it was clamped into". The data could only exercise a
|
||||
symmetric closed interval around zero, so it reported 0 escapes against a live
|
||||
defect. The generator now draws from **every shape `UnitBounds` can express**:
|
||||
half-open above, half-open below, intervals that exclude zero, and bare
|
||||
`UnitBounds()`. Measured A/B against the defective clamp on the same seed and
|
||||
the same 250 000 ticks: **the old generator sees 0 escapes, the new one sees
|
||||
39 991.** Reverting the clamp fix under the widened generator takes
|
||||
`test_alloc.py` red on four checks; re-applying it takes it green. The
|
||||
collapse is now a row in part 12's mutation table.
|
||||
* **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,579 @@
|
||||
"""One site grid target, divided across N inverters so that the shares SUM to it.
|
||||
|
||||
⚠️ THE REASON THIS EXISTS, AND IT IS NOT EFFICIENCY. The control primitive in
|
||||
this system is METER EMULATION: the master reports a FABRICATED grid reading
|
||||
(0x056E direction, 0x056F magnitude, FC 0x10, every 1-2 s) and the inverter
|
||||
regulates ITSELF to it. So one number means "the battery moves X W" to this
|
||||
add-on and "the grid is at X W" to the inverter - an identity that holds ONLY at
|
||||
N=1. Hand two inverters the same target and each supplies it IN FULL: the loop
|
||||
gain at the meter becomes N times the per-unit gain.
|
||||
|
||||
That is measured, not argued. On admin/goodwe's two-master rig (TEST-19,
|
||||
`sim/sitesim.py --table`, the add-on's own `compute()` in the loop and the
|
||||
firmware's own encoder on the wire):
|
||||
|
||||
N=2 gain 0.6 BROADCAST worst export -400 W (N=1: +10 W)
|
||||
N=2 gain 1.0 BROADCAST commands [1000, 2000, 1000, 0] W,
|
||||
grid [0, -2000, 0, +2000] W, tail p2p 4000 W,
|
||||
FOREVER, and not one guard fires.
|
||||
|
||||
`gain: 1.0` is what config.yaml:374 accepts as its documented maximum. The sign
|
||||
watchdog is evaluated on every one of those cycles and never trips, because the
|
||||
disagreement flips on alternate ticks and never holds its 180 s window. So the
|
||||
broadcast fault is not merely inefficient: it is invisible to every detector
|
||||
this repository has.
|
||||
|
||||
THE FIX IS STRUCTURAL, WHICH IS WHY IT IS A MODULE AND NOT A TUNING CHANGE
|
||||
=========================================================================
|
||||
ONE integrator on P1, above the split (§10.3, and `compute()` is that
|
||||
integrator). Its output S is the SITE's grid target. This module turns S into
|
||||
per-unit shares with one invariant:
|
||||
|
||||
sum(shares) + unmet_w == S, with unmet_w == 0 whenever the envelopes allow
|
||||
|
||||
and each inverter is then told a function of ITS OWN SHARE, never of the shared
|
||||
measurement. Write s_i = a_i * S with sum(a_i) = 1. Each unit closes its own
|
||||
loop with per-unit gain g_i, so the gain seen AT THE METER is
|
||||
|
||||
gbar = sum(a_i * g_i)
|
||||
|
||||
which is a CONVEX COMBINATION of the per-unit gains and therefore lies in
|
||||
[min g_i, max g_i] FOR ANY SPLIT. Compare the broadcast case, a_i = 1 for every
|
||||
unit, where the same sum is N * g and grows without bound in N.
|
||||
|
||||
⚠️ THAT IS THE WHOLE ARGUMENT, AND IT IS WHY THE WEIGHTS ARE A COMFORT CHOICE.
|
||||
Nothing above constrains the a_i beyond summing to 1. So SoC, thermals and pack
|
||||
wear may set them freely and a BAD SPLIT CANNOT DESTABILISE THE SITE - it can
|
||||
only be unkind to one pack. That property is the reason §6.2 chose share
|
||||
allocation over a regulator/ballast arrangement, in which the ballast unit's
|
||||
share is a function of the shared measurement and the gain question comes back.
|
||||
|
||||
⚠️ WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0
|
||||
is "please do not use this unit", and it is honoured while any other unit
|
||||
has headroom - but it is NOT a constraint, because the SUM is the safety
|
||||
property and stranding capacity behind a preference would leave a standing
|
||||
error for the site integrator to chase forever. To make a unit genuinely
|
||||
unusable, close its envelope or leave it out of `live`. See `_fill()`.
|
||||
|
||||
WHAT THIS MODULE MUST NOT DO
|
||||
============================
|
||||
⚠️ IT MUST NOT READ P1. One integrator on the site measurement, above the split.
|
||||
A second reader of the meter is a second controller, and two controllers on one
|
||||
actuator is the failure the whole arbiter exists to prevent. Nothing in here
|
||||
takes a measurement of any kind: the inputs are S, the per-unit intervals, and
|
||||
per-tick liveness. That is checkable by reading the imports, and it is why there
|
||||
are none beyond the standard library.
|
||||
|
||||
⚠️ IT MUST NOT CACHE AN ENVELOPE. `plan()` is a pure function called once per
|
||||
tick with that tick's intervals. A unit's available range moves for reasons that
|
||||
have nothing to do with the allocator - a stale link, a pack at its floor, a
|
||||
device rating that failed to read - and an allocator holding a construction-time
|
||||
envelope over-allocates into a unit that cannot take it. There is no state here
|
||||
to hold one; that is deliberate and it is graded.
|
||||
|
||||
⚠️ ROUNDING IS A SAFETY PROPERTY. The setpoint register is 1 W (control.py says
|
||||
so where it quantises). A per-unit `round()` biases the sum by up to N/2 W every
|
||||
tick, and at 1-2 Hz a systematic 1 W bias is a PERMANENT standing error the site
|
||||
integrator will chase forever - it is not a rounding artefact, it is a slow
|
||||
offset with a control loop wound around it. So the sum is computed in INTEGER
|
||||
ARITHMETIC from an integer target and the fractional parts are settled by
|
||||
LARGEST REMAINDER (Hamilton): floor everything, then hand the leftover watts one
|
||||
at a time to the units with the largest discarded fraction. The sum cannot drift
|
||||
because no float ever enters it.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from math import ceil, floor, inf
|
||||
|
||||
# The tolerance on a float comparison of watts. Envelopes arrive as floats from
|
||||
# config and from device registers; this is the width below which two of them
|
||||
# are the same number. It is never used on the sum - see the module docstring.
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
# --- why a unit is not in the envelope set, and why the whole thing refused ---
|
||||
# Named rather than spelled inline so that main.py, SAFETY-28's write gate and
|
||||
# the tests can all agree on which case fired without matching English.
|
||||
DROP_STALE = "stale link"
|
||||
DROP_CONTRADICTION = "contradicts itself"
|
||||
DROP_NO_INTEGER = "no integer setpoint in its interval"
|
||||
|
||||
REFUSE_SCOPE = "mis-routed claim"
|
||||
REFUSE_EMPTY = "no unit available"
|
||||
REFUSE_SIGN = "opposite directions"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitPlan:
|
||||
"""What ONE unit was told this tick, and why it was that and not more.
|
||||
|
||||
This is the per-unit half of the allocation record. `why` is written for an
|
||||
operator reading an action log at 2 a.m., not for a developer: it says which
|
||||
mechanism chose the number - the weighted fill, an envelope that ran out, a
|
||||
claim of the unit's own that outranked the site.
|
||||
"""
|
||||
unit: str
|
||||
share_w: int
|
||||
lo: float
|
||||
hi: float
|
||||
weight: float
|
||||
why: str
|
||||
pinned: bool = False
|
||||
clamped: bool = False
|
||||
dropped: bool = False # not commanded at all - see `within_envelope`
|
||||
|
||||
@property
|
||||
def span(self) -> str:
|
||||
return (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, "
|
||||
f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]")
|
||||
|
||||
@property
|
||||
def within_envelope(self) -> bool:
|
||||
"""Is the number this unit was told ACTUALLY inside the interval?
|
||||
|
||||
⚠️ THIS IS ASKED, NOT ASSUMED. The record is what SAFETY-28 gates on,
|
||||
and a record that describes a share it did not check is worth less than
|
||||
no record: the half-open clamp defect shipped a line reading
|
||||
`inverter-a -> +500 W in [2000, +inf] (weighted share)` - the record
|
||||
stating, in the same breath, both the floor and the share below it, and
|
||||
agreeing with itself. The clamp is fixed, so this should now be true on
|
||||
every plan the allocator builds; that is exactly why it is cheap to
|
||||
assert rather than to trust.
|
||||
|
||||
A DROPPED unit is always within: it was told nothing. Its interval may
|
||||
be a contradiction (`lo > hi`), which no number is inside, and printing
|
||||
that as a violation would cry wolf on the one case that is already
|
||||
reported by name.
|
||||
"""
|
||||
if self.dropped:
|
||||
return True
|
||||
return self.lo <= self.share_w <= self.hi
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.dropped:
|
||||
return (f"{self.unit} -> not commanded, envelope {self.span} "
|
||||
f"({self.why})")
|
||||
if not self.within_envelope:
|
||||
# Unreachable as the allocator stands. If it ever is reachable
|
||||
# again, the log says so in the line an operator is already reading
|
||||
# rather than in a line nobody wrote.
|
||||
return (f"{self.unit} -> {self.share_w:+d} W OUTSIDE {self.span} "
|
||||
f"({self.why}) - ENVELOPE VIOLATED, DO NOT WRITE")
|
||||
return f"{self.unit} -> {self.share_w:+d} W in {self.span} ({self.why})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Allocation:
|
||||
"""EVERY TICK PRODUCES ONE OF THESE, including the ticks that refused.
|
||||
|
||||
⚠️ IT IS DESIGNED TO BE GATEABLE, because SAFETY-28 gates the write path on
|
||||
it. That means three things, and each is a decision rather than a
|
||||
convenience:
|
||||
|
||||
* `ok` is ONE boolean and it is the only thing a gate has to read. A gate
|
||||
that must parse a message, or check three fields in the right order, is a
|
||||
gate somebody gets wrong.
|
||||
* a REFUSAL still carries the record - `plans`, `dropped` and the reason -
|
||||
so the gate can say WHY it stopped the write instead of only that it did.
|
||||
A refusal that returns nothing is a refusal nobody can debug.
|
||||
* `unmet_w` is separate from `ok`. An allocation that could not place every
|
||||
watt is still SAFE TO WRITE and strictly better than commanding zero; it
|
||||
is the site target that was not fully met, not a broken allocation. Fusing
|
||||
the two would make a saturated inverter park the whole site.
|
||||
|
||||
`sum_w` is the integer the shares are required to add up to, and
|
||||
`quantisation_w` is the ONE rounding that produced it from `target_w`. Both
|
||||
are here so that "the shares sum to S" can be checked by a reader rather
|
||||
than believed: `sum(shares.values()) + unmet_w == sum_w` is an identity over
|
||||
integers, and `sum_w - target_w` is a single site-level rounding of at most
|
||||
half a watt that is NEVER distributed into a per-unit bias.
|
||||
"""
|
||||
target_w: float
|
||||
sum_w: int
|
||||
shares: dict = field(default_factory=dict)
|
||||
plans: tuple = ()
|
||||
dropped: tuple = () # ((unit, why), ...) - order is roster order
|
||||
unmet_w: int = 0
|
||||
quantisation_w: float = 0.0
|
||||
refused: str | None = None
|
||||
passthrough: bool = False # N<=1: the share IS the site target, unrounded
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""The one boolean SAFETY-28's write gate reads."""
|
||||
return self.refused is None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""One line an operator can act on."""
|
||||
if self.refused is not None:
|
||||
return (f"REFUSED to allocate {self.target_w:+.0f} W: "
|
||||
f"{self.refused}")
|
||||
if self.passthrough:
|
||||
return f"pass-through {self.target_w:+.0f} W (one unit, nothing to divide)"
|
||||
body = ", ".join(f"{u}{s:+d}" for u, s in sorted(self.shares.items()))
|
||||
out = f"{self.sum_w:+d} W as {body}"
|
||||
if self.unmet_w:
|
||||
out += f", {self.unmet_w:+d} W UNMET"
|
||||
if self.dropped:
|
||||
out += (" - dropped "
|
||||
+ ", ".join(f"{u} ({w})" for u, w in self.dropped))
|
||||
return out
|
||||
|
||||
|
||||
def _int_envelope(b):
|
||||
"""A unit's interval, narrowed to the integers a 1 W register can hold.
|
||||
|
||||
⚠️ NARROWED INWARD, ALWAYS. `ceil` on the floor and `floor` on the ceiling,
|
||||
so every integer in the result is inside the real interval. Rounding the
|
||||
other way would let a share of exactly the rating be commanded on a unit
|
||||
whose rating is 2499.5 W, which is the one direction an envelope must never
|
||||
move: the envelope is what `device` and `supervised` filed, i.e. what the
|
||||
hardware said it can do.
|
||||
|
||||
Infinities pass through untouched - an absent bound is not a large bound,
|
||||
and `ceil(-inf)` is not a number. A unit that filed nothing this cycle has
|
||||
exactly that interval, which is why this case is the common one at N>=2 and
|
||||
not an edge.
|
||||
"""
|
||||
lo = -inf if b.lo == -inf else ceil(b.lo - EPS)
|
||||
hi = inf if b.hi == inf else floor(b.hi + EPS)
|
||||
return lo, hi
|
||||
|
||||
|
||||
def _clamp_int(v, lo, hi):
|
||||
"""`v` as an int, pulled inside `[lo, hi]`. EACH END IS APPLIED ON ITS OWN.
|
||||
|
||||
⚠️ THE TWO ENDS ARE INDEPENDENT AND MUST BE TREATED SO. An earlier form
|
||||
substituted `v` itself for a missing bound and fed both into one
|
||||
`min(max(...))`, which reads as "an absent bound cannot bind" but is not
|
||||
what it does: with `hi == inf` the whole expression collapses to
|
||||
`min(max(v, lo), v) == v`, so a MISSING UPPER BOUND SILENTLY DISCARDED THE
|
||||
LOWER ONE. `_clamp_int(0, 2142, inf)` returned 0 - a share below a 2142 W
|
||||
floor the unit itself filed - and every caller here inherited it.
|
||||
|
||||
So: skip the end that is infinite, apply the end that is not, and never let
|
||||
one end's absence reach the other. `UnitBounds()` defaults to
|
||||
`(-inf, +inf)`, and this helper is the whole of the ticket's first
|
||||
deliverable ("each share clipped to that unit's UnitBounds interval"), so a
|
||||
one-sided envelope is the ordinary case, not an edge.
|
||||
"""
|
||||
v = int(v)
|
||||
if lo != -inf:
|
||||
v = max(v, lo)
|
||||
if hi != inf:
|
||||
v = min(v, hi)
|
||||
return v
|
||||
|
||||
|
||||
def _fill(order, weights, cap, amount):
|
||||
"""WATER-FILL: place `amount` watts across `order`, capped, by weight.
|
||||
|
||||
`amount` and every `cap` are NON-NEGATIVE magnitudes; the direction has
|
||||
already been taken out by the caller. Returns `(add, leftover)`.
|
||||
|
||||
THE LOOP IS THE STANDARD ONE and it terminates in at most len(order) rounds:
|
||||
share the remaining amount in proportion to weight; any unit whose share
|
||||
would exceed its cap is PINNED AT ITS CAP and dropped from the active set;
|
||||
repeat with what is left. A unit that is not capped in a round is not capped
|
||||
in any later round either, so nothing is revisited.
|
||||
|
||||
⚠️ AND THEN THE SECOND PASS, WHICH IS THE POLICY DECISION IN THIS FUNCTION.
|
||||
Units with weight 0 are excluded from the weighted rounds - that is what a
|
||||
zero weight is for. But if the weighted set fills up and watts remain, they
|
||||
are offered to the zero-weight units, EQUALLY, rather than being reported as
|
||||
unmet. The reason is the module docstring's: the weights are a comfort
|
||||
choice and the sum is the safety property, so a preference must never
|
||||
manufacture a standing error at the meter that the site integrator will then
|
||||
chase. To make a unit genuinely unusable, close its envelope - `cap` is
|
||||
obeyed absolutely and is never overridden here.
|
||||
"""
|
||||
add = {u: 0.0 for u in order}
|
||||
rem = float(amount)
|
||||
|
||||
for pool in (
|
||||
[u for u in order if weights[u] > 0.0],
|
||||
[u for u in order if weights[u] <= 0.0], # the last-resort pass
|
||||
):
|
||||
# Inside the last-resort pass every weight is 0, so the proportional
|
||||
# step needs a weight of its own; equal shares is the only choice that
|
||||
# does not invent a preference the caller did not express.
|
||||
w = {u: (weights[u] if weights[u] > 0.0 else 1.0) for u in pool}
|
||||
active = [u for u in pool if cap[u] > 0.0]
|
||||
while rem > EPS and active:
|
||||
total = sum(w[u] for u in active)
|
||||
capped = [u for u in active if rem * w[u] / total >= cap[u] - add[u]]
|
||||
if capped:
|
||||
for u in capped:
|
||||
rem -= cap[u] - add[u]
|
||||
add[u] = cap[u]
|
||||
active = [u for u in active if u not in capped]
|
||||
else:
|
||||
for u in active:
|
||||
add[u] += rem * w[u] / total
|
||||
rem = 0.0
|
||||
return add, rem
|
||||
|
||||
|
||||
def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
site_priority=None, site_key="site") -> Allocation:
|
||||
"""Divide the site grid target `target_w` across the units in `bounds`.
|
||||
|
||||
`bounds` unit name -> that unit's interval for THIS TICK (arbiter stage 1,
|
||||
`UnitBounds`). Read per tick, never cached - see the module
|
||||
docstring. Duck-typed on `.lo`, `.hi`, `.gate`, `.pinned_w`,
|
||||
`.pinned_by` and `.contradiction` so that this module does not
|
||||
import the arbiter and cannot grow a dependency on claims.
|
||||
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
|
||||
`live` the units whose master has reported recently enough to be
|
||||
commanded, or None for "all of them". A unit not listed is
|
||||
DROPPED and the record says so; the survivors then carry the
|
||||
whole site target, which is a real change in what the site can
|
||||
do and must never happen silently (AC 6).
|
||||
`scope_errors` the arbiter's mis-routing complaints. See REFUSE_SCOPE below.
|
||||
`site_priority` the priority of the SITE claim that chose `target_w`. It is
|
||||
what a unit's own winning SET is weighed against - see PINS.
|
||||
`site_key` the name to use when there are no units at all.
|
||||
|
||||
⚠️ N<=1 IS A PASS-THROUGH AND IS BYTE-IDENTICAL TO WHAT SHIPPED. The share
|
||||
IS the site target, as a float, unclamped and unrounded. That is §6.2's N=1
|
||||
identity - with one inverter the fabricated grid reading and the battery
|
||||
setpoint are the same number - and it is also the compatibility promise:
|
||||
every existing test in this repository replays through here and must see the
|
||||
number it saw before ARB-03. Do not "tidy" this branch into the general path
|
||||
by giving it a roster of one; the general path rounds to an integer and
|
||||
applies the envelope, and both are behaviour changes on the live install.
|
||||
"""
|
||||
units = tuple(sorted(bounds))
|
||||
|
||||
# --- REFUSAL 1: the claim list that produced S is known to be mis-routed --
|
||||
# ⚠️ THIS IS THE ANSWER TO ARB-03's OPEN QUESTION, AND IT IS A REFUSAL
|
||||
# RATHER THAN AN INPUT. `scope_violations()` reports a claim filed on an
|
||||
# actuator its source's scope does not permit. It is tempting to CONSUME
|
||||
# one - to read a unit-scoped claim that landed on the site as an allocation
|
||||
# input - and that is wrong twice over. First, by the time this function
|
||||
# runs the mis-routed claim has ALREADY been partitioned by `resolve()`, so
|
||||
# its effect is baked into S or into somebody's interval and cannot be
|
||||
# undone from here; consuming it would mean re-routing, which would make
|
||||
# `actuator` advisory and hand every filing site a second chance to be
|
||||
# wrong. Second, and worse, promoting a unit claim that landed on the site
|
||||
# requires guessing WHICH unit it belonged to, and there is no information
|
||||
# here to guess with - `actuator_for()` exists precisely because that answer
|
||||
# is chosen where the claim is built. Applying it to ALL units would put one
|
||||
# inverter's rating on a sibling that does not have it.
|
||||
#
|
||||
# So what is left is that S itself may be wrong, and dividing a number that
|
||||
# may be wrong is worse than not dividing it. Refuse, and let main.py fail
|
||||
# toward inaction and shout - which is the same answer clause 3 of the
|
||||
# arbiter's own rule already gives to a contradiction.
|
||||
#
|
||||
# ⚠️ IT IS ABOVE THE N<=1 PASS-THROUGH ON PURPOSE, and that is the one place
|
||||
# this card touches the pass-through's behaviour. A correctly routed
|
||||
# one-inverter cycle CANNOT produce a scope violation - `scope_violations()`
|
||||
# only complains about a unit source on the site when the site has named
|
||||
# units, and a site source on a unit requires somebody to have hand-written
|
||||
# `actuator=` past `actuator_for()` - so this cannot fire on the shipped
|
||||
# install, and where it does fire the input is a known bug rather than a
|
||||
# state to pass through.
|
||||
if scope_errors:
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=int(round(float(target_w))),
|
||||
shares={}, plans=(), dropped=(),
|
||||
unmet_w=int(round(float(target_w))),
|
||||
quantisation_w=float(int(round(float(target_w)))) - float(target_w),
|
||||
refused=f"{REFUSE_SCOPE}: " + "; ".join(scope_errors))
|
||||
|
||||
if len(units) <= 1:
|
||||
only = units[0] if units else site_key
|
||||
share = float(target_w)
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=int(round(share)),
|
||||
shares={only: share},
|
||||
plans=(UnitPlan(only, int(round(share)), -inf, inf, 1.0,
|
||||
"N<=1: the share IS the site target"),),
|
||||
passthrough=True)
|
||||
|
||||
w = {u: 1.0 if weights is None else float(weights.get(u, 1.0))
|
||||
for u in units}
|
||||
for u, wu in w.items():
|
||||
if wu < 0.0 or wu != wu:
|
||||
raise ValueError(f"weight for {u!r} is {wu!r}; weights are "
|
||||
"non-negative comfort factors, not signed targets")
|
||||
|
||||
# ⚠️ THE TARGET IS ROUNDED ONCE, HERE, AND NOWHERE ELSE. Everything below
|
||||
# this line is integer arithmetic, which is what makes "the shares sum to S"
|
||||
# an identity rather than a tolerance. `round()` is banker's rounding: over a
|
||||
# long replay it is unbiased, where `int(x + 0.5)` would add a systematic
|
||||
# half-watt in the discharge direction. In practice `target_w` arrives from
|
||||
# `compute()` already quantised to `step_w` (10 W by default) so this is a
|
||||
# no-op on the live path; it is not a no-op for a peak-shaving floor or an
|
||||
# arbitrage setpoint, which are not quantised.
|
||||
sum_w = int(round(float(target_w)))
|
||||
quant = float(sum_w) - float(target_w)
|
||||
|
||||
# --- the envelope set, rebuilt from scratch every tick -------------------
|
||||
dropped = []
|
||||
ilo, ihi = {}, {}
|
||||
considered = []
|
||||
for u in units:
|
||||
b = bounds[u]
|
||||
if live is not None and u not in live:
|
||||
# AC 6. The survivor is about to carry this unit's share and the
|
||||
# record has to say so; `dropped` is how it says it, and main.py
|
||||
# logs the transition.
|
||||
dropped.append((u, DROP_STALE))
|
||||
continue
|
||||
if getattr(b, "contradiction", False):
|
||||
# A unit contradicting ITSELF - its own `lo` above its own `hi`.
|
||||
# Stage 1 already decided that parks THAT unit and not the site,
|
||||
# which is the whole point of the partition; here it means the unit
|
||||
# has no interval to allocate into.
|
||||
dropped.append((u, DROP_CONTRADICTION))
|
||||
continue
|
||||
lo, hi = _int_envelope(b)
|
||||
if lo > hi:
|
||||
# An interval that contains no integer. The register is 1 W, so
|
||||
# there is no number this unit could be commanded that both bounds
|
||||
# accept - dropping it is the only answer that does not violate one.
|
||||
dropped.append((u, DROP_NO_INTEGER))
|
||||
continue
|
||||
ilo[u], ihi[u] = lo, hi
|
||||
considered.append(u)
|
||||
|
||||
def refuse(why):
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
|
||||
dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant,
|
||||
refused=why)
|
||||
|
||||
# --- REFUSAL 2: nobody is available ------------------------------------
|
||||
if not considered:
|
||||
return refuse(f"{REFUSE_EMPTY}: "
|
||||
+ ", ".join(f"{u} ({why})" for u, why in dropped))
|
||||
|
||||
# --- PINS: a unit's own SET, weighed against the site's -------------------
|
||||
# ⚠️ THIS IS THE DECISION ARB-03 DEFERRED TO HERE, IN ITS OWN WORDS: "Stage 1
|
||||
# states the constraint and records what it is gated on; deciding whether it
|
||||
# applies is stage 3's business." `UnitBounds.gate` is the priority of the
|
||||
# unit's own winning SET; `site_priority` is the priority of the SITE claim
|
||||
# that chose S. Clause 1 of the arbiter's rule, transposed to a site with
|
||||
# more than one actuator: the higher priority wins, and a tie goes to the
|
||||
# unit, because a claim about one pack is more specific than a claim about
|
||||
# the connection and specificity is what a tie has left to break on.
|
||||
#
|
||||
# A pinned unit is NOT in the water-fill. It is doing something of its own -
|
||||
# a maintenance drain, a failsafe park - and what it does still counts
|
||||
# toward the site sum, so the free units carry the REMAINDER. That is not
|
||||
# the allocator compensating for a disturbance, which would be a second
|
||||
# controller; it is the shares summing to S, which is the invariant. The
|
||||
# meter's own answer arrives next tick through the one integrator above.
|
||||
#
|
||||
# ⚠️ AND site_priority=None MEANS "NO SITE SET WAS NAMED", so every pin
|
||||
# stands. `resolve()` always passes the real one. A direct caller that
|
||||
# passes nothing is saying it has no site claim to weigh, and honouring the
|
||||
# unit's own highest-priority claim is the only safe reading of that.
|
||||
pinned = {}
|
||||
for u in considered:
|
||||
b = bounds[u]
|
||||
gate = getattr(b, "gate", None)
|
||||
if gate is None:
|
||||
continue
|
||||
if site_priority is None or gate >= site_priority:
|
||||
pv = getattr(b, "pinned_w", None)
|
||||
if pv is None:
|
||||
continue
|
||||
pinned[u] = _clamp_int(round(float(pv)), ilo[u], ihi[u])
|
||||
|
||||
free = [u for u in considered if u not in pinned]
|
||||
remainder = sum_w - sum(pinned.values())
|
||||
|
||||
# --- WATER-FILL over the free units --------------------------------------
|
||||
# `base` is the point of each envelope nearest zero: 0 for an ordinary
|
||||
# symmetric rating, and the forced end for an envelope that does not contain
|
||||
# zero. Filling from there rather than from `lo` is what keeps a unit that
|
||||
# is merely ALLOWED to charge from being TOLD to.
|
||||
base = {u: _clamp_int(0, ilo[u], ihi[u]) for u in free}
|
||||
placed = sum(base.values())
|
||||
need = remainder - placed
|
||||
up = need >= 0
|
||||
cap = {u: (ihi[u] - base[u]) if up else (base[u] - ilo[u]) for u in free}
|
||||
add, leftover = _fill(free, w, cap, abs(need))
|
||||
|
||||
exact = {u: base[u] + (add[u] if up else -add[u]) for u in free}
|
||||
|
||||
# --- LARGEST-REMAINDER ROUNDING -----------------------------------------
|
||||
# Floor every share, then hand out the discarded watts one at a time, worst
|
||||
# discarded fraction first. The sum is integer arithmetic end to end, so it
|
||||
# cannot drift; ties break on the unit name so that the same inputs give the
|
||||
# same allocation on every tick and on every host.
|
||||
share = {u: int(floor(exact[u] + EPS)) for u in free}
|
||||
for u in free: # floors can never leave the envelope,
|
||||
share[u] = _clamp_int(share[u], ilo[u], ihi[u]) # but say so anyway
|
||||
debt = remainder - sum(share.values())
|
||||
frac_order = sorted(free, key=lambda u: (-(exact[u] - floor(exact[u])), u))
|
||||
step = 1 if debt > 0 else -1
|
||||
while debt != 0:
|
||||
moved = False
|
||||
for u in (frac_order if debt > 0 else list(reversed(frac_order))):
|
||||
if debt == 0:
|
||||
break
|
||||
nxt = share[u] + step
|
||||
if (step > 0 and nxt <= ihi[u]) or (step < 0 and nxt >= ilo[u]):
|
||||
share[u] = nxt
|
||||
debt -= step
|
||||
moved = True
|
||||
if not moved:
|
||||
break
|
||||
|
||||
shares = dict(pinned)
|
||||
shares.update(share)
|
||||
unmet = sum_w - sum(shares.values())
|
||||
|
||||
# --- REFUSAL 3: SIGN COHERENCE, so circulating power is not expressible ---
|
||||
# ⚠️ REFUSED, NOT AVERAGED. Two shares of opposite sign mean one pack
|
||||
# discharging into the AC bus while another charges off it: energy round-
|
||||
# tripping through two conversion stages for no reason, and - this is the
|
||||
# part that makes it a safety property rather than an efficiency one -
|
||||
# INVISIBLE. The meter sees the net, which is exactly S, so the site
|
||||
# integrator is content; each unit sees a target it is meeting, so no
|
||||
# per-unit guard fires; the sign watchdog compares a command against the
|
||||
# meter and both agree. Nothing in this repository would ever report it.
|
||||
#
|
||||
# It cannot arise from an ordinary tick: the fill only ever moves shares in
|
||||
# ONE direction from a base that is zero whenever the envelope contains
|
||||
# zero, so opposite signs require an envelope that EXCLUDES zero (a forced
|
||||
# floor) or a pin. Those are the cases worth refusing, and the refusal is a
|
||||
# site-level one because the alternative - dropping the offending unit and
|
||||
# re-filling - would silently override a claim that outranked the loop.
|
||||
pos = sorted(u for u, s in shares.items() if s > 0)
|
||||
neg = sorted(u for u, s in shares.items() if s < 0)
|
||||
if pos and neg:
|
||||
return refuse(
|
||||
f"{REFUSE_SIGN}: {', '.join(pos)} would discharge while "
|
||||
f"{', '.join(neg)} charges - circulating power, which nets to "
|
||||
f"{sum_w:+d} W at the meter and is invisible to every guard")
|
||||
|
||||
plans = []
|
||||
for u in units:
|
||||
drop = dict(dropped).get(u)
|
||||
if drop is not None:
|
||||
plans.append(UnitPlan(u, 0, bounds[u].lo, bounds[u].hi, w[u],
|
||||
f"DROPPED: {drop}", dropped=True))
|
||||
continue
|
||||
if u in pinned:
|
||||
by = getattr(bounds[u], "pinned_by", None) or "its own claim"
|
||||
plans.append(UnitPlan(u, pinned[u], ilo[u], ihi[u], w[u],
|
||||
f"pinned by {by} at priority "
|
||||
f"{bounds[u].gate}", pinned=True))
|
||||
continue
|
||||
at_edge = share[u] in (ilo[u], ihi[u])
|
||||
why = "weighted share"
|
||||
if at_edge and cap[u] - add[u] <= EPS and abs(need) > EPS:
|
||||
why = "envelope full - its surplus went to the others"
|
||||
elif w[u] <= 0.0 and share[u] != base[u]:
|
||||
why = "last resort: the preferred units were full"
|
||||
plans.append(UnitPlan(u, share[u], ilo[u], ihi[u], w[u], why,
|
||||
clamped=at_edge))
|
||||
|
||||
return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares,
|
||||
plans=tuple(plans), dropped=tuple(dropped),
|
||||
unmet_w=int(unmet), quantisation_w=quant)
|
||||
@@ -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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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