Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
834e7ca44b | ||
|
|
5d7e275df4 | ||
|
|
24c3d87d60 | ||
|
|
d5b9317d57 | ||
|
|
e2efc765af | ||
|
|
357cb4f07f | ||
|
|
564ba11c11 | ||
|
|
3033420dd7 | ||
|
|
a8bd2b9330 | ||
|
|
9e42cecc24 | ||
|
|
9b270a66a5 | ||
|
|
bbc903b2b4 | ||
|
|
d8e02ad3f8 | ||
|
|
4482cd8201 | ||
|
|
3527c8a04c | ||
|
|
8790c4d38f | ||
|
|
1d393ced22 | ||
|
|
3516947b5e |
@@ -1,7 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0
|
||||
|
||||
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
|
||||
[[SAFETY-32]] adds `bms_soc_floor: int(0,50)` as a **required** per-entry key on
|
||||
the `inverters` roster - no trailing `?`. A config that declares a roster and
|
||||
omits it on any entry is REFUSED at startup. An install running `inverters: []`,
|
||||
which is every single-inverter install including the reference one, is
|
||||
unaffected: the flat `bms_soc_floor` still describes the one pack.
|
||||
|
||||
CFG-04 recorded the rule this follows - the Supervisor keys the built image by
|
||||
`version:`, so a schema change without a bump is a schema nobody reads.
|
||||
|
||||
Contents, in merge order since 0.6.0 (`2d025ee`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3934ad7` | MAINT-05 - one recalibration per pack, migration graded against the roster |
|
||||
| `67e649a` | SPIKE-04a - inverter B and pack B recorded, from datasheet and serial |
|
||||
| `3993f9f` | SAFETY-32 - per-pack `bms_soc_floor`, rule 8 per unit, package-wide roster pin |
|
||||
| `f72c48d` | UX-01 - add-on configuration in English and Dutch |
|
||||
| `86eaef2` | TEL-07 - a second MQTT device for inverter B; A's identity byte-identical |
|
||||
| `4482cd8` | SAFETY-41 - the clamp alarm stops narrating normal anti-windup |
|
||||
|
||||
⚠️ **0.6.0 WAS NEVER SECTIONED.** Its content sits below this heading rather than
|
||||
under one of its own, because the bump shipped without cutting the section. That
|
||||
is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` and
|
||||
the table above is what is new since it. Splitting it retroactively would mean
|
||||
guessing a boundary; saying so is more useful than a guess.
|
||||
|
||||
## Unreleased
|
||||
|
||||
⚠️ **`slew_w` IS A RATIO TO THE RAIL NOW, AND ITS DEFAULT CHANGED FROM 1000 TO 0
|
||||
([[SAFETY-39]]).** `0` does not mean "off" and does not mean "unset" - it means
|
||||
**follow `max_w`**, exactly as `integrator_max_w: 0` already does, and resolves
|
||||
to `max_w / 9`: 222 W at the shipped 2000 W cap, 556 W at the live install's
|
||||
5000, 1067 W at a ~9600 W two-inverter site. The 9 is
|
||||
`PLANT_SETTLE_S / LOOP_TICK_S` - an 18 s measured step response against a 2 s
|
||||
meter tick - so the loop takes the plant's own settling time to cross its whole
|
||||
output range at any rail.
|
||||
|
||||
**An install that has never set `slew_w` will therefore command smaller steps
|
||||
than it did on 0.7.0, and will converge more slowly after a large disturbance.**
|
||||
That is the point rather than a side effect: `slew_w: 1000` was derived against
|
||||
a **1500 W** clamp in 2026-08 and the live install has run it against 5000 since,
|
||||
which crosses the whole range in 10 s against an 18 s plant. Measured on the
|
||||
owner's system 2026-09-05: a 3.4 kW export drove the setpoint 380 -> 5000 W in
|
||||
five ticks, the battery to -5154 W past a 5000 VA rating, and the grid from
|
||||
3.4 kW of **export** to 2.8 kW of **import**.
|
||||
|
||||
An explicit `slew_w` is still accepted and is never silently reduced. What is
|
||||
new is that a value **faster than the rail allows** is now **REFUSED AT STARTUP**
|
||||
(`validate.py` rule 10), graded against the ROSTER SUM - so `slew_w: 1000` with
|
||||
`max_w: 5000` will not start. Set it to `0`, or to any value at or below
|
||||
`max_w / 9`. Slower is always accepted, including the interim 400 the reference
|
||||
install has run since 2026-09-05.
|
||||
|
||||
⚠️ **This does NOT fix the over-absorption, and the record says so at length.**
|
||||
Re-deriving the slew removes the >= 900 W setpoint step; the loop still absorbs
|
||||
~1 kW more than the site needs on a large export step, because that is a `gain`
|
||||
term and `gain: 0.6` was measured and deliberately left alone. See
|
||||
`DECISIONS/SAFETY-39-slew-against-the-rail.md` §6, and do not read this entry as
|
||||
more than it says.
|
||||
|
||||
|
||||
**`test_peakguard.py` answered two of its own checks with the wall clock
|
||||
(CI-06).** The file sits on the required `checks / unit (pull_request)` context
|
||||
and had a measured ~20 % flake rate. It cost a wrong merge diagnosis (parents
|
||||
green, merge red — read as a semantic merge conflict, it was the flake), a false
|
||||
red on two reviewers' CONTROL runs, and a false "mutation caught" that reached a
|
||||
merged card in four places. **A flaky check does not waste a re-run; it
|
||||
manufactures evidence for whichever hypothesis the reader already holds.**
|
||||
|
||||
Both causes are the control law's own gain `900 / remaining` applied to a
|
||||
wall-clock gap the file never intended to have. Neither fix widens anything.
|
||||
|
||||
* **The cross-time projection.** The "one cycle of the intent form is exactly
|
||||
the delivered form plus the command standing on the wire" check decided the
|
||||
intent form from a **second** `PeakTracker.project(..., datetime.now())` taken
|
||||
after the real cycle had returned, and compared it against a floor decided
|
||||
from the **first** projection — three lines under a comment saying both run
|
||||
"against the same frozen projection". `excess = 900 × (projected − ceiling) /
|
||||
remaining` moves at `excess / remaining` W/s, so at a 1500 W floor the 1.5 W
|
||||
tolerance bought exactly **`remaining / 1000` seconds**: 333 ms with 880 s
|
||||
left in the quarter, **6 ms with 10 s left**. The gap between the two
|
||||
statements measured **12.7–39.5 ms** over 26 instrumented runs. The check was
|
||||
decided by *where in the quarter it ran*. It now keeps the `Projection`
|
||||
**object** the real cycle graded and decides both forms from it — the clock is
|
||||
out of the comparison and the tolerance is **exact**, not widened. Measured on
|
||||
a real cycle: re-deciding the delivered form from that object reproduces the
|
||||
controller's own floor to **0.000000000000 W**.
|
||||
* **The fixture's own room.** `wired_over_limit()` laid telegrams with
|
||||
`range(0, span + 1, 5)`, leaving **0–5 s of the open block uncovered by
|
||||
construction**, and `excess` multiplies an uncovered second back up by
|
||||
`900 / remaining`. Measured on the shipped file: at **12 s of remaining a
|
||||
fixture that asked for a 1500 W floor delivered 613 W (−59 %)**, and at 2 s
|
||||
the guard **did not engage at all** — which fails "the real cycle files the
|
||||
peak claim", "…and the number written to the inverter is the guard's floor"
|
||||
and "the STATUS PAGE row for a floor-limited guard" **together**. The
|
||||
four-attempt retry could not help: four attempts take ~40 ms and all land in
|
||||
the same dying quarter. Fixed with one telegram stamped at `now` (what a real
|
||||
meter does, and what the docstring already claimed) plus `FIXTURE_ROOM_S`,
|
||||
**derived** from the 1 % accuracy the helper promises rather than picked:
|
||||
`net_w × lag / (remaining × want) ≤ 0.01 − lag/900` → **23.6 s → 24 s**, at
|
||||
the measured 0.1 s worst-case lag.
|
||||
|
||||
**Nothing was skipped, disabled or marked xfail** — the file still carries all
|
||||
of SAFETY-07 and SAFETY-25's forced-discharge SoC floor, and it gains a check:
|
||||
the delivered form re-decided from the cycle's own projection must reproduce the
|
||||
real controller's number to the bit, which pins the test's `decide()` kwargs as
|
||||
main.py's actual call rather than an approximation of it. **One tolerance got
|
||||
stricter** (1.5 W → exact), because removing the clock made exactness possible.
|
||||
|
||||
**`site_standing` — the one quantity BOTH site guards are asked about — is now
|
||||
pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).**
|
||||
`app/main.py`'s `site_standing = float(sum(standing.values()))` is the first
|
||||
@@ -82,6 +192,56 @@ shipped `sum` is correct and must not be "fixed".**
|
||||
unit's share and never the site target. A site asking for −500 W while −250 W
|
||||
stands would then take a **latching site-wide false trip** under its own
|
||||
300 W gate. Closed by part 9a's device-clamped counterfactual.
|
||||
* **⚠️ AND THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER — THE ONE A SHIPPED
|
||||
INSTALL CAN ACTUALLY SEE.** The ordered pair went onto `main.py:1620`, the
|
||||
**site** detector. That detector is armed only at N≥2, and part 9c asserts it
|
||||
is **absent at a roster of one** — so on every install this add-on has, the
|
||||
**per-unit** banner is the only sign-disagreement sentence that can ever be
|
||||
printed, and its call at **`main.py:1560`** was still unpinned. The identical
|
||||
swap there is **0 bytes** and left the tree **21/21 green**, and nothing
|
||||
behavioural could ever have caught it: `disagrees()` is **symmetric** in those
|
||||
two arguments (`commanded_w * against_w < 0`, `abs()` gate on both), so the
|
||||
mutant takes **identical actions on every input**. What moves is the sentence
|
||||
— measured, **9 emitted banner lines invert in 3 distinct sentences**,
|
||||
including the one persisted to `sign_reason` and read back after a restart:
|
||||
*"commanded +1000 W against its own measured power −1000 W"* becomes
|
||||
*"commanded −1000 W against its own measured power +1000 W"*. Closed by the
|
||||
ordered pair on **both** per-unit banners — the N≥2 `its own measured power`
|
||||
one and the **N=1** `a meter reading` one — with both operands read off the
|
||||
**observed cycle** rather than off the arguments the detector was handed, plus
|
||||
a precondition on each that the two operands really are different and opposite
|
||||
in sign. **Red on four named checks, green in the other twenty files. No
|
||||
`app/` change.**
|
||||
* **⭐ AND THE FAMILY IS NOW ENUMERATED BY MACHINE, because it had been
|
||||
enumerated by hand twice and was short both times.**
|
||||
`tools/symmetric_message_scan.py` finds every call whose argument order is
|
||||
load-bearing for a **message** rather than for a decision: it renames each
|
||||
parameter pair, erases every formatted string, canonicalises the commutative
|
||||
operators and iterates to a **fixpoint** (the symmetry is usually one call
|
||||
deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||||
classifies each sentence by where it goes. A second census covers sentences
|
||||
the **caller** builds. It found **one open survivor this card does not close
|
||||
and now discloses** — `rail_unreachable()`'s `dev_min`/`dev_max` at
|
||||
`main.py:3208`, **0 bytes, 21/21 green**, a status-page row — and **twelve**
|
||||
unpinned caller-built sentences including SAFETY-31's `circ_reason`, which is
|
||||
the same shape and is also persisted. The list, the pinned status of every
|
||||
member and the instrument's own blind spots are in
|
||||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7.
|
||||
* **⚠️ AND A LEDGER SENTENCE THIS CARD ITSELF GOT WRONG, CORRECTED WITH THE
|
||||
MEASUREMENT.** An earlier revision said *"before part 9 existed, **three** of
|
||||
these left the whole suite green — M1, M2 and M3 — and the remaining seven
|
||||
were caught by SAFETY-30's own N≥2 checks"*. **Eight** did. Re-run one at a
|
||||
time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged,
|
||||
byte delta and `main.py` fingerprint printed, **full 21-file suite** every
|
||||
time: M1, M2, M3, M4, M7, M8, M9 and M10 are all **21/21 PASS**; only M5 and
|
||||
M6 go red. It is the same species of error this round exists to fix — **a
|
||||
survivor recorded as a kill** — and the file contradicted itself three times
|
||||
while saying it. **And the two that ARE caught make the card's case:** M5
|
||||
(`sum(abs …)`) and M6 (`-sum`) are the only two that reverse the **sign**.
|
||||
Every other entry changes only the **magnitude**, and the old `_sign_site`
|
||||
fixture's ~−2500 W shares put nine different magnitudes on the **same side of
|
||||
the same 320 W gate**. The pre-existing coverage could see a sign and nothing
|
||||
else.
|
||||
* **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to
|
||||
the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`,
|
||||
bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the
|
||||
@@ -95,6 +255,75 @@ shipped `sum` is correct and must not be "fixed".**
|
||||
it is recorded rather than quietly dropped — unlike the one that *was*
|
||||
believed, two bullets up. Ledger, and an **enumerated list of what was not
|
||||
mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a.
|
||||
* **⭐ ROUND 4: THE SCANNER COVERED ONE SYNTACTIC SINK AND CALLED IT THE
|
||||
FAMILY, AND TWO LIVE MEMBERS WERE SITTING IN THE HOLE.** Every one of census
|
||||
B's 16 members was a sentence that is **appended, assigned, or logged**. Not
|
||||
one was **RETURNED** — and census A's return bucket only ever held a callee
|
||||
formatting its own *parameters*, so a method formatting its own **attributes**
|
||||
and handing the string back was invisible to both. That is the whole
|
||||
`describe()` / `explain()` family, which is where this codebase keeps its
|
||||
operator sentences. **Worse than the gap: it was in none of the five blind
|
||||
spots the tool printed about itself.** The tool now scans the return sink
|
||||
(census C), resolving it through the returning method's **call sites** so that
|
||||
a returned *key* — `store.unit_key()`, `mqtt.unit_state_key()` — is not
|
||||
mistaken for a returned *sentence*; it also scores a sentence handed to a
|
||||
constructor as `reason=` or placed under a `"text"` dict key, which added
|
||||
**six more caller-built members** it had been stepping past. Re-run, it
|
||||
**rediscovered both of the members this round was sent to fix without being
|
||||
told where to look**.
|
||||
* **⭐ `ClampResult.describe()` — the clamp alarm the owner has been complaining
|
||||
about all day — had a SECOND independent way to lie, and no `test_*.py`
|
||||
mentioned `describe()` at all.** Transposing `requested_w` and `applied_w` in
|
||||
`clamp.py:253` is a **zero-byte** edit that leaves the guard reporting a
|
||||
setpoint clamped **UPWARD**, in the measured case to +9999 W by a bound whose
|
||||
rating the same sentence gives as 2000 W. It is the §15 action-log row
|
||||
(`main.py:1366`) **and** the ClampWatchdog notification reason
|
||||
(`main.py:2452`). Measured **21/21 green** before; now **red on 4 checks** in
|
||||
`test_clamp.py` part 6d, pinned on the **ordered pair** on the contiguous
|
||||
literal at **both** consumers, with operands read off `ctl.target` and
|
||||
`hass.writes[-1]` — what the loop asked and what the device was sent — rather
|
||||
than off the `ClampResult` the swap lives in.
|
||||
* **⭐ `PeakDecision.explain()` — the status-page banner — the same shape.**
|
||||
`projected_w` ↔ `ceiling_w` at `peakguard.py:493`, **zero bytes**, and the
|
||||
row then reads *"quarter projects 5013 W under a 2300 W ceiling"*: a quarter
|
||||
**2713 W over** its ceiling, on a row whose own words say *idle* and whose own
|
||||
flag says **ok**. `"peak guard idle"` appeared in no `test_*.py`. Measured
|
||||
**21/21 green** before; now **red on 3 checks** in `test_peakguard.py` part 13,
|
||||
pinned on the decision and on the shipped status row at `main.py:3313`.
|
||||
⚠️ **A correction the ticket is owed:** the idle sentence does not reach
|
||||
`main.py:3299/3302` — those are the *floor-limited* branch, where `explain()`
|
||||
returns `self.reason` before the idle literal is built. It reaches
|
||||
**`main.py:3313`** and, on the release edge, `main.py:1951`.
|
||||
* **`test_arbiter.py`'s scope-violation check made a claim WIDER than its
|
||||
assertion** — it said `scope_violations` *"names the source and the actuator
|
||||
it does not belong on"* and asserted `"peak" in scope_errors[0]`, which is
|
||||
still true after the two swap roles. Measured this round: **0 bytes, 21/21
|
||||
green**; now red on 2 checks, pinned on the ordered pair.
|
||||
* **14 more zero-byte mutations measured, and 10 SURVIVORS ARE DISCLOSED RATHER
|
||||
THAN CLOSED**, each with its file fingerprint and its 21/21 verdict, in
|
||||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7e–§7g: **two** in
|
||||
`allocator.py`'s `UnitPlan.explain()`, three across `arbiter.py`'s two
|
||||
`explain()`s, two in `tariff.py`, two in `arbitrage.py`'s trade reasons and
|
||||
one in `main.py`'s SoC-floor notification. The other four of the fourteen are
|
||||
kills: `allocator.py:162` was already caught by `test_alloc.py`, and
|
||||
`clamp.py:253`, `peakguard.py:493` and `arbiter.py:264` are closed here. **An enumerated list with honest
|
||||
gaps beats a fifth round finding a fifth instance.**
|
||||
⚠️ **And the probe that grades those mutations was itself the defect, once.**
|
||||
Its first cut numbered the operands **by order of appearance**, so after a
|
||||
transposition the other leaf took the same number and all thirteen renders came
|
||||
back **byte-identical** — which reads exactly like *the edit is inert*. Keying
|
||||
the value to the **name** fixed it. A measurement comparing the swap against
|
||||
itself, in the instrument this time.
|
||||
* **The tool now states what it does NOT scan**, largest hole first:
|
||||
`%s`-style lazy logging (`_LOG.error("… %s … %s", a, b)` puts the operands in
|
||||
*arguments*, so no census here looks at them and the transposition is the same
|
||||
zero-byte edit — **31 such calls across 8 modules, counted and listed by the
|
||||
tool rather than asserted**), `.format()`/`%`/`+`/`.join()` sentences, a sentence that
|
||||
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
|
||||
sentences, message attributes outside the `reason`-shaped set, and everything
|
||||
outside `app/`. Its five over/under-report gaps stay, and every census row now
|
||||
prints whether the two interpolations **share a format spec** — the difference
|
||||
between a transposition that is silent and one that merely looks broken.
|
||||
|
||||
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
|
||||
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
|
||||
|
||||
@@ -231,15 +231,73 @@ fingerprint, and verifies no cache appears during the run). The mutated line was
|
||||
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
|
||||
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
|
||||
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
|
||||
| **M11** | **argument SWAP** at the call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
|
||||
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
|
||||
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
|
||||
|
||||
**Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2
|
||||
**Every one of the ten `site_standing` entries is red in `test_watchdog.py`.** Re-measured in round 2
|
||||
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
|
||||
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
|
||||
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
|
||||
Before part 9 existed, **three** of these left the whole suite green — M1, M2
|
||||
**and M3** — and the remaining seven were caught by SAFETY-30's own N≥2 checks,
|
||||
none of which grade the *quantity*.
|
||||
Adding assertions cannot turn a red green, so those verdicts carry forward to
|
||||
the round-3 tree unchanged.
|
||||
|
||||
### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.**
|
||||
|
||||
Round 2 wrote: *"Before part 9 existed, three of these left the whole suite
|
||||
green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own
|
||||
N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it
|
||||
**specific and wrong**, and it is the same species of error the round was
|
||||
written to fix — **a survivor recorded as a kill**. This file contradicted it
|
||||
three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*,
|
||||
§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too.
|
||||
|
||||
**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @
|
||||
`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the
|
||||
line re-read from disk, byte delta **and** `app/main.py` fingerprint printed,
|
||||
mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than
|
||||
`test_watchdog.py` alone. (`release/1.0` has since moved on to `9e42cec`, which
|
||||
merged [[CI-06]]; that commit touches `test_peakguard.py` and `CHANGELOG.md`
|
||||
only, neither of which grades `site_standing`, so the ten verdicts below stand
|
||||
on the tree they were taken on and this branch is rebased onto the newer tip.)
|
||||
|
||||
| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 |
|
||||
|---|---|---|---|
|
||||
| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** |
|
||||
| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** |
|
||||
| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** |
|
||||
| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** |
|
||||
| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** |
|
||||
| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** |
|
||||
| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** |
|
||||
| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** |
|
||||
|
||||
**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not
|
||||
three and seven, and not the four and six a reviewer proposed either. The
|
||||
correct sentence is: *before part 9 existed, eight of these ten left the whole
|
||||
21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5
|
||||
and M6.*
|
||||
|
||||
**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S
|
||||
CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are
|
||||
the only two entries in the table that **reverse the SIGN** of `site_standing`.
|
||||
Every other entry changes only its **magnitude** — and `_sign_site`, the suite's
|
||||
only N≥2 site fixture before this card, runs shares of about **−2500 W each**
|
||||
against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`,
|
||||
`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land
|
||||
on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine
|
||||
different numbers, all on the **same side of the same threshold**. The three
|
||||
checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the
|
||||
claim it trips through, and where the latch is written — none of them grades the
|
||||
**quantity**, and the sign flip reaches them only because it stops the trip
|
||||
happening at all.
|
||||
|
||||
That is the whole thesis of this card measured rather than argued: **the
|
||||
pre-existing coverage could see a sign, and nothing else.** Part 9's fixture —
|
||||
per-unit shares each **below** the gate whose **sum** clears it — is the only
|
||||
arrangement in which the magnitude is load-bearing, and it is what takes the
|
||||
other eight red.
|
||||
|
||||
⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported
|
||||
`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more
|
||||
@@ -250,6 +308,17 @@ and that **no claim in this card rests on it**. `test_peakguard.py` is the file
|
||||
CI-06 records as flaky, and this is the behaviour it records. Believe no red
|
||||
from it without three isolated re-runs.
|
||||
|
||||
⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the
|
||||
ten full-suite runs above, plus §7's sibling swaps — produced **no** red from
|
||||
`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one
|
||||
elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the
|
||||
failing check *was* captured: *"one cycle of the intent form is exactly the
|
||||
delivered form plus the command standing on the wire"* — which is CI-06's
|
||||
**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then
|
||||
passed **four for four** in isolation on that same tree. That is the first time
|
||||
this card has been able to put a name to one of these reds, and it is the name
|
||||
CI-06 predicts.
|
||||
|
||||
⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded
|
||||
M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has
|
||||
the correction and the mechanism: `[0]` **survives** a full green 21-file suite
|
||||
@@ -321,6 +390,73 @@ coverage rather than merely inheriting it. **W2 is caught entirely by
|
||||
pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not
|
||||
claim to.
|
||||
|
||||
### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see
|
||||
|
||||
Round 2 closed §4a by asserting the ordered pair on the **site** banner at
|
||||
`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the
|
||||
identical swap there —
|
||||
|
||||
```python
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped
|
||||
if inv.sign_wd.evaluate(against, stood, self.following, now) # M12
|
||||
```
|
||||
|
||||
— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes
|
||||
before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode
|
||||
purged, tree mounted `:ro`, the line re-read from disk.
|
||||
|
||||
**Nothing behavioural can catch it, and that is structural rather than
|
||||
unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments —
|
||||
`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap
|
||||
cannot change a single trip decision on any input. What it changes is the
|
||||
**sentence**. Measured by diffing the suite's own printed banners between the
|
||||
round-3 tree and the same tree carrying only M12: **9 emitted banner lines
|
||||
invert, in 3 distinct sentences.**
|
||||
|
||||
```
|
||||
shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W …
|
||||
M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W …
|
||||
|
||||
shipped: … commanded +1000 W against its own measured power -1000 W …
|
||||
M12: … commanded -1000 W against its own measured power +1000 W …
|
||||
|
||||
shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm)
|
||||
M12: … commanded +4000 W against a meter reading -2000 W …
|
||||
```
|
||||
|
||||
**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE
|
||||
detector is **not armed at a roster of one**, and inverter B does not physically
|
||||
exist. So on the shipped install the **per-unit banner is the only
|
||||
sign-disagreement sentence that can ever be printed** — in the action log, in
|
||||
`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`,
|
||||
which is persisted and read back after the restart the latch is designed to
|
||||
survive. Round 2 pinned the N≥2-only call site and left the shipping one open.
|
||||
|
||||
**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read
|
||||
off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the
|
||||
plant moved, never off the arguments the detector was handed, which would be
|
||||
comparing the swap against itself:
|
||||
|
||||
* `test_watchdog.py` **part 3**, the N≥2 per-unit trip:
|
||||
`f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`,
|
||||
plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition**
|
||||
that the two operands are different numbers of opposite sign — without which
|
||||
a transposition would produce the same string and the check would be
|
||||
decoration;
|
||||
* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip:
|
||||
`f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the
|
||||
namespaced key **and** the flat rollback mirror, plus the same precondition.
|
||||
|
||||
**Measured after the fix: M12 is RED on four named checks in
|
||||
`test_watchdog.py` and GREEN in the other twenty files** — one file, four
|
||||
checks. M11 re-measured on the same tree: still red.
|
||||
|
||||
**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed"
|
||||
enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s
|
||||
body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in
|
||||
the file round 2 edited, is *"a variant is not covered because a near-miss of it
|
||||
is."* §7 is the enumeration that would have caught both instances at once.
|
||||
|
||||
## 5. The survivor this card went looking for, and closed
|
||||
|
||||
M7 is not one of the card's three. It was found by asking the question the
|
||||
@@ -406,6 +542,384 @@ disk, byte delta printed):**
|
||||
re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is
|
||||
otherwise carried forward from round 1's measurement on `f72c48d`. It is a
|
||||
weaker statement than round 1 made and is written down as such.
|
||||
⚠️ **Round 3 closed the other half of this**, but only in the *before*
|
||||
direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the
|
||||
**full 21-file suite** to establish which of them the pre-existing coverage
|
||||
could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not
|
||||
three**. The *after* direction — "each of the ten is red in
|
||||
`test_watchdog.py` and green in the other twenty **on the round-3 tree**" —
|
||||
is still measured end-to-end only for **M3, M11 and M12**. For the other
|
||||
seven, "red" carries forward from round 2 (which adding assertions cannot
|
||||
undo) and "green elsewhere" carries forward from round 1.
|
||||
* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not
|
||||
this card's subject. It is *characterised* here (five isolated runs on the M3
|
||||
tree, all PASS) only far enough to overturn the false kill it produced.
|
||||
|
||||
### 6b. Round 3: what was closed, and what is still open
|
||||
|
||||
**Closed in round 3:**
|
||||
|
||||
1. **M12**, the zero-byte argument swap at the **per-unit** call site
|
||||
`main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21
|
||||
green**, and it is the **only** sign-disagreement banner a shipped
|
||||
one-inverter install can print. Red on four named checks, green in the other
|
||||
twenty files.
|
||||
2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See
|
||||
§4's own ⚠️ paragraph: re-measured mutation by mutation on clean
|
||||
`release/1.0` @ `bbc903b`, full 21-file suite each time.
|
||||
3. **The family itself is now enumerated by machine** —
|
||||
`tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time.
|
||||
|
||||
**Open, named, and NOT claimed:**
|
||||
|
||||
* **A3 and B1** — the only two status-page rows in which the freeze detector
|
||||
ever names a device range: the **NOT COVERED** row and the **armed** row's
|
||||
"below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the
|
||||
literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both
|
||||
measured: **0 bytes each, 21/21 green each.** Real, open members of the
|
||||
family. They are **status-page rows rather than latching alarms**, and closing
|
||||
them wants a fixture with a device whose two bounds are **asymmetric** — every
|
||||
fixture in this repository uses `±d`, so the transposition is currently
|
||||
invisible by construction — which belongs to `test_freeze.py`'s card, not to
|
||||
this one.
|
||||
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
|
||||
level** — the distinguishing literal appears in no `test_*.py` — and **not**
|
||||
mutated. Weaker evidence than A1–A4 and B1, and written down as such
|
||||
— §7d says the same thing with the same list.
|
||||
* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card
|
||||
closed for SAFETY-02/30 and is *also* persisted and re-read after a restart.
|
||||
§6a already declines to touch `circ_wd`; §7d says what that costs.
|
||||
* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other
|
||||
`CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard.
|
||||
|
||||
### 6c. Round 4: what was closed, and what is still open
|
||||
|
||||
**The blocker round 3 shipped.** The enumeration read as complete and covered
|
||||
**one syntactic sink**. Every one of census B's 16 members was a sentence that
|
||||
is **appended, assigned, or passed as a log argument** (`problems.append`,
|
||||
`out.append({"text": …})`, `self.reason = …`, `_LOG.info`). **Not one was a
|
||||
sentence that is RETURNED.** Census A has a return bucket, but only for
|
||||
symmetric **parameters**, so a method formatting its **own attributes** and
|
||||
handing the string back was invisible to both — and `describe()` / `explain()` /
|
||||
the reason-builders are exactly where this codebase keeps its operator
|
||||
sentences. Worse than the gap: it was in **none of the five blind spots the tool
|
||||
prints about itself**. A tool that asserts a completeness it does not have is
|
||||
the recurring defect wearing a new hat.
|
||||
|
||||
**Closed in round 4:**
|
||||
|
||||
1. **`clamp.py:253` `ClampResult.describe()`**, pair `requested_w` /
|
||||
`applied_w` — §7e C6. A survivor of the round-3 tree at **21/21 green**,
|
||||
**0 bytes**. It is the §15 action-log row at `main.py:1366` **and** the
|
||||
ClampWatchdog notification reason at `main.py:2452`; **no `test_*.py`
|
||||
mentioned `describe()` at all.** Pinned on the ordered pair, on the
|
||||
contiguous literal, at **both** consumers, with a precondition that the two
|
||||
operands render differently — `test_clamp.py` part 6d. **4 checks red** under
|
||||
the transposition.
|
||||
2. **`peakguard.py:493` `PeakDecision.explain()`**, pair `projected_w` /
|
||||
`ceiling_w` — §7e C7. Same measurement: **0 bytes, 21/21 green**; the string
|
||||
`"peak guard idle"` was in no `test_*.py`. Pinned on the decision and on the
|
||||
shipped status row at `main.py:3313` — `test_peakguard.py` part 13. **3
|
||||
checks red.**
|
||||
3. **`arbiter.py:264` (B12)** — the check whose **claim was wider than its
|
||||
assertion**, carried forward from §7d. Measured this round rather than left
|
||||
at source level: **0 bytes, 21/21 green** before, **`test_arbiter.py` red**
|
||||
after. **2 checks red.**
|
||||
4. **The tool's sinks are now stated rather than implied.** It prints what it
|
||||
scans as a MESSAGE, that it now scans the RETURN sink through the returning
|
||||
method's call sites, and — the part that was missing — **an explicit list of
|
||||
what it does not scan at all**, largest hole first.
|
||||
|
||||
**⚠️ The correction round 4 owes the ticket.** The brief placed
|
||||
`PeakDecision.explain()`'s idle sentence at `main.py:3299/3302`. Those two lines
|
||||
are the **floor-limited** branch, where `explain()` returns `self.reason` before
|
||||
the idle literal is ever built. The idle sentence reaches the page at
|
||||
**`main.py:3313`** and the action log at **`main.py:1951`** (edge-logged, so it
|
||||
is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed
|
||||
below, unpinned.
|
||||
|
||||
**Open, named, and measured — see §7f for the ledger:**
|
||||
|
||||
* **TEN further zero-byte survivors** found by the widened scan, each measured
|
||||
at 21/21 green on this tree: **two** in `allocator.py`'s `UnitPlan.explain()`
|
||||
(C1 :154, C2 :160), three in `arbiter.py`'s two `explain()`s (C4a, C4b :344,
|
||||
C5 :392), two in `tariff.py` (C8, C9), two in `arbitrage.py`'s trade reasons
|
||||
(B17, B18) and one in `main.py`'s SoC-floor notification (B19).
|
||||
None is a control decision; every one is a sentence an operator reads.
|
||||
⚠️ **CORRECTED after review, and the error is worth keeping visible.** This
|
||||
bullet first said "Nine" while listing eleven, and the CHANGELOG said eleven
|
||||
while listing eleven — and BOTH counted `allocator.py:162` (C3) among the
|
||||
survivors when §7f records it as a KILL, red in `test_alloc.py`. The
|
||||
authoritative tables in §7e and §7f were right throughout; only the summary
|
||||
sentences a reader actually relies on were wrong. That is this card's own
|
||||
recurring defect in prose rather than in code: a claim wider than the
|
||||
measurement beneath it. Arithmetic, from §7f: 14 measured = 10 surviving +
|
||||
1 already caught (C3) + 3 caught by this round (C6, C7, B12).
|
||||
* **The action-log surface of the peak-guard idle line** (`main.py:1951`) is
|
||||
not pinned. The status row (3313) is, and both come off the same `explain()`,
|
||||
so the *sentence* is closed and the *second call site* is not — which is
|
||||
precisely the distinction §4c was written about, recorded rather than assumed.
|
||||
* Everything §6a and §6b list stays open.
|
||||
|
||||
## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE
|
||||
|
||||
**Why this section exists, and it is the most useful thing in the card.** Round 2
|
||||
pinned the operand order of `SignWatchdog.evaluate()` at `main.py:1620`. Round 3
|
||||
found the **identical zero-byte defect at `main.py:1560`**, one call site over —
|
||||
and that one is worse, because `test_watchdog.py` part 9c asserts the site
|
||||
detector is **never armed at
|
||||
a roster of one**, so on the shipped install the per-unit banner is the **only**
|
||||
sign-disagreement sentence that can ever be printed. Twice in one card the
|
||||
enumeration was written by hand and twice it was short. MUTATION.md habit 12: *a
|
||||
hand-written scope is the same defect as a hand-written fixture.* So the family
|
||||
is now **computed**, by `tools/symmetric_message_scan.py`.
|
||||
|
||||
### 7a. What makes this family invisible, and why it is not a fixture problem
|
||||
|
||||
Every other defect in this document is a fixture problem: *no fixture happens to
|
||||
separate the mutant from the shipped code.* Build the right fixture and it goes
|
||||
red. This one is not that.
|
||||
|
||||
Where a callee is **symmetric** in two arguments, a swap at a call site cannot
|
||||
change **any** action on **any** input. `watchdog.disagrees()` is
|
||||
`commanded_w * against_w < 0` with an `abs()` gate on **both** sides: the
|
||||
product commutes and the two gates are the same gate. There is nothing for a
|
||||
behavioural check to observe, ever — **not "no fixture separates them" but no
|
||||
fixture *can*.** The only surface that moves is the **sentence**, and the only
|
||||
check that can see it is one asserting the **ordered pair** (MUTATION.md habit
|
||||
14) at **that** call site.
|
||||
|
||||
Two consequences the earlier rounds got wrong:
|
||||
|
||||
* **The unit of coverage is the CALL SITE, not the function.** `evaluate()`'s
|
||||
operand order was pinned and the defect still shipped, because the pin was on
|
||||
the *other* call.
|
||||
* **A behavioural suite is not weak evidence here, it is NO evidence.** "21/21
|
||||
green" is what this mutation is *guaranteed* to produce. Measured, twice: M11
|
||||
at `main.py:1620` on the round-1 tree, and M12 at `main.py:1560` on the
|
||||
round-2 tree.
|
||||
|
||||
### 7b. Census A — the callee formats its own parameters
|
||||
|
||||
`tools/symmetric_message_scan.py` renames each parameter pair, erases every
|
||||
formatted string, canonicalises the commutative operators (`*`, `+`, `and`,
|
||||
`or`, `==`, `min`/`max`, iterated tuple literals) and compares the **bodies**;
|
||||
it iterates to a **fixpoint**, because the symmetry is usually one call deep —
|
||||
`evaluate()` is symmetric only *through* `disagrees()`, and a single pass
|
||||
reports an empty family on a tree whose answer is already known. It then
|
||||
classifies each sentence by **where it goes**.
|
||||
|
||||
Fixpoint on this tree: **3 rounds, 17 interchangeable positional and 5 keyword
|
||||
pairs across 14 functions**. 45 parameter pairs reach a sentence; **8** are
|
||||
symmetric — **5** whose sentence goes only to an operator, and **3** whose
|
||||
sentence is the **return value** and therefore has to be classified by hand,
|
||||
because a returned string is sometimes a KEY and sometimes a SENTENCE.
|
||||
|
||||
| # | scan row | pair | called at | swap costs | reaches an operator via | pinned? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A1** | `watchdog.py:613` `SignWatchdog.evaluate()` | `(commanded_w, against_w)` | **`main.py:1560`** — per-unit, **and the N=1 detector** | **0 B** | `sign_reason` (persisted), the action log, `_LOG.error`, `status_payload()`'s headline banner | **YES — round 3.** `test_watchdog.py` part 3 (`its own measured power`) and part 7 (`a meter reading`), on the contiguous literal with both operands read off the observed cycle. **Measured: 0 B, red on 4 named checks, green in the other 20 files.** |
|
||||
| **A2** | same row, second call site | `(commanded_w, against_w)` | **`main.py:1620`** — site | **0 B** | `site_sign_reason`, the action log, the status page | **YES — round 2**, part 9a. Re-measured on the round-3 tree: still red. |
|
||||
| **A3** | `freeze.py:405` `FreezeWatchdog.evaluate()` and `freeze.py:311` `rail_unreachable()` — both symmetric through `rail_ceiling_w()`'s `for bound in (dev_min, dev_max)` | `(dev_min, dev_max)` | **`main.py:3208`** (and 1928, 2755, 3196, 3219) | **0 B** | the status page's `freeze detector: NOT COVERED — the setpoint device reports a range of …` row | **NO.** Measured: swapped at `main.py:3208-3209`, **0 bytes**, `main.py` fingerprint `61aa413f…` → `2305b560…`, **21/21 green**. Disclosed in §7d, not closed. ⚠️ `rail_unreachable()` is the row the scan puts in its **RETURN** bucket: its returned string is a **sentence**, not a key, so it is a family member after all — which is exactly why that bucket says *read by hand*. |
|
||||
| **A4** | `static_tariff.py:522` `_raise_gap()` | `(start, end)` | `static_tariff.py:509`, `:513` | **0 B** | the `ProfileInvalid` refusal an operator reads at startup | **YES — pre-existing.** Measured: both call sites swapped, **0 bytes**, `static_tariff.py` fingerprint `caef7fde…` → `50e9a768…`, **red in `test_static_tariff.py` on two named checks** (*"…and names the uncovered range so the operator can fix it"*, *"…and the refusal names the uncovered range"*), because `_hhmm(start)}-{_hhmm(end)` is asserted as the contiguous literal `"22:00-23:00"`. ⚠️ That batch run also reported `FAIL test_peakguard.py` on a `static_tariff.py`-only edit; the failing check is *"one cycle of the intent form is exactly the delivered form plus the command standing on the wire"*, which is [[CI-06]]'s **mechanism 1 by name**, and `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. |
|
||||
| A5–A6 | `static_tariff.py:522` `_raise_gap()` | `(start, weekday)`, `(end, weekday)` | — | — | — | **not real — the scan's one over-report.** It erases the sentence, so it cannot see that `DAY_NAMES[start]` with `start = 1320` raises `IndexError`. A mixed-type pair fails loudly, which is the opposite of this family. |
|
||||
| A7–A8 | `store.py:177` `unit_key()`, `mqtt.py:222` `unit_state_key()` | `(unit, key)`, `(slug, key)` | many | 0 B | — | **not this family.** The formatted string is the **return value and it is a KEY**: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it. |
|
||||
|
||||
### 7c. Census B — the caller builds the sentence
|
||||
|
||||
Census A only sees a callee formatting its **own** parameters, which was gap 2
|
||||
of its own honest-gap list. The other half never crosses a function boundary:
|
||||
an operator sentence built where it is printed, interpolating two values that
|
||||
are **interchangeable by shape**. No predicate is involved at all, so census A's
|
||||
symmetry test has nothing to test. The scan pairs interpolated expressions that
|
||||
are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` /
|
||||
`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only
|
||||
sentences whose sink is a **message**: an f-string that becomes a URL or a store
|
||||
key is data, and a swap there is an ordinary behavioural change. **16 candidates** in round 3, every one of them a **zero-byte** edit — and **22** after round 4 widened the sink classifier (B17–B22 below):
|
||||
|
||||
| # | site | pair | reaches an operator as | pinned? |
|
||||
|---|---|---|---|---|
|
||||
| **B1** | `main.py:3231` | `inv.dev_min` / `inv.dev_max` | status page, `… accepts only −120…130 W` | **NO — MEASURED.** Transposed in place, **0 bytes**, `main.py` fingerprint `61aa413f…` → `ac36a97b…`, purged, `:ro`, **21/21 green**. The row then tells the operator the device accepts `130…−120 W`. |
|
||||
| **B2** | `main.py:3362` | `inv.dev_min` / `inv.dev_max` | status page, `setpoint … (range −120…130 W)` | **NO** — the literal `setpoint … (range` is in no `test_*.py`. |
|
||||
| **B3** | `main.py:3435` | `sign_wd.cfg.hold_s` / `.cfg.min_w` | status page, `sign watchdog: armed (trips after 180 s opposed, both over 300 W)` | **NO** — `s opposed` is in no `test_*.py`. |
|
||||
| **B4** | `main.py:3460` | `site_sign_wd.cfg.hold_s` / `.min_w` | status page, the site sign row | **NO** — `armed against the sum` is in no `test_*.py`. |
|
||||
| **B5** | `main.py:3491` | `circ_wd.cfg.hold_s` / `.min_w` | status page, the circulation row | **NO** — `circulation watchdog: armed` is in no `test_*.py`. |
|
||||
| **B6** | `circulation.py:317` | `cfg.min_w` / `cfg.hold_s` | **`circ_reason` — persisted, and re-read after a restart** | **NO** — `test_watchdog.py` asserts only `"CIRCULATING_POWER" in reason`. **This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30**, on a string with the same persistence. |
|
||||
| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. |
|
||||
| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. |
|
||||
| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. |
|
||||
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **ROUND 4: MEASURED, THEN CLOSED.** Round 3 left it at source level; the transposition is **0 bytes**, `arbiter.py` `fdb4ed0520eeb721` → `9111f627ef639ecc`, and it was **21/21 green**. Now red in `test_arbiter.py` on 2 checks. Round 3's reasoning was right: `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
|
||||
| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. |
|
||||
| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. |
|
||||
| **B17** | `arbitrage.py:377` | `v.spread_eur_kwh` / `v.required_spread_eur_kwh` | the DISCHARGE decision's `reason` | **ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green.** Not visible to round 3 at all — the sentence is handed to a constructor as `reason=`, and the sink walker scored only *assignments*. The row then reads *"0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover"* with the two swapped: a trade justified by a spread it did not clear. |
|
||||
| **B18** | `arbitrage.py:393` | same pair, CHARGE branch | the CHARGE decision's `reason` | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** |
|
||||
| **B19** | `main.py:2652` | `g.reason` / `g.soc_floor_reason` | the `goodwe_peak_soc_floor` notification body | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** The alarm then opens with the *reason the floor bound* and closes with the *guard's own conclusion*, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send. |
|
||||
| B20–B22 | `main.py:2558`, `main.py:2652` (two more pairs) | `g.reason` / `g.ceiling_w`, `g.ceiling_w` / `g.soc_floor_reason` | the same two notifications | **new in round 4, NOT measured** — mixed types (`str` beside `:.0f`), the same over-report class as B16. |
|
||||
|
||||
**⚠️ Six of these twenty-two were invisible to round 3, and the reason is one
|
||||
line of the sink classifier.** It scored a sentence a MESSAGE when it was
|
||||
appended, logged, raised, or assigned to a `reason`-shaped **name**. This
|
||||
codebase mostly builds operator sentences as `Decision(…, reason=f"…")` and
|
||||
`out.append({"text": f"…"})` — a **keyword argument** and a **dict key**, which
|
||||
that walker stepped straight past. Both are message sinks now.
|
||||
|
||||
### 7d. What this enumeration does NOT close, with sizes
|
||||
|
||||
* **A3 is a measured, open survivor.** `dev_min`/`dev_max` transposed in
|
||||
`rail_unreachable()`'s NOT COVERED row: **0 bytes, 21/21 green.** It is
|
||||
disclosed here rather than fixed, because closing it wants a `test_freeze.py`
|
||||
or status-page fixture with a device whose two bounds are **asymmetric** —
|
||||
every fixture in the repository uses `±d` — and that is a different file's
|
||||
card. It is a **status-page row**, not a latching alarm.
|
||||
* **B1 is a second measured, open survivor.** `inv.dev_min` and `inv.dev_max`
|
||||
transposed in `main.py:3231`'s status row: **0 bytes**, fingerprint
|
||||
`61aa413f…` → `ac36a97b…`, **21/21 green**. The page then tells the operator
|
||||
the device accepts `130…−120 W`. Same disposition as A3: **A3 is the row the
|
||||
freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED
|
||||
row prints when the device is what set the rail** — the two rows in which that
|
||||
detector ever names a device range, and neither one's operand order is
|
||||
asserted anywhere.
|
||||
* **Thirteen further census-B sites are unpinned and UNMEASURED.** B2–B14 above
|
||||
are determined at the **source level** — the distinguishing literal appears in
|
||||
no `test_*.py`, or the check that exists asserts a substring wider than its
|
||||
own claim — and were **not** mutated. That is a weaker statement than A1–A4
|
||||
and B1, and is written down as one.
|
||||
* **B12 is a check whose CLAIM is wider than its ASSERTION**, which is habit
|
||||
14's own shape one module over. `test_arbiter.py:452` says
|
||||
`scope_violations` *"names the source and the actuator it does not belong
|
||||
on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Swap `c.source` and
|
||||
`c.actuator` in `arbiter.py:264` and the sentence becomes
|
||||
*"inverter-b: a SITE claim filed on unit 'peak'"* — and the substring is still
|
||||
found. Source-level, not mutated, and not fixed here.
|
||||
* **B6 is the closest thing to a repeat.** SAFETY-31's `circ_reason` is
|
||||
persisted and re-read after a restart, exactly like `sign_reason`, and its
|
||||
two limits are graded by a bare `"CIRCULATING_POWER" in reason`. This card
|
||||
does not fix it; §6a already declines to touch `circ_wd`, and this is the
|
||||
precise shape of what that declining costs.
|
||||
* **The instrument's own blind spots** are printed by the instrument, every
|
||||
run: semantic symmetry it cannot spell (it under-reports), census B pairs
|
||||
that are the same *kind* but not the same *shape*, `%s`-style logging and
|
||||
non-f-string payloads, anything outside `app/`, and the one way it
|
||||
**over-reports** — it erases the sentence, so a swap that would make the
|
||||
formatting itself raise is scored as changing no decision (A5–A6 above).
|
||||
|
||||
### 7e. Census C — the sentence an object returns about itself (round 4)
|
||||
|
||||
**Why there is a third census.** A and B between them covered **one syntactic
|
||||
sink**. Everything either of them reported was a sentence that is appended,
|
||||
assigned, or logged; **none was returned**. Census A's RETURN bucket only ever
|
||||
held a callee formatting its own **parameters**, so a method formatting its own
|
||||
**attributes** and handing the string back fell between them — and that is the
|
||||
whole `describe()` / `explain()` / reason-builder family, which is where the
|
||||
operator sentences in this codebase actually live.
|
||||
|
||||
**The test** is census B's — one f-string, two interpolations **one leaf apart**
|
||||
— run over the RETURN sink, plus one thing B never needed: **a returned string
|
||||
is not yet a message.** `store.unit_key()` and `mqtt.unit_state_key()` return
|
||||
f-strings too, and swapping there renames a store key and an MQTT topic, which
|
||||
every ordinary check can see (MAINT-05). So the sink of a returned sentence is
|
||||
**resolved through its call sites**: it is in this family only when some caller
|
||||
in `app/` hands it to an operator. That resolution matches on the **bare method
|
||||
name**, which over-collects — at least four unrelated classes have an
|
||||
`explain()` — so every verdict is printed **with the sites that produced it**,
|
||||
and a MESSAGE verdict is a prompt to read them, not a proof.
|
||||
|
||||
The scan reports **53 returned pairs, 39 reaching an operator, 16 of those
|
||||
sharing a format spec** (two values rendered alike, so a transposition is silent
|
||||
rather than visibly broken). It **rediscovered both members this round was sent
|
||||
to fix without being told where to look**, which is the only reason to trust the
|
||||
rest of the list.
|
||||
|
||||
| # | site | pair | reaches an operator as | Δ bytes | verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **C6** | `clamp.py:253` `ClampResult.describe()` | `requested_w` / `applied_w` | §15 action log (`main.py:1366`) **and** the ClampWatchdog notification reason (`main.py:2452`) | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_clamp.py` part 6d, 4 checks |
|
||||
| **C7** | `peakguard.py:493` `PeakDecision.explain()` | `projected_w` / `ceiling_w` | status row `main.py:3313`; action log `main.py:1951` on the release edge | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_peakguard.py` part 13, 3 checks |
|
||||
| C1 | `allocator.py:154` `UnitPlan.explain()` | `self.span` / `self.why` | the arbiter/allocator log line | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C2 | `allocator.py:160` | `self.span` / `self.why` | the ENVELOPE VIOLATED line | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C3 | `allocator.py:162` | `self.span` / `self.why` | the ordinary in-envelope line | **0** | **RED** — `test_alloc.py` already asserts that whole literal. The one member of this census the suite could already see, and it says the habit works. |
|
||||
| C4a | `arbiter.py:344` `UnitBounds.explain()` | `self.lo` / `self.hi` | `CONTRADICTION within the unit (device lo=… > peak hi=…)` | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C4b | `arbiter.py:344` | `self.lo_src` / `self.hi_src` | the same sentence, the two SOURCE names | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C5 | `arbiter.py:392` `Resolution.explain()` | `self.winner` / `self.bound_by` | `main.py:2236`'s `ARBITER:` action-log row and `self.reason` at `main.py:2329` | **0** | **SURVIVOR, 21/21 green** — the winner and the limiter swap names, on the line §10.1 exists to make legible |
|
||||
| C8 | `tariff.py:279` `PeakReading.explain()` | `self.month` / `self.detail` | the PEAK UNREADABLE row | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C9 | `tariff.py:528` `Projection.explain()` | `self.projected_w` / `self.elapsed_s` | the projection line | **0** | **SURVIVOR, 21/21 green** — watts printed as seconds and seconds as watts, both `:.0f` |
|
||||
|
||||
**Not in the family, and the scan says why rather than dropping them silently:**
|
||||
`inverter.py:338` `__repr__()` and `static_tariff.py:378` `words` are reported
|
||||
**UNRESOLVED — no call site inside `app/`** (a `__repr__` is called by the
|
||||
interpreter, not by a name the scan can match); `price_forecast.py:421/428` and
|
||||
`tariff.py:214` `month_key()` resolve to **OTHER** — `month_key` is a **store
|
||||
key**, which is MAINT-05's case and a swap there is behaviour every check sees.
|
||||
|
||||
### 7f. Round 4's mutation ledger
|
||||
|
||||
Same protocol as §4: a fresh full-repo copy per mutation, `:ro`, bytecode
|
||||
purged **and the purge count printed** before the edit, the edited file
|
||||
**re-read from disk**, both sides' length and sha256 printed, and the full
|
||||
**21-file** suite via `tools/mutation_run.sh`. Every row is a pure
|
||||
transposition, so **every Δ is 0 bytes** — verified, not assumed: `len(old) ==
|
||||
len(new)` is asserted before the write and `len(after) == len(before)` after it.
|
||||
|
||||
**⚠️ And every row was also required to change the ANSWER.** A byte delta says
|
||||
the file moved; it does not say the sentence did. Each mutated tree is rendered
|
||||
through a scratch probe that evaluates the shipped f-string with **distinct
|
||||
operands** and diffs it against the same render of the clean tree. **The first
|
||||
cut of that probe numbered the operands by ORDER OF APPEARANCE** — so after a
|
||||
transposition the other leaf took the same number and all thirteen diffs came
|
||||
back **empty**, which reads exactly like "the edit is inert". Keying the value
|
||||
to the **name** fixed it. That is the same defect as everything else in this
|
||||
card, in the instrument this time: a measurement comparing the swap against
|
||||
itself.
|
||||
|
||||
| mutation | file, before → after (sha256, 16) | Δ | verdict |
|
||||
|---|---|---|---|
|
||||
| C1 `allocator.py:154` span↔why | `10c1b26ed0099654` → `ac0c174de1ec28ad` | 0 | **SURVIVED 21/21** |
|
||||
| C2 `allocator.py:160` span↔why | `10c1b26ed0099654` → `9b10730f4659befd` | 0 | **SURVIVED 21/21** |
|
||||
| C3 `allocator.py:162` span↔why | `10c1b26ed0099654` → `07097bf5da31f0c6` | 0 | **RED** — `test_alloc.py` |
|
||||
| C4a `arbiter.py:344` lo↔hi | `fdb4ed0520eeb721` → `009820de7ef0eff0` | 0 | **SURVIVED 21/21** |
|
||||
| C4b `arbiter.py:344` lo_src↔hi_src | `fdb4ed0520eeb721` → `04cb8cf69dfc7432` | 0 | **SURVIVED 21/21** |
|
||||
| C5 `arbiter.py:392` winner↔bound_by | `fdb4ed0520eeb721` → `9238c3db920da8ad` | 0 | **SURVIVED 21/21** |
|
||||
| **C6** `clamp.py:253` requested↔applied | `0153133741497fa5` → `47e4e371043e3201` | 0 | **SURVIVED 21/21 before — RED after**, `test_clamp.py`, 4 checks |
|
||||
| **C7** `peakguard.py:493` projected↔ceiling | `d95df61a51716c26` → `2d2ee3d0bcd51251` | 0 | **SURVIVED 21/21 before — RED after**, `test_peakguard.py`, 3 checks |
|
||||
| C8 `tariff.py:279` month↔detail | `82790523dd5227c9` → `4b2147ca17a28425` | 0 | **SURVIVED 21/21** |
|
||||
| C9 `tariff.py:528` projected↔elapsed | `82790523dd5227c9` → `517e9148d26a243a` | 0 | **SURVIVED 21/21** |
|
||||
| B17 `arbitrage.py:377` spread↔required | `67fbf949b2938349` → `cddd8045bdeb5e29` | 0 | **SURVIVED 21/21** |
|
||||
| B18 `arbitrage.py:393` spread↔required | `67fbf949b2938349` → `04a4f3ad3d255de1` | 0 | **SURVIVED 21/21** |
|
||||
| B19 `main.py:2652` reason↔soc_floor_reason | `61aa413fb890433e` → `bbcbb28f476a98d0` | 0 | **SURVIVED 21/21** |
|
||||
| **B12** `arbiter.py:264` source↔actuator | `fdb4ed0520eeb721` → `9111f627ef639ecc` | 0 | **SURVIVED 21/21 before — RED after**, `test_arbiter.py`, 2 checks |
|
||||
|
||||
**14 measured, 12 survived the full suite before this round's checks, 1 was
|
||||
already caught, 3 are caught now.** The tree these were taken on is
|
||||
`SAFETY-38` @ `357cb4f` rebased on `release/1.0` @ `9e42cec`; `app/main.py` is
|
||||
234 542 bytes.
|
||||
|
||||
### 7g. What round 4 does NOT close, with sizes
|
||||
|
||||
* **Eleven measured zero-byte survivors are left open** — C1, C2, C4a, C4b, C5,
|
||||
C8, C9, B17, B18, B19 and the three already open from round 3 (A3, B1, and the
|
||||
B2–B11/B13–B14 source-level set). Each is a sentence an operator reads and
|
||||
none is a control decision. They are disclosed with their measurement rather
|
||||
than pinned because pinning each one wants a fixture in the file that owns the
|
||||
module — `test_alloc.py`, `test_arbiter.py`, `test_tariff.py`,
|
||||
`test_arbitrage.py` — and this card has now spent four rounds inside
|
||||
`test_watchdog.py`'s subject. **An enumerated list with honest gaps beats a
|
||||
fifth round finding a fifth instance**; what would NOT be honest is a fifth
|
||||
round finding one that is **not on this list**.
|
||||
* **`main.py:1951`, the action-log surface of the peak-guard idle line,** is
|
||||
unpinned. The status row off the same `explain()` is pinned; the second call
|
||||
site is not, and §4c is the card about exactly that costing a round.
|
||||
* **The largest remaining hole is not in any census: `%s`-style lazy logging.**
|
||||
`_LOG.error("… %s … %s", a, b)` puts the two operands in *arguments*, not in
|
||||
an f-string, so nothing here looks at them and the transposition is the same
|
||||
zero-byte edit. The tool now says so first in its NOT-SCANNED list, **with
|
||||
the size measured rather than asserted: 31 such calls across 8 modules in
|
||||
`app/`, each one listed**.
|
||||
* **The tool's own blind-spot list was the round-4 blocker and is now two
|
||||
lists.** "What this scans, and what it does not" enumerates its message sinks
|
||||
from the constants themselves and then names, in order, what it never looks at:
|
||||
`%s`-style logging, `.format()`/`%`/`+`/`.join()` sentences, a sentence that
|
||||
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
|
||||
sentences, attributes outside the `reason`-shaped set, and everything outside
|
||||
`app/`. "What it can see but gets wrong" keeps the five gaps round 3 printed,
|
||||
with the over-report gap now backed by a **printed format-spec column** so a
|
||||
reader can tell a silent same-typed transposition from a noisy mixed-type one.
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
# SAFETY-39 — `slew_w` is a ratio to the rail, and `gain` is the constant that is still wrong
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-39`.
|
||||
**Card:** SAFETY-39 — *"`slew_w` was derived at a 1500 W clamp and runs at 5000,
|
||||
so the loop outruns the plant"*.
|
||||
**Nothing here reached the inverter.** The live install still runs the interim
|
||||
`slew_w: 400` applied by hand on 2026-09-05 12:00, which this branch's new rule
|
||||
accepts unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 0. The three things a reader should take away
|
||||
|
||||
1. **The overshoot is reproduced in simulation, and one half of it is not
|
||||
reproducible at all.** The setpoint stepping to the rail and the meter
|
||||
crossing from export into import are both reproduced, on two independent
|
||||
plant models. The **−5154 W battery figure is not reproducible in either**,
|
||||
because both clamp the plant at its rating. An acceptance criterion written
|
||||
on it would pass on the broken configuration too, so none is. §2.
|
||||
2. **The deliverable is the relationship, not the number.** `slew_w: 0` now
|
||||
means *follow the rail* and resolves to `SLEW_RAIL_FRAC * max_w`;
|
||||
`validate.py` rule 10 refuses an explicit value faster than that against the
|
||||
**site** rail. Verified by breaking it. §4, §5.
|
||||
3. ⚠️ **`slew_w` does not own the over-absorption. `gain` does, and `gain` was
|
||||
not changed.** Re-deriving the slew removes the ≥ 900 W step — the symptom
|
||||
that escalated 2 → 1 → 13 → 43 per 30 min on the morning of the excursion —
|
||||
and removes nothing else. Anyone reading this card as "the overshoot is
|
||||
fixed" is reading more than was delivered. §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, stated in the terms the code uses
|
||||
|
||||
`slew_w: 1000` was derived on 2026-08-22 against a **clamp of 1500 W**
|
||||
(goodwe/`NOTES.md`, *"Simulated 2 kW load step (gain 0.6, clamp 1500)"*). Its
|
||||
stated justification:
|
||||
|
||||
> `slew: 1000` is the default because it puts most of the correction in the first
|
||||
> cycle **while still capping how far the loop can jump ahead of the inverter**.
|
||||
|
||||
At 1500 W that sentence is true, and the reason is the rail rather than the
|
||||
number: 1000 W/cycle reaches the rail in two cycles, so the excursion is bounded
|
||||
by 1500 W whatever the loop does.
|
||||
|
||||
The live install then went to `max_w: 5000` and the constant did not move.
|
||||
`DRIVER-07` ("raise actuator power cap to full inverter rating") is still in To
|
||||
Do; the cap was raised ahead of it and `slew_w` was never revisited. **Nothing
|
||||
in the code is broken.** The rail moved and the derivation did not.
|
||||
|
||||
| | derived at | shipped default | live install |
|
||||
|---|---|---|---|
|
||||
| `max_w` | **1500** | 2000 | **5000** |
|
||||
| `slew_w` | 1000 | 1000 | **1000** |
|
||||
|
||||
Measured on the owner's system, 2026-09-05 11:43–11:46:
|
||||
|
||||
```
|
||||
11:43:55 grid -3385 W a real 3.4 kW export
|
||||
11:43:56 SP 380 → 1380 → 2380 → 3380 → 4380 → 5000 +1000 per 2 s tick
|
||||
11:44:47 batt -5154 W ABOVE the 5000 VA rating
|
||||
11:45:55 grid +2831 W flipped to 2.8 kW IMPORT
|
||||
SP 5000 → 2930 → 3840 → 3750 hunting
|
||||
```
|
||||
|
||||
Large (≥ 900 W) setpoint steps per ~30 min that morning: `2 → 1 → 13 → 43`.
|
||||
|
||||
---
|
||||
|
||||
## 2. ⭐ Reproduced first — and what could not be
|
||||
|
||||
`tools/slew_sim.py --repro`, against admin/goodwe's `plantsim.Plant`, the real
|
||||
`compute()`, and the firmware's own encoder on the wire. Gain 0.6, `max_w` 5000,
|
||||
`slew_w` 1000, 2 s tick, one 3385 W export step at t = 10 s:
|
||||
|
||||
```
|
||||
t grid in setpoint battery grid out reason
|
||||
10 -3739 -280 290 -3675 slew-limited
|
||||
12 -3675 -1280 57 -3442 slew-limited
|
||||
14 -3442 -2280 -316 -3069 slew-limited
|
||||
...
|
||||
34 -1012 -5000 -2823 -562 i-clamped ← the rail
|
||||
38 -167 -5000 -3541 156 saturated-freeze ← EXPORT → IMPORT
|
||||
44 562 -4310 -4018 633 saturated-freeze
|
||||
50 544 -3210 -3804 419 tracking
|
||||
```
|
||||
|
||||
Reproduced: the setpoint stepping a full 1000 W per tick to the rail, the loop
|
||||
absorbing ~640 W more than the site needed, and the meter crossing from **3.7 kW
|
||||
of export to +633 W of import** — the loop manufacturing the error it then
|
||||
chases. Over a family of 51 export steps (1.0–4.2 kW × 3 phases), **48 of 51**
|
||||
runs contain a ≥ 900 W setpoint step.
|
||||
|
||||
### ⚠️ What is NOT reproducible, and why no criterion is written on it
|
||||
|
||||
`plantsim.Plant.limit()` clamps the plant at `p_max`. The modelled battery
|
||||
**cannot exceed its rating by one watt, at any tuning, on any step, ever.**
|
||||
Neither can `test_control.py`'s independent model. So:
|
||||
|
||||
* the **−5154 W excursion is not reproducible in simulation at all**, and this
|
||||
is a property of the models rather than of the tuning;
|
||||
* the card's AC *"battery power no longer exceeds `max_w` on a step of the size
|
||||
observed"* is **unfalsifiable in simulation** — it passes identically on the
|
||||
configuration that produced the excursion. It is the one acceptance criterion
|
||||
this card had to **rewrite rather than meet**, per the board's own rule that a
|
||||
third review round means the AC is wrong.
|
||||
|
||||
What replaced it, and what is graded in CI: **the ≥ 900 W step is gone from
|
||||
every member of the family**, and **the meter excursion and the over-absorption
|
||||
are measured and reported rather than claimed to be fixed.**
|
||||
|
||||
### The two models agree
|
||||
|
||||
`test_control.py`'s in-repo first-order lag and admin/goodwe's ramp-plus-lag
|
||||
`plantsim.Plant` are independent, and they agree on every sign and rank:
|
||||
|
||||
| | in-repo model | `plantsim.Plant` |
|
||||
|---|---|---|
|
||||
| ≥ 900 W steps at `slew_w` 1000 | 48/51 | 48/51 |
|
||||
| ≥ 900 W steps at the derived value | 0/51 | 0/51 |
|
||||
| over-absorption, slew 1000 → 250 | 1005 → 1330 W | 958 → 1081 W |
|
||||
| over-absorption, gain 0.6 → 0.2 | 1109 → 393 W | 987 → 435 W |
|
||||
|
||||
---
|
||||
|
||||
## 3. The derivation
|
||||
|
||||
The constraint, in words, and it is the 2026-08-22 sentence made checkable:
|
||||
|
||||
> **The loop must not traverse its full output range faster than the plant takes
|
||||
> to settle.**
|
||||
|
||||
Three measured quantities, and each is named where it came from:
|
||||
|
||||
| | value | where it was measured |
|
||||
|---|---|---|
|
||||
| plant settling time | **18 s** | goodwe/`NOTES.md` §5.10, 2026-08-22 — a 1.78 kW kettle step driven from HA off a plug that meters its own draw. ~18 s up to the deadband, ~23 s down. **18 is taken because it is the shorter and therefore the looser bound**; this is a refusal boundary, and rules 5 and 6 refuse the impossible case only, never "close to the rail". |
|
||||
| control tick | **2 s** | the live install, measured 2026-09-05 — the setpoint stepped +1000 W every 2 s. `run_control()` runs a cycle on a changed meter value or on `cycle_floor_s`, whichever comes first, so the tick is the *meter's* rate. |
|
||||
| output range | `max_w` | `main.py` builds `Tuning(max_w=self.site_max_w)`, the **roster sum**. |
|
||||
|
||||
```
|
||||
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 0.1111 (9 ticks)
|
||||
|
||||
slew_w ≤ SLEW_RAIL_FRAC × max_w
|
||||
```
|
||||
|
||||
| rail | derived `slew_w` | ticks to cross | seconds to cross |
|
||||
|---|---|---|---|
|
||||
| 1500 (where the original was derived) | 167 W | 9 | 18 s |
|
||||
| 2000 (shipped default) | 222 W | 9 | 18 s |
|
||||
| **5000 (the live install)** | **556 W** | 9 | 18 s |
|
||||
| 9600 (with inverter B — **datasheet, not measured**) | 1067 W | 9 | 18 s |
|
||||
|
||||
And the configuration that was measured failing: `slew_w` 1000 at `max_w` 5000
|
||||
crosses the rail in **5.0 ticks = 10 s**, against a plant that takes 18 s.
|
||||
|
||||
### ⚠️ The tick is the weak term and it is not wrong in the safe direction
|
||||
|
||||
A **slower** meter makes this bound conservative — it refuses configurations that
|
||||
would have been fine, which is visible immediately and costs one edit. A meter
|
||||
**faster** than 2 s makes it loose by exactly that ratio and nothing in this
|
||||
add-on would say so. `run_control()`'s `await asyncio.sleep(1)` bounds the tick
|
||||
below at 1 s, so the worst case is a factor of two — but if a faster meter is
|
||||
ever fitted, `LOOP_TICK_S` has to be **re-derived, not merely re-read**. Compare
|
||||
`validate.FIRMWARE_INGEST_VETO_S`, which carries the same kind of copy and can
|
||||
only ever be wrong the other way; that difference is why both numbers are
|
||||
written out in `control.py` rather than their quotient.
|
||||
|
||||
### ⚠️ The rig cannot pick 556 out of the table, and is not asked to
|
||||
|
||||
`tools/slew_sim.py --slew`, worst case over 51 export steps:
|
||||
|
||||
```
|
||||
slew_w 1000 ← 2026-09-05 live pair step 1000 W 48/51 ≥900 W over-absorbed 958 W
|
||||
slew_w 800 step 800 W 0/51 ≥900 W over-absorbed 1107 W
|
||||
slew_w 700 step 700 W 0/51 ≥900 W over-absorbed 1133 W
|
||||
slew_w 600 step 600 W 0/51 ≥900 W over-absorbed 1058 W
|
||||
slew_w 555 ← the derived value step 560 W 0/51 ≥900 W over-absorbed 987 W
|
||||
slew_w 500 step 500 W 0/51 ≥900 W over-absorbed 1029 W
|
||||
slew_w 400 ← the live interim step 400 W 0/51 ≥900 W over-absorbed 1150 W
|
||||
slew_w 300 step 300 W 0/51 ≥900 W over-absorbed 1143 W
|
||||
slew_w 250 step 250 W 0/51 ≥900 W over-absorbed 1081 W
|
||||
```
|
||||
|
||||
The ≥ 900 W step disappears at 800 and stays gone. Below that the table is
|
||||
**flat and slightly non-monotone**, and 800 is *better* than 556 on
|
||||
over-absorption. So the rig does not choose the number — **the constraint
|
||||
chooses it, and the rig says what the constraint does and does not buy.** That
|
||||
is the same relationship the 2026-08-22 table had to its own three columns.
|
||||
|
||||
⚠️ **A single step is not evidence here.** The deadband, the 10 W quantisation
|
||||
and the phase of the step against the tick move the result by hundreds of watts,
|
||||
non-monotonically. The first cut of this derivation was a single step and ranked
|
||||
gain 0.3 as *worse* than 0.6, which the family shows is backwards. Every number
|
||||
above is a worst case over a family, which is how admin/goodwe grades the same
|
||||
kind of quantity (`config.yaml`'s "1890 closed-loop runs", "924 runs").
|
||||
|
||||
---
|
||||
|
||||
## 4. What ships — the relationship, not the number
|
||||
|
||||
**`slew_w: 0` means FOLLOW THE RAIL.** `control.slew_limit_w()` resolves it to
|
||||
`SLEW_RAIL_FRAC * max_w`. It is the shipped default and it is the only value
|
||||
that cannot be left behind when the rail moves. This is the idiom
|
||||
`integrator_max_w: 0` has carried in the same options block since it was added —
|
||||
not a new concept, the same one, applied to the key that needed it.
|
||||
|
||||
An explicit number is still accepted **and is never silently reduced**: the live
|
||||
install runs 400, and silently reducing an operator's stated bound is case (a)
|
||||
in `validate.py`'s own docstring — the defect that whole module exists to
|
||||
prevent.
|
||||
|
||||
**`validate.py` rule 10** refuses an explicit `slew_w` above
|
||||
`SLEW_RAIL_FRAC × site rail`. Graded against **`sum(unit_caps(opts))`**, the
|
||||
roster sum — the same number `Controller.site_max_w` builds — and *not* against
|
||||
the flat `max_w` and *not* against the weakest unit. Rules 5, 6 and 8 take
|
||||
`min()` because each grades one watchdog, one shaping claim, one pack. `slew_w`
|
||||
is not a per-unit quantity at all: `compute()` emits the **site** target and the
|
||||
allocator divides it afterwards.
|
||||
|
||||
⚠️ `config.yaml`'s note on `site_sign_disagree_w` says there is deliberately no
|
||||
rule pairing a site quantity with `max_w`, "because … `validate_startup_config()`
|
||||
is handed options rather than a roster". That objection is to grading a site
|
||||
quantity against the **flat key**, and it is answered rather than ignored:
|
||||
`unit_caps()` already reads the roster the add-on will build, so the roster sum
|
||||
*is* available at startup, and `site_rail_w()` is that number.
|
||||
|
||||
---
|
||||
|
||||
## 5. ⭐ Verified by breaking it
|
||||
|
||||
`test_validate.py`, section 10 — the acceptance criterion written as a test:
|
||||
|
||||
```
|
||||
restoring slew_w 1000 at max_w 5000 -> 1 problem(s)
|
||||
slew_w is 1000 W against a site rail of 5000 W, so the loop crosses its
|
||||
whole output range in 5.0 cycles - 10 s at the 2 s tick this site was
|
||||
measured running - while the plant takes 18 s to settle …
|
||||
```
|
||||
|
||||
and the four things the refusal has to say, each graded separately: which rail
|
||||
it used, how long the crossing takes, which measurement it is graded against,
|
||||
and what to do about it (including `0`, which cannot go stale). Plus:
|
||||
|
||||
* **equality starts** — at exactly the derived value the crossing takes exactly
|
||||
the settling time, which is the constraint met, not violated (rule 6's case,
|
||||
not rule 5's; getting it backwards would refuse the recommended value);
|
||||
* **one watt of slack starts** — a boundary that is not meaningful to one watt
|
||||
must not refuse on one watt;
|
||||
* **the live interim `slew_w: 400` starts**, and so does the schema floor —
|
||||
there is no such thing as too slow here, and refusing it would refuse the
|
||||
mitigation that is holding;
|
||||
* **`slew_w: 0` starts at every rating from 100 W to 15 kW.**
|
||||
|
||||
`test_control.py` grades the same boundary from the law's side, so the rule can
|
||||
only ever refuse what the ratio says is too fast, and the two cannot drift apart.
|
||||
|
||||
---
|
||||
|
||||
## 5b. What the change found in this repository's own tests
|
||||
|
||||
Five files moved, and **none of them moved because a number was rounded
|
||||
differently**. Each is worth reading as a finding.
|
||||
|
||||
**Two fixtures encoded the defect.** `test_inverter.py`'s `BASE_OPTS` paired
|
||||
`slew_w: 1000` with `max_w: 5000` — the 2026-09-05 configuration exactly — and
|
||||
`test_loop.py`'s `BASE` paired 1000 with 2000. Both were typed when those rails
|
||||
were new; nothing in either file had noticed. Rule 10 refused them on its first
|
||||
run, so those fixtures **would not start**. That is what a rule about a
|
||||
*relationship* is for, and it is the clearest evidence the rule is not a
|
||||
formality.
|
||||
|
||||
**Two reproductions depended on a default they never named.**
|
||||
`test_alloc.py`'s `close_loop` and `test_freeze.py`'s "make both terms
|
||||
reachable" case reached `slew_w: 1000` through `Tuning`'s default. ARCH-01's
|
||||
4000 W broadcast limit cycle and SAFETY-18's proof-of-capability alarm would
|
||||
both have gone quiet — reporting a measured finding as *absent* because a
|
||||
default moved underneath them. Both now write the number beside the measurement
|
||||
it belongs to.
|
||||
|
||||
**One harness had a latent off-by-one that only a ramping loop could expose.**
|
||||
`test_watchdog.py` graded SAFETY-02's banner by pairing `c.grid` with
|
||||
`last_write` sampled **after** `cycle()`, while the detector is evaluated
|
||||
**inside** `cycle()` against the *standing* command from the previous one. At
|
||||
`slew_w: 1000` the loop reached its rail in two cycles and consecutive commands
|
||||
were identical, so the wrong pair and the right pair were the same numbers. At a
|
||||
rail-following slew they differ by one step and the check went red. `_drive()`
|
||||
now has a `pre` hook that reads the operands the detector will actually use, and
|
||||
the check reads the **latching** cycle rather than the one before it. The
|
||||
assertion is strictly stronger than it was.
|
||||
|
||||
**One tripwire had two assertions that were exact by coincidence.**
|
||||
`test_peakguard.py` part 10c read `limited_discharging == limited` (49/49) and
|
||||
`min_soc <= bms_soc_floor + 0.05`. At the slower slew the loop drains that pack
|
||||
~82 Wh less over the same two hours and no longer touches the cut-out *inside
|
||||
that window*: 48/49, low-water 10.57 % against a 10 % cut-out, 0 cycles at the
|
||||
BMS limit instead of 190. **The finding is unchanged and is sharper on the
|
||||
measure that carries it** — 162 cycles of granted discharge below the EMS floor,
|
||||
up from 138. The two exact equalities were artefacts of one tuning, the old and
|
||||
new numbers are tabulated in the file beside the checks, and the assertions now
|
||||
grade the substance. ⚠️ *Loosening an assertion to get a branch green is the
|
||||
first thing a reviewer should suspect here; the table is there so the suspicion
|
||||
can be settled rather than argued.*
|
||||
|
||||
---
|
||||
|
||||
## 6. ⚠️ `gain: 0.6` — measured, and deliberately NOT changed
|
||||
|
||||
The card asks whether `gain` still holds at this clamp. **It does not, and here
|
||||
is the measurement.**
|
||||
|
||||
`tools/slew_sim.py --gain`, `slew_w` at the derived 556 W, same 51-step family:
|
||||
|
||||
```
|
||||
gain 0.6 ← shipped over-absorbed 987 W meter driven to +987 W
|
||||
gain 0.5 over-absorbed 941 W
|
||||
gain 0.4 over-absorbed 977 W
|
||||
gain 0.3 over-absorbed 876 W
|
||||
gain 0.2 over-absorbed 435 W
|
||||
gain 0.1 over-absorbed 66 W
|
||||
```
|
||||
|
||||
Against the slew column in §3, which is flat. The mechanism is visible in
|
||||
`control.py` and is structural rather than empirical:
|
||||
|
||||
* **`slew_w` bounds the OUTPUT.**
|
||||
* **The integrator accumulates `gain × error` per cycle** and is bounded only by
|
||||
`integrator_max_w`. At gain 0.6 into a 3.4 kW error it moves **~2040 W per
|
||||
tick** and reaches a 5000 W rail in **under three ticks, whatever `slew_w`
|
||||
is**.
|
||||
|
||||
So the 2026-08-22 derivation's own sentence — *"the gain, not the slew, is the
|
||||
limit by then"* — has become literally true, and `gain` is the term that owns
|
||||
the over-absorption at this rail. It is written about a 1500 W rail, where
|
||||
`gain × 1500 = 900 W` and the clamp caught it in two cycles; at 5000 there is
|
||||
3.3× more room to wind before the clamp binds.
|
||||
|
||||
**Why nothing was changed:**
|
||||
|
||||
* `gain` is a live control-law constant on a running house. Two of them moving
|
||||
in one release makes the next measurement uninterpretable — and the interim
|
||||
`slew_w: 400` has been in service since 2026-09-05 12:00, so there is a clean
|
||||
baseline to measure the next change against and it would be destroyed.
|
||||
* A validator rule pairing `gain` with a rail would be **inventing a
|
||||
relationship nobody has measured.** The bound would have to be
|
||||
`gain × error_max ≤ SLEW_RAIL_FRAC × max_w`, and `error_max` is the
|
||||
disturbance, which is not config and is not bounded.
|
||||
* `integrator_max_w` is **not** the lever it looks like: it bounds the output as
|
||||
well as the accumulator (`target = clamp(i_w, ±max_w)`), which is why setting
|
||||
it to 2000 on the live system on 2026-09-05 capped the inverter to 2000 W of
|
||||
its 5000 and was reverted the same day.
|
||||
|
||||
This is named in `validate.py`'s *"deliberately not refused"* list so that it is
|
||||
a known open item rather than an omission, and it is asserted in
|
||||
`test_control.py` so that nobody can quietly read this card as having fixed it.
|
||||
**It wants its own ticket.**
|
||||
|
||||
---
|
||||
|
||||
## 7. N = 1 → N = 2: the rail is what moved
|
||||
|
||||
⚠️ **Inverter B does not exist on site** (SPIKE-04, ≈ 2026-09-12). Its figures
|
||||
are from its **datasheet and serial**, not a measurement: GW5048D-ES, serial
|
||||
`55048ESU22AW0992`, 4600 W, pack 4 × Dyness DL5.0C = 20.48 kWh, floor 10 %
|
||||
(goodwe/`NOTES.md` §1.1b) — and the 4600 W is printed under *battery input data*,
|
||||
so it may be DC, in which case the AC ceiling is nearer 4324 W.
|
||||
|
||||
⚠️ **And `sim/plantsim.py`'s `INVERTER_B` is inverter A copied**, carrying
|
||||
`measured=False`. **Every two-inverter row below is a result about two copies of
|
||||
A on a 10000 W rail, not about the ~9600 W site B implies.**
|
||||
|
||||
**The site rail becomes `sum(max_w)` ≈ 9600 W while `slew_w` is per-site.** The
|
||||
derived value there is `0.1111 × 9600 = 1067 W` per tick, which at an even split
|
||||
is 533 W per unit against each unit's own 5000/4600 W rating — ARCH-01's
|
||||
split-invariance holding, as its `two_master_gain_sweep` already shows.
|
||||
|
||||
**Does the enforced relationship produce it automatically? Yes.** Rule 10 reads
|
||||
`sum(unit_caps(opts))` and `slew_limit_w()` reads `Tuning.max_w`, which
|
||||
`main.py` sets from `Controller.site_max_w`. Declaring inverter B moves both,
|
||||
with no operator action and no second derivation. That is the property that
|
||||
makes this a fix rather than a re-tune, and `test_validate.py` asserts the
|
||||
9600 W arithmetic directly.
|
||||
|
||||
⚠️ **But the direction of the rule is not the one the ticket assumed, and it
|
||||
must be said.** A rail that **grows loosens** this bound: `slew_w: 1000`
|
||||
crosses 9600 W in 19.2 s and **passes**, while the same 1000 at 5000 W is the
|
||||
configuration measured failing. That is what the constraint says and it is
|
||||
correct — the bound is on how long the crossing *takes*. What the rule protects
|
||||
against is the constant being left behind when the rail moves, in either
|
||||
direction. **It is not a bound on how much power the site may command** (that is
|
||||
each unit's own `max_w` through the arbiter's `supervised` claim) **and it does
|
||||
not bound the over-absorption** (§6).
|
||||
|
||||
⚠️ **The ≥ 900 W symptom goes stale at N = 2 the same way the constant did.**
|
||||
On the two-copies-of-A rows, 48/51 runs contain a ≥ 900 W step *at the derived
|
||||
value*, because at a 10000 W rail the derived step **is** 1111 W. A check
|
||||
written on the absolute 900 W would report the correct tuning as broken.
|
||||
Anything grading this symptom at N ≥ 2 has to grade it as a **fraction of the
|
||||
rail** too — which is this whole card, one layer out.
|
||||
|
||||
---
|
||||
|
||||
## 8. `max_w: 5000` — a recommendation, not a change
|
||||
|
||||
The battery reached **5154 W against a 5000 VA inverter**, so the configured cap
|
||||
left no headroom for the overshoot that the loop was going to produce anyway.
|
||||
Three things are worth writing down and none of them is changed here:
|
||||
|
||||
1. **The cap is the rating, so the overshoot has nowhere to go but past it.**
|
||||
`compute()` bounds the *command* to `±max_w`; nothing bounds the plant's
|
||||
response to a command at the rail, and §6 shows the loop asks for the rail on
|
||||
any large step at gain 0.6. Recommend **`max_w` at 4500–4750 W**, i.e. 90–95 %
|
||||
of the 5000 VA nameplate, leaving 250–500 W for the overshoot the loop still
|
||||
has. This is `freeze.py`'s `RAIL_FRAC` argument applied to the cap itself.
|
||||
2. **The −5154 W may not be an AC figure at all.** `batt_entity` is the pack
|
||||
sensor and `max_w` is graded against `0x0518`, the inverter's **AC** power —
|
||||
`config.yaml`'s roster comment already records exactly this ambiguity for
|
||||
inverter B. If the battery reading is DC, 5154 W DC is ~4900 W AC and the
|
||||
inverter was never over its nameplate. **Nobody has measured which**, and the
|
||||
recommendation above is worth making either way.
|
||||
3. **It is the owner's number.** `translations/en.yaml` marks `max_w` "THIS IS
|
||||
YOUR NUMBER" precisely so it is not confused with a derived constant, and
|
||||
changing it unilaterally in a pull request would undo that distinction.
|
||||
`SAFETY-20`'s `min(max_w, dev_max)` settles the device side once ESPHome has
|
||||
answered; the *headroom* question is a policy choice and belongs to the owner.
|
||||
|
||||
---
|
||||
|
||||
## 9. What was deliberately not done
|
||||
|
||||
* **`saturation_w` and the deadband were not touched.** The card forbids it and
|
||||
it is right to: widening either hides the symptom. The loop is following its
|
||||
command correctly; the command is wrong.
|
||||
* **`gain` was not changed.** §6.
|
||||
* **`max_w` was not changed.** §8.
|
||||
* **`control.py`'s slew *limit* was not clamped to the bound.** An explicit
|
||||
value above it is refused at startup, not quietly reduced — silently reducing
|
||||
an operator's stated bound is case (a) in `validate.py`'s docstring.
|
||||
* **Nothing in admin/goodwe was modified**, and nothing reached the inverter.
|
||||
@@ -432,7 +432,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
|---|---|---|
|
||||
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning. **Not read when `inverters` is declared** — each entry carries its own, and the site ceiling is their sum |
|
||||
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
|
||||
| `slew_w` | 1000 | Maximum change per cycle |
|
||||
| `slew_w` | 0 | Largest change in one cycle. **0 means follow the rail** - `max_w / 9`, i.e. the loop takes the plant's own measured ~18 s settling time to cross its whole output range at the 2 s tick the live install runs. 222 W at `max_w: 2000`, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. A fixed number is accepted and never silently reduced, but one FASTER than the rail implies is refused at startup (SAFETY-39): `slew_w: 1000` against `max_w: 5000` crossed the rail in 10 s against an 18 s plant, and on 2026-09-05 drove the meter from 3.4 kW of export to 2.8 kW of import |
|
||||
| `deadband_w` | 15 | Ignore errors smaller than this |
|
||||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||
| `step_w` | 10 | Quantisation |
|
||||
|
||||
@@ -135,7 +135,7 @@ manufactures them from a **fixture**: the check runs, the assertion is
|
||||
evaluated, the sentence above it is true — and the numbers it compares are
|
||||
arranged so that the failure it describes could not have changed the answer.
|
||||
|
||||
It is not hypothetical and it is not rare. TEN instances in two weeks; four
|
||||
It is not hypothetical and it is not rare. ELEVEN instances in two weeks; four
|
||||
were found by review, two - CFG-04's fixture pair - by an author's own mutation
|
||||
battery, which is the cheaper place, one by review again, in the one
|
||||
place nobody had thought to point the rule at: not a check in the suite, but a
|
||||
@@ -145,6 +145,15 @@ that names the rule in its own text - and the TENTH INSIDE THE FIX WRITTEN TO
|
||||
CLOSE THE NINTH, for the second time in this list. See SAFETY-38 below: the
|
||||
check that closed it asserted a NUMBER and not the number's POSITION.
|
||||
|
||||
⚠️ AND THE ELEVENTH IS IN A MEASURING INSTRUMENT RATHER THAN IN A CHECK, which
|
||||
is where the seventh was too. SAFETY-38 round 4's render probe - the thing that
|
||||
decides whether a mutation changed the SENTENCE and not merely the bytes -
|
||||
numbered each interpolated operand BY ORDER OF APPEARANCE. After a transposition
|
||||
the other operand takes the same number, so all thirteen mutant renders came back
|
||||
BYTE-IDENTICAL to the clean tree: the probe compared the swap against itself, and
|
||||
its output read exactly like "the edit is inert". Keying the value to the
|
||||
operand's NAME fixed it. Habit 16.
|
||||
|
||||
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
|
||||
MAINT-05's, is the one that says why: the defect is not carelessness about
|
||||
fixtures, it is that **a check and the thing it checks were built from the same
|
||||
@@ -276,7 +285,25 @@ same place twice, the check is decoration.
|
||||
The fix is to assert the **ORDERED PAIR**,
|
||||
`f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`.
|
||||
|
||||
The general habit is **14** below.
|
||||
⚠️ **AND THE ELEVENTH WAS THE SAME DEFECT ONE CALL SITE OVER — THE ONE A
|
||||
SHIPPED INSTALL CAN ACTUALLY SEE.** Round 2 pinned the operand order of
|
||||
`SignWatchdog.evaluate()` at `main.py:1620`. That call site is the **site**
|
||||
detector, and `test_watchdog.py` part 9c asserts it is **never armed at a
|
||||
roster of one** — which is every install this add-on has. The **per-unit**
|
||||
call at `main.py:1560` was left unpinned, and swapping its first two
|
||||
arguments is the same **ZERO-BYTE** edit: `disagrees()` is symmetric in them
|
||||
(`commanded_w * against_w < 0` with an `abs()` gate on both), so **no trip
|
||||
decision can change** and the full 21-file suite stays green — measured. What
|
||||
moves is five banner lines, including the one persisted to `sign_reason` and
|
||||
re-read after a restart: *"commanded −1000 W against its own measured power
|
||||
+1000 W"*, operands inverted, on the only sign-disagreement sentence a
|
||||
one-inverter install can ever print. Closed in round 3 by the ordered pair on
|
||||
**both** per-unit banners — the N≥2 `its own measured power` one and the N=1
|
||||
`a meter reading` one — red on four named checks, green in the other twenty
|
||||
files. **Habit 15 is what falls out of finding the same shape twice in the
|
||||
same card.**
|
||||
|
||||
The general habits are **14** and **15** below.
|
||||
|
||||
Note what these have in common with a masked rung: the mutation *runs*, and the
|
||||
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
|
||||
@@ -427,6 +454,98 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
this one was found: **inside the fix written to close the previous instance
|
||||
of the same family**, which is the second time in this list.
|
||||
|
||||
⚠️ **AND THE FIX FOR IT WAS ONE CALL SITE WIDE, WHICH IS THE HALF WORTH
|
||||
KEEPING.** The ordered pair went onto `main.py:1620`, the **site** detector
|
||||
— armed only at N≥2, and part 9c asserts it is **absent at a roster of
|
||||
one**. The identical swap at `main.py:1560`, the **per-unit** detector, was
|
||||
still **ZERO BYTES**, still changed no decision, still left the 21-file
|
||||
suite green — and it is the **only** sign-disagreement sentence a shipped
|
||||
one-inverter install can ever print: *"commanded −1000 W against its own
|
||||
measured power +1000 W"*, backwards, in the log, on the status page and in
|
||||
`sign_reason` after the restart the latch is designed to survive. Measured
|
||||
size: **main.py:1560, 0 bytes, 21/21 green.** So habit 14 is not "pin the
|
||||
banner", it is **"pin every call site of the banner, and say which one the
|
||||
shipped roster reaches"** — and habit 15 is how you find them all.
|
||||
|
||||
15. **A SYMMETRIC PREDICATE MAKES ARGUMENT ORDER A PROPERTY OF THE MESSAGE
|
||||
ALONE — so ENUMERATE THAT FAMILY BY MACHINE, and grade the CALL SITES, not
|
||||
the function.** Where a callee is symmetric in two arguments, a swap at any
|
||||
call site is invisible to every behavioural check **by construction**: not
|
||||
"no fixture happens to separate them" but *no fixture can*. There is
|
||||
nothing for a mutation battery to find, because the mutant and the shipped
|
||||
tree take identical actions on every input. The only surface that moves is
|
||||
the sentence, and the only check that can see it is one asserting the
|
||||
**ordered pair** (habit 14) at **that** call site.
|
||||
Three consequences, and all three are why this is its own habit:
|
||||
* **The unit of coverage is the CALL SITE.** SAFETY-38 pinned
|
||||
`evaluate()`'s operand order and still shipped the defect, because the
|
||||
pin was on the call two lines below the one that matters. Ask of every
|
||||
banner: *how many places call this, and which does the shipped roster
|
||||
reach?*
|
||||
* **The scope must be COMPUTED** — habit 12's argument, and SAFETY-38 is
|
||||
its proof: the family was enumerated by hand twice and was short both
|
||||
times. `tools/symmetric_message_scan.py` renames each parameter pair,
|
||||
erases every formatted string, canonicalises the commutative operators
|
||||
and iterates to a **fixpoint** over `app/` (the symmetry is usually one
|
||||
call deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||||
classifies each sentence by where it GOES. A returned string may be a
|
||||
**key** — swapping `store.unit_key(unit, key)` renames a store key, which
|
||||
is behaviour and is MAINT-05's card — or a **sentence**, which is this
|
||||
family.
|
||||
* **Say what the instrument cannot see, with shapes** (habit 13). That scan
|
||||
under-reports semantic symmetry no canonicaliser on its finite list can
|
||||
spell; its second census, for sentences the CALLER builds, pairs only
|
||||
expressions **one leaf apart** and never sees `%s`-style logging; it
|
||||
covers only `app/`; and it **over-reports in exactly one way** — it
|
||||
erases the sentence, so a swap that would make the FORMATTING itself
|
||||
raise is scored as changing no decision. It prints all of that on every
|
||||
run. The family, every member's call sites, and which of them are pinned
|
||||
are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two
|
||||
measured, still-open survivors named there rather than rounded off**,
|
||||
both zero bytes and both 21/21 green.
|
||||
* ⚠️ **AND THE SINK IS HALF THE SCOPE, WHICH IS HOW THE FIRST THREE ROUNDS
|
||||
OF THIS SCAN MISSED THE PLACE THE CODEBASE ACTUALLY KEEPS ITS SENTENCES.**
|
||||
The scan classified a sentence as operator-facing when it was **appended,
|
||||
assigned to a `reason`-shaped name, or logged** — and every one of its 16
|
||||
reported members was one of those. **None was RETURNED.** A method
|
||||
formatting its own **attributes** and handing the string back —
|
||||
`ClampResult.describe()`, `PeakDecision.explain()`, every reason-builder
|
||||
in the tree — was outside all of it, and so were sentences handed to a
|
||||
constructor as `reason=` or placed under a `"text"` dict key. Four
|
||||
zero-byte survivors of a green 21-file suite were sitting there, two of
|
||||
them on the alarm SAFETY-40 and SAFETY-41 are about. **The general form:
|
||||
an enumeration is scoped by a SINK as much as by a PREDICATE, and a sink
|
||||
list is a hand-written list like any other (habit 12).** Ask of any such
|
||||
scan: *which syntactic forms does this call a message, and what does the
|
||||
code under it actually use?*
|
||||
|
||||
16. ⚠️ **AN INSTRUMENT THAT ASSERTS A COMPLETENESS IT DOES NOT HAVE IS WORSE
|
||||
THAN NO INSTRUMENT, AND ITS BLIND-SPOT LIST IS PART OF THE CODE UNDER
|
||||
REVIEW.** `tools/symmetric_message_scan.py` printed five honest gaps and a
|
||||
census count, and a reader — including its author, twice — took the count
|
||||
for the family. The gap that mattered, the RETURN sink, was **in none of the
|
||||
five**. A gap list is not a disclaimer; it is a claim, and it is the claim
|
||||
most likely to be believed because nothing else in the output argues with
|
||||
it. Two consequences:
|
||||
* **State what is NOT scanned, largest hole first, as a list of forms** —
|
||||
not "this under-reports". The tool now names `%s`-style lazy logging
|
||||
(`_LOG.error("… %s … %s", a, b)`, where the operands are *arguments*, so
|
||||
no f-string census can ever see them), `.format()`/`%`/`+`/`.join()`
|
||||
sentences, sentences that leave through a dict or dataclass, `yield`,
|
||||
message attributes outside its own constant set, and everything outside
|
||||
`app/`. Where the list is derived from a constant in the file, it is
|
||||
**printed from that constant**, so the two cannot drift.
|
||||
* **AND THE PROBE THAT GRADES THE MUTATIONS IS SUBJECT TO EVERY HABIT
|
||||
ABOVE.** Round 4's render probe gave each interpolated operand a value by
|
||||
**order of appearance** — so after a transposition the other operand took
|
||||
the same number and **all thirteen mutant renders came back
|
||||
byte-identical**, which reads exactly like *the edit changed nothing*.
|
||||
Keying the value to the operand's **NAME** fixed it. That is habit 4, in
|
||||
the measuring instrument: the fixture's own values were arranged so the
|
||||
failure it was built to see could not change the answer. **Eleventh
|
||||
instance of habit 4 in this file, and the second one in an instrument
|
||||
rather than in a check.**
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
|
||||
@@ -122,6 +122,18 @@ CIRC_OPTION_DEFAULTS = {
|
||||
# 200 W -> 445.0 s 350 W -> 10.0 s 1000 W -> 5.0 s
|
||||
# 250 W -> 40.0 s 400 W -> 5.0 s 1700 W -> 0.0 s
|
||||
#
|
||||
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
|
||||
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
|
||||
# than the assurance. What these windows are about is how long the command
|
||||
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
|
||||
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
|
||||
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
|
||||
# exactly 9 cycles at EVERY rail by construction. So the new value can never
|
||||
# be the slowest crossing at any `max_w`, and it is strictly inside the
|
||||
# envelope this campaign measured. An explicit number still spans 50..5000
|
||||
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
|
||||
# paragraph stops being true and the campaign has to be re-run.
|
||||
#
|
||||
# THE KNEE IS AT 400 W and it is sharp. Below ~250 W a flapping load holds
|
||||
# the sensor above the threshold for MINUTES at a time - 505 s at 100 W -
|
||||
# and no window shorter than the 900 s billing quarter could tell that from
|
||||
|
||||
@@ -145,14 +145,94 @@ from datetime import datetime
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
|
||||
# Re-assert the notification this often while the clamp stays engaged. 30 s is
|
||||
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
|
||||
# that a pinned loop is never quiet for a whole billing quarter.
|
||||
REASSERT_S = 30.0
|
||||
# Re-assert the notification this often while the clamp stays engaged.
|
||||
#
|
||||
# ⚠️ RAISED 30 s -> 150 s on 2026-09-05, and the reason is a REGIME CHANGE, not a
|
||||
# change of mind about the alarm. 30 s was the planning cadence (2.3), chosen
|
||||
# when a loop-side engagement was a rare event: `main.py`'s note at the action
|
||||
# log records "60 cycles of the §10.3 runaway produce 0 write-path engagements
|
||||
# and 2 loop-side ones". On the live install the counter reached 48 loop-side
|
||||
# ENGAGEMENTS in ~25 minutes of ordinary operation - roughly one cycle in six,
|
||||
# not a pinned rail, but far more often than a guard sized for 2-in-60 assumed -
|
||||
# and the owner received a persistent notification about it all day. ⚠️ NOT
|
||||
# because of the 30 s re-assert - see the block below; those notifications never
|
||||
# consulted this constant at all.
|
||||
#
|
||||
# ⚠️ BOTH FIGURES ARE ENGAGEMENT COUNTS; only the regime differs - 2-in-60 was
|
||||
# measured over a §10.3 RUNAWAY, 48-in-305 was ordinary running on the live
|
||||
# install. The 48 is read off the live log, not measured in this repo.
|
||||
#
|
||||
# ⚠️ AND RAISING THIS CONSTANT ALONE DID NOT FIX THAT FLOOD - review found it
|
||||
# and the first version of this change was wrong. At ~5 s cycles a 1-in-6
|
||||
# engagement leaves ~25 s of clean cycles between engagements, past
|
||||
# CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED between almost every one
|
||||
# and came back as a fresh rising edge. The rising edge used to fire
|
||||
# unconditionally, so those notifications never consulted this constant at all:
|
||||
# 48 notifications at 30 s, and still 48 at 150 s. The fix was to rate-limit the
|
||||
# RISING EDGE too and let `last_alarm` survive a de-assert, so this interval
|
||||
# bounds the notification rate across episodes rather than only within one.
|
||||
# Measured on that same window afterwards: 51 episodes, 11 notifications.
|
||||
#
|
||||
# WHY the loop reaches its integrator bound so much more often than the guard's
|
||||
# author saw: `max_w` on the live install is 5000 W, while the loop's slew was
|
||||
# derived against a 1500 W clamp - admin/goodwe `NOTES.md` §"Simulated 2 kW load
|
||||
# step (gain 0.6, clamp 1500)". ⚠️ THAT DERIVATION IS IN THE OTHER REPO; nothing
|
||||
# in this one corroborates it, so do not go looking here. SAFETY-39 is the ticket
|
||||
# that re-derives it, and fixing that is what would stop the railing; this
|
||||
# constant only changes how often the alarm repeats.
|
||||
#
|
||||
# ⚠️ 150 s IS THE REQUIREMENT'S EDGE WITH MARGIN, AND THE CHECK NOW ENFORCES THAT
|
||||
# EDGE. `test_clamp.py` part 2 grades the cadence in wall-clock terms - a pinned
|
||||
# loop must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter
|
||||
# that bills the capacity tariff - which caps this constant at 900/5 = 180 s.
|
||||
# Measured on the worst sliding 900 s window: 180 s earns 5 alarms, 181 s earns
|
||||
# 4. 150 s earns 6.
|
||||
#
|
||||
# ⚠️ THAT CHECK USED TO PERMIT 224 s, AND THE FIX SHIPPED WITH THIS COMMIT. Its
|
||||
# sweep starts at i=0, where the rising edge is free, so it measured one alarm
|
||||
# more than a real quarter earns. A later editor raising this to 200 s would have
|
||||
# had a green suite and a requirement quietly unmet. `test_clamp.py` now grades
|
||||
# floor(QUARTER_S / REASSERT_S) directly.
|
||||
#
|
||||
# ⚠️ MIN_PER_QUARTER = 5 IS A TEST-LOCAL INTEGER, not a spec figure - it exists
|
||||
# only in `test_clamp.py` and the CHANGELOG entry that introduced it. The 900 s
|
||||
# quarter IS system-wide (`tariff.py`, `freeze.py`, `DOCS.md`). So the 180 s
|
||||
# ceiling rests on one number a test author chose; if that number is ever
|
||||
# revisited, this constant moves with it.
|
||||
#
|
||||
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
|
||||
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
|
||||
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
|
||||
# suppressed: every engagement is still counted, the action-log row is written on
|
||||
# every engaged cycle regardless, and the rising edge of every episode alarms
|
||||
# unconditionally, so no alarm is DROPPED - only delayed.
|
||||
REASSERT_S = 150.0
|
||||
|
||||
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
|
||||
CLEAR_CYCLES = 3
|
||||
|
||||
# ⚠️ SAFETY-41. Consecutive ENGAGED cycles after which an episode speaks even if
|
||||
# the notification budget is spent. THE ESCALATION EXISTS BECAUSE THE RATE LIMIT
|
||||
# ALONE DELAYS A RUNAWAY, and review measured it: a benign one-cycle clamp
|
||||
# followed by a continuous runaway held the runaway's first notification for up
|
||||
# to 140 s - one REASSERT_S minus a cycle. On the live install, where ordinary
|
||||
# railing spends the budget essentially all the time, a runaway arriving during
|
||||
# normal operation would be delayed with near-certainty. That is the "benign
|
||||
# noise suppresses the bug signal" shape this module's docstring is written
|
||||
# against.
|
||||
#
|
||||
# THE DISCRIMINATOR IS RUN LENGTH, and the two populations genuinely differ: the
|
||||
# live flood was runs of length ONE (engage, de-assert, engage), while a §10.3
|
||||
# runaway engages on EVERY cycle. So a sustained run speaks regardless of budget,
|
||||
# and scattered chatter does not.
|
||||
#
|
||||
# Measured, shipped-without vs shipped-with - it costs nothing it was protecting:
|
||||
# live scatter flood (305 cycles, 1-in-6) 11 -> 11 notifications
|
||||
# chatter 1-engaged/3-clean over 8000 s 50 -> 50 notifications
|
||||
# pinned 900 s quarter (floor is 5) 6 -> 6 alarms
|
||||
# runaway delay after a benign episode 140 s -> 10 s
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
@@ -258,6 +338,10 @@ class ClampWatchdog:
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
|
||||
# spoken yet - the two the escalation clause needs.
|
||||
self.run_len = 0
|
||||
self.spoken = False
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
@@ -269,26 +353,72 @@ class ClampWatchdog:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
self.run_len += 1
|
||||
if not self.active:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.spoken = False
|
||||
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
|
||||
# not used to be. It fired unconditionally, so a condition that
|
||||
# engages and clears repeatedly raised one notification PER
|
||||
# EPISODE no matter how fast the episodes came - and
|
||||
# `REASSERT_S` never entered into it, because it only governs
|
||||
# the still-engaged path below.
|
||||
#
|
||||
# That is the shape the live install actually produced: 48
|
||||
# engagements in 25 minutes, scattered roughly one cycle in six.
|
||||
# At a ~5 s cycle the gaps are ~25 s of clean cycles, which
|
||||
# exceeds CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED
|
||||
# between almost every engagement and each one came back as a
|
||||
# fresh rising edge. Measured on this class over that window:
|
||||
# 48 episodes and 48 notifications at REASSERT_S = 30 - and
|
||||
# still 48 at REASSERT_S = 150. Raising the re-assert removed
|
||||
# NONE of them, because none of them came from the re-assert.
|
||||
#
|
||||
# So the interval now bounds the NOTIFICATION RATE rather than
|
||||
# the repeat rate within one episode: `last_alarm` survives a
|
||||
# de-assert, and a new episode speaks only if the interval has
|
||||
# elapsed. ⚠️ NOTHING IS DROPPED. Every engagement is still
|
||||
# counted, every episode still increments, the action-log row is
|
||||
# still written on every engaged cycle, and a genuine runaway -
|
||||
# which by definition keeps engaging - is picked up by the
|
||||
# re-assert path within REASSERT_S. Delay, not loss.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
|
||||
# budget was spent by an EARLIER, unrelated episode speaks anyway
|
||||
# once it has engaged SUSTAINED_CYCLES times in a row. A runaway
|
||||
# engages every cycle and reaches it; the scattered one-cycle
|
||||
# railing that produced the live flood never does.
|
||||
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
|
||||
self.run_len = 0
|
||||
if not self.active:
|
||||
return False
|
||||
self.clean_cycles += 1
|
||||
if self.clean_cycles >= CLEAR_CYCLES:
|
||||
self.active = False
|
||||
self.since = None
|
||||
self.last_alarm = None
|
||||
# ⚠️ `last_alarm` DELIBERATELY SURVIVES THE DE-ASSERT. Clearing it
|
||||
# here is what made the next rising edge speak immediately however
|
||||
# soon it arrived, which is the flood described at the rising edge
|
||||
# above. The episode ends; the notification budget does not reset
|
||||
# with it.
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
|
||||
@@ -18,12 +18,92 @@ exists because its absence produced a specific, observed failure.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-39. THE ONE RELATIONSHIP IN THIS FILE THAT IS NOT A NUMBER.
|
||||
#
|
||||
# ⚠️ WHAT WENT WRONG, because the shape of it is the reason this is a ratio and
|
||||
# not a constant. `slew_w: 1000` was derived on 2026-08-22 against a clamp of
|
||||
# 1500 W (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"),
|
||||
# and its stated justification was that it "puts most of the correction in the
|
||||
# first cycle WHILE STILL CAPPING HOW FAR THE LOOP CAN JUMP AHEAD OF THE
|
||||
# INVERTER". At 1500 W that sentence was true: 1000 W/cycle reaches the rail in
|
||||
# two cycles and the rail itself bounds the excursion.
|
||||
#
|
||||
# The rail then moved to 5000 W on the live install and the constant did not.
|
||||
# 1000 W per 2 s tick is 500 W/s, so the loop traverses its WHOLE OUTPUT RANGE
|
||||
# in 10 s against a plant NOTES.md §5.10 measured at ~18 s up / ~23 s down for a
|
||||
# 1.78 kW step. It commands its entire range before it can observe its answer to
|
||||
# the first step. Measured on the owner's system 2026-09-05 11:43-11:46: a real
|
||||
# 3.4 kW export, the setpoint ramping 380 -> 5000 in five ticks, the battery at
|
||||
# -5154 W against a 5000 VA inverter, and the grid pushed from 3.4 kW of EXPORT
|
||||
# to 2.8 kW of IMPORT - the loop manufactured the error it then chased.
|
||||
#
|
||||
# ⚠️ SO THE FIX IS NOT A NEW NUMBER. A number is what went stale. What is
|
||||
# written here is the RELATIONSHIP the 2026-08-22 sentence was really asserting:
|
||||
#
|
||||
# THE LOOP MUST NOT TRAVERSE ITS FULL OUTPUT RANGE FASTER THAN THE PLANT
|
||||
# TAKES TO SETTLE.
|
||||
#
|
||||
# slew_w <= max_w * LOOP_TICK_S / PLANT_SETTLE_S
|
||||
#
|
||||
# `max_w` here is the SITE rail (main.py builds `Tuning(max_w=site_max_w)`, the
|
||||
# roster sum), so the bound moves with the roster and a second inverter cannot
|
||||
# re-open this by arriving. validate.py rule 10 refuses a configuration that
|
||||
# breaks it; `slew_w = 0` below means "follow the rail" and is the shipped
|
||||
# default, exactly as `integrator_max_w = None` already means "follow max_w".
|
||||
#
|
||||
# ⚠️ BOTH NUMBERS ARE WRITTEN OUT RATHER THAN THEIR QUOTIENT, AND THAT IS NOT
|
||||
# TIDINESS. The quotient is only meaningful in the regime the two were measured
|
||||
# in, and one of them is NOT a property of this code:
|
||||
#
|
||||
# PLANT_SETTLE_S is the inverter and the pack. goodwe/NOTES.md §5.10,
|
||||
# 2026-08-22, a 1.78 kW kettle step driven from HA with a
|
||||
# plug that meters its own draw: ~18 s from the step to the
|
||||
# deadband going up, ~23 s coming down. 18 is taken because
|
||||
# it is the SHORTER of the two and therefore the LOOSER
|
||||
# bound - this is a refusal boundary, and rules 5 and 6 in
|
||||
# validate.py refuse the impossible case only, never "close
|
||||
# to the rail".
|
||||
#
|
||||
# LOOP_TICK_S is the METER, not this loop. `run_control()` runs a cycle on
|
||||
# a changed meter value or on `cycle_floor_s`, whichever comes
|
||||
# first, so the tick is the meter's own update rate. 2.0 s is
|
||||
# what the live install measured on 2026-09-05 (the setpoint
|
||||
# stepped +1000 W every 2 s), and it is the fastest meter this
|
||||
# project has ever run.
|
||||
#
|
||||
# ⚠️ AND THIS ONE IS NOT WRONG IN THE SAFE DIRECTION, WHICH IS WHY IT IS SAID
|
||||
# HERE. A SLOWER meter makes this bound conservative - it refuses some
|
||||
# configurations that would have been fine, which is visible immediately and
|
||||
# costs one edit. A meter FASTER than 2 s makes it loose by exactly that ratio,
|
||||
# and nothing in this add-on would say so. The tick cannot go below 1 s
|
||||
# (`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), so the
|
||||
# worst case is a factor of two - but if a faster meter is ever fitted, this
|
||||
# constant has to be RE-DERIVED and not merely re-read. Compare
|
||||
# validate.FIRMWARE_INGEST_VETO_S, which carries the same kind of copy and can
|
||||
# only ever be wrong the other way.
|
||||
PLANT_SETTLE_S = 18.0
|
||||
LOOP_TICK_S = 2.0
|
||||
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
gain: float = 0.6
|
||||
max_w: float = 2000.0
|
||||
slew_w: float = 1000.0
|
||||
# SAFETY-39. How far the command may move in one cycle. ⚠️ 0 MEANS "FOLLOW
|
||||
# THE RAIL" - `SLEW_RAIL_FRAC * max_w` - and it is the default and the
|
||||
# recommended setting, for the reason written at the top of this file: a
|
||||
# fixed number here is a constant that outlives the rail it was derived
|
||||
# against, and that is the defect measured on the live system on
|
||||
# 2026-09-05. Read `slew_limit_w()`, never this field.
|
||||
#
|
||||
# It stays settable, because slower than the rail implies is a legitimate
|
||||
# and sometimes necessary choice - the live install has run an explicit
|
||||
# 400 W since 2026-09-05 12:00 - and because `slew_w` is an option an
|
||||
# operator already has. FASTER than the rail implies is what validate.py
|
||||
# rule 10 refuses.
|
||||
slew_w: float = 0.0
|
||||
deadband_w: float = 15.0
|
||||
step_w: int = 10
|
||||
saturation_w: float = 500.0
|
||||
@@ -83,6 +163,28 @@ class Decision:
|
||||
clamped: bool = False
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
"""How far the command may move this cycle, in W. Always positive.
|
||||
|
||||
⚠️ THE ONE READER OF `Tuning.slew_w`, and everything else must come through
|
||||
here. `slew_w = 0` means "follow the rail" and resolves to
|
||||
`SLEW_RAIL_FRAC * max_w`, which is the whole of SAFETY-39: the limit is a
|
||||
RATIO to the rail, so raising `max_w` moves it and cannot leave it behind.
|
||||
A caller that read the field directly would see 0 and apply no limit at all
|
||||
- the exact opposite of the setting's meaning - which is why this exists as
|
||||
a function rather than as a comment telling people to remember.
|
||||
|
||||
⚠️ AND IT IS NOT CLAMPED TO THE BOUND. An explicit `slew_w` above the rail
|
||||
fraction is refused at STARTUP by validate.py rule 10, not silently reduced
|
||||
here. Silently reducing it is the failure this whole module's docstring is
|
||||
about - an operator's stated number quietly meaning something else - and
|
||||
validate.py's own docstring calls it case (a).
|
||||
"""
|
||||
if tuning.slew_w:
|
||||
return abs(float(tuning.slew_w))
|
||||
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
|
||||
|
||||
|
||||
def compute(
|
||||
prev_w: float,
|
||||
grid_w: float,
|
||||
@@ -231,7 +333,8 @@ def compute(
|
||||
if clamped:
|
||||
reason = "clamped"
|
||||
|
||||
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
||||
slew = slew_limit_w(tuning)
|
||||
slewed = max(prev_w - slew, min(prev_w + slew, target))
|
||||
if slewed != target:
|
||||
reason = "slew-limited"
|
||||
target = slewed
|
||||
|
||||
@@ -247,7 +247,13 @@ class Controller:
|
||||
# binds each unit's own share through its own `supervised` claim, so
|
||||
# nothing here can over-command a device.
|
||||
max_w=self.site_max_w,
|
||||
slew_w=float(opts.get("slew_w", 1000)),
|
||||
# ⚠️ SAFETY-39: THE FALLBACK IS 0 AND 0 MEANS "FOLLOW THE RAIL",
|
||||
# which is what config.yaml ships and what `Tuning`'s own default
|
||||
# is. It used to be 1000 - a third copy of a constant that had
|
||||
# already gone stale against the rail in the other two - and rule
|
||||
# 6's comment in validate.py documents what that costs when the
|
||||
# copies disagree. `control.slew_limit_w()` is the one reader.
|
||||
slew_w=float(opts.get("slew_w", 0) or 0),
|
||||
deadband_w=float(opts.get("deadband_w", 15)),
|
||||
target_grid_w=float(opts.get("target_grid_w", 0)),
|
||||
step_w=int(opts.get("step_w", 10)),
|
||||
|
||||
@@ -183,6 +183,60 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
the per-unit form and main.py runs it from `run_control()`, once every
|
||||
unit's own ESPHome has replied.
|
||||
|
||||
10. `slew_w` above the fraction of the SITE RAIL the plant can follow. [(a)]
|
||||
SAFETY-39, and the first rule here whose subject is a RELATIONSHIP rather
|
||||
than a threshold. `slew_w` bounds how far the site command may move in one
|
||||
cycle; `control.SLEW_RAIL_FRAC` is `LOOP_TICK_S / PLANT_SETTLE_S`, so
|
||||
`SLEW_RAIL_FRAC * rail` is the largest step at which the loop still takes
|
||||
the plant's own measured settling time to traverse its whole output range.
|
||||
Above it the controller commands its entire range before it can observe
|
||||
its answer to the first step, and the stated limit - "the command moves at
|
||||
most `slew_w` per cycle, so it cannot jump ahead of the inverter" - is
|
||||
untrue: it jumps ahead by construction.
|
||||
|
||||
⚠️ THIS IS (a) AND NOT (b), and the distinction is what makes it a refusal
|
||||
rather than a warning. Nothing goes inert. What happens is that a bound
|
||||
the operator set means something other than what it says, and the layer
|
||||
that overrules it is the PLANT - which has no status row, publishes
|
||||
nothing, and cannot be read off any page in this add-on. Measured on the
|
||||
owner's system 2026-09-05 11:43-11:46 at `max_w: 5000`, `slew_w: 1000`:
|
||||
setpoint 380 -> 5000 in five 2 s ticks, battery -5154 W against a 5000 VA
|
||||
inverter, and the grid driven from 3.4 kW of EXPORT to 2.8 kW of IMPORT.
|
||||
Large (>= 900 W) setpoint steps per 30 min climbed 2 -> 1 -> 13 -> 43 that
|
||||
morning. Nothing in the add-on reported anything wrong, because nothing
|
||||
was: the loop followed its command exactly, and the command was wrong.
|
||||
|
||||
⚠️ IT IS GRADED AGAINST THE ROSTER SUM, NOT THE FLAT `max_w` AND NOT THE
|
||||
WEAKEST UNIT, and this is the one rule in this file for which that is the
|
||||
right rail. Rules 5, 6 and 8 are all about ONE unit - one watchdog, one
|
||||
shaping claim, one pack - so they take `min()` over `unit_caps()`.
|
||||
`slew_w` is not per unit: `main.py` builds `Tuning(max_w=self.site_max_w)`
|
||||
and `compute()` produces the SITE target, which the allocator then splits.
|
||||
So the range this bound is about is the sum, `Controller.site_max_w`, and
|
||||
`site_rail_w()` below is that same number computed from options.
|
||||
|
||||
⚠️ AND THE `site_sign_disagree_w` NOTE IN config.yaml IS NOT A PRECEDENT
|
||||
AGAINST THIS. That note says there is deliberately no rule pairing
|
||||
`site_sign_disagree_w` with `max_w`, "because the bound on the SITE command
|
||||
is `Controller.site_max_w`, the ROSTER SUM, and `validate_startup_config()`
|
||||
is handed options rather than a roster. A rule written against max_w would
|
||||
refuse a perfectly reachable two-inverter configuration." That objection is
|
||||
to grading a site quantity against the FLAT key. It is answered rather than
|
||||
ignored: `unit_caps()` already reads the roster the add-on will build, so
|
||||
the roster sum IS available here, and this rule takes it.
|
||||
|
||||
⚠️ THE DIRECTION OF THIS RULE, SAID PLAINLY, BECAUSE IT IS NOT THE
|
||||
DIRECTION THE TICKET ASSUMED. A rail that GROWS loosens this bound: at a
|
||||
9600 W two-inverter rail, `slew_w: 1000` traverses the rail in 19.2 s and
|
||||
passes, while at 5000 W the same 1000 W is the configuration measured
|
||||
above. That is what the constraint says and it is correct - the bound is on
|
||||
traverse TIME, and a bigger rail at the same step size takes longer to
|
||||
cross. What this rule therefore protects against is the constant being left
|
||||
behind when the rail moves EITHER way. It is NOT a bound on how much power
|
||||
the site may command, and it does NOT bound the over-absorption: see the
|
||||
note below on `gain`, which is the term that owns that and which SAFETY-39
|
||||
measured and did not change.
|
||||
|
||||
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
|
||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||
@@ -226,6 +280,34 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
is answered. Compare rule 5, which CAN be settled at startup because both
|
||||
`sign_disagree_w` and `max_w` are config and are both known at boot.
|
||||
|
||||
- `gain` at any value the schema accepts, INCLUDING against a large rail.
|
||||
SAFETY-39 measured this and deliberately left it alone, which is worth more
|
||||
than the rule would have been. On admin/goodwe's rig, over a family of 51
|
||||
export steps from 1.0 to 4.2 kW at `max_w: 5000` and a 2 s tick, the
|
||||
over-absorption - peak battery power beyond what the site actually needed -
|
||||
is FLAT in `slew_w`: 958 W at slew 1000 and 1081 W at slew 250. It falls
|
||||
in `gain`: 987 W at 0.6, 876 W at 0.3, 435 W at 0.2 - a real reduction, but
|
||||
NOT to zero, and the 0.2 figure is the one worth not rounding away. The
|
||||
reason is structural and is visible in control.py: `slew_w` bounds the
|
||||
OUTPUT, while the integrator accumulates `gain * error` per cycle and is
|
||||
bounded only by `integrator_max_w`. At gain 0.6 into a 3.4 kW error the
|
||||
integrator moves ~2040 W per tick and reaches a 5000 W rail in under three
|
||||
ticks whatever `slew_w` is.
|
||||
So the 2026-08-22 derivation's own sentence - "the gain, not the slew, is
|
||||
the limit by then" - is now literally true, and `gain` is the constant that
|
||||
needs re-deriving next. It is NOT refused and NOT changed here: it is a
|
||||
live control-law constant on a running house, changing it in the same pull
|
||||
request as `slew_w` would make the next measurement uninterpretable, and a
|
||||
validator rule pairing `gain` with a rail would be inventing a relationship
|
||||
nobody has measured. Named here so it is a known open item rather than an
|
||||
omission. See DECISIONS/SAFETY-39-slew-against-the-rail.md.
|
||||
|
||||
- `slew_w` BELOW the rail fraction, at any value the schema accepts. Slower
|
||||
than the plant is always safe and is sometimes necessary - the live install
|
||||
has run an explicit 400 W against a 5000 W rail since 2026-09-05 12:00,
|
||||
which is 12.5 ticks to traverse the rail against the 9 this rule requires.
|
||||
Refusing "too slow" would refuse the mitigation that is holding.
|
||||
|
||||
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
|
||||
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
|
||||
every ordering of the two is a legitimate site configuration. Refusing on
|
||||
@@ -235,6 +317,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
import logging
|
||||
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
@@ -392,6 +475,27 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
|
||||
return caps
|
||||
|
||||
|
||||
def site_rail_w(opts: dict) -> float | None:
|
||||
"""The SITE rail: what the whole site may be commanded, in W. Rule 10's rail.
|
||||
|
||||
⚠️ THE SUM, NOT THE MINIMUM, AND NOT THE FLAT KEY - and it is the only place
|
||||
in this file that wants a sum. Rules 5, 6 and 8 grade a threshold against
|
||||
ONE unit (one watchdog, one shaping claim, one pack) and take `min()` over
|
||||
`unit_caps()`. `slew_w` is not a per-unit quantity at all: `main.py` builds
|
||||
`Tuning(max_w=self.site_max_w)` from the roster sum and `compute()` emits
|
||||
the SITE target, which the allocator then divides. So the output range
|
||||
`slew_w` is a rate across is `Controller.site_max_w`, and this is that same
|
||||
number read out of options rather than off a built roster.
|
||||
|
||||
At the shipped roster of one this is `unit_caps()`'s single entry, which is
|
||||
`max_w` exactly - byte for byte the number the single-inverter shape has
|
||||
always meant. None when no entry carries a usable cap, which rule 9 has
|
||||
already refused by the time rule 10 runs.
|
||||
"""
|
||||
caps = unit_caps(opts)
|
||||
return float(sum(caps)) if caps else None
|
||||
|
||||
|
||||
def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
|
||||
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
|
||||
|
||||
@@ -935,6 +1039,62 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
|
||||
f"less, or raise max_w to at least {charge_w:.0f} W.")
|
||||
|
||||
# --- 10. a slew rate the plant can actually follow -----------------------
|
||||
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
|
||||
# threshold, and the whole point of writing it as one is that the number it
|
||||
# bounds must not be able to outlive the rail again. `control.py`'s header
|
||||
# carries the derivation; the short version is that `slew_w: 1000` was
|
||||
# derived against a 1500 W clamp and the live install raised the clamp to
|
||||
# 5000 without revisiting it, so the loop traversed its whole output range
|
||||
# in 10 s against a plant measured at ~18 s.
|
||||
#
|
||||
# ⚠️ `slew_w: 0` IS NOT GRADED AND MUST NOT BE. Zero means "follow the
|
||||
# rail" - `control.slew_limit_w()` resolves it to `SLEW_RAIL_FRAC * max_w`,
|
||||
# which satisfies this rule by construction at every rail there is. It is
|
||||
# the shipped default, and a rule that refused it would refuse the fix.
|
||||
#
|
||||
# ⚠️ STRICTLY `>`, MATCHING RULE 6 RATHER THAN RULE 5. Equality is the
|
||||
# derived value itself: at `slew_w == SLEW_RAIL_FRAC * rail` the loop takes
|
||||
# exactly `PLANT_SETTLE_S` to cross its range, which is the constraint
|
||||
# satisfied, not violated. Refusing it would refuse the value this rule
|
||||
# exists to recommend - and `slew_limit_w()` produces exactly that value
|
||||
# for every default install, so `>=` would refuse the shipped default
|
||||
# through a float comparison. A tolerance is applied for the same reason:
|
||||
# `SLEW_RAIL_FRAC * 5000` is 555.55..., and an operator who writes the 556
|
||||
# a status page rounded to is one watt over a boundary that is not
|
||||
# meaningful to one watt.
|
||||
slew_w = _number(opts, "slew_w", 0.0)
|
||||
rail = site_rail_w(opts)
|
||||
if slew_w is None:
|
||||
problems.append(f"slew_w {opts.get('slew_w')!r} is not a number")
|
||||
elif slew_w < 0:
|
||||
problems.append(
|
||||
f"slew_w is {slew_w:.0f} W and must not be negative - it is a "
|
||||
"magnitude, and 0 means 'follow max_w' (the recommended setting).")
|
||||
elif slew_w and rail is not None and rail > 0:
|
||||
bound = SLEW_RAIL_FRAC * rail
|
||||
if slew_w > bound + 1.0:
|
||||
ticks = rail / slew_w
|
||||
problems.append(
|
||||
f"slew_w is {slew_w:.0f} W against a site rail of {rail:.0f} W, "
|
||||
f"so the loop crosses its whole output range in {ticks:.1f} "
|
||||
f"cycles - {ticks * LOOP_TICK_S:.0f} s at the {LOOP_TICK_S:.0f} s "
|
||||
f"tick this site was measured running - while the plant takes "
|
||||
f"{PLANT_SETTLE_S:.0f} s to settle (goodwe/NOTES.md §5.10, a "
|
||||
"1.78 kW step). The controller would command its entire range "
|
||||
"before it could observe its answer to the first step, which is "
|
||||
"not a slow loop, it is a loop that MANUFACTURES the error it "
|
||||
"then chases: measured on this system 2026-09-05, a 3.4 kW "
|
||||
"export drove the setpoint 380 -> 5000 W in five ticks, the "
|
||||
"battery to -5154 W past a 5000 VA rating, and the grid from "
|
||||
"3.4 kW of export to 2.8 kW of import. The stated limit - the "
|
||||
"command moves at most slew_w per cycle, so it cannot jump "
|
||||
"ahead of the inverter - is untrue at this rail; THE PLANT "
|
||||
f"WINS, and it has no status row. Set slew_w to 0, which means "
|
||||
f"'follow the rail' and resolves to {bound:.0f} W here and "
|
||||
"moves on its own if the rail does, or write a value at or "
|
||||
f"below {bound:.0f} W. Lower is always accepted.")
|
||||
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
|
||||
|
||||
@@ -331,6 +331,18 @@ SIGN_OPTION_DEFAULTS = {
|
||||
# 150 W -> 15.0 s 300 W -> 10.0 s 1000 W -> 0.0 s
|
||||
# 200 W -> 15.0 s 500 W -> 5.0 s
|
||||
#
|
||||
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
|
||||
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
|
||||
# than the assurance. What these windows are about is how long the command
|
||||
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
|
||||
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
|
||||
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
|
||||
# exactly 9 cycles at EVERY rail by construction. So the new value can never
|
||||
# be the slowest crossing at any `max_w`, and it is strictly inside the
|
||||
# envelope this campaign measured. An explicit number still spans 50..5000
|
||||
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
|
||||
# paragraph stops being true and the campaign has to be re-run.
|
||||
#
|
||||
# ⚠️ AND THE CURVE IS FLAT, WHICH IS THE FINDING THAT DECIDED THE SHAPE OF
|
||||
# THIS PAIR. Unlike the site comparison - where the meter is driven by the
|
||||
# LOAD and a flapping load holds it opposed for minutes - a unit's own power
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.6.0"
|
||||
version: "0.7.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -516,7 +516,40 @@ options:
|
||||
# ==========================================================================
|
||||
auto_start: false
|
||||
gain: 0.6
|
||||
slew_w: 1000
|
||||
|
||||
# ⚠️ SAFETY-39. 0 IS NOT "UNSET" AND IT IS NOT "NO LIMIT" - IT IS "FOLLOW THE
|
||||
# RAIL", and it is the shipped value. `control.slew_limit_w()` resolves it to
|
||||
# `SLEW_RAIL_FRAC * max_w`, which is 222 W against the 2000 W default here,
|
||||
# 556 W against the live install's 5000 W, and 1067 W against the ~9600 W a
|
||||
# second inverter makes of the site. `integrator_max_w: 0` two lines down has
|
||||
# meant exactly this since it was added; this key now means it too.
|
||||
#
|
||||
# ⚠️ WHY IT IS A RATIO AND NOT A NUMBER, WHICH IS THE WHOLE OF THIS CARD. It
|
||||
# WAS a number: 1000, derived 2026-08-22 against a clamp of 1500 W
|
||||
# (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)") on the
|
||||
# grounds that it caps "how far the loop can jump ahead of the inverter". The
|
||||
# rail then went to 5000 on the live install and the constant did not move, so
|
||||
# 1000 W per 2 s tick crossed the whole output range in 10 s against a plant
|
||||
# NOTES.md §5.10 measured at ~18 s up / ~23 s down. Measured on the owner's
|
||||
# system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000
|
||||
# in five ticks, the battery to -5154 W past a 5000 VA rating, and the grid
|
||||
# from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Large (>= 900 W) steps per 30 min
|
||||
# went 2 -> 1 -> 13 -> 43 that morning. Nothing was broken; the rail moved.
|
||||
#
|
||||
# An explicit number is still accepted and is not clamped - the live install
|
||||
# has run 400 since 2026-09-05 12:00, which is slower than the rail implies
|
||||
# and is fine. What validate.py rule 10 refuses is a number FASTER than the
|
||||
# rail implies, because at that setting the stated limit is not the limit and
|
||||
# the layer that overrules it is the plant, which has no status row.
|
||||
#
|
||||
# ⚠️ AND THE COST IS REAL AND IS NOT HIDDEN: following the rail is slower to
|
||||
# converge than 1000 was. Measured over 51 export steps on the in-repo model
|
||||
# (test_control.py) the largest tick step falls from 1000 W to 556 W and the
|
||||
# >= 900 W step disappears from every run - but the OVER-ABSORPTION does not
|
||||
# move, because that is a `gain` term and not a slew term. See
|
||||
# DECISIONS/SAFETY-39-slew-against-the-rail.md, which says so at length rather
|
||||
# than letting this card be read as more than it is.
|
||||
slew_w: 0
|
||||
deadband_w: 15
|
||||
target_grid_w: -10
|
||||
step_w: 10
|
||||
@@ -744,7 +777,16 @@ schema:
|
||||
# ==========================================================================
|
||||
auto_start: bool
|
||||
gain: float(0.05,1.0)
|
||||
slew_w: int(50,5000)
|
||||
|
||||
# ⚠️ THE FLOOR IS 0, NOT 50, AND 0 IS THE SHIPPED VALUE - see the options block
|
||||
# above. It means "follow max_w", so the range starts at the setting that
|
||||
# cannot go stale rather than at the smallest number somebody could type.
|
||||
# ⚠️ THE CEILING IS STILL 5000 AND IS STILL NOT THE BOUND THAT MATTERS. The
|
||||
# binding one is `SLEW_RAIL_FRAC * max_w`, a relation BETWEEN keys, and this
|
||||
# grammar cannot express a relation - which is the same reason
|
||||
# `maintenance_charge_w`'s ceiling is not its real bound either (see the note
|
||||
# above it). validate.py rule 10 is where it is written.
|
||||
slew_w: int(0,5000)
|
||||
deadband_w: int(0,500)
|
||||
target_grid_w: float(-200,200)
|
||||
step_w: int(1,100)
|
||||
|
||||
@@ -580,7 +580,16 @@ def close_loop(gain, split, cycles=200, load_w=2000.0, mode="alloc",
|
||||
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)
|
||||
# ⚠️ `slew_w` IS PINNED HERE AND WAS NOT BEFORE, AND THAT IS SAFETY-39.
|
||||
# ARCH-01's 4000 W peak-to-peak broadcast limit cycle was measured at the
|
||||
# shipped pair of the day - `max_w: 2000` with `slew_w: 1000` - and this
|
||||
# function reached that 1000 through `Tuning`'s DEFAULT. SAFETY-39 made that
|
||||
# default 0, meaning "follow the rail" (`SLEW_RAIL_FRAC * max_w`, 222 W at
|
||||
# this rail), so the same call would now sweep a DIFFERENT law and report
|
||||
# ARCH-01's measurement as absent. A reproduction that silently re-tunes
|
||||
# itself when a default moves is not a reproduction. The number is written
|
||||
# here, next to the measurement it belongs to.
|
||||
tuning = Tuning(gain=gain, slew_w=1000.0)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
power = [0.0, 0.0]
|
||||
grid = load_w
|
||||
|
||||
@@ -449,9 +449,32 @@ mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0,
|
||||
r_mis = resolve(mis_peak)
|
||||
print(f" peak floor misfiled on inverter-b: {r_mis.explain()} "
|
||||
f"scope_errors={r_mis.scope_errors}")
|
||||
check("the mis-routing is CAUGHT: scope_violations names the source and the "
|
||||
"actuator it does not belong on", len(r_mis.scope_errors) == 1
|
||||
and "peak" in r_mis.scope_errors[0])
|
||||
# ⚠️ THE ORDERED PAIR, AND THE CHECK THAT USED TO BE HERE WAS WIDER THAN ITS
|
||||
# OWN CLAIM. It said `scope_violations` "names the source and the actuator it
|
||||
# does not belong on" and then asserted `"peak" in r_mis.scope_errors[0]`.
|
||||
# `arbiter.py:264` builds that sentence as
|
||||
# `f"{c.source}: a SITE claim filed on unit {c.actuator!r} - …"`; transpose the
|
||||
# two - zero bytes, no decision changes, SAFETY-38 census B row B12 - and it
|
||||
# reads "inverter-b: a SITE claim filed on unit 'peak'", naming the misfiled
|
||||
# claim as the actuator and the actuator as the claim. `"peak" in …` is STILL
|
||||
# TRUE under that, so the old line graded neither half of what it claimed.
|
||||
# The operands are read off the CLAIM this test built, not off the message.
|
||||
_mis_c = mis_peak[-1]
|
||||
check("PRECONDITION: the misfiled claim's source and actuator are DIFFERENT "
|
||||
"STRINGS, so a transposition genuinely produces a different sentence",
|
||||
_mis_c.source != _mis_c.actuator and _mis_c.source == "peak"
|
||||
and _mis_c.actuator == "inverter-b")
|
||||
check("the mis-routing is CAUGHT, and scope_violations names the source FIRST "
|
||||
"and the actuator it does not belong on SECOND - the ORDERED PAIR on the "
|
||||
"contiguous literal, because a substring test finds either name under a "
|
||||
"transposition that swaps their roles",
|
||||
len(r_mis.scope_errors) == 1
|
||||
and r_mis.scope_errors[0].startswith(
|
||||
f"{_mis_c.source}: a SITE claim filed on unit {_mis_c.actuator!r}"))
|
||||
check("...and the TRANSPOSED sentence is not what it says, so the check above "
|
||||
"discriminates rather than decorates",
|
||||
f"{_mis_c.actuator}: a SITE claim filed on unit {_mis_c.source!r}"
|
||||
not in r_mis.scope_errors[0])
|
||||
check("...and the reason it must be caught, measured rather than argued: with "
|
||||
"the floor on a unit the SITE has no floor, so the loop charges 1500 W "
|
||||
"into the quarter the guard was trying to shave",
|
||||
|
||||
@@ -78,7 +78,8 @@ import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from app.allocator import Allocation, UnitPlan, plan
|
||||
@@ -239,11 +240,112 @@ print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: "
|
||||
check(f"a loop pinned for a whole billing quarter speaks at least "
|
||||
f"{MIN_PER_QUARTER} times (it spoke {alarms})",
|
||||
alarms >= MIN_PER_QUARTER)
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it is the SAME DEFECT as the one recorded
|
||||
# twenty lines above, one notch weaker. The sweep above runs i = 0..899 and the
|
||||
# rising edge at i=0 is FREE, so it measures `1 + floor(899 / REASSERT_S)` - one
|
||||
# alarm more than a real quarter earns. Measured on this very check:
|
||||
# REASSERT_S = 180 -> 5 alarms, green (and 180 is the true edge: on the worst
|
||||
# REASSERT_S = 224 -> 5 alarms, GREEN sliding 900 s window 180 gives 5 and
|
||||
# REASSERT_S = 225 -> 4 alarms, red 181 gives 4)
|
||||
# So the check as written permits 224 s while the REQUIREMENT permits 180 s, and
|
||||
# anyone raising the constant to 200 s would get a green suite and conclude the
|
||||
# floor was met when a real quarter would earn 4 alarms. Grade the cadence
|
||||
# DIRECTLY, so the check enforces the ceiling its own comment claims for it.
|
||||
check(f"...and the CADENCE itself honours the floor, independent of where the "
|
||||
f"sweep happens to start: floor({QUARTER_S:.0f}/{REASSERT_S:.0f}) = "
|
||||
f"{int(QUARTER_S // REASSERT_S)} >= {MIN_PER_QUARTER}",
|
||||
QUARTER_S // REASSERT_S >= MIN_PER_QUARTER)
|
||||
check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was "
|
||||
f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S)
|
||||
check("...while every single cycle is still counted",
|
||||
pinned.engagements == int(QUARTER_S))
|
||||
|
||||
# --- THE SCATTERED REGIME, WHICH IS THE ONE THE OWNER ACTUALLY HIT -----------
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it falsified the first version of this card.
|
||||
# The rising edge used to fire UNCONDITIONALLY, so REASSERT_S governed only the
|
||||
# still-engaged path. A condition that engages and clears repeatedly therefore
|
||||
# raised one notification PER EPISODE at any value of the constant.
|
||||
#
|
||||
# That is what the live install produced: 48 engagements in ~25 minutes,
|
||||
# scattered about one cycle in six. At the measured ~5 s cycle the gaps are ~25 s
|
||||
# of clean cycles, past CLEAR_CYCLES x 5 s = 15 s, so the alarm de-asserted
|
||||
# between almost every engagement. Raising REASSERT_S 30 -> 150 removed NONE of
|
||||
# them. The interval now bounds the notification RATE across episodes.
|
||||
#
|
||||
# The stimulus is written in wall-clock seconds and in the shape the field
|
||||
# actually produced, so it does not move when the constant does.
|
||||
SCATTER_CYCLE_S = 5.0
|
||||
SCATTER_ONE_IN = 6
|
||||
SCATTER_CYCLES = 305
|
||||
|
||||
scat = ClampWatchdog()
|
||||
scat_alarms = sum(1 for i in range(SCATTER_CYCLES)
|
||||
if scat.note(i % SCATTER_ONE_IN == 0, "scattered",
|
||||
T0 + timedelta(seconds=SCATTER_CYCLE_S * i)))
|
||||
_scat_span = SCATTER_CYCLE_S * SCATTER_CYCLES
|
||||
print(f" scattered 1-in-{SCATTER_ONE_IN} over {_scat_span:.0f} s at "
|
||||
f"{SCATTER_CYCLE_S:.0f} s cycles: {scat.episodes} episodes, "
|
||||
f"{scat.engagements} engagements, {scat_alarms} alarm(s)")
|
||||
check(f"a condition that de-asserts between engagements is rate-limited ACROSS "
|
||||
f"episodes, not once per episode ({scat_alarms} alarms for "
|
||||
f"{scat.episodes} episodes)",
|
||||
scat_alarms < scat.episodes)
|
||||
check(f"...and the rate honours the same interval the pinned case does: at most "
|
||||
f"ceil({_scat_span:.0f}/{REASSERT_S:.0f}) + 1 = "
|
||||
f"{int(_scat_span // REASSERT_S) + 1}",
|
||||
scat_alarms <= int(_scat_span // REASSERT_S) + 1)
|
||||
check("...while EVERY episode and EVERY engagement is still counted - the "
|
||||
"budget is on notifications, never on evidence",
|
||||
scat.episodes == scat.engagements == SCATTER_CYCLES // SCATTER_ONE_IN + 1)
|
||||
# ⚠️ THE COUNTERFACTUAL, AND THE FIRST VERSION OF IT COULD NOT FAIL FOR THE
|
||||
# DEFECT IT NAMED. It read `ClampWatchdog().note(True, ...) is True` - a FRESH
|
||||
# object, so it only ever exercised the `last_alarm is None` branch, which is the
|
||||
# trivially-true one. A fresh watchdog has no earlier episode to be held back BY.
|
||||
# That is the third instance in this file of the family recorded at line 216 and
|
||||
# at the 224 s sweep, and it was guarding the most safety-critical property in
|
||||
# the change. Review found it.
|
||||
#
|
||||
# The stimulus must therefore SPEND THE BUDGET FIRST: a benign one-cycle clamp,
|
||||
# a gap long enough to de-assert, then a continuous runaway. Without the
|
||||
# escalation clause the runaway's first notification is held for up to
|
||||
# REASSERT_S - measured at 140 s before it was added.
|
||||
_esc = ClampWatchdog()
|
||||
_esc.note(True, "benign", T0) # spends the budget
|
||||
for _i in range(1, 1 + CLEAR_CYCLES + 1): # de-assert
|
||||
_esc.note(False, "", T0 + timedelta(seconds=5.0 * _i))
|
||||
_RUNAWAY_AT = 5.0 * (CLEAR_CYCLES + 2)
|
||||
_esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
|
||||
for _j in range(200)
|
||||
if _esc.note(True, "runaway",
|
||||
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
|
||||
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
|
||||
print(f" budget spent by a benign episode, then a continuous runaway from "
|
||||
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s "
|
||||
f"(delay {_esc_delay:.0f}s)")
|
||||
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
|
||||
f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s "
|
||||
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
|
||||
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
|
||||
check("...and it speaks at the escalation boundary rather than immediately, "
|
||||
"which is what keeps the scattered flood suppressed",
|
||||
_esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
|
||||
|
||||
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
|
||||
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
|
||||
# `last_alarm = now`, the chatter case collapses from 50 notifications to 1 - the
|
||||
# alarm never fires again. That is the "alarm that never fires" class, which is a
|
||||
# different bug from one that fires later, and the suite was green on it.
|
||||
_starve = ClampWatchdog()
|
||||
_starve_fires = sum(1 for _i in range(1600)
|
||||
if _starve.note(_i % 4 == 0, "chatter",
|
||||
T0 + timedelta(seconds=5.0 * _i)))
|
||||
print(f" chatter 1-engaged/{CLEAR_CYCLES}-clean over 8000 s: "
|
||||
f"{_starve.episodes} episodes, {_starve_fires} notification(s)")
|
||||
check(f"a de-assert does not RESET the notification budget, so repeated chatter "
|
||||
f"cannot starve the alarm to silence ({_starve_fires} notifications over "
|
||||
f"8000 s, not 1)",
|
||||
_starve_fires > 10)
|
||||
|
||||
# --- THE HYSTERESIS, PINNED AGAINST THE CHATTER DIRECTION --------------------
|
||||
# ⚠️ REVIEW-FOUND. This used to issue `range(CLEAR_CYCLES - 1)` clean cycles,
|
||||
# which at CLEAR_CYCLES = 1 issues ZERO of them and passes while printing
|
||||
@@ -898,6 +1000,105 @@ check("...and main.py:856's consumer is the line that goes quietly false",
|
||||
bool(ctl_w.clamp_last and ctl_w.clamp_last.engaged))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6d. SAFETY-38 round 4 - THE ORDERED PAIR INSIDE `ClampResult.describe()`
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS IS THE ALARM THE OWNER HAS BEEN COMPLAINING ABOUT ALL DAY, AND UNTIL
|
||||
# THIS BLOCK NO test_*.py MENTIONED `describe()` AT ALL. `clamp.py:253` builds
|
||||
# the whole §15 action-log row and the whole ClampWatchdog notification reason
|
||||
# out of its own attributes:
|
||||
#
|
||||
# f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W was
|
||||
# clamped to {self.applied_w:+.0f} W by the {self.bound} bound
|
||||
# (rating {self.limit_w:.0f} W) - something upstream is winding"
|
||||
#
|
||||
# Transpose `requested_w` and `applied_w` and the guard reports clamping a
|
||||
# setpoint UPWARD - here, to +9999 W by a bound whose rating the SAME SENTENCE
|
||||
# gives as 2000 W. MEASURED on this tree: a zero-byte edit (22 507 bytes before,
|
||||
# 22 507 after, sha256 0153133741497fa5 -> 47e4e371043e3201), the emitted
|
||||
# sentence changes, and the full 21-file suite stayed GREEN. Nothing decides on
|
||||
# the order, so nothing behavioural can ever see it - which is why the check
|
||||
# below has to be on the ORDERED PAIR and on the CONTIGUOUS LITERAL. A substring
|
||||
# test for either number passes under the swap; both numbers are still there.
|
||||
#
|
||||
# ⚠️ AND THE OPERANDS ARE READ OFF THE PLANT, NOT OFF `clamp_last`. `ctl.target`
|
||||
# is what the loop asked for and `hass.writes[-1]` is what the device was
|
||||
# actually sent; neither is a `ClampResult` field. Grading the sentence against
|
||||
# `result.requested_w` would compare the swap against itself - DEV-02's defect,
|
||||
# and the same one that made the first cut of this card's own render probe emit
|
||||
# thirteen empty diffs.
|
||||
|
||||
|
||||
async def _sentence(max_w, dev, wanted):
|
||||
"""One real write over `max_w`, then the guard, on a real Controller."""
|
||||
hass = _Hass(0.0)
|
||||
c = Controller({"setpoint_entity": "number.sp", "meter_source": "off",
|
||||
"max_w": max_w},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -dev, dev
|
||||
c.target = wanted
|
||||
arm(c, wanted)
|
||||
await c.write_setpoint(wanted)
|
||||
await c.check_output_clamp()
|
||||
return hass, c
|
||||
|
||||
|
||||
hass_x, ctl_x = _sentence_run = asyncio.run(_sentence(2000.0, 5000.0, 9999.0))
|
||||
notes_x = clamp_notes(hass_x)
|
||||
_asked_x = ctl_x.target # what the loop asked for
|
||||
_sent_x = hass_x.writes[-1] # what the device was actually sent
|
||||
_rating_x = ctl_x.inverters[0].max_w # the bound the sentence names
|
||||
_rows_x = [e for e in ctl_x.events if REASON_CODE in e]
|
||||
print(f" max_w {_rating_x:.0f} on a +-5000 W device, asked for "
|
||||
f"{_asked_x:+.0f} W: the device saw {_sent_x:+.0f} W")
|
||||
print(f" action log: {_rows_x[0] if _rows_x else '(none)'}")
|
||||
check("PRECONDITION: the write really clamped on the MAX_W bound, and the two "
|
||||
"operands are DIFFERENT NUMBERS that RENDER differently - equal ones "
|
||||
"would make the transposed sentence identical and every check below "
|
||||
"vacuous. The asked-for magnitude is also strictly ABOVE the rating "
|
||||
"while the sent one is not, which is what makes the swapped sentence "
|
||||
"self-contradictory rather than merely wrong",
|
||||
ctl_x.clamp_last is not None and ctl_x.clamp_last.engaged
|
||||
and ctl_x.clamp_last.bound == "max_w"
|
||||
and _asked_x != _sent_x
|
||||
and f"{_asked_x:+.0f}" != f"{_sent_x:+.0f}"
|
||||
and abs(_sent_x) <= _rating_x < abs(_asked_x))
|
||||
check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
|
||||
"contiguous literal names the setpoint the loop COMPUTED first and the "
|
||||
"one that REACHED THE WIRE second. Under the transposition this row says "
|
||||
"the loop asked for +2000 W and the guard clamped it UP to +9999 W - "
|
||||
"past the 2000 W rating printed four words later - which is the alarm "
|
||||
"reading backwards on the one line an operator is woken by",
|
||||
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in e for e in _rows_x))
|
||||
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), "
|
||||
"which is a SECOND surface off the same sentence and has to be pinned "
|
||||
"separately - SAFETY-38 round 3's whole lesson was that pinning one call "
|
||||
"site leaves the other shipping",
|
||||
bool(notes_x)
|
||||
and f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in notes_x[0]["message"])
|
||||
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
|
||||
"checks above discriminate rather than decorate",
|
||||
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" in e for e in _rows_x)
|
||||
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" not in notes_x[0]["message"])
|
||||
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
|
||||
# unit of coverage is the call site, and `bound` is the only thing that differs
|
||||
# between the two rows an operator can receive.
|
||||
_asked_w, _sent_w = ctl_w.target, hass_w.writes[-1]
|
||||
check("...and the same ordered pair holds on the DEVICE-bound row, where the "
|
||||
"loop asked for +3000 W and the device took +2500 W. PRECONDITION: those "
|
||||
"two also render differently",
|
||||
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
|
||||
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
|
||||
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in notes_w[0]["message"])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. SAFETY-28: the write path is gated on the allocation record")
|
||||
|
||||
@@ -17,9 +17,19 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import sys
|
||||
|
||||
from app.control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC,
|
||||
Tuning, compute, slew_limit_w,
|
||||
maintenance_charge_floor, peak_at_risk)
|
||||
|
||||
T = Tuning()
|
||||
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
|
||||
# `Tuning()`'s default became 0 - "follow the rail", `SLEW_RAIL_FRAC * max_w`,
|
||||
# 222 W against this 2000 W rail - so a bare `Tuning()` here would silently
|
||||
# re-tune every assertion below that the slew limit touches, and several of them
|
||||
# assert on a number the slew produces. The pair written here (max_w 2000,
|
||||
# slew_w 1000) is exactly what release/1.0 shipped, which is what most of this
|
||||
# file is a regression test against. The new default gets its own section at the
|
||||
# end, where it is the subject rather than a hidden parameter.
|
||||
T = Tuning(slew_w=1000.0)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -49,6 +59,13 @@ def check(name, cond):
|
||||
# A comment cannot go stale-green, because it never claims to be running.
|
||||
#
|
||||
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
|
||||
# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the
|
||||
# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too.
|
||||
# The new row is the RAIL DEFAULT specifically - `slew_limit_w()` reduced to
|
||||
# `return abs(float(tuning.slew_w))`, which leaves the limit intact and only
|
||||
# removes the 0-means-the-rail resolution. Three checks notice, which is over
|
||||
# the invariant's floor of two. Both figures were reproduced by deleting the
|
||||
# mechanism, running this file and counting, on a tree with __pycache__ purged.
|
||||
#
|
||||
# mechanism in compute() checks that fail when deleted
|
||||
# ------------------------------------------ -----------------------------
|
||||
@@ -56,7 +73,8 @@ def check(name, cond):
|
||||
# integrator clamp (AC 1) 6
|
||||
# integrator bound follows max_w 4
|
||||
# output clamp 3
|
||||
# slew limit 4
|
||||
# slew limit 5
|
||||
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
|
||||
# output freeze 2
|
||||
# deadband 5
|
||||
# quantisation 2
|
||||
@@ -328,7 +346,14 @@ def unwind(t):
|
||||
return prev
|
||||
|
||||
|
||||
tight, loose = unwind(Tuning(max_w=2000)), unwind(Tuning(max_w=2000, integrator_max_w=3000))
|
||||
# ⚠️ BOTH FIXTURES CARRY release/1.0's `slew_w` EXPLICITLY (SAFETY-39). The
|
||||
# subject here is `integrator_max_w`, and the figure the check names - 1000 W on
|
||||
# the first cycle after the drop - is `max_w - slew_w`, so it moves with the slew
|
||||
# limit and would stop being the number DOCS.md quotes. Pinning it keeps this a
|
||||
# test about integrator headroom instead of a test about two mechanisms at once;
|
||||
# the shipped default's own unwind is measured in the SAFETY-39 section below.
|
||||
tight = unwind(Tuning(max_w=2000, slew_w=1000.0))
|
||||
loose = unwind(Tuning(max_w=2000, slew_w=1000.0, integrator_max_w=3000))
|
||||
check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <= 1000)
|
||||
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
|
||||
loose > tight)
|
||||
@@ -372,7 +397,15 @@ def legacy(prev, grid, actual, t, sat_count):
|
||||
ALIAS = {"i-clamped": "clamped"}
|
||||
diffs = []
|
||||
seen = set()
|
||||
for tune in (Tuning(), Tuning(target_grid_w=-10.0), Tuning(max_w=5000, slew_w=5000)):
|
||||
# ⚠️ `slew_w` IS WRITTEN OUT IN ALL THREE (SAFETY-39). `legacy()` below is a
|
||||
# TRANSCRIPTION of release/1.0 and reads `t.slew_w` directly, as release/1.0
|
||||
# did; the new law reads `slew_limit_w(t)`, which treats 0 as 'follow the
|
||||
# rail'. The two agree for every non-zero value and differ at 0 BY DESIGN, so
|
||||
# a fixture that left the field at its new default would report the design as
|
||||
# a regression. Fixing it in `legacy()` instead would make this sweep compare
|
||||
# the new law against itself, which is the one thing it must never do.
|
||||
for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
|
||||
Tuning(max_w=5000, slew_w=5000)):
|
||||
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
|
||||
for grid in (-6000.0, -1000.0, -500.0, -14.0, 0.0, 14.0, 500.0, 1000.0, 6000.0):
|
||||
for actual in (-2000.0, 0.0, 600.0, 2000.0):
|
||||
@@ -443,6 +476,205 @@ check("worst billed rest point falls from 15 W to under 5 W",
|
||||
compute(500.0, 4.9, 500.0, TB).reason == "deadband"
|
||||
and compute(500.0, 5.0, 500.0, TB).reason != "deadband")
|
||||
|
||||
print("SAFETY-39: the slew limit is a RATIO to the rail, not a number")
|
||||
# ⚠️ WHAT WENT WRONG, IN ONE LINE, BECAUSE THE SHAPE IS THE POINT. `slew_w:
|
||||
# 1000` was derived on 2026-08-22 against a clamp of 1500 W and its stated
|
||||
# justification was that it caps "how far the loop can jump ahead of the
|
||||
# inverter". The live install then ran max_w 5000 with the same 1000, and on
|
||||
# 2026-09-05 11:43-11:46 a real 3.4 kW export drove the setpoint 380 -> 5000 W
|
||||
# in five 2 s ticks, the battery to -5154 W against a 5000 VA inverter, and the
|
||||
# grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken. The rail
|
||||
# moved and the constant did not.
|
||||
#
|
||||
# So what is graded below is the RELATIONSHIP and not a value: at every rail,
|
||||
# the loop takes the plant's own settling time to cross its whole output range.
|
||||
# A check that pinned "555 W" would go stale the same way the comment did.
|
||||
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = {SLEW_RAIL_FRAC:.4f}"
|
||||
f" ({1 / SLEW_RAIL_FRAC:.0f} ticks to cross the rail)")
|
||||
check("0 means FOLLOW THE RAIL and resolves to SLEW_RAIL_FRAC * max_w",
|
||||
slew_limit_w(Tuning(max_w=5000.0, slew_w=0.0)) == SLEW_RAIL_FRAC * 5000.0)
|
||||
check("an explicit value is passed through untouched - it is the operator's "
|
||||
"number and nothing here silently reduces it",
|
||||
slew_limit_w(Tuning(max_w=5000.0, slew_w=400.0)) == 400.0)
|
||||
check("the shipped Tuning default is 0, i.e. the rail, so the law carries no "
|
||||
"second constant to go stale beside config.yaml",
|
||||
Tuning().slew_w == 0.0)
|
||||
|
||||
# ⭐ THE RELATIONSHIP ITSELF, over every rail this add-on can be built at: the
|
||||
# single-inverter schema range (100..5000), the live install (5000), and the
|
||||
# roster sums a second inverter produces - 9600 with inverter B's datasheet
|
||||
# figure, 10000 with two of A. ⚠️ 9600 IS NOT A MEASUREMENT: inverter B does not
|
||||
# exist on site yet (SPIKE-04, ~2026-09-12) and 4600 W is its datasheet and
|
||||
# serial, per goodwe/NOTES.md §1.1b. It is here because the ARITHMETIC is what
|
||||
# is being graded, and the arithmetic does not care.
|
||||
_rails = [100.0, 500.0, 1500.0, 2000.0, 5000.0, 9600.0, 10000.0]
|
||||
_ticks = {r: r / slew_limit_w(Tuning(max_w=r)) for r in _rails}
|
||||
print(" ticks to cross the rail at the default, by rail: "
|
||||
+ ", ".join(f"{r:.0f}W={t:.1f}" for r, t in _ticks.items()))
|
||||
check("⭐ AT EVERY RAIL THE DEFAULT TAKES THE SAME NUMBER OF TICKS TO CROSS IT - "
|
||||
"which is PLANT_SETTLE_S at the measured tick. This is the whole of "
|
||||
"SAFETY-39: raising max_w moves the slew limit with it, so the constant "
|
||||
"cannot be left behind by the rail again",
|
||||
all(abs(t - PLANT_SETTLE_S / LOOP_TICK_S) < 1e-9 for t in _ticks.values()))
|
||||
check("...and that is the same as saying the crossing takes the plant's own "
|
||||
f"measured settling time ({PLANT_SETTLE_S:.0f} s) at the "
|
||||
f"{LOOP_TICK_S:.0f} s tick the live install was measured running",
|
||||
all(abs(t * LOOP_TICK_S - PLANT_SETTLE_S) < 1e-9 for t in _ticks.values()))
|
||||
|
||||
# ⭐ VERIFIED BY BREAKING IT. The configuration that produced the 2026-09-05
|
||||
# excursion, put through the same arithmetic: it crosses the rail in 10 s
|
||||
# against a plant that takes 18 s. This is the number validate.py rule 10
|
||||
# refuses on, and it is asserted HERE too so that the law and the gate cannot
|
||||
# drift apart - the rule can only ever refuse what this ratio says is too fast.
|
||||
_broken = Tuning(max_w=5000.0, slew_w=1000.0)
|
||||
_broken_s = 5000.0 / slew_limit_w(_broken) * LOOP_TICK_S
|
||||
print(f" the 2026-09-05 configuration (max_w 5000, slew_w 1000) crosses its "
|
||||
f"rail in {_broken_s:.0f} s against a plant measured at {PLANT_SETTLE_S:.0f} s")
|
||||
check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by "
|
||||
"the ratio rather than by anybody's opinion - test_validate.py's rule 10 "
|
||||
"section refuses exactly this pair",
|
||||
_broken_s < PLANT_SETTLE_S
|
||||
and slew_limit_w(_broken) > SLEW_RAIL_FRAC * _broken.max_w)
|
||||
|
||||
# THE CLOSED LOOP, AND WHAT THIS PLANT IS AND IS NOT.
|
||||
#
|
||||
# ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's
|
||||
# `plantsim.Plant` is the battery model this project grades control changes
|
||||
# against, and this repository's CI cannot reach it - that repository is private
|
||||
# and this job's token is minted for this one (see .gitea/workflows/checks.yml).
|
||||
# So the rig evidence lives in `tools/slew_sim.py`, is quoted in the pull
|
||||
# request and in DECISIONS/SAFETY-39-slew-against-the-rail.md, and what runs
|
||||
# HERE is a first-order lag with the time constant that reproduces §5.10's
|
||||
# measured ~18 s to the deadband. test_alloc.py makes the same split for the
|
||||
# same reason and says so in the same words.
|
||||
#
|
||||
# ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE
|
||||
# RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the
|
||||
# plant exceed `p_max`; both clamp. The -5154 W against a 5000 VA inverter that
|
||||
# the ticket reports is therefore NOT REPRODUCIBLE IN SIMULATION AT ALL, by
|
||||
# construction, and no acceptance criterion here is written on it. What IS
|
||||
# reproducible, and is what is graded, is the loop OVER-ABSORBING and driving
|
||||
# the meter from export into import - the error it then chases.
|
||||
_TAU_S = 6.0 # first-order lag; with the 2 s tick it reaches the
|
||||
# deadband ~18 s after a step, which is §5.10's figure
|
||||
|
||||
|
||||
def _step_run(tuning, export_w, load_w=350.0, phase=5, cycles=90,
|
||||
dt=LOOP_TICK_S):
|
||||
"""One export step onto a converged loop.
|
||||
|
||||
-> (worst import at the meter, peak battery beyond what the site NEEDED,
|
||||
largest setpoint change in one tick).
|
||||
"""
|
||||
import math
|
||||
prev, i_w, sat, actual, pv = 0.0, None, 0, 0.0, 0.0
|
||||
a = 1.0 - math.exp(-dt / _TAU_S)
|
||||
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
|
||||
for c in range(cycles):
|
||||
if c == phase:
|
||||
pv = export_w + load_w
|
||||
grid = load_w - pv - actual
|
||||
d = compute(prev, grid, actual, tuning, sat, i_w)
|
||||
if c >= phase and last is not None:
|
||||
biggest = max(biggest, abs(d.target_w - last))
|
||||
last = d.target_w
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
# the plant: a first-order lag, clamped at the rating like the real one
|
||||
actual += (max(-tuning.max_w, min(tuning.max_w, prev)) - actual) * a
|
||||
if c >= phase:
|
||||
worst_import = max(worst_import, load_w - pv - actual)
|
||||
peak = max(peak, abs(actual))
|
||||
return worst_import, peak - export_w, biggest
|
||||
|
||||
|
||||
def _family(tuning):
|
||||
"""The worst case over 51 export steps, 1.0-4.2 kW, at three phases.
|
||||
|
||||
⚠️ A FAMILY AND NOT ONE STEP, AND THAT IS A FINDING RATHER THAN CAUTION.
|
||||
A single run of this loop is not evidence: the deadband, the 10 W
|
||||
quantisation and the phase of the step against the tick move the result by
|
||||
hundreds of watts, non-monotonically, so two adjacent slews can rank either
|
||||
way on one step and the opposite way on the next. Every number below is a
|
||||
worst case over the family, which is how admin/goodwe grades the same
|
||||
quantities (see `config.yaml`'s "1890 closed-loop runs" and "924 runs").
|
||||
"""
|
||||
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
|
||||
for export_w in range(1000, 4300, 200):
|
||||
for phase in (5, 6, 7):
|
||||
imp, over, step = _step_run(tuning, float(export_w), phase=phase)
|
||||
worst["runs"] += 1
|
||||
worst["imp"] = max(worst["imp"], imp)
|
||||
worst["over"] = max(worst["over"], over)
|
||||
worst["step"] = max(worst["step"], step)
|
||||
worst["big"] += step >= 900.0
|
||||
return worst
|
||||
|
||||
|
||||
_LIVE = Tuning(max_w=5000.0, slew_w=1000.0) # what ran on 2026-09-05
|
||||
_RAIL = Tuning(max_w=5000.0) # slew_w 0 - follow the rail
|
||||
_INTERIM = Tuning(max_w=5000.0, slew_w=400.0) # applied live 2026-09-05 12:00
|
||||
_SLOWER = Tuning(max_w=5000.0, slew_w=250.0)
|
||||
_GAIN03 = Tuning(max_w=5000.0, gain=0.3)
|
||||
_GAIN02 = Tuning(max_w=5000.0, gain=0.2)
|
||||
_R = {k: _family(t) for k, t in
|
||||
(("live", _LIVE), ("rail", _RAIL), ("interim", _INTERIM),
|
||||
("slower", _SLOWER), ("gain03", _GAIN03), ("gain02", _GAIN02))}
|
||||
print(f" worst case over {_R['live']['runs']} export steps at max_w 5000, "
|
||||
f"{LOOP_TICK_S:.0f} s tick:")
|
||||
for _k, _lab in (("live", "gain 0.6 slew_w 1000 (the 2026-09-05 live pair)"),
|
||||
("rail", "gain 0.6 slew_w 0 (follow the rail)"),
|
||||
("interim", "gain 0.6 slew_w 400 (the live interim)"),
|
||||
("slower", "gain 0.6 slew_w 250"),
|
||||
("gain03", "gain 0.3 slew_w 0"),
|
||||
("gain02", "gain 0.2 slew_w 0")):
|
||||
_w = _R[_k]
|
||||
print(f" {_lab:<48} largest tick step {_w['step']:5.0f} W, "
|
||||
f"{_w['big']:2}/{_w['runs']} runs step >= 900 W, "
|
||||
f"over-absorbed {_w['over']:5.0f} W, meter driven to {_w['imp']:+6.0f} W")
|
||||
|
||||
# ⭐ THE DEFECT, REPRODUCED BEFORE ANYTHING IS CHANGED. The live pair steps the
|
||||
# setpoint by a full 1000 W on nearly every member of the family and drives the
|
||||
# meter from EXPORT into IMPORT - the loop manufacturing the error it then
|
||||
# chases. That is the 2026-09-05 observation and it is what makes this a control
|
||||
# fault rather than a slow loop.
|
||||
check("⭐ REPRODUCED: at max_w 5000 with slew_w 1000 the setpoint steps a full "
|
||||
"1000 W per tick on nearly every step in the family, and the meter is "
|
||||
"driven from export into import",
|
||||
_R["live"]["step"] >= 900.0
|
||||
and _R["live"]["big"] > 0.9 * _R["live"]["runs"]
|
||||
and _R["live"]["imp"] > 200.0)
|
||||
check("⭐ FIXED, ON THE THING THE SLEW ACTUALLY OWNS: following the rail removes "
|
||||
"the >= 900 W step from EVERY member of the family - which is the symptom "
|
||||
"that escalated 2 -> 1 -> 13 -> 43 per 30 min on the morning of the "
|
||||
"2026-09-05 excursion",
|
||||
_R["live"]["big"] > 0 and _R["rail"]["big"] == 0
|
||||
and _R["interim"]["big"] == 0)
|
||||
|
||||
# ⚠️ AND WHAT IT DOES NOT BUY, MEASURED, ASSERTED, AND SAID BEFORE ANYBODY CAN
|
||||
# READ MORE INTO THIS CARD THAN IT DELIVERED. `slew_w` bounds the OUTPUT; the
|
||||
# integrator accumulates `gain * error` per cycle and is bounded only by
|
||||
# `integrator_max_w`. At gain 0.6 into a 3.4 kW error the integrator moves
|
||||
# ~2000 W per tick and reaches a 5000 W rail in under three ticks WHATEVER
|
||||
# `slew_w` is. So the over-absorption is a GAIN term, and slowing the slew does
|
||||
# not reduce it - on this model it gets slightly WORSE, because the command
|
||||
# spends longer away from where it needs to be while the integrator keeps
|
||||
# winding. admin/goodwe's rig, a different plant model, agrees: over-absorption
|
||||
# 990 W at slew 1000 and 1086 W at slew 250, against 1010 W at gain 0.6 and
|
||||
# 863 W at gain 0.3 (tools/slew_sim.py).
|
||||
#
|
||||
# `gain` is therefore the next constant to re-derive. SAFETY-39 measured it and
|
||||
# CHANGED NOTHING: it is a live control-law constant on a running house, and two
|
||||
# of them moving in one release makes the next measurement uninterpretable.
|
||||
check("⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION, AND THIS CHECK IS HERE SO "
|
||||
"THAT NOBODY READS SAFETY-39 AS 'THE OVERSHOOT IS FIXED'. Quartering the "
|
||||
"slew from 1000 to 250 does not reduce the over-absorption at all",
|
||||
_R["slower"]["over"] >= _R["live"]["over"])
|
||||
check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at "
|
||||
"the same slew. That is the measurement behind 'gain is the next constant "
|
||||
"to re-derive', and it is a measurement rather than an opinion",
|
||||
_R["gain03"]["over"] < _R["rail"]["over"]
|
||||
and _R["gain02"]["over"] < _R["gain03"]["over"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -144,9 +144,17 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def tuning(max_w=LIVE_INSTALL_MAX_W):
|
||||
"""The shipped defaults from config.yaml, at the live install's max_w."""
|
||||
return Tuning(gain=0.6, max_w=max_w, slew_w=1000, deadband_w=15,
|
||||
def tuning(max_w=LIVE_INSTALL_MAX_W, slew_w=0):
|
||||
"""The shipped defaults from config.yaml, at the live install's max_w.
|
||||
|
||||
⚠️ `slew_w` IS 0 - "FOLLOW THE RAIL" - AND IS A PARAMETER (SAFETY-39). 0 is
|
||||
what config.yaml ships and resolves to `SLEW_RAIL_FRAC * max_w`, so it moves
|
||||
with `max_w` instead of being a number typed beside it. It has to be
|
||||
overridable because one case below picks an ABSURDLY SMALL rail on purpose,
|
||||
and at that rail a rail-following slew is 2 W per cycle - which would make
|
||||
the slew, rather than the thing under test, the reason nothing fires.
|
||||
"""
|
||||
return Tuning(gain=0.6, max_w=max_w, slew_w=slew_w, deadband_w=15,
|
||||
target_grid_w=-10, step_w=10, saturation_w=500,
|
||||
saturation_cycles=3)
|
||||
|
||||
@@ -327,10 +335,10 @@ check("the capture loaded", len(rows) > 1000)
|
||||
# covers both kWh registers, so a real `unchanged_s` on this data can only be
|
||||
# SHORTER than what is reconstructed here - this replay is the pessimistic
|
||||
# version of the converged case, not a flattering one.
|
||||
def replay(hold_s, max_w, cycle_floor_s=15.0):
|
||||
def replay(hold_s, max_w, cycle_floor_s=15.0, slew_w=0):
|
||||
"""Drive the real control law and the real detector over the capture."""
|
||||
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
|
||||
t = tuning(max_w)
|
||||
t = tuning(max_w, slew_w)
|
||||
target, i_w, sat = 0.0, 0.0, 0
|
||||
prev_pwr, changed_at = None, rows[0]["t"]
|
||||
last_cycle = None
|
||||
@@ -456,7 +464,15 @@ check("...and the shipped law really does park the command there",
|
||||
# converged capture is silent" is satisfied by a detector that is wired to
|
||||
# nothing. Drop the hold to 1 s and the rail to nothing, and the SAME replay
|
||||
# must alarm - so the silence above is the data's doing, not the harness's.
|
||||
wd_hot, _, _, fired_hot = replay(1.0, 20.0)
|
||||
# ⚠️ AND THE SLEW IS PINNED FOR THIS CASE ONLY, WHICH IS THE OTHER HALF OF
|
||||
# MAKING BOTH TERMS REACHABLE (SAFETY-39). The rail here is 20 W, chosen absurdly
|
||||
# small so the rail term cannot be what holds the alarm off. At the shipped
|
||||
# `slew_w: 0` the limit follows that rail too - `SLEW_RAIL_FRAC * 20` is 2.2 W
|
||||
# per cycle - so the command could not reach 19 W inside the capture and the
|
||||
# replay would go silent for a reason that has nothing to do with the detector.
|
||||
# That is exactly the vacuous pass this case exists to rule out, arriving
|
||||
# through the case itself, so the slew is written out here and nowhere else.
|
||||
wd_hot, _, _, fired_hot = replay(1.0, 20.0, slew_w=20.0)
|
||||
check("...and the same replay DOES alarm once both terms are made reachable",
|
||||
fired_hot is not None and wd_hot.episodes > 0)
|
||||
|
||||
@@ -699,7 +715,7 @@ def _opts(**over):
|
||||
o = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_INSTALL_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": SHIPPED_DEADBAND_W,
|
||||
"slew_w": 0, "deadband_w": SHIPPED_DEADBAND_W,
|
||||
"target_grid_w": -10, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": STALE_INPUT_S}
|
||||
o.update(over)
|
||||
|
||||
@@ -215,8 +215,17 @@ B_BMS_FLOOR_SIM = 15
|
||||
|
||||
A_RATING = 5000.0
|
||||
|
||||
# ⚠️ `slew_w` IS 0 HERE, AND IT USED TO BE 1000 - SAFETY-39, AND THE WAY THIS
|
||||
# FIXTURE FOUND OUT IS THE ARGUMENT FOR THE RULE. This dict pairs `slew_w: 1000`
|
||||
# with `max_w: 5000` because both were typed when the flat 5000 W install was
|
||||
# new, and that pair is EXACTLY the configuration measured on the owner's system
|
||||
# on 2026-09-05 driving the meter from 3.4 kW of export to 2.8 kW of import. The
|
||||
# fixture was not wrong when it was written and nothing in this file noticed it
|
||||
# had become so; validate.py rule 10 refused it the day it existed, which is
|
||||
# what a rule about a relationship is for. 0 means "follow the rail"
|
||||
# (`control.SLEW_RAIL_FRAC * max_w`) and is what config.yaml ships.
|
||||
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
|
||||
max_w=A_RATING, gain=0.6, slew_w=1000, deadband_w=15,
|
||||
max_w=A_RATING, gain=0.6, slew_w=0, deadband_w=15,
|
||||
target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15)
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class _Pub:
|
||||
|
||||
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 1000,
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 0,
|
||||
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
|
||||
"heartbeat_s": 10, "stale_input_s": 15}
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import inf, isinf
|
||||
from pathlib import Path
|
||||
@@ -1006,7 +1007,7 @@ def built(db_path, opts=None, store=None):
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_source": "ha_signed",
|
||||
"p1_net_entity": "sensor.net", "meter_phases": 1,
|
||||
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
|
||||
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 0, "deadband_w": 15,
|
||||
"target_grid_w": 0, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": 15}, **(opts or {})),
|
||||
hass, store or _Store(), _Pub(), stop=asyncio.Event(),
|
||||
@@ -1062,6 +1063,58 @@ check("...and the status page says NOT COVERED rather than 'idle' or a green "
|
||||
# block with no seconds remaining - so the whole setup is retried on a fresh
|
||||
# Controller rather than left to flake. It is bounded at three attempts: a fourth
|
||||
# would mean something is wrong that is not a boundary.
|
||||
#
|
||||
# ⚠️⚠️ CI-06. AND THE QUARTER MUST HAVE ROOM LEFT TO SIZE A FIXTURE IN, WHICH IS
|
||||
# A SECOND AND MUCH WIDER RACE THAN THE BOUNDARY ONE ABOVE. It is where three of
|
||||
# this file's four known-flaky checks came from, and it is not a boundary
|
||||
# effect - it is the control law's own gain applied to this helper's arithmetic.
|
||||
#
|
||||
# Every second of the open block the telegrams do NOT cover is credited at the
|
||||
# HELD value instead of the measured one, so the projection loses `net_w x
|
||||
# uncovered / 900` W - and `excess = 900 x (projected - ceiling) / remaining`
|
||||
# multiplies that back up by `900 / remaining`. The floor this helper delivers
|
||||
# is therefore
|
||||
#
|
||||
# floor ~ want x (1 - uncovered/900) - net_w x uncovered / remaining
|
||||
#
|
||||
# and the second term is the whole story: at 600 s left one uncovered second
|
||||
# costs 6 W of a 1500 W floor, at 12 s left it costs 292 W, and at 3 s left it
|
||||
# takes the projection under the ceiling and THE GUARD DOES NOT ENGAGE AT ALL -
|
||||
# which fails "the real cycle files the peak claim", "...and the number written
|
||||
# to the inverter is the guard's floor" and "the STATUS PAGE row for a
|
||||
# floor-limited guard" together, for no reason except the minute the suite ran.
|
||||
# Measured on this tree as shipped: at 12 s left the fixture asked for 1500 W
|
||||
# and delivered 613 W, a -59 % fixture that stayed green and graded a different
|
||||
# site than the one its text names.
|
||||
#
|
||||
# TWO uncovered stretches produced it and both are closed here:
|
||||
#
|
||||
# 1. THE TAIL. `range(0, span + 1, 5)` stops at the last 5 s mark at or before
|
||||
# `now`, so 0-5 s of the block were never covered by construction. One
|
||||
# final telegram stamped at `now` closes it - which is also what a real
|
||||
# meter does, and what this helper's own docstring already claims it does.
|
||||
# 2. THE LAG between that last telegram and `cycle()`'s own `datetime.now()`.
|
||||
# That one is real work and cannot be removed, only bounded: measured at
|
||||
# 12-96 ms over 23 fixtures on this tree under contention.
|
||||
#
|
||||
# So the ROOM the helper needs is derived from what it promises rather than
|
||||
# picked. Requiring the delivered floor to be within 1 % of the floor asked for,
|
||||
# at the shipped ceiling and this file's smallest fixture (`net_w` ~ 3500 W,
|
||||
# `want` 1500 W), and taking the lag at 0.1 s - past every one of the 23
|
||||
# measurements:
|
||||
#
|
||||
# net_w x lag / (remaining x want) <= 0.01 - lag/900
|
||||
# remaining >= 3500 x 0.1 / (1500 x 0.00989) = 23.6 s -> FIXTURE_ROOM_S
|
||||
#
|
||||
# Below that the helper WAITS OUT THE REST OF THE QUARTER rather than grading a
|
||||
# fixture the arithmetic cannot deliver. The wait is at most FIXTURE_ROOM_S and
|
||||
# is taken on about 2.6 % of runs; a suite that silently graded a -59 % fixture
|
||||
# instead was the alternative. ⚠️ This is not a widened tolerance - the checks
|
||||
# downstream are unchanged and the 1 % is a requirement on the FIXTURE, not on
|
||||
# any assertion.
|
||||
FIXTURE_ROOM_S = 24.0
|
||||
|
||||
|
||||
def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
standing_w=None, soc=55.0, prepare=None):
|
||||
"""A real Controller driven one real cycle over a real over-limit quarter.
|
||||
@@ -1081,6 +1134,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
computed backwards from `remaining` at the moment of the run.
|
||||
"""
|
||||
for attempt in range(4):
|
||||
# ⚠️ ROOM FIRST, AND BEFORE THE CONTROLLER IS BUILT. See FIXTURE_ROOM_S.
|
||||
# Retrying does not help here - four attempts take ~40 ms each and all
|
||||
# four would land in the same dying quarter - so the tail of the block
|
||||
# is waited out once, which puts the next attempt at ~900 s of room.
|
||||
_room = QUARTER_S - (datetime.now(timezone.utc).timestamp() % QUARTER_S)
|
||||
if _room < FIXTURE_ROOM_S:
|
||||
time.sleep(_room + 0.05)
|
||||
ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts,
|
||||
store=_Store(store) if store is not None else None)
|
||||
ctl.auto, ctl.soc, ctl.batt = True, soc, batt_w
|
||||
@@ -1099,6 +1159,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
net_w = CAP_W + want_floor_w * remaining / QUARTER_S
|
||||
for i in range(0, span + 1, 5):
|
||||
ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w))
|
||||
# ⚠️ AND ONE TELEGRAM AT `now` ITSELF. The averager credits a sample's
|
||||
# value over the interval to the NEXT sample, so without this the block
|
||||
# is covered only to the last 5 s mark and 0-5 s of it are extrapolated
|
||||
# from the held value. See the header: at 12 s of remaining that alone
|
||||
# turned a 1500 W fixture into a 613 W one. A real meter does not stop
|
||||
# sending at the last multiple of five either.
|
||||
ctl.p1.submit(sample(now, net_w))
|
||||
# ⚠️ ASSIGNED, not read back from `p1.net_w`. This helper stamps
|
||||
# `ingest_mono` with an epoch second so the averager (which keys on
|
||||
# `ingest_ts`) replays correctly, while `net_w` keys on a MONOTONIC age
|
||||
@@ -1273,6 +1340,23 @@ _shipped_decide = peakguard.decide
|
||||
|
||||
def _recording_decide(projection, **kw):
|
||||
_seen.update(kw)
|
||||
# ⚠️ CI-06. THE PROJECTION OBJECT ITSELF IS KEPT, AND THAT IS WHAT MAKES THE
|
||||
# COMPARISON BELOW A COMPARISON RATHER THAN A RACE. The check under
|
||||
# "THE CONSEQUENCE, ITERATED" says both forms are run "against the same
|
||||
# frozen projection", and until this line existed that sentence was FALSE:
|
||||
# the intent form was decided against a SECOND `PeakTracker.project(
|
||||
# b3.p1.averager, datetime.now())` taken after the cycle had returned, so
|
||||
# the two numbers came from two different instants of the wall clock. The
|
||||
# control law is `excess = 900 x (projected - ceiling) / remaining`, whose
|
||||
# derivative in time is `excess / remaining`, so at a 1500 W floor the two
|
||||
# instants separated at 1500 / remaining W per second and the check's 1.5 W
|
||||
# tolerance bought exactly `remaining / 1000` SECONDS - 900 ms at the top of
|
||||
# a quarter and 6 ms with ten seconds left of it. Measured on this tree, the
|
||||
# gap between the two statements is 13-40 ms idle and larger under load, so
|
||||
# the check was answered by WHERE IN THE QUARTER IT RAN about `gap_ms / 900`
|
||||
# of the time. Keeping the object removes the clock from the comparison
|
||||
# altogether: the tolerance below is EXACT, not widened.
|
||||
_seen["projection"] = projection
|
||||
return _shipped_decide(projection, **kw)
|
||||
|
||||
|
||||
@@ -1294,15 +1378,36 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
|
||||
# against the same frozen projection - the inverter never follows, so the meter
|
||||
# never moves - and the two are allowed to separate over time rather than being
|
||||
# judged at a single point where they need not differ.
|
||||
_proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc))
|
||||
#
|
||||
# ⚠️ CI-06. "THE SAME FROZEN PROJECTION" IS NOW THE SAME OBJECT. `_proj3` is the
|
||||
# `Projection` the real cycle actually graded, captured by `_recording_decide`
|
||||
# above, and not a second projection of the same averager taken at a later
|
||||
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
|
||||
# tolerances that used to absorb the wall clock are gone.
|
||||
_proj3 = _seen["projection"]
|
||||
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
|
||||
soc_floor=SOC_FLOOR)
|
||||
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
|
||||
# The delivered form, re-decided from the SAME projection. It has to reproduce
|
||||
# the real controller's own floor EXACTLY or `_kw` is not the call main.py
|
||||
# makes - which is a second thing this now grades and did not before, and the
|
||||
# one that keeps the identity below honest rather than self-referential.
|
||||
_delivered = _shipped_decide(_proj3, batt_w=0.0, **_kw)
|
||||
check("the delivered form re-decided from the cycle's OWN projection is the "
|
||||
"number the real Controller wrote, to the bit. The kwargs below are "
|
||||
"main.py's call and not an approximation of it, so the identity that "
|
||||
"follows is a statement about `decide()` and not about this fixture",
|
||||
near(_delivered.floor_w, b3.peak_guard.floor_w))
|
||||
check("one cycle of the intent form is exactly the delivered form plus the "
|
||||
"command standing on the wire - which is the whole defect in one line: "
|
||||
"watts that were asked for and never arrived, counted as if they had",
|
||||
near(_intent.floor_w - b3.peak_guard.floor_w, STANDING, 1.5))
|
||||
"watts that were asked for and never arrived, counted as if they had. "
|
||||
"⚠️ EXACT, not within 1.5 W: both forms are decided from one Projection "
|
||||
"object, so a tolerance here would only be hiding a clock. CI-06 - the "
|
||||
"1.5 W it used to carry bought `remaining / 1000` seconds and made this "
|
||||
"line answer by where in the quarter the suite happened to run",
|
||||
near(_intent.floor_w - _delivered.floor_w, STANDING)
|
||||
and not _intent.saturated and not _delivered.saturated)
|
||||
|
||||
_i_cmd, _d_batt = STANDING, 0.0
|
||||
_i_track, _d_track, _i_sat_at = [], [], None
|
||||
@@ -2215,22 +2320,64 @@ print(f" the same day's high-load block "
|
||||
print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, "
|
||||
f"{HOT_R['limited_discharging']} of them with the loop simultaneously "
|
||||
f"granted discharge (max {HOT_R['max_granted']:.0f} W)")
|
||||
check("THE ASYMMETRY, ON REAL DATA. On every cycle where the guard reports "
|
||||
print(f" {HOT_R['below']} cycles below the {SOC_FLOOR:.0f} % EMS floor, "
|
||||
f"{HOT_R['below_discharging']} of them discharging; low-water "
|
||||
f"{HOT_R['min_soc']:.2f} % against a {BMS_FLOOR:.0f} % BMS cut-out")
|
||||
# ⚠️ THESE TWO CHECKS WERE RE-WRITTEN AT SAFETY-39 AND THE OLD NUMBERS ARE
|
||||
# PRINTED HERE RATHER THAN DELETED, BECAUSE "AN ASSERTION WAS LOOSENED TO GET A
|
||||
# BRANCH GREEN" IS THE FIRST THING A REVIEWER SHOULD SUSPECT AND IT HAS TO BE
|
||||
# ANSWERABLE. They read `limited_discharging == limited` and `min_soc <=
|
||||
# bms_soc_floor + 0.05`, and both held at `slew_w: 1000`. SAFETY-39 re-derived
|
||||
# `slew_w` as a ratio to the rail (222 W at this 2000 W cap), and on this same
|
||||
# two-hour replay:
|
||||
#
|
||||
# slew_w 1000 slew_w 0 (the rail)
|
||||
# limited 49 49
|
||||
# limited_discharging 49 48
|
||||
# low-water SoC 9.98 % 10.57 %
|
||||
# cycles at the BMS cut 190 0
|
||||
# energy out 1247 Wh 1165 Wh
|
||||
# below / discharging 406 / 138 406 / 162
|
||||
#
|
||||
# So the loop drains this pack ~82 Wh more slowly and no longer reaches the
|
||||
# cut-out INSIDE THIS WINDOW. The FINDING is untouched and is in fact sharper on
|
||||
# the measure that carries it - 162 cycles of granted discharge below the floor,
|
||||
# up from 138. What moved is two numbers that happened to be exact at one
|
||||
# tuning: one cycle of loop spin-up, and whether a two-hour window is long
|
||||
# enough to touch a cut-out the loop is now approaching more slowly.
|
||||
#
|
||||
# The checks below therefore assert the SUBSTANCE and not the coincidence: that
|
||||
# the two states occur together on all but at most one cycle, and that the pack
|
||||
# still ends up past the EMS floor and within a point of its own cut-out with no
|
||||
# EMS-side bound anywhere in the path. If a future change makes either of these
|
||||
# fail, that IS a finding - do not widen them again.
|
||||
check("THE ASYMMETRY, ON REAL DATA. On the cycles where the guard reports "
|
||||
"FLOOR-LIMITED - 'commanding no forced discharge' on the status page and "
|
||||
"in the operator's notification - the arbiter is at the same instant "
|
||||
"granting the LOOP a discharge. A `lo` limit cannot cap a discharge, so "
|
||||
"the guard's floor and the pack emptying are not alternatives; they "
|
||||
"happen together",
|
||||
"happen together. ⚠️ AT MOST ONE CYCLE MAY DIFFER, and that one is the "
|
||||
"loop's cold start from 0 W - a tolerance of one, not a tolerance",
|
||||
HOT_R["limited"] > 10
|
||||
and HOT_R["limited_discharging"] == HOT_R["limited"]
|
||||
and HOT_R["limited_discharging"] >= HOT_R["limited"] - 1
|
||||
and HOT_R["max_granted"] > 0.0)
|
||||
check("...and the loop is granted a discharge on hundreds of cycles with the "
|
||||
"pack ALREADY below the EMS floor, which is the same asymmetry counted "
|
||||
"over the whole window rather than over the floor-limited subset - the "
|
||||
"measure the finding actually rests on, and it does not depend on one "
|
||||
"cycle of spin-up",
|
||||
HOT_R["below"] > 100 and HOT_R["below_discharging"] > 100)
|
||||
check("...which is why SAFETY-24's floor could not be widened into this one, "
|
||||
"and why the fix is not a bigger number. Recorded in "
|
||||
"and why the fix is not a bigger number: the pack still ends the window "
|
||||
"PAST the EMS floor and within one point of its own BMS cut-out, with no "
|
||||
"EMS-side bound anywhere in the path. Recorded in "
|
||||
"DECISIONS/SAFETY-25-loop-soc-floor.md, where AC 2 was DECIDED on "
|
||||
"2026-09-02: Option A, the BMS stays the only backstop, and the "
|
||||
"loop-side reserve is DEFERRED to ALLOC-01 - so this replay is not a "
|
||||
"pending exposure but the RECORDED behaviour, and part 10 stays as its "
|
||||
"tripwire", HOT_R["min_soc"] <= BMS_FLOOR + 0.05)
|
||||
"tripwire",
|
||||
HOT_R["min_soc"] < SOC_FLOOR
|
||||
and HOT_R["min_soc"] <= BMS_FLOOR + 1.0)
|
||||
|
||||
# --- 10d. the trap in the obvious fix, checked rather than asserted ---------
|
||||
# ⚠️ THE MEMO'S CENTRAL ENGINEERING CLAIM, RUN THROUGH THE REAL ARBITER. The
|
||||
@@ -5669,6 +5816,86 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF "
|
||||
not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("13. SAFETY-38 round 4: the ORDERED PAIR inside PeakDecision.explain()")
|
||||
|
||||
# ⚠️ THE STRING "peak guard idle" APPEARED IN NO test_*.py UNTIL THIS PART.
|
||||
# `peakguard.py:493` formats its OWN ATTRIBUTES and RETURNS the sentence:
|
||||
#
|
||||
# f"peak guard idle - quarter projects {self.projected_w:.0f} W under a "
|
||||
# f"{self.ceiling_w:.0f} W ceiling ({self.ceiling_from})"
|
||||
#
|
||||
# Transpose the two and this fixture's row reads "projects 5013 W under a
|
||||
# 2300 W ceiling" - a quarter projected 2713 W OVER the ceiling, printed on a
|
||||
# row whose own words say the guard is IDLE and whose own flag says ok.
|
||||
# MEASURED on this tree: zero-byte edit (50 032 bytes
|
||||
# before, 50 032 after, sha256 d95df61a51716c26 -> 2d2ee3d0bcd51251), the
|
||||
# emitted sentence changes, and the full 21-file suite stayed GREEN.
|
||||
#
|
||||
# The sentence was invisible to SAFETY-38's scanner for two rounds because both
|
||||
# censuses scored one syntactic sink - APPEND, ASSIGN, LOG ARG - and this one is
|
||||
# RETURNED. `tools/symmetric_message_scan.py` census C now scans the return sink
|
||||
# and resolves it through the method's call sites; it rediscovered this pair and
|
||||
# clamp.py's on its own, which is the only reason to trust the rest of its list.
|
||||
#
|
||||
# ⚠️ THE OPERANDS COME OFF THE PROJECTION AND OFF `quarter_ceiling_w()`, NOT OFF
|
||||
# THE DECISION. `d.projected_w` and `d.ceiling_w` are the very fields the swap
|
||||
# exchanges, so grading the sentence against them would compare the swap against
|
||||
# itself and pass under it.
|
||||
_S38_PROJ = PeakTracker.project(averager_at(CAP_W - 1200.0, 450.0),
|
||||
B + timedelta(seconds=450))
|
||||
_S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
|
||||
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a "
|
||||
f"{_S38_CEIL_W:.0f} W ceiling")
|
||||
_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a "
|
||||
f"{_S38_PROJ.projected_w:.0f} W ceiling")
|
||||
print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
|
||||
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
|
||||
print(f" {_S38_IDLE.explain()!r}")
|
||||
check("PRECONDITION: the guard is IDLE with a projection to print, the ceiling "
|
||||
"came from the MONTH PEAK rather than the cap, and the two operands are "
|
||||
"DIFFERENT NUMBERS that RENDER differently. Equal operands would make the "
|
||||
"transposed sentence identical and every check below decoration",
|
||||
not _S38_IDLE.engaged and _S38_IDLE.projected_w is not None
|
||||
and _S38_CEIL_FROM == CEILING_MONTH_PEAK
|
||||
and _S38_PROJ.projected_w != _S38_CEIL_W
|
||||
and f"{_S38_PROJ.projected_w:.0f}" != f"{_S38_CEIL_W:.0f}"
|
||||
and _S38_PROJ.projected_w < _S38_CEIL_W)
|
||||
check("⚠️ THE ORDERED PAIR ON THE CONTIGUOUS LITERAL. The PROJECTION is named "
|
||||
"first and the CEILING second; a substring test for either number passes "
|
||||
"under the transposition because both numbers are still in the sentence, "
|
||||
"so only the ordered pair can tell the two slots apart (MUTATION.md "
|
||||
"habit 14)", _S38_LITERAL in _S38_IDLE.explain())
|
||||
check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
|
||||
"discriminates rather than decorates",
|
||||
_S38_SWAPPED not in _S38_IDLE.explain())
|
||||
check("...and the row still names WHERE the ceiling came from, which is the "
|
||||
"difference between a cap an operator set and a peak the month already "
|
||||
"banked", f"ceiling ({CEILING_MONTH_PEAK})" in _S38_IDLE.explain())
|
||||
|
||||
# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints
|
||||
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
|
||||
# row at main.py:3313 is what a browser shows, and the action-log line at
|
||||
# main.py:1951 is what the release edge writes.
|
||||
_c38, _h38 = built(TMP / "s38pin.db")
|
||||
_c38.peak_guard = _S38_IDLE
|
||||
_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]]
|
||||
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
|
||||
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
|
||||
"off the shipped `checks()` rather than off `explain()` a second time. "
|
||||
"This is the surface the owner actually looks at",
|
||||
len(_rows38) == 1 and _S38_LITERAL in _rows38[0]["text"]
|
||||
and _S38_SWAPPED not in _rows38[0]["text"])
|
||||
check("...and the row is green and un-warned, which is what makes the "
|
||||
"transposed version dangerous rather than merely untidy: an OK row "
|
||||
"carrying a projection 2713 W OVER its own ceiling is the one an "
|
||||
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
|
||||
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
|
||||
@@ -134,6 +134,14 @@ DERIVED = {
|
||||
"bms_soc_floor": ("0x0560", "MEASURED"),
|
||||
"integrator_max_w": ("MEASURED", "Do not raise it"),
|
||||
"gain": ("MEASURED ON", "do not raise it"),
|
||||
# SAFETY-39. `slew_w` joins the table because it stopped being an
|
||||
# operator's convenience and became a DERIVED value the moment it was
|
||||
# tied to the rating: 0 resolves to about a ninth of `max_w`, from an
|
||||
# ~18 s measured step response. It is the one entry here whose value is
|
||||
# a RATIO, so the description has to say what it follows as well as
|
||||
# where the ratio came from - a citation alone would leave an operator
|
||||
# reading "0" on the tab with no idea it is not "off".
|
||||
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
|
||||
}
|
||||
_uncited = []
|
||||
for key, needles in sorted(DERIVED.items()):
|
||||
|
||||
@@ -18,9 +18,10 @@ import asyncio
|
||||
import sys
|
||||
|
||||
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
|
||||
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
|
||||
from app.validate import (
|
||||
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
|
||||
actuator_writers, refuse, validate_startup_config,
|
||||
actuator_writers, refuse, site_rail_w, validate_startup_config,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -457,6 +458,149 @@ check("arbiter: the drain SET is clamped, not contradicted",
|
||||
check("...and the clamp names itself, which is why it is not refused",
|
||||
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("10. a slew rate the plant can actually follow (SAFETY-39)")
|
||||
# ⚠️ THIS RULE GRADES A RELATIONSHIP, WHICH IS THE POINT OF IT. Every other rule
|
||||
# in this file compares a configured number against another configured number
|
||||
# and refuses one ordering. This one refuses a RATIO: `slew_w` may not exceed
|
||||
# `SLEW_RAIL_FRAC * rail`, so the same `slew_w` is fine at one rating and
|
||||
# refused at another, and raising the rating moves the bound with it instead of
|
||||
# leaving the constant behind. That is the failure the ticket is about -
|
||||
# `slew_w: 1000` was derived at a 1500 W clamp, the live install went to 5000,
|
||||
# and nothing anywhere said the derivation no longer held.
|
||||
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = "
|
||||
f"{LOOP_TICK_S:.0f} s / {PLANT_SETTLE_S:.0f} s = {SLEW_RAIL_FRAC:.4f}")
|
||||
|
||||
_R5000 = base(max_w=5000, maintenance_charge_w=2000, maintenance_discharge_w=2000)
|
||||
check("the rail this rule grades is the SITE rail, and at the shipped roster of "
|
||||
"one it is `max_w` exactly - byte for byte the number the single-inverter "
|
||||
"shape has always meant",
|
||||
site_rail_w(_R5000) == 5000.0)
|
||||
|
||||
# ⭐ VERIFIED BY BREAKING IT. This is the acceptance criterion stated as a test:
|
||||
# put the 2026-09-05 pair back and the gate must go red. A rule that could not
|
||||
# be made to fire on the configuration it was written for is a rule nobody has
|
||||
# checked.
|
||||
_broken = validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000)
|
||||
print(f" restoring slew_w 1000 at max_w 5000 -> {len(_broken)} problem(s)")
|
||||
if _broken:
|
||||
print(f" {_broken[0][:160]}...")
|
||||
check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSED. "
|
||||
"It crosses the whole output range in 10 s against a plant measured at "
|
||||
"18 s, which is not a fast loop, it is a loop that commands its entire "
|
||||
"range before it can observe its answer to the first step",
|
||||
len(_broken) == 1 and why(_broken, "slew_w is 1000 W"))
|
||||
check("...and the refusal says WHICH RAIL it graded against and HOW LONG the "
|
||||
"crossing takes, because 'slew_w is too high' sends an operator to lower "
|
||||
"a number when raising max_w or setting 0 are both correct answers too",
|
||||
why(_broken, "site rail of 5000 W")
|
||||
and why(_broken, "crosses its whole output range in 5.0 cycles"))
|
||||
check("...and it names the plant measurement it is graded against, so the "
|
||||
"number is traceable to a step response rather than to a preference",
|
||||
why(_broken, "goodwe/NOTES.md §5.10"))
|
||||
check("...and it says what to DO, including the setting that cannot go stale",
|
||||
why(_broken, "Set slew_w to 0") and why(_broken, "556 W"))
|
||||
|
||||
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
|
||||
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
|
||||
# cross its range, which is the constraint met. Rule 6 permits equality for the
|
||||
# same shape of reason and rule 5 refuses it for the opposite one; this is
|
||||
# rule 6's case, and getting it backwards would refuse the value this rule
|
||||
# exists to recommend.
|
||||
_bound = SLEW_RAIL_FRAC * 5000.0
|
||||
check(f"at exactly the derived value ({_bound:.0f} W) the config STARTS - "
|
||||
"equality is the constraint satisfied, not violated",
|
||||
validate_startup_config(dict(_R5000, slew_w=int(_bound)), -5000, 5000) == [])
|
||||
check("...and one watt of slack above it starts too, because a boundary that "
|
||||
"is not meaningful to one watt must not refuse on one watt - an operator "
|
||||
"reading 556 off a status page that rounded it is not misconfigured",
|
||||
validate_startup_config(dict(_R5000, slew_w=556), -5000, 5000) == [])
|
||||
check("...and well above it does not",
|
||||
validate_startup_config(dict(_R5000, slew_w=600), -5000, 5000) != [])
|
||||
|
||||
# SLOWER IS ALWAYS ACCEPTED, and the live install is the reason it has to be.
|
||||
check("the interim that is holding on the live system - slew_w 400 against "
|
||||
"max_w 5000, applied 2026-09-05 12:00 - STARTS. It is 12.5 ticks to cross "
|
||||
"the rail against the 9 this rule requires, and refusing 'too slow' would "
|
||||
"refuse the mitigation",
|
||||
validate_startup_config(dict(_R5000, slew_w=400), -5000, 5000) == [])
|
||||
check("...and so does the schema floor at the biggest rail, because there is no "
|
||||
"such thing as too slow here",
|
||||
validate_startup_config(dict(_R5000, slew_w=50), -5000, 5000) == [])
|
||||
|
||||
# ⚠️ 0 IS NOT GRADED AND MUST NOT BE. It means "follow the rail" and satisfies
|
||||
# this rule by construction at every rail there is; a rule that refused it would
|
||||
# refuse the fix. Checked at three ratings so that "it passes" cannot be an
|
||||
# accident of the default one.
|
||||
# ⚠️ NO SLEW PROBLEM AT ANY RATING, rather than NO PROBLEMS AT ALL. At the
|
||||
# schema floor of 100 W rules 5 and 5b fire on the shipped sign thresholds,
|
||||
# which is correct and is not this rule's business - asserting an empty list
|
||||
# would make this check fail for a reason it is not about, and somebody would
|
||||
# then "fix" it by moving a sign threshold.
|
||||
_zero_problems = {m: [p for p in validate_startup_config(
|
||||
base(max_w=m, slew_w=0,
|
||||
maintenance_charge_w=min(2000, m),
|
||||
maintenance_discharge_w=min(2000, m)),
|
||||
-abs(m), abs(m)) if "slew_w" in p]
|
||||
for m in (100, 500, 2000, 5000, 15000)}
|
||||
check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
|
||||
"must not be, because it is the shipped value and it satisfies this rule "
|
||||
f"by construction at every rail there is: {_zero_problems}",
|
||||
not any(_zero_problems.values()))
|
||||
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
|
||||
"is a magnitude, and 0 already has a meaning here",
|
||||
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
|
||||
"must not be negative"))
|
||||
|
||||
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
|
||||
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
|
||||
# `compute()` emits the SITE target. So the rail is the roster sum, and adding
|
||||
# an inverter moves this bound with no operator action and no second derivation.
|
||||
#
|
||||
# ⚠️ INVERTER B'S 4600 W IS ITS DATASHEET AND SERIAL, NOT A MEASUREMENT, AND
|
||||
# INVERTER B IS NOT ON SITE (SPIKE-04, ~2026-09-12; goodwe/NOTES.md §1.1b). It
|
||||
# is used here because what is graded is the ARITHMETIC OF THE RULE, which does
|
||||
# not depend on the number being measured - not because 9600 W is a fact about
|
||||
# this house yet.
|
||||
def _roster(a=5000, b=4600, **over):
|
||||
o = base(setpoint_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a", "max_w": a,
|
||||
"bms_soc_floor": 10, "soc_entity": "sensor.sa", "batt_entity": "sensor.ba"},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": b,
|
||||
"bms_soc_floor": 10, "soc_entity": "sensor.sb", "batt_entity": "sensor.bb"}])
|
||||
o.pop("max_w"); o.pop("soc_entity"); o.pop("batt_entity")
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
_site = site_rail_w(_roster())
|
||||
print(f" roster 5000 + 4600 (B FROM DATASHEET, NOT MEASURED) -> site rail "
|
||||
f"{_site:.0f} W, bound {SLEW_RAIL_FRAC * _site:.0f} W")
|
||||
check("the rail is the roster SUM and not the weakest unit, which is what "
|
||||
"separates this rule from 5, 6 and 8 - those grade one watchdog, one "
|
||||
"shaping claim and one pack, and this grades the range one SITE COMMAND "
|
||||
"is a rate across",
|
||||
_site == 9600.0)
|
||||
check("⭐ AND THE BOUND MOVES ON ITS OWN WHEN THE ROSTER DOES: the derived value "
|
||||
"at a 9600 W site is 1067 W with nobody re-deriving anything, which is "
|
||||
"the property that makes this a fix rather than a re-tune",
|
||||
abs(SLEW_RAIL_FRAC * _site - 9600.0 / 9.0) < 1e-9)
|
||||
check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
|
||||
"ACCEPTED at 9600 - the bound is on how long the crossing TAKES, and a "
|
||||
"bigger rail at the same step size takes longer to cross. ⚠️ This is the "
|
||||
"direction of the rule stated as a test, because it is not the direction "
|
||||
"the ticket assumed: a growing rail LOOSENS this bound. What the rule "
|
||||
"protects against is the constant being left behind when the rail moves; "
|
||||
"it is not a bound on how much the site may command, and it does not "
|
||||
"bound the over-absorption, which is a `gain` term SAFETY-39 measured "
|
||||
"and deliberately did not change",
|
||||
validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000) != []
|
||||
and validate_startup_config(_roster(slew_w=1000)) == [])
|
||||
check("...and a slew that outruns even the doubled rail is still refused there, "
|
||||
"so the roster path is not simply un-graded",
|
||||
why(validate_startup_config(_roster(slew_w=2000)),
|
||||
"site rail of 9600 W"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
||||
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
||||
@@ -470,12 +614,21 @@ import re # noqa: E402
|
||||
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||
with open(_CFG, encoding="utf-8") as _fh:
|
||||
_txt = _fh.read()
|
||||
# ⚠️ `slew_w` JOINED THIS SET AT SAFETY-39 AND IT IS THE INTERESTING MEMBER.
|
||||
# The others are refused when they are too big for `max_w`; this one is refused
|
||||
# when it is too big for `max_w` AS A RATIO, so shipping the wrong number here
|
||||
# would be shipping the exact defect the card is about with the gate that
|
||||
# catches it standing right beside it. Reading it out of config.yaml is what
|
||||
# makes "the shipped value is 0" a fact rather than a claim.
|
||||
_shipped = {k: float(v) for k, v in re.findall(
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
||||
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all four defaults found)",
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w"
|
||||
r"|slew_w): (-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all five defaults found)",
|
||||
set(_shipped) == {"max_w", "maintenance_charge_w",
|
||||
"maintenance_discharge_w", "sign_disagree_w"})
|
||||
"maintenance_discharge_w", "sign_disagree_w", "slew_w"})
|
||||
check(f"...and the shipped slew_w is 0, i.e. FOLLOW THE RAIL - the one value "
|
||||
f"that cannot be left behind when max_w moves (got {_shipped.get('slew_w')})",
|
||||
_shipped.get("slew_w") == 0.0)
|
||||
check(f"the shipped defaults are accepted {_shipped}",
|
||||
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
||||
check("...and the shipped charge rate is at or under the shipped ceiling",
|
||||
|
||||
@@ -1393,13 +1393,29 @@ def _sign_site(hass, store=None, **over):
|
||||
return c
|
||||
|
||||
|
||||
async def _drive(c, n=14, dt=0.2, watch=None):
|
||||
async def _drive(c, n=14, dt=0.2, watch=None, pre=None):
|
||||
"""Drive `n` real cycles. `watch` samples AFTER each cycle, `pre` BEFORE it.
|
||||
|
||||
⚠️ `pre` EXISTS BECAUSE `watch` ALONE CANNOT SEE WHAT A DETECTOR WAS HANDED,
|
||||
AND SAFETY-39 IS HOW THAT WAS FOUND. `cycle()` evaluates the sign watchdogs
|
||||
against THE STANDING COMMAND - the write from the PREVIOUS cycle - and then
|
||||
issues a new one. So `watch`'s `last_write` is the command AFTER the
|
||||
detector ran, and pairing it with `c.grid` builds a pair the detector never
|
||||
saw. While `slew_w` was 1000 the loop was at its rail within two cycles and
|
||||
consecutive commands were IDENTICAL, so the off-by-one was invisible; at a
|
||||
rail-following slew the loop ramps and the two numbers differ by one step.
|
||||
That is a fault in this harness, not in the code it grades - and a check
|
||||
that compares an operator-facing sentence against the wrong cycle is
|
||||
precisely the "the data could not reach the failure the text names" defect
|
||||
MUTATION.md keeps finding. `pre` reads the operands the detector will use.
|
||||
"""
|
||||
seen = []
|
||||
for _ in range(n):
|
||||
await c.read_inputs()
|
||||
before = {} if pre is None else pre(c)
|
||||
await c.cycle()
|
||||
if watch is not None:
|
||||
seen.append(watch(c))
|
||||
seen.append({**before, **watch(c)})
|
||||
await asyncio.sleep(dt)
|
||||
return seen
|
||||
|
||||
@@ -1508,6 +1524,50 @@ check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The eviden
|
||||
check("...and the reason names the instrument, so the operator is sent to the "
|
||||
"right one", AGAINST_OWN_POWER in _b30.sign_wd.reason)
|
||||
print(f" {_b30.sign_wd.reason!r}")
|
||||
# ⚠️ THE LAST CYCLE BEFORE B LATCHED, NAMED RATHER THAN INDEXED - part 5's
|
||||
# lesson and 9a's. After the trip B's share is 0 W and its measured power
|
||||
# follows it down, so a fixed index reads the REMEDY instead of the evidence.
|
||||
# Both operands are read off THIS OBSERVED CYCLE - `last_write` and `batt` off
|
||||
# the objects - and not off the fixture's constants, so the check cannot be
|
||||
# satisfied by two numbers the fixture happens to hold anyway (habit 2).
|
||||
_preb30 = [x for x in _s30 if not x["b"]]
|
||||
_midb30 = _preb30[-1]
|
||||
print(f" last cycle before B latched: B stood "
|
||||
f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W")
|
||||
check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A "
|
||||
"SHIPPED INSTALL CAN EVER PRINT. Part 9a pins the SITE banner's operand "
|
||||
"order at main.py:1620; that call site is armed only at N>=2 and part 9c "
|
||||
"asserts it is ABSENT at a roster of one, so on the shipped install the "
|
||||
"PER-UNIT banner is the only one there is - and its call site is a "
|
||||
"different line. Swapping the first two arguments at main.py:1560 - "
|
||||
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` - is "
|
||||
"a ZERO-BYTE edit that changes NO trip decision, because `disagrees()` "
|
||||
"is symmetric in those two arguments (`commanded_w * against_w < 0` with "
|
||||
"an abs() gate on both). Nothing behavioural can see it and the full "
|
||||
"21-file suite stayed green. What it changes is the SENTENCE: 'commanded "
|
||||
"-1000 W against its own measured power +1000 W', the exact opposite of "
|
||||
"which instrument read what, on the line somebody woken by this alarm "
|
||||
"reads first - and it is persisted to `sign_reason` and re-read after a "
|
||||
"restart. Graded on the CONTIGUOUS LITERAL with each value in its own "
|
||||
"slot, per MUTATION.md habit 14: an `in` test on a number grades "
|
||||
"PRESENCE, never ARRANGEMENT",
|
||||
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
|
||||
f"{_midb30['meas'][1]:+.0f} W" in _b30.sign_wd.reason)
|
||||
check("PRECONDITION for the line above, and without it that check is "
|
||||
"decoration: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
|
||||
"SIGN, so transposing them really does produce a different string. A "
|
||||
"cycle where B's share and B's measured power were equal, or where "
|
||||
"either was 0 W, would make the swapped sentence identical to the "
|
||||
"correct one and the assertion could not reach the failure it names",
|
||||
_midb30["stood"][1] != _midb30["meas"][1]
|
||||
and _midb30["stood"][1] * _midb30["meas"][1] < 0
|
||||
and f"{_midb30['stood'][1]:+.0f}" != f"{_midb30['meas'][1]:+.0f}")
|
||||
check("...and the SAME ordered pair is what an operator finds AFTER A RESTART, "
|
||||
"because `sign_reason` is the persisted copy of this string. The swap "
|
||||
"survives the container restart the same way the latch does",
|
||||
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
|
||||
f"{_midb30['meas'][1]:+.0f} W"
|
||||
in str(_c30.store.data.get(unit_key("inverter-b", "sign_reason"), "")))
|
||||
# ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster
|
||||
# of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because
|
||||
# B's failsafe is a UNIT claim resolved on B's own actuator and never competes
|
||||
@@ -1764,11 +1824,27 @@ async def _n1_sign():
|
||||
return _orig_eval(*a, **kw)
|
||||
|
||||
c.site_sign_wd.evaluate = _spy
|
||||
await _drive(c, n=12, dt=0.2)
|
||||
return c, _n1_calls
|
||||
# ⚠️ THE WATCH READS THE CONTROLLER'S OWN STATE, NOT `_spy`'s ARGUMENTS.
|
||||
# `_n1_calls` records what was HANDED to a detector, so grading the banner
|
||||
# against it would compare the swap against itself - the DEV-02 defect.
|
||||
# `last_write` and `c.grid` are read off the objects the plant moved.
|
||||
# ⚠️ `stood` COMES FROM `pre`, i.e. BEFORE `cycle()` RUNS (SAFETY-39). It is
|
||||
# the STANDING command the sign watchdog is evaluated against; taking it
|
||||
# after the cycle would pair this cycle's meter with next cycle's command
|
||||
# and grade the banner against a pair that never existed. See `_drive`.
|
||||
# `n` is 24 rather than 12 because at the shipped rail-following `slew_w`
|
||||
# the loop needs nine cycles to reach a 2000 W rail instead of two.
|
||||
seen = await _drive(c, n=24, dt=0.2, pre=lambda c: dict(
|
||||
stood=(c.inverters[0].last_write
|
||||
if c.inverters[0].last_write is not None
|
||||
else c.inverters[0].target)),
|
||||
watch=lambda c: dict(
|
||||
grid=c.grid,
|
||||
tripped=c.inverters[0].sign_wd.tripped))
|
||||
return c, _n1_calls, seen
|
||||
|
||||
|
||||
_c1s, _n1_site_calls = asyncio.run(_n1_sign())
|
||||
_c1s, _n1_site_calls, _seen1s = asyncio.run(_n1_sign())
|
||||
_inv1 = _c1s.inverters[0]
|
||||
print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} "
|
||||
f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s")
|
||||
@@ -1786,6 +1862,55 @@ check("...the reason sentence still says 'a meter reading', word for word - it "
|
||||
"is what an operator reads, what the action log carries and what is "
|
||||
"persisted, so it is graded on the literal",
|
||||
"against a meter reading" in _inv1.sign_wd.reason)
|
||||
# THE CYCLE THE LATCH HAPPENED ON, named rather than indexed: afterwards the
|
||||
# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the
|
||||
# remedy. Both operands come off the controller, not off the fixture's options.
|
||||
#
|
||||
# ⚠️ IT IS THE FIRST *TRIPPED* SAMPLE, AND IT USED TO BE THE LAST UN-TRIPPED ONE
|
||||
# (SAFETY-39). The detector is evaluated INSIDE `cycle()` against the STANDING
|
||||
# command - the write from the previous cycle - and against the meter read at
|
||||
# the top of THIS one, and it latches on the cycle whose pair crosses the hold.
|
||||
# So the operands the sentence quotes belong to the LATCHING cycle, not to the
|
||||
# one before it. `stood` is now sampled by `_drive`'s `pre` hook, before
|
||||
# `cycle()` overwrites it with the failsafe's 0 W, which is what makes that
|
||||
# cycle readable at all - and it is why the old form had to reach back a cycle.
|
||||
# The two forms agreed while `slew_w` was 1000 and the loop sat at its rail with
|
||||
# consecutive commands IDENTICAL; at a rail-following slew the loop ramps and
|
||||
# they differ by exactly one step, which is how this was found.
|
||||
_first1s = [x for x in _seen1s if x["tripped"]]
|
||||
_mid1s = _first1s[0]
|
||||
print(f" the cycle the latch happened on: stood {_mid1s['stood']:+.0f} W, "
|
||||
f"meter {_mid1s['grid']:+.0f} W")
|
||||
print(f" {_inv1.sign_wd.reason!r}")
|
||||
check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME "
|
||||
"zero-byte defect part 9a closed at main.py:1620, one call site over at "
|
||||
"main.py:1560 - and this is the one a one-inverter install actually "
|
||||
"reaches, since the site detector is never armed here (asserted below). "
|
||||
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` "
|
||||
"changes no trip decision - `disagrees()` is symmetric in those two "
|
||||
"arguments - and inverts the banner to 'commanded +4000 W against a "
|
||||
"meter reading -2000 W': the loop is standing at -2000 W of charge INTO "
|
||||
"a +4000 W import and the latching banner says the opposite. The "
|
||||
"substring check above passes under the swap, because both numbers are "
|
||||
"still in the sentence; only the ORDERED PAIR can tell the slots apart "
|
||||
"(MUTATION.md habit 14)",
|
||||
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W" in _inv1.sign_wd.reason)
|
||||
check("PRECONDITION: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
|
||||
"SIGN on that cycle, so a transposition genuinely produces a different "
|
||||
"string. Equal or zero operands would make the swapped banner identical "
|
||||
"and the check above vacuous",
|
||||
_mid1s["stood"] != _mid1s["grid"]
|
||||
and _mid1s["stood"] * _mid1s["grid"] < 0
|
||||
and f"{_mid1s['stood']:+.0f}" != f"{_mid1s['grid']:+.0f}")
|
||||
check("...and the persisted copy carries the same ordered pair, under BOTH the "
|
||||
"namespaced key and the flat rollback mirror - which is what an operator "
|
||||
"reads after the restart the latch is designed to survive",
|
||||
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W" in str(_c1s.store.data.get(REASON_KEY, ""))
|
||||
and f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W"
|
||||
in str(_c1s.store.data.get("sign_reason", "")))
|
||||
check("...the latch is under the same namespaced key with the same flat "
|
||||
"rollback mirror, and SAFETY-30's site pair is untouched at its default. "
|
||||
"A one-inverter install's state file is byte-identical to what it was",
|
||||
@@ -2439,7 +2564,14 @@ def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05):
|
||||
return c
|
||||
|
||||
|
||||
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=8):
|
||||
# ⚠️ `n` IS 20 AND WAS 8 (SAFETY-39). The comment on `_s38_frozen_ctrl` says
|
||||
# gain 0.05 is there "to buy a ramp" so that the SAME run carries both a
|
||||
# below-the-rail and an at-the-rail answer. At the shipped rail-following
|
||||
# `slew_w` the ramp is now the SLEW - 55 W a cycle against this 500 W site,
|
||||
# where gain 0.05 alone gave 125 - so eight cycles no longer reach the rail and
|
||||
# the run carried only the counterfactual. The fixture needs the cycles, not a
|
||||
# different tuning: the point is a run that crosses the rail part way through.
|
||||
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=20):
|
||||
hass = _S38Plant()
|
||||
c = _s38_frozen_ctrl(hass, ratings)
|
||||
_s38_seed_quarter(c, meter_w)
|
||||
@@ -2514,7 +2646,7 @@ async def _s38_one():
|
||||
hass = _S38Plant()
|
||||
c = _s38_frozen_ctrl(hass, _S38_ONE)
|
||||
_s38_seed_quarter(c, _S38_FROZEN_METER_W)
|
||||
for _ in range(8):
|
||||
for _ in range(20): # SAFETY-39: the slew is the ramp now - see _s38_frozen
|
||||
_s38_feed(c, _S38_FROZEN_METER_W)
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-39's evidence, on admin/goodwe's OWN plant model.
|
||||
|
||||
python3 tools/slew_sim.py --repro # ⭐ the 2026-09-05 excursion, replayed
|
||||
python3 tools/slew_sim.py --slew # the derivation: slew against the rail
|
||||
python3 tools/slew_sim.py --gain # what the slew does NOT own
|
||||
python3 tools/slew_sim.py --roster # N=1 -> N=2, and the rail that moved
|
||||
python3 tools/slew_sim.py --all
|
||||
|
||||
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
|
||||
==========================================
|
||||
Byte for byte tools/alloc_sim.py's reason, and it has not changed: this needs
|
||||
admin/goodwe checked out beside this repository for `plantsim.Plant`, 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 - pass SILENTLY having checked nothing.
|
||||
|
||||
So the rig evidence lives here and is quoted in the pull request and in
|
||||
DECISIONS/SAFETY-39-slew-against-the-rail.md, and test_control.py's SAFETY-39
|
||||
section grades the same properties in CI against a plant it models itself and
|
||||
says it models. The two plants are INDEPENDENT - a first-order lag written in
|
||||
test_control.py versus `plantsim.Plant`'s ramp-plus-lag with an efficiency and
|
||||
a SoC integrator - and they agree on every sign and rank below, which is worth
|
||||
more than either alone.
|
||||
|
||||
WHAT IS REAL IN HERE
|
||||
====================
|
||||
* the control law - `compute()` and `Tuning`, imported out of THIS checkout,
|
||||
never transcribed.
|
||||
* the wire - every command goes out through `frame_check.setpoint_atomic()`,
|
||||
the firmware's own encoder, and back in through `plantsim.parse`/`decode`,
|
||||
because `Inverter.command()` is what drives the modelled unit.
|
||||
* the plant - `plantsim.Plant`, the model the rig uses.
|
||||
|
||||
MODELLED, and therefore not evidence about the thing modelled:
|
||||
* the meter. `grid = load - pv - sum(battery)` with no poll delay and no
|
||||
noise, sampled once per tick. The real P1 is a poll with its own age.
|
||||
* the tick. 2.0 s, which is what the live install was MEASURED running on
|
||||
2026-09-05; it is a parameter here (`--tick`) and not a constant, because
|
||||
the whole of this card is that the derivation depends on the regime.
|
||||
|
||||
⚠️ WHAT THIS CANNOT SHOW, SAID BEFORE THE RESULTS RATHER THAN AFTER THEM
|
||||
=======================================================================
|
||||
THE -5154 W BATTERY EXCURSION IS NOT REPRODUCIBLE HERE, AND NOT BECAUSE THE
|
||||
TUNING IS WRONG. `plantsim.Plant.limit()` clamps the plant at `p_max`, so the
|
||||
modelled battery cannot exceed its rating by one watt at any tuning, on any
|
||||
step, ever. Neither can test_control.py's model. An acceptance criterion of
|
||||
"battery power no longer exceeds max_w in simulation" would therefore be
|
||||
UNFALSIFIABLE - it passes on the broken configuration too - so no criterion in
|
||||
this card is written on it, and the ticket's own AC to that effect is the one
|
||||
that had to be rewritten rather than met.
|
||||
|
||||
What IS reproducible, and is what everything below measures:
|
||||
* the setpoint stepping a full `slew_w` per tick, over and over (the live
|
||||
symptom that escalated 2 -> 1 -> 13 -> 43 large steps per 30 min);
|
||||
* the loop OVER-ABSORBING - taking more than the site needed - and driving
|
||||
the meter from EXPORT into IMPORT, i.e. manufacturing the error it chases.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST. `plantsim.INVERTER_B` is inverter A's numbers
|
||||
COPIED, `measured=False`. Every N=2 row below is therefore a result about TWO
|
||||
COPIES OF A on a 10000 W rail, NOT about the ~9600 W site that B's datasheet
|
||||
and serial imply (goodwe/NOTES.md §1.1b; B is a GW5048D-ES rated 4600 W, and
|
||||
that figure may itself be DC). Each such row prints the caveat with it.
|
||||
"""
|
||||
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 and alloc_sim.py take,
|
||||
then the sibling checkouts that actually occur. A missing sibling is not a
|
||||
finding about the control law 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 / "plantsim.py").exists():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def load(sim):
|
||||
sys.path[:0] = [str(sim), str(sim.parent / "master")]
|
||||
os.environ.setdefault("GOODWE_ADDON_REPO", str(ADDON))
|
||||
sys.path[:0] = [str(ADDON / "goodwe_controller")]
|
||||
import plantsim # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
return plantsim, control
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# one experiment
|
||||
|
||||
|
||||
def step_run(plantsim, control, *, gain, slew_w, rail_w, n=1, tick_s=2.0,
|
||||
export_w=3385.0, load_w=350.0, phase=5, cycles=300, dt=0.05,
|
||||
lag_s=None):
|
||||
"""One export step onto a converged loop, through the real wire.
|
||||
|
||||
`rail_w` is the SITE rail - what `main.py` hands `compute()` as `max_w`
|
||||
(`Controller.site_max_w`, the roster sum). Each of the `n` modelled units
|
||||
is rated `rail_w / n` and is commanded its even share, which is the
|
||||
arrangement ARCH-01's split-invariance says any valid split is equivalent
|
||||
to at the meter.
|
||||
"""
|
||||
over = {} if lag_s is None else dict(lag_s=lag_s)
|
||||
spec = dict(plantsim.INVERTER_A, rating_w=rail_w / n, **over)
|
||||
invs = [plantsim.Inverter.from_spec(name, spec) for name in "AB"[:n]]
|
||||
site = plantsim.Site(invs, load=load_w, pv=0.0)
|
||||
tuning = control.Tuning(gain=gain, max_w=rail_w, slew_w=slew_w,
|
||||
target_grid_w=-10.0)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
|
||||
for c in range(cycles):
|
||||
if c == phase:
|
||||
site.pv = export_w + load_w
|
||||
site.grid = site.load - site.pv - site.battery
|
||||
# ⚠️ ONE UNIT'S POWER, NOT THE SITE'S SUM, at n > 1. The shipped add-on
|
||||
# binds a single `inverter_power` sensor, so a two-inverter install has
|
||||
# a loop that sees unit A only. sitesim.run_site() makes the same choice
|
||||
# and says at length why feeding the sum would be a KINDER rig than the
|
||||
# deployment.
|
||||
actual = invs[0].plant.actual
|
||||
d = control.compute(prev, site.grid, actual, tuning, sat, i_w)
|
||||
if c >= phase and last is not None:
|
||||
biggest = max(biggest, abs(d.target_w - last))
|
||||
last = d.target_w
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
for inv in invs:
|
||||
inv.command(d.target_w / n)
|
||||
site.settle(tick_s, dt=dt)
|
||||
if c >= phase:
|
||||
worst_import = max(worst_import, site.grid)
|
||||
peak = max(peak, abs(site.battery))
|
||||
return dict(imp=worst_import, over=peak - export_w, step=biggest)
|
||||
|
||||
|
||||
def family(plantsim, control, *, exports=None, phases=(5, 6, 7), **kw):
|
||||
"""The worst case over a family of step sizes and phases.
|
||||
|
||||
⚠️ A FAMILY, NOT ONE STEP, AND THAT IS ITSELF A FINDING. A single run of
|
||||
this loop is not evidence: the 15 W deadband, the 10 W quantisation and the
|
||||
phase of the step against the tick move the answer by hundreds of watts,
|
||||
NON-MONOTONICALLY, so two adjacent slews rank either way depending on which
|
||||
step you happen to run. The first version of this derivation was a single
|
||||
step and reported gain 0.3 as WORSE than gain 0.6, which the family shows is
|
||||
backwards. Everything printed below is a worst case over the family, which
|
||||
is how admin/goodwe grades the same kind of quantity (config.yaml's "1890
|
||||
closed-loop runs", "924 runs").
|
||||
"""
|
||||
exports = exports or range(1000, 4300, 200)
|
||||
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
|
||||
for export_w in exports:
|
||||
for phase in phases:
|
||||
r = step_run(plantsim, control, export_w=float(export_w),
|
||||
phase=phase, **kw)
|
||||
worst["runs"] += 1
|
||||
worst["imp"] = max(worst["imp"], r["imp"])
|
||||
worst["over"] = max(worst["over"], r["over"])
|
||||
worst["step"] = max(worst["step"], r["step"])
|
||||
worst["big"] += r["step"] >= 900.0
|
||||
return worst
|
||||
|
||||
|
||||
def row(label, w):
|
||||
print(f" {label:<44} step {w['step']:6.0f} W "
|
||||
f"{w['big']:3}/{w['runs']} runs >= 900 W "
|
||||
f"over-absorbed {w['over']:6.0f} W meter to {w['imp']:+7.0f} W")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def repro(ps, ct, tick_s):
|
||||
print("⭐ THE 2026-09-05 EXCURSION, REPLAYED - gain 0.6, max_w 5000, "
|
||||
f"slew_w 1000, {tick_s:.0f} s tick")
|
||||
print(" Live, 11:43:55-11:45:55: grid -3385 W, setpoint 380 -> 5000 in five")
|
||||
print(" ticks, battery -5154 W, grid +2831 W. One 3385 W export step here:\n")
|
||||
print(f" {'t':>5} {'grid in':>9} {'setpoint':>9} {'battery':>9} {'grid out':>9} reason")
|
||||
spec = dict(ps.INVERTER_A)
|
||||
inv = ps.Inverter.from_spec("A", spec)
|
||||
site = ps.Site([inv], load=350.0, pv=0.0)
|
||||
t = ct.Tuning(gain=0.6, max_w=5000.0, slew_w=1000.0, target_grid_w=-10.0)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
for c in range(34):
|
||||
if c == 5:
|
||||
site.pv = 3385.0 + 350.0
|
||||
site.grid = site.load - site.pv - site.battery
|
||||
g = site.grid
|
||||
d = ct.compute(prev, g, inv.plant.actual, t, sat, i_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
inv.command(d.target_w)
|
||||
site.settle(tick_s, dt=0.05)
|
||||
print(f" {c * tick_s:5.0f} {g:9.0f} {d.target_w:9.0f} "
|
||||
f"{site.battery:9.0f} {site.grid:9.0f} {d.reason}")
|
||||
print("\n ⚠️ THE BATTERY NEVER PASSES 5000 W HERE AND CANNOT: plantsim.Plant")
|
||||
print(" clamps at its rating. The -5154 W figure is NOT reproducible in")
|
||||
print(" this model at any tuning, so nothing in this card is graded on it.")
|
||||
print(" What IS reproduced is the setpoint stepping 1000 W a tick to the")
|
||||
print(" rail, and the meter crossing from export into import.")
|
||||
|
||||
|
||||
def slew_table(ps, ct, tick_s, rail):
|
||||
frac = ct.SLEW_RAIL_FRAC
|
||||
print(f"THE DERIVATION - gain 0.6, site rail {rail:.0f} W, {tick_s:.0f} s tick, "
|
||||
f"51 export steps 1.0-4.2 kW\n")
|
||||
print(f" the constraint: the loop must not cross its whole output range "
|
||||
f"faster than the")
|
||||
print(f" plant settles. {rail:.0f} W / ({ct.PLANT_SETTLE_S:.0f} s / "
|
||||
f"{tick_s:.0f} s per tick) = {frac * rail:.0f} W per tick.\n")
|
||||
for slew in (1000, 800, 700, 600, int(frac * rail), 500, 400, 300, 250):
|
||||
w = family(ps, ct, gain=0.6, slew_w=float(slew), rail_w=rail,
|
||||
tick_s=tick_s)
|
||||
tag = " <- the derived value" if slew == int(frac * rail) else (
|
||||
" <- the 2026-09-05 live pair" if slew == 1000 else (
|
||||
" <- the live interim" if slew == 400 else ""))
|
||||
row(f"slew_w {slew}{tag}", w)
|
||||
print("\n READ IT THIS WAY. The >= 900 W step - the live symptom, which")
|
||||
print(" escalated 2 -> 1 -> 13 -> 43 per 30 min that morning - is gone from")
|
||||
print(" every run below 900 W of slew, and that is what the slew owns.")
|
||||
print(" The OVER-ABSORPTION does not move, and at the slowest slews it is")
|
||||
print(" slightly WORSE. The rig cannot pick the derived value out of that")
|
||||
print(" column, and it is not asked to: the CONSTRAINT picks it, and this")
|
||||
print(" table is what says what the constraint does and does not buy.")
|
||||
|
||||
|
||||
def gain_table(ps, ct, tick_s, rail):
|
||||
frac = ct.SLEW_RAIL_FRAC
|
||||
print(f"WHAT THE SLEW DOES NOT OWN - slew_w at the derived "
|
||||
f"{frac * rail:.0f} W, gain swept\n")
|
||||
for gain in (0.6, 0.5, 0.4, 0.3, 0.2, 0.1):
|
||||
w = family(ps, ct, gain=gain, slew_w=frac * rail, rail_w=rail,
|
||||
tick_s=tick_s)
|
||||
row(f"gain {gain}" + (" <- shipped" if gain == 0.6 else ""), w)
|
||||
print("\n `slew_w` bounds the OUTPUT. The integrator accumulates")
|
||||
print(" `gain * error` per cycle and is bounded only by `integrator_max_w`,")
|
||||
print(" so at gain 0.6 into a 3.4 kW error it moves ~2000 W per tick and")
|
||||
print(f" reaches the {rail:.0f} W rail in under three ticks WHATEVER the slew is.")
|
||||
print(" The over-absorption is therefore a GAIN term. `gain: 0.6` is the")
|
||||
print(" next constant to re-derive; SAFETY-39 measured it and changed")
|
||||
print(" nothing, because two control-law constants moving in one release on")
|
||||
print(" a running house makes the next measurement uninterpretable.")
|
||||
|
||||
|
||||
def roster_table(ps, ct, tick_s):
|
||||
frac = ct.SLEW_RAIL_FRAC
|
||||
print("N=1 -> N=2: THE RAIL IS WHAT MOVED\n")
|
||||
print(" ⚠️ INVERTER B DOES NOT EXIST (SPIKE-04, ~2026-09-12) AND")
|
||||
print(" plantsim.INVERTER_B IS INVERTER A COPIED, measured=False. The")
|
||||
print(" N=2 rows are therefore about TWO COPIES OF A on a 10000 W rail.")
|
||||
print(" The site B actually implies is ~9600 W (5000 + 4600), and 4600 is")
|
||||
print(" B's DATASHEET and SERIAL, not a measurement, and may be DC -")
|
||||
print(" goodwe/NOTES.md §1.1b. Read the rows as architecture, not as a")
|
||||
print(" prediction about this house.\n")
|
||||
for n, rail in ((1, 5000.0), (2, 10000.0)):
|
||||
for slew in (1000.0, frac * rail):
|
||||
w = family(ps, ct, gain=0.6, slew_w=slew, rail_w=rail, n=n,
|
||||
tick_s=tick_s)
|
||||
lab = (f"n={n} rail {rail:.0f} slew {slew:.0f}"
|
||||
+ (" [B = A COPIED]" if n == 2 else ""))
|
||||
row(lab, w)
|
||||
print(f"\n The derived value at the REAL 9600 W site is "
|
||||
f"{frac * 9600:.0f} W per tick, and")
|
||||
print(" validate.py reads it off `sum(unit_caps(opts))` - the same number")
|
||||
print(" `Controller.site_max_w` builds - so adding inverter B moves the")
|
||||
print(" bound with no operator action and no second derivation.")
|
||||
print("\n ⚠️ AND THE DIRECTION IS NOT THE ONE THE TICKET ASSUMED. A rail that")
|
||||
print(" GROWS loosens this bound: slew_w 1000 crosses a 9600 W rail in")
|
||||
print(" 19.2 s and PASSES, while at 5000 W the same 1000 is the")
|
||||
print(" configuration that was measured failing. That is what the")
|
||||
print(" constraint says and it is correct - the bound is on how long the")
|
||||
print(" crossing TAKES, and a bigger rail at the same step size takes")
|
||||
print(" longer to cross.")
|
||||
print("\n ⚠️ AND THE >= 900 W STEP COUNT STAYS AT 48/51 AT THE DERIVED VALUE")
|
||||
print(" ON THE N=2 ROW, WHICH IS NOT A FAILURE - IT IS THE THRESHOLD GOING")
|
||||
print(" STALE, on the same day and in the same way as the constant. 900 W")
|
||||
print(" was a meaningful step against a 5000 W rail; against 10000 W the")
|
||||
print(" derived step IS 1111 W, and a check written on the absolute 900")
|
||||
print(" would report the correct tuning as broken. Anything grading this")
|
||||
print(" symptom at N>=2 has to grade it as a FRACTION of the rail too.")
|
||||
print("\n WHAT DOES NOT GET WORSE HERE, STATED BECAUSE IT WAS EXPECTED TO.")
|
||||
print(" Over the SAME family of 1.0-4.2 kW export steps, doubling the rail")
|
||||
print(" does not worsen the over-absorption (958 -> 810 W): the extra rail")
|
||||
print(" is headroom these disturbances never ask for. That is a statement")
|
||||
print(" about this family, not about the site - a bigger site sees bigger")
|
||||
print(" steps, and the over-absorption is a `gain` term either way, which")
|
||||
print(" the enforced ratio does not bound and this card did not change.")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--repro", action="store_true")
|
||||
ap.add_argument("--slew", action="store_true")
|
||||
ap.add_argument("--gain", action="store_true")
|
||||
ap.add_argument("--roster", action="store_true")
|
||||
ap.add_argument("--all", action="store_true")
|
||||
ap.add_argument("--tick", type=float, default=None,
|
||||
help="seconds per control cycle (default: control.LOOP_TICK_S)")
|
||||
ap.add_argument("--rail", type=float, default=5000.0)
|
||||
a = ap.parse_args()
|
||||
if not any((a.repro, a.slew, a.gain, a.roster, a.all)):
|
||||
a.all = True
|
||||
sim = find_sim()
|
||||
if sim is None:
|
||||
sys.exit("slew_sim: admin/goodwe's sim/ is not beside this checkout, so "
|
||||
"plantsim.Plant is unavailable. Clone it as a sibling or set "
|
||||
"$GOODWE_SIM. This is a missing repository, NOT a result about "
|
||||
"the control law - do not report it as one.")
|
||||
ps, ct = load(sim)
|
||||
tick = a.tick if a.tick is not None else ct.LOOP_TICK_S
|
||||
print(f"slew_sim: plant from {sim}")
|
||||
print(f" control law from {ADDON / 'goodwe_controller' / 'app'}")
|
||||
print(f" {ps.Inverter.from_spec('A', ps.INVERTER_A).describe()}")
|
||||
print(f" tick {tick:.1f} s, SLEW_RAIL_FRAC {ct.SLEW_RAIL_FRAC:.4f} "
|
||||
f"= {ct.LOOP_TICK_S:.0f} s / {ct.PLANT_SETTLE_S:.0f} s\n")
|
||||
if a.repro or a.all:
|
||||
repro(ps, ct, tick); print()
|
||||
if a.slew or a.all:
|
||||
slew_table(ps, ct, tick, a.rail); print()
|
||||
if a.gain or a.all:
|
||||
gain_table(ps, ct, tick, a.rail); print()
|
||||
if a.roster or a.all:
|
||||
roster_table(ps, ct, tick)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,878 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-38 round 3 - the SYMMETRIC-PREDICATE FAMILY, enumerated by machine.
|
||||
|
||||
WHAT IT LOOKS FOR, in one sentence: a call whose argument ORDER is load-bearing
|
||||
for a MESSAGE and for nothing else - so swapping the arguments cannot change a
|
||||
single decision, is invisible to every behavioural check BY CONSTRUCTION, and
|
||||
still inverts what an operator reads on the line they are woken by.
|
||||
|
||||
⚠️ WHY IT IS AN INSTRUMENT AND NOT A LIST. MUTATION.md habit 12: "a hand-written
|
||||
scope is the same defect as a hand-written fixture". SAFETY-38 met this family
|
||||
twice - `SignWatchdog.evaluate()` called at `main.py:1620` (round 2) and at
|
||||
`main.py:1560` (round 3), ONE CALL SITE OVER - and both times the enumeration
|
||||
was written by hand and both times it was short. So the scope is computed.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE TEST IT APPLIES
|
||||
|
||||
For every function in `app/` and every pair (a, b) of its parameters:
|
||||
|
||||
rename a -> b and b -> a throughout the body, ERASE every string literal
|
||||
and f-string, CANONICALISE the commutative operators, and compare the two
|
||||
ASTs. If they are EQUAL, nothing the function DECIDES can depend on which
|
||||
of the two values arrived first.
|
||||
|
||||
Then, if some f-string in that function mentions BOTH names, the order is
|
||||
load-bearing for the MESSAGE ALONE, and the pair is reported.
|
||||
|
||||
⚠️ THE SYMMETRY IS USUALLY ONE CALL DEEP, WHICH IS WHY THIS IS A FIXPOINT AND
|
||||
NOT A SINGLE PASS. `SignWatchdog.evaluate()` does not compare its two arguments
|
||||
itself; it hands them to `watchdog.disagrees()`, whose body is
|
||||
`commanded_w * against_w < 0 and abs(commanded_w) > min_w and
|
||||
abs(against_w) > min_w`. An intraprocedural scan sees `disagrees(a, b, ...)`
|
||||
become `disagrees(b, a, ...)`, calls that a difference, and reports NOTHING -
|
||||
which is exactly what the first version of this file did, on a tree where the
|
||||
answer was already known. So symmetric pairs discovered in one round are used
|
||||
to canonicalise CALL ARGUMENTS in the next, and the scan iterates until the set
|
||||
stops growing.
|
||||
|
||||
CANONICALISATION, and each entry is here because something in `app/` needs it:
|
||||
* `a * b`, `a + b` - operand order sorted (`disagrees`)
|
||||
* `x and y`, `x or y` - term order sorted (`disagrees`' conjunction)
|
||||
* `a == b`, `a != b` - operand order sorted
|
||||
* `min(a, b)`, `max(a, b)` - argument order sorted (`clamp_to_rating`)
|
||||
* a tuple/list/set LITERAL being iterated - element order sorted
|
||||
(`rail_ceiling_w`'s `for bound in (dev_min, dev_max)`)
|
||||
* arguments at a KNOWN SYMMETRIC POSITION PAIR of a called function - sorted
|
||||
(the fixpoint)
|
||||
|
||||
Equal ASTs after the rename is a SUFFICIENT condition for behavioural
|
||||
invariance, not a necessary one. This scan therefore UNDER-REPORTS and says so
|
||||
- see "WHAT THIS CANNOT SEE". That is the conservative direction: everything it
|
||||
names is genuinely in the family, and the gap is stated with its shape.
|
||||
|
||||
Run: `python3 tools/symmetric_message_scan.py [--quiet]` from goodwe_controller/.
|
||||
Exit status is 0 always; it is a census, not a gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import itertools
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parent.parent / "app"
|
||||
|
||||
# {function name: {(i, j) positional index pairs that are interchangeable}}
|
||||
_SYM: dict[str, set[tuple[int, int]]] = {}
|
||||
# ⚠️ AND THE SAME THING FOR KEYWORD-ONLY PARAMETERS, WHICH THE FIRST FIXPOINT
|
||||
# DROPPED. `freeze.rail_ceiling_w(max_w, *, dev_min=None, dev_max=None)` folds
|
||||
# its two device bounds with `for bound in (dev_min, dev_max): ceiling = min(
|
||||
# ceiling, abs(bound))` - perfectly symmetric - and `rail_unreachable()` prints
|
||||
# `"a range of {dev_min}...{dev_max} W"`. A scan that only followed POSITIONAL
|
||||
# arguments called that pair asymmetric and hid a genuine member of the family
|
||||
# behind an implementation detail of its own traversal.
|
||||
_SYM_KW: dict[str, set[tuple[str, str]]] = {}
|
||||
|
||||
|
||||
class _Canon(ast.NodeTransformer):
|
||||
"""Rename one pair, erase every string, and sort what commutes."""
|
||||
|
||||
COMMUTATIVE_BINOP = (ast.Mult, ast.Add, ast.BitAnd, ast.BitOr, ast.BitXor)
|
||||
COMMUTATIVE_CMP = (ast.Eq, ast.NotEq)
|
||||
COMMUTATIVE_CALL = ("min", "max")
|
||||
|
||||
def __init__(self, a: str = "", b: str = ""):
|
||||
self.a, self.b = a, b
|
||||
|
||||
# -- the rename ---------------------------------------------------------
|
||||
def visit_Name(self, node):
|
||||
self.generic_visit(node)
|
||||
if self.a and node.id == self.a:
|
||||
node.id = self.b
|
||||
elif self.b and node.id == self.b:
|
||||
node.id = self.a
|
||||
return node
|
||||
|
||||
def visit_arg(self, node):
|
||||
if self.a and node.arg == self.a:
|
||||
node.arg = self.b
|
||||
elif self.b and node.arg == self.b:
|
||||
node.arg = self.a
|
||||
return node
|
||||
|
||||
# -- the message is exactly what is ALLOWED to differ --------------------
|
||||
def visit_JoinedStr(self, node):
|
||||
return ast.Constant(value="<msg>")
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<msg>")
|
||||
return node
|
||||
|
||||
# -- canonicalisation ---------------------------------------------------
|
||||
def visit_BinOp(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.op, self.COMMUTATIVE_BINOP):
|
||||
l, r = ast.dump(node.left), ast.dump(node.right)
|
||||
if l > r:
|
||||
node.left, node.right = node.right, node.left
|
||||
return node
|
||||
|
||||
def visit_BoolOp(self, node):
|
||||
self.generic_visit(node)
|
||||
node.values = sorted(node.values, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Compare(self, node):
|
||||
self.generic_visit(node)
|
||||
if (len(node.ops) == 1
|
||||
and isinstance(node.ops[0], self.COMMUTATIVE_CMP)):
|
||||
l, r = ast.dump(node.left), ast.dump(node.comparators[0])
|
||||
if l > r:
|
||||
node.left, node.comparators[0] = node.comparators[0], node.left
|
||||
return node
|
||||
|
||||
def visit_For(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)):
|
||||
node.iter.elts = sorted(node.iter.elts, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Call(self, node):
|
||||
self.generic_visit(node)
|
||||
name = (node.func.id if isinstance(node.func, ast.Name)
|
||||
else node.func.attr if isinstance(node.func, ast.Attribute)
|
||||
else "")
|
||||
if name in self.COMMUTATIVE_CALL and len(node.args) == 2 \
|
||||
and not node.keywords:
|
||||
if ast.dump(node.args[0]) > ast.dump(node.args[1]):
|
||||
node.args = [node.args[1], node.args[0]]
|
||||
return node
|
||||
# the fixpoint: sort the arguments at a known symmetric position pair
|
||||
for (i, j) in sorted(_SYM.get(name, ())):
|
||||
if j < len(node.args):
|
||||
if ast.dump(node.args[i]) > ast.dump(node.args[j]):
|
||||
node.args[i], node.args[j] = node.args[j], node.args[i]
|
||||
# ...and at a known symmetric KEYWORD pair
|
||||
for (ka, kb) in sorted(_SYM_KW.get(name, ())):
|
||||
ia = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == ka), None)
|
||||
ib = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == kb), None)
|
||||
if ia is not None and ib is not None:
|
||||
va, vb = node.keywords[ia].value, node.keywords[ib].value
|
||||
if ast.dump(va) > ast.dump(vb):
|
||||
node.keywords[ia].value, node.keywords[ib].value = vb, va
|
||||
return node
|
||||
|
||||
|
||||
def _canon(src: str, a: str = "", b: str = "") -> str:
|
||||
return ast.dump(_Canon(a, b).visit(ast.parse(src)))
|
||||
|
||||
|
||||
# ⚠️ THE SINK IS HALF THE CLAIM, AND THE FIRST VERSION OF THIS SCAN OMITTED IT.
|
||||
# Erasing every f-string makes a function whose ONLY job is to FORMAT one look
|
||||
# perfectly symmetric - `store.unit_key(unit, key)` and
|
||||
# `mqtt.unit_state_key(slug, key)` both came back as "the swap changes nothing"
|
||||
# when in fact the string IS the return value: swapping there renames a STORE
|
||||
# KEY and an MQTT TOPIC, which is a behaviour change every ordinary check can
|
||||
# see, and MAINT-05 is the card about exactly that. The family this file is
|
||||
# looking for is narrower - the string goes to an OPERATOR and to nobody else -
|
||||
# so each sentence is classified by where it GOES.
|
||||
_LOG_SINKS = {"debug", "info", "warning", "warn", "error", "exception",
|
||||
"critical", "log", "log_event", "notify", "persistent_notify"}
|
||||
_MSG_ATTRS = {"reason", "message", "msg", "banner", "detail", "note",
|
||||
"explanation", "why", "text"}
|
||||
|
||||
|
||||
def _sink_of(fn, target, follow=True):
|
||||
"""RETURN | MESSAGE | OTHER - where this f-string node ends up.
|
||||
|
||||
`follow` is the ONE step of data flow below, and it is off on the recursive
|
||||
call so that two locals that interpolate each other cannot loop forever.
|
||||
"""
|
||||
for stmt in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(stmt):
|
||||
pass
|
||||
# a parent map is cheaper than repeated walks
|
||||
parent = {}
|
||||
for node in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
parent[id(child)] = node
|
||||
cur = target
|
||||
while id(cur) in parent:
|
||||
up = parent[id(cur)]
|
||||
if isinstance(up, ast.Return):
|
||||
return "RETURN"
|
||||
if isinstance(up, ast.Raise):
|
||||
return "MESSAGE"
|
||||
if isinstance(up, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
||||
tgts = (up.targets if isinstance(up, ast.Assign) else [up.target])
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Attribute) and t.attr in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
if isinstance(t, ast.Name) and t.id in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
# ⚠️ ONE STEP OF DATA FLOW, AND IT IS NOT OPTIONAL. `main.py`'s
|
||||
# status page builds the device-range clause into a LOCAL - `_cap =
|
||||
# "; ".join(f"... accepts only {inv.dev_min}...{inv.dev_max} W" ...)`
|
||||
# - and only then concatenates it into the row's `text`. Stopping at
|
||||
# the assignment scores that OTHER and drops a member this card
|
||||
# MEASURED as a live survivor (0 bytes, 21/21 green). So: if the
|
||||
# name assigned here is interpolated into some other sentence in the
|
||||
# same function that IS a message, this one is a message too.
|
||||
if follow:
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Name) and _feeds_a_message(fn, t.id,
|
||||
up):
|
||||
return "MESSAGE"
|
||||
return "OTHER"
|
||||
# ⚠️ ROUND 4. A SENTENCE HANDED TO A CONSTRUCTOR AS `reason=` IS A
|
||||
# MESSAGE, and scoring only ASSIGNMENTS missed every one of them. This
|
||||
# codebase builds most of its operator sentences that way -
|
||||
# `ArbitrageDecision(action=..., reason=f"...")`,
|
||||
# `out.append({"text": f"..."})` - so the sentence never touches a name
|
||||
# this walker was watching. arbitrage.py's two DISCHARGE/CHARGE reasons
|
||||
# were scored OTHER for exactly this and are genuine members.
|
||||
if isinstance(up, ast.keyword) and up.arg in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
if isinstance(up, ast.Dict):
|
||||
for k, v in zip(up.keys, up.values):
|
||||
if (v is cur and isinstance(k, ast.Constant)
|
||||
and k.value in _MSG_ATTRS):
|
||||
return "MESSAGE"
|
||||
if isinstance(up, ast.Call):
|
||||
name = (up.func.attr if isinstance(up.func, ast.Attribute)
|
||||
else up.func.id if isinstance(up.func, ast.Name) else "")
|
||||
if name in _LOG_SINKS:
|
||||
return "MESSAGE"
|
||||
if name == "append":
|
||||
return "MESSAGE"
|
||||
cur = up
|
||||
return "OTHER"
|
||||
|
||||
|
||||
def _feeds_a_message(fn, name, origin):
|
||||
"""Is `name` interpolated into some OTHER sentence that IS a message?"""
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr) or node is origin:
|
||||
continue
|
||||
used = any(isinstance(x, ast.Name) and x.id == name
|
||||
for v in node.values if isinstance(v, ast.FormattedValue)
|
||||
for x in ast.walk(v.value))
|
||||
if used and _sink_of(fn, node, follow=False) == "MESSAGE":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _fstring_pairs(fn, params):
|
||||
"""{(a, b): [(lineno, sink), ...]} for every f-string mentioning both."""
|
||||
out: dict[tuple[str, str], list[tuple[int, str]]] = {}
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
used = set()
|
||||
for v in node.values:
|
||||
if isinstance(v, ast.FormattedValue):
|
||||
for x in ast.walk(v.value):
|
||||
if isinstance(x, ast.Name) and x.id in params:
|
||||
used.add(x.id)
|
||||
if len(used) < 2:
|
||||
continue
|
||||
sink = _sink_of(fn, node)
|
||||
for a, b in itertools.combinations(sorted(used), 2):
|
||||
out.setdefault((a, b), []).append((node.lineno, sink))
|
||||
return out
|
||||
|
||||
|
||||
def _functions():
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for fn in ast.walk(tree):
|
||||
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
yield path, fn
|
||||
|
||||
|
||||
def _positional(fn):
|
||||
return [a.arg for a in fn.args.posonlyargs + fn.args.args
|
||||
if a.arg != "self"]
|
||||
|
||||
|
||||
def _all_params(fn):
|
||||
return _positional(fn) + [a.arg for a in fn.args.kwonlyargs]
|
||||
|
||||
|
||||
def _body_src(fn) -> str:
|
||||
"""The function's BODY alone.
|
||||
|
||||
⚠️ NOT `ast.unparse(fn)`. The signature lists the parameters IN ORDER, so
|
||||
renaming a pair inside it reorders `arguments.args` and every function in
|
||||
the repository compares unequal to itself - which is what the first version
|
||||
of this scan did, silently, and it reported an empty family on a tree whose
|
||||
answer was already known. The claim being tested is about what the body
|
||||
DECIDES, and the body is what is compared.
|
||||
"""
|
||||
return ast.unparse(ast.Module(body=list(fn.body), type_ignores=[]))
|
||||
|
||||
|
||||
def _symmetric(fn, a, b) -> bool:
|
||||
src = _body_src(fn)
|
||||
return _canon(src) == _canon(src, a, b)
|
||||
|
||||
|
||||
def build_fixpoint(max_rounds=6):
|
||||
"""Fill `_SYM` until it stops growing. Returns the number of rounds."""
|
||||
for round_no in range(1, max_rounds + 1):
|
||||
grew = False
|
||||
for _path, fn in _functions():
|
||||
pos = _positional(fn)
|
||||
kws = [a.arg for a in fn.args.kwonlyargs]
|
||||
if len(pos) + len(kws) < 2:
|
||||
continue
|
||||
found = set()
|
||||
for i, j in itertools.combinations(range(len(pos)), 2):
|
||||
if _symmetric(fn, pos[i], pos[j]):
|
||||
found.add((i, j))
|
||||
found_kw = set()
|
||||
for ka, kb in itertools.combinations(sorted(kws), 2):
|
||||
if _symmetric(fn, ka, kb):
|
||||
found_kw.add((ka, kb))
|
||||
if found - _SYM.get(fn.name, set()):
|
||||
_SYM.setdefault(fn.name, set()).update(found)
|
||||
grew = True
|
||||
if found_kw - _SYM_KW.get(fn.name, set()):
|
||||
_SYM_KW.setdefault(fn.name, set()).update(found_kw)
|
||||
grew = True
|
||||
if not grew:
|
||||
return round_no
|
||||
return max_rounds
|
||||
|
||||
|
||||
def scan():
|
||||
rows = []
|
||||
for path, fn in _functions():
|
||||
params = _all_params(fn)
|
||||
if len(params) < 2:
|
||||
continue
|
||||
kwonly = {a.arg for a in fn.args.kwonlyargs}
|
||||
for (a, b), hits in sorted(_fstring_pairs(fn, params).items()):
|
||||
rows.append(dict(
|
||||
module=path.name, func=fn.name, line=fn.lineno, a=a, b=b,
|
||||
msg_lines=sorted(set(hits)),
|
||||
sinks={s for _l, s in hits},
|
||||
symmetric=_symmetric(fn, a, b),
|
||||
positional=not (a in kwonly or b in kwonly)))
|
||||
return rows
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CENSUS B - THE SENTENCE THE CALLER BUILDS
|
||||
# =============================================================================
|
||||
# ⚠️ CENSUS A ABOVE ONLY SEES A CALLEE FORMATTING ITS OWN PARAMETERS, AND THAT
|
||||
# WAS GAP 2 OF ITS OWN "WHAT THIS CANNOT SEE" LIST. The other half of the family
|
||||
# never passes through a function boundary at all: an operator sentence built
|
||||
# where it is printed, interpolating two values that are INTERCHANGEABLE BY
|
||||
# SHAPE. `main.py`'s status page says
|
||||
#
|
||||
# f"{inv.setpoint_entity or inv.name} accepts only {inv.dev_min}...{inv.dev_max} W"
|
||||
#
|
||||
# and transposing those two costs ZERO BYTES, changes nothing any check can
|
||||
# observe, and tells the operator a range backwards. No predicate is involved,
|
||||
# so census A's symmetry test has nothing to test.
|
||||
#
|
||||
# THE TEST: inside one MESSAGE-sink f-string, take every pair of interpolated
|
||||
# expressions, blank every attribute NAME and every string CONSTANT, and compare.
|
||||
# If the blanked forms are EQUAL while the originals DIFFER, the two expressions
|
||||
# are the same shape with one leaf changed - `inv.dev_min` / `inv.dev_max`,
|
||||
# `site_dev_bound("dev_min")` / `site_dev_bound("dev_max")` - which is exactly
|
||||
# the pair a transposition swaps. Same direction as census A: it under-reports
|
||||
# (two values of the same KIND spelled differently are missed) rather than
|
||||
# inventing members.
|
||||
|
||||
|
||||
|
||||
class _Blank(ast.NodeTransformer):
|
||||
def visit_Attribute(self, node):
|
||||
self.generic_visit(node)
|
||||
node.attr = "<leaf>"
|
||||
return node
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<leaf>")
|
||||
return node
|
||||
|
||||
|
||||
def _blanked(expr):
|
||||
return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr))))
|
||||
|
||||
|
||||
def _called_name(func):
|
||||
return (func.attr if isinstance(func, ast.Attribute)
|
||||
else func.id if isinstance(func, ast.Name) else "")
|
||||
|
||||
|
||||
def _innermost(tree):
|
||||
"""{id(node): INNERMOST enclosing function}, or None at module level.
|
||||
|
||||
⚠️ NOT `for fn in ast.walk(tree): for node in ast.walk(fn)`, which is what
|
||||
the first version of census B did. That visits every sentence once per
|
||||
ENCLOSING def, so a sentence inside a nested function was classified twice -
|
||||
once against the inner scope and once against the outer - and the two
|
||||
classifications are not obliged to agree. The rows were de-duplicated
|
||||
afterwards, which hid the double visit rather than removing it.
|
||||
"""
|
||||
m = {}
|
||||
|
||||
def rec(node, fn):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
f = (child if isinstance(child, (ast.FunctionDef,
|
||||
ast.AsyncFunctionDef)) else fn)
|
||||
m[id(child)] = f
|
||||
rec(child, f)
|
||||
|
||||
rec(tree, None)
|
||||
return m
|
||||
|
||||
|
||||
def _spec(fv):
|
||||
"""The FORMAT SPEC of one interpolation, as text; '' when there is none.
|
||||
|
||||
⚠️ IT IS PRINTED BESIDE EVERY PAIR BECAUSE IT IS THE ONE CHEAP TYPE SIGNAL
|
||||
THIS SCAN HAS, and gap 4 of the second list below is exactly about the pairs
|
||||
that do not share
|
||||
one. `{self.requested_w:+.0f}` beside `{self.applied_w:+.0f}` is two floats
|
||||
rendered identically - a transposition there is silent and reads as a fact.
|
||||
`{self.limit_w:.0f}` beside `{self.bound}` is a float beside a string: a
|
||||
transposition there prints `rating max_w W`, which is visibly broken rather
|
||||
than quietly wrong. Same row in this census, very different hazard, and the
|
||||
reader cannot tell them apart without this column.
|
||||
"""
|
||||
if fv.format_spec is None:
|
||||
return ""
|
||||
return "".join(str(v.value) if isinstance(v, ast.Constant) else "{...}"
|
||||
for v in fv.format_spec.values)
|
||||
|
||||
|
||||
def _same_shape_pairs(node):
|
||||
"""Pairs of interpolations in ONE f-string that are exactly one leaf apart."""
|
||||
fvs = [v for v in node.values if isinstance(v, ast.FormattedValue)]
|
||||
for x, y in itertools.combinations(fvs, 2):
|
||||
ua, ub = ast.unparse(x.value), ast.unparse(y.value)
|
||||
if ua == ub:
|
||||
continue
|
||||
if _blanked(x.value) != _blanked(y.value):
|
||||
continue
|
||||
yield ua, ub, _spec(x), _spec(y)
|
||||
|
||||
|
||||
def _sentences():
|
||||
"""(path, innermost fn, JoinedStr, sink) for every f-string in app/."""
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
inner = _innermost(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
fn = inner.get(id(node))
|
||||
if fn is None:
|
||||
continue
|
||||
yield path, fn, node, _sink_of(fn, node)
|
||||
|
||||
|
||||
def scan_caller():
|
||||
rows = []
|
||||
for path, fn, node, sink in _sentences():
|
||||
# ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS CENSUS SAID
|
||||
# "anything but a RETURN". That let in `p1.py`'s
|
||||
# `self.url = f"http://{self.host}:{self.port}…"` - an f-string that is
|
||||
# DATA, used to make a request, where a transposition is an ordinary
|
||||
# behavioural change every check can see. A formatted string is only in
|
||||
# this family when its only consumer is a person. RETURN is not OTHER
|
||||
# though, and treating it as if it were is what census C below fixes.
|
||||
if sink != "MESSAGE":
|
||||
continue
|
||||
for ua, ub, sa, sb in _same_shape_pairs(node):
|
||||
rows.append((path.name, node.lineno, fn.name, ua, ub, sa, sb))
|
||||
return sorted(set(rows), key=lambda r: (r[0], r[1], r[3]))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CENSUS C - THE SENTENCE THE OBJECT RETURNS ABOUT ITSELF
|
||||
# =============================================================================
|
||||
# ⚠️ SAFETY-38 ROUND 4. CENSUS A AND CENSUS B BETWEEN THEM COVERED ONE
|
||||
# SYNTACTIC SINK AND CALLED IT THE FAMILY. Every member either census reported
|
||||
# was a sentence APPENDED, ASSIGNED to a `reason`-shaped attribute, or passed as
|
||||
# a LOG ARGUMENT. Not one was a sentence that is RETURNED. Census A had a RETURN
|
||||
# bucket, but only for a callee formatting its own PARAMETERS - so a method
|
||||
# formatting its own ATTRIBUTES and handing the string back was invisible to
|
||||
# both, and `describe()` / `explain()` / the reason-builders are precisely where
|
||||
# this codebase keeps its operator sentences. Two live members were sitting in
|
||||
# that hole:
|
||||
#
|
||||
# clamp.py ClampResult.describe() requested_w <-> applied_w
|
||||
# peakguard.py PeakDecision.explain() projected_w <-> ceiling_w
|
||||
#
|
||||
# Worse than the gap: it was in NONE of the five blind spots this file prints
|
||||
# about itself. A tool that asserts a completeness it does not have is the
|
||||
# recurring defect in a new hat, and the list at the bottom now names its sinks.
|
||||
#
|
||||
# THE TEST is census B's - one f-string, two interpolations one leaf apart - run
|
||||
# over the RETURN sink, plus one thing census B never needed: A RETURNED STRING
|
||||
# IS NOT YET A MESSAGE. `store.unit_key()` and `mqtt.unit_state_key()` return
|
||||
# f-strings too, and swapping there renames a store key and an MQTT topic, which
|
||||
# every ordinary check can see (MAINT-05 is the card about that). So the sink of
|
||||
# a returned sentence is RESOLVED THROUGH ITS CALL SITES: the string is in this
|
||||
# family only when some caller in `app/` feeds it to an operator.
|
||||
|
||||
|
||||
def _return_consumers(func_name, depth=0, seen=frozenset()):
|
||||
"""[(site, sink, verdict)] - where the STRING RETURNED BY `func_name` goes.
|
||||
|
||||
One hop is not enough: `Resolution.explain()` is returned again by wrappers
|
||||
that are themselves logged, so a call site whose own sink is RETURN is
|
||||
resolved by recursing on the ENCLOSING function's name, bounded by `depth`
|
||||
and by `seen` so mutual recursion cannot loop.
|
||||
|
||||
⚠️ THE MATCH IS ON THE BARE METHOD NAME, exactly as `call_sites()` above,
|
||||
and `explain` is the name of at least four unrelated methods in this tree
|
||||
(`PeakDecision`, `UnitPlan`, `Resolution`, the maintenance migration
|
||||
report). So this OVER-collects sites, which pushes a verdict towards
|
||||
MESSAGE. That is the deliberate direction for a census whose measured
|
||||
failure has twice been under-reporting - but it means the verdict is a
|
||||
PROMPT TO READ THE SITES PRINTED BESIDE IT, not a proof.
|
||||
"""
|
||||
if func_name in seen or depth > 2:
|
||||
return []
|
||||
seen = seen | {func_name}
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
inner = _innermost(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _called_name(node.func) != func_name:
|
||||
continue
|
||||
fn = inner.get(id(node))
|
||||
if fn is None:
|
||||
continue
|
||||
sink = _sink_of(fn, node)
|
||||
label = f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`"
|
||||
if sink == "RETURN":
|
||||
sub = _return_consumers(fn.name, depth + 1, seen)
|
||||
if any(v == "MESSAGE" for _l, _s, v in sub):
|
||||
verdict = "MESSAGE"
|
||||
elif not sub:
|
||||
verdict = "UNRESOLVED"
|
||||
else:
|
||||
verdict = "OTHER"
|
||||
sink = f"RETURN by {fn.name}() -> {verdict}"
|
||||
else:
|
||||
verdict = sink
|
||||
out.append((label, sink, verdict))
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def scan_returned():
|
||||
"""Census C rows: a returned sentence, two interpolations one leaf apart."""
|
||||
rows = []
|
||||
for path, fn, node, sink in _sentences():
|
||||
if sink != "RETURN":
|
||||
continue
|
||||
pairs = list(_same_shape_pairs(node))
|
||||
if not pairs:
|
||||
continue
|
||||
sites = _return_consumers(fn.name)
|
||||
if any(v == "MESSAGE" for _l, _s, v in sites):
|
||||
verdict = "MESSAGE"
|
||||
elif not sites:
|
||||
verdict = "UNRESOLVED - no call site inside app/"
|
||||
else:
|
||||
verdict = "OTHER"
|
||||
for ua, ub, sa, sb in pairs:
|
||||
rows.append(dict(module=path.name, line=node.lineno, func=fn.name,
|
||||
a=ua, b=ub, spec_a=sa, spec_b=sb,
|
||||
verdict=verdict, sites=sites))
|
||||
return sorted(rows, key=lambda r: (r["module"], r["line"], r["a"]))
|
||||
|
||||
|
||||
# ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the
|
||||
# whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand
|
||||
# order pinned and the defect still shipped, because the pin was on the call two
|
||||
# lines below the one a one-inverter roster actually reaches. So a family member
|
||||
# is reported WITH EVERY PLACE IT IS CALLED.
|
||||
def call_sites(name):
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _called_name(node.func) != name:
|
||||
continue
|
||||
# ⚠️ THE RECEIVER IS PRINTED, because matching on the bare
|
||||
# attribute name conflates methods: `evaluate` is the name of
|
||||
# SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and
|
||||
# a list of four lines with no receiver tells a reader nothing
|
||||
# about which detector they are looking at. `inv.sign_wd` and
|
||||
# `self.freeze_wd` are the disambiguation, and they are the
|
||||
# caller's own words.
|
||||
out.append(f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`")
|
||||
return out
|
||||
|
||||
|
||||
def lazy_log_sites():
|
||||
"""Log calls with a %-format template and >=2 substitution ARGUMENTS.
|
||||
|
||||
⚠️ THE SIZE OF THE LARGEST HOLE, MEASURED RATHER THAN ASSERTED. The
|
||||
NOT-SCANNED list below used to end "and app/ uses that form", which is a
|
||||
claim about the tree with no number on it - the exact shape this card has
|
||||
spent four rounds arguing against. `_LOG.error("%s ... %s", a, b)` puts both
|
||||
operands in ARGUMENTS, so no f-string census here can ever reach them, and
|
||||
transposing the two costs the same zero bytes. This counts them.
|
||||
"""
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _called_name(node.func) not in _LOG_SINKS or len(node.args) < 3:
|
||||
continue
|
||||
head = node.args[0]
|
||||
tmpl = ""
|
||||
if isinstance(head, ast.Constant) and isinstance(head.value, str):
|
||||
tmpl = head.value
|
||||
elif isinstance(head, ast.BinOp) or isinstance(head, ast.JoinedStr):
|
||||
tmpl = ast.unparse(head)
|
||||
if tmpl.count("%") >= 2:
|
||||
out.append((path.name, node.lineno,
|
||||
f"{path.name}:{node.lineno} "
|
||||
f"`{ast.unparse(node.func)}` with "
|
||||
f"{len(node.args) - 1} args"))
|
||||
return [r[2] for r in sorted(out)]
|
||||
|
||||
|
||||
def _pair_line(sa, sb):
|
||||
same = sa == sb
|
||||
return (f" format spec: '{sa}' / '{sb}' -> "
|
||||
+ ("SAME - two values rendered alike, so a transposition is "
|
||||
"SILENT and reads as a fact"
|
||||
if same else
|
||||
"DIFFERENT - likely mixed types, so a transposition is more "
|
||||
"likely to look broken than to lie (gap 4 below)"))
|
||||
|
||||
|
||||
def main():
|
||||
quiet = "--quiet" in sys.argv
|
||||
rounds = build_fixpoint()
|
||||
rows = scan()
|
||||
fam = [r for r in rows if r["symmetric"] and "MESSAGE" in r["sinks"]
|
||||
and "RETURN" not in r["sinks"]]
|
||||
valued = [r for r in rows if r["symmetric"] and "RETURN" in r["sinks"]]
|
||||
print("SYMMETRIC-PREDICATE FAMILY - argument order load-bearing for a "
|
||||
"MESSAGE only")
|
||||
print("=" * 78)
|
||||
print(f"fixpoint reached after {rounds} round(s); "
|
||||
f"{sum(len(v) for v in _SYM.values())} interchangeable positional "
|
||||
f"and {sum(len(v) for v in _SYM_KW.values())} keyword pair(s) "
|
||||
f"across {len(set(_SYM) | set(_SYM_KW))} function(s) in app/")
|
||||
print(f"functions formatting >=2 of their own parameters into one "
|
||||
f"sentence: {len(rows)} pair(s)")
|
||||
print(f"of those, pairs whose SWAP LEAVES EVERY DECISION IDENTICAL: "
|
||||
f"{len(fam) + len(valued)}")
|
||||
print(f" ...of which the sentence is RETURNED (so the swap is a VALUE "
|
||||
f"change an ordinary check can see): {len(valued)}")
|
||||
print(f" ...and the sentence goes ONLY TO AN OPERATOR - THE FAMILY: "
|
||||
f"{len(fam)}")
|
||||
print()
|
||||
print("THE FAMILY - a swap here is invisible to every behavioural check "
|
||||
"BY CONSTRUCTION:")
|
||||
for r in sorted(fam, key=lambda r: (r["module"], r["line"])):
|
||||
kind = ("POSITIONAL - a transposition at a call site costs ZERO BYTES"
|
||||
if r["positional"] else
|
||||
"KEYWORD-ONLY at the definition - a transposition has to be "
|
||||
"written out by name, which is not free and not silent")
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print(f" sentence at line(s): "
|
||||
f"{', '.join(f'{l} -> {s}' for l, s in r['msg_lines'])}")
|
||||
print(f" {kind}")
|
||||
sites = call_sites(r["func"])
|
||||
print(f" CALL SITES ({len(sites)}) - each one is a separate pin, "
|
||||
f"and the receiver says which detector it is:")
|
||||
for site in sites or ["(none inside app/)"]:
|
||||
print(f" {site}")
|
||||
print()
|
||||
print("SYMMETRIC, BUT THE SENTENCE IS THE RETURN VALUE - READ THIS BUCKET "
|
||||
"BY HAND.")
|
||||
print("A returned string is sometimes a KEY (a swap renames a store key or "
|
||||
"an MQTT topic,")
|
||||
print("which is behaviour every ordinary check can see - MAINT-05 is the "
|
||||
"card about it)")
|
||||
print("and sometimes a SENTENCE (a swap is message-only after all, and it "
|
||||
"IS this family):")
|
||||
for r in sorted(valued, key=lambda r: (r["module"], r["line"])):
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("CENSUS B - SENTENCES THE CALLER BUILDS, interpolating two values of "
|
||||
"the SAME SHAPE")
|
||||
print("A transposition inside one of these is ZERO BYTES and no predicate "
|
||||
"is involved at")
|
||||
print("all, so nothing behavioural can ever see it. Same rule: assert the "
|
||||
"ORDERED PAIR.")
|
||||
cb = scan_caller()
|
||||
print(f"candidates: {len(cb)}")
|
||||
for mod, line, fn, ua, ub, sa, sb in cb:
|
||||
print(f" {mod}:{line} in {fn}()")
|
||||
print(f" {ua} <-> {ub}")
|
||||
print(_pair_line(sa, sb))
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("CENSUS C - SENTENCES AN OBJECT RETURNS ABOUT ITSELF (SAFETY-38 "
|
||||
"round 4)")
|
||||
print("A method formatting its OWN ATTRIBUTES and RETURNING the string was "
|
||||
"in neither")
|
||||
print("census above: census A only reads a callee's PARAMETERS, census B "
|
||||
"only scored a")
|
||||
print("MESSAGE sink. The verdict below is resolved through the CALL SITES "
|
||||
"of the")
|
||||
print("returning method - a returned string is only in this family once "
|
||||
"some caller")
|
||||
print("hands it to an operator (contrast store.unit_key(), whose return "
|
||||
"value is a KEY).")
|
||||
cc = scan_returned()
|
||||
fam_c = [r for r in cc if r["verdict"] == "MESSAGE"]
|
||||
hot = [r for r in fam_c if r["spec_a"] == r["spec_b"]]
|
||||
print(f"returned sentences with a same-shape pair: {len(cc)} pair(s); "
|
||||
f"{len(fam_c)} reach an operator;")
|
||||
print(f"of those, {len(hot)} ALSO share a format spec - two values "
|
||||
f"rendered alike, which is")
|
||||
print("the hazard gap 4 below names: a transposition there is silent and "
|
||||
"reads as a fact.")
|
||||
print()
|
||||
print("*** = reaches an operator AND shares a format spec - THE ACTIONABLE "
|
||||
"SUBSET")
|
||||
print(" ! = reaches an operator, specs differ (a swap is likelier to look "
|
||||
"broken)")
|
||||
print(" = does not reach an operator, or could not be resolved")
|
||||
# grouped by the sentence, so the CONSUMERS list is printed once per
|
||||
# method rather than once per pair - the ungrouped form was 20 identical
|
||||
# lines under each of six pairs and nobody reads that.
|
||||
for key in sorted({(r["module"], r["line"], r["func"]) for r in cc}):
|
||||
mod, line, fname = key
|
||||
group = [r for r in cc if (r["module"], r["line"], r["func"]) == key]
|
||||
v = group[0]["verdict"]
|
||||
print(f" {mod}:{line} {fname}() verdict: {v}")
|
||||
for r in group:
|
||||
mark = ("***" if r["verdict"] == "MESSAGE"
|
||||
and r["spec_a"] == r["spec_b"]
|
||||
else " ! " if r["verdict"] == "MESSAGE" else " ")
|
||||
print(f" {mark} {r['a']} <-> {r['b']}")
|
||||
print(f" {_pair_line(r['spec_a'], r['spec_b']).strip()}")
|
||||
if not quiet:
|
||||
sites = group[0]["sites"]
|
||||
print(f" CONSUMERS ({len(sites)}) - bare-name match, so read "
|
||||
f"the receiver:")
|
||||
for label, sink, _v in sites or [("(none inside app/)", "", "")]:
|
||||
print(f" {label} -> {sink}")
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("WHAT THIS SCANS, AND WHAT IT DOES NOT - stated because round 4's "
|
||||
"blocker was")
|
||||
print("a completeness this file ASSERTED and did not have. The sink "
|
||||
"classifier is a")
|
||||
print("syntactic walk up the parent chain plus ONE step of local data "
|
||||
"flow, and it")
|
||||
print("recognises exactly these sinks and no others:")
|
||||
print(f" SCANNED as a MESSAGE : a call to {sorted(_LOG_SINKS)}")
|
||||
print(" ...or to `append`; a `raise`; an "
|
||||
"assignment to an")
|
||||
print(f" attribute or local named {sorted(_MSG_ATTRS)}")
|
||||
print(" ...or to a local that is then interpolated "
|
||||
"into one of")
|
||||
print(" those (the one data-flow step).")
|
||||
print(" SCANNED as a RETURN : census C - resolved through the returning "
|
||||
"method's")
|
||||
print(" call sites, one hop plus two of recursion.")
|
||||
print(" NOT SCANNED AT ALL, and each of these can hold a member:")
|
||||
lazy = lazy_log_sites()
|
||||
print(" * `%s`-style lazy logging - `_LOG.error('%s ... %s', a, b)`. The "
|
||||
"arguments")
|
||||
print(" are not an f-string, so no census here looks at them, and a "
|
||||
"transposition")
|
||||
print(" of two `%s` arguments is the same zero-byte edit. THIS IS THE "
|
||||
"LARGEST")
|
||||
print(f" REMAINING HOLE, and it is MEASURED, not asserted: "
|
||||
f"{len(lazy)} such call(s)")
|
||||
print(f" in app/, across "
|
||||
f"{len({s.split(':')[0] for s in lazy})} module(s). None of them is "
|
||||
f"scanned by anything")
|
||||
print(" above. Listed here so the size of the hole is a number:")
|
||||
for site in lazy or ["(none)"]:
|
||||
print(f" {site}")
|
||||
print(" * `.format()`, `%`-formatting, and sentences built by `+` or "
|
||||
"`.join()` out of")
|
||||
print(" non-f-string pieces.")
|
||||
print(" * A sentence that leaves the function inside a DICT, LIST or "
|
||||
"dataclass and is")
|
||||
print(" rendered somewhere else - the one data-flow step is local and "
|
||||
"follows only")
|
||||
print(" a bare Name.")
|
||||
print(" * `yield`ed sentences, and sentences stored on an object by any "
|
||||
"attribute")
|
||||
print(f" name outside {sorted(_MSG_ATTRS)}.")
|
||||
print(" * Anything outside `app/`: `sim/`, `tools/`, `web.py`'s "
|
||||
"templates, the")
|
||||
print(" translations JSON, the firmware, the witness.")
|
||||
print()
|
||||
print("WHAT IT CAN SEE BUT GETS WRONG - the shape of each gap, named "
|
||||
"rather than")
|
||||
print("rounded off:")
|
||||
print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of "
|
||||
"commutative")
|
||||
print(" forms is finite and hand-written; a function symmetric only "
|
||||
"through a form")
|
||||
print(" not on it is REJECTED and never appears. This under-reports.")
|
||||
print(" 2. In CENSUS B and C, two interpolated values of the same KIND "
|
||||
"that are not the")
|
||||
print(" same SHAPE - `a.lo` beside `hi`, or one of them behind a local "
|
||||
"variable.")
|
||||
print(" Only one-leaf-apart expressions are paired.")
|
||||
print(" 3. WHETHER each call site is pinned. The sites are listed above; "
|
||||
"whether a")
|
||||
print(" check asserts the ORDERED PAIR at each one is a fact about "
|
||||
"`test_*.py` and is")
|
||||
print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section "
|
||||
"7. Call sites")
|
||||
print(" outside `app/` are not listed either, and every site list here "
|
||||
"matches on the")
|
||||
print(" bare METHOD NAME - three watchdogs are all called `evaluate` "
|
||||
"and at least")
|
||||
print(" four unrelated classes have an `explain()` - so the receiver is "
|
||||
"printed")
|
||||
print(" beside each one and a reader has to pick.")
|
||||
print(" 4. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is "
|
||||
"ERASED before")
|
||||
print(" census A's comparison, so a swap that would make the FORMATTING "
|
||||
"ITSELF fail")
|
||||
print(" - a `DAY_NAMES[start]` where `start` is 1320 - is scored as "
|
||||
"'changes no")
|
||||
print(" decision'. `_raise_gap`'s (start, weekday) pairs are that. The "
|
||||
"hazard is a")
|
||||
print(" SAME-TYPED pair, which is why every census B and C row now "
|
||||
"prints whether")
|
||||
print(" the two format specs match.")
|
||||
print(" 5. CENSUS C's verdict is a bare-name resolution (gap 3) and "
|
||||
"therefore reads")
|
||||
print(" MESSAGE too readily. Every verdict is printed with the sites "
|
||||
"that produced")
|
||||
print(" it; a MESSAGE verdict is a prompt to read them, not a proof.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -242,7 +242,7 @@ configuration:
|
||||
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
|
||||
slew_w:
|
||||
name: "Advanced · Largest change per cycle (W)"
|
||||
description: "How far the setpoint may move in one control cycle. It bounds how fast the site can turn around, and several safety windows were measured across the whole range this accepts."
|
||||
description: "How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW THE INVERTER RATING and is the recommended setting: the limit becomes about a ninth of the rating, so the loop takes the plant's own MEASURED settling time to cross its whole output range and the number cannot be left behind when the rating changes. DERIVED (SAFETY-39) from an ~18 s measured step response against a 2 s meter tick. A fixed number is accepted and is never silently reduced, but a number faster than the rating allows is refused at startup - at 1000 W against a 5000 W rating this loop drove the meter from 3.4 kW of export to 2.8 kW of import on 2026-09-05. Lower is always accepted."
|
||||
deadband_w:
|
||||
name: "Advanced · Deadband (W)"
|
||||
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
|
||||
|
||||
@@ -222,7 +222,7 @@ configuration:
|
||||
description: "Welk deel van de resterende afwijking de regeling per cyclus wegregelt. GEMETEN OP ECHTE HARDWARE en al aan de bovengrens - niet verhogen. Lager is trager en veiliger."
|
||||
slew_w:
|
||||
name: "Geavanceerd · Grootste verandering per cyclus (W)"
|
||||
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. Dit begrenst hoe snel de installatie kan omkeren; verschillende veiligheidsvensters zijn over het volledige toegelaten bereik gemeten."
|
||||
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. ⚠️ 0 BETEKENT VOLG HET OMVORMERVERMOGEN en is de aanbevolen instelling: de grens wordt ongeveer een negende van dat vermogen, zodat de regeling er de GEMETEN insteltijd van de installatie over doet om haar volledige bereik te doorlopen en het getal niet achterblijft wanneer het vermogen verandert. AFGELEID (SAFETY-39) uit een gemeten stapresponsie van ~18 s bij een meterinterval van 2 s. Een vast getal wordt aanvaard en wordt nooit stilzwijgend verlaagd, maar een getal sneller dan het vermogen toelaat wordt bij het starten geweigerd - bij 1000 W tegen 5000 W dreef deze regeling de meter op 2026-09-05 van 3,4 kW injectie naar 2,8 kW afname. Lager wordt altijd aanvaard."
|
||||
deadband_w:
|
||||
name: "Geavanceerd · Dode zone (W)"
|
||||
description: "Afwijkingen kleiner dan deze waarde worden genegeerd. ⚠️ De dode zone werkt maar één kant op: elk rustpunt erbinnen blijft eeuwig staan, en het afnameregister van de meter telt elke positieve watt mee zonder injectie die dat compenseert - en precies daarom staat het rustpunt hieronder licht negatief."
|
||||
|
||||
Reference in New Issue
Block a user