diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index a9b8084..419a84d 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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 diff --git a/goodwe_controller/app/allocator.py b/goodwe_controller/app/allocator.py new file mode 100644 index 0000000..aa122cc --- /dev/null +++ b/goodwe_controller/app/allocator.py @@ -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) diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index 6cd826a..e814167 100644 --- a/goodwe_controller/app/arbiter.py +++ b/goodwe_controller/app/arbiter.py @@ -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 diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 06ab844..4b7ffd4 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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: diff --git a/goodwe_controller/test_alloc.py b/goodwe_controller/test_alloc.py new file mode 100644 index 0000000..25d7442 --- /dev/null +++ b/goodwe_controller/test_alloc.py @@ -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") diff --git a/goodwe_controller/test_arbiter.py b/goodwe_controller/test_arbiter.py index 6cd426b..7549004 100644 --- a/goodwe_controller/test_arbiter.py +++ b/goodwe_controller/test_arbiter.py @@ -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: diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py index c0dfc99..914cd2e 100644 --- a/goodwe_controller/test_clamp.py +++ b/goodwe_controller/test_clamp.py @@ -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") diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 5c13c4a..d9a789d 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -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() diff --git a/goodwe_controller/tools/alloc_sim.py b/goodwe_controller/tools/alloc_sim.py new file mode 100644 index 0000000..a66e492 --- /dev/null +++ b/goodwe_controller/tools/alloc_sim.py @@ -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())