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