Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06661c5e17 | ||
|
|
464d398051 | ||
|
|
090a8ecc35 | ||
|
|
c7eb8c6b97 | ||
|
|
706e625d99 | ||
|
|
0581648c73 | ||
|
|
f156fadac0 | ||
|
|
6c4565659b | ||
|
|
c8dcb38a95 |
@@ -1,5 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.0
|
||||
|
||||
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
|
||||
THIS IS A MINOR BUMP.** [[SAFETY-39]] adds validator rule 10, which refuses an
|
||||
explicit `slew_w` above `SLEW_RAIL_FRAC` x the site rail, where `SLEW_RAIL_FRAC`
|
||||
is `LOOP_TICK_S / PLANT_SETTLE_S` = 2/18. On a 5000 W rail that ceiling is
|
||||
**555.6 W**.
|
||||
|
||||
⚠️ **THE VALUE THAT BREAKS IS 0.7.0'S OWN SHIPPED DEFAULT.** `slew_w` defaulted
|
||||
to **1000** and the schema was `int(50,5000)`; 1000 > 555.6, so an install that
|
||||
never touched the key and simply took the default is refused on 0.8.0 until the
|
||||
value is lowered. That is as backward-incompatible as it gets, and it is the
|
||||
whole justification for MINOR over PATCH.
|
||||
|
||||
The schema also widens from `int(50,5000)` to `int(0,5000)`, and `slew_w: 0` -
|
||||
**a value that was not legal before** - now means *follow the rail* and is the
|
||||
shipped default. This is a new capability rather than a changed meaning.
|
||||
|
||||
⚠️ Refusal is `SystemExit(78)` (`EX_CONFIG`): the add-on does not start. **Read
|
||||
the live options and check them against the ceiling BEFORE `ha apps update`.**
|
||||
That check is NOT performed by this PR and cannot be - see the PR body.
|
||||
|
||||
CFG-04's rule and PKG-06's: the Supervisor keys the built image by `version:`,
|
||||
so shipping a change without a bump means `ha apps update` is a silent no-op.
|
||||
**Six merges accumulated behind an unbumped 0.7.0 before this was caught, for
|
||||
the second time in two days.** The automated check that would catch it without
|
||||
a human noticing is still unwritten, and that is PKG-06's standing follow-up.
|
||||
|
||||
Contents, in merge order since 0.7.0 (`bbc903b`). ⚠️ Each row carries the merge
|
||||
subject's **whole** claim, including the half that is not flattering:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `9e42cec` | CI-06 - `test_peakguard.py` answered two of its checks with the wall clock |
|
||||
| `24c3d87` | SAFETY-38 - pin `site_standing` with shares that cannot answer for the site |
|
||||
| `a4290d3` | SAFETY-39 - `slew_w` is a ratio to the rail, **and `gain` is the constant that is still wrong** |
|
||||
| `c8dcb38` | SAFETY-37 - a stale reading is `None`, and arrival cannot be what says so |
|
||||
| `0581648` | SAFETY-13 - the output freeze gets an exit, and only where it needs one |
|
||||
| `464d398` | SAFETY-42 - a unit that stops delivering, **and the discriminator the card asked for does not exist** |
|
||||
|
||||
⚠️ **TWO DISCLOSED RESIDUALS SHIP IN THIS RELEASE.** SAFETY-13's bounded
|
||||
freeze-exit shows 0 spurious releases on the sweep's measured-plant slice but
|
||||
**2 of 272 on a finer grid**, and the docs carry the 2-of-272 figure rather than
|
||||
the flattering one. SAFETY-42's card proposed a discriminator that **turned out
|
||||
not to exist**, and the branch says so rather than shipping a weaker check under
|
||||
the original name.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
|
||||
@@ -29,7 +76,153 @@ is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` an
|
||||
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
|
||||
## Detail notes — accumulated, NOT a separate release
|
||||
|
||||
⚠️ **THIS HEADING USED TO SAY "Unreleased" AND IT WAS WRONG.** Everything below
|
||||
has SHIPPED. The section accumulated across 0.6.0, 0.7.0 and 0.8.0 because each
|
||||
release was cut without clearing it, so it names tickets from all three
|
||||
([[SAFETY-18]] and [[SAFETY-30]] from the 0.6.0 era through [[SAFETY-42]] in
|
||||
0.8.0). A reader who trusted the old heading would have concluded that shipped,
|
||||
running behaviour was still pending — and there was a SECOND heading saying the
|
||||
same thing further down, from the 0.4.x era.
|
||||
|
||||
Retitled rather than re-sorted, on purpose: splitting ~3000 lines by release is
|
||||
real work that does not belong inside a version bump, where it would bury the
|
||||
one line that changes what the Supervisor installs. Card PKG-08 carries it.
|
||||
|
||||
⚠️ **THE OUTPUT FREEZE NOW HAS AN EXIT, AND ONLY WHERE IT CANNOT END ON ITS OWN
|
||||
([[SAFETY-13]]).** While the loop is frozen the command's magnitude may fall but
|
||||
never rise; at `prev_w == 0` there is no magnitude left to fall, so a charge
|
||||
cannot start. That refusal is deliberate - the inverter reporting power we did
|
||||
not command means another writer is on the actuator, the §1.5 non-goal - and it
|
||||
is kept. What it did not have was an END: nothing cleared it but the other
|
||||
writer leaving.
|
||||
|
||||
The exit is now explicit, and *which* freezes get one is the decision rather
|
||||
than *how long*:
|
||||
|
||||
- A divergence a **limit** could have produced - the inverter doing less than
|
||||
the command, in the command's own direction - **clears itself**, because the
|
||||
command is pinned at something the inverter can reach. It gets no exit, and
|
||||
that is release/1.0's behaviour kept unchanged.
|
||||
- A divergence a limit could **not** have produced - the inverter overshooting
|
||||
the command, or producing the opposite sign - may not clear itself at all, so
|
||||
it gets one. It releases once the divergence has outlasted the plant's own
|
||||
measured settling time: `SETTLE_CYCLES` = **12** cycles at the measured tick.
|
||||
|
||||
⚠️ **THE BOUND COMES FROM THE SLOWER OF THE TWO DIRECTIONS THE PLANT WAS
|
||||
MEASURED IN.** `NOTES.md` §5.10 timed the same 1.78 kW step both ways - ~18 s
|
||||
up, ~23 s down - so `PLANT_SETTLE_DOWN_S` (23 s) joins `PLANT_SETTLE_S` (18 s)
|
||||
as a named constant. SAFETY-39 uses 18 correctly, because its bound is a
|
||||
*refusal* and the looser number refuses less; this bound asks how long the plant
|
||||
may still be the *explanation*, and there the longer number is the safe one. A
|
||||
bound of 9 derived from 18 fires the exit on a **single inverter with nothing
|
||||
else on the bus** - measured in the container, 62 of 272 load-step pairs at the
|
||||
plant's own measured shape, against 2 at 12.
|
||||
|
||||
⚠️ **`freeze-released` IS A PLACE TO START LOOKING, NOT A FINDING.** It says the
|
||||
divergence outlasted the plant's measured settling time. It is **not** proof of
|
||||
a second writer: a large load or PV swing can leave our own power coasting
|
||||
outside our own command's interval, and a plant slower than the one that was
|
||||
measured does the same. DOCS.md now gives the innocent causes first and the
|
||||
second writer only when the state *stands*.
|
||||
|
||||
⚠️ **RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP.** The integrator
|
||||
freeze is untouched, so the command can only walk, one slew step per cycle,
|
||||
toward an accumulator that stopped accumulating when the freeze engaged.
|
||||
Measured at the live install's shape (`max_w 5000`, `slew_w 400`,
|
||||
`target_grid_w -10`) with another writer holding the pack at +700 W and 2 kW of
|
||||
export standing: the command holds at 0 W, starts on the twelfth saturated
|
||||
cycle and settles at **-1190 W** against a frozen integrator of -1194 W, not at
|
||||
the 5000 W rail. On `release/1.0` @ `c8dcb38` the same run held **0.0 W on every
|
||||
cycle**, for as long as the other writer stayed.
|
||||
|
||||
⚠️ **THIS IS NOT A REGRESSION FIX AND IT ESCALATES AT TWO INVERTERS.** The
|
||||
unbounded block is `release/1.0` behaviour, confirmed cycle for cycle against
|
||||
SAFETY-04's branch. At a roster of two, `actual_w` is the SITE SUM, so the
|
||||
comparison is a site command against two units' power and one unit still moving
|
||||
after the other has stopped can put the site into the block with no external
|
||||
actor at all. ⚠️ That escalation is read from the code and from single-unit
|
||||
runs; **it is not measured on two inverters.** Inverter B does not physically
|
||||
exist yet and `sim/plantsim.py`'s `INVERTER_B` is `INVERTER_A` copied with
|
||||
`measured=False`, so any two-inverter figure would be two copies of A.
|
||||
|
||||
Graded against `release/1.0` over the 3780-case sweep in `test_control.py`:
|
||||
**3267 cases bit-identical, 432 releases, 27 of those change the command** -
|
||||
every one a charge from `prev_w == 0` that `release/1.0` refused to start - and
|
||||
**81 differ in the saturation counter alone**, because its ceiling moved with
|
||||
the bound from 10 to 13. That ceiling change also removes a degenerate setting:
|
||||
`config.yaml` accepts `saturation_cycles` up to 10, which is now strictly below
|
||||
the release bound, so **no accepted configuration can stop the block engaging**.
|
||||
At the old bound of 9 a setting of 9 or 10 deleted it silently.
|
||||
|
||||
⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
|
||||
([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.**
|
||||
`delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The
|
||||
Supervisor keys the built image by `version:`, so this needs a bump before it
|
||||
ships - see the 0.7.0 heading above, which makes the same point about
|
||||
`bms_soc_floor`. The key is ADDITIVE and defaulted, so an existing options blob
|
||||
stays valid; what a missing bump costs is that nobody's Supervisor re-reads the
|
||||
schema.
|
||||
|
||||
**Two or more inverters only, and at one inverter it is inert by construction.**
|
||||
A unit standing at a discharge share it is not producing - short by more than
|
||||
`saturation_w`, with nothing this add-on can read to explain it, while at least
|
||||
one sibling is NOT in that state - is named after `delivery_hold_s`. That fault
|
||||
was invisible to everything: the per-unit sign check needs OPPOSITION and a unit
|
||||
at 0 W is not opposing but ABSENT, the site sum still agrees with the meter in
|
||||
direction, circulating power is exactly zero when one unit does nothing, and the
|
||||
only remaining trace is the saturation freeze - which `clamp.py` itself calls an
|
||||
alarm operators learn to ignore. It is [[HW-01]]'s shape, which ran 6 h 25 m.
|
||||
|
||||
⚠️ **IT REPORTS. IT DOES NOT ACT.** Nothing is commanded differently, the loop is
|
||||
untouched, `control.py` is not modified, and the site goes on running short by
|
||||
that unit's share. Commanding an inverter that has stopped listening achieves
|
||||
nothing (HW-01), and the only re-allocation this add-on can express is
|
||||
all-or-nothing - measured on the rig, dropping a unit derated to 50 % from the
|
||||
split leaves the site **400 W worse** than keeping it, and this detector cannot
|
||||
tell that unit from a wedged one. The status row is a CAUTION, not a failure,
|
||||
because the controller has not stopped.
|
||||
|
||||
⚠️ **THE CARD'S OWN DISCRIMINATOR WAS FALSIFIED, AND THAT IS THE MAIN CONTENT.**
|
||||
SAFETY-42 asked for "saturation persisting past N while |grid error| > X, both
|
||||
derived". Built and run on the two-master rig first, it fails in both
|
||||
directions, and no (N, X) fixes either: a pack at its own floor and a wedged unit
|
||||
are **identical on all 120 cycles** of a run in grid, site target, every share
|
||||
and every measured watt - so the benign case fires - and with a sibling that has
|
||||
headroom the standing grid error collapses to **30 W** while the wedged unit
|
||||
stands at a +770 W share delivering nothing, so the real case does not. What
|
||||
replaces it is a per-unit shortfall plus two reductions the card did not ask for:
|
||||
an ACCOUNT (the pack's own `bms_soc_floor`, the only non-power fact that
|
||||
separates an empty pack from a wedged one) and a PROPER SUBSET of the units asked
|
||||
to discharge (which is what keeps a whole-site derate out, and what makes this
|
||||
inert at N=1 - measured zero in all 1890 one-inverter runs).
|
||||
|
||||
The report **survives a Home Assistant restart** ([[SAFETY-34]]) under
|
||||
`unit/<name>/delivery_tripped`, with one notification id per unit, re-raised on
|
||||
the first cycle of every process and cleared by the same **Start** press as the
|
||||
other latches. **Charge shares are deliberately not graded** - there is no
|
||||
configured charge ceiling to explain a constant-voltage taper with, and arming
|
||||
that direction would fire on every maintenance charge.
|
||||
`DECISIONS/SAFETY-42-unit-delivery.md` carries the campaign (2640 healthy
|
||||
two-inverter runs plus 1890 at one inverter as the control), the
|
||||
inverter-B sensitivity sweep the margin is sized on, and the mutation ledger.
|
||||
|
||||
⚠️ **AND THE TWO THRESHOLDS IT READS ARE NOW GRADED AT A CONFIG THAT IS NOT THE
|
||||
SHIPPED ONE.** `saturation_w` and each unit's `bms_soc_floor` are handed to this
|
||||
check from the running config, and the promise that comes with that - raise
|
||||
`saturation_w` and this check moves with you, there is no fourth copy of a
|
||||
default to keep in step - is only true while the call site passes the
|
||||
**configured** value. A literal equal to the shipped value keeps every fixture
|
||||
green and removes the promise silently, and two such literals were survivors of
|
||||
all 23 suites - measured, not argued - because no fixture that reaches this
|
||||
check had ever run at a `saturation_w` other than the shipped 500 or at a
|
||||
`bms_soc_floor` other than the shipped example's 10. Part 4b of
|
||||
`test_delivery.py` now runs the same fault at a raised `saturation_w` and at two
|
||||
cut-outs that bracket 10 from both sides, and each pair must answer two
|
||||
different ways - so no constant passes. §9 of the decision record enumerates
|
||||
every number this check reads and says which were already pinned this way and
|
||||
which were not.
|
||||
|
||||
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
|
||||
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
|
||||
@@ -3247,7 +3440,12 @@ human to read rather than alarmed on - at a converged -10 W a 1 Wh register need
|
||||
about six minutes to move, so any threshold would false-trip at the target
|
||||
operating point.
|
||||
|
||||
## Unreleased
|
||||
## Detail notes from the 0.4.x era — NOT unreleased
|
||||
|
||||
⚠️ Retitled with the same correction as the section above: [[TEL-05]] and the
|
||||
entries under this heading shipped long ago. Two headings in one file both
|
||||
claiming "Unreleased" is how a reader ends up believing running behaviour is
|
||||
still pending.
|
||||
|
||||
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
|
||||
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
# SAFETY-13 — the output freeze gets an exit, and only where it cannot end on its own
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-13`, off `release/1.0` @ `c8dcb38`.
|
||||
**Card:** SAFETY-13 — *"Bound the no-start-while-saturated output block"*
|
||||
([Trello](https://trello.com/c/uZjmuF7B)).
|
||||
**Nothing here reached the inverter.** Every figure below is from the pure control
|
||||
law run in a container against a plant model, on this branch and on a pristine
|
||||
`c8dcb38` for the before-figures. No rig, no ESP32, no Home Assistant.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **The card's headline case is real, and the route to it is not the obvious
|
||||
one.** The block does not engage on the way down into an export; the slew
|
||||
limit moves the command off zero before the counter reaches 3. It engages
|
||||
from the **deadband** — a site resting at its target with another writer on
|
||||
the bus, counting saturated cycles while nothing is wrong — and 0 W is then a
|
||||
fixed point. §2.
|
||||
2. **`prev_w == 0` is not a special case, so the fix is not one either.** The
|
||||
same run pins at −800 W against its own integrator standing at −2388 W, at a
|
||||
non-zero `prev_w`, with
|
||||
the same absence of an exit. Zero is only the instance where the pinned
|
||||
magnitude is the whole of what the battery could have done. §3.
|
||||
3. **The decision is not "how long", it is "which".** A divergence a *limit*
|
||||
could have produced clears itself and gets no exit — `release/1.0` unchanged.
|
||||
A divergence a limit could not have produced may not, and gets one. The
|
||||
predicate is physical, not statistical — and it is **not** a second-writer
|
||||
detector; §4.1 and §8 say what it can and cannot tell apart.
|
||||
4. **Releasing the output freeze does not unfreeze the loop**, and that is what
|
||||
makes the release bounded rather than merely different. §6.
|
||||
5. **The bound comes from the SLOWER half of a two-sided measurement, and the
|
||||
first version of this branch took the faster half.** §8 is the correction and
|
||||
the measurement behind it; it is the reason `SETTLE_CYCLES` is 12 and not 9.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was there, in the terms the code uses
|
||||
|
||||
`control.py`, in `compute()`, after the clamp and the slew limit:
|
||||
|
||||
```python
|
||||
if frozen:
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
```
|
||||
|
||||
"Magnitude may fall, never rise." At `prev_w == 0` the magnitude is zero and
|
||||
`max(target, 0)` forbids any negative command, so a charge cannot **start**.
|
||||
|
||||
The reason is written in the file and it is a good one: `frozen` means the
|
||||
inverter's reported power has diverged from the command by more than
|
||||
`saturation_w` for `saturation_cycles` consecutive cycles, and at a command of
|
||||
zero that means the actuator is producing power nobody in this add-on asked for.
|
||||
Starting to push the other way into it is the configuration §1.5 of the
|
||||
specification rules out — *"any configuration in which two writers can reach one
|
||||
actuator"*.
|
||||
|
||||
**The defect is that it has no end.** Not the error growing, not the integrator
|
||||
moving, not time. `sat_count` stops rising at its cap and the state is a fixed
|
||||
point.
|
||||
|
||||
---
|
||||
|
||||
## 2. Reproducing it — and correcting the route
|
||||
|
||||
Driven at the live install's shape: `max_w 5000`, `slew_w 400`,
|
||||
`target_grid_w -10`, `saturation_w 500`, `saturation_cycles 3`, `gain 0.6`.
|
||||
Another writer holds the pack at **+700 W** whatever is commanded.
|
||||
|
||||
**The route that does NOT reach it.** Start at `prev_w = 0` with 2 kW of export
|
||||
already standing. On `c8dcb38` the loop moves off zero at the slew limit before
|
||||
the counter reaches 3 — commands −400, −800 — so by the time `frozen` is true
|
||||
`prev_w` is −800 and the block that gets applied is `max(target, −800)`. The
|
||||
site charges at 800 W, not 0 W, for as long as the run lasts.
|
||||
|
||||
**The route that does.** The site is *resting*. `grid == target_grid_w`, so the
|
||||
loop is in its deadband and the command is 0 — and `saturated_now` is computed
|
||||
**before** the deadband branch, so the counter climbs anyway. Three cycles later
|
||||
the loop is frozen at `prev_w == 0` with nothing wrong. Then the export arrives:
|
||||
|
||||
| cycle | grid | actual | prev | command | integrator | sat | reason |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 0–1 | −10 | +700 | 0 | 0 | 0 | 1–2 | `deadband` |
|
||||
| 2–5 | −10 | +700 | 0 | **0** | 0 | 3–6 | `saturated-freeze` |
|
||||
| 6 | −2000 | +700 | 0 | **0** | −1194 | 7 | `saturated-freeze` |
|
||||
| 7… | −2000 | +700 | 0 | **0** | −1194 | 8→10 | `saturated-freeze` |
|
||||
|
||||
On `c8dcb38` the command is `0.0` on **every** cycle from there on. The
|
||||
integrator has moved to −1194 W and stopped (its own freeze holds it), the site
|
||||
exports 2 kW, and the battery is idle until the other writer leaves.
|
||||
|
||||
That is the card's sentence — *"the site can sit exporting 2 kW with an idle
|
||||
battery indefinitely"* — reproduced, with the route corrected: it is entered
|
||||
from the **deadband**, not from a correction that ran out of room.
|
||||
|
||||
---
|
||||
|
||||
## 3. …and `prev_w == 0` is not where it stops
|
||||
|
||||
The same tuning, same writer, but the loop starts from zero with the export
|
||||
already standing (the route from §2 that "does not reach it"):
|
||||
|
||||
| | `c8dcb38` |
|
||||
|---|---|
|
||||
| command after the freeze engages | **−800 W**, on every cycle |
|
||||
| the loop's integrator, frozen | −2388 W |
|
||||
| what ends it | nothing |
|
||||
|
||||
So the missing exit is a property of the **output freeze**, not of the zero
|
||||
case. `prev_w == 0` is the instance where the pinned magnitude happens to be
|
||||
zero. A rule written for zero alone would have left −800 W exactly as unbounded
|
||||
as it found it — which is why the change below is written against the freeze and
|
||||
not against a special case of it.
|
||||
|
||||
**And this is the half that escalates at two inverters.** `actual_w` is
|
||||
`site_batt_w`, the SUM (ALLOC-01, DEV-02), so the comparison is a site command
|
||||
against two units' power. One unit still moving after the other has stopped is
|
||||
enough to put the site into the freeze with no external actor at all.
|
||||
|
||||
⚠️ **What that claim is and is not.** The escalation is stated from the code —
|
||||
`prev_w` is `self.target`, the site total, and `actual_w` is the site sum — and
|
||||
from the single-unit runs above. **It is not measured on two inverters.**
|
||||
Inverter B does not physically exist (`NOTES.md` §1.1b: on paper, from datasheet
|
||||
and serial), and `sim/plantsim.py:216` `INVERTER_B` is `INVERTER_A` copied with
|
||||
`measured=False`. Any two-inverter number would be two copies of A.
|
||||
|
||||
---
|
||||
|
||||
## 4. The decision: which freezes get an exit, not how long the block lasts
|
||||
|
||||
Three answers were on the table, and the card named them: lift after N frozen
|
||||
cycles, exempt above some export magnitude, or keep it unbounded and write down
|
||||
the cost.
|
||||
|
||||
**A plain cycle bound was rejected**, because it bounds the wrong thing. The
|
||||
freeze is right to be permanent in one case and cannot be permanent in the
|
||||
other, and a single N treats them alike:
|
||||
|
||||
| the inverter is… | can the freeze clear itself? |
|
||||
|---|---|
|
||||
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap, the plant still ramping | **Yes.** The command is pinned at something the inverter can reach, so it catches up, `saturated_now` goes false, and the freeze ends. Asking for more would achieve nothing anyway. |
|
||||
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **No.** Clearing the freeze requires the inverter to track *our* command, and the other writer is precisely what stops that. |
|
||||
|
||||
**An export-magnitude exemption was rejected** for a narrower reason: it makes
|
||||
the block's end depend on how big the other actor's disturbance happens to be,
|
||||
which is not a property of the thing the block is about.
|
||||
|
||||
So the rule implemented is:
|
||||
|
||||
> The output freeze applies as before, **unless** the inverter's reported power
|
||||
> cannot be explained by the command not being met **and** the divergence has
|
||||
> outlasted the plant that would otherwise have explained it.
|
||||
|
||||
### 4.1 The predicate is physical
|
||||
|
||||
`unasked_power(prev_w, actual_w, saturation_w)`. A limit can only make the
|
||||
inverter do **less** than the command, in the command's own direction. It cannot
|
||||
make it overshoot the command and it cannot make it produce the opposite sign.
|
||||
So everything a limit can produce lies in the closed interval spanned by `0` and
|
||||
`prev_w`, widened by `saturation_w` for noise, ramp lag and idle draw.
|
||||
|
||||
⚠️ **What it does NOT establish is that anything outside was put there by
|
||||
something else.** The first version of this branch said exactly that, in the
|
||||
docstring, in `control.py` and in DOCS.md, and it is false: the predicate
|
||||
compares against **one cycle's** command while the pack carries every command
|
||||
before it, so our own coast-down through zero lands outside our own interval.
|
||||
§8 measures it. What the predicate guarantees is only its own first sentence —
|
||||
*this cycle's command does not account for this cycle's reading* — and the time
|
||||
bound below is what turns that into a reason to act.
|
||||
|
||||
| prev | actual | interval | verdict |
|
||||
|---|---|---|---|
|
||||
| +2000 | +700 | [0, 2000] | a limit |
|
||||
| −5000 | −700 | [−5000, 0] | a limit |
|
||||
| 0 | +700 | [−500, +500] | **unasked** |
|
||||
| −800 | +700 | [−1300, +500] | **unasked** |
|
||||
|
||||
⚠️ **At `prev_w == 0` this predicate and the saturation detector are the same
|
||||
test** — asserted in `test_control.py` over 801 readings. That is not a
|
||||
coincidence to tidy away: with nothing commanded there is no interval left for a
|
||||
limit to live in, which is exactly why the zero case is the one that strands.
|
||||
|
||||
⚠️ **It is not a detector and nothing alarms on it.** A stale, wrong-signed or
|
||||
noisy reading lands outside the interval too. All it decides is whether the
|
||||
freeze gets an exit; being wrong about it costs a bounded command (§6).
|
||||
SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on sign, and it is
|
||||
deliberately a different test with a different threshold.
|
||||
|
||||
### 4.2 The bound, and which half of the measurement it comes from
|
||||
|
||||
`SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)` = **12**.
|
||||
|
||||
⚠️ **`PLANT_SETTLE_DOWN_S` is new, and adding it is the point rather than a
|
||||
cost.** `NOTES.md` §5.10 did not measure *a* settling time; it measured the same
|
||||
1.78 kW step in **both directions** — "Up: ~18 s from step to deadband. Down:
|
||||
~23 s from a −1784 W export excursion" (`NOTES.md:1550`). So the plant's
|
||||
settling time is a **pair**, and which half is the safe one depends on which way
|
||||
being wrong hurts:
|
||||
|
||||
| the bound | the question | the safe half | why |
|
||||
|---|---|---|---|
|
||||
| SAFETY-39's `SLEW_RAIL_FRAC` | may this configuration be **refused**? | **18 s**, the shorter | a looser bound refuses fewer working configurations |
|
||||
| this one | may the plant still be the **explanation**? | **23 s**, the longer | a shorter bound stops believing the plant too early, and then it accuses |
|
||||
|
||||
The first version of this branch reused `PLANT_SETTLE_S` and got 9 — three
|
||||
cycles short of the plant's own measured down-ramp, on the side that produces a
|
||||
false positive. §8 is the measurement and the correction.
|
||||
|
||||
⚠️ **It is no longer the reciprocal of `SLEW_RAIL_FRAC`, and it never should
|
||||
have been read as one.** The two were equal only while both were being derived
|
||||
from the same half of the pair, and that coincidence read as a derivation. They
|
||||
are 9 and 12 now; `test_control.py` asserts the inequality so they cannot be
|
||||
quietly re-fused.
|
||||
|
||||
⚠️ **It inherits SAFETY-39's caveat about `LOOP_TICK_S`, in the opposite
|
||||
direction.** A meter **slower** than 2 s makes 12 cycles cover more than 23 s, so
|
||||
the freeze holds longer than the plant needs: that costs energy, never safety. A
|
||||
meter **faster** makes them cover less, so the freeze can release while the
|
||||
plant is still moving — bounded by §6 rather than by luck.
|
||||
|
||||
**`_SAT_COUNT_CAP` moved with it, from 10 to 13**, because it is written as
|
||||
`SETTLE_CYCLES + 1` — a counter capped below the longest bound read from it
|
||||
would silently delete the exit. That has a consequence worth stating on its own:
|
||||
`config.yaml` accepts `saturation_cycles` as `int(1,10)`, so at the old bound of
|
||||
9 a legal setting could sit **above** the exit and delete the block. At 12 the
|
||||
whole accepted range is strictly below the bound, and the degenerate
|
||||
configuration this branch used to document no longer exists. §7.
|
||||
|
||||
---
|
||||
|
||||
## 5. What actually changed, graded against `release/1.0`
|
||||
|
||||
`test_control.py`'s sweep still compares this law against a transcription of
|
||||
`release/1.0` — the transcription was **not** taught the new rule, because an
|
||||
oracle taught the change compares the law against itself. What changed is the
|
||||
assertion: every case must be identical, or be a release with both of its
|
||||
conditions true, or differ **in the saturation counter alone**.
|
||||
|
||||
⚠️ **The sweep had to be widened to keep grading anything.** Its `sat_count`
|
||||
fixtures ran to 9; with the bound at 12 the counter never reached the exit and
|
||||
every release silently vanished from the table, which is what the assertion on
|
||||
the count caught. It runs to 12 now, at 3780 cases.
|
||||
|
||||
| | cases |
|
||||
|---|---|
|
||||
| total | 3780 |
|
||||
| **bit-identical to `release/1.0`** | **3267** |
|
||||
| releases | 432 |
|
||||
| …of those, reason-only (the freeze was not binding) | 405 |
|
||||
| …of those, **the command changes** | **27** |
|
||||
| differ in the **saturation counter alone** | 81 |
|
||||
|
||||
All 27 command changes are at `prev_w == 0`, all with `release/1.0` commanding
|
||||
exactly `0.0 W`, and every new command is **negative** — a charge start that was
|
||||
refused.
|
||||
|
||||
⚠️ **The 81 are the counter's ceiling moving, and they reach nothing.**
|
||||
`_SAT_COUNT_CAP` went from 10 to 13 with the bound, so above `release/1.0`'s own
|
||||
ceiling the two counters differ. Nothing else may differ in those cases, and the
|
||||
sweep asserts that: `frozen` is `sat_count >= saturation_cycles`, `config.yaml`
|
||||
accepts that at most 10, and no other line in `compute()` reads the counter's
|
||||
value — so a counter reading 13 instead of 10 cannot change what is commanded.
|
||||
The 81 all sit at an `sc` above 10, which is a state `release/1.0` could never
|
||||
hold at all.
|
||||
|
||||
The behaviour after the fix, on §2's run: the command holds at 0 W through
|
||||
`sat_count` 3–11, releases on the **twelfth** saturated cycle, and walks
|
||||
0 → −400 → −800 → **−1190 W**, where it stops — the same settling point as at
|
||||
the old bound, three cycles later.
|
||||
|
||||
The genuine-limit case, driven for 30 cycles with the inverter curtailed to
|
||||
±1000 W against a 4 kW import: `freeze-released` never appears and the command
|
||||
stays pinned at +2400 W. Unchanged, deliberately.
|
||||
|
||||
---
|
||||
|
||||
## 6. Why the release cannot become windup (the card's fourth criterion)
|
||||
|
||||
**The integrator freeze is not lifted with the output freeze.** The command is
|
||||
derived from `i_w`, then clamped, then slew-limited from `prev_w` — so a
|
||||
released cycle can only move the command *toward* an accumulator that stopped
|
||||
accumulating when the freeze engaged, by at most one slew step, and never past
|
||||
it. `test_control.py` sweeps 107 625 states, of which **67 500 release**: no
|
||||
released case leaves the interval between the previous command and the frozen
|
||||
integrator, and none moves further than one slew step.
|
||||
|
||||
⚠️ **107 625 was previously reported as the number of released states. It is
|
||||
the number of states swept**, and the assertion only ever graded the subset that
|
||||
released. Both numbers are counted and printed now.
|
||||
|
||||
On §2's run that shows up as the settling point: **−1190 W against a frozen
|
||||
integrator of −1194 W**, not the 5000 W rail. The tech lead's note on SAFETY-04
|
||||
— that the carried `i_w` and `release/1.0`'s recomputed value land on the same
|
||||
slew-limited command — is preserved: the `i_w=None` path is still graded against
|
||||
the transcription, cycle for cycle, everywhere except the 432 releases.
|
||||
|
||||
⚠️ **The first form of this check was wrong and the sweep said so**, which is
|
||||
left in the test file. It asserted `|target| <= |i_w|` on a released cycle and
|
||||
produced `prev −5000, i_w −3000, target −4600`: the command was walking back
|
||||
from −5000 toward the integrator and had not arrived. It was moving the right
|
||||
way and the assertion still failed, because "never exceeds the integrator" is
|
||||
not what no-windup means here.
|
||||
|
||||
---
|
||||
|
||||
## 7. What this does NOT buy, said plainly
|
||||
|
||||
**The site still does not reach its target while another writer is on the bus.**
|
||||
It settles at the frozen integrator, not at the meter's target. What the exit
|
||||
buys is that the battery stops being *idle* while the site exports; it does not
|
||||
buy correct regulation against an actuator somebody else is also writing to, and
|
||||
nothing in a rules engine can. A standing grid error with a second writer
|
||||
present is the symptom of a configuration this add-on refuses to support.
|
||||
|
||||
**`freeze-released` is a place to start looking, not a finding.** It says the
|
||||
divergence outlasted the plant's own measured settling time, and that is all it
|
||||
says — §8. DOCS.md now tells the operator the innocent causes first (a large
|
||||
load or PV swing, a plant slower than the one that was measured) and the second
|
||||
writer only when the state **stands**. It is carried on `Decision.reason`, which
|
||||
nothing after the freeze overwrites. No new alarm was added: SAFETY-41 removed
|
||||
one that narrated normal anti-windup, and this state is not yet known to be
|
||||
worth waking anybody for.
|
||||
|
||||
**No accepted configuration removes the block any more.** `config.yaml` accepts
|
||||
`saturation_cycles` as `int(1,10)`; the bound is 12, so every accepted value is
|
||||
strictly below it and the freeze always engages before its exit is due. This
|
||||
paragraph used to say the opposite — at a bound of 9, settings of 9 and 10
|
||||
deleted the block silently and the branch documented that as a known
|
||||
consequence. Moving the bound closed it by construction. `test_control.py` pins
|
||||
it against `config.yaml`'s own schema line rather than a transcription of it.
|
||||
|
||||
**Nothing was measured on two inverters.** See the warning in §3.
|
||||
|
||||
---
|
||||
|
||||
## 8. ⚠️ ROUND 2: the bound was derived from the wrong half, and it accused
|
||||
|
||||
**What was wrong.** `SETTLE_CYCLES` was `ceil(PLANT_SETTLE_S / LOOP_TICK_S)` = 9.
|
||||
`PLANT_SETTLE_S` is 18 s, the **up**-ramp of `NOTES.md` §5.10's kettle step;
|
||||
the same measurement recorded ~23 s coming back **down** (`NOTES.md:1550`).
|
||||
SAFETY-39 takes 18 correctly — its bound is a refusal, and the looser number
|
||||
refuses less. This bound asks the opposite question, and the looser number stops
|
||||
believing the plant three cycles too early.
|
||||
|
||||
**Why that is not a tuning quibble.** The predicate has no memory. `prev_w` is
|
||||
one cycle's command; the pack carries every command before it. A command that
|
||||
falls **through zero** — the ordinary end of a load or PV step — leaves the pack
|
||||
coasting in the old direction, so the reading has the sign the command *used* to
|
||||
have and lands outside the interval spanned by 0 and the new `prev_w`.
|
||||
`unasked_power()` calls that "unasked". With the bound three cycles short of the
|
||||
plant's own down-ramp, the counter reaches it while the coast-down is still
|
||||
going, and the law emits `freeze-released` on **one inverter with nothing else
|
||||
on the bus.**
|
||||
|
||||
**Measured, in the container, N = 1.** One `compute()`, one plant model, one
|
||||
command; no second writer anywhere in the harness. The plant is a first-order
|
||||
lag whose two time constants come from the two measured settling times
|
||||
(tau = t/3, since a first-order step is 95 % done at 3 tau) with
|
||||
`sim/plantsim.py`'s measured 800 W/s ramp cap. It is a **model**, and it is
|
||||
labelled one wherever it is used.
|
||||
|
||||
| | bound 9 | bound 12 |
|
||||
|---|---|---|
|
||||
| 1280 closed-loop scenarios (a grid of plant pairs, slews, `saturation_cycles`, load steps) | **113 fire** | **24 fire** |
|
||||
| 272 load-step pairs at the measured 18 s / 23 s plant, `slew_w 400` | **62 fire** | **2 fire** |
|
||||
|
||||
The single case now pinned in `test_control.py`: a 4 kW export falling to
|
||||
2.5 kW. At cycle 34 the command is −310 W, the pack is still at −1343.4 W
|
||||
coasting down from −4860 W a few cycles earlier, `unasked_power(−310, −1343.4,
|
||||
500)` is True, `sat_count` is 9, and the law says `freeze-released`. At 12 the
|
||||
same run never releases.
|
||||
|
||||
**What the fix does not fix.** The bound makes the false positive rare on *the
|
||||
plant that was measured*. It cannot make it impossible, because it is a
|
||||
measurement and the plant can be slower than the measurement. Same sweep, bound
|
||||
held at 12, lengthening only the plant's down-ramp: 8 of 320 scenarios at the
|
||||
measured 23 s, 59 of 320 at 34 s, 268 of 320 at 50 s. That residual is why the
|
||||
three statements below were corrected rather than merely re-numbered.
|
||||
|
||||
**Three shipped statements were falsified by this and are now rewritten:**
|
||||
|
||||
1. `unasked_power()`'s docstring — *"Anything outside that interval was put
|
||||
there by something other than this loop."* It was our own power. The
|
||||
docstring now says what the predicate does guarantee (this cycle's command
|
||||
does not account for this cycle's reading) and states the coast-down as its
|
||||
known blind spot.
|
||||
2. `control.py`, in `compute()` — *"SO THE BOUND BELONGS TO (b) AND ONLY TO
|
||||
(b)"*. It fired in case (a). The comment now says the bound is a reason to
|
||||
stop believing (a), not a proof of (b), and that the release is written to be
|
||||
survivable when the inference is wrong.
|
||||
3. **DOCS.md — *"If you see `freeze-released` in the action log, look for the
|
||||
second writer."*** This is the one that reaches a person, and on a
|
||||
single-inverter install it sent them hunting something that does not exist.
|
||||
Replaced by a section that gives the innocent causes first and the second
|
||||
writer only when the state stands.
|
||||
@@ -0,0 +1,592 @@
|
||||
# SAFETY-42 — a unit that stops delivering its share, the discriminator the card asked for, and why that discriminator does not exist
|
||||
|
||||
**Status: DECIDED 2026-09-06.** A new per-inverter check, `app/delivery.py`,
|
||||
names an inverter that is **standing at a discharge share it is not producing**
|
||||
while at least one sibling is not in that state and nothing this add-on can read
|
||||
explains it. It **reports and does not act**. At a roster of one it is inert by
|
||||
construction.
|
||||
|
||||
⚠️ **This record also FALSIFIES the discriminator the card proposed.** SAFETY-42
|
||||
asked for *"saturation persisting past N while |grid error| stays above X, with N
|
||||
and X derived"*. That predicate was built and run on the rig before anything was
|
||||
written, and it fails in **both** directions — not for want of better N and X,
|
||||
but because of what the quantity is. §2 is the measurement. This is the
|
||||
SAFETY-39 shape: saying the criterion is wrong was worth more than five
|
||||
near-misses at satisfying it.
|
||||
|
||||
Filed with SAFETY-42, card `6a9d498a49082c0086e525c1`.
|
||||
|
||||
---
|
||||
|
||||
## 0. What is uncovered, and why it is structural rather than a threshold
|
||||
|
||||
A unit delivering **0 W against a non-zero share** is seen by nothing:
|
||||
|
||||
| guard | why it is silent |
|
||||
|---|---|
|
||||
| [[SAFETY-30]] per-unit sign | gates on **opposition ≥ 250 W on both sides**. A unit at 0 W never opens it — it is not opposing, it is **absent** |
|
||||
| SAFETY-30 site sign | the sum still **agrees with the meter in direction**; only the magnitude is short |
|
||||
| [[SAFETY-31]] circulation | `Σ\|Bᵢ\| − \|ΣBᵢ\|` is **zero** when one unit does nothing — circulation needs two units pulling *against* each other |
|
||||
| the saturation freeze | **fires** — and `clamp.py`:93-96 says of exactly this: *"A loop can be legally saturated all day — a cold pack, a curtailed inverter, an export limit — so a saturation-shaped alarm is one an operator learns to ignore"* |
|
||||
|
||||
So the one signal that does fire is the one this codebase documents as
|
||||
unreadable, and that documentation is **right**. This card therefore needed a
|
||||
different signal, not a louder one.
|
||||
|
||||
It is [[HW-01]]'s shape. On 2026-09-02 the comms subsystem failed and the power
|
||||
stage went on executing its last instruction for **6 h 25 m** with all three
|
||||
layers spectators. At N=2 the same fault leaves a healthy sibling regulating
|
||||
alone, the site short by up to one unit's share, and nothing saying so.
|
||||
|
||||
## 1. The rig
|
||||
|
||||
Everything in §2 and §3 was measured on `admin/goodwe`'s two-master rig
|
||||
(TEST-19), used **read-only** and run container-free, from a harness in a
|
||||
scratch tree. Real and imported, never transcribed: the control law
|
||||
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
|
||||
`allocator.plan`), the per-unit envelope the shipped `supervised` and `device`
|
||||
claims build, the sign predicate (`watchdog.disagrees`), the circulating sensor
|
||||
(`circulation.circulating_w`), the check under test (`delivery.delinquent`,
|
||||
`short_by`, `account`), the wire (`frame_check.setpoint_atomic`, the firmware's
|
||||
own encoder, in and back out through `plantsim.parse`/`decode`), and the plant
|
||||
(`plantsim.Plant`). The per-unit reading is **holding register 0x0518**, the one
|
||||
the live `batt_entity` binds.
|
||||
|
||||
Modelled, and therefore not evidence about the thing modelled: the master's
|
||||
failsafe machine (absent), and the two fault injections —
|
||||
|
||||
* **wedged** — the write never reaches the plant, so its latched command stands.
|
||||
Started at 0 W, which is *"delivering 0 W against a non-zero share"*.
|
||||
* **at its floor** — the write arrives and `Plant.limit()` zeroes it, because
|
||||
`soc <= soc_min`. This is the benign case the acceptance criterion names.
|
||||
|
||||
### ⚠️ Inverter B does not physically exist
|
||||
|
||||
`plantsim.INVERTER_B` is **inverter A copied**, `measured=False` (NOTES.md
|
||||
§1.1b; the datasheet-and-serial figures 4600 W / 20.48 kWh / floor 10 % are
|
||||
deliberately *not* in the model). **Every two-inverter figure in this document
|
||||
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
|
||||
margin that costs.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
The harness is **not committed here**, for the reason SAFETY-30's and
|
||||
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
|
||||
other repository and are not present in this one's CI image. What it does is
|
||||
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
|
||||
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
|
||||
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
|
||||
envelopes of `±min(max_w, rating)`; put each share on its own wire through
|
||||
`Inverter.command()`, honouring a configurable **write stagger** so B's number
|
||||
can land up to four cycles after A's (the serialised write path); advance
|
||||
`Site.settle()` one control cycle; and record, per unit, the command standing
|
||||
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
|
||||
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
|
||||
cycles** on which it names anybody — not a count of naming samples, because the
|
||||
window is cleared by any cycle that does not.
|
||||
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
|
||||
trees. Run it with `python3 -B` and purge.
|
||||
|
||||
## 2. The card's discriminator, measured — and it does not work
|
||||
|
||||
### 2.1 It fires on the benign case, and no (N, X) can stop it
|
||||
|
||||
`Plant.limit()` returns 0 W for a discharge command into a pack at its own
|
||||
floor. A wedged unit that never receives its command also produces 0 W. The two
|
||||
are not merely similar:
|
||||
|
||||
> **Load 2000 W, equal weights, 2000 W per-unit cap, 120 control cycles.
|
||||
> The two arms are IDENTICAL on all 120 cycles** in (grid, site target, each
|
||||
> unit's share, each unit's measured power), to the last emitted watt. Both
|
||||
> report `reason == "saturated-freeze"` on **116 of the 120** cycles (the other
|
||||
> four are `slew-limited`, before it engages on cycle 4), both hold |grid error|
|
||||
> over 300 W on **all 120** and settle at **1200 W**, and `circulating_w` is
|
||||
> **0.0 W** in both.
|
||||
|
||||
A predicate whose inputs are *saturation duration* and *|grid error|* reads the
|
||||
same number on both arms at every cycle. There is no N and no X. And *"a
|
||||
legitimately saturated site — cold pack at its floor — does **not** alarm"* is
|
||||
this card's own acceptance criterion.
|
||||
|
||||
`test_delivery.py` part 4 asserts that identity on the add-on's own end-to-end
|
||||
fixture, so it goes red if it ever stops being true.
|
||||
|
||||
### 2.2 It is silent on the headline case, because a healthy sibling erases it
|
||||
|
||||
The standing grid error is exactly the thing a working sibling removes.
|
||||
|
||||
> **Load 800 W, equal weights, 2000 W per-unit cap, B wedged, 200 cycles.**
|
||||
> Over the last 30 cycles the loop is saturation-frozen on **30 of 30** — so
|
||||
> "saturation persisting past N" is true for every N — while the worst
|
||||
> **|grid error| in that window is 30 W**. B is standing at a **+770 W**
|
||||
> discharge share and delivering **0 W**.
|
||||
|
||||
Any X above 30 W leaves the card's predicate silent for the whole run, on the
|
||||
exact fault it was written for. The card's own sentence — *"a healthy sibling
|
||||
regulating alone, the site short by up to one unit's rating"* — describes the
|
||||
case, and the grid error is the one number that cannot report it.
|
||||
|
||||
### 2.3 What that leaves
|
||||
|
||||
The derivation instruction stands and is honoured in §3. What could not be
|
||||
derived was a threshold on the wrong quantity. Both failures are written into
|
||||
`delivery.py`'s docstring as well, because the next person to read the card will
|
||||
propose the same predicate.
|
||||
|
||||
## 3. The quantity that does work
|
||||
|
||||
Per unit, for a share that asks it to **discharge**:
|
||||
|
||||
```
|
||||
short_by(i) = share_i − measured_i # watts that did not arrive
|
||||
```
|
||||
|
||||
and the reduction that makes it readable:
|
||||
|
||||
> a unit is **delinquent** while it is short by more than `saturation_w` with
|
||||
> **no account for it**, and the set of units in that state is a **proper
|
||||
> subset** of the units that were asked to discharge.
|
||||
|
||||
### 3.1 The account — the only thing that separates a wedged unit from a cold pack
|
||||
|
||||
§2.1 says there is nothing in the power domain. There is exactly one non-power
|
||||
fact this add-on holds about a pack: **its own state of charge against its own
|
||||
`bms_soc_floor`**, which SAFETY-32 made a required per-entry key. `account()`
|
||||
asks it through **`peakguard.discharge_permitted()`** — public since SAFETY-32
|
||||
precisely so that this question has one answer — and writes its own sentence,
|
||||
because that function's wording names the *peak guard's* floor and would be a
|
||||
false statement here.
|
||||
|
||||
Three things account for a shortfall, and each is a state already being handled:
|
||||
|
||||
| account | why it is not this fault |
|
||||
|---|---|
|
||||
| the unit is not live | `live_units()` has already dropped it from the split and the action log says so — firing inside that is the SAFETY-15/19 failure |
|
||||
| the unit is parked by a sign trip | it is commanded 0 W on purpose; not delivering is compliance |
|
||||
| `discharge_permitted(soc, bms_soc_floor)` | the pack is at or below its own cut-out and physically cannot |
|
||||
|
||||
⚠️ **The floor passed in is `bms_soc_floor` alone, NOT
|
||||
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`.** That
|
||||
conjunction is right for the peak guard and wrong here: `peak_soc_floor` bounds
|
||||
a money-saving discharge only (SAFETY-25), ordinary self-consumption discharges
|
||||
straight past it, and using it would **explain away** a wedged unit sitting
|
||||
anywhere between the two floors.
|
||||
|
||||
⚠️ **Every unknown accounts for the shortfall**, i.e. fails toward silence —
|
||||
the opposite direction to `discharge_permitted()`'s own caller, and the right
|
||||
one here: there it withholds a discharge, here it withholds an **alarm**. The
|
||||
cost is bounded, because the same absence stops the site through `inputs_ok`
|
||||
(an unreadable SoC) or is refused by the roster schema (an unstated floor).
|
||||
|
||||
### 3.2 The proper subset — what only exists at N ≥ 2
|
||||
|
||||
A derate this add-on cannot see — thermal, grid-code, a DC-side restriction —
|
||||
hits **every** unit that was asked. One box that has stopped listening does not.
|
||||
So if every asked unit is short and unexplained, this module says **nothing**:
|
||||
it has no instrument that tells that from a plant which simply cannot deliver,
|
||||
and reporting it anyway would be the saturation-shaped alarm produced by the
|
||||
guard written to replace it.
|
||||
|
||||
**At N = 1 the subset is never proper, for every input**, so the check is inert
|
||||
there — not by a roster guard, but by the algebra, the same way
|
||||
`circulation.circulating_w()` is identically zero at N ≤ 1. That is AC 5, and
|
||||
§3.3's control arm measures it rather than asserting it.
|
||||
|
||||
### 3.3 The window — the one number this card chose
|
||||
|
||||
**2640 closed-loop runs at a roster of two in which NOTHING is faulty** — every
|
||||
unit executes the share the allocator gave it — over 6 gains × 5 slews, the
|
||||
whole range `config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w`
|
||||
`int(0,5000)` (0 = follow the rail), across three families:
|
||||
|
||||
* **1050 load reversals** — 7 sizes (±300 W … ±5000 W) × 5 write staggers;
|
||||
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes;
|
||||
* **750 weighted reversals** — 5 splits (0.5, 0.7, 0.9, 0.99, 0.1) × 5 staggers
|
||||
on a ±2200 W reversal. ⚠️ **This family did not exist in SAFETY-30's campaign
|
||||
and it is the one that dominates here**, for a mechanical reason: an
|
||||
asymmetric split gives one unit a much larger step than its sibling, so the
|
||||
big one is still ramping while the small one has settled — which is precisely
|
||||
the *proper subset* this check keys on.
|
||||
|
||||
Every run is executed a second time at a **roster of one** on the same stimulus.
|
||||
**The N=1 arm is the control**, and it is what separates *"a property of the
|
||||
check"* from *"a property of the second inverter"*.
|
||||
|
||||
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
|
||||
threshold:
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 100 W | 40.0 s (608) | 0.0 s (0) | 75.0 s (576) | **0.0 s (0)** |
|
||||
| 250 W | 55.0 s (490) | 0.0 s (0) | 60.0 s (480) | **0.0 s (0)** |
|
||||
| **500 W** | **20.0 s (206)** | **0.0 s (0)** | **30.0 s (368)** | **0.0 s (0)** |
|
||||
| 700 W | 20.0 s (125) | 0.0 s (0) | 20.0 s (174) | **0.0 s (0)** |
|
||||
| 1000 W | 15.0 s (70) | 0.0 s (0) | 15.0 s (111) | **0.0 s (0)** |
|
||||
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
|
||||
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
|
||||
|
||||
Three findings worth stating rather than leaving in the table:
|
||||
|
||||
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
|
||||
threshold.** That is the compatibility promise measured, not argued.
|
||||
2. **The flapping family is zero everywhere too** — and it is the family that
|
||||
defeated SAFETY-30's site window (980 s at two inverters, 1000 s at one, no
|
||||
window inside the billing quarter surviving it). It cannot touch this check,
|
||||
because a common stimulus makes both units short at the same time and the
|
||||
subset stops being proper. That is the proper-subset rule earning its place
|
||||
on a case it was not designed for.
|
||||
3. **The curve is NOT monotone** — 55.0 s at 250 W against 40.0 s at 100 W —
|
||||
and that is a property of the reduction rather than noise: lowering the
|
||||
threshold puts MORE units into the short set, which can turn a proper subset
|
||||
into the whole roster and **break** the run. Both the numerator and the
|
||||
denominator move. Do not read this table as a sensitivity curve.
|
||||
|
||||
#### The watts side is not a new option — it is `Tuning.saturation_w`
|
||||
|
||||
`main.py` hands the check `self.tuning.saturation_w` (500 W) and there is no
|
||||
`delivery_w` key anywhere. That is a derivation, not a saving:
|
||||
|
||||
* it is already **the gap at which `compute()` itself concludes the inverter is
|
||||
not following** and freezes the integrator. Below it the shipped control law
|
||||
calls the divergence ordinary tracking, and an alarm under a threshold the
|
||||
control law calls normal is an alarm about nothing;
|
||||
* `saturation_cycles` (3) consecutive cycles over that same gap are needed
|
||||
before the freeze engages, so **every cycle this counts is a cycle the freeze
|
||||
has already seen**. This is a reduction over the freeze's input, not a second
|
||||
opinion about it;
|
||||
* CFG-03 already tracks three places a default has to be kept in step. A fourth
|
||||
copy could drift, and the drift that matters has a direction: an operator who
|
||||
raises `saturation_w` has told the loop that a bigger gap is ordinary
|
||||
tracking, and a check that stayed put would then alarm on it.
|
||||
|
||||
**Rejected:** a `delivery_w` option, for the three reasons above. A margin above
|
||||
`saturation_w` (the way `circulation_w` sits 1.75× above its knee) was also
|
||||
rejected: there is no knee here — the table above is not monotone — so a margin
|
||||
would be a preference, and all the margin this check needs is in the window.
|
||||
|
||||
#### `delivery_hold_s: 185` — schema `int(30,900)`
|
||||
|
||||
Four bounds, and the value clears all four.
|
||||
|
||||
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
|
||||
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
|
||||
reversals, 0.0 s on flapping).
|
||||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||||
executing its share for less than the firmware's own veto window is a *link*
|
||||
fault the add-on already answers by dropping it from the split. Reporting
|
||||
inside that window would be a second, louder alarm about a state already
|
||||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||||
* **margin, from inverter B not existing — and it is MEASURED, not felt.** The
|
||||
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
|
||||
*different* second unit through the weighted family (750 runs each):
|
||||
|
||||
| B lag | B ramp | worst legitimate | runs naming |
|
||||
|---:|---:|---:|---:|
|
||||
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
|
||||
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
|
||||
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
|
||||
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
|
||||
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
|
||||
|
||||
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
|
||||
The shortfall lasts as long as the unit's first-order lag holds it more than
|
||||
`saturation_w` below its share; at these step sizes the ramp is never the
|
||||
binding term. So the number to carry margin against is B's lag, and a unit
|
||||
**four times slower than A** produces 90.0 s.
|
||||
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
|
||||
|
||||
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
|
||||
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
|
||||
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
|
||||
reader must not be able to think this was carried across — and not 150 or 200.
|
||||
|
||||
**What it costs, stated rather than hidden:** up to 185 s of a site running
|
||||
short by one unit's share before anybody is told. Against a fault that ran
|
||||
6 h 25 m unnoticed, and against a guard that takes **no action**, the window
|
||||
buys its margin with reporting latency and nothing else.
|
||||
|
||||
## 4. It reports. It does not act — and the strongest argument is for acting
|
||||
|
||||
Card requirement 3 asks which, and why. The answer is recorded with the
|
||||
evidence **against** it first, because "alarm-only" is the weaker-looking answer.
|
||||
|
||||
**Command it harder — no.** HW-01 settles it: the power stage executed its last
|
||||
instruction for 6 h 25 m through every command this add-on sent. A command is
|
||||
not a remedy for a box that is not listening.
|
||||
|
||||
**Stop the site, as SAFETY-31 does — no**, and the asymmetry with SAFETY-31 is
|
||||
exact. Circulating power is stopped because *this controller is one of the two
|
||||
participants*: removing our half halves the loop and un-hides the residue at a
|
||||
meter that was netting it to zero. Here this controller is **not** a participant,
|
||||
nothing is netting, and the sibling is the only thing still delivering. Parking
|
||||
it takes the site from "short by one unit" to "short by all of them".
|
||||
|
||||
**Re-allocate its share to the sibling — no, and NOT because the loop already
|
||||
does it.** That comfortable answer is false, and the rig says so. Dropping the
|
||||
wedged unit from `live` — the mechanism `live_units()` already uses for a dead
|
||||
link — is a real improvement at loads the site cannot cover on one unit's
|
||||
*share*:
|
||||
|
||||
| B wedged, per-unit cap 2000 W | kept in the split | dropped from the split |
|
||||
|---|---|---|
|
||||
| load 800 W | settles inside 100 W from cycle 6 | from cycle 8 |
|
||||
| load 1500 W | **800 W delivered, 700 W error, NEVER inside 100 W in 150 cycles** | 1500 W delivered, inside 100 W from cycle 14 |
|
||||
| load 2000 W | **800 W delivered, 1200 W error, never settles** | 2000 W delivered, inside 100 W from cycle 8 |
|
||||
|
||||
The mechanism is the saturation freeze doing its job on a site comparison that a
|
||||
non-delivering unit has poisoned: `|site target − site batt|` stays over
|
||||
`saturation_w` forever, so the target cannot rise and the sibling's *share*
|
||||
never grows. That is not three cycles of latency; it is the difference between a
|
||||
site that recovers and one that does not.
|
||||
|
||||
**What refuses it anyway** is that the only re-allocation this add-on can
|
||||
express is **all or nothing**. There is no way to say *"give the sibling the
|
||||
watts that did not arrive and leave this unit the ones that did"*. And a unit
|
||||
delivering **part** of its share is short by more than `saturation_w` too, with
|
||||
nothing here to explain it — so this detector names it, and cannot tell a 50 %
|
||||
derate from a wedged box, because no instrument this add-on has can:
|
||||
|
||||
| B derated to 50 %, load 3000 W, cap 2000 W | site delivers | B contributes | grid error |
|
||||
|---|---:|---:|---:|
|
||||
| kept in the split | 2400 W | 800 W | 600 W |
|
||||
| **dropped** | **2000 W** | **0 W** | **1000 W — 400 W worse** |
|
||||
|
||||
**SAFETY-42 names B in that run.** So the remedy would make the site worse, in a
|
||||
case the detector cannot rule out, on a live control law, driven by a brand-new
|
||||
alarm. A **partial** re-allocation is what would make acting safe, and the
|
||||
allocator has no way to express it today. That is a card, and this section is
|
||||
its evidence.
|
||||
|
||||
`test_delivery.py` part 5 asserts the decision as a **counterfactual** rather
|
||||
than by reading the code: the same fixture run with the report tripping and with
|
||||
it never tripping produces byte-identical grid, site target, shares and measured
|
||||
power on every cycle.
|
||||
|
||||
## 5. Requirement 4: the saturation freeze is untouched
|
||||
|
||||
`control.py` is not modified by this card, at all. The freeze still shapes the
|
||||
loop; this reports on it. `test_delivery.py` part 6 asserts what each
|
||||
neighbouring guard concludes on the same fixture — SAFETY-30 per-unit silent,
|
||||
SAFETY-30 site silent, SAFETY-31 identically zero, the freeze firing at the
|
||||
same counter on **both** arms — which is the honest version of "every existing
|
||||
guard is silent", including the one that is not.
|
||||
|
||||
## 6. Surviving a restart (SAFETY-34)
|
||||
|
||||
`persistent_notification` is **in memory**. Home Assistant restarts, the card is
|
||||
gone, and the fault runs on — precisely how the 2026-09-02 outage went unseen
|
||||
for 6 h 25 m with the detector correct throughout. It matters more here than
|
||||
anywhere else in this add-on, because SAFETY-02 and SAFETY-31 at least leave a
|
||||
**stopped** site for somebody to notice; this one leaves a site that looks like
|
||||
it is working.
|
||||
|
||||
* durable half: `unit/<name>/delivery_tripped` / `delivery_reason`, namespaced
|
||||
per unit for `store.unit_key()`'s reason, written **reason first**.
|
||||
* visible half: `_delivery_announced`, a per-**process** set of unit names,
|
||||
deliberately not persisted. A restart empties the set and keeps the latch, so
|
||||
the alarm is re-raised on the first cycle of every process — **one code path**
|
||||
for a fresh trip and for a restart into an existing one, `check_circulating()`'s
|
||||
argument.
|
||||
* **no flat mirror**, where SAFETY-02 has one. `store.py`'s rule 2 keeps the flat
|
||||
keys so a rollback still finds its latch, and the version being rolled back to
|
||||
drives exactly one actuator — a roster at which this check is inert.
|
||||
* one **notification id per unit**, built from the same `unit_slugs()` TEL-07
|
||||
announces the MQTT entities under. Two wedged inverters must produce two cards,
|
||||
not one replacing the other.
|
||||
* it **latches**, and not for SAFETY-02's reason. Nothing here removes its own
|
||||
evidence, because nothing here changes what is commanded. It latches so that a
|
||||
unit which stopped delivering for minutes and then resumed cannot erase its own
|
||||
record — the intermittent fault is the one an operator most needs told about.
|
||||
Cleared by the same **Start** press as the others.
|
||||
* the status row is `ok: False, warn: True` — this list's **caution**. Every
|
||||
other latched row means the site is at 0 W and a red cross is the truth; this
|
||||
one means the site is still running and quietly short, and a red cross would
|
||||
say the controller had stopped.
|
||||
|
||||
## 7. What this does NOT catch, said plainly
|
||||
|
||||
* **Charge shares are not graded.** The account gate needs a bound this
|
||||
controller reads in the commanded direction. There is one for discharge
|
||||
(`bms_soc_floor`, required of every roster entry since SAFETY-32) and **none**
|
||||
for charge. A pack in constant-voltage taper legitimately takes hundreds of
|
||||
watts less than it is told for tens of minutes, and arming that direction would
|
||||
fire on the taper of every §12 maintenance charge. The same fault in the other
|
||||
direction wants the same treatment; what it needs first is a per-pack charge
|
||||
ceiling this add-on does not have. **That is a card.**
|
||||
* **Every unit wedged at once is not caught.** The subset is not proper, and
|
||||
there is no instrument here that separates that from a plant which simply
|
||||
cannot deliver. The site is short at the meter in that case, which is where the
|
||||
existing guards can see it.
|
||||
* **A pack that refuses above its configured floor is reported.** That is
|
||||
deliberate: either the unit has stopped delivering, or `bms_soc_floor` does not
|
||||
describe that battery. The notification says both, in those words.
|
||||
* **A unit with a share of 0 W is not graded**, and neither is it a peer. With
|
||||
only one unit asked to discharge there is nothing to compare against.
|
||||
|
||||
## 8. Mutation ledger
|
||||
|
||||
Every run on a **freshly copied tree of its own, `__pycache__` purged and the
|
||||
count printed, mounted `:ro`, `python3 -B`** — MUTATION.md's rule, followed
|
||||
because the first SAFETY-30 battery reused one directory, a container mounted it
|
||||
mid-copy, read a missing `config.yaml` as a failing check, and the whole run was
|
||||
discarded. **The baseline is green** (23/23) or nothing below is a result. Byte
|
||||
deltas measured, not estimated; the `+0 B` ones are marked, since a
|
||||
size-preserving edit is what TEST-15 turned into a false survivor once already.
|
||||
|
||||
| # | file | mutation | Δ | result |
|
||||
|---|---|---|---|---|
|
||||
| M1 | `delivery.py` | the SoC account removed — a cold pack at its floor alarms | −31 B | **RED** — `test_delivery.py`. This is the card |
|
||||
| M2 | `delivery.py` | the proper-subset rule removed — a whole-site cause names every unit | −34 B | **RED** |
|
||||
| **M3** | `delivery.py` | **the `len(asked) < 2` fast path relaxed to `< 1`** | **+0 B** | **SURVIVOR — and an EQUIVALENT MUTANT.** See below |
|
||||
| M4 | `delivery.py` | the strict bound relaxed to `>=` — a gap the control law calls tracking is named | +1 B | **RED** |
|
||||
| **M5** | `delivery.py` | `share <= 0` relaxed to `share == 0` — charge shares graded | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then **RED**. See below |
|
||||
| M6 | `delivery.py` | `delivery_hold_s` 185 → a round 300 | **+0 B** | **RED** — the CFG-03 pin |
|
||||
| M7 | `main.py` | the watts threshold no longer `Tuning.saturation_w` (×4) | +6 B | **RED** |
|
||||
| M8 | `main.py` | the latch never persisted — nothing survives a restart | −35 B | **RED** |
|
||||
| M9 | `main.py` | `_delivery_announced` never set — one notification per cycle | −34 B | **RED** |
|
||||
| M10 | `main.py` | `peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor` used instead of the pack's own cut-out | +151 B | **RED** — the between-the-floors fixture |
|
||||
| **M11** | `main.py` | **`inv.is_live(now)[0]` replaced by a literal `True`** | −15 B | **SURVIVOR at review — GAP CLOSED**, then **RED**. See below |
|
||||
| M12 | `inverter.py` | the latch not restored from the store on start-up | −46 B | **RED** |
|
||||
|
||||
**11 of 12 red. One survivor, argued rather than left.**
|
||||
|
||||
### M5 was a real gap, and the two obvious checks did not close it
|
||||
|
||||
`test_delivery.py` already asserted `short_by(-2000, 0) is None` and
|
||||
`short_by(-2000, -100) is None` — and **both stayed green under the mutant**.
|
||||
For a charge share that is *under*-delivered, `share − measured` is negative and
|
||||
the `gap > 0` test answers `None` anyway, so those two inputs cannot separate
|
||||
`share <= 0` from `share == 0`. The one input that can is a unit moving **more**
|
||||
than its charge share — `-2000 W` told, `-3000 W` measured, `share − measured` =
|
||||
`+1000 W` — which the mutant reports as a shortfall. It is not one; it is
|
||||
something else driving that pack, which is SAFETY-30's and SAFETY-31's question.
|
||||
The check now uses that input and the mutant is red.
|
||||
|
||||
This is MUTATION.md's third failure mode in miniature: **the assertion ran, the
|
||||
sentence was true, and the data could not reach the failure it named.**
|
||||
|
||||
### M11 was a wiring gap the pure checks structurally could not see
|
||||
|
||||
`account(live=False, …)` was already graded — as a **function**. What was not
|
||||
graded was that `cycle()` actually *asks* `inv.is_live(now)`, and replacing that
|
||||
call with a literal `True` left every check green, because **no fixture had a
|
||||
unit whose writes were being rejected**. A dropped unit still has its last
|
||||
*accepted* command standing on the wire (`write_setpoint` leaves `last_write`
|
||||
alone on a rejected write) and still measures nothing, so it looks exactly like a
|
||||
wedged one to every number this check reads — and reporting it would be the
|
||||
second, louder alarm about a state already being handled that this module takes
|
||||
its 30 s floor from. `test_delivery.py` part 6 now builds that unit (with
|
||||
`rejected_since` back-dated past `LINK_STALE_S`, because the fixture runs in two
|
||||
seconds and the timer is `test_inverter.py`'s to grade) and the mutant is red.
|
||||
|
||||
### M3 is recorded as a survivor because it is one — and it is EQUIVALENT
|
||||
|
||||
`len(asked) < 2` → `len(asked) < 1`. With **one** asked unit, `unaccounted` is a
|
||||
subset of a one-element set, so it is either empty — `not unaccounted` returns
|
||||
`{}` — or equal to `asked` — `len(unaccounted) == len(asked)` returns `{}`.
|
||||
There is no third case, and with **zero** asked units both spellings return `{}`
|
||||
as well. **No input separates them**, so no check can, and one written to try
|
||||
would be a check about nothing.
|
||||
|
||||
⚠️ **And finding it corrected the module's own docstring, which is the more
|
||||
useful half.** That paragraph claimed `len(asked) < 2` was *what makes N=1
|
||||
inert*. It is not: the **proper-subset rule** is, and the line is a fast path and
|
||||
a signpost. A false attribution of the mechanism in the file's own prose is
|
||||
exactly what this repository keeps finding, and a mutation that survives is how
|
||||
it surfaced. The line stays — it tells a reader at a glance that a one-inverter
|
||||
install never reaches any of this — with the equivalence written down beside it,
|
||||
the way `circulation.circulating_w()` records its own `max(0.0, …)`.
|
||||
|
||||
### Not separately mutated, and said rather than left implicit
|
||||
|
||||
`account()`'s `parked` branch is graded as a function (part 1) but its **wiring**
|
||||
— `parked=inv.sign_wd.tripped` — is not mutated on its own. Part 6's reversed-unit
|
||||
fixture exercises the path end to end and asserts the account it produces, which
|
||||
is weaker than a mutation and is stated as such.
|
||||
|
||||
## 9. Round 2 — the thresholds this check reads, and which of them are pinned at a **changed** config
|
||||
|
||||
Everything in §8 mutated the *logic*. This section asks a narrower question that
|
||||
§8 never asked, and it found two live survivors of the whole 23-file suite:
|
||||
|
||||
> for each number `main.py` hands this reduction, is the check still red when
|
||||
> that number is replaced by a **literal equal to the value the fixtures use**?
|
||||
|
||||
That is not pedantry about style. Two of these numbers are **options**, and
|
||||
`delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in `config.yaml`
|
||||
both make a promise about them *in shipped, operator-readable prose*: there is no
|
||||
fourth copy of a default to keep in step, and no way for the two to drift,
|
||||
because an operator who raises `saturation_w` has told the loop that a bigger gap
|
||||
is ordinary tracking. A literal at the call site keeps every fixture green and
|
||||
removes exactly that promise — and the promise is why the design was chosen over
|
||||
a fourth option key.
|
||||
|
||||
### The enumeration
|
||||
|
||||
| # | what the call site reads | mutated to | Δ | before round 2 | now |
|
||||
|---|---|---|---|---|---|
|
||||
| R5 | `self.tuning.saturation_w` | `250.0` | −19 B | **SURVIVED all 23 suites** | red — part 4b(a) |
|
||||
| R6 | `bms_floor=inv.bms_soc_floor` | `10.0` | −13 B | **SURVIVED all 23 suites** | red — part 4b(b) |
|
||||
| R7 | `arm(self.delivery_cfg)` | `arm(DeliveryConfig())` | −1 B | red | red — part 4 already ran at a hold of 1 s, not the shipped 185 s |
|
||||
| R8 | `inv.is_live(now)[0]` | `inv.is_live(now, 3600.0)[0]` | +8 B | red | red — part 6's link-dead fixture |
|
||||
| R9 | `bms_floor=inv.bms_soc_floor` | the **conjoined** floor, `max(pack cut-out, site peak_soc_floor)` | +105 B | red | red — and now red **twice**: the pre-existing between-floors check at shipped floors, and part 4b(b)'s shallow arm at a cut-out that is not shipped |
|
||||
|
||||
**R7 and R8 were already pinned across a changed setting, and R5 and R6 were
|
||||
not.** The difference is entirely in the fixtures, not in the code:
|
||||
`delivery_hold_s` had to be moved off 185 s for any of this file to run inside a
|
||||
two-second test, and part 6 had to back-date `rejected_since` past
|
||||
`LINK_STALE_S` to build a dropped unit at all. Nothing ever forced
|
||||
`saturation_w` off 500 or a `bms_soc_floor` off 10 — so those two sat at the
|
||||
shipped value in every fixture, and a literal equal to the shipped value is
|
||||
invisible to a fixture that only ever uses the shipped value. **That is this
|
||||
repository's recurring defect stated in one line: a fixture whose values cannot
|
||||
reach the failure it names.**
|
||||
|
||||
`LINK_STALE_S` (R8) is the one number here with **no option behind it** — it is a
|
||||
module constant in `inverter.py` and no test reassigns it — so "pinned across a
|
||||
changed config" is not available for it, and is not claimed. What part 6 pins is
|
||||
that *widening* the window changes the answer. Spelling `is_live(now, 30.0)` at
|
||||
the call site would be an equivalent mutant today and would only drift if the
|
||||
constant moved, which is a source change and not a config change.
|
||||
|
||||
### Why R5 and R6 are not equivalent mutants — the inputs that separate them
|
||||
|
||||
Both are separating inputs found by asking where the shipped answer and the
|
||||
literal's answer can differ, and both are configs the shipped schema accepts.
|
||||
|
||||
**R5, watts.** Two copies of inverter A, B wedged, `saturation_w: 1800` (inside
|
||||
its own schema `int(100,2000)`), load 1600 W. B's standing share and its
|
||||
shortfall then land strictly **between** `250.0` and the configured 1800 W:
|
||||
|
||||
* head → `named = {}` — the operator raised the threshold and this check moved
|
||||
with them, which is the whole promise;
|
||||
* R5 → `named = {inverter-b}` — an alarm at a gap the control law has been told
|
||||
is ordinary tracking.
|
||||
|
||||
At the shipped `saturation_w: 500` **both** name it, which is why every fixture
|
||||
in part 4 is blind: their gaps are all above both thresholds.
|
||||
|
||||
**R6, percent.** The literal is invisible because `bms_soc_floor: 10` is what
|
||||
`config.yaml`'s own `inverters:` example states and what every fixture in
|
||||
`test_delivery.py` used. Two arms, on either side of it, both inside the schema
|
||||
`int(0,50)` and both `validate.py` rule-8-valid:
|
||||
|
||||
* a pack at **15 %** against its own **25 %** cut-out is genuinely cold. Head
|
||||
stays silent; R6 grades it against 10 %, finds it "permitted", and **names an
|
||||
empty pack** — the crying-wolf failure this card exists to remove.
|
||||
* a pack at **8 %** against its own **5 %** cut-out is genuinely wedged. Head
|
||||
names it; R6 grades it against 10 %, calls it a cold pack, and **loses the
|
||||
fault**.
|
||||
|
||||
One arm on each side is what leaves no constant at all: a single deeper arm
|
||||
kills `10.0` but not `50.0`, a single shallower arm kills `10.0` but not `0.0`.
|
||||
The two arms are byte-identical in the power domain on every cycle — the
|
||||
configured cut-out is the only thing that differs, which is §3.1's claim made
|
||||
again at cut-outs that are not the shipped one.
|
||||
|
||||
### What is still not pinned this way, said rather than left implicit
|
||||
|
||||
`parked=inv.sign_wd.tripped` and `standing[inv.name]`/`inv.batt` are wiring, not
|
||||
thresholds: there is no "value the fixture happens to use" for a boolean or for a
|
||||
measurement, so this question does not apply to them, and §8's last note already
|
||||
records that the `parked` wiring is graded end-to-end rather than by mutation.
|
||||
+154
-1
@@ -473,7 +473,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||
| `step_w` | 10 | Quantisation |
|
||||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1** |
|
||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1**. Every value this file accepts (1-10) is below the freeze's own release bound of 12 cycles, so no accepted setting can stop the block engaging - see SAFETY-13, below |
|
||||
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
||||
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
||||
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
|
||||
@@ -485,6 +485,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `unit_sign_disagree_s` | 85 | **Two or more inverters only.** How long one unit's command and its own power must stay opposed before that unit is parked |
|
||||
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
|
||||
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
|
||||
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
|
||||
|
||||
#### The sign-disagreement failsafe
|
||||
|
||||
@@ -584,6 +585,70 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
|
||||
10.5 % and 0.0 %. There is no reason to move either value without a capture that
|
||||
says so.
|
||||
|
||||
|
||||
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
|
||||
|
||||
The two checks above both key on **direction**. A unit sitting at **0 W against
|
||||
a non-zero share** has no direction: it is not pushing the wrong way, it is
|
||||
**absent**. So the per-unit sign gate never opens (it needs opposition on both
|
||||
sides of `unit_sign_disagree_w`), the site sum still agrees with the meter in
|
||||
direction, and circulating power is exactly **zero** — that quantity needs two
|
||||
units pulling *against* each other, and one of these is pulling nowhere.
|
||||
|
||||
The only thing that does fire is the **saturation freeze**, and this add-on's own
|
||||
code calls that an alarm operators learn to ignore, because a loop can be legally
|
||||
saturated all day: a cold pack, a curtailed inverter, an export limit.
|
||||
|
||||
So `delivery_hold_s` grades a different quantity — **how many watts of a
|
||||
discharge share did not arrive** — and makes it readable with a reduction that
|
||||
only exists once there is more than one inverter:
|
||||
|
||||
> a unit is named while it is short of its share by more than `saturation_w`,
|
||||
> **with nothing this add-on can read to explain it**, and the units in that
|
||||
> state are **fewer than the units that were asked to discharge**.
|
||||
|
||||
Both halves matter, and the second is why this cannot fire on a
|
||||
one-inverter install at all:
|
||||
|
||||
- **"nothing explains it"** is what keeps a legitimately empty pack out. A pack
|
||||
at or below its own `bms_soc_floor` cannot discharge, and that is the *only*
|
||||
fact this add-on holds that distinguishes an empty pack from a wedged one —
|
||||
the two are otherwise identical at the unit and at the meter, watt for watt.
|
||||
A unit the add-on has already dropped from the split, or parked by a sign
|
||||
trip, is likewise accounted for.
|
||||
- **"fewer than were asked"** is what keeps a whole-site cause out. A derate this
|
||||
add-on cannot see — thermal, grid-code, DC-side — hits every unit. One box that
|
||||
has stopped listening does not. If *every* unit that was asked is short and
|
||||
unexplained, nothing is reported: there is no instrument here that tells that
|
||||
from a plant which simply cannot deliver.
|
||||
|
||||
**It reports. It does not act.** Commanding an inverter that has stopped
|
||||
listening achieves nothing — that happened on this hardware once for 6 h 25 m —
|
||||
and the only re-allocation this add-on can express is all-or-nothing, which
|
||||
would throw away the output of a unit delivering *part* of its share. So the
|
||||
loop is untouched, the sibling keeps regulating, and the site keeps running
|
||||
**short by that unit's share** until somebody looks. The status page shows it as
|
||||
a caution rather than a failure for exactly that reason: the controller has not
|
||||
stopped.
|
||||
|
||||
The report **survives a Home Assistant restart** (`unit/<name>/delivery_tripped`
|
||||
in the add-on's own state file) and the notification is re-raised on the next
|
||||
start, because a `persistent_notification` is in memory and that is precisely how
|
||||
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
|
||||
other latches.
|
||||
|
||||
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure,
|
||||
and it **displaces** `Running — holding grid at zero`. That last sentence is a
|
||||
claim the site cannot make while a unit is absent, and a page that goes on
|
||||
reading healthy while the fault runs is the whole shape this check exists to
|
||||
remove.
|
||||
|
||||
**Charge shares are not graded**, and that is a stated gap rather than an
|
||||
oversight: there is no configured charge ceiling anywhere in this add-on, so a
|
||||
pack in constant-voltage taper — which legitimately takes hundreds of watts less
|
||||
than it is told, for tens of minutes — could not be explained, and arming that
|
||||
direction would fire on the taper of every maintenance charge.
|
||||
|
||||
#### Saturation is counted in cycles, not seconds
|
||||
|
||||
The specification states the saturation window as **"> 10 s"**. This add-on counts
|
||||
@@ -628,6 +693,94 @@ correct toward that wrong value instead of holding a stale command. It is bounde
|
||||
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||||
safety property.
|
||||
|
||||
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
|
||||
|
||||
While the loop is frozen the command's **magnitude may fall but never rise**. That is
|
||||
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
|
||||
nothing commanded there is no magnitude left to fall, so a charge cannot begin. That
|
||||
refusal is deliberate. Commanding 0 W while the inverter reports hundreds of watts
|
||||
means something is driving the actuator that this add-on did not ask, and starting to
|
||||
push power the other way into it is how two controllers end up fighting over one
|
||||
actuator — the configuration §1.5 of the specification rules out.
|
||||
|
||||
**What was missing was an end to it.** Nothing cleared the block: not the error growing,
|
||||
not the integrator moving, not time. The exit is now explicit, and *which* freezes get
|
||||
one is the decision:
|
||||
|
||||
| the inverter is… | can it clear itself? | what happens now |
|
||||
|---|---|---|
|
||||
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap | **yes.** The command is pinned at something the inverter can reach, so it catches up and the freeze clears | unchanged: frozen for as long as it lasts, with no exit |
|
||||
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **not necessarily.** If another writer is on the actuator, clearing the freeze needs the inverter to track *our* command and the other writer is what stops it, so nothing ends it | the freeze releases once the divergence has outlasted the plant that could otherwise have explained it — see *what `freeze-released` actually tells you*, below, because this row is also where our own ramp lands |
|
||||
|
||||
The second row's threshold is the plant's own measured settling time — **the slower of
|
||||
the two directions it was measured in.** The 2026-08-22 step test timed the same 1.78 kW
|
||||
step both ways: about 18 s to settle going up, about 23 s coming back down. 23 s is the
|
||||
one that belongs here, because the question this bound answers is *how long may the plant
|
||||
still be the explanation*, and the safe answer is to keep believing the plant for as long
|
||||
as the plant has ever been seen to take. At the measured 2 s meter tick that is
|
||||
**12 cycles** (`control.py`, `SETTLE_CYCLES`).
|
||||
|
||||
⚠️ **The first version of this feature used 18 s, and 9 cycles was wrong in the direction
|
||||
that accuses.** Nine cycles is three short of the plant's own down-ramp, so an ordinary
|
||||
load step on a **single** inverter — the command falling through zero while the pack is
|
||||
still coasting the old way — could produce `freeze-released` with nothing else on the bus
|
||||
at all. Driven in the container against a model of the measured plant, one inverter and
|
||||
no second writer anywhere: over 272 load-step pairs the 9-cycle bound produced a release
|
||||
in 62 of them and the shipped 12-cycle bound in 2.
|
||||
|
||||
**Releasing the output freeze does not unfreeze the loop.** The *integrator* freeze is
|
||||
untouched, so the command is free to move only as far as an accumulator that stopped
|
||||
accumulating cycles ago, one slew step per cycle. Measured on the simulator at the live
|
||||
install's shape (`max_w 5000`, `slew_w 400`, `target_grid_w -10`), with another writer
|
||||
holding the pack at +700 W and 2 kW of export standing: the command holds at 0 W while
|
||||
the counter climbs, starts on the **twelfth** saturated cycle, walks 0 → −400 → −800, and
|
||||
settles at **−1190 W** against a frozen integrator of −1194 W — not at the 5000 W rail.
|
||||
Before this change the same run held 0.0 W on every cycle, for as long as the other
|
||||
writer stayed.
|
||||
|
||||
⚠️ **The site still does not reach its target in that state, and it is not supposed
|
||||
to.** What the exit buys is that the battery stops being idle while the site exports;
|
||||
what it does not buy is correct regulation against an actuator somebody else is also
|
||||
writing to. A standing grid error with another writer on the bus is the symptom of a
|
||||
configuration this add-on refuses to support, not a tuning problem.
|
||||
|
||||
#### What `freeze-released` in the action log actually tells you
|
||||
|
||||
**It says: the inverter's power did not match the command for longer than this plant has
|
||||
ever been measured to take to settle.** That is all it says. It is a reason to go
|
||||
looking; it is not a finding, and in particular it is **not** proof that a second writer
|
||||
exists.
|
||||
|
||||
The test behind it compares the reading against **one cycle's** command, and the pack
|
||||
carries more than one cycle of history. So our own power, arriving late, can look like
|
||||
somebody else's:
|
||||
|
||||
- **A big, fast load step is the ordinary innocent cause.** The command falls through
|
||||
zero while the pack is still coasting in the old direction, and for a few cycles the
|
||||
reading has the sign the command *used* to have. Measured, one inverter, nothing else
|
||||
on the bus: eight cycles after a +1990 W command the command was 0 and the pack still
|
||||
read +522.3 W.
|
||||
- **A plant slower than the one that was measured does it too.** The 12-cycle bound is
|
||||
derived from a 23 s down-ramp. A colder or larger pack that takes longer than that will
|
||||
produce releases on its own, and no constant read off the 2026-08-22 measurement can
|
||||
prevent it. In the same container sweep, holding the bound at 12 and lengthening only
|
||||
the plant's down-ramp, single-inverter releases went from 8 of 320 scenarios at the
|
||||
measured 23 s to 59 of 320 at 34 s and 268 of 320 at 50 s.
|
||||
|
||||
**So, in order:** if you see `freeze-released` **once, during or just after a large load
|
||||
or PV swing**, that is most likely the loop's own ramp and needs nothing. If you see it
|
||||
**standing** — cycle after cycle, at a steady load, with a standing grid error the
|
||||
battery is not correcting — then look for the second writer: another controller, a
|
||||
scheduler in the inverter's own app, or an installer tool left connected. That is the
|
||||
configuration §1.5 of the specification rules out, and it is the one this exit exists to
|
||||
stop the loop stalling on forever.
|
||||
|
||||
⚠️ **No `saturation_cycles` you can set removes the block.** This file accepts 1-10, and
|
||||
all of those are below the 12-cycle release bound, so the freeze always engages before
|
||||
its own exit is due. (The first version of this feature had the bound at 9, where a
|
||||
setting of 9 or 10 *did* silently delete the block; moving the bound to 12 closed that
|
||||
by construction rather than by warning about it.)
|
||||
|
||||
#### Why `target_grid_w` is not zero
|
||||
|
||||
The deadband is a one-way ratchet: any resting point inside it holds until
|
||||
|
||||
@@ -16,6 +16,7 @@ the short version is in the comments below. Do not "clean this up" - each rule
|
||||
exists because its absence produced a specific, observed failure.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -65,6 +66,17 @@ from dataclasses import dataclass
|
||||
# validate.py refuse the impossible case only, never "close
|
||||
# to the rail".
|
||||
#
|
||||
# ⚠️ AND THAT CHOICE IS LOCAL TO THIS BOUND. §5.10 MEASURED THE STEP IN BOTH
|
||||
# DIRECTIONS, so "the plant's settling time" is a PAIR and not a number, and
|
||||
# which half is the safe one depends on which way being wrong hurts. Taking
|
||||
# 18 for a REFUSAL is right: the looser number refuses less. Taking 18 for a
|
||||
# question of the form "how long does the plant remain a candidate
|
||||
# explanation" is WRONG, because the looser number stops believing the plant
|
||||
# too early - and there the answer is a false accusation. SAFETY-13 asks that
|
||||
# second question and so reads PLANT_SETTLE_DOWN_S below, deliberately not
|
||||
# this constant. Anything else added here has to say which of the two it
|
||||
# means and why.
|
||||
#
|
||||
# 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
|
||||
@@ -86,6 +98,99 @@ 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
|
||||
|
||||
# The other half of the same measurement: the descent of the same 1.78 kW step,
|
||||
# ~23 s from a -1784 W export excursion back into the deadband (goodwe/NOTES.md
|
||||
# §5.10, the same run, the same day, the same plug). It is slower than the ramp
|
||||
# up, which is the ordinary asymmetry of a pack that is pushed one way and
|
||||
# coasts the other. Nothing above this line reads it; SAFETY-13 below does.
|
||||
PLANT_SETTLE_DOWN_S = 23.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-13. HOW LONG THE PLANT STAYS A CANDIDATE EXPLANATION.
|
||||
#
|
||||
# The output freeze below stops the command growing while the inverter is not
|
||||
# following. Two completely different things produce that divergence and the
|
||||
# freeze cannot tell them apart on magnitude alone:
|
||||
#
|
||||
# (a) THE PLANT HAS NOT CAUGHT UP YET, or it is at a limit - a cold pack, a
|
||||
# curtailment, an export cap. Asking for more is pointless, and the
|
||||
# freeze is right to hold. It also CLEARS ITSELF: the command is pinned
|
||||
# at something the inverter can do, so the inverter catches up to it and
|
||||
# `saturated_now` goes false.
|
||||
#
|
||||
# ⚠️ AND (a) INCLUDES THE CASE `unasked_power()` BELOW CANNOT SEE AS (a):
|
||||
# the command has fallen THROUGH ZERO and the pack is still coasting in
|
||||
# the old direction, so the reading has the sign the command used to have
|
||||
# and not the one it has now. That is this loop's own power, arriving
|
||||
# late. It is the reason the time bound is what does the work here and
|
||||
# the predicate alone is not enough.
|
||||
#
|
||||
# (b) SOMETHING ELSE IS DRIVING THE ACTUATOR - the §1.5 non-goal, two writers
|
||||
# on one actuator. The freeze CANNOT clear itself here, because clearing
|
||||
# it needs the inverter to track OUR command and the other writer is what
|
||||
# stops it. Nothing ends this but the other writer going away.
|
||||
#
|
||||
# ⚠️ SO THE BOUND IS ONLY EVER A REASON TO STOP BELIEVING (a). It does not
|
||||
# establish (b) and nothing below claims it does: what it says is that the
|
||||
# plant has run out of alibi, and (b) is then the only explanation this loop
|
||||
# has left - not the only one there is. Everything downstream of the release is
|
||||
# written to be safe when that inference is wrong, because sometimes it is.
|
||||
#
|
||||
# Case (a) is bounded by the plant, and the plant was measured:
|
||||
#
|
||||
# SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)
|
||||
#
|
||||
# ⚠️ THE **DOWN** NUMBER, AND THE DIRECTION IS THE WHOLE POINT. §5.10 measured
|
||||
# the same step both ways - 18 s up, 23 s down - and SAFETY-39 takes 18 because
|
||||
# 18 is the LOOSER bound for a REFUSAL. This is the opposite job. Here the
|
||||
# question is "how long may the plant still be the explanation", and the
|
||||
# conservative answer is the LONGER of the two: believe the plant for as long
|
||||
# as the plant has ever been observed to take. Deriving this from 18 gives 9,
|
||||
# which is three cycles SHORT of the plant's own measured down-ramp - and being
|
||||
# short here does not refuse anything, it ACCUSES. Measured, single inverter,
|
||||
# no second writer anywhere, 1280 closed-loop scenarios: at 9 the exit fires in
|
||||
# 113 of them and at 12 in 24. The direction of that error is why the pair is
|
||||
# now two named constants instead of one.
|
||||
#
|
||||
# ⚠️ AND IT IS NO LONGER THE RECIPROCAL OF SLEW_RAIL_FRAC. It was, at 9, and
|
||||
# that coincidence read as a derivation - two different questions that happened
|
||||
# to share an answer while both were being asked of the same half of a two-
|
||||
# sided measurement. They are 9 and 12 now. If they ever coincide again it is
|
||||
# arithmetic, not agreement.
|
||||
#
|
||||
# ⚠️ IT INHERITS SAFETY-39's CAVEAT ABOUT LOOP_TICK_S, in the opposite
|
||||
# direction, which is why it is repeated rather than cross-referenced. A meter
|
||||
# SLOWER than 2 s makes these 12 cycles cover MORE than 23 s, so the freeze
|
||||
# holds longer than the plant needs: that costs energy - see the case in
|
||||
# DOCS.md - and never safety. A meter FASTER than 2 s makes them cover LESS,
|
||||
# so the freeze can release while the plant is genuinely still moving. That one
|
||||
# is bounded twice over and by neither this constant nor luck: the INTEGRATOR
|
||||
# freeze is not released with the output freeze, so the command can only reach
|
||||
# the integrator's frozen value, and the output clamp bounds that in turn.
|
||||
#
|
||||
# ⚠️ AND A PLANT SLOWER THAN THE ONE THAT WAS MEASURED DOES THE SAME THING,
|
||||
# which no constant derived from a measurement can fix. In the same 1280
|
||||
# scenarios, holding the bound at 12 and lengthening only the plant's down-ramp
|
||||
# past the measured 23 s, the exit starts firing again on a single inverter:
|
||||
# 8/320 at 23 s, 59/320 at 34 s, 268/320 at 50 s. A re-measured plant means a
|
||||
# re-derived constant here, exactly as for the two above it.
|
||||
SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
|
||||
|
||||
# The saturation counter's ceiling, one above the longest bound read from it. A
|
||||
# counter capped BELOW that bound would silently make the bound unreachable -
|
||||
# i.e. delete the exit condition - so it is written against SETTLE_CYCLES and
|
||||
# not as a literal, and it moves when SETTLE_CYCLES moves.
|
||||
#
|
||||
# ⚠️ IT WAS 10, WHICH IS ALSO config.yaml's MAXIMUM FOR `saturation_cycles`,
|
||||
# AND THAT COLLISION IS GONE. At a bound of 9 the largest accepted
|
||||
# `saturation_cycles` (10) sat ABOVE the exit, so a legal configuration could
|
||||
# push the freeze's engagement past its own release and the no-start block
|
||||
# stopped existing. At 12 every accepted value 1..10 is strictly below the
|
||||
# bound, so the freeze always engages before its exit is due and no accepted
|
||||
# configuration can delete the block. `test_control.py` pins that at the schema
|
||||
# edge rather than in prose, because it is a property of two files agreeing.
|
||||
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -185,6 +290,71 @@ def slew_limit_w(tuning: Tuning) -> float:
|
||||
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
|
||||
|
||||
|
||||
def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
|
||||
"""True when `actual_w` cannot be explained by THIS cycle's command not being met.
|
||||
|
||||
SAFETY-13. The one question the saturation magnitude test cannot answer:
|
||||
is the inverter FAILING TO REACH what we asked, or is it doing something
|
||||
the command in front of it does not account for?
|
||||
|
||||
⚠️ THE ANSWER IS PHYSICAL, NOT STATISTICAL, WHICH IS WHY IT IS A PREDICATE
|
||||
AND NOT A THRESHOLD. A limit can only make the inverter do LESS than the
|
||||
command, in the command's own direction. It cannot make it overshoot the
|
||||
command, and it cannot make it produce power of the OPPOSITE sign. So the
|
||||
whole set of readings a limit can produce is the closed interval spanned by
|
||||
0 and `prev_w`, widened by `saturation_w` for measurement noise, ramp lag
|
||||
and the inverter's own idle draw.
|
||||
|
||||
⚠️ WHAT IT DOES NOT SAY - AND AN EARLIER VERSION OF THIS DOCSTRING DID -
|
||||
IS THAT ANYTHING OUTSIDE THE INTERVAL WAS PUT THERE BY SOMETHING OTHER THAN
|
||||
THIS LOOP. THAT IS FALSE AND IT WAS MEASURED FALSE. This predicate has no
|
||||
memory: `prev_w` is one cycle's command, while the plant carries every
|
||||
command before it. Drive ONE inverter, with nothing else on the bus, from a
|
||||
discharge down through zero, and the pack is still coasting in the old
|
||||
direction after the command has crossed. Measured in the container, one
|
||||
`compute()` against one plant: eight cycles after a +1990 W command the
|
||||
command is 0 and the pack still reads +522.3 W, so
|
||||
`unasked_power(0, 522.3, 500)` is True - and every watt of that 522.3 was
|
||||
asked for by this loop. In 1280 single-inverter closed-loop scenarios with
|
||||
no second writer in them at all, the exit built on this predicate fires in
|
||||
24 of them.
|
||||
|
||||
So what it guarantees is exactly the sentence at the top: this cycle's
|
||||
command does not account for this cycle's reading. What it CANNOT do is
|
||||
tell "somebody else is writing" from "we are". That gap is the reason
|
||||
SETTLE_CYCLES is derived from the plant's slower direction, why the caller
|
||||
checks both, and why the reason string it produces is a place to start
|
||||
looking rather than a finding.
|
||||
|
||||
Worked, at `saturation_w = 500`:
|
||||
|
||||
prev +2000, actual +700 -> inside [0, 2000] a limit. False.
|
||||
prev -5000, actual -700 -> inside [-5000, 0] a limit. False.
|
||||
prev 0, actual +700 -> outside [-500, 500] unasked. True.
|
||||
prev -800, actual +700 -> outside [-1300, 500] unasked. True.
|
||||
|
||||
⚠️ AT `prev_w == 0` THIS IS EXACTLY `saturated_now`, and that is not a
|
||||
coincidence to be tidied away - it is the reason the freeze's zero case is
|
||||
the one the card was written about. With nothing commanded there is no
|
||||
interval left for a LIMIT to live in, so at zero this test cannot be
|
||||
answered by "the inverter is not reaching what we asked" at all. It can
|
||||
still be answered by "the inverter has not finished leaving what we asked
|
||||
for LAST time", which is the coast-down above and is why zero needs the
|
||||
time bound as much as any other `prev_w` does.
|
||||
|
||||
⚠️ IT IS NOT A DETECTOR AND MUST NOT BE ALARMED ON. A saturated inverter
|
||||
whose reading is stale, wrong-signed or simply noisy lands outside the
|
||||
interval too, and so does our own ramp, above. All this predicate decides
|
||||
is whether the output freeze gets an EXIT; being wrong about it costs at
|
||||
most a bounded command that the frozen integrator and the output clamp both
|
||||
still bound. SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on
|
||||
sign, and it is deliberately a different test with a different threshold.
|
||||
"""
|
||||
lo = min(0.0, float(prev_w)) - abs(float(saturation_w))
|
||||
hi = max(0.0, float(prev_w)) + abs(float(saturation_w))
|
||||
return not (lo <= float(actual_w) <= hi)
|
||||
|
||||
|
||||
def compute(
|
||||
prev_w: float,
|
||||
grid_w: float,
|
||||
@@ -232,7 +402,7 @@ def compute(
|
||||
# clockless; the remaining ceiling is that saturation_cycles expresses a
|
||||
# bound in seconds rather than an exact number of them.
|
||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
||||
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
||||
sat_count = min(sat_count + 1, _SAT_COUNT_CAP) if saturated_now else 0
|
||||
frozen = sat_count >= tuning.saturation_cycles
|
||||
|
||||
# --- deadband ----------------------------------------------------------
|
||||
@@ -346,8 +516,69 @@ def compute(
|
||||
# implementation: commanding 0 while the inverter reports >500 W means
|
||||
# something else is driving the bus, and that is not the moment to
|
||||
# start pushing power the other way.
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
#
|
||||
# --- SAFETY-13: ...AND IT IS NOT THE MOMENT FOREVER EITHER ----------
|
||||
# Everything above stands. What was missing is an END. Held at
|
||||
# `prev_w == 0` this is a refusal to START, and nothing in this
|
||||
# function or above it clears it - not the error growing, not the
|
||||
# integrator moving, not time. Measured on release/1.0 @ c8dcb38, at
|
||||
# the live install's shape (max_w 5000, slew_w 400, target_grid_w -10):
|
||||
# a site resting in the deadband with another writer holding the pack
|
||||
# at +700 W reaches sat_count 3 with the command at 0, and 0 W is then
|
||||
# a FIXED POINT - a 2 kW export arriving afterwards leaves the command
|
||||
# at exactly 0.0 W on every subsequent cycle, with the integrator
|
||||
# standing at -1194 W the whole time.
|
||||
#
|
||||
# ⚠️ AND THE ZERO CASE IS NOT SPECIAL, WHICH IS WHY THE FIX IS NOT
|
||||
# SPECIAL EITHER. The same run pinned at prev_w == -800 W, against its
|
||||
# own integrator standing at -2388 W, for as long as it ran.
|
||||
# `prev_w == 0` is only the instance where the pinned magnitude happens
|
||||
# to be zero - i.e. where the standing cost is the whole of what the
|
||||
# battery could have done.
|
||||
# A rule written for the zero case alone would have left the other
|
||||
# instances exactly as unbounded as it found them.
|
||||
#
|
||||
# ⚠️ WHAT DECIDES IS NOT HOW LONG, IT IS WHETHER THE FREEZE CAN EVER
|
||||
# END ON ITS OWN. See SETTLE_CYCLES and `unasked_power()` at the top:
|
||||
# a divergence a LIMIT could produce clears itself once the inverter
|
||||
# catches up to the pinned command, so it needs no exit and gets none -
|
||||
# unbounded, exactly as shipped. A divergence a limit CANNOT produce
|
||||
# does not clear itself by being waited on, so it gets one.
|
||||
#
|
||||
# ⚠️ AND THAT IS NOT THE SAME SENTENCE AS "THE OTHER WRITER". An
|
||||
# earlier version of this comment said the two conditions below fire
|
||||
# only in case (b); they do not, and the sweep says so. Our own plant
|
||||
# coasting through a command that has just crossed zero satisfies
|
||||
# `unasked_power()` too, because that predicate compares against ONE
|
||||
# cycle's command and the pack remembers more than one - measured, a
|
||||
# single inverter with nothing else on the bus reaching this branch in
|
||||
# 24 of 1280 closed-loop scenarios. `SETTLE_CYCLES` is what keeps that
|
||||
# rare rather than routine (113 of the same 1280 at the old bound of
|
||||
# 9), and it does it by believing the plant for as long as the plant
|
||||
# has ever been measured to take. It is a bound on how long we wait,
|
||||
# not a proof about what we found.
|
||||
#
|
||||
# ⚠️ SO THE RELEASE IS WRITTEN TO BE SURVIVABLE WHEN IT IS WRONG, and
|
||||
# that is the paragraph below, not this one: what a false release costs
|
||||
# is one slew step per cycle toward a frozen integrator. If it ever has
|
||||
# to cost less than that, the thing to change is what happens after the
|
||||
# release, not the confidence of the sentence before it.
|
||||
#
|
||||
# ⚠️ RELEASING IS NOT UNFREEZING. The INTEGRATOR freeze above is not
|
||||
# lifted, and that is what makes this bounded rather than merely
|
||||
# different: the command is free to move, but only as far as an
|
||||
# integrator that stopped accumulating cycles ago, through the same
|
||||
# slew limit as any other cycle. It buys back the standing error, not
|
||||
# the rail.
|
||||
#
|
||||
# `reason` is authoritative here in a way it is not elsewhere in this
|
||||
# function: nothing after this line writes it.
|
||||
if unasked_power(prev_w, actual_w, tuning.saturation_w) \
|
||||
and sat_count >= SETTLE_CYCLES:
|
||||
reason = "freeze-released"
|
||||
else:
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
|
||||
# --- quantise ----------------------------------------------------------
|
||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
"""SAFETY-42 - a unit that stops delivering its share, which nothing else can see.
|
||||
|
||||
At one inverter this file is inert by construction (see `delinquent()`), and
|
||||
that is deliberate rather than a limitation: the whole signal it reads is a
|
||||
DIFFERENCE BETWEEN SIBLINGS, and a roster of one has none.
|
||||
|
||||
WHAT IT CATCHES, in one sentence: an inverter that accepts its share and does
|
||||
not produce it, beside a sibling that does - HW-01's shape at N=2, where the
|
||||
2026-09-02 fault wedged inverter A for 6 h 25 m with every layer a spectator.
|
||||
|
||||
WHY EVERY EXISTING GUARD IS SILENT, AND IT IS STRUCTURAL
|
||||
=======================================================
|
||||
SAFETY-30 per unit gates on OPPOSITION: both the command and the unit's own
|
||||
power over `unit_sign_disagree_w` (250 W) with opposite
|
||||
signs. A unit sitting at 0 W never opens that gate. It is
|
||||
not opposing, it is ABSENT.
|
||||
SAFETY-30 site the sum of the commands still AGREES with the meter in
|
||||
direction. Only the magnitude is short, and the site
|
||||
comparison is a sign test.
|
||||
SAFETY-31 `sum(|Bi|) - |sum(Bi)|` is ZERO when one unit does
|
||||
nothing: circulation needs two units pulling AGAINST each
|
||||
other, and one of these is pulling nowhere at all.
|
||||
the saturation freeze fires - and `clamp.py`:93-96 says of exactly this: "A
|
||||
loop can be legally saturated all day - a cold pack, a
|
||||
curtailed inverter, an export limit - so a
|
||||
saturation-shaped alarm is one an operator learns to
|
||||
ignore."
|
||||
|
||||
⚠️ SO THE ONE SIGNAL THAT DOES FIRE IS THE ONE THIS CODEBASE DOCUMENTS AS
|
||||
UNREADABLE, and that documentation is right. This module therefore does not make
|
||||
that signal louder; it computes a different one.
|
||||
|
||||
⚠️ THE ONE PLACE IT OVERLAPS SAFETY-30, AND THE OVERLAP RESOLVES ITSELF. A unit
|
||||
measured at -2000 W against a +2000 W share is OPPOSING - SAFETY-30's fault -
|
||||
and is ALSO short by 4000 W with nothing here to explain it. Both detectors have
|
||||
evidence, and only one alarm results, because SAFETY-30's per-unit hold is the
|
||||
shorter of the two (85 s against 185 s at the shipped defaults): it trips first,
|
||||
files its `failsafe` SET, the share goes to 0 W, and this check's gate closes on
|
||||
`share > 0` while `account()` answers "parked" as well. That is asserted on a
|
||||
reversed-unit fixture in `test_delivery.py` part 6 rather than left to the
|
||||
reader, because two alarms for one fault is how an operator learns to skim.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE CARD'S OWN DISCRIMINATOR WAS MEASURED AND IT DOES NOT WORK - READ THIS FIRST
|
||||
==============================================================================
|
||||
SAFETY-42 proposed: alarm on `saturation persisting past N` while
|
||||
`|grid error| > X`, with N and X derived rather than chosen. That predicate was
|
||||
built and run on the two-master rig before this module was written, and it
|
||||
fails in BOTH directions. Neither failure is a matter of picking better N and X;
|
||||
both are properties of the quantity.
|
||||
|
||||
1. IT FIRES ON EVERY BENIGN SATURATION, and the two cases are not merely
|
||||
similar - they are THE SAME TRACE. `plantsim.Plant.limit()` returns 0 W for
|
||||
a discharge command into a pack at its own floor; a wedged unit that never
|
||||
receives its command also produces 0 W. Run both against the same stimulus
|
||||
(load 2000 W, equal weights, 2000 W per-unit cap) and every emitted watt
|
||||
agrees: over 120 control cycles the two arms are IDENTICAL on all 120 in
|
||||
(grid, site target, each unit's share, each unit's measured power). Both
|
||||
report `reason == "saturated-freeze"` on 116 of those 120 cycles - the
|
||||
other four are `slew-limited`, before the freeze engages on cycle 4 - and
|
||||
both hold |grid error| over 300 W on all 120, settling at 1200 W. A
|
||||
predicate reading only those
|
||||
numbers cannot separate them at ANY (N, X), because nothing in its inputs
|
||||
differs. And "a cold pack at its floor must not alarm" is this card's own
|
||||
acceptance criterion. `test_delivery.py` part 4 asserts that identity on
|
||||
the add-on's own end-to-end fixture, so it goes red if it ever stops being
|
||||
true.
|
||||
|
||||
2. IT IS SILENT ON THE HEADLINE CASE, because the standing grid error is
|
||||
exactly what a HEALTHY SIBLING ERASES. Same rig, load 800 W, equal weights,
|
||||
2000 W per-unit cap, inverter B wedged: over the last 30 of 200 cycles the
|
||||
loop is saturation-frozen on every one of them - so "saturation persisting
|
||||
past N" is true for any N - while the worst |grid error| in that window is
|
||||
30 W. B is standing at a +770 W discharge share and delivering 0 W. Any X
|
||||
above 30 W leaves the card's predicate silent for the whole run, on the
|
||||
exact fault it was written for: a healthy sibling regulating alone with the
|
||||
site short by one unit's share.
|
||||
|
||||
The card's derivation instruction stands and is honoured; what could not be
|
||||
derived was a threshold on the wrong quantity. This is written down rather than
|
||||
quietly replaced, because the next person to read the card will propose it again.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE QUANTITY THAT DOES WORK, AND WHY IT NEEDS TWO INVERTERS
|
||||
===========================================================
|
||||
Per unit, for a share that asks it to DISCHARGE:
|
||||
|
||||
short_by(i) = share_i - measured_i (watts that did not arrive)
|
||||
|
||||
`share_i` is the command STANDING ON THAT UNIT'S WIRE - `Inverter.standing_w()`,
|
||||
the same operand SAFETY-30's per-unit comparison uses - and `measured_i` is that
|
||||
unit's own power, holding register 0x0518, per-instance since DEV-02.
|
||||
|
||||
That number alone is the saturation signal again, per unit. What makes it
|
||||
readable is the reduction over the roster:
|
||||
|
||||
a unit is DELINQUENT while it is short with NO ACCOUNT FOR IT, and the set
|
||||
of units in that state is a PROPER SUBSET of the units that were asked to
|
||||
discharge.
|
||||
|
||||
Both halves are load-bearing:
|
||||
|
||||
* THE ACCOUNT is what keeps the cold pack out, and it is the only thing that
|
||||
can. The two cases are identical in the power domain (see 1 above), so the
|
||||
discriminator has to be a fact this controller reads that is NOT power - and
|
||||
there is exactly one: the pack's own state of charge against its own
|
||||
`bms_soc_floor`. `account()` below asks it through `peakguard.
|
||||
discharge_permitted()`, the predicate SAFETY-32 already made public, rather
|
||||
than transcribing the comparison a second time.
|
||||
* THE PROPER SUBSET is what keeps a site-wide cause out, and it is the half
|
||||
that only exists at N>=2. A derate this add-on cannot see - a thermal
|
||||
limit, a grid-code curtailment, an inverter-side DC restriction - hits the
|
||||
whole roster. One box that has stopped listening does not. If EVERY unit
|
||||
that was asked to deliver is short and unexplained, this module says
|
||||
nothing: it cannot tell that from a plant that simply cannot do it, and
|
||||
saying so anyway is the saturation-shaped alarm all over again.
|
||||
|
||||
⚠️ AT N=1 THE SUBSET IS NEVER PROPER AND THIS IS INERT. One asked unit is either
|
||||
accounted for (nothing to say) or unaccounted for (the whole roster, so nothing
|
||||
to compare it against). `delinquent()` returns `{}` for every input at a roster
|
||||
of one - not by a guard on `len`, but because a proper subset of a one-element
|
||||
set that contains the element is impossible. That is AC 5, and it is asserted
|
||||
rather than assumed in `test_delivery.py` part 1.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
DISCHARGE SHARES ONLY, AND THE COST OF THAT IS STATED RATHER THAN HIDDEN
|
||||
=======================================================================
|
||||
`short_by()` answers None for a charge share. The account gate needs a bound
|
||||
this controller actually reads in the commanded direction, and there is one for
|
||||
discharge - `bms_soc_floor`, required of every roster entry since SAFETY-32 -
|
||||
and NONE for charge. A pack in constant-voltage taper legitimately takes
|
||||
hundreds of watts less than it is told for tens of minutes, and there is no
|
||||
configured ceiling anywhere in this add-on to explain that with. Armed on charge
|
||||
shares this module would alarm on the taper of every §12 maintenance charge,
|
||||
which is the crying-wolf failure it exists to avoid.
|
||||
|
||||
What that costs, said plainly: a unit that stops ABSORBING its share is not
|
||||
caught here. It is the same fault in the other direction and it wants the same
|
||||
treatment; what it needs first is a per-pack charge ceiling this add-on does not
|
||||
have. That is a card, and this paragraph is its evidence.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
IT REPORTS. IT DOES NOT ACT - AND THAT IS A DECISION, NOT AN OMISSION
|
||||
====================================================================
|
||||
Card requirement 3 asks which, and why. Three candidates, and the reasoning is
|
||||
recorded because "alarm-only" is the weaker-looking answer and is still right:
|
||||
|
||||
COMMAND IT HARDER - no. HW-01 settles it: on 2026-09-02 the power stage went
|
||||
on executing its last instruction for 6 h 25 m through every command this
|
||||
add-on sent it. A command is not a remedy for a box that is not listening,
|
||||
and pretending otherwise is theatre.
|
||||
|
||||
RE-ALLOCATE ITS SHARE TO THE SIBLING - no, AND NOT FOR THE COMFORTABLE
|
||||
REASON. The comfortable reason would be that the loop already does it, and
|
||||
the rig says otherwise: with B wedged and equal weights, dropping B from
|
||||
`live` - the mechanism `live_units()` already uses for a dead link - is a
|
||||
real improvement at loads the site cannot cover on one unit's SHARE.
|
||||
Measured, per-unit cap 2000 W, B wedged:
|
||||
|
||||
load 1500 W kept in the split: the site settles at 800 W with a 700 W
|
||||
grid error and NEVER comes inside 100 W in 150 cycles -
|
||||
the saturation freeze pins the site target, so A's share
|
||||
never rises. Dropped: 1500 W delivered, inside 100 W from
|
||||
cycle 14.
|
||||
load 2000 W kept: 800 W delivered, 1200 W error, never settles.
|
||||
Dropped: 2000 W delivered, inside 100 W from cycle 8.
|
||||
|
||||
That is not three cycles of latency; it is the difference between a site
|
||||
that recovers and one that does not. It is the strongest argument on this
|
||||
card and it is stated first, because the answer still has to be no.
|
||||
|
||||
WHAT REFUSES IT is that the only re-allocation this add-on can express is
|
||||
ALL OR NOTHING. `live_units()` drops a unit entirely and the survivors carry
|
||||
the whole target; there is no way to say "give the sibling the watts that
|
||||
did not arrive and leave this unit the ones that did". And a unit delivering
|
||||
PART of its share is short by more than `saturation_w` too, with nothing
|
||||
here to explain it, so this detector names it - it cannot tell a 50 %
|
||||
derate from a wedged box, because neither can any instrument this add-on
|
||||
has. Measured, B derated to 50 %, load 3000 W, per-unit cap 2000 W, and
|
||||
SAFETY-42 names B in that run:
|
||||
|
||||
kept in the split site delivers 2400 W (B contributing 800 W),
|
||||
grid error 600 W
|
||||
dropped site delivers 2000 W (B contributing 0 W),
|
||||
grid error 1000 W - 400 W WORSE
|
||||
|
||||
So the remedy would make the site worse in a case this detector cannot rule
|
||||
out, on a live control law, driven by a brand-new alarm. A partial
|
||||
re-allocation - the sibling gets the undelivered watts without the short
|
||||
unit being commanded to zero - is what would make acting safe, and the
|
||||
allocator has no way to express it today. That is a card, and this block is
|
||||
its evidence.
|
||||
|
||||
STOP THE SITE, as SAFETY-31 does - no, and the asymmetry with SAFETY-31 is
|
||||
exact. Circulating power is stopped because THIS CONTROLLER IS ONE OF THE
|
||||
TWO PARTICIPANTS: removing our half halves the loop and un-hides the residue
|
||||
at a meter that was netting it to zero. Here this controller is not a
|
||||
participant in the fault, nothing is netting, and the sibling is the only
|
||||
thing still delivering. Parking it would take the site from "short by one
|
||||
unit" to "short by all of them" and would remove the evidence.
|
||||
|
||||
So: a durable latch, a named unit, an action-log row, and an operator
|
||||
notification - and the loop is left exactly as it was. Requirement 4 of the card
|
||||
("do not weaken the saturation freeze to make room") is met by not touching
|
||||
`control.py` at all.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
IT SURVIVES A RESTART, WHICH IS SAFETY-34 AND IS WHY THE LATCH EXISTS AT ALL
|
||||
===========================================================================
|
||||
`persistent_notification` is IN MEMORY. Home Assistant restarts and the card is
|
||||
gone with the fault still running, which is precisely how the 2026-09-02 outage
|
||||
went unseen for 6 h 25 m. So the durable half is `unit/<name>/delivery_tripped`
|
||||
and `unit/<name>/delivery_reason` in `/data/state.json` - namespaced per unit,
|
||||
for `store.unit_key()`'s reason - and `main.py` re-raises the notification from
|
||||
it on the first cycle of every process.
|
||||
|
||||
⚠️ AND THAT IS WHY THIS LATCHES THOUGH IT DOES NOT ACT. A detector that cleared
|
||||
itself when the unit resumed would erase its own record of an intermittent
|
||||
fault, which is the one shape an operator most needs to be told about. It is
|
||||
cleared by the same operator press that clears SAFETY-02's and SAFETY-31's -
|
||||
a deliberate human act, after looking at why.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .peakguard import discharge_permitted
|
||||
|
||||
# The reason code. Distinct from every other one in this add-on by construction:
|
||||
# nothing else uses it, and it deliberately does not contain the word
|
||||
# "saturation" - see the module docstring on why a saturation-shaped alarm is
|
||||
# the thing this module must not produce.
|
||||
REASON_CODE = "UNIT_NOT_DELIVERING"
|
||||
|
||||
# CFG-03. THE ONE IN-CODE COPY OF THIS DEFAULT. `config.yaml`'s `options:` block
|
||||
# is the source of truth an operator edits and the Supervisor reads; `schema:`
|
||||
# holds the bounds. This dict exists for the reason
|
||||
# `circulation.CIRC_OPTION_DEFAULTS` and `watchdog.SIGN_OPTION_DEFAULTS` do -
|
||||
# the Dockerfile does not put config.yaml in the image, so the running add-on
|
||||
# cannot read it - and `test_delivery.py` pins the two together by READING
|
||||
# config.yaml, so a drift goes red in CI rather than shipping.
|
||||
#
|
||||
# ⚠️ THERE IS ONLY ONE KEY HERE, AND THE MISSING SECOND ONE IS THE POINT. The
|
||||
# other half of this check is a WATTS threshold, and it is not an option and not
|
||||
# a constant: it is `control.Tuning.saturation_w`, read at runtime. See
|
||||
# `MIN_W_IS_SATURATION_W` below.
|
||||
DELIVERY_OPTION_DEFAULTS = {
|
||||
# --- THE WINDOW, AND IT IS THE ONLY NUMBER THIS CARD CHOSE ------------
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT PICKED. 2640 closed-loop
|
||||
# runs at a roster of two in which NOTHING is faulty - every unit executes
|
||||
# the share the allocator gave it - over 6 gains x 5 slews, the whole range
|
||||
# config.yaml accepts for `gain` float(0.05,1.0) and `slew_w` int(0,5000),
|
||||
# across three families of stimulus that can produce a legitimate shortfall:
|
||||
#
|
||||
# 1050 load reversals 7 sizes x 5 write staggers (the serialised write
|
||||
# path landing B's number up to four cycles late)
|
||||
# 840 flapping loads 7 periods x 4 amplitudes
|
||||
# 750 weighted splits 5 weightings x 5 staggers on a +-2200 W reversal
|
||||
#
|
||||
# ⚠️ THE WEIGHTED FAMILY IS NEW HERE AND IT IS THE ONE THAT DOMINATES. An
|
||||
# asymmetric split hands one unit a much larger step than its sibling, so
|
||||
# the big one is still ramping while the small one has settled - which is
|
||||
# exactly the PROPER SUBSET this check keys on. SAFETY-30's campaign had no
|
||||
# such family, so reading its numbers across would have understated this.
|
||||
#
|
||||
# Every run was executed a second time at a roster of ONE, on the same
|
||||
# stimulus, as the control. Worst CONTINUOUS legitimate episode:
|
||||
#
|
||||
# threshold reversals flapping weighted N=1 control (1890 runs)
|
||||
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s
|
||||
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s
|
||||
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s
|
||||
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s
|
||||
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s
|
||||
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s
|
||||
#
|
||||
# * is the shipped threshold, `Tuning.saturation_w`.
|
||||
#
|
||||
# ⚠️ THREE THINGS IN THAT TABLE ARE WORTH MORE THAN THE ROW THEY SIT ON.
|
||||
# (a) The N=1 column is ZERO EVERYWHERE, in all 1890 runs at every
|
||||
# threshold. The compatibility promise is measured, not argued.
|
||||
# (b) The FLAPPING column is zero everywhere too - and that is the family
|
||||
# that defeated SAFETY-30's site window (980 s at N=2, 1000 s at N=1, no
|
||||
# window inside the billing quarter surviving it). It cannot touch this
|
||||
# check, because a common stimulus makes BOTH units short at once and
|
||||
# the subset stops being proper.
|
||||
# (c) THE CURVE IS NOT MONOTONE - 55.0 s at 250 W against 40.0 s at 100 W -
|
||||
# and that is a property of the reduction, not noise: a lower threshold
|
||||
# puts MORE units into the short set, which can turn a proper subset
|
||||
# into the whole roster and BREAK the run. Do not read it as a
|
||||
# sensitivity curve.
|
||||
#
|
||||
# THE BOUNDS THE VALUE ANSWERS TO:
|
||||
#
|
||||
# LOWER, from the rig - 30.0 s, the worst continuous legitimate episode at
|
||||
# the shipped threshold across all 2640 runs.
|
||||
#
|
||||
# LOWER, from the firmware - `inverter.LINK_STALE_S`, 30 s. A unit not
|
||||
# executing its share for less than the firmware's own veto window is a
|
||||
# LINK fault the add-on already answers by dropping it from the split with
|
||||
# a line in the action log. Reporting inside that window would be a
|
||||
# second, louder alarm about a state already being handled - the
|
||||
# SAFETY-15/SAFETY-19 failure exactly.
|
||||
#
|
||||
# MARGIN, from INVERTER B NOT EXISTING, AND IT IS MEASURED RATHER THAN
|
||||
# GUESSED. The campaign's B is A COPIED (`plantsim.INVERTER_B`,
|
||||
# `measured=False`), so the 30.0 s above is two identical machines.
|
||||
# Sweeping a DIFFERENT second unit through the weighted family: the worst
|
||||
# legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and
|
||||
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 90.0 s at
|
||||
# four times it. That is the sensitivity the margin has to carry until
|
||||
# SPIKE-04 measures the real unit.
|
||||
#
|
||||
# UPPER, from the bill - 900 s, the quarter that sets the capacity tariff.
|
||||
# A site running short of one unit's share must be reported well inside
|
||||
# one.
|
||||
#
|
||||
# 185 s: thirty-seven control cycles. 6.2x the worst legitimate episode this
|
||||
# rig produces, 2.06x the worst a second unit FOUR TIMES slower than A
|
||||
# produces, 6.2x the firmware's own veto window, and 4.9x inside the
|
||||
# quarter. ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
|
||||
# is a different check on a different quantity, so a reader must not be
|
||||
# able to think it was carried across - and not 150 or 200.
|
||||
#
|
||||
# WHAT IT COSTS, stated rather than hidden: up to 185 s of a site running
|
||||
# short by one unit's share before anybody is told. Against a fault that ran
|
||||
# 6 h 25 m unnoticed, and against a guard that TAKES NO ACTION, this window
|
||||
# buys its margin with reporting latency and with nothing else.
|
||||
"delivery_hold_s": 185,
|
||||
}
|
||||
|
||||
|
||||
# ⚠️ WHY THERE IS NO `delivery_w` OPTION, AND WHY THAT IS A DERIVATION RATHER
|
||||
# THAN A SAVING. The watts side of this check asks "how far short is far
|
||||
# enough to be worth saying anything about", and the shipped control law
|
||||
# already answers that question in one number: `Tuning.saturation_w`, 500 W,
|
||||
# the gap at which `compute()` itself concludes THE INVERTER IS NOT FOLLOWING
|
||||
# and freezes the integrator. Below it the loop calls the divergence ordinary
|
||||
# tracking, and an alarm under a threshold the control law calls normal is an
|
||||
# alarm about nothing.
|
||||
#
|
||||
# So `main.py` hands this check `self.tuning.saturation_w` and there is no
|
||||
# fourth copy of a default to keep in step (CFG-03's complaint) and no way for
|
||||
# the two to drift: an operator who raises `saturation_w` has told the loop
|
||||
# that a bigger gap is ordinary tracking, and this check moves with them by
|
||||
# construction rather than by somebody remembering.
|
||||
#
|
||||
# ⚠️ AND THAT SENTENCE IS A CHECKED CLAIM, NOT A DESCRIPTION OF AN INTENTION.
|
||||
# It is only true while the call site passes the CONFIGURED value; a literal
|
||||
# there that happens to equal 500 today keeps every fixture green and quietly
|
||||
# removes the whole guarantee - `250.0` in place of `self.tuning.saturation_w`
|
||||
# was a survivor of all 23 suites, because every fixture's gap was far above
|
||||
# both numbers. test_delivery.py part 4b is what makes it checkable: it runs
|
||||
# the same wedged unit at the shipped `saturation_w` and at a raised one inside
|
||||
# the shipped schema, with a shortfall that falls BETWEEN them, and the two
|
||||
# arms must answer differently. No constant passes both.
|
||||
#
|
||||
# ⚠️ IT IS ALSO WHY THIS CANNOT DOUBLE-REPORT THE SATURATION FREEZE'S OWN
|
||||
# EVENT. `compute()` needs `saturation_cycles` (3) CONSECUTIVE cycles over
|
||||
# that same gap before it freezes anything, so every cycle this module counts
|
||||
# is a cycle the freeze has already seen. This is a reduction over the freeze's
|
||||
# input, not a second opinion about it.
|
||||
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryConfig:
|
||||
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
||||
|
||||
hold_s: float = float(DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitDelivery:
|
||||
"""ONE unit's four facts for this cycle, and nothing else.
|
||||
|
||||
`name` the roster name. An alarm that cannot say which box to look at
|
||||
is the whole reason SAFETY-30 split per-unit from site.
|
||||
`share_w` what is STANDING on this unit's wire - `Inverter.standing_w()`,
|
||||
the same operand SAFETY-30's per-unit comparison takes, after
|
||||
the deadband, the slew limit, quantisation and the arbiter.
|
||||
Not the optimizer's wish: the wire carries the emitted value.
|
||||
`measured_w` this unit's OWN power, holding register 0x0518, + = discharge.
|
||||
None when the reading did not arrive.
|
||||
`accounted` why a shortfall on this unit is already explained, or None if
|
||||
nothing this controller reads explains one. Built by
|
||||
`account()`; it is a field rather than a method because the
|
||||
facts behind it (liveness, the sign latch, SoC) belong to
|
||||
main.py's `Inverter` and this module imports none of it.
|
||||
"""
|
||||
|
||||
name: str
|
||||
share_w: float | None
|
||||
measured_w: float | None
|
||||
accounted: str | None = None
|
||||
|
||||
|
||||
def short_by(share_w, measured_w):
|
||||
"""Watts of a DISCHARGE share that did not arrive, or None if unanswerable.
|
||||
|
||||
Positive means short. Zero and negative are folded into None rather than
|
||||
reported, because "delivered its share" and "delivered more than its share"
|
||||
are both not-this-fault and a caller comparing a float against a threshold
|
||||
should not have to know which.
|
||||
|
||||
⚠️ CHARGE SHARES ANSWER None, AND THAT IS THE STATED LIMIT AT THE TOP OF
|
||||
THIS FILE, not an oversight to be tidied away. There is no configured charge
|
||||
ceiling in this add-on to explain a taper with.
|
||||
|
||||
⚠️ AND A MISSING READING ANSWERS None RATHER THAN "the whole share". Zero
|
||||
would be this check inventing a fault from an absence, which is the shape of
|
||||
the 2026-09-02 outage read the other way round; and the site is already
|
||||
stopped for a missing per-unit reading anyway (`inputs_ok` requires every
|
||||
unit's `batt`, and §11.1 rule 1 commands 0 W without consulting this).
|
||||
"""
|
||||
if share_w is None or measured_w is None:
|
||||
return None
|
||||
share = float(share_w)
|
||||
if share <= 0.0:
|
||||
return None
|
||||
gap = share - float(measured_w)
|
||||
return gap if gap > 0.0 else None
|
||||
|
||||
|
||||
def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
|
||||
"""Why a shortfall on this unit is ALREADY EXPLAINED, or None if nothing is.
|
||||
|
||||
Every branch here is a state this add-on can read and already has an answer
|
||||
for, and naming one of them a fault would be raising a second, louder alarm
|
||||
about something already being handled - the SAFETY-15/SAFETY-19 failure that
|
||||
`circulation.py` and `watchdog.py` both take their 30 s lower bound from.
|
||||
|
||||
`live` `Inverter.is_live()`. A unit whose link is stale or whose
|
||||
writes are being rejected has ALREADY been dropped from the
|
||||
split with a line in the action log saying so.
|
||||
`parked` that unit's sign watchdog has latched. It is being commanded
|
||||
0 W on purpose; not delivering is compliance, not a fault.
|
||||
`soc` that pack's measured state of charge, percent. None is not a
|
||||
full battery and is not permission - see below.
|
||||
`bms_floor` that pack's OWN cut-out, `bms_soc_floor`. Required of every
|
||||
roster entry since SAFETY-32, so at the only roster this
|
||||
module is live at it is always configured.
|
||||
|
||||
⚠️ THE SoC BRANCH IS THE WHOLE DISCRIMINATOR AND IT IS ASKED THROUGH
|
||||
`peakguard.discharge_permitted()`, WHICH IS PUBLIC FOR EXACTLY THIS REASON:
|
||||
"a safety predicate transcribed into its own check is one copy plus a future
|
||||
disagreement about what the check means". What is NOT reused is its wording,
|
||||
which is peak-guard flavoured ("the peak guard's SoC floor") and would be a
|
||||
false statement here - this module is not the peak guard and its floor is
|
||||
the pack's own cut-out, not `peak_soc_floor`. One predicate, two sentences.
|
||||
⚠️ AND THE FLOOR PASSED IN IS `bms_soc_floor` ALONE, NOT
|
||||
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`. That
|
||||
conjunction is right for the peak guard and wrong here: `peak_soc_floor`
|
||||
bounds a money-saving discharge only (SAFETY-25), ordinary self-consumption
|
||||
discharges straight past it, and using it would EXPLAIN AWAY a wedged unit
|
||||
sitting anywhere between the two floors.
|
||||
|
||||
⚠️ EVERY UNKNOWN ACCOUNTS FOR THE SHORTFALL, i.e. FAILS TOWARD SILENCE, and
|
||||
that direction is the opposite of the one `discharge_permitted()` was
|
||||
written for. There it withholds a discharge; here it withholds an ALARM. An
|
||||
unreadable SoC or an unstated floor means this module cannot say whether the
|
||||
pack is empty, and an alarm nobody can justify is the crying-wolf failure
|
||||
this whole file is written against. The cost is bounded: the same absence
|
||||
stops the site through `inputs_ok` (SoC) or is refused at startup by the
|
||||
roster schema (the floor).
|
||||
"""
|
||||
if not live:
|
||||
return ("its link is stale or its writes are being rejected, and the "
|
||||
"allocator has already dropped it from the split")
|
||||
if parked:
|
||||
return "it is latched at 0 W by a sign trip, so it is doing as it is told"
|
||||
why = discharge_permitted(soc, bms_floor)
|
||||
if why is not None:
|
||||
if soc is None:
|
||||
return "its state of charge did not read, so its pack cannot be graded"
|
||||
if bms_floor is None:
|
||||
return ("no bms_soc_floor is configured for it, so there is nothing "
|
||||
"to say whether its pack is empty")
|
||||
return (f"its pack is at {float(soc):.0f} %, at or below its own "
|
||||
f"{float(bms_floor):.0f} % cut-out (bms_soc_floor)")
|
||||
return None
|
||||
|
||||
|
||||
def delinquent(units, min_w: float) -> dict:
|
||||
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
|
||||
|
||||
`units` one `UnitDelivery` per inverter, in roster order.
|
||||
`min_w` the shortfall that counts - `control.Tuning.saturation_w`. See
|
||||
`MIN_W_IS_SATURATION_W`.
|
||||
|
||||
Returns `{name: sentence}`, empty when there is nothing to say. The sentence
|
||||
is the operator's first line and it carries the peer clause, because "B is
|
||||
not delivering" and "B is not delivering while A is" are different facts and
|
||||
only the second one is this module's conclusion.
|
||||
|
||||
⚠️ THE PROPER-SUBSET RULE IS THE CHECK, NOT A FILTER ON IT. A unit is named
|
||||
only while the units in its state are FEWER than the units that were asked
|
||||
to discharge. Everything asked and nothing explained is a SITE condition -
|
||||
a derate, a curtailment, a grid-code limit, a plant that simply cannot -
|
||||
and this module has no instrument that can tell that from every box being
|
||||
wedged at once. Reporting it anyway would be the saturation-shaped alarm
|
||||
`clamp.py` warns about, produced by the guard written to replace it.
|
||||
|
||||
⚠️ AND THAT IS WHY N=1 IS INERT WITHOUT A ROSTER GUARD. A `if n_units > 1`
|
||||
in the caller would be a second place the same decision lives; the algebra
|
||||
is the guard, the same way `circulation.circulating_w()` is identically zero
|
||||
at N<=1.
|
||||
|
||||
⚠️ BUT THE ALGEBRA THAT DOES IT IS THE PROPER-SUBSET RULE, NOT THE
|
||||
`len(asked) < 2` LINE - AND THIS PARAGRAPH USED TO SAY OTHERWISE. The
|
||||
mutation battery found it: relaxing that line to `len(asked) < 1` is a
|
||||
SURVIVOR at +0 B, and it is an EQUIVALENT MUTANT rather than a gap in the
|
||||
checks. With one asked unit, `unaccounted` is a subset of a one-element set,
|
||||
so it is either empty (`not unaccounted` returns `{}`) or equal to `asked`
|
||||
(`len(unaccounted) == len(asked)` returns `{}`). There is no third case and
|
||||
no input on which the two spellings differ. The line is a FAST PATH and a
|
||||
signpost, not the mechanism - said here rather than left for the next
|
||||
mutation sweep to rediscover, exactly as `circulation.circulating_w()` says
|
||||
of its own `max(0.0, ...)`. Do not report its survival as a gap in the
|
||||
checks, and do not delete it either: it is what tells a reader at a glance
|
||||
that a one-inverter install never reaches any of this.
|
||||
"""
|
||||
asked = [u for u in units
|
||||
if u.share_w is not None and float(u.share_w) > 0.0]
|
||||
if len(asked) < 2:
|
||||
return {}
|
||||
|
||||
gaps = {}
|
||||
for u in asked:
|
||||
gap = short_by(u.share_w, u.measured_w)
|
||||
if gap is not None and gap > float(min_w):
|
||||
gaps[u.name] = gap
|
||||
|
||||
unaccounted = {u.name: gaps[u.name] for u in asked
|
||||
if u.name in gaps and u.accounted is None}
|
||||
if not unaccounted or len(unaccounted) == len(asked):
|
||||
return {}
|
||||
|
||||
peers = [u.name for u in asked if u.name not in unaccounted]
|
||||
out = {}
|
||||
for u in asked:
|
||||
if u.name not in unaccounted:
|
||||
continue
|
||||
out[u.name] = (
|
||||
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
|
||||
f"and delivering {float(u.measured_w):+.0f} W - "
|
||||
f"{unaccounted[u.name]:.0f} W of its share is not arriving - while "
|
||||
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
|
||||
f"that state. Nothing this add-on reads explains it")
|
||||
return out
|
||||
|
||||
|
||||
class DeliveryWatchdog:
|
||||
"""ONE PER INVERTER, evaluated once per control cycle.
|
||||
|
||||
Per unit rather than per site, and that is card requirement 2: the site
|
||||
figure cannot say which box to look at, and naming the box is the whole
|
||||
reason SAFETY-30 split per-unit from site in the first place. It sits beside
|
||||
`Inverter.sign_wd` on the same object, under the same namespaced store keys,
|
||||
for the same reason.
|
||||
|
||||
⚠️ NOT SHARED ACROSS CADENCES, for `SignWatchdog`'s reason: `since` is a
|
||||
wall-clock start time, so two loops at two rates would each reset the
|
||||
other's window.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: DeliveryConfig | None = None, *,
|
||||
tripped: bool = False, reason: str = ""):
|
||||
self.cfg = cfg or DeliveryConfig()
|
||||
self.since: datetime | None = None
|
||||
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED - SAFETY-34, and it is the
|
||||
# requirement the card states in its own words. See the module note.
|
||||
self.tripped = bool(tripped)
|
||||
self.reason = reason if self.tripped else ""
|
||||
|
||||
def arm(self, cfg: DeliveryConfig) -> None:
|
||||
"""Set the window. Nothing else.
|
||||
|
||||
⚠️ IT IS CONFIGURATION, NOT STATE, AND IT DELIBERATELY DOES NOT TOUCH
|
||||
`since`, `tripped` OR `reason` - `SignWatchdog.arm()`'s argument,
|
||||
which was found the expensive way: a detector that cleared its own
|
||||
window when it was told what it was measuring would lose the episode it
|
||||
is in the middle of, every cycle, and would never accumulate a hold.
|
||||
"""
|
||||
self.cfg = cfg
|
||||
|
||||
def evaluate(self, sentence, now: datetime) -> bool:
|
||||
"""Advance one cycle. `sentence` is this unit's entry from `delinquent()`
|
||||
(None when it has none). Returns True while tripped.
|
||||
|
||||
`now` is the caller's clock - `SignWatchdog.evaluate()`'s and
|
||||
`CirculationWatchdog.evaluate()`'s discipline, so this is testable and
|
||||
replayable rather than reading a clock of its own.
|
||||
"""
|
||||
if self.tripped:
|
||||
# Latched. It does not re-evaluate, and unlike SAFETY-02 and
|
||||
# SAFETY-31 that is NOT because the trip removed the evidence -
|
||||
# nothing here changes what is commanded, so the evidence is still
|
||||
# standing. It is because an intermittent unit that resumed must not
|
||||
# erase its own record. See the module note.
|
||||
return True
|
||||
|
||||
if sentence is None:
|
||||
self.since = None
|
||||
return False
|
||||
|
||||
if self.since is None:
|
||||
self.since = now
|
||||
return False
|
||||
|
||||
held = (now - self.since).total_seconds()
|
||||
if held < self.cfg.hold_s:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
self.reason = (f"{sentence} - held for {held:.0f} s continuously "
|
||||
f"(limit: over {self.cfg.hold_s:.0f} s)")
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
"""How long the current shortfall has stood, for the status page.
|
||||
|
||||
Takes the clock rather than reading one, for `SignWatchdog.held_s()`'s
|
||||
reason: an unclocked function is one nothing can test, which is exactly
|
||||
what happened to that one.
|
||||
"""
|
||||
if self.since is None:
|
||||
return 0.0
|
||||
return (now - self.since).total_seconds()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.since = None
|
||||
self.tripped = False
|
||||
self.reason = ""
|
||||
@@ -122,6 +122,7 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
from .delivery import DeliveryConfig, DeliveryWatchdog
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
@@ -166,6 +167,13 @@ UNIT_A = "inverter-a"
|
||||
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
|
||||
# reads this same mapping, so a key renamed here cannot silently stop being
|
||||
# migrated. See store.py.
|
||||
# ⚠️ SAFETY-42's PAIR IS DELIBERATELY NOT IN THIS TUPLE, AND THE ABSENCE IS THE
|
||||
# STATEMENT. The values here are the PRE-DEV-02 FLAT key names that
|
||||
# `store.migrate_unit_keys()` moves under a unit; `delivery_tripped` /
|
||||
# `delivery_reason` never existed flat, because the check that writes them
|
||||
# cannot fire at a roster of one (see delivery.py) and a one-inverter install is
|
||||
# the only install that ever had flat keys. Adding them here would make the
|
||||
# migration look for keys that cannot exist and would report them as absent.
|
||||
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
|
||||
@@ -184,7 +192,8 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
bms_soc_floor=None, store=None, sign_cfg=None):
|
||||
bms_soc_floor=None, store=None, sign_cfg=None,
|
||||
delivery_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
@@ -274,6 +283,25 @@ class Inverter:
|
||||
tripped=bool(data.get(self.key("sign_tripped"), False)),
|
||||
reason=str(data.get(self.key("sign_reason"), "") or ""))
|
||||
|
||||
# --- SAFETY-42, one instance per unit, under NAMESPACED keys ---------
|
||||
# ⚠️ PER UNIT BECAUSE THE CONCLUSION IS ABOUT A UNIT. "This box is not
|
||||
# producing what it was told, while its sibling is" names a device, and
|
||||
# naming the device is card requirement 2 - the site figure cannot say
|
||||
# which one to look at, which is the whole reason SAFETY-30 split
|
||||
# per-unit from site. The site-level REDUCTION that decides whether a
|
||||
# unit qualifies lives in `delivery.delinquent()` and is computed once
|
||||
# per cycle in `Controller.cycle()`; what is per-unit here is the
|
||||
# WINDOW and the LATCH.
|
||||
#
|
||||
# ⚠️ AND IT IS RESTORED FROM THIS UNIT'S OWN KEYS, LIKE `sign_wd`. A
|
||||
# latch held only in memory is not a latch: Home Assistant restarts, the
|
||||
# `persistent_notification` goes with it, and the fault runs on unseen.
|
||||
# That is SAFETY-34, and it is how the 2026-09-02 outage lasted 6 h 25 m.
|
||||
self.delivery_wd = DeliveryWatchdog(
|
||||
delivery_cfg or DeliveryConfig(),
|
||||
tripped=bool(data.get(self.key("delivery_tripped"), False)),
|
||||
reason=str(data.get(self.key("delivery_reason"), "") or ""))
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
@@ -436,7 +464,8 @@ def roster_names(opts: dict) -> list:
|
||||
return names
|
||||
|
||||
|
||||
def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
def build_roster(opts: dict, store, sign_cfg=None,
|
||||
delivery_cfg=None) -> list:
|
||||
"""The site's inverters, in roster order.
|
||||
|
||||
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
||||
@@ -490,7 +519,7 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# mirror exactly as validate.py rule 8 defaults it, so an install
|
||||
# that never set the key reads the same 10 % in both places.
|
||||
bms_soc_floor=_flat_bms_floor(opts),
|
||||
store=store, sign_cfg=sign_cfg)]
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
|
||||
|
||||
roster = []
|
||||
names = roster_names(opts)
|
||||
@@ -517,7 +546,7 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# SAFETY-32's own defect written in three lines - the shallower pack
|
||||
# graded against the deeper pack's BMS.
|
||||
bms_soc_floor=_entry_bms_floor(entry),
|
||||
store=store, sign_cfg=sign_cfg))
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
|
||||
return roster
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ from .arbiter import (
|
||||
Claim, Resolution, actuator_for, resolve,
|
||||
)
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
|
||||
account as delivery_account, delinquent,
|
||||
)
|
||||
from .circulation import (
|
||||
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
|
||||
circulating_w, opposing,
|
||||
@@ -216,8 +220,17 @@ class Controller:
|
||||
hold_s=float(opts.get("site_sign_disagree_s",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
|
||||
)
|
||||
# SAFETY-42. THE WINDOW A UNIT MAY BE SHORT OF ITS SHARE BEFORE IT IS
|
||||
# NAMED. There is no second key: the WATTS half of that check is
|
||||
# `Tuning.saturation_w`, read at runtime rather than copied - see
|
||||
# `delivery.MIN_W_IS_SATURATION_W`, and CFG-03 on why a fourth copy of a
|
||||
# default is worth avoiding.
|
||||
self.delivery_cfg = delivery_cfg = DeliveryConfig(
|
||||
hold_s=float(opts.get("delivery_hold_s",
|
||||
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
|
||||
)
|
||||
self.store_migration = migrate_unit_keys(store, UNIT_A)
|
||||
self.inverters = build_roster(opts, store, sign_cfg)
|
||||
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
|
||||
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
|
||||
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
|
||||
# of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an
|
||||
@@ -365,6 +378,32 @@ class Controller:
|
||||
"opposed the meter.", inv.name,
|
||||
inv.sign_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
||||
# ⚠️ A LATCH THAT DOES NOT ACT, WHICH IS THE ONE ASYMMETRY WITH EVERY
|
||||
# OTHER RESTORED LATCH ON THIS OBJECT, AND IT IS DELIBERATE. SAFETY-02's
|
||||
# and SAFETY-31's latches survive a restart because they command 0 W and
|
||||
# an add-on that came back armed would drive straight into the fault
|
||||
# again. This one commands nothing at all: it is restored so that the
|
||||
# ALARM survives, which is the whole of SAFETY-34 and is exactly how the
|
||||
# 2026-09-02 outage went unseen for 6 h 25 m with the detector correct
|
||||
# the entire time. `delivery.py` carries the argument for reporting
|
||||
# rather than acting, with the rig measurements on both sides of it.
|
||||
#
|
||||
# `_delivery_announced` is the per-PROCESS half - see `_circ_announced`,
|
||||
# which is the same pattern for the same reason. The store says "this
|
||||
# unit is tripped"; the flag says "this process has put that in front of
|
||||
# a human". A restart clears the flag and keeps the latch.
|
||||
self._delivery_announced = set()
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-42 was TRIPPED on %s before this restart and stays "
|
||||
"tripped: %s. NOTHING IS BEING COMMANDED DIFFERENTLY - this "
|
||||
"guard reports and does not act - so if that inverter is "
|
||||
"still not delivering, the site is still short by its "
|
||||
"share. Restarting does not clear this.",
|
||||
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
|
||||
# The per-unit watchdogs above ask "is THIS actuator doing what I told
|
||||
@@ -1738,6 +1777,117 @@ class Controller:
|
||||
"add-on restart AND a Home Assistant restart; restarting will "
|
||||
"not clear it.", self.circ_wd.reason)
|
||||
|
||||
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
|
||||
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
|
||||
# not yet stale" hold below. A run of held cycles would otherwise leave
|
||||
# each unit's `since` untouched across the gap and its window would
|
||||
# resume from a start time that predates it - the ordering bug the sign
|
||||
# watchdog's own note describes, in a third detector.
|
||||
#
|
||||
# ⚠️ THE OPERANDS ARE `standing` AND `inv.batt`, THE SAME PAIR SAFETY-30
|
||||
# GRADES, AND THE CONCLUSION IS A DIFFERENT ONE. SAFETY-30 asks whether
|
||||
# they OPPOSE; this asks whether the second one ARRIVED. A unit sitting
|
||||
# at 0 W against a +2000 W share opposes nothing - it is not pushing the
|
||||
# wrong way, it is absent - so the sign gate never opens, the site sum
|
||||
# still agrees with the meter in direction, circulating power is
|
||||
# identically zero, and the only trace left is `reason ==
|
||||
# "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an
|
||||
# operator learns to ignore. That is this card.
|
||||
#
|
||||
# ⚠️ AND `delinquent()` IS CALLED ONCE, OVER THE WHOLE ROSTER, BECAUSE
|
||||
# THE CHECK IS A REDUCTION ACROSS UNITS AND NOT A PER-UNIT PREDICATE.
|
||||
# What separates a wedged box from a legitimately saturated site is that
|
||||
# the site's shortfall is a PROPER SUBSET of the units asked to deliver.
|
||||
# Called per unit it would be a shortfall test, which is the saturation
|
||||
# signal again. delivery.py carries the measurement: a pack at its own
|
||||
# cut-out and a wedged unit produce IDENTICAL traces at the unit and at
|
||||
# the meter, so nothing computed from one unit alone can tell them
|
||||
# apart. At a roster of one the reduction is empty for every input.
|
||||
#
|
||||
# ⚠️ EVERY NUMBER BELOW IS THE CONFIGURED ONE, NEVER A LITERAL THAT
|
||||
# HAPPENS TO EQUAL IT TODAY, AND THAT IS A SHIPPED PROMISE RATHER THAN
|
||||
# A STYLE RULE. `delivery.MIN_W_IS_SATURATION_W` and config.yaml's
|
||||
# `delivery_hold_s` block both tell an operator, in prose, that there is
|
||||
# no fourth copy of a threshold to keep in step and no way for the two
|
||||
# to drift - an operator who raises `saturation_w` has told the loop a
|
||||
# bigger gap is ordinary tracking, and this check moves with them BY
|
||||
# CONSTRUCTION. A literal here breaks exactly that promise, silently.
|
||||
#
|
||||
# Both of these were once literals that no check in this repository
|
||||
# could see, because every fixture sat at the shipped value:
|
||||
#
|
||||
# self.tuning.saturation_w -> 250.0 (-19 B) - a survivor of all
|
||||
# 23 suites, because part 4's
|
||||
# gaps are far above BOTH
|
||||
# bms_floor=inv.bms_soc_floor -> 10.0 (-13 B) - a survivor of all
|
||||
# 23 suites, because every
|
||||
# fixture's cut-out WAS 10 %
|
||||
#
|
||||
# test_delivery.py part 4b is what stands between this call and both of
|
||||
# them: it grades this reduction at a `saturation_w` and at two
|
||||
# `bms_soc_floor`s that are NOT the shipped ones, and its arms are
|
||||
# answered opposite ways at two configured values, so no constant can
|
||||
# pass. Change an argument here to a number and read that part before
|
||||
# you decide the red is wrong.
|
||||
for inv in self.inverters:
|
||||
# ⚠️ `self.delivery_cfg`, NOT `DeliveryConfig()`. Same promise: the
|
||||
# window is `delivery_hold_s`, and part 4b's neighbours already
|
||||
# grade this one at a hold that is not the shipped 185 s.
|
||||
inv.delivery_wd.arm(self.delivery_cfg)
|
||||
_delinquent = delinquent(
|
||||
[UnitDelivery(
|
||||
inv.name, standing[inv.name], inv.batt,
|
||||
delivery_account(
|
||||
# ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is
|
||||
# `LINK_STALE_S` - the SAME call `live_units()` makes for
|
||||
# the allocator. It has to be: what this branch means is
|
||||
# "the allocator has already dropped this unit and said so",
|
||||
# and a different window here would be this check answering
|
||||
# a question about a roster the allocator did not build.
|
||||
# It is the ONE number here with no option behind it, so
|
||||
# "graded at a changed config" is not available for it;
|
||||
# what part 6 grades instead is that WIDENING the window
|
||||
# changes the answer.
|
||||
live=inv.is_live(now)[0],
|
||||
parked=inv.sign_wd.tripped,
|
||||
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
|
||||
# the site's `peak_soc_floor` either - that one bounds a
|
||||
# money-saving discharge only, and grading a pack against it
|
||||
# explains a wedged unit away in a band an ordinary install
|
||||
# has ten points of.
|
||||
soc=inv.soc, bms_floor=inv.bms_soc_floor))
|
||||
for inv in self.inverters],
|
||||
self.tuning.saturation_w)
|
||||
for inv in self.inverters:
|
||||
was_short = inv.delivery_wd.tripped
|
||||
if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \
|
||||
and not was_short:
|
||||
# REASON FIRST, FLAG SECOND, and each `set` writes the file -
|
||||
# the same atomicity ordering the three trips above use: a crash
|
||||
# between the two must not persist a latch with no explanation
|
||||
# for the operator who has to decide about clearing it.
|
||||
#
|
||||
# ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02
|
||||
# HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback
|
||||
# still finds its latch", and the version being rolled back TO
|
||||
# drives exactly ONE actuator - a roster at which this check is
|
||||
# inert by construction. A flat mirror would hand a downgraded
|
||||
# add-on a latch taken on evidence about a roster it cannot have.
|
||||
inv.store_set("delivery_reason", inv.delivery_wd.reason)
|
||||
inv.store_set("delivery_tripped", True)
|
||||
self._delivery_announced.discard(inv.name)
|
||||
self.log_event(f"NOT DELIVERING {inv.name}: "
|
||||
+ inv.delivery_wd.reason)
|
||||
_LOG.error(
|
||||
"SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED "
|
||||
"DIFFERENTLY: this guard reports and does not act, because "
|
||||
"commanding a unit that is not listening achieves nothing "
|
||||
"(HW-01) and the only re-allocation this add-on can express "
|
||||
"would throw away a partially-delivering unit's remaining "
|
||||
"output. The site is short by that unit's share until "
|
||||
"somebody looks at it. This survives an add-on AND a Home "
|
||||
"Assistant restart.", inv.name, inv.delivery_wd.reason)
|
||||
|
||||
stale_after = float(self.o.get("stale_input_s", 15))
|
||||
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
||||
# does not become a fault; a LATCHED inverter must still reach the claim
|
||||
@@ -2458,6 +2608,15 @@ class Controller:
|
||||
# fresh trip instead of getting its own.
|
||||
await self.check_circulating()
|
||||
|
||||
# --- SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's
|
||||
# It grades `inv.delivery_wd.tripped`, which was advanced near the top
|
||||
# of this method beside SAFETY-02 and SAFETY-31 and which nothing since
|
||||
# has touched. Called on EVERY cycle rather than only on the cycle that
|
||||
# trips, because its second job is re-raising the alarm after a restart
|
||||
# into an existing trip - see its docstring on why that shares one code
|
||||
# path with the fresh trip instead of getting its own.
|
||||
await self.check_not_delivering()
|
||||
|
||||
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
|
||||
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
|
||||
# method assigned above and nothing since has touched.
|
||||
@@ -2795,6 +2954,85 @@ class Controller:
|
||||
f"know why two inverters were opposing."),
|
||||
})
|
||||
|
||||
async def check_not_delivering(self) -> None:
|
||||
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
|
||||
|
||||
⚠️ THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm.
|
||||
`unit/<name>/delivery_tripped` in `/data/state.json` survives everything,
|
||||
but nothing looks at a JSON file at 3 a.m. What the operator sees is a
|
||||
`persistent_notification`, and THAT IS IN MEMORY: a Home Assistant
|
||||
restart deletes it while the fault runs on. That is precisely how the
|
||||
2026-09-02 outage went unseen for 6 h 25 m with the detector correct the
|
||||
whole time (SAFETY-34), and it matters more here than anywhere else in
|
||||
this file, because SAFETY-02 and SAFETY-31 at least leave a stopped site
|
||||
for somebody to notice. This one leaves a site that looks like it is
|
||||
working and is quietly delivering one unit less than it has.
|
||||
|
||||
So the durable half and the visible half are joined HERE, and the join
|
||||
is `_delivery_announced`: a per-PROCESS set of unit names, deliberately
|
||||
not persisted. The latch says "this unit is tripped"; membership says
|
||||
"this process has put that in front of a human". A restart empties the
|
||||
set and keeps the latch, so the notification is RE-RAISED on the first
|
||||
cycle after every add-on start - which is also the first cycle after a
|
||||
Home Assistant restart, because the add-on is restarted with it.
|
||||
|
||||
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s
|
||||
argument. A fresh trip and a restart into an existing trip raise the
|
||||
alarm through these same lines; two paths would be two chances for the
|
||||
restart one to rot unnoticed, and the restart one is the case nobody
|
||||
exercises by hand.
|
||||
|
||||
⚠️ AND ONE NOTIFICATION ID PER UNIT. Every other alarm in this file has
|
||||
a single fixed id because its subject is the site; this one's subject is
|
||||
a named box, and two wedged inverters must produce two cards rather than
|
||||
one replacing the other. The id is built from the same `unit_slugs()`
|
||||
TEL-07 announces the MQTT entities under, so the card and the entity an
|
||||
operator correlates it with carry the same token.
|
||||
"""
|
||||
names = self.units
|
||||
slugs = unit_slugs(names)
|
||||
for inv, slug in zip(self.inverters, slugs):
|
||||
if not inv.delivery_wd.tripped or inv.name in self._delivery_announced:
|
||||
continue
|
||||
# Added BEFORE the await, for `check_circulating()`'s reason:
|
||||
# `hass.call()` can raise or hang, and a retry every cycle for a
|
||||
# broker that is down would be an alarm storm on the event bus. The
|
||||
# latch is already durable and the log line already fired; this
|
||||
# surface is best-effort by design.
|
||||
self._delivery_announced.add(inv.name)
|
||||
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": f"goodwe_not_delivering_{slug}",
|
||||
"title": f"GoodWe: {inv.name} is not delivering its share",
|
||||
"message": (
|
||||
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
|
||||
f"unit sitting at 0 W against a non-zero share is not "
|
||||
f"pushing the wrong way, it is ABSENT: the per-unit sign "
|
||||
f"check needs opposition on both sides of "
|
||||
f"{self.unit_sign_cfg.min_w:.0f} W and never opens, the "
|
||||
f"site sum still agrees with the meter in "
|
||||
f"direction, and circulating power is exactly zero when one "
|
||||
f"unit does nothing. The only other trace is a saturation "
|
||||
f"freeze, which this add-on's own code calls an alarm "
|
||||
f"operators learn to ignore.\n\nNOTHING HAS BEEN COMMANDED "
|
||||
f"DIFFERENTLY. This guard reports and does not act. "
|
||||
f"Commanding an inverter that has stopped listening "
|
||||
f"achieves nothing - that has happened on this hardware, "
|
||||
f"once for 6 h 25 m - and the only re-allocation this "
|
||||
f"add-on can express is all-or-nothing, which would throw "
|
||||
f"away the output of a unit that is delivering PART of its "
|
||||
f"share. So {peers} is still regulating and the site is "
|
||||
f"short by up to {inv.rating_w():.0f} W until somebody "
|
||||
f"looks at {inv.name}.\n\nWHAT TO CHECK, in order: is "
|
||||
f"{inv.name} answering on RS485 at all; is its pack colder "
|
||||
f"or emptier than its configured bms_soc_floor says (if it "
|
||||
f"is, that setting does not describe this battery and this "
|
||||
f"alarm is telling you so); and is anything else writing to "
|
||||
f"it.\n\nThis alarm survives a Home Assistant restart. "
|
||||
f"Restarting will not clear it; start control from the "
|
||||
f"add-on UI once you know why."),
|
||||
})
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
|
||||
@@ -3557,6 +3795,49 @@ class Controller:
|
||||
+ (f" - now {_circ:.0f} W" if _circ else "")
|
||||
+ (f", opposed for {held:.0f} s" if held else "")})
|
||||
|
||||
# --- SAFETY-42 -------------------------------------------------------
|
||||
# ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE
|
||||
# "reports, does not act" DECISION IS VISIBLE TO AN OPERATOR. Every
|
||||
# other latched row in this list means the site is at 0 W and a red
|
||||
# cross is the truth. This one means the site is STILL RUNNING and is
|
||||
# quietly short by one unit's share; rendering it as a hard failure
|
||||
# would say the controller had stopped, which it has not. It is not "ok"
|
||||
# either - `ok: False` with `warn: True` is this list's caution, and a
|
||||
# caution is what a fault nobody is acting on actually is.
|
||||
if len(self.inverters) > 1:
|
||||
for inv in self.inverters:
|
||||
tag = f" ({inv.name})"
|
||||
if inv.delivery_wd.tripped:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"NOT DELIVERING{tag} - "
|
||||
+ inv.delivery_wd.reason
|
||||
+ ". Nothing has been commanded differently and "
|
||||
"the rest of the roster is still regulating, "
|
||||
"so the site is short by this unit's share. "
|
||||
"Starting control clears this row; restarting "
|
||||
"the add-on does NOT, and neither does "
|
||||
"restarting Home Assistant."})
|
||||
else:
|
||||
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"delivery watchdog{tag}: armed (reports after "
|
||||
f"{inv.delivery_wd.cfg.hold_s:.0f} s short of "
|
||||
f"its share by over "
|
||||
f"{self.tuning.saturation_w:.0f} W, with a "
|
||||
f"sibling that is not)"
|
||||
+ (f" - short for {_dheld:.0f} s now"
|
||||
if _dheld else "")})
|
||||
else:
|
||||
# AC 5, and the same honesty `circulating power: n/a` uses one block
|
||||
# up. At one inverter the reduction this check is has no proper
|
||||
# subset to find, for EVERY input, so "armed" would be a claim to be
|
||||
# watching something that cannot happen.
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": "delivery watchdog: n/a at one inverter - a unit "
|
||||
"short of its share is indistinguishable from a "
|
||||
"plant that cannot deliver until there is a "
|
||||
"sibling to compare it with"})
|
||||
|
||||
if not o.get("estop_fitted", False):
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": "no e-stop fitted: if this host dies the battery stays "
|
||||
@@ -3616,6 +3897,26 @@ class Controller:
|
||||
level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}"
|
||||
elif not self.auto:
|
||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
||||
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
|
||||
# Every `bad` banner above means the site is at 0 W; this one means
|
||||
# the site is STILL RUNNING and quietly short by one unit's share,
|
||||
# and a red headline would say the controller had stopped.
|
||||
#
|
||||
# ⚠️ BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT
|
||||
# IS NOT COSMETIC. That sentence is a claim the site cannot make
|
||||
# while a unit is absent - the loop is saturated and the meter is
|
||||
# short - and "the page reads healthy while the fault runs" is
|
||||
# precisely the shape this whole card exists to remove. It sits
|
||||
# BELOW "Stopped" because a stopped site is the more urgent fact and
|
||||
# the banner carries one sentence.
|
||||
_nd = ", ".join(i.name for i in self.inverters
|
||||
if i.delivery_wd.tripped)
|
||||
level, banner = "warn", (
|
||||
f"NOT DELIVERING — {_nd} is standing at a share it is not "
|
||||
f"producing. The site is still running and short by it; "
|
||||
f"nothing has been commanded differently.")
|
||||
elif self.maint.phase != IDLE:
|
||||
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
|
||||
# of one this is the string it has always been, character for
|
||||
@@ -3766,6 +4067,27 @@ class Controller:
|
||||
self.store.set("circ_reason", "")
|
||||
self.log_event("circulating-power trip cleared by operator")
|
||||
self._circ_announced = False
|
||||
# SAFETY-42, cleared by the SAME press, and for a reason that is
|
||||
# NOT the one above. The other three latches are cleared here
|
||||
# because their trip removed its own evidence - a site commanding
|
||||
# 0 W cannot oppose anything - so only a human can say the fault
|
||||
# is gone. This one commands nothing, so its evidence is still
|
||||
# standing and it could in principle clear itself. It does not:
|
||||
# a unit that stopped delivering for minutes and then resumed is
|
||||
# an INTERMITTENT fault, and a detector that erased its own
|
||||
# record of that would be the least useful thing in this file.
|
||||
# ⚠️ AND `_delivery_announced` GOES WITH IT, like
|
||||
# `_circ_announced`: leaving a name in it through a clear would
|
||||
# mean the NEXT trip on that unit latched and raised no
|
||||
# notification at all.
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
self.log_event(f"not-delivering report on {inv.name} "
|
||||
f"cleared by operator")
|
||||
inv.delivery_wd.reset()
|
||||
inv.store_set("delivery_tripped", False)
|
||||
inv.store_set("delivery_reason", "")
|
||||
self._delivery_announced = set()
|
||||
if not self.auto:
|
||||
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
|
||||
# Zeroing `self.target` alone would leave each unit's last share
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.7.0"
|
||||
version: "0.8.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -511,6 +511,52 @@ options:
|
||||
circulation_w: 700
|
||||
circulation_hold_s: 75
|
||||
|
||||
# ⚠️ CFG-03. THIS LINE IS THE SOURCE OF TRUTH FOR THIS DEFAULT, and
|
||||
# `app/delivery.py`'s `DELIVERY_OPTION_DEFAULTS` is the ONE in-code copy -
|
||||
# the Dockerfile does not put this file in the image, so the running add-on
|
||||
# cannot read it. test_delivery.py pins the copy by READING this line, so a
|
||||
# drift goes red in CI rather than shipping. Two places - options:, schema: -
|
||||
# plus that one dict, and no fourth.
|
||||
#
|
||||
# ⚠️ THERE IS NO `delivery_w` TO GO WITH IT, AND THAT IS DELIBERATE. The watts
|
||||
# half of this check is `saturation_w`, the option two blocks down, read
|
||||
# directly: it is already the gap at which the control law itself concludes
|
||||
# the inverter is not following, and a second key would be a fourth place a
|
||||
# threshold has to be kept in step and a way for the two to disagree. So
|
||||
# RAISING `saturation_w` raises this check with it, and test_delivery.py part
|
||||
# 4b holds that promise by running the same fault at two different
|
||||
# `saturation_w` values and requiring two different answers - a check that
|
||||
# only ever ran at 500 could not tell this wiring from a hard-coded number.
|
||||
#
|
||||
# How long ONE inverter may stand at a discharge share it is not producing -
|
||||
# short by more than `saturation_w` - while at least one sibling is NOT in
|
||||
# that state, before the add-on names it. It REPORTS: nothing is commanded
|
||||
# differently, the loop is untouched, and the site goes on running short by
|
||||
# that unit's share until somebody looks. app/delivery.py carries why, with
|
||||
# the rig measurements on both sides of the decision.
|
||||
#
|
||||
# Ignored at one inverter, where the check is inert by construction: with no
|
||||
# sibling, "this box cannot deliver" and "this plant cannot deliver" are the
|
||||
# same observation.
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs
|
||||
# at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the
|
||||
# whole range this schema accepts - across load reversals, a flapping load and
|
||||
# ASYMMETRIC SPLITS, plus 1890 runs at a roster of ONE as the control. At the
|
||||
# shipped threshold the worst CONTINUOUS legitimate episode is 30.0 s (the
|
||||
# weighted family; 20.0 s on reversals, 0.0 s on flapping, and 0.0 s at every
|
||||
# threshold in all 1890 one-inverter runs). 185 s is 6.2x that.
|
||||
#
|
||||
# ⚠️ AND THE MARGIN IS SIZED ON A MEASUREMENT, NOT ON A FEELING, BECAUSE
|
||||
# INVERTER B DOES NOT PHYSICALLY EXIST. The rig's B is A copied. Sweeping a
|
||||
# DIFFERENT second unit through that campaign: the worst legitimate episode is
|
||||
# insensitive to B's ramp (30.0 s at half A's ramp) and scales with its LAG -
|
||||
# 45.0 s at twice A's measured 10 s lag, 90.0 s at four times it. 185 s is
|
||||
# 2.06x even the four-times-slower case.
|
||||
#
|
||||
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
|
||||
# and not 150 or 200. 37 control cycles.
|
||||
delivery_hold_s: 185
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
@@ -772,6 +818,16 @@ schema:
|
||||
# loop must be caught and acted on well inside one.
|
||||
circulation_hold_s: int(30,900)
|
||||
|
||||
# SAFETY-42. The floor of 30 is `inverter.LINK_STALE_S`, for
|
||||
# `circulation_hold_s`'s reason and the same one SAFETY-30's per-unit window
|
||||
# takes it from: below the firmware's own veto window a unit not executing its
|
||||
# share is a LINK fault the add-on already answers by dropping it from the
|
||||
# split and saying so in the action log, and reporting inside that window
|
||||
# would be a second, louder alarm about a state already being handled. The
|
||||
# ceiling of 900 is the quarter that bills the capacity tariff - a site
|
||||
# running short of one unit's share must be reported well inside one.
|
||||
delivery_hold_s: int(30,900)
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
|
||||
@@ -17,8 +17,9 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import sys
|
||||
|
||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC,
|
||||
Tuning, compute, slew_limit_w,
|
||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_DOWN_S, PLANT_SETTLE_S,
|
||||
SETTLE_CYCLES, SLEW_RAIL_FRAC, Tuning, compute,
|
||||
slew_limit_w, unasked_power,
|
||||
maintenance_charge_floor, peak_at_risk)
|
||||
|
||||
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
|
||||
@@ -59,6 +60,34 @@ 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 SAFETY-13 ROWS WERE RE-MEASURED IN ROUND 2 AND THREE OF THEM WERE
|
||||
# WRONG AS FIRST WRITTEN. Every figure below marked (S13) was taken by deleting
|
||||
# the mechanism, purging __pycache__, running this file in the CI image and
|
||||
# counting `FAIL` lines, on THIS tree; the release/1.0 comparison figures were
|
||||
# taken the same way on a pristine c8dcb38 worktree.
|
||||
#
|
||||
# - `output freeze` was written as 11. The mutation form its own note
|
||||
# describes - the whole `target = min(...)/max(...)` line removed, `reason`
|
||||
# left alone - gives 6 on this tree AND 6 on the first SAFETY-13 commit,
|
||||
# against 2 on release/1.0. 11 is not reproducible by that form, or by
|
||||
# removing the line and its `reason` (4), or by swapping min/max (12). The
|
||||
# row is 6.
|
||||
# - `saturation detector, saturated_now = False` was left at 11 while the
|
||||
# SAFETY-13 section added checks that grade it. It is 15.
|
||||
# - the footnote's `frozen = False` figure was left at 10 for the same
|
||||
# reason. It is 13.
|
||||
#
|
||||
# The rows below `output freeze` are this ticket's own mechanisms: removing the
|
||||
# EXIT restores release/1.0's unbounded freeze; making `unasked_power()` return
|
||||
# True gives an exit to the limit case as well, which is the half of the
|
||||
# decision that has to stay unbounded; deriving SETTLE_CYCLES from
|
||||
# PLANT_SETTLE_S (18 s, the UP ramp) instead of PLANT_SETTLE_DOWN_S (23 s) is
|
||||
# the round-1 defect itself; and pinning `_SAT_COUNT_CAP` back at release/1.0's
|
||||
# literal 10 caps the counter below the bound and deletes the exit silently.
|
||||
#
|
||||
# ⚠️ ONLY THE ROWS THIS BRANCH MOVES WERE RE-MEASURED. Everything not marked
|
||||
# (S13) still carries 389d9ec's figures and was not re-run here.
|
||||
#
|
||||
# ⚠️ 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
|
||||
@@ -75,10 +104,14 @@ def check(name, cond):
|
||||
# output clamp 3
|
||||
# slew limit 5
|
||||
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
|
||||
# output freeze 2
|
||||
# output freeze (S13) 6
|
||||
# the output freeze's EXIT (S13), freeze made unconditional again 4
|
||||
# `unasked_power()` discriminates, replaced by `return True` (S13) 6
|
||||
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
|
||||
# `_SAT_COUNT_CAP` pinned back at release/1.0's 10 (S13) 6
|
||||
# deadband 5
|
||||
# quantisation 2
|
||||
# saturation detector, `saturated_now = False` 11
|
||||
# saturation detector, `saturated_now = False` (S13) 15
|
||||
# saturation duration (AC 2), fires instantly 2
|
||||
# sat counter reset on a good cycle 6
|
||||
# target_grid_w bias 3
|
||||
@@ -86,7 +119,8 @@ def check(name, cond):
|
||||
#
|
||||
# The detector figure is for the `saturated_now = False` form specifically;
|
||||
# disabling it further down as `frozen = False` is a weaker mutation and gives
|
||||
# 10. Reproduce the same form or the number will not match.
|
||||
# 13 on this tree (10 on release/1.0). Reproduce the same form or the number
|
||||
# will not match.
|
||||
#
|
||||
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
|
||||
# for the mechanism under test. Deleting the integrator freeze once failed
|
||||
@@ -282,8 +316,15 @@ check("frozen at i_w=0.0: an export push moves the integrator",
|
||||
# hundreds of watts means something else is driving the bus. Asserted so that
|
||||
# nobody "fixes" it by accident - the integrator moving is what this ticket
|
||||
# owns, the command rule belongs to the output freeze.
|
||||
#
|
||||
# ⚠️ SAFETY-13 KEPT THIS ASSERTION RATHER THAN INVERTING IT, and the fixture is
|
||||
# why it survived unchanged: `sat_count=3` becomes 4, which is below
|
||||
# SETTLE_CYCLES, so this is still inside the window in which the block holds.
|
||||
# SAFETY-13 gave the block an EXIT, not a different opinion about its first
|
||||
# cycles - see the SAFETY-13 section below, where the same state is driven past
|
||||
# the bound and the release is asserted on the other side of it.
|
||||
check("frozen at i_w=0.0: the output freeze still blocks a charge from 0 W",
|
||||
ze.target_w == 0.0)
|
||||
ze.target_w == 0.0 and ze.sat_count < SETTLE_CYCLES)
|
||||
# Where prev_w is already charging the output freeze does NOT block, and there
|
||||
# the difference reaches the wire: held at 0.0 the integrator abandons the
|
||||
# charge mid-export.
|
||||
@@ -358,6 +399,348 @@ check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <=
|
||||
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
|
||||
loose > tight)
|
||||
|
||||
print("SAFETY-13: the output freeze has an exit, and only where it needs one")
|
||||
|
||||
# THE SHAPE OF THIS SECTION. release/1.0's output freeze holds the command's
|
||||
# magnitude down while the inverter is not following, and nothing ends it. At
|
||||
# `prev_w == 0` the held magnitude is ZERO, so it reads as "refuses to start a
|
||||
# charge"; that is the case the card was written about, but it is one instance
|
||||
# of a rule that is unbounded at every prev_w. SAFETY-13 bounds it exactly where
|
||||
# it CANNOT end on its own - see `unasked_power()` - and nowhere else.
|
||||
|
||||
# --- 1. the predicate, which is the whole of the decision -------------------
|
||||
# A limit can only make the inverter do LESS than the command, in the command's
|
||||
# own direction, so everything a limit can produce lies in the interval spanned
|
||||
# by 0 and prev_w widened by saturation_w. These are the four cases in the
|
||||
# docstring, run rather than described.
|
||||
check("limit: 700 W under a +2000 W command is explainable",
|
||||
not unasked_power(2000.0, 700.0, 500.0))
|
||||
check("limit: -700 W under a -5000 W command is explainable",
|
||||
not unasked_power(-5000.0, -700.0, 500.0))
|
||||
check("unasked: +700 W under a 0 W command is not",
|
||||
unasked_power(0.0, 700.0, 500.0))
|
||||
check("unasked: +700 W under a -800 W command is not",
|
||||
unasked_power(-800.0, 700.0, 500.0))
|
||||
# The band edges belong to the limit side, because saturation_w is the same
|
||||
# tolerance the detector uses - a reading the detector calls "following" must
|
||||
# never be called "another writer".
|
||||
check("the saturation band is inclusive on both edges",
|
||||
not unasked_power(0.0, 500.0, 500.0)
|
||||
and not unasked_power(0.0, -500.0, 500.0)
|
||||
and unasked_power(0.0, 500.01, 500.0))
|
||||
# ⚠️ AT prev_w == 0 THE PREDICATE AND THE DETECTOR ARE THE SAME TEST, which is
|
||||
# why the zero case is the one that strands: with nothing commanded there is no
|
||||
# interval for a limit to live in.
|
||||
same = all(unasked_power(0.0, a, 500.0) == (abs(a) > 500.0)
|
||||
for a in [x * 5.0 for x in range(-400, 401)])
|
||||
check("at prev_w == 0 unasked_power IS the saturation test, over 801 readings",
|
||||
same)
|
||||
|
||||
# --- 2. the bound is derived, and from WHICH HALF of the measurement --------
|
||||
# ⚠️ THE DIRECTION IS THE ASSERTION. goodwe/NOTES.md 5.10 measured the same
|
||||
# 1.78 kW step both ways: ~18 s up, ~23 s down. SAFETY-39 takes the SHORTER
|
||||
# one because its bound is a REFUSAL and the looser number refuses less. This
|
||||
# bound asks the opposite question - how long the plant may still be the
|
||||
# explanation for a divergence - and for that the conservative half is the
|
||||
# LONGER one. The first version of this file derived it from 18, got 9, and
|
||||
# that is three cycles short of the plant's own measured down-ramp, in the
|
||||
# direction that ACCUSES rather than refuses (section 8 below drives it).
|
||||
check(f"SETTLE_CYCLES is ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) ="
|
||||
f" {SETTLE_CYCLES}",
|
||||
SETTLE_CYCLES == 12
|
||||
and SETTLE_CYCLES * LOOP_TICK_S >= PLANT_SETTLE_DOWN_S
|
||||
and (SETTLE_CYCLES - 1) * LOOP_TICK_S < PLANT_SETTLE_DOWN_S)
|
||||
check("...from the LONGER of the two measured settle times, so it is strictly"
|
||||
" above what the shorter one would have given",
|
||||
PLANT_SETTLE_DOWN_S > PLANT_SETTLE_S
|
||||
and SETTLE_CYCLES > -(-PLANT_SETTLE_S // LOOP_TICK_S))
|
||||
# ...and it is NOT SLEW_RAIL_FRAC's reciprocal any more. It was, while both
|
||||
# were being derived from the same half of the pair, and that coincidence read
|
||||
# as a derivation. Asserted as an INEQUALITY so the two cannot silently be
|
||||
# re-fused into one number again.
|
||||
check("the slew's rail crossing and this bound are now different numbers",
|
||||
SETTLE_CYCLES > 1.0 / SLEW_RAIL_FRAC)
|
||||
# The counter has to be able to REACH the bound or the exit does not exist.
|
||||
# Driven, not read off the constant: run a saturated state and see how high the
|
||||
# counter actually goes.
|
||||
c, sc = 0, 0
|
||||
while c < 50:
|
||||
sc = compute(0.0, -2000.0, 900.0, T, sc).sat_count
|
||||
c += 1
|
||||
check(f"the saturation counter reaches {sc}, which is above the bound",
|
||||
sc >= SETTLE_CYCLES)
|
||||
|
||||
# --- 3. prev_w == 0: blocked below the bound, released above it -------------
|
||||
# The same state either side of the bound. This is the pair the card asks for.
|
||||
blk = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
|
||||
check("prev_w=0: below the bound the charge is still refused",
|
||||
blk.target_w == 0.0 and blk.reason == "saturated-freeze"
|
||||
and blk.sat_count == SETTLE_CYCLES - 1)
|
||||
rel = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
|
||||
check(f"prev_w=0: at the bound it is released to {rel.target_w:.0f} W",
|
||||
rel.target_w < 0.0 and rel.reason == "freeze-released"
|
||||
and rel.sat_count == SETTLE_CYCLES)
|
||||
# ...and the released command is the SLEW step from 0, not the rail. A release
|
||||
# that jumped to the integrator in one cycle would be the windup this ticket
|
||||
# was told to avoid.
|
||||
check(f"the released command is exactly one slew step"
|
||||
f" ({slew_limit_w(T):.0f} W)",
|
||||
abs(rel.target_w) == slew_limit_w(T))
|
||||
# The mirror. Nothing here is sign-specific, so the discharge side must behave
|
||||
# identically - every freeze rule in this file has a mirror, because the one
|
||||
# that did not is the defect that got through review.
|
||||
mblk = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
|
||||
mrel = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
|
||||
check("prev_w=0, discharging: same block, same release, mirrored",
|
||||
mblk.reason == "saturated-freeze" and mrel.reason == "freeze-released"
|
||||
and mrel.target_w == slew_limit_w(T))
|
||||
|
||||
# --- 4. non-zero prev_w, and the two halves are NOT the same --------------
|
||||
# (a) A divergence a LIMIT could have produced is never released, however long
|
||||
# it stands. This is release/1.0's behaviour and it is kept deliberately: the
|
||||
# freeze clears itself here, because the command is pinned at something the
|
||||
# inverter can do.
|
||||
TL = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0)
|
||||
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
|
||||
for _ in range(30):
|
||||
d = compute(prev, 4000.0, max(-1000.0, min(1000.0, prev)), TL, sat, i_w)
|
||||
prev, i_w, sat, _r = d.target_w, d.i_w, d.sat_count, reasons.add(d.reason)
|
||||
check("non-zero prev_w, a curtailed inverter: never released over 30 cycles",
|
||||
"freeze-released" not in reasons and prev == 2400.0)
|
||||
# (b) A divergence a limit could NOT have produced is released at the bound,
|
||||
# at a non-zero prev_w exactly as at zero. release/1.0 pinned this run at
|
||||
# -800 W, against its own integrator standing at -2388 W, for as long as it ran.
|
||||
prev, i_w, sat, rows = 0.0, 0.0, 0, []
|
||||
for _ in range(20):
|
||||
d = compute(prev, -2000.0, 700.0, TL, sat, i_w)
|
||||
rows.append((prev, d.target_w, d.i_w, d.reason))
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
pinned = [r for r in rows if r[3] == "saturated-freeze"]
|
||||
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
|
||||
pinned and all(r[1] == -800.0 for r in pinned))
|
||||
check(f"...then released, settling at {prev:.0f} W", prev == -2390.0)
|
||||
|
||||
# --- 5. THE CARD'S OWN CASE, DRIVEN END TO END -----------------------------
|
||||
# The live install's shape (max_w 5000, slew_w 400, target_grid_w -10). The site
|
||||
# rests in the deadband with another writer holding the pack at +700 W - so the
|
||||
# saturation counter climbs while the command is 0 and nothing is wrong - and
|
||||
# then 2 kW of export arrives. On release/1.0 @ c8dcb38 the command is 0.0 W on
|
||||
# every cycle from there on: 0 W is a FIXED POINT, and the export is given away
|
||||
# for as long as the other writer stays.
|
||||
prev, i_w, sat, cmds = 0.0, 0.0, 0, []
|
||||
for c in range(24):
|
||||
d = compute(prev, -10.0 if c < 6 else -2000.0, 700.0, TL, sat, i_w)
|
||||
cmds.append(d.target_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
check("the card's case: the block does hold at first, at 0 W",
|
||||
cmds[6] == 0.0 and cmds[7] == 0.0)
|
||||
started = [i for i, c in enumerate(cmds) if c != 0.0]
|
||||
check(f"the card's case: the charge starts at cycle {started[0]}"
|
||||
f" instead of never", bool(started))
|
||||
# ⚠️ AND IT STOPS AT THE FROZEN INTEGRATOR, NOT AT THE RAIL. This is AC 4: the
|
||||
# integrator moved while the output was blocked, and the release must not turn
|
||||
# that divergence into windup. It cannot, because releasing the OUTPUT freeze
|
||||
# does not release the INTEGRATOR freeze - so the command can only walk, one
|
||||
# slew step per cycle, to a value the integrator stopped accumulating at.
|
||||
check(f"the card's case: it settles at {prev:.0f} W, at the frozen integrator "
|
||||
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail",
|
||||
prev == -1190.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
|
||||
steps = [abs(cmds[i] - cmds[i - 1]) for i in range(1, len(cmds))]
|
||||
check(f"the card's case: no cycle moves more than the slew limit "
|
||||
f"(worst {max(steps):.0f} W of {slew_limit_w(TL):.0f})",
|
||||
max(steps) <= slew_limit_w(TL))
|
||||
|
||||
# --- 6. AC 4, AS A PROPERTY RATHER THAN A TRAJECTORY -----------------------
|
||||
# ⚠️ THE FIRST FORM OF THIS CHECK WAS WRONG AND THE SWEEP SAID SO, which is
|
||||
# worth leaving in the file. It asserted `|target| <= |i_w|` on a released
|
||||
# cycle, and the sweep produced prev_w -5000, i_w -3000, target -4600: the
|
||||
# command was walking BACK from -5000 toward the integrator and had not
|
||||
# arrived. It was moving the right way and the assertion still failed, because
|
||||
# "never exceeds the integrator" is not what no-windup means here.
|
||||
#
|
||||
# What it means: a released cycle can only move the command TOWARD the
|
||||
# integrator, by at most one slew step, and never past it. So the divergence
|
||||
# that built up while the output was blocked cannot arrive as a jump - it is
|
||||
# spent one slew step at a time, against an integrator that stopped
|
||||
# accumulating when the freeze engaged and is not released with the output.
|
||||
# ⚠️ AND THE COUNT BELOW IS THE RELEASED STATES, NOT THE SWEPT ONES. The first
|
||||
# version of this check named 41*25*21*5 - the size of the sweep - while
|
||||
# grading only the subset that actually releases, so the number in the message
|
||||
# was not the number the assertion was about. It is counted now.
|
||||
step = float(T.step_w)
|
||||
swept, past, jumped = 0, [], []
|
||||
n_released = 0
|
||||
for prev in [x * 250.0 for x in range(-20, 21)]:
|
||||
for grid in [x * 500.0 for x in range(-12, 13)]:
|
||||
for actual in [x * 500.0 for x in range(-10, 11)]:
|
||||
for i0 in (-3000.0, -500.0, 0.0, 500.0, 3000.0):
|
||||
swept += 1
|
||||
d = compute(prev, grid, actual, TL, SETTLE_CYCLES, i0)
|
||||
if d.reason != "freeze-released":
|
||||
continue
|
||||
n_released += 1
|
||||
lo, hi = min(prev, d.i_w), max(prev, d.i_w)
|
||||
if not (lo - step <= d.target_w <= hi + step):
|
||||
past.append((prev, grid, actual, i0, d.target_w, d.i_w))
|
||||
if abs(d.target_w - prev) > slew_limit_w(TL) + step:
|
||||
jumped.append((prev, grid, actual, i0, d.target_w))
|
||||
check(f"of {swept} states swept, the {n_released} that release keep the command"
|
||||
f" between the previous command and the frozen integrator"
|
||||
+ (f" (e.g. {past[0]})" if past else ""), not past and n_released > 0)
|
||||
check("...and no released cycle moves further than one slew step"
|
||||
+ (f" (e.g. {jumped[0]})" if jumped else ""), not jumped)
|
||||
|
||||
# --- 7. NO ACCEPTED CONFIGURATION CAN DELETE THE BLOCK ---------------------
|
||||
# ⚠️ THIS CHECK REPLACES ITS OWN OPPOSITE, AND THE REASON IS WORTH KEEPING.
|
||||
# While the bound was 9, `saturation_cycles` - which config.yaml accepts up to
|
||||
# 10 - could be set AT OR ABOVE it, and then the freeze did not engage until
|
||||
# its own exit was already due: the no-start block silently stopped existing,
|
||||
# and this file asserted that as a known consequence. Moving the bound to 12
|
||||
# put the whole accepted range strictly below it, so the degenerate
|
||||
# configuration is gone rather than documented.
|
||||
#
|
||||
# It is pinned against config.yaml ITSELF and not a transcription, because the
|
||||
# property is "two files agree" and a transcription is what lets them drift.
|
||||
# Regex, not PyYAML: the add-on image ships no PyYAML (test_validate.py says
|
||||
# the same thing at more length).
|
||||
import os # noqa: E402
|
||||
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:
|
||||
_m = re.search(r"^ saturation_cycles: int\((\d+),(\d+)\)\s*$",
|
||||
_fh.read(), re.M)
|
||||
check("config.yaml's saturation_cycles range really was read", _m is not None)
|
||||
_sc_lo, _sc_hi = int(_m.group(1)), int(_m.group(2))
|
||||
check(f"every accepted saturation_cycles ({_sc_lo}..{_sc_hi}) is strictly"
|
||||
f" below the bound ({SETTLE_CYCLES}), so no accepted configuration can"
|
||||
f" push the freeze's engagement past its own exit",
|
||||
_sc_hi < SETTLE_CYCLES)
|
||||
# Driven at the schema's edge rather than argued: the block still engages, and
|
||||
# it engages before the exit is due.
|
||||
TN = Tuning(slew_w=1000.0, saturation_cycles=_sc_hi)
|
||||
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
|
||||
first_freeze = None
|
||||
for _ in range(20):
|
||||
d = compute(prev, -2000.0, 700.0, TN, sat, i_w)
|
||||
reasons.add(d.reason)
|
||||
if d.reason == "saturated-freeze" and first_freeze is None:
|
||||
first_freeze = d.sat_count
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
check(f"at the largest accepted saturation_cycles ({_sc_hi}) the block still"
|
||||
f" engages, at sat_count {first_freeze}, below the bound",
|
||||
"saturated-freeze" in reasons and first_freeze is not None
|
||||
and first_freeze < SETTLE_CYCLES)
|
||||
|
||||
# --- 8. THE BOUND'S DIRECTION, DRIVEN ON ONE INVERTER ----------------------
|
||||
# ⚠️ WHY THIS SECTION EXISTS. The exit's two conditions are read as "another
|
||||
# writer is on the actuator". They are not that, and the first shipped form of
|
||||
# this file said they were. `unasked_power()` compares a reading against ONE
|
||||
# cycle's command while the pack carries every command before it, so a command
|
||||
# that has fallen THROUGH ZERO - or simply fallen faster than the pack coasts -
|
||||
# leaves our own power outside our own interval. With the bound derived from
|
||||
# the 18 s UP measurement that is not a corner case: it is what a normal load
|
||||
# step does.
|
||||
#
|
||||
# The plant here is a MODEL and is labelled one. It is a first-order lag whose
|
||||
# two time constants come from the two measured settling times (a first-order
|
||||
# step is 95 % done at 3 tau, so tau = t / 3), with the ramp cap
|
||||
# sim/plantsim.py measured on inverter A. It is not the rig and it is not the
|
||||
# house; what it is for is that the two settling times ENTER the model in the
|
||||
# same direction they enter the constant, so the check moves if either does.
|
||||
#
|
||||
# ⚠️ AND THERE IS NO SECOND WRITER IN THIS SECTION. One `compute()`, one
|
||||
# plant, one command. Every `freeze-released` below is therefore false.
|
||||
import math # noqa: E402
|
||||
|
||||
|
||||
class _Plant:
|
||||
"""One inverter, obeying our command and nothing else. A MODEL."""
|
||||
|
||||
RAMP_W_S = 800.0 # sim/plantsim.py INVERTER_A, measured=True
|
||||
|
||||
def __init__(self, up_s, down_s):
|
||||
self.a_up = 1.0 - math.exp(-LOOP_TICK_S / (up_s / 3.0))
|
||||
self.a_dn = 1.0 - math.exp(-LOOP_TICK_S / (down_s / 3.0))
|
||||
self.w = 0.0
|
||||
|
||||
def tick(self, cmd):
|
||||
a = self.a_up if abs(cmd) > abs(self.w) else self.a_dn
|
||||
cap = self.RAMP_W_S * LOOP_TICK_S
|
||||
self.w += max(-cap, min(cap, a * (cmd - self.w)))
|
||||
|
||||
|
||||
from app import control as _control # noqa: E402
|
||||
|
||||
TS8 = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0,
|
||||
saturation_w=500.0, saturation_cycles=3)
|
||||
|
||||
|
||||
def _drive(bound, load1, load2, cycles=60, switch=25):
|
||||
"""Run one closed loop at an arbitrary bound. -> (release cycles, rows)."""
|
||||
was_b, was_c = _control.SETTLE_CYCLES, _control._SAT_COUNT_CAP
|
||||
# ⚠️ BOTH, OR THE PROBE LIES: `_SAT_COUNT_CAP` is what `compute()` caps the
|
||||
# counter with, so moving the bound without it caps the counter BELOW the
|
||||
# bound and the exit quietly becomes unreachable - which reads as "no false
|
||||
# positives". That is the hazard `_SAT_COUNT_CAP`'s own comment names.
|
||||
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = bound, bound + 1
|
||||
try:
|
||||
p = _Plant(PLANT_SETTLE_S, PLANT_SETTLE_DOWN_S)
|
||||
prev, i_w, sat, rows = 0.0, 0.0, 0, []
|
||||
for c in range(cycles):
|
||||
load = load1 if c < switch else load2
|
||||
d = compute(prev, load - p.w, p.w, TS8, sat, i_w)
|
||||
rows.append((c, prev, p.w, d.target_w, d.sat_count, d.reason))
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
p.tick(prev)
|
||||
return [r for r in rows if r[5] == "freeze-released"], rows
|
||||
finally:
|
||||
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = was_b, was_c
|
||||
|
||||
|
||||
_SHORT = int(-(-PLANT_SETTLE_S // LOOP_TICK_S)) # what 18 s would give: 9
|
||||
|
||||
# A 4 kW export falling to 2.5 kW. Nothing exotic; no second writer.
|
||||
_bad, _rows = _drive(_SHORT, -4000.0, -2500.0)
|
||||
check(f"a bound derived from the SHORTER measurement ({_SHORT} cycles) fires"
|
||||
f" the exit on ONE inverter with nothing else on the bus"
|
||||
+ (f" - {len(_bad)} cycle(s), first at cycle {_bad[0][0]}" if _bad else ""),
|
||||
bool(_bad))
|
||||
if _bad:
|
||||
_c, _prev, _act, _tgt, _sat, _ = _bad[0]
|
||||
check(f"...and what it called unasked is our own coast-down: command"
|
||||
f" {_prev:.0f} W, pack still at {_act:.1f} W, and the pack was"
|
||||
f" commanded {min(r[1] for r in _rows[:_c]):.0f} W a few cycles"
|
||||
f" earlier",
|
||||
unasked_power(_prev, _act, TS8.saturation_w)
|
||||
and abs(_act) > abs(_prev)
|
||||
and min(r[1] for r in _rows[:_c]) < _act)
|
||||
_good, _ = _drive(SETTLE_CYCLES, -4000.0, -2500.0)
|
||||
check(f"the shipped bound ({SETTLE_CYCLES} cycles, the LONGER measurement)"
|
||||
f" does not fire on the same run", not _good)
|
||||
# ...and not on any of these, either. The whole grid of load steps the same
|
||||
# plant can be given, at the tuning every headline figure in this ticket is
|
||||
# quoted from.
|
||||
_L = [x * 500.0 for x in range(-8, 9)]
|
||||
_fired = {}
|
||||
for _b in (_SHORT, SETTLE_CYCLES):
|
||||
_fired[_b] = sum(1 for _a in _L for _z in _L
|
||||
if _a != _z and _drive(_b, _a, _z)[0])
|
||||
_pairs = len(_L) * (len(_L) - 1)
|
||||
check(f"over {_pairs} load-step pairs on the measured plant, one inverter and"
|
||||
f" no second writer: the short bound fires in {_fired[_SHORT]} of them,"
|
||||
f" the shipped bound in {_fired[SETTLE_CYCLES]}",
|
||||
_fired[SETTLE_CYCLES] < _fired[_SHORT])
|
||||
# ⚠️ AND THE SHIPPED FIGURE IS NOT ZERO IN GENERAL. It is small here because
|
||||
# the plant in this model is the one that was measured. A plant slower than
|
||||
# that brings the false release back, and no constant derived from a
|
||||
# measurement can fix that - it is why DOCS.md tells the operator what
|
||||
# `freeze-released` is evidence OF, rather than what it proves.
|
||||
|
||||
print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
|
||||
|
||||
|
||||
@@ -394,8 +777,37 @@ def legacy(prev, grid, actual, t, sat_count):
|
||||
# "i-clamped", because the truncation happens on the integrator before the
|
||||
# command is derived from it. Aliased here rather than papered over - if any
|
||||
# OTHER reason ever diverges, this check goes red.
|
||||
# ⚠️ SAFETY-13 IS A DELIBERATE DIVERGENCE FROM `legacy()`, AND THE SWEEP NOW
|
||||
# GRADES THE DIVERGENCE INSTEAD OF FORBIDDING IT. `legacy()` above stays a
|
||||
# transcription of release/1.0 - it is not edited to agree, for the reason the
|
||||
# note on `slew_w` gives: an oracle taught the new rule compares the new law
|
||||
# against itself. What changed is the assertion. Every case must either be
|
||||
# IDENTICAL to release/1.0, or be a case where release/1.0 said
|
||||
# "saturated-freeze" and this law says "freeze-released" with the two conditions
|
||||
# that permit it both true. Anything else is a regression, and the counts below
|
||||
# say how much of the sweep each answer covers.
|
||||
ALIAS = {"i-clamped": "clamped"}
|
||||
# ⚠️ SAFETY-13 ROUND 2 MOVED THE COUNTER'S CEILING, SO THE SWEEP NOW HAS A
|
||||
# THIRD ANSWER. `_SAT_COUNT_CAP` is `SETTLE_CYCLES + 1`, which was 10 - the
|
||||
# same literal release/1.0 used - and is now 13. Above release/1.0's own
|
||||
# ceiling the two counters therefore differ, and NOTHING ELSE MAY: `frozen` is
|
||||
# `sat_count >= saturation_cycles` and config.yaml accepts that at most 10, so
|
||||
# a counter reading 13 instead of 10 cannot change it, and no other line in
|
||||
# `compute()` reads the counter's value. That is asserted rather than asserted-
|
||||
# by-omission, in `cap_diffs` below.
|
||||
#
|
||||
# ⚠️ AND THE SWEEP HAS TO REACH THE BOUND OR IT GRADES NOTHING. `sc` runs to 12
|
||||
# now; at 9 the counter tops out one short of the exit and every release
|
||||
# silently disappears from this table, which is exactly what happened when the
|
||||
# bound moved and is why the count is asserted and not merely printed. 12 is a
|
||||
# state release/1.0 could never hold - its counter stopped at 10 - so
|
||||
# `legacy()` is being fed one input outside its own reachable domain, on
|
||||
# purpose, and `cap_diffs` is where that shows up.
|
||||
LEGACY_SAT_CAP = 10 # release/1.0's literal, transcribed in legacy()
|
||||
diffs = []
|
||||
released = []
|
||||
value_diffs = []
|
||||
cap_diffs = []
|
||||
seen = set()
|
||||
# ⚠️ `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
|
||||
@@ -409,16 +821,69 @@ for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
|
||||
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):
|
||||
for sc in (0, 2, 3, 9):
|
||||
for sc in (0, 2, 3, 9, 12):
|
||||
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
|
||||
seen.add(d.reason)
|
||||
got = (d.target_w, d.sat_count, d.frozen,
|
||||
ALIAS.get(d.reason, d.reason))
|
||||
if got != legacy(prev, grid, actual, tune, sc):
|
||||
diffs.append((prev, grid, actual, sc, got,
|
||||
legacy(prev, grid, actual, tune, sc)))
|
||||
check(f"i_w=None reproduces release/1.0 over {3*7*9*4*4} cases, reason included"
|
||||
+ (f" (first diff {diffs[0]})" if diffs else ""), not diffs)
|
||||
was = legacy(prev, grid, actual, tune, sc)
|
||||
if got == was:
|
||||
continue
|
||||
# everything that reaches the wire, without the counter
|
||||
wire_same = (got[0], got[2], got[3]) == (was[0], was[2], was[3])
|
||||
if wire_same:
|
||||
# the ceiling moved and nothing else did. Only legal
|
||||
# above release/1.0's own cap, and only with both
|
||||
# counters standing at their own ceilings.
|
||||
if (sc >= LEGACY_SAT_CAP
|
||||
and was[1] == LEGACY_SAT_CAP
|
||||
and got[1] == _control._SAT_COUNT_CAP):
|
||||
cap_diffs.append((prev, grid, actual, sc, got[1], was[1]))
|
||||
continue
|
||||
diffs.append((prev, grid, actual, sc, got, was))
|
||||
continue
|
||||
explained = (
|
||||
d.reason == "freeze-released"
|
||||
and was[3] == "saturated-freeze"
|
||||
and d.sat_count >= SETTLE_CYCLES
|
||||
and unasked_power(prev, actual, tune.saturation_w))
|
||||
if not explained:
|
||||
diffs.append((prev, grid, actual, sc, got, was))
|
||||
continue
|
||||
released.append((prev, grid, actual, sc))
|
||||
if got[0] != was[0]:
|
||||
value_diffs.append((prev, got[0], was[0]))
|
||||
TOTAL = 3 * 7 * 9 * 4 * 5
|
||||
check(f"i_w=None reproduces release/1.0 over {TOTAL} cases except where"
|
||||
f" SAFETY-13 releases the freeze, reason included"
|
||||
+ (f" (first unexplained diff {diffs[0]})" if diffs else ""), not diffs)
|
||||
# ⚠️ EXACT COUNTS, NOT INEQUALITIES. A weaker form of this line ("some
|
||||
# releases happened") was written while the bound was moving, and the coverage
|
||||
# audit caught it immediately: a `unasked_power()` that stops discriminating
|
||||
# went from 6 checks to 5. The three numbers partition the sweep, so any one of
|
||||
# them moving is a change somebody has to explain.
|
||||
check(f"{TOTAL - len(released) - len(cap_diffs)} of {TOTAL} cases are"
|
||||
f" bit-identical to release/1.0; {len(released)} are releases;"
|
||||
f" {len(cap_diffs)} differ in the saturation counter alone",
|
||||
len(released) == 432 and len(cap_diffs) == 81
|
||||
and TOTAL - len(released) - len(cap_diffs) == 3267)
|
||||
# ⚠️ THE COUNTER-ONLY DIFFERENCES ARE NOT A LOOPHOLE, so they are pinned twice:
|
||||
# they may only occur at an `sc` above release/1.0's ceiling, and the largest
|
||||
# `saturation_cycles` config.yaml accepts is below BOTH ceilings, so no
|
||||
# accepted configuration can see the difference at all.
|
||||
check(f"the {len(cap_diffs)} counter-only differences all sit above"
|
||||
f" release/1.0's cap of {LEGACY_SAT_CAP}, where no accepted"
|
||||
f" saturation_cycles can read them",
|
||||
all(r[3] >= LEGACY_SAT_CAP for r in cap_diffs) and _sc_hi <= LEGACY_SAT_CAP)
|
||||
# ⚠️ AND THE RELEASES ARE MOSTLY A LABEL. Only these change what reaches the
|
||||
# wire; the rest report "freeze-released" on a command the freeze was not
|
||||
# binding on anyway. Every one of them is a charge that release/1.0 refused to
|
||||
# start from exactly 0 W, which is the ticket, stated as a count.
|
||||
check(f"{len(value_diffs)} of those {len(released)} change the command,"
|
||||
f" and every one is a charge start from prev_w == 0 that was refused",
|
||||
len(value_diffs) == 27
|
||||
and all(p == 0.0 and new_ < 0.0 and old_ == 0.0
|
||||
for p, new_, old_ in value_diffs))
|
||||
|
||||
# ...and the rename is not a quiet deletion: the signal SAFETY-03 alarms on has
|
||||
# to actually occur in that sweep, or its hook is dead.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1917,6 +1917,12 @@ _AUDITED = {
|
||||
("test_freshness.py", "option_default"),
|
||||
("test_price.py", "shipped"),
|
||||
("test_tariff.py", "shipped_option"),
|
||||
# SAFETY-42. `read_option()` there takes a key and reads config.yaml, so the
|
||||
# signature puts it in this table whether anybody remembered it or not -
|
||||
# which is what this table is for. It answers the SITE value because it is
|
||||
# anchored at exactly two spaces; a loose-whitespace pattern would have
|
||||
# answered a per-inverter `max_w`, which is the defect below.
|
||||
("test_delivery.py", "read_option"),
|
||||
}
|
||||
# Helpers the audited readers call. Not called with a key themselves, so there
|
||||
# is nothing to feed them; they are exec'd so their callers work.
|
||||
|
||||
@@ -130,6 +130,11 @@ DERIVED = {
|
||||
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
|
||||
"circulation_w": ("SAFETY-31", "924"),
|
||||
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
|
||||
# SAFETY-42. Two needles rather than one: the campaign it was derived from,
|
||||
# and the word that says what it does NOT do. An operator who reads
|
||||
# "not-delivering report" and assumes the add-on has handled it is the
|
||||
# failure this alarm's whole wording is written against.
|
||||
"delivery_hold_s": ("SAFETY-42", "DERIVED FROM", "REPORTS ONLY"),
|
||||
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
|
||||
"bms_soc_floor": ("0x0560", "MEASURED"),
|
||||
"integrator_max_w": ("MEASURED", "Do not raise it"),
|
||||
|
||||
@@ -234,6 +234,9 @@ configuration:
|
||||
circulation_hold_s:
|
||||
name: "Safety · Circulating-power hold (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
|
||||
delivery_hold_s:
|
||||
name: "Safety · Not-delivering report after (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus 1890 runs at one inverter as the control, which report nothing at any threshold): the worst legitimate episode those runs produce is 30.0 s, the floor of 30 s is the firmware's own link veto below which a unit is already being dropped from the split, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 6.2 times that, and the margin is sized on a measurement rather than a feeling: the second inverter in those tests is a copy of the first, and a second unit four times slower produces 90.0 s. Do not change it without re-deriving it. A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
|
||||
auto_start:
|
||||
name: "Advanced · Start controlling automatically on boot"
|
||||
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
|
||||
|
||||
@@ -214,6 +214,9 @@ configuration:
|
||||
circulation_hold_s:
|
||||
name: "Veiligheid · Wachttijd rondpompend vermogen (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
delivery_hold_s:
|
||||
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus 1890 tests met een omvormer als controlegroep, die bij geen enkele drempel iets melden): de langste terechte episode in die tests duurt 30.0 s, de ondergrens van 30 s is het eigen veto van de firmware waaronder een omvormer al uit de verdeling wordt gehaald, en de bovengrens is het factuurkwartier van 900 s. 185 s is 6,2 keer zoveel, en die marge is op een meting gebaseerd en niet op een gevoel: de tweede omvormer in die tests is een kopie van de eerste, en een tweede eenheid die vier keer trager is levert 90,0 s op. Verander dit niet zonder de afleiding opnieuw te maken. Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
|
||||
auto_start:
|
||||
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
|
||||
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
|
||||
|
||||
Reference in New Issue
Block a user