diff --git a/goodwe_controller/DECISIONS/SAFETY-31-circulating-power.md b/goodwe_controller/DECISIONS/SAFETY-31-circulating-power.md index 01dec93..471da98 100644 --- a/goodwe_controller/DECISIONS/SAFETY-31-circulating-power.md +++ b/goodwe_controller/DECISIONS/SAFETY-31-circulating-power.md @@ -149,8 +149,70 @@ each swept across the **whole range `config.yaml` accepts** for the two settings that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and `slew_w` `int(50,5000)`. +### ⚠️ THE HARNESS BEHIND EVERY FIGURE ABOVE IS NOT COMMITTED, AND UNTIL 2026-09-07 THAT MADE THEM UNCHECKABLE + +**Read this before quoting any number in this section.** The paragraph below +said the campaign harness "is not committed here" and treated that as a +packaging detail. It is not: a figure produced by a script that exists in +nobody's tree cannot be re-run, cannot be reviewed, and cannot be defended when +it is contradicted. It has now been contradicted twice. + +| sweep | worst peak | worst continuous | trips | +|---|---:|---:|---:| +| the 924-run campaign below — **harness uncommitted** | 1700 W | 505.0 s at 100 W; 5.0 s above 700 W | 0 / 924 | +| independent reviewer sweep, 2026-09-07, 48 honest reversals | **2750 W** | **15.0 s above 700 W** | 0 / 48 | +| `admin/goodwe` `two_master_circulation`, 840 runs — **committed** | 1048 W | 10.0 s at 100 W; 5.0 s above 700 W | **0 / 840** | + +**This is not proof that anything was fabricated, and it should not be reported +as one.** The three swept different grids. What the third one adds is a +measurement of *why* the spread is that wide — on one grid, changing nothing but +the model: + +| plant / stagger | worst legitimate peak | +|---|---:| +| modelled plant (lag 10 s, ramp 800 W/s), staggers up to a whole cycle | 1048 W | +| `INSTANT` (lag 0, unbounded ramp), staggers up to a whole cycle | 2250 W | +| `INSTANT`, staggers strictly under a whole cycle | **0 W** | + +A factor of 2.1×, and a factor of infinity, between three defensible +descriptions of the same rig. **The plant model and the write-stagger set are +terms of the number**, and a worst case quoted without them is not a +measurement. That is enough to explain the whole spread above without anybody +having invented anything — and it is exactly what cannot be established when the +instrument is missing. + +**What survives, and what does not.** + +* **The window argument survives, and is now committed.** The claim that + decides `circulation_hold_s: 75` is *"the worst continuous legitimate episode + above 700 W is about one control cycle"*. All three sweeps agree on the shape + of that (5.0 s, 15.0 s, 5.0 s — one to three cycles against a fifteen-cycle + window), and **all three trip zero times**. `two_master_circulation` re-runs + it on every pull request in `admin/goodwe`. +* **The threshold curve does NOT survive as stated.** `505.0 s at 100 W` is the + number the "knee at 400 W" argument for `circulation_w: 700` rests on, and the + committed sweep measures **10.0 s** at 100 W on its grid — 50× smaller. The + curve still *falls* with the threshold (126 of 840 runs cross 100 W against 1 + that crosses 700 W), so the knee is real in direction; its depth is not + established by anything anybody can run. **Do not quote 505.0 s.** +* **Nothing about the guard is compromised.** 0 trips in 924 + 48 + 840 + non-faulty runs, from three independent grids. + +**What would settle it:** the original harness, committed. Until then the +committed sweep is the only figure in this section anybody can check, and it is +labelled as its own grid's rather than as a reproduction. + ### Reproducing it +⚠️ **THE COMMITTED VERSION IS `admin/goodwe`'s `sim/inject.py +two_master_circulation`** (`sim/sitesim.py:circulation_findings`, runnable on its +own as `python3 sim/sitesim.py --circulation`, `norig`, ~36 s, graded by +`checks.yml` on every pull request there). It produces the fault with +`plantsim.Inverter.wedge()` — a unit answering its bus and no longer steering its +power stage, which is HW-01 — and sweeps the legitimate family with the +serialised write path staggered up to a whole control cycle. The paragraph below +describes the ORIGINAL campaign, which is the one that is not committed. + The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness is not committed here because it imports that repo's `plantsim`/`frame_check`, which are not present in this one or in CI. What it does is small enough to @@ -206,7 +268,11 @@ margin above the knee is what absorbs that without re-opening this card. **Revisit when SPIKE-04 lands, not before.** The **schema floor is the knee itself**, 400 W, so a legally configured install -cannot land in the 445–505 s regime. What the threshold costs: a circulating +cannot land in the 445–505 s regime — ⚠️ and those two figures are from the +uncommitted campaign; the committed sweep measures 10.0 s at 100 W on its own +grid. See the disclosure at the head of this section. The floor is still the +right shape (fewer runs cross a higher threshold, measured 126/840 at 100 W +against 1/840 at 700 W); its DEPTH is not established by anything re-runnable. What the threshold costs: a circulating loop smaller than 350 W per unit is not reported, which is deliberate — at that scale the round-trip loss is a few watts. @@ -289,7 +355,32 @@ SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded; | M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check | | M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks | | M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED | +| **M20** | `main.py` | **`pos, neg = opposing(...)` → `([], [])`** — the attribution clause deleted from the alarm | −102 B | **SURVIVED ALL 26 FILES on the shipped battery — GAP CLOSED**, then RED on §6a | +| M21 | `main.py` | `pos` and `neg` exchanged — the alarm names both packs, both the wrong way round | **+0 B** | **RED** — §6a ×3 (the mirrored run, and "only its own sentence") | +| M22 | `main.py` | `opposing(..., min_w=cfg.min_w / 2.0)` → `min_w=0.0` | −25 B | **RED** — §6a's resting-pack check | +**And four in `admin/goodwe`**, graded through `python3 sim/sitesim.py +--circulation` rather than through the injector — deliberately, because +`inject.py` is the file that takes the rig lock, and "run the injector to grade a +mutation" is not a sentence anybody should have to type. Byte deltas over +`plantsim.py` + `sitesim.py` together. + +| # | file | mutation | Δ | result | +|---|---|---|---|---| +| S1 | `plantsim.py` | `wedge()` no longer stops the latch reaching the power stage | −20 B | **RED** — the opposing-signs precondition **and** "the watchdog did not trip" | +| S2 | `sitesim.py` | the sensor computed over the **allocation record** instead of register 0x0518 | **+0 B** | **RED** ×3 — this is M10 restated on the rig | +| S3 | `sitesim.py` | the campaign run on `plant=INSTANT` | −51 B | **RED** — the plant is a *term* of the figures; it was a SURVIVOR until that check was added, and finding out why produced §3's 1048/2250/0 W table | +| S4 | `sitesim.py` | the write stagger removed from the legitimate campaign | −19 B | **RED** — "the legitimate campaign produced no opposition at all" | + +**S3 is recorded as a survivor because it was one, and it is the most useful +result in this table.** The scenario first shipped with a vacuity check whose +text claimed `plant=INSTANT` would make the sweep measure 0 W. S3 was run and +**passed**: `INSTANT` with a stagger of exactly one control cycle samples the +plant before the second unit has stepped, so it produces opposition anyway — and +a *larger* worst peak (2250 W) than the modelled plant does (1048 W). The claim +in the check's own text was wrong. It was corrected against the measurement, the +plant was pinned as an explicit term, and S3 is red now for a stated reason +rather than by accident. §3's spread table is that measurement. **M19 is recorded as a survivor because it was one.** The first battery found no check grading what `publish()` does when a per-unit reading is missing, so turning *"I do not know"* into *"0 W"* on the one surface an operator actually @@ -297,6 +388,30 @@ watches went unnoticed — the sensor's own `None` rule, defeated one layer out. A check was added and the mutation re-run; it is red now. The run that found it was purged, so it is a result and not an artefact. +**M20 IS RECORDED AS A SURVIVOR BECAUSE IT WAS ONE, AND IT IS THE MORE +UNCOMFORTABLE OF THE TWO.** The commit message for this card claimed *"18 +mutations, all RED"*, and M1–M19 above have no entry for the notification's +attribution clause. Verification on 2026-09-07 ran it and it **survived the +whole 26-file suite**: the alarm still fires, still stops the site, still says +the meter cannot see it — and no longer says *which two packs* or *which way +round*. `circulation.py`'s own docstring calls that sentence the first thing +somebody woken by this alarm needs and *"impossible to reconstruct from the +number alone"*. + +It survived for one reason, and it is not reachability: **M18 (the whole call +removed) is red**, so `check_circulating()` is exercised. The three checks on +that notification asserted `METER CANNOT SEE`, `CONTROL HAS BEEN STOPPED` and +`isolator` — and **never an inverter name**. Nothing graded the attribution. + +`test_watchdog.py` §6a closes it, and closes it on a fixture that can tell the +three failures apart. It runs a roster of **`["garage", "shed", "cellar"]`** — +operator names, none of them `UNIT_A`, for test_maintenance.py §11's reason: +on a fixture whose names are the module's own constants a check cannot tell the +roster from the constant. It runs the site **twice, mirrored**, so the sentence +is not symmetric and a swap (M21, `+0 B`) is a different red from a deletion. +And it carries a **third pack resting at 5 W**, so the floor that stops a pack +in standby being named as a participant in a 6 kW loop (M22) is graded too. + ## 7. What this card does NOT close * **Inverter B's ramp and lag are placeholders.** Every transient number in §3 @@ -305,7 +420,9 @@ was purged, so it is a result and not an artefact. margin. **Do not present any figure here as a measurement of inverter B.** * **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the notification says so; there is no software fix and this card does not pretend - to one. + to one. It *can* now be produced on demand: `plantsim.Inverter.wedge()`. +* **The depth of the threshold curve is not established by anything committed.** + §3's disclosure. The window is; the knee's magnitude is not. * **A loop below ~350 W per unit is not reported.** Deliberate, §3. * **The saturation freeze fires under a wedged unit and leaves a standing grid error.** Observed on the rig while producing §2's numbers: with B wedged, the diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index a2f1c81..8ba9338 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -722,18 +722,41 @@ print(f" e.g. {_sustained.reason!r}") print() print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE") -# ⚠️ THE SHAPES BELOW ARE THE RIG'S, NOT INVENTED. Measured on TEST-19 over 924 -# closed-loop runs in which every unit executed the share it was given: the -# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a -# peak of 1700 W. That is the serialised write path: on a reversing cycle B's -# new number lands up to a whole cycle after A's, both slew through zero at -# 800 W/s behind a 10 s lag, and between the two crossings they genuinely -# oppose. A guard that trips on that is a false-alarm generator, and this -# project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19). +# THE SHAPE BELOW IS THE RIG'S: the serialised write path means that on a +# reversing cycle B's new number lands up to a whole control cycle after A's, +# both slew through zero behind a real ramp and a real lag, and between the two +# crossings they genuinely oppose. A guard that trips on that is a false-alarm +# generator, and this project's doctrine on crying wolf is explicit (SAFETY-15, +# SAFETY-19). +# +# ⚠️ THE MAGNITUDE IS A FIXTURE INPUT, NOT A MEASUREMENT, AND THIS COMMENT USED +# TO SAY OTHERWISE. It read "the worst single transient the rig can produce - +# 1700 W", sourced to a 924-run campaign whose harness IS NOT COMMITTED IN +# EITHER REPOSITORY (DECISIONS/SAFETY-31-circulating-power.md §3 says so in its +# own words). Three sweeps have now been run against that claim and no two agree: +# +# 924-run campaign, harness uncommitted peak 1700 W, 505.0 s at 100 W +# independent reviewer sweep, 2026-09-07 peak 2750 W, 15.0 s above 700 W +# goodwe `two_master_circulation`, 840 runs peak 1048 W, 5.0 s above 700 W +# +# ⚠️ THAT IS NOT EVIDENCE THAT ANY OF THEM IS WRONG. The three swept different +# grids, and the last one measured WHY the spread is so wide: on its own grid the +# worst legitimate peak is 1048 W on the modelled plant, 2250 W on `INSTANT`, and +# 0 W on `INSTANT` with no write stagger reaching a whole cycle. The plant model +# and the stagger set are TERMS of the number, and a worst case quoted without +# them is not a measurement. It is evidence that the claim cannot be adjudicated +# while the instrument is missing. +# +# So what this check now says is what it can support: 1700 W is LARGER than +# anything any of the three sweeps produced legitimately, and one control cycle +# of it does not trip. The committed, re-runnable version of the claim is +# `admin/goodwe`'s `sim/inject.py two_master_circulation`, which runs on every +# pull request there and requires ZERO of ~840 non-faulty runs to trip. _one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30 _t1, _ = circ_drive(_one_cycle) -check("the worst single transient the rig can produce - 1700 W for ONE control " - "cycle - does not trip", not _t1.tripped) +check("a single transient of 1700 W - larger than the worst peak any committed " + "sweep of the legitimate family has produced - lasting ONE control cycle " + "does not trip", not _t1.tripped) _repeated = ([0.0] * 3 + [1700.0]) * 20 _t2, _ = circ_drive(_repeated) check("...and neither does it REPEATED every fourth cycle for 400 s, which is " @@ -1091,6 +1114,158 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS " "this' a different question from 'is this site tripped'", len(_notes2) == 1) + +print() +print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND") +# ⚠️ THIS BLOCK EXISTS BECAUSE A MUTATION SURVIVED ALL 26 FILES. Replacing +# +# pos, neg = opposing([inv.batt for inv in self.inverters], +# min_w=self.circ_wd.cfg.min_w / 2.0) +# +# in `check_circulating()` with `pos, neg = ([], [])` (-102 B) silently deletes +# the "Right now X is discharging while Y charges" clause from the notification, +# and the whole suite stayed GREEN. The block above asserts three tokens - +# METER CANNOT SEE, CONTROL HAS BEEN STOPPED, isolator - and NEVER an inverter +# name, so the one sentence `circulation.py` calls "the first thing somebody +# woken by this alarm needs" and "impossible to reconstruct from the number +# alone" was graded by nothing at all. +# +# ⚠️ IT IS NOT DEAD CODE, WHICH IS THE PART THAT MAKES IT A CHECK GAP RATHER +# THAN A REACHABILITY ONE. Ledger M18 - the `await self.check_circulating()` +# call removed - goes red on four checks above, so this function runs. What +# nothing asserted was the ATTRIBUTION. +# +# ⚠️ AND THE ROSTER IS ["garage", "shed", "cellar"], WHICH IS THE OTHER HALF OF +# THE FIX. Every fixture above runs on `inverter-a`/`inverter-b`; +# test_maintenance.py section 11 records what that costs - on a fixture whose +# names are the module's own constants, a check cannot tell the roster from the +# constant, and `UNIT_A` is not even what a nameless entry falls back to. Here +# the names are an operator's, the two participants pull OPPOSITE ways so the +# sentence is not symmetric, and a third pack rests at 5 W. That makes three +# DIFFERENT mutations - the clause dropped, the two directions swapped, and the +# `min_w` floor dropped - into three DIFFERENT red checks. A fixture naming both +# units the same, or one in which the direction did not matter, would see the +# first and neither of the others. + +_CIRC_ROSTER = [ + dict(name="garage", setpoint_entity="number.sp_a", soc_entity="sensor.soc_a", + batt_entity="sensor.batt_a", max_w=5000), + # ⚠️ B_RATING_HYPOTHESIS, and part 4's note above says why it is not 4600: + # inverter B does not physically exist and a datasheet is not a measurement. + dict(name="shed", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b", + batt_entity="sensor.batt_b", max_w=B_RATING_HYPOTHESIS), + # THE BYSTANDER. A third pack resting at standby draw, on the roster, read + # every cycle, and NOT a participant in anybody's loop. + dict(name="cellar", setpoint_entity="number.sp_c", soc_entity="sensor.soc_c", + batt_entity="sensor.batt_c", max_w=B_RATING_HYPOTHESIS), +] +_CIRC_NAMES = [e["name"] for e in _CIRC_ROSTER] +_REST_W = 5.0 + + +class _NamedCircHass(_Hass): + """Three named packs; the meter still reads exactly zero. + + Same house as `_CircHass` - grid = load - sum(B) with two units cancelling - + with one extra pack idling at standby draw so that "who is named" and "who + is merely on the roster" are different questions. + """ + + def __init__(self, a_w, b_w, c_w=_REST_W): + super().__init__() + self.vals = {"sensor.batt_a": a_w, "sensor.batt_b": b_w, + "sensor.batt_c": c_w} + + async def number(self, entity, invert=False): + return {"sensor.grid": 0.0, "sensor.soc_a": 50.0, + "sensor.soc_b": 50.0, "sensor.soc_c": 50.0, + **self.vals}.get(entity) + + +def _named_trio(hass, store, *, hold_s=1, min_w=700): + opts = {"meter_entity": "sensor.grid", "meter_source": "off", + "max_w": 5000, "gain": 0.6, "auto_start": True, + "circulation_w": min_w, "circulation_hold_s": hold_s, + "inverters": _CIRC_ROSTER} + c = Controller(opts, hass, store, _P(), stop=asyncio.Event()) + c.auto = True + for inv in c.inverters: + inv.dev_min, inv.dev_max = -5000.0, 5000.0 + return c + + +async def _circ_named(a_w, b_w): + hass = _NamedCircHass(a_w, b_w) + c = _named_trio(hass, _MemStore()) + for _ in range(14): + await c.read_inputs() + await c.cycle() + await asyncio.sleep(0.25) + notes = [x for x in hass.calls + if x[0] == "persistent_notification" + and x[2].get("notification_id") == "goodwe_circulating_power"] + return c, notes + + +_cg, _notes_g = asyncio.run(_circ_named(+3000.0, -3000.0)) +_cs, _notes_s = asyncio.run(_circ_named(-3000.0, +3000.0)) +_msg_g = _notes_g[0][2]["message"] if _notes_g else "" +_msg_s = _notes_s[0][2]["message"] if _notes_s else "" +print(f" roster : {_cg.units}") +print(f" garage discharging, measured: " + f"{dict(zip(_cg.units, [i.batt for i in _cg.inverters]))}") +print(f" -> {_msg_g.split(chr(10))[2] if _msg_g.count(chr(10)) > 2 else _msg_g!r}") +print(f" shed discharging, measured: " + f"{dict(zip(_cs.units, [i.batt for i in _cs.inverters]))}") +print(f" -> {_msg_s.split(chr(10))[2] if _msg_s.count(chr(10)) > 2 else _msg_s!r}") + +# --- THE PRECONDITIONS, AS THEIR OWN CHECKS (MUTATION.md rule 1) ------------ +check("PRECONDITION: the roster carries three DISTINCT operator-chosen names, " + f"none of them `UNIT_A` ({UNIT_A!r}) - so every claim below is a claim " + "about the roster and not about a constant that happens to equal it", + _cg.units == tuple(_CIRC_NAMES) and len(set(_CIRC_NAMES)) == 3 + and UNIT_A not in _CIRC_NAMES) +check("PRECONDITION: the two runs are MIRROR IMAGES - same house, same meter at " + "0 W, same magnitudes, and the two participants swapped. Without this the " + "direction claim below could be satisfied by a sentence that ignores the " + "measurement entirely", + [i.batt for i in _cg.inverters] == [+3000.0, -3000.0, _REST_W] + and [i.batt for i in _cs.inverters] == [-3000.0, +3000.0, _REST_W] + and _cg.grid == 0.0 and _cs.grid == 0.0) +check("PRECONDITION: both runs actually tripped and actually raised the card, " + "so an absent sentence below is an absent sentence and not an absent alarm", + _cg.circ_wd.tripped and _cs.circ_wd.tripped + and len(_notes_g) == 1 and len(_notes_s) == 1) + +# --- AC 4: THE ATTRIBUTION ITSELF ------------------------------------------- +_WHO_G = "Right now garage is discharging while shed charges." +_WHO_S = "Right now shed is discharging while garage charges." +check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. " + "`circulation.py` calls this the first thing somebody woken at 3 a.m. " + "needs and impossible to reconstruct from the watts alone, and until this " + "line the sentence could be deleted with all 26 files green", + _WHO_G in _msg_g) +check("⚠️ AND WITH THE TWO SWAPPED, THE SENTENCE SWAPS. This is the half a " + "single fixture cannot have: `pos` and `neg` exchanged, or a hard-coded " + "sentence, satisfies the check above and fails this one. The operator is " + "being told which pack to walk to", + _WHO_S in _msg_s) +check("...and each run carries ONLY its own sentence - the mirrored alarm does " + "not also contain the other direction, so the clause is derived from this " + "cycle's measurement rather than assembled from the roster", + _WHO_S not in _msg_g and _WHO_G not in _msg_s) +check("...and the RESTING pack is not named. `cellar` is on the roster, is read " + "every cycle and is measured at 5 W of standby draw - `opposing()` is " + "called with a floor of half the trip threshold precisely so a pack idling " + "beside a 6 kW loop is not reported as one of its two participants", + "cellar" not in _msg_g and "cellar" not in _msg_s + and _cg.inverters[2].batt == _REST_W) +check("...and the sentence sits with the figure, not instead of it: the same " + "card still carries the watts, the window and the three things only this " + "alarm can say", + all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m + and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m + for m in (_msg_g, _msg_s))) print() print(" the operator's press is the only way out, and it clears BOTH halves")