Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f156fadac0 | ||
|
|
6c4565659b | ||
|
|
c8dcb38a95 | ||
|
|
8e241a3243 | ||
|
|
6864109a01 | ||
|
|
a4290d39ac |
@@ -31,6 +31,105 @@ guessing a boundary; saying so is more useful than a guess.
|
||||
|
||||
## Unreleased
|
||||
|
||||
⚠️ **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.
|
||||
|
||||
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
|
||||
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
|
||||
`0x0518`) goes through a freshness gate: unchanged for >= 35 s *while* the
|
||||
standing command is over 250 W *and* has travelled over 250 W since the reading
|
||||
last moved. A stale reading is a **missing input**, which §11.1 rule 1 already
|
||||
answers with 0 W and keeps commanding.
|
||||
|
||||
**This is a behaviour change at the shipped roster of one, and it is deliberate.**
|
||||
Before it, a frozen power register read as a healthy measurement for ever - which
|
||||
is [[HW-01]], where a value held for 6 h 25 m while the pack drained to its floor
|
||||
and every detector read healthy. A healthy install is byte-identical: sixty reads
|
||||
of a dithering register under a reversing command all reach the consumers
|
||||
unchanged, asserted rather than assumed. A false verdict fails toward 0 W, behind
|
||||
the existing `stale_input_s` hold, and clears itself the moment the register
|
||||
reports a different value.
|
||||
|
||||
At two or more inverters it also removes two false alarms that had no gate at
|
||||
all: [[SAFETY-30]]'s per-unit sign check no longer trips on a frozen reading
|
||||
(measured: it trips without the gate and NEVER with it - the tick index is
|
||||
deliberately not quoted, because it is counted on the wall clock inside
|
||||
`cycle()` and is therefore load-dependent; the checkable statement is the
|
||||
presence or absence of the trip, which part 3 grades), and
|
||||
[[SAFETY-31]]'s circulating-power sensor reports `None` instead of a loop that is
|
||||
not happening.
|
||||
|
||||
**No new configuration key, and no schema change.** The bound is derived from
|
||||
`circulation_hold_s` and `unit_sign_disagree_*`, which the checks read out of
|
||||
`config.yaml` at both ends - so moving either default takes the gate red rather
|
||||
than leaving it silently too slow. `DECISIONS/SAFETY-37-freshness-policy.md`
|
||||
carries the derivation, what [[TEL-06]] and [[TEL-08]] still owe, and three
|
||||
named survivors - including the largest: **this detects a value that STOPPED, not
|
||||
a value that is WRONG.**
|
||||
|
||||
⚠️ **`slew_w` IS A RATIO TO THE RAIL NOW, AND ITS DEFAULT CHANGED FROM 1000 TO 0
|
||||
([[SAFETY-39]]).** `0` does not mean "off" and does not mean "unset" - it means
|
||||
**follow `max_w`**, exactly as `integrator_max_w: 0` already does, and resolves
|
||||
|
||||
@@ -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,376 @@
|
||||
# SAFETY-37 — the freshness policy, and the one sentence in the card that had to be rewritten
|
||||
|
||||
**Status: DECIDED 2026-09-06.** A per-unit reading that has stopped following its
|
||||
own command is reported to its consumers as **`None`**, never as a held number.
|
||||
The verdict is a **conjunction**, not an age threshold, and this document records
|
||||
why an age threshold is not available at this layer — which is the one place the
|
||||
card's wording could not be implemented as written.
|
||||
|
||||
Filed with SAFETY-37. Card `6a9a7eda7932387c3acbd700`, scope widened by the
|
||||
architect pass of 2026-09-06 to carry the single policy for [[TEL-06]] and
|
||||
[[TEL-08]] as well.
|
||||
|
||||
Every figure below was measured or read out of this repository on **2026-09-06**
|
||||
and is re-derived by `test_freshness.py` on every run. Where a number is carried
|
||||
forward from an earlier measurement, the section and the date it was taken are
|
||||
given at the number.
|
||||
|
||||
---
|
||||
|
||||
## 0. The three faces, and which one this card built
|
||||
|
||||
| where | the defect | this card |
|
||||
|---|---|---|
|
||||
| **Layer 1** — `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` | the ingest-age veto tests the **VALUE** of `sensor.goodwe_p1_sample_age`, not its arrival. A dead publisher freezes it at a healthy number and the veto can never fire | **not touched.** It is firmware, behind a flash. §5 says what it owes |
|
||||
| **The add-on** — `app/hass.py:69-82` | returns a held value as a value. Nothing separates "read 40 %" from "still says 40 % because nothing updated it" | **built here** |
|
||||
| **The evidence** — [[HW-01]] | SoC held 40 % for 6 h 25 m while the pack drained to its floor, every detector reading healthy | the case the policy is answerable to |
|
||||
|
||||
The two consumers the card names — SAFETY-30's per-unit sign check and
|
||||
SAFETY-31's circulating-power sensor — both read `Inverter.batt` (register
|
||||
`0x0518`) and **neither had a freshness gate of any kind**. Both already do the
|
||||
right thing with a `None`: `watchdog.SignWatchdog.evaluate` **clears** its window
|
||||
on a `None` measurement, and `circulation.circulating_w` returns `None` for a
|
||||
roster with a missing term, which clears that window too. **Neither could ever
|
||||
receive one.** The gate is the piece that was missing *between* the shipped read
|
||||
and the shipped checks, which is why the change to each consumer is zero lines.
|
||||
|
||||
---
|
||||
|
||||
## 1. ⚠️ The sentence that had to be rewritten, and why saying so is the finding
|
||||
|
||||
The card says: *"A pack legitimately resting at 40 % and a dead sensor reading
|
||||
40 % are the same number; **only arrival separates them**."*
|
||||
|
||||
**For a signal this add-on polls out of Home Assistant's state cache, arrival
|
||||
does not separate them, and cannot.** `read_inputs()` runs about once a second
|
||||
(`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), and while the
|
||||
entity exists Home Assistant answers `200` with a number **whether or not the
|
||||
publisher behind it is alive**. So both members of the pair arrive, identically,
|
||||
for ever. `test_freshness.py` part 1a builds an arrival-age gate and grades it:
|
||||
the age since arrival is `0.0 s` at every instant of **both** runs. A threshold
|
||||
on it is a threshold that can never fire — which is TEL-06's own defect rebuilt
|
||||
one layer up.
|
||||
|
||||
The other half of the card's rule survives intact and is honoured absolutely:
|
||||
|
||||
> ⚠️ Home Assistant's `last_updated` does **not** move when a value is rewritten
|
||||
> unchanged, so it cannot be the source of truth.
|
||||
|
||||
Neither does `last_changed`, and this project measured on the ENV-01 rig (70 s of
|
||||
a frozen meter) and independently against the live house that `last_reported`
|
||||
does not advance on a repeat on either serialiser either —
|
||||
`p1.HomeWizardLocalSource`'s docstring records both. **`app/freshness.py` reads
|
||||
no Home Assistant timestamp at all.** Every clock in the verdict is
|
||||
`time.monotonic()` inside this process, stamped at the instant an arrival carried
|
||||
a different number. That is what "arrival-stamped" can mean here, and it is
|
||||
graded (part 1a asserts the module's body contains no `last_updated`).
|
||||
|
||||
**So the criterion was rewritten rather than approximated:** *a reading is stale
|
||||
when it has not moved through a change it was obliged to follow.* The obligation,
|
||||
not the age, is what separates the pair.
|
||||
|
||||
---
|
||||
|
||||
## 2. The verdict
|
||||
|
||||
```
|
||||
STALE ⟺ unchanged for ≥ 35.0 s
|
||||
AND |standing command now| ≥ 250 W
|
||||
AND |standing command now − standing command when the reading
|
||||
last changed| ≥ 250 W
|
||||
```
|
||||
|
||||
Latched: **only an arrival carrying a different number clears it.**
|
||||
|
||||
`0x0518` is the inverter reporting **itself** — r = 0.998 with battery power
|
||||
against r = 0.09 with the real meter (`goodwe/NOTES.md` §3.3) — and it tracks its
|
||||
own command: command and actual agreeing within 4 W once settled (§5.10,
|
||||
measured 2026-08-22), the register dithering ±1 W while holding (§5.11, same
|
||||
day). A unit told to move 250 W and still answering the same integer watt is not
|
||||
tracking.
|
||||
|
||||
### 2.1 Why a bare "unchanged for N seconds" threshold is not available
|
||||
|
||||
**Measured, not feared.** Replaying the committed 30-day capture of this house's
|
||||
own 1 W-resolution power entity — `scenarios/p1-net-30d-2026-08/`, 485,841
|
||||
samples straight out of Home Assistant's recorder, where a row exists only when
|
||||
the state string **changed**, so the gap between two rows *is* the unchanged
|
||||
duration every consumer saw:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| longest unchanged run over 30 days | **263.2 s** (held −899.0 W, 2026-08-11 17:04) |
|
||||
| runs ≥ 35 s | 10 of 485,840 (0.0021 %) |
|
||||
| runs ≥ 17 s | 386 of 485,840 (0.0794 %) |
|
||||
|
||||
A real power figure at 1 W resolution held **one value for four and a half
|
||||
minutes with nothing whatever wrong**. So an unchanged threshold free of false
|
||||
positives must be over 263 s — and 263 s is **3.5× too slow** to protect a
|
||||
consumer that trips at 75 s. There is no admissible threshold. The conjunction
|
||||
is not a refinement; without it the card has no implementable verdict.
|
||||
|
||||
⚠️ **That is the METER's register, not `0x0518`, and it is used as a surrogate
|
||||
and named as one.** No 30-day capture of the inverter's own power exists — it is
|
||||
not in the recorder export. The single thing this measurement is used for is the
|
||||
statement above: that the legitimate unchanged duration of a power entity in this
|
||||
house is *minutes*. It is not used to set the bound.
|
||||
|
||||
⚠️ **And every one of those long runs happened under an unmoving load** — the
|
||||
worst is 263 s of a steady −899 W export, a house doing one thing. That is why
|
||||
the conjunction keys on command travel: none of them arms it.
|
||||
|
||||
### 2.2 Why the magnitude term exists — a surviving mutation put it there
|
||||
|
||||
Deleting `abs(standing_w) < move_w` from `_grade()` left **every check in the
|
||||
first draft green**, because in all of them a command that had *travelled*
|
||||
250 W had also *ended up* above 250 W. The case that separates them is a site
|
||||
being brought to **rest**: the write path is serialised, so a register can reach
|
||||
its new value on the same read at which the *old* command is still standing —
|
||||
`standing_at_change` then captures 1300 W, the site is commanded to 0 W, and the
|
||||
travel term alone reads 1300 W of unfollowed command for as long as the site
|
||||
rests. It would call a perfectly truthful +5 W reading dead. The magnitude term
|
||||
is what says *a site at rest is not owed movement*, and part 4 now grades it.
|
||||
|
||||
**The hole that term leaves cannot mislead either consumer**, which is why it is
|
||||
the right trade: a unit commanded under 250 W is one `watchdog.disagrees()` is
|
||||
`False` for whatever the measurement says, and a frozen term beside a live unit
|
||||
that is itself resting nets to **zero** circulating power rather than to a false
|
||||
alarm. Both halves are asserted, not argued.
|
||||
|
||||
---
|
||||
|
||||
## 3. The bounds — one per signal, each with its derivation
|
||||
|
||||
⚠️ **One constant for both signals would be useless on SoC or trigger-happy on
|
||||
power**, which is the third clause of the policy. `freshness.BOUNDS` carries them
|
||||
as data with a `wired` column, and `test_freshness.py` part 7 walks the AST of
|
||||
every shipped module to check that column tells the truth.
|
||||
|
||||
### 3.1 `batt` — `0x0518`, S16, 1 W, published every 10 s — **WIRED**
|
||||
|
||||
**Floor, 17.0 s** — the fastest a healthy register can be *obliged* to have
|
||||
answered. Three terms, each read out of this repository:
|
||||
|
||||
| | | source |
|
||||
|---|---|---|
|
||||
| 6.0 s | the plant | `goodwe/NOTES.md` §5.9, measured 2026-08-22 with the poll temporarily at 1 s: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s |
|
||||
| 10.0 s | the publisher | `firmware/goodwe-master.yaml:181`, `update_interval: 10s` on the `modbus_controller` carrying both `0x0518` and `0x050E` |
|
||||
| 1.0 s | this add-on's read period | `main.run_control()`'s `await asyncio.sleep(1)` |
|
||||
|
||||
**Ceiling, 75.0 s** — the tighter of the two consumers, both read out of the
|
||||
shipped `config.yaml`: `circulation_hold_s` **75** (line 512) and
|
||||
`unit_sign_disagree_s` **85** (line 451). A gate slower than its consumer hands
|
||||
over the frozen number and explains afterwards.
|
||||
|
||||
**Chosen: 35.0 s.** The admissible band `[2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]`
|
||||
contains four integers, and **the band is what is derived — 35 is a choice inside
|
||||
it**, deliberately not 30 (that is `validate.FIRMWARE_INGEST_VETO_S`, and would
|
||||
read as a borrowed constant nobody re-derived) and not 60 (round). Part 6
|
||||
computes both ends **from `config.yaml`** and goes red if an operator default
|
||||
moves: drop `circulation_hold_s` to 60 and this fails.
|
||||
|
||||
**`MOVE_W` = 250 W** is the **largest** admissible value, not a sensitivity
|
||||
preference. The gate must arm everywhere a stale reading could mislead a
|
||||
consumer; the earliest either consumer speaks is `unit_sign_disagree_w`, 250 W
|
||||
(config.yaml:450), because below that `watchdog.disagrees()` is `False` whatever
|
||||
the measurement says. Taking the ceiling of the admissible range minimises
|
||||
spurious arming. It is pinned to `config.yaml` by part 6, not to a literal.
|
||||
|
||||
### 3.2 `soc` — `0x050E`, U16, 1 %, published every 10 s — **STATED, NOT WIRED**
|
||||
|
||||
SoC **cannot use the conjunction above.** A pack resting at its floor holds one
|
||||
percent for hours with nothing wrong — the card's own example — and the standing
|
||||
command says nothing about it, because 0 W is exactly what a rested pack is
|
||||
given. The obliged movement for SoC is **energy, not command travel**: one
|
||||
percent of pack A is ≈ **153.6 Wh** (15.36 kWh nominal, `goodwe/NOTES.md` §1.1b's
|
||||
table — ⚠️ **NOMINAL and FROM DATASHEET**, not a measured usable capacity), so at
|
||||
pack A's 5000 W ceiling one percent takes at least **110 s**, and at a realistic
|
||||
1000 W about **553 s**.
|
||||
|
||||
So the bound is *"SoC unchanged while ∫|battery power| dt since its last change
|
||||
exceeded K × 153.6 Wh"*, and **K cannot be chosen from anything written down
|
||||
today**. Wiring it with a guessed K would be a gate that fires on a rested pack,
|
||||
which is the hazard, not the fix. `Freshness` takes its obligation term from the
|
||||
caller precisely so an energy integral can be handed in where command travel is
|
||||
handed in for `batt` — TEL-08 needs no second mechanism.
|
||||
|
||||
---
|
||||
|
||||
## 4. What changes on a running install
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| healthy register, N=1 | value reaches consumers | **identical** — 60 reads of a dithering register under a reversing command, asserted in part 5 |
|
||||
| healthy register, N≥2 | value reaches consumers | identical |
|
||||
| **frozen register, N=1** | reads as a healthy measurement, for ever | **site stops**: `inputs_ok` False → `stale_input_s` hold → 0 W, and keeps commanding |
|
||||
| **frozen register, N≥2** | per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening | both consumers get `None`, both windows **clear** |
|
||||
|
||||
⚠️ **The N=1 row is a behaviour change on the roster that is running in a house
|
||||
today, and it is named rather than hidden.** It is also the point of the card:
|
||||
HW-01 was a one-inverter site. The direction it fails in is the safe one — a
|
||||
false verdict commands 0 W and keeps commanding (§11.1 rule 1), behind the
|
||||
existing `stale_input_s` hold, and clears itself on the register's next move.
|
||||
|
||||
The counterfactual is run, not argued. `test_freshness.py` part 3 drives real
|
||||
`cycle()` calls at N=2 with A's register frozen at −1200 W and B's live at
|
||||
+900 W, the command travel produced by the shipped `compute()` and allocator:
|
||||
|
||||
```
|
||||
standing on A: [0, 445, 890, 1335, 1335, ...] (the shipped control law)
|
||||
gated : sign trip tick=None circulating_w=[1800, 1800, 1800, None, None, ...]
|
||||
counterfact : sign trip tick=11 circulating_w=[1800, 1800, 1800, 1800, 1800, ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. What TEL-06 and TEL-08 still owe
|
||||
|
||||
**[[TEL-06]] — layer 1, Blocked, and nothing in this card can reach it.** The
|
||||
ESPHome veto tests the *value* of `sensor.goodwe_p1_sample_age`. This policy
|
||||
lives in the add-on, on the other side of a flash, and cannot help. What TEL-06
|
||||
owes is the same mechanism **inside the firmware**: a `millis()` stamp taken when
|
||||
the setpoint/age entity is *written*, and a veto on `millis() − last_write_ms`
|
||||
rather than on the published number. It is narrowed to that one substitution —
|
||||
the policy question ("arrival, not value") is answered here; the remaining work
|
||||
is a firmware stamp and a flash, which is a supervised human step.
|
||||
|
||||
**[[TEL-08]] — stale SoC.** It owes exactly three things, and no mechanism:
|
||||
1. a **measured** usable capacity for pack A (or a defensible bound on it) — the
|
||||
153.6 Wh per percent above is nominal and from the datasheet;
|
||||
2. the **energy integral** since the reading last changed;
|
||||
3. the constant **K** — how many percent-equivalents of throughput must pass
|
||||
before an unmoved SoC is a fault.
|
||||
|
||||
It should consume `freshness.Freshness` with `BOUNDS["soc"]` filled in, not
|
||||
restate the policy. The row is already in the table with `wired=False` and the
|
||||
reason, and part 7 goes red the day that column stops being true.
|
||||
|
||||
**⚠️ Neither of them, and not this card, closes HW-01's whole class.** See §6.
|
||||
|
||||
---
|
||||
|
||||
## 6. Known survivors, with their size
|
||||
|
||||
Graded in `test_freshness.py` part 9, so they are residuals rather than
|
||||
surprises.
|
||||
|
||||
| # | kind | what survives | size |
|
||||
|---|---|---|---|
|
||||
| 1 | **false positive** | an inverter physically unable to follow — saturating, thermally derating, or a BMS refusing a charge — is declared stale while telling the truth | one spurious **site stop**, behind `stale_input_s`, self-clearing the moment the register moves. It cannot drive the battery anywhere |
|
||||
| 2 | **miss** | a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever | ⚠️ scoped for ONE consumer, NOT both, and the earlier claim here was wrong. TRUE for the per-unit sign check: no such check can speak about that unit (`unit_sign_disagree_w` = 250 W). **FALSE for circulation.** "Contributing at most 250 W" bounds the COMMAND; once the register is frozen its REPORTED value is unrelated to the command, which is exactly HW-01's shape - commanded ~0 W, register stuck on the last large number. Measured: a unit frozen at −1200 W beside a healthy +900 W sibling yields `circulating_w` = **1800 W against the 700 W floor, 2.6× over**, with no gate armed. Found in review; part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling, so neither graded the sentence |
|
||||
| 3 | **miss — the largest** | a dead publisher whose last values **alternate**, or a cache replaying two old samples, never starts the unchanged clock at all | **this gate detects a value that STOPPED; it cannot detect a value that is WRONG.** Nothing in this add-on can. TEL-06's arrival stamp inside the firmware is the only layer that could |
|
||||
|
||||
Recorded because it looks like a survivor and is not: a publisher failure that
|
||||
freezes **both** registers is caught on both units independently, on each unit's
|
||||
own command travel and each unit's own number.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mutation record — 2026-09-06
|
||||
|
||||
`tools/mutation_run.sh`'s rule, honoured on every run: purge first (count printed,
|
||||
`0` remaining), tree mounted `:ro`, `python3 -B`, fingerprint the tree, **and
|
||||
fingerprint again after the revert**. Clean tree `app/freshness.py`
|
||||
`sha256:f40fa3e8819014ce` / 20,065 B, `app/inverter.py` `7e760c6e8cdfa5b7` /
|
||||
30,778 B, `app/main.py` `7d7ff4ba0697ea64` / 238,535 B. Each mutant graded by
|
||||
`test_freshness.py` + `test_inverter.py`.
|
||||
|
||||
| # | mutation | file | Δ B | result |
|
||||
|---|---|---|---|---|
|
||||
| M0 | none (control) | freshness | +0 | **green** — the harness is not red on everything |
|
||||
| M1 | `reading()` returns `self.last` unconditionally | freshness | −24 | red, 3 named checks |
|
||||
| M2 | drop the command-travel term | freshness | −59 | red |
|
||||
| M3 | drop the magnitude term | freshness | −54 | **SURVIVED**, then red — §2.2. This is the finding |
|
||||
| M4 | latch never holds | freshness | +13 | red, 3 named checks |
|
||||
| M5 | bound 90 s (slower than both consumers) | freshness | **+0** | red |
|
||||
| M6 | bound 10 s (under the physical floor) | freshness | **+0** | red |
|
||||
| M7 | `MOVE_W` 700 W | freshness | **+0** | red, on part 6's `config.yaml` pin |
|
||||
| M8 | the wiring in `_read_unit` removed | main | −5 | red, 3 named checks |
|
||||
| M9 | reset the unchanged clock on every arrival (value-stamped) | freshness | −43 | red |
|
||||
| M10 | `standing_w()` drops `last_write` | inverter | −73 | red, and takes `test_inverter.py` with it |
|
||||
| M11 | `standing_at_change` refreshed on every arrival | freshness | +121 | ⚠️ **DISCOUNTED — the mutant does not parse** |
|
||||
| M11b | the same defect, written parseably | freshness | +105 | red |
|
||||
| M12 | `BOUNDS["soc"].wired = True` — the table lies | freshness | −1 | red |
|
||||
| M13 | the wrong signal submitted (`inv.soc` for `inv.batt`) | main | +4 | red, and `test_inverter.py` too |
|
||||
| M14 | one `Freshness` shared by every unit | inverter | +29 | red |
|
||||
| M15 | latch cleared by a failed read | freshness | +31 | **SURVIVED**, then red — see below |
|
||||
|
||||
⚠️ **M5, M6 and M7 are `+0 B`, which MUTATION.md names as the risky delta.** All
|
||||
three are **reds**, which is the direction stale bytecode cannot fabricate, and
|
||||
every run purged with the count printed and graded a `:ro` tree that physically
|
||||
cannot hold a cache.
|
||||
|
||||
⚠️ **M11 IS NOT A RESULT AND IS NOT COUNTED.** Its edit dedents a statement into
|
||||
the middle of an `if` body, so the module raises `IndentationError` at line 312
|
||||
and both files go red without a single check being evaluated. Verified by
|
||||
`ast.parse` rather than assumed. **M11b** is the same defect written so it
|
||||
parses, and it is a real red.
|
||||
|
||||
### The two survivors, and what each one taught
|
||||
|
||||
**M3, the magnitude term** — see §2.2. Two terms of a conjunction that move
|
||||
together in every fixture are one term as far as the checks are concerned.
|
||||
Recorded as `MUTATION.md` **habit 17**, twelfth instance of "a check whose DATA
|
||||
cannot reach the failure it names".
|
||||
|
||||
**M15, the latch cleared by a failed read** — found only because the sweep went
|
||||
looking for what the checks would NOT see. No fixture had ever injected a read
|
||||
failure *while latched*, and it matters: a stale verdict stops the site, the site
|
||||
is then commanded 0 W, and one unreachable-Home-Assistant read would drop the
|
||||
latch into a state where the conjunction is no longer armed — so the held
|
||||
−1200 W would come back to the consumers on the very next successful read. Part 4
|
||||
now grades exactly that and M15 is red.
|
||||
|
||||
### And one process defect worth more than the ledger
|
||||
|
||||
One mutation's container run overran a two-minute command budget and the wrapper
|
||||
was killed **between the edit and the revert**, leaving `app/freshness.py`
|
||||
mutated on disk. Nothing announced it; the next mutation's harness cheerfully
|
||||
fingerprinted and graded the mutant *plus* a second mutation. It was caught by
|
||||
comparing the tree fingerprint against the recorded clean one, restored, and
|
||||
verified back to byte and `sha256` before anything else ran. Same defect as stale
|
||||
bytecode, different cause. Recorded as `MUTATION.md` **habit 18**: *fingerprint
|
||||
after the revert, not only before the run.*
|
||||
|
||||
### A third, in this file's own fixture
|
||||
|
||||
M2 originally reported "red" with **no named check behind it**. The mutant makes
|
||||
the FRESH run stale, so `test_freshness.py`'s own diagnostic
|
||||
`print(f"… {sorted(set(_fresh_read))}")` raised `TypeError` comparing `None` with
|
||||
a float **before check one ran**. MUTATION.md's reporting rule is *"which suite
|
||||
went red, and on which named check"*, and a print that can crash first destroys
|
||||
exactly that. Sorted with `key=str` now; M2 reports its check.
|
||||
|
||||
### Re-verified at the committed head
|
||||
|
||||
The 16-row sweep above graded `test_freshness.py` as it stood when each mutation
|
||||
ran; the two closures (M3's part-4 check, M15's failed-read check), the `key=str`
|
||||
fix and the wall-clock hardening all landed afterwards. So six mutations were
|
||||
re-run against **the exact tree that is committed** —
|
||||
`test_freshness.py sha256:a62f3e1f1ac856cf`, 44,913 B:
|
||||
|
||||
| # | result at head |
|
||||
|---|---|
|
||||
| M0 control | **green** |
|
||||
| M2 drop the command-travel term | red, on part 1's "FRESH AND UNCHANGED is not stale" |
|
||||
| M3 drop the magnitude term | red, on part 4's magnitude check |
|
||||
| M8 wiring removed | red, on three named checks in parts 2 and 3 |
|
||||
| M11b `standing_at_change` drifts | red, on three named checks in part 1 |
|
||||
| M15 latch cleared by a failed read | red, on part 4's failed-read check |
|
||||
|
||||
The other ten are unaffected by those edits: each is red on a check in parts 1,
|
||||
2, 4, 6, 7 or 9 that none of them touches, and a mutation red on one check
|
||||
cannot be made green by adding another.
|
||||
|
||||
### The wall clock, which part 3 cannot avoid and therefore measures
|
||||
|
||||
The freshness verdict runs on an injected monotonic clock and is deterministic.
|
||||
The sign watchdog inside `cycle()` cannot be injected, so part 3 runs against the
|
||||
real one — which is CI-06's failure mode exactly (`test_peakguard.py` answered
|
||||
two checks with the wall clock, flaked at ~20 %, and cost a wrong merge
|
||||
diagnosis). So the margin is measured and asserted rather than assumed:
|
||||
|
||||
gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin)
|
||||
|
||||
If a loaded machine ever narrows that, the check fails **naming the number**,
|
||||
which is the difference between "the box was slow" and "the code is broken".
|
||||
+125
-1
@@ -295,6 +295,42 @@ the P1 sample already went stale. Size the pair together: the ESP32's own
|
||||
watchdog commands 0 W after ~30 s of silence from this add-on regardless, and
|
||||
that layer is unaffected by either option.
|
||||
|
||||
#### How long a FROZEN inverter power register takes to reach 0 W
|
||||
|
||||
A third clock, and it is a different failure from the one above: the entity is
|
||||
still there and still answering — it has simply stopped changing. Before
|
||||
SAFETY-37 that read as a healthy measurement for ever, which is what happened on
|
||||
2026-09-02 (HW-01): a value held for 6 h 25 m while the pack drained to its
|
||||
floor, and every detector read healthy throughout.
|
||||
|
||||
`Inverter.batt` — the inverter's own AC power, register `0x0518` — is now
|
||||
withheld from the control path when **all three** of these are true:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the reading has not changed for | **35 s** |
|
||||
| **and** the command standing on that inverter is over | **250 W** |
|
||||
| **and** that command has travelled, since the reading last moved, over | **250 W** |
|
||||
|
||||
All three, because a converged site legitimately repeats one power figure for
|
||||
minutes — measured at up to **263 s** over 30 days of this house's own meter —
|
||||
and a site at rest is not owed movement. It is only when the controller has
|
||||
*asked the inverter to move* and the register has not answered that the reading
|
||||
is treated as missing.
|
||||
|
||||
**A withheld reading is a MISSING INPUT**, so it joins the `stale_input_s` clock
|
||||
above: 35 s to withhold, then 15 s of hold, then 0 W. It clears itself the
|
||||
instant the register reports a different value — one watt is enough, and this
|
||||
register dithers ±1 W while holding — so a working inverter clears it on its
|
||||
next 10 s publish. There is **no option to configure**: the 35 s is derived from
|
||||
the ESPHome publish interval and the two watchdog windows it has to stay inside,
|
||||
and the add-on's own checks refuse a value that does not.
|
||||
|
||||
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
|
||||
STOPPED**, including a dead publisher whose last two samples happen to
|
||||
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
|
||||
itself, which is TEL-06.
|
||||
|
||||
There is **no fallback to an inverter-side power figure**, deliberately. The
|
||||
inverter's own AC power tracks its battery almost perfectly and the real meter
|
||||
hardly at all, so a controller that failed over to it would be regulating
|
||||
@@ -437,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 |
|
||||
@@ -592,6 +628,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
|
||||
|
||||
@@ -546,6 +546,46 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
instance of habit 4 in this file, and the second one in an instrument
|
||||
rather than in a check.**
|
||||
|
||||
17. ⚠️ **TWO TERMS OF A CONJUNCTION THAT MOVE TOGETHER IN EVERY FIXTURE ARE
|
||||
ONE TERM AS FAR AS THE CHECKS ARE CONCERNED - TWELFTH INSTANCE OF HABIT 4,
|
||||
and the third found by an author's own battery rather than by review.**
|
||||
SAFETY-37's freshness gate is `unchanged >= 35 s AND |standing| >= 250 W
|
||||
AND |travel| >= 250 W`. Deleting the MIDDLE term left every check in the
|
||||
first draft green, because in all of them a command that had *travelled*
|
||||
250 W had also *ended up* above 250 W: the fixture never separated the two,
|
||||
so one of them was decoration as far as the suite could tell. The case that
|
||||
separates them is a site being brought to REST - the register reaches its
|
||||
new value on the same read at which the OLD command is still standing, so
|
||||
the travel term alone reads 1300 W of unfollowed command while the site
|
||||
sits truthfully at +5 W. Ask of every conjunction: **is there a fixture in
|
||||
which each term is the ONLY one that is false?** If not, the suite cannot
|
||||
tell an AND from an OR.
|
||||
|
||||
18. ⚠️ **AN UNVERIFIED REVERT IS THE SAME DEFECT AS STALE BYTECODE, AND TWO
|
||||
HARNESSES SHARING ONE BACKUP PATH IS THE WORST VERSION OF IT.** SAFETY-37's
|
||||
sweep hit this twice, in ascending order of nastiness.
|
||||
* A mutation's container run overran a two-minute command budget and the
|
||||
wrapper was killed **between the edit and the `cp` that puts the file
|
||||
back**, leaving `app/freshness.py` mutated on disk. Nothing announced it;
|
||||
the next mutation's harness cheerfully fingerprinted and graded the mutant
|
||||
*plus* a second mutation.
|
||||
* Then two copies of the sweep were started - a scheduling mistake, not a
|
||||
tool defect - and the harness kept its backup at the FIXED path
|
||||
`/tmp/mut.bak`. One copy saved `app/main.py` there; the other restored it
|
||||
**over `app/freshness.py`**. The result was a 238 KB `freshness.py` that
|
||||
still parsed as Python, plus `main.py` left holding a live mutation. No
|
||||
error, no warning, and the only reason it was noticed is that the tree
|
||||
fingerprint did not match the recorded clean one.
|
||||
|
||||
Two rules, and the first is the cheap one:
|
||||
* **Fingerprint after the revert, not only before the run.**
|
||||
`tools/mutation_run.sh` prints the tree it is about to grade for exactly
|
||||
this reason; the revert deserves the same treatment, and a mismatch is a
|
||||
stop-everything result rather than a note.
|
||||
* **A harness's scratch file must be unique per invocation** (`mktemp`), so
|
||||
that two of it cannot silently overwrite each other's work. A fixed path
|
||||
in a tool that edits source is a shared mutable global with a filename.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
|
||||
@@ -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,385 @@
|
||||
"""SAFETY-37 - a reading that holds its last value and reads healthy.
|
||||
|
||||
THE DEFECT, in one sentence: `hass.number()` returns a held value as a value,
|
||||
and nothing anywhere in this add-on distinguishes "the inverter reports 40 %"
|
||||
from "the entity still says 40 % because nothing has updated it since Tuesday".
|
||||
|
||||
The architect pass of 2026-09-06 put three cards on one mechanism. This module
|
||||
is that mechanism; the other two are its consumers.
|
||||
|
||||
LAYER 1 `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` - the
|
||||
(TEL-06) ingest-age veto tests the VALUE of `sensor.goodwe_p1_sample_age`.
|
||||
A dead publisher leaves the entity frozen at a healthy number and
|
||||
the veto can never fire. NOT FIXED HERE - it is firmware, it is on
|
||||
the other side of a flash, and nothing in this module can reach
|
||||
it. What it owes is written at the bottom of this docstring.
|
||||
THIS ADD-ON `app/hass.py:69-82`, consumed by SAFETY-30's per-unit sign check
|
||||
(SAFETY-37) and SAFETY-31's circulating-power sensor. Both read `Inverter.batt`
|
||||
(register 0x0518) and NEITHER had a freshness gate of any kind.
|
||||
THE EVIDENCE HW-01, 2026-09-02: SoC held 40 % for 6 h 25 m while the pack
|
||||
drained to its floor. Every detector read healthy throughout.
|
||||
|
||||
⚠️ AT N>=2 EVERY ONE OF THOSE READINGS DOUBLES, which is why this card is the
|
||||
one that had to build it: the per-unit sign check and the circulation sensor
|
||||
only exist at N>=2, and both are fed straight from `Inverter.batt`.
|
||||
|
||||
|
||||
WHAT CANNOT BE THE SOURCE OF TRUTH, AND IT IS NOT A DETAIL
|
||||
==========================================================
|
||||
Home Assistant's `last_updated` DOES NOT MOVE when a value is rewritten
|
||||
unchanged. Neither does `last_changed`, and this project measured that
|
||||
`last_reported` does not advance on a repeat on either serialiser either - see
|
||||
`p1.HomeWizardLocalSource`'s docstring, which records the ENV-01 rig measurement
|
||||
over 70 s of a frozen meter and an independent one against the live house.
|
||||
|
||||
So a value-side timestamp out of Home Assistant is a "time since the number
|
||||
changed" sensor wearing an arrival sensor's name. Thresholding it is how a
|
||||
detector ends up unable to fire, which is TEL-06's defect exactly. That trap
|
||||
produced two false diagnoses on 2026-09-05, both by a reader who already knew
|
||||
about it, which is why it is written here in full rather than referenced.
|
||||
|
||||
⚠️ AND THE CONVERSE TRAP IS THE ONE THIS MODULE HAD TO SOLVE. The add-on polls
|
||||
Home Assistant's state cache roughly once a second (`run_control()`'s
|
||||
`await asyncio.sleep(1)` bounds the loop body and `read_inputs()` runs every
|
||||
pass). While the entity exists, HA answers 200 with a number whether or not the
|
||||
publisher behind it is alive. So AN ARRIVAL AT THE ADD-ON IS NOT AN ARRIVAL FROM
|
||||
THE INVERTER, and an arrival age alone cannot see HW-01 at all.
|
||||
|
||||
What IS arrival-stamped here, and what makes this module honest, is the clock
|
||||
the verdict is measured on: `changed_mono` is the MONOTONIC INSTANT OF THE
|
||||
ARRIVAL THAT LAST CARRIED A DIFFERENT NUMBER. Every term below is a fact about
|
||||
arrivals this process observed, on its own clock. Nothing is read out of a Home
|
||||
Assistant timestamp, and nothing would change if Home Assistant stopped
|
||||
publishing timestamps altogether.
|
||||
|
||||
|
||||
THE VERDICT, AND WHY IT IS A CONJUNCTION
|
||||
========================================
|
||||
A bare "unchanged for N seconds" threshold is not available, and that is
|
||||
MEASURED rather than feared. Over the committed 30-day capture of this house's
|
||||
own 1 W-resolution power entity - `scenarios/p1-net-30d-2026-08/`, 485,841
|
||||
samples straight out of Home Assistant's recorder, where a row exists only when
|
||||
the state string CHANGED, so the gap between two rows IS the unchanged duration
|
||||
every consumer saw:
|
||||
|
||||
longest unchanged run, 30 days 263.2 s (held -899.0 W, 2026-08-11 17:04)
|
||||
runs >= 35 s 10 of 485,840 (0.0021 %)
|
||||
runs >= 17 s 386 of 485,840 (0.0794 %)
|
||||
|
||||
Measured on 2026-09-06 by replaying that capture. A real power figure at 1 W
|
||||
resolution held ONE value for four and a half minutes with nothing whatever
|
||||
wrong. ⚠️ That is the METER's register, not 0x0518, and it is named as a
|
||||
surrogate rather than passed off as the thing: no 30-day capture of 0x0518
|
||||
exists, because that register is not in the recorder export. What it does
|
||||
establish - and this is all it is used for - is that the LEGITIMATE unchanged
|
||||
duration of a power entity in this house is minutes, so an unchanged threshold
|
||||
alone must be minutes, and minutes is three times too slow to protect consumers
|
||||
that trip at 75 s and 85 s. The conjunction is not a refinement; without it
|
||||
there is no admissible threshold at all.
|
||||
|
||||
⚠️ AND EVERY ONE OF THOSE LONG RUNS HAPPENED UNDER AN UNMOVING LOAD. That is
|
||||
the way out, and it is the same shape `freeze.looks_frozen()` already uses for
|
||||
the meter (unchanged AND the command pinned at the rail). A reading is stale
|
||||
when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
|
||||
|
||||
STALE <=> unchanged for >= UNCHANGED_S
|
||||
AND |standing command now| >= MOVE_W
|
||||
AND |standing command now - standing command when the reading
|
||||
last changed| >= MOVE_W
|
||||
|
||||
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
|
||||
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
|
||||
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
|
||||
register dithering +/-1 W while holding (§5.11, same day). A unit told to move
|
||||
250 W and still answering the same integer watt cannot be tracking.
|
||||
|
||||
|
||||
THE BOUNDS - ONE PER SIGNAL, EACH WITH ITS DERIVATION
|
||||
=====================================================
|
||||
⚠️ ONE CONSTANT FOR BOTH SIGNALS WOULD BE USELESS ON SoC OR TRIGGER-HAPPY ON
|
||||
POWER, which is the third clause of the policy. They are separated here, and
|
||||
only one of them is WIRED - see `BOUNDS` below and the note on SoC.
|
||||
|
||||
`batt` / 0x0518, S16, 1 W, published every 10 s
|
||||
----------------------------------------------
|
||||
LOWER BOUND, 17.0 s, the fastest a healthy register can be OBLIGED to have
|
||||
answered. Three terms, each read out of this repository on 2026-09-06:
|
||||
|
||||
6.0 s the plant. goodwe/NOTES.md §5.9, measured 2026-08-22 with the poll
|
||||
temporarily at 1 s and a 0 -> +500 W step: 26 W at +1.43 s, 469 W
|
||||
(94 %) at +3.34 s, settled 507 W at +6.05 s.
|
||||
10.0 s the publisher. `firmware/goodwe-master.yaml:181`,
|
||||
`update_interval: 10s` on the `modbus_controller` that carries both
|
||||
0x0518 and 0x050E. A command that moves right after a poll is not
|
||||
visible until the next one.
|
||||
1.0 s this add-on's own read period - `run_control()`'s
|
||||
`await asyncio.sleep(1)`, which bounds the loop body that calls
|
||||
`read_inputs()`.
|
||||
|
||||
UPPER BOUND, 75.0 s, the tighter of the two consumers this card must protect,
|
||||
both read from `config.yaml` on 2026-09-06:
|
||||
|
||||
75 s `circulation_hold_s` (config.yaml:512) - SAFETY-31 trips here.
|
||||
85 s `unit_sign_disagree_s` (config.yaml:451) - SAFETY-30's per-unit sign
|
||||
check trips here.
|
||||
|
||||
A gate slower than either is a gate that hands its consumer the frozen number
|
||||
and then explains afterwards. ⚠️ AND THAT INEQUALITY IS GRADED, NOT ASSERTED -
|
||||
`test_freshness.py` part 6 reads BOTH ends out of the shipped `config.yaml`
|
||||
and goes red if an operator's default moves under this bound.
|
||||
|
||||
35.0 s IS THE CHOSEN VALUE, and the admissible band is narrow enough that it
|
||||
is nearly forced: at least 2x the 17.0 s floor (34.0) and at most half the
|
||||
75.0 s consumer window (37.5) leaves [34.0, 37.5]. 35 is the integer in it.
|
||||
⚠️ DELIBERATELY NOT 30 AND NOT 60. 30 is `validate.FIRMWARE_INGEST_VETO_S` and
|
||||
would read as a borrowed constant nobody re-derived; 60 is round, and a round
|
||||
number here would be a guess wearing a measurement's clothes.
|
||||
|
||||
WHAT IT COSTS, stated rather than absorbed: a frozen 0x0518 on a unit that is
|
||||
never asked to move 250 W is not detected by this module at all.
|
||||
|
||||
⚠️ THAT COST IS BOUNDED FOR ONE CONSUMER AND NOT FOR THE OTHER, and an earlier
|
||||
version of this note claimed both. TRUE for the per-unit sign check: no such
|
||||
check can speak about that unit either, since `unit_sign_disagree_w` is 250 W.
|
||||
FALSE for circulation. "Contributing at most 250 W" bounds the COMMAND - but a
|
||||
frozen register's REPORTED value is unrelated to its command, and that is
|
||||
precisely HW-01's shape: commanded ~0 W, register stuck on the last large
|
||||
number. Measured in review: a unit frozen at -1200 W beside a healthy +900 W
|
||||
sibling gives `circulating_w` = 1800 W against a 700 W floor, 2.6x over, with
|
||||
no gate armed. So this gate is scoped to where the SIGN consumer can be misled;
|
||||
the circulation consumer keeps an unbounded exposure below MOVE_W and that is
|
||||
a known, stated gap rather than a covered one.
|
||||
|
||||
`soc` / 0x050E, U16, 1 %, published every 10 s - STATED, NOT WIRED
|
||||
-----------------------------------------------------------------
|
||||
⚠️ THIS ROW IS THE POLICY'S ANSWER FOR TEL-08 AND IT IS NOT CONNECTED TO
|
||||
ANYTHING. It is here because the third clause of the policy is "state each
|
||||
bound and its derivation", and because a reader who finds only `batt` wired
|
||||
would reasonably conclude SoC was forgotten. It was not; it needs a
|
||||
corroborator this card cannot derive.
|
||||
|
||||
SoC CANNOT USE THE CONJUNCTION ABOVE. A pack resting at its floor holds one
|
||||
percent for hours with nothing wrong - the card's own example - and the
|
||||
standing command says nothing about it, because a command of 0 W is exactly
|
||||
what a rested pack is given. The physically obliged movement for SoC is
|
||||
ENERGY, not command travel: one percent of pack A is about 153.6 Wh (15.36 kWh
|
||||
nominal, goodwe/NOTES.md §1.1b's table - ⚠️ NOMINAL and FROM DATASHEET, not a
|
||||
measured usable capacity), so at pack A's 5000 W ceiling one percent takes at
|
||||
least 110 s and at a realistic 1000 W about 553 s. The bound TEL-08 needs is
|
||||
therefore "SoC unchanged while the integral of |battery power| since its last
|
||||
change exceeded K x 153.6 Wh", and K cannot be chosen from anything written
|
||||
down today: nobody has measured this pack's usable capacity, its BMS reporting
|
||||
granularity, or how far its reported percent lags true state of charge.
|
||||
|
||||
So TEL-08 owes: that capacity measurement (or a defensible bound on it), the
|
||||
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
|
||||
below takes its obligation term from the caller precisely so that an energy
|
||||
integral can be handed in where command travel is handed in here.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
# --- the three terms of the lower bound, each read out of this repository ----
|
||||
# See the module docstring for where each one is measured. They are separate
|
||||
# names rather than a literal 17.0 so that a reader who changes the firmware's
|
||||
# `update_interval` can find this file by grepping for the number they moved.
|
||||
PLANT_SETTLE_S = 6.0 # goodwe/NOTES.md §5.9, measured 2026-08-22
|
||||
PUBLISH_PERIOD_S = 10.0 # firmware/goodwe-master.yaml:181
|
||||
READ_PERIOD_S = 1.0 # main.run_control()'s `await asyncio.sleep(1)`
|
||||
BATT_OBLIGED_S = PLANT_SETTLE_S + PUBLISH_PERIOD_S + READ_PERIOD_S # 17.0
|
||||
|
||||
# The unchanged bound for register 0x0518. See the derivation above; the band is
|
||||
# [2 * BATT_OBLIGED_S, 0.5 * circulation_hold_s] = [34.0, 37.5].
|
||||
BATT_UNCHANGED_S = 35.0
|
||||
|
||||
# How far the standing command must have travelled for the register to be
|
||||
# OBLIGED to have followed it.
|
||||
#
|
||||
# ⚠️ IT IS THE LARGEST ADMISSIBLE VALUE, NOT A SENSITIVITY PREFERENCE, and that
|
||||
# is what makes it a derivation. The gate has to arm everywhere a stale reading
|
||||
# could mislead a consumer; the earliest either consumer speaks is
|
||||
# `unit_sign_disagree_w`, 250 W (config.yaml:450), because below that
|
||||
# `watchdog.disagrees()` is False whatever the measurement says. Taking the
|
||||
# ceiling of the admissible range is what minimises spurious arming. A smaller
|
||||
# number would arm the gate in states where nothing downstream can be misled,
|
||||
# and every such arming is a spurious site stop bought for nothing.
|
||||
#
|
||||
# ⚠️ NOT A CONFIG KEY. CFG-03 tracks the places a default has to be kept in
|
||||
# step and this does not earn another one - it is derived from a default
|
||||
# `config.yaml` already carries, and `test_freshness.py` part 6 READS that file
|
||||
# and goes red if the two drift.
|
||||
MOVE_W = 250.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bound:
|
||||
"""One signal's freshness bound, and whether anything consumes it."""
|
||||
|
||||
signal: str
|
||||
unchanged_s: float
|
||||
move_w: float
|
||||
wired: bool
|
||||
derivation: str
|
||||
|
||||
|
||||
# ⚠️ THE POLICY AS DATA, AND THE `wired` COLUMN IS LOAD-BEARING. A reader must be
|
||||
# able to tell, without reading main.py, which of these bounds is actually
|
||||
# gating a consumer today. `test_freshness.py` part 7 greps the shipped app/ for
|
||||
# each row and refuses a row that claims to be wired and is not - and a row that
|
||||
# claims not to be and is.
|
||||
BOUNDS = {
|
||||
"batt": Bound(
|
||||
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
|
||||
unchanged_s=BATT_UNCHANGED_S,
|
||||
move_w=MOVE_W,
|
||||
wired=True,
|
||||
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
|
||||
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
move_w=float("inf"),
|
||||
wired=False,
|
||||
derivation="TEL-08. Command travel is the WRONG obligation term for "
|
||||
"SoC - a rested pack holds a percent for hours under a 0 W "
|
||||
"command. The term is energy: >= K x 153.6 Wh through the "
|
||||
"pack since the reading last changed. K needs a measured "
|
||||
"usable capacity nobody has taken."),
|
||||
}
|
||||
|
||||
|
||||
class Freshness:
|
||||
"""One signal's arrival record, and the stale verdict it supports.
|
||||
|
||||
⚠️ ARRIVAL-DRIVEN THROUGHOUT. Every clock reading this object ever makes is
|
||||
handed to `submit()` by the caller at the moment a value arrived; nothing
|
||||
here reads a clock of its own and nothing here reads a Home Assistant
|
||||
timestamp. That is what makes the verdict replayable - `test_freshness.py`
|
||||
drives real seconds through it without sleeping - and it is the difference
|
||||
between this and the value-stamped veto TEL-06 is blocked on.
|
||||
|
||||
⚠️ THE VERDICT LATCHES, AND WITHOUT THAT IT OSCILLATES AT ABOUT 1 Hz.
|
||||
Declaring the reading stale makes `Controller.inputs_ok` False, which
|
||||
commands 0 W (§11.1 rule 1), which drops the standing command below
|
||||
`MOVE_W`, which un-arms the conjunction, which would return the held number
|
||||
to the consumers - and round again every read. So the ONLY thing that clears
|
||||
the latch is an arrival carrying a DIFFERENT number, which is exactly the
|
||||
evidence the verdict was about. A register that is working clears it on its
|
||||
next 10 s publish; a register that is not, does not.
|
||||
"""
|
||||
|
||||
def __init__(self, bound: Bound):
|
||||
self.bound = bound
|
||||
# The last number that ARRIVED, or None if the most recent read did not
|
||||
# produce one. This is what `reading()` hands back when the gate is open,
|
||||
# so a read failure is still an immediate None exactly as it is today.
|
||||
self.last = None
|
||||
# The number the reading is currently HOLDING, and the monotonic instant
|
||||
# of the arrival that last carried a different one. `held` survives a
|
||||
# failed read; the unchanged clock is about the value, not about our
|
||||
# ability to fetch it.
|
||||
self.held = None
|
||||
self.changed_mono = None
|
||||
# The standing command at that same instant. The conjunction compares
|
||||
# against THIS, not against the previous cycle's command: what matters
|
||||
# is whether the register has followed anything since it last moved.
|
||||
self.standing_at_change = None
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.arrivals = 0
|
||||
|
||||
def submit(self, value, standing_w, now_mono) -> None:
|
||||
"""One READ of this signal. `value` None means nothing arrived.
|
||||
|
||||
`value` the number `hass.number()` produced, or None.
|
||||
`standing_w` what this controller currently has standing on this unit's
|
||||
wire, signed, + = discharge. None when nothing is known.
|
||||
`now_mono` the caller's monotonic clock, in seconds.
|
||||
|
||||
⚠️ A FAILED READ IS NOT AN ARRIVAL AND IS NOT AN AGE EITHER. It clears
|
||||
`last` immediately - which is byte-for-byte what shipping code does
|
||||
today, a missing reading being a missing reading - and it leaves the
|
||||
unchanged clock running, because the value has not changed and our
|
||||
inability to fetch it is not evidence that it has.
|
||||
"""
|
||||
if value is None:
|
||||
self.last = None
|
||||
return
|
||||
value = float(value)
|
||||
self.arrivals += 1
|
||||
if self.changed_mono is None or value != self.held:
|
||||
self.held = value
|
||||
self.changed_mono = float(now_mono)
|
||||
self.standing_at_change = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# Movement is the ONLY thing that clears the latch. See the class
|
||||
# docstring on the 1 Hz oscillation this prevents.
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.last = value
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _grade(self, standing_w, now_mono) -> None:
|
||||
"""The conjunction. Sets the latch; never clears it."""
|
||||
if self.stale or self.changed_mono is None:
|
||||
return
|
||||
held_s = self.unchanged_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
|
||||
# always answers a float, so neither branch is reachable from the wired
|
||||
# consumer today. They are here for TEL-08, whose obligation term is an
|
||||
# energy integral that has nothing to say until it has a window - and
|
||||
# "nothing to say" must withhold the verdict, not manufacture one.
|
||||
if standing_w is None or self.standing_at_change is None:
|
||||
return
|
||||
standing_w = float(standing_w)
|
||||
if abs(standing_w) < self.bound.move_w:
|
||||
return
|
||||
moved_w = abs(standing_w - self.standing_at_change)
|
||||
if moved_w < self.bound.move_w:
|
||||
return
|
||||
self.stale = True
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W - the reading is not following, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
|
||||
def unchanged_s(self, now_mono) -> float | None:
|
||||
"""Seconds since an arrival carried a different number. Status page."""
|
||||
if self.changed_mono is None:
|
||||
return None
|
||||
return max(0.0, float(now_mono) - self.changed_mono)
|
||||
|
||||
def reading(self):
|
||||
"""What the consumers get: the value, or None when it cannot be trusted.
|
||||
|
||||
⚠️ `None`, NEVER A HELD NUMBER, and never a zero. Both consumers already
|
||||
do the right thing with None and neither could ever receive one:
|
||||
`watchdog.SignWatchdog.evaluate` CLEARS its window on a None measurement
|
||||
rather than accumulating it, and `circulation.circulating_w` returns None
|
||||
for a roster with a missing term, which clears the circulation window
|
||||
too. This module is the thing that was missing between them.
|
||||
"""
|
||||
return None if self.stale else self.last
|
||||
|
||||
def note(self, unit_name: str) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
return f"{unit_name}: {self.reason}" if self.stale else ""
|
||||
|
||||
|
||||
def monotonic() -> float:
|
||||
"""The clock this module's callers use, in one place so tests can find it.
|
||||
|
||||
⚠️ `time.monotonic()`, not the wall clock, for `p1.P1Sample.age_s`'s reason:
|
||||
an NTP step on a Raspberry Pi that has just found the network is routinely
|
||||
minutes, and a freshness verdict computed across one would either fake a
|
||||
stale reading or, worse, hide a real one.
|
||||
"""
|
||||
return time.monotonic()
|
||||
@@ -122,6 +122,9 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
# 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
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
# shipped default and a literal here would be the fourth defaults site that
|
||||
@@ -213,7 +216,22 @@ class Inverter:
|
||||
|
||||
# --- this unit's own measurements ------------------------------------
|
||||
self.soc = None
|
||||
# ⚠️ SAFETY-37: `batt` IS NOW THE GATED READING, NOT THE RAW ONE. It is
|
||||
# None whenever `batt_fresh` says the register is not following its own
|
||||
# command - see freshness.py - and a None here is a MISSING input, which
|
||||
# §11.1 rule 1 answers with 0 W. The raw number that arrived is on
|
||||
# `batt_fresh.last`; read that only for diagnostics, never for control.
|
||||
self.batt = None
|
||||
# SAFETY-37. One arrival record per SIGNAL per UNIT. Per unit for
|
||||
# DEV-02's reason - a freshness verdict about B is not evidence about A,
|
||||
# and at N>=2 a shared record would be the flat-scalar defect rebuilt
|
||||
# inside the gate that exists to protect the per-unit checks.
|
||||
#
|
||||
# ⚠️ ONLY `batt` IS WIRED. `BOUNDS["soc"]` exists and is deliberately not
|
||||
# constructed here: SoC's obligation term is energy through the pack, not
|
||||
# command travel, and the constant it needs has not been measured. That
|
||||
# is TEL-08's, and freshness.py's docstring says exactly what it owes.
|
||||
self.batt_fresh = Freshness(BOUNDS["batt"])
|
||||
|
||||
# --- what this unit was last told, and what it last accepted ---------
|
||||
# `target` is THIS unit's share of the site target from the last
|
||||
@@ -266,6 +284,24 @@ class Inverter:
|
||||
self.store.set(self.key(name), value)
|
||||
|
||||
# -- the numbers other modules ask this object for -----------------------
|
||||
def standing_w(self) -> float:
|
||||
"""What this controller has standing on THIS unit's wire right now.
|
||||
|
||||
⚠️ ONE DEFINITION, TWO CALLERS, and it was a transcription before
|
||||
SAFETY-37. `cycle()` computes this for the sign watchdog and
|
||||
`_read_unit()` needs the identical number for the freshness conjunction;
|
||||
two spellings of "what is standing" would be two answers to the question
|
||||
the gate and the check it protects are both asking, and the copy that
|
||||
drifts is always the one further from the wire.
|
||||
|
||||
`last_write` is the value after the device clamp and confirmed accepted;
|
||||
`target` covers the case where nothing has been written yet and,
|
||||
deliberately, the case where the last write was REJECTED - the inverter
|
||||
is then still latched on `last_write`, which is what `last_write` holds.
|
||||
"""
|
||||
return float(self.last_write if self.last_write is not None
|
||||
else self.target)
|
||||
|
||||
def rating_w(self) -> float:
|
||||
"""The largest magnitude this unit can be commanded, in W.
|
||||
|
||||
@@ -319,6 +355,18 @@ class Inverter:
|
||||
f"(limit {float(stale_s):.0f} s)")
|
||||
return True, ""
|
||||
|
||||
def stale_note(self) -> str:
|
||||
"""SAFETY-37's one line for the status page. Empty when nothing to say.
|
||||
|
||||
Separate from `link_note()` because they are about different halves of
|
||||
the device and want opposite remedies: liveness is about the WRITE path
|
||||
and its answer is to drop this unit and let the survivors carry it,
|
||||
while a stale MEASUREMENT is about the read path and its answer is to
|
||||
stop the site (`inputs_ok`). Folding one into the other is how a
|
||||
measurement fault ends up handing a sibling extra power.
|
||||
"""
|
||||
return self.batt_fresh.note(self.name)
|
||||
|
||||
def link_note(self, now: datetime) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
if not self.has_writer():
|
||||
|
||||
@@ -45,6 +45,7 @@ from .circulation import (
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
|
||||
)
|
||||
from . import freshness
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
@@ -1109,8 +1110,18 @@ class Controller:
|
||||
for inv in self.inverters)
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
async def read_inputs(self) -> None:
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
|
||||
`now_mono` the monotonic clock the per-unit freshness records are
|
||||
stamped on. Defaulted rather than read inside `_read_unit()`
|
||||
so that ONE instant covers every unit in a gather - two
|
||||
units stamped from two clock reads would carry a freshness
|
||||
skew nobody could reproduce - and so that a check can drive
|
||||
real seconds through the gate without sleeping through them.
|
||||
"""
|
||||
o = self.o
|
||||
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
|
||||
if self.p1_enabled:
|
||||
# ⚠️ P1 is the only authoritative measurement of what the utility
|
||||
# sees (§5.1). When it is stale this is None, which falls into the
|
||||
@@ -1130,7 +1141,8 @@ class Controller:
|
||||
# names for the WRITE path, one function up. Within a unit the order is
|
||||
# unchanged (SoC, then battery power), so at the shipped roster of one
|
||||
# this is the same two calls in the same order.
|
||||
await asyncio.gather(*(self._read_unit(inv) for inv in self.inverters))
|
||||
await asyncio.gather(*(self._read_unit(inv, now_mono)
|
||||
for inv in self.inverters))
|
||||
if o.get("peak_forecast_entity"):
|
||||
raw = await self.hass.number(o["peak_forecast_entity"])
|
||||
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
|
||||
@@ -1176,10 +1188,41 @@ class Controller:
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
|
||||
async def _read_unit(self, inv) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities."""
|
||||
async def _read_unit(self, inv, now_mono: float) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities.
|
||||
|
||||
⚠️ SAFETY-37: THE POWER READING GOES THROUGH THE FRESHNESS GATE AND THE
|
||||
SoC READING DOES NOT, AND THE ASYMMETRY IS DELIBERATE. `batt` feeds the
|
||||
two consumers this card is about - SAFETY-30's per-unit sign check and
|
||||
SAFETY-31's circulating-power sensor - and its obligation term is
|
||||
command travel, which this method already has. SoC's obligation term is
|
||||
ENERGY through the pack and the constant it needs has never been
|
||||
measured on this site; wiring it here with a guessed constant would be a
|
||||
gate that fires on a rested pack, which is TEL-08's whole hazard.
|
||||
`freshness.BOUNDS["soc"]` states the bound and what TEL-08 owes.
|
||||
|
||||
⚠️ THE STANDING COMMAND IS ASKED OF THE UNIT, NOT RECOMPUTED. `cycle()`
|
||||
grades the sign watchdog against `inv.standing_w()` and this gate arms
|
||||
off the same method, because the gate exists to protect that check and a
|
||||
second spelling of "what is standing" would let the two disagree about
|
||||
the number they are both reasoning about.
|
||||
"""
|
||||
inv.soc = await self.hass.number(inv.soc_entity)
|
||||
inv.batt = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
was_stale = inv.batt_fresh.stale
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
# ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input,
|
||||
# not a measurement - `inputs_ok` sees the None, the site holds for
|
||||
# `stale_input_s` and then commands 0 W and keeps commanding, which is
|
||||
# §11.1 rule 1 and is already what a missing reading does here.
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
|
||||
_LOG.error("SAFETY-37: %s battery-power register is not following "
|
||||
"its own command - %s. Treating it as MISSING, which "
|
||||
"commands 0 W. It clears itself the moment the register "
|
||||
"reports a different value.",
|
||||
inv.name, inv.batt_fresh.reason)
|
||||
|
||||
def write_refusal(self, value: float, inv=None) -> str | None:
|
||||
"""Why `value` may NOT be written to `inv` right now, or None if it may.
|
||||
@@ -1469,9 +1512,20 @@ class Controller:
|
||||
return tuple(live)
|
||||
|
||||
def liveness_notes(self) -> list:
|
||||
"""Why each unit is or is not commandable, for the status page."""
|
||||
"""Why each unit is or is not commandable, for the status page.
|
||||
|
||||
⚠️ SAFETY-37's STALE-READING LINE RIDES HERE TOO, AND IT IS A DIFFERENT
|
||||
FAULT WITH A DIFFERENT REMEDY. A unit that is not commandable is dropped
|
||||
and its share is carried by the survivors; a unit whose power register
|
||||
has stopped following its command stops the SITE. They share this list
|
||||
because they share one place on the status page - the operator asking
|
||||
"why is nothing happening" needs both - and nothing downstream reads it.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
return [n for n in (inv.link_note(now) for inv in self.inverters) if n]
|
||||
return [n for n in
|
||||
[x for inv in self.inverters
|
||||
for x in (inv.link_note(now), inv.stale_note())]
|
||||
if n]
|
||||
|
||||
async def cycle(self) -> None:
|
||||
"""Gather claims, resolve precedence, write the winner.
|
||||
@@ -1560,7 +1614,10 @@ class Controller:
|
||||
standing = {}
|
||||
for inv in self.inverters:
|
||||
was_tripped = inv.sign_wd.tripped
|
||||
stood = inv.last_write if inv.last_write is not None else inv.target
|
||||
# ⚠️ `Inverter.standing_w()`, NOT THE EXPRESSION IT REPLACES.
|
||||
# SAFETY-37's freshness gate arms off the same method, and this used
|
||||
# to be the only spelling of it. See that method's docstring.
|
||||
stood = inv.standing_w()
|
||||
standing[inv.name] = stood
|
||||
against = self.grid if n_units <= 1 else inv.batt
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
"""Runnable check for SAFETY-37's freshness policy. `python3 test_freshness.py`
|
||||
|
||||
THE SUBJECT OF THIS FILE IS THE FIXTURE, NOT THE CODE. A pack legitimately
|
||||
resting at 40 % and a dead sensor reading 40 % ARE THE SAME NUMBER. A check that
|
||||
cannot separate "fresh and unchanged" from "stale" cannot see the failure it
|
||||
names, and this repository has recorded that defect - "a check whose DATA cannot
|
||||
reach the failure it names" - eleven times. So part 1 builds the separation and
|
||||
ASSERTS THE VALUE STREAMS ARE BIT-IDENTICAL across the pair; everything after it
|
||||
is downstream of that pair being real.
|
||||
|
||||
⚠️ AND THE CARD'S WORDING HAD TO BE SHARPENED TO BUILD IT, WHICH IS SAID HERE
|
||||
RATHER THAN QUIETLY WORKED AROUND. The card says "only arrival separates them".
|
||||
For a signal this add-on polls out of Home Assistant's state cache, ARRIVAL
|
||||
CANNOT SEPARATE THEM: `read_inputs()` runs about once a second and Home
|
||||
Assistant answers 200 with a number whether or not the publisher behind the
|
||||
entity is alive, so both members of the pair arrive, identically, forever.
|
||||
Part 1a demonstrates exactly that - an arrival-age gate is graded and FAILS to
|
||||
tell the pair apart - and it is the reason the shipped verdict is a conjunction
|
||||
with an OBLIGATION term rather than an age threshold. Every clock in that
|
||||
verdict is still an arrival clock: nothing reads a Home Assistant timestamp,
|
||||
which is the half of "arrival-stamped" that TEL-06 is blocked on and that this
|
||||
module does honour.
|
||||
|
||||
1. THE PAIR. Fresh-and-unchanged vs stale, on identical value streams, on
|
||||
the policy object itself. Plus 1a, the arrival-age gate that cannot.
|
||||
2. THE WIRING. The same pair driven through the REAL `Controller.read_inputs`
|
||||
at a roster of TWO, with the two units carrying DIFFERENT power values.
|
||||
3. THE CONSUMERS REFUSE, and the counterfactual is run beside them: the
|
||||
per-unit sign check and the circulating-power sensor, with the gate and
|
||||
with the gate removed, through real `cycle()` calls.
|
||||
4. THE LATCH, and the ~1 Hz oscillation it exists to prevent.
|
||||
5. N=1. Nothing changes on a healthy install - asserted on the shipped read
|
||||
path, not assumed - and the one thing that DOES change is named.
|
||||
6. THE BOUNDS, graded against the shipped `config.yaml` at both ends.
|
||||
7. THE POLICY TABLE tells the truth about what is wired.
|
||||
8. THE MEASUREMENT the bound rests on, replayed from the committed capture.
|
||||
9. KNOWN SURVIVORS, graded so they are residuals rather than surprises.
|
||||
|
||||
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12; goodwe/NOTES.md §1.1b).
|
||||
Every two-unit case below is two copies of inverter A's behaviour with
|
||||
DIFFERENT numbers passed in, and no number here is a measured parameter for a
|
||||
second device.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md
|
||||
before you report a survivor.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import glob
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import freshness as F
|
||||
from app.circulation import circulating_w
|
||||
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
|
||||
from app.inverter import Inverter
|
||||
from app.main import Controller
|
||||
from app.store import DEFAULTS
|
||||
from app.watchdog import SignWatchdog, SignWatchdogConfig, disagrees
|
||||
|
||||
fails = []
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
class MemStore:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
def set_time(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
"""A Home Assistant that answers per ENTITY and RECORDS what it served.
|
||||
|
||||
⚠️ PER ENTITY, AND THE RECORD IS LOAD-BEARING. A stub that answered one
|
||||
number whatever it was asked could not tell "B was read from B's sensor"
|
||||
from "B was handed A's reading" - and the served-value log is what lets
|
||||
part 1 assert that the fresh run and the stale run saw the SAME numbers.
|
||||
"""
|
||||
|
||||
def __init__(self, numbers=None, limits=None):
|
||||
self.numbers = dict(numbers or {})
|
||||
self.limits_by = dict(limits or {})
|
||||
self.served = [] # (entity, value) in call order
|
||||
self.writes = []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
self.served.append((entity, v))
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
A_RATING = 5000.0
|
||||
# ⚠️ ILLUSTRATIVE, AND NOT B'S DATASHEET FIGURES. SPIKE-04a established B as a
|
||||
# GW5048D-ES rated 4600 W with a 10 % floor (goodwe/NOTES.md §1.1b), and this
|
||||
# fixture deliberately does not use them: 5000/4600 is a 400 W gap, narrower
|
||||
# than a slew step, and two equal-ish numbers cannot show a check that read the
|
||||
# wrong unit's. Same reasoning as test_inverter.py's `B_RATING_HYPOTHESIS`.
|
||||
B_RATING_SIM = 3000.0
|
||||
A_FLOOR, B_FLOOR_SIM = 10, 15
|
||||
|
||||
ROSTER = [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": A_RATING, "bms_soc_floor": A_FLOOR},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": B_RATING_SIM, "bms_soc_floor": B_FLOOR_SIM},
|
||||
]
|
||||
|
||||
# ⚠️ A AND B CARRY DIFFERENT POWER VALUES IN EVERY FIXTURE BELOW, and the card
|
||||
# names this as the trap it expects: "if both read the same, the test cannot
|
||||
# distinguish a frozen reading from a live one and will pass against the broken
|
||||
# code". SAFETY-30's fix round walked into exactly that with two watchdog holds
|
||||
# both set to 1.
|
||||
A_FROZEN_W = -1200.0 # inverter A's register, stuck on a charge figure
|
||||
B_LIVE_W = 900.0 # inverter B's register, moving, and NOT A's number
|
||||
|
||||
BASE = dict(meter_source="off", meter_entity="sensor.grid", setpoint_entity="",
|
||||
soc_entity="", batt_entity="", max_w=A_RATING, gain=0.6, slew_w=0,
|
||||
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
|
||||
stale_input_s=15, inverters=ROSTER)
|
||||
|
||||
|
||||
def site(opts=None, numbers=None):
|
||||
"""A real two-inverter Controller off the shipped `build_roster()`."""
|
||||
hass = Hass(dict({"sensor.grid": 0.0,
|
||||
"sensor.soc_a": 50.0, "sensor.batt_a": A_FROZEN_W,
|
||||
"sensor.soc_b": 55.0, "sensor.batt_b": B_LIVE_W},
|
||||
**(numbers or {})),
|
||||
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
|
||||
"number.sp_b": (-B_RATING_SIM, B_RATING_SIM, 1.0)})
|
||||
c = Controller(dict(BASE, **(opts or {})), hass, MemStore(), Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -B_RATING_SIM, B_RATING_SIM
|
||||
return c, hass
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("1. THE PAIR: 'fresh and unchanged' vs 'stale', on IDENTICAL value streams")
|
||||
|
||||
# The two runs below feed the policy object the SAME forty readings of the same
|
||||
# number at the same instants. The ONLY difference is what the controller was
|
||||
# asking the inverter to do while those readings arrived.
|
||||
#
|
||||
# FRESH the site is converged. The command sits where it is, the register
|
||||
# answers the same watt, and nothing is wrong. This is the case the
|
||||
# 30-day capture in part 8 measures at up to 263.2 s.
|
||||
# STALE the site reversed. The command travelled 2500 W and the register did
|
||||
# not move by one watt in 40 s, which is 2.4x the fastest it could
|
||||
# possibly be obliged to answer in.
|
||||
STREAM = [(float(t), A_FROZEN_W) for t in range(0, 40)]
|
||||
|
||||
|
||||
def drive(bound, stream, standing_of):
|
||||
"""Feed a Freshness one arrival per (t, value). Returns (obj, served, verdicts)."""
|
||||
f = Freshness(bound)
|
||||
served, verdicts = [], []
|
||||
for t, v in stream:
|
||||
f.submit(v, standing_of(t), t)
|
||||
served.append(v)
|
||||
verdicts.append(f.reading())
|
||||
return f, served, verdicts
|
||||
|
||||
|
||||
_fresh, _fresh_served, _fresh_read = drive(
|
||||
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W)
|
||||
# The command travels from -1200 W to +1300 W at t=1 and stays there: a site
|
||||
# turnaround, which is the ordinary stimulus SAFETY-31's own campaign sweeps.
|
||||
_stale, _stale_served, _stale_read = drive(
|
||||
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W if t < 1 else 1300.0)
|
||||
|
||||
print(f" served (both runs): {_fresh_served[:3]} ... x{len(_fresh_served)}")
|
||||
# ⚠️ `key=str`, AND IT IS NOT COSMETIC. Without it this line raises TypeError
|
||||
# comparing None with a float the moment a mutation makes the FRESH run stale -
|
||||
# which is what M2 (dropping the command-travel term) did: the file died on a
|
||||
# DIAGNOSTIC PRINT before check one, so the harness reported "red" with no named
|
||||
# check behind it. MUTATION.md's reporting rule is "which suite went red, and on
|
||||
# WHICH NAMED CHECK"; a print that can crash first destroys exactly that.
|
||||
print(f" fresh verdicts: unique={sorted(set(_fresh_read), key=str)}")
|
||||
print(f" stale verdicts: first None at t="
|
||||
f"{next((i for i, v in enumerate(_stale_read) if v is None), None)}")
|
||||
|
||||
check("⚠️ THE FIXTURE'S OWN PRECONDITION: the two runs saw a BIT-IDENTICAL "
|
||||
"value stream. If this ever stops being true the pair below is comparing "
|
||||
"two different experiments and proves nothing about freshness",
|
||||
_fresh_served == _stale_served
|
||||
and len({v for v in _fresh_served}) == 1)
|
||||
check("FRESH AND UNCHANGED is not stale. Forty identical readings over 39 s "
|
||||
"with the command standing still - a converged site - and the reading "
|
||||
"reaches its consumers untouched",
|
||||
not _fresh.stale and _fresh_read == [A_FROZEN_W] * len(STREAM))
|
||||
check("⚠️ STALE IS. The same forty readings, the same instants, the same "
|
||||
"numbers - and the command moved 2500 W underneath them. The reading "
|
||||
"becomes None to its consumers, which is the whole card",
|
||||
_stale.stale and _stale_read[-1] is None)
|
||||
check("...and it becomes None at the BOUND, not before: 35.0 s of unchanged "
|
||||
"arrivals, so the last fresh verdict is at t=34 and the first None at "
|
||||
"t=35",
|
||||
_stale_read[34] == A_FROZEN_W and _stale_read[35] is None
|
||||
and BATT_UNCHANGED_S == 35.0)
|
||||
check("...and NEVER a held number and never a zero - `reading()` has exactly "
|
||||
"two shapes, the arrived value or None",
|
||||
set(type(v) for v in _stale_read) == {float, type(None)}
|
||||
and 0.0 not in [v for v in _stale_read if v is not None])
|
||||
check("...and the reason names the three terms an operator has to check, with "
|
||||
"the numbers that fired it",
|
||||
"STALE_READING" in _stale.reason and "2500 W" in _stale.reason
|
||||
and "unchanged for 35 s" in _stale.reason)
|
||||
|
||||
# --- 1a. the gate the card asked for first, graded and found unable ----------
|
||||
# An arrival-age gate is what "arrival-stamped" reads as on first hearing. It is
|
||||
# built here and run against the SAME pair, because a criterion that cannot be
|
||||
# satisfied is worth more said out loud than approximated in silence.
|
||||
_arrival_ages = []
|
||||
_af = Freshness(BOUNDS["batt"])
|
||||
for t, v in STREAM:
|
||||
_af.submit(v, A_FROZEN_W, t)
|
||||
# Every read produced a number, so on both members of the pair the age
|
||||
# since the last ARRIVAL is 0.0 at every instant. There is nothing to
|
||||
# threshold.
|
||||
_arrival_ages.append(0.0 if _af.last is not None else None)
|
||||
check("⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART, and that is why the "
|
||||
"shipped verdict is not one. Home Assistant answered every read with a "
|
||||
"number in BOTH runs, so the age since arrival is 0.0 s at every instant "
|
||||
"of both - a threshold on it is a threshold that can never fire, which "
|
||||
"is TEL-06's defect rebuilt in this repository",
|
||||
set(_arrival_ages) == {0.0} and _af.arrivals == len(STREAM))
|
||||
check("...while the arrival record IS what the shipped verdict is measured on: "
|
||||
"`unchanged_s` is the gap between arrivals that carried different "
|
||||
"numbers, on this process's own monotonic clock, and no Home Assistant "
|
||||
"timestamp is read anywhere in the module",
|
||||
_stale.unchanged_s(39.0) == 39.0
|
||||
and "last_updated" not in io.open(HERE / "app" / "freshness.py",
|
||||
encoding="utf-8").read().split(
|
||||
'"""', 2)[2])
|
||||
check("...and a read that produced NOTHING is still an immediate None, exactly "
|
||||
"as it is today - a failed read is not an arrival and is not an age",
|
||||
Freshness(BOUNDS["batt"]).reading() is None
|
||||
and (lambda f: (f.submit(5.0, 0.0, 0.0), f.submit(None, 0.0, 1.0),
|
||||
f.reading() is None and f.held == 5.0)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("2. THE WIRING: the same pair through the REAL read path, at a roster of 2")
|
||||
|
||||
|
||||
async def _reads(c, hass, plan):
|
||||
"""Drive real `read_inputs()` calls. `plan` is (t, standing_a, batt_a)."""
|
||||
out = []
|
||||
for t, standing_a, batt_a in plan:
|
||||
hass.numbers["sensor.batt_a"] = batt_a
|
||||
# `last_write` is what the write path sets and what `standing_w()`
|
||||
# reads; setting it here is standing in for a write, not for the gate.
|
||||
c.inverters[0].last_write = standing_a
|
||||
await c.read_inputs(now_mono=t)
|
||||
out.append((t, c.inverters[0].batt, c.inverters[1].batt))
|
||||
return out
|
||||
|
||||
|
||||
_PLAN_FRESH = [(float(t), A_FROZEN_W, A_FROZEN_W) for t in range(0, 40)]
|
||||
_PLAN_STALE = [(float(t), A_FROZEN_W if t < 1 else 1300.0, A_FROZEN_W)
|
||||
for t in range(0, 40)]
|
||||
|
||||
_cf, _hf = site()
|
||||
_of = run(_reads(_cf, _hf, _PLAN_FRESH))
|
||||
_cs, _hs = site()
|
||||
_os = run(_reads(_cs, _hs, _PLAN_STALE))
|
||||
|
||||
print(f" fresh run: A={sorted({r[1] for r in _of})} B={sorted({r[2] for r in _of})}")
|
||||
print(f" stale run: A={sorted({r[1] for r in _os}, key=str)} "
|
||||
f"B={sorted({r[2] for r in _os})}")
|
||||
|
||||
check("PRECONDITION: both runs were served the same per-entity numbers, so the "
|
||||
"difference below is the wiring and not the stimulus",
|
||||
[x for x in _hf.served if x[0] == "sensor.batt_a"]
|
||||
== [x for x in _hs.served if x[0] == "sensor.batt_a"])
|
||||
check("⚠️ THE TWO UNITS CARRY DIFFERENT POWER VALUES - A at -1200 W, B at "
|
||||
"+900 W. A fixture whose units read the same number cannot show a gate "
|
||||
"that answered for the wrong unit, which is the trap the card names",
|
||||
A_FROZEN_W != B_LIVE_W and all(r[2] == B_LIVE_W for r in _of))
|
||||
check("THROUGH THE REAL PATH: a converged site keeps its reading",
|
||||
all(r[1] == A_FROZEN_W for r in _of))
|
||||
check("⚠️ THROUGH THE REAL PATH: a frozen register becomes `Inverter.batt is "
|
||||
"None`. Not a held number, not a zero - the same None a missing entity "
|
||||
"produces, which is the state §11.1 rule 1 already answers with 0 W",
|
||||
_os[34][1] == A_FROZEN_W and _os[35][1] is None
|
||||
and all(r[1] is None for r in _os[35:]))
|
||||
check("...and ONLY the frozen unit's. B's own reading is untouched, which is "
|
||||
"the per-unit half: a freshness verdict about A is not evidence about B",
|
||||
all(r[2] == B_LIVE_W for r in _os) and _cs.inverters[1].batt == B_LIVE_W
|
||||
and not _cs.inverters[1].batt_fresh.stale)
|
||||
check("...and `inputs_ok` goes False, so the site stops rather than regulating "
|
||||
"against a number nobody measured",
|
||||
_cs.inputs_ok is False and _cf.inputs_ok is True)
|
||||
check("...and the operator gets a line saying which unit and why, on the same "
|
||||
"status list the liveness notes use",
|
||||
any("STALE_READING" in n and "inverter-a" in n
|
||||
for n in _cs.liveness_notes())
|
||||
and _cf.liveness_notes() == [])
|
||||
check("...and the raw number is still recorded for diagnostics, so the "
|
||||
"operator can see WHAT it was stuck on",
|
||||
_cs.inverters[0].batt_fresh.last == A_FROZEN_W)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("3. THE CONSUMERS REFUSE - and the counterfactual runs beside them")
|
||||
|
||||
# Both consumers already do the right thing with a None. Neither could ever
|
||||
# receive one before this card, which is the entire exposure: the gate is the
|
||||
# thing that was missing BETWEEN the shipped read and the shipped check.
|
||||
_cfg3 = SignWatchdogConfig(min_w=250.0, hold_s=85.0)
|
||||
check("PRECONDITION on the shipped predicate: a None measurement does not "
|
||||
"disagree with anything, so it CLEARS the sign window",
|
||||
disagrees(1300.0, None, True, _cfg3) is False
|
||||
and disagrees(1300.0, A_FROZEN_W, True, _cfg3) is True)
|
||||
check("PRECONDITION on the shipped sensor: a roster with a missing term is "
|
||||
"None, and None clears the circulation window rather than accumulating",
|
||||
circulating_w([1300.0, None]) is None
|
||||
and circulating_w([A_FROZEN_W, B_LIVE_W]) == 2 * min(
|
||||
abs(A_FROZEN_W), abs(B_LIVE_W)))
|
||||
|
||||
|
||||
async def _cycles(gated, ticks=25, step_mono=12.0, wall=0.20):
|
||||
"""Real cycles at N=2 with A's register frozen and the LOOP driving.
|
||||
|
||||
⚠️ THE COMMAND TRAVEL IS PRODUCED BY THE SHIPPED CONTROL LAW, NOT BY THE
|
||||
FIXTURE. The meter is pinned at +3000 W of import, so `compute()` ramps the
|
||||
site into discharge at its own slew rate through the real allocator and the
|
||||
real write path; nothing here assigns `last_write`. A fixture that set the
|
||||
standing command by hand would be feeding the gate its own arming condition,
|
||||
which is the shape of check this repository keeps finding.
|
||||
|
||||
`gated=False` is THE COUNTERFACTUAL - the shipped behaviour before this
|
||||
card, rebuilt by handing the consumers the raw held number the gate
|
||||
withheld. Same cycles, same stimulus, so the difference between the two
|
||||
columns is the gate and nothing else.
|
||||
|
||||
⚠️ TWO CLOCKS, DELIBERATELY, AND THE WALL-CLOCK ONE IS THE FLAKE RISK.
|
||||
The freshness verdict runs on the INJECTED monotonic clock (12 s a tick, so
|
||||
the 35 s bound is crossed on tick 3, deterministically, however loaded the
|
||||
machine is). The sign watchdog runs on the WALL clock inside `cycle()`,
|
||||
which cannot be injected - so `unit_sign_disagree_s` is set to 2 s and the
|
||||
ticks are 0.20 s apart, putting the gate's close at ~0.6 s against a 2 s
|
||||
window. That ORDER is the field's - 35 s < 75 s < 85 s by construction, part
|
||||
6 - and the gate is given no head start beyond it.
|
||||
|
||||
⚠️ AND THE MARGIN IS MEASURED AND ASSERTED RATHER THAN ASSUMED (CI-06).
|
||||
`test_peakguard.py` answered two of its own checks with the wall clock, flaked
|
||||
at ~20 %, and cost a wrong merge diagnosis - a flaky check does not waste a
|
||||
re-run, it manufactures evidence for whichever hypothesis the reader already
|
||||
holds. So this returns the wall clock at which the gate closed, and the check
|
||||
below fails with THAT NUMBER if the box was too slow, rather than reporting a
|
||||
trip that looks like a code defect.
|
||||
"""
|
||||
c, hass = site({"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 2,
|
||||
"sign_disagree_w": 250, "sign_disagree_s": 2,
|
||||
"site_sign_disagree_w": 250, "site_sign_disagree_s": 2,
|
||||
"auto_start": True},
|
||||
{"sensor.grid": 3000.0})
|
||||
c.auto = True
|
||||
circ, tripped_at, standing = [], None, []
|
||||
t_start, t_gate = time.monotonic(), None
|
||||
for k in range(ticks):
|
||||
# A's register is FROZEN on the charge figure it stopped at; B's is
|
||||
# live and is a DIFFERENT number. Neither is touched by the loop.
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W
|
||||
hass.numbers["sensor.batt_b"] = B_LIVE_W
|
||||
await c.read_inputs(now_mono=k * step_mono)
|
||||
if not gated:
|
||||
for inv in c.inverters:
|
||||
inv.batt = inv.batt_fresh.last
|
||||
if t_gate is None and c.inverters[0].batt_fresh.stale:
|
||||
t_gate = time.monotonic() - t_start
|
||||
circ.append(c.site_circulating_w)
|
||||
standing.append(c.inverters[0].standing_w())
|
||||
await c.cycle()
|
||||
if c.inverters[0].sign_wd.tripped and tripped_at is None:
|
||||
tripped_at = k
|
||||
await asyncio.sleep(wall)
|
||||
return c, circ, tripped_at, standing, t_gate
|
||||
|
||||
|
||||
_cg, _circg, _tripg, _standg, _tgate = run(_cycles(True))
|
||||
_cu, _circu, _tripu, _standu, _ = run(_cycles(False))
|
||||
_WD_S = 2.0 # `unit_sign_disagree_s` as this fixture sets it, above
|
||||
print(f" standing on A (gated): {[round(v) for v in _standg]}")
|
||||
print(f" gated : sign trip tick={_tripg} circ={_circg[:6]}...")
|
||||
print(f" gate closed after {_tgate:.2f} s of WALL clock, against the "
|
||||
f"{_WD_S:.0f} s watchdog window ({_WD_S / _tgate:.1f}x margin)")
|
||||
print(f" counterfact: sign trip tick={_tripu} circ={_circu[:6]}...")
|
||||
|
||||
check("⚠️ PRECONDITION ON THE WALL CLOCK, AND IT IS A CHECK RATHER THAN AN "
|
||||
"ASSUMPTION (CI-06). The gate closes on the INJECTED clock, the watchdog "
|
||||
"on the real one, so this fixture only means something while the gate "
|
||||
f"closes well inside the window: it closed at {_tgate:.2f} s against "
|
||||
f"{_WD_S:.0f} s. If this fails the machine was slow, not the code - and "
|
||||
"saying so here is what stops that being read as a defect",
|
||||
_tgate is not None and _tgate < 0.5 * _WD_S)
|
||||
check("PRECONDITION: the SHIPPED control law really did travel the command - "
|
||||
"the gate's arming term is produced by `compute()` and the allocator, "
|
||||
"not written into the fixture",
|
||||
max(abs(v) for v in _standu) > MOVE_W
|
||||
and abs(_standu[3] - _standu[0]) > MOVE_W)
|
||||
|
||||
check("⚠️ COUNTERFACTUAL FIRST - the stimulus is REAL. Without the gate, the "
|
||||
"frozen register false-trips SAFETY-30's per-unit sign check and parks a "
|
||||
"healthy inverter. That is the fault this card was filed for",
|
||||
_tripu is not None and _cu.inverters[0].sign_wd.tripped
|
||||
and "SIGN_DISAGREEMENT" in _cu.inverters[0].sign_wd.reason)
|
||||
check("⚠️ AND WITH THE GATE IT DOES NOT TRIP. The reading is withheld before "
|
||||
"the watchdog's window can accumulate, so the check refuses to conclude "
|
||||
"rather than concluding wrongly - which is what a stale reading deserves",
|
||||
_tripg is None and not _cg.inverters[0].sign_wd.tripped)
|
||||
check("...and inverter B was not parked either way - the verdict is per unit",
|
||||
not _cg.inverters[1].sign_wd.tripped
|
||||
and not _cu.inverters[1].sign_wd.tripped)
|
||||
check("⚠️ COUNTERFACTUAL: without the gate the circulating-power sensor "
|
||||
"reports a loop that is not happening, computed from one frozen term and "
|
||||
"one live one",
|
||||
all(v == 2 * min(abs(A_FROZEN_W), abs(B_LIVE_W)) for v in _circu)
|
||||
and _circu[0] == 1800.0)
|
||||
check("⚠️ AND WITH THE GATE IT REPORTS `None` - an unknown, which CLEARS the "
|
||||
"circulation window instead of accumulating it. Both consumers gated, "
|
||||
"from one place, by one mechanism",
|
||||
_circg[0] == 1800.0 and _circg[-1] is None
|
||||
and all(v is None for v in _circg[_circg.index(None):]))
|
||||
check("...and the circulation watchdog did not latch on the stale evidence",
|
||||
not _cg.circ_wd.tripped and _cg.circ_wd.over_since is None)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("4. THE LATCH, and the ~1 Hz oscillation it exists to prevent")
|
||||
|
||||
# Declaring the reading stale makes inputs_ok False, which commands 0 W, which
|
||||
# drops the standing command below MOVE_W, which un-arms the conjunction. Without
|
||||
# a latch the held number would come straight back to the consumers, and the
|
||||
# whole thing would flip once per read for as long as the register stayed dead.
|
||||
_lat = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_lat.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_was = _lat.stale
|
||||
_after_zero = []
|
||||
for t in range(40, 50):
|
||||
_lat.submit(A_FROZEN_W, 0.0, float(t)) # the site has been stopped
|
||||
_after_zero.append(_lat.reading())
|
||||
check("PRECONDITION: it is stale before the site is stopped",
|
||||
_was and _lat.reading() is None)
|
||||
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W. The conjunction is no "
|
||||
"longer armed - |standing| is 0 W, below the 250 W term - so an unlatched "
|
||||
"verdict would hand the held -1200 W straight back and the site would "
|
||||
"restart into it, then stop, then restart, once per read",
|
||||
all(v is None for v in _after_zero))
|
||||
# ⚠️ AND A FAILED READ MUST NOT CLEAR IT EITHER. This check exists because the
|
||||
# mutation that added `self.stale = False` to `submit()`'s None branch SURVIVED
|
||||
# the whole sweep - no fixture had ever injected a read failure while latched.
|
||||
# It matters: a stale verdict stops the site, the site is then commanded 0 W, and
|
||||
# a single unreachable-Home-Assistant read would drop the latch into a state
|
||||
# where the conjunction is no longer armed - so the held -1200 W would come back
|
||||
# to the consumers on the very next successful read.
|
||||
_lat_fail = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_lat_fail.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_lat_fail.submit(None, 0.0, 40.0) # Home Assistant did not answer
|
||||
_after_fail = _lat_fail.reading()
|
||||
_lat_fail.submit(A_FROZEN_W, 0.0, 41.0) # it answers again, same number,
|
||||
# site now resting at 0 W
|
||||
check("⚠️ A FAILED READ DOES NOT CLEAR THE LATCH - found by a SURVIVING "
|
||||
"mutation, not by design. Our inability to fetch a value is not evidence "
|
||||
"that the value moved, and a stale verdict that a single unreachable read "
|
||||
"can drop is one a stopped site would drop every time",
|
||||
_after_fail is None and _lat_fail.stale
|
||||
and _lat_fail.reading() is None)
|
||||
|
||||
check("⚠️ AND ONLY MOVEMENT CLEARS IT - a different number arriving, which is "
|
||||
"exactly the evidence the verdict was about. Not a timer, not a restart, "
|
||||
"not the operator's start button",
|
||||
(lambda f: (f.submit(A_FROZEN_W + 1.0, 0.0, 60.0),
|
||||
(not f.stale) and f.reading() == A_FROZEN_W + 1.0)[-1])(_lat))
|
||||
# ⚠️ THE MAGNITUDE TERM OF THE CONJUNCTION, AND THIS CHECK EXISTS BECAUSE A
|
||||
# MUTATION SURVIVED WITHOUT IT. Deleting `abs(standing_w) < move_w` from
|
||||
# `_grade()` left every other check in this file green, because in all of them a
|
||||
# command that has TRAVELLED 250 W has also ENDED UP above 250 W. The case that
|
||||
# separates them is a site being brought to REST.
|
||||
_rest = Freshness(BOUNDS["batt"])
|
||||
# The register reaches +5 W on the same read at which the old +1300 W command is
|
||||
# still standing - the write path is serialised, so the register can catch up
|
||||
# BEFORE the command settles - and then the site is commanded to 0 W and the
|
||||
# register sits at +5 W, correctly, for a minute.
|
||||
_rest.submit(1290.0, 1300.0, 0.0)
|
||||
_rest.submit(5.0, 1300.0, 1.0) # standing_at_change captures 1300 W
|
||||
for t in range(2, 60):
|
||||
_rest.submit(5.0, 0.0, float(t)) # site at rest; register truthfully +5 W
|
||||
check("⚠️ THE MAGNITUDE TERM IS LOAD-BEARING, and it took a surviving mutation "
|
||||
"to show it. A register that reached +5 W while the old +1300 W command "
|
||||
"was still standing has a `standing_at_change` of 1300 W, so the TRAVEL "
|
||||
"term alone reads 1300 W of unfollowed command for the whole minute the "
|
||||
"site rests - and calls a perfectly truthful reading dead. Requiring "
|
||||
"|standing| >= 250 W as well is what says 'a site at rest is not owed "
|
||||
"movement'",
|
||||
not _rest.stale and _rest.reading() == 5.0)
|
||||
check("...and the hole that term leaves CANNOT MISLEAD EITHER CONSUMER, which "
|
||||
"is why it is the right trade: a unit commanded under 250 W is one "
|
||||
"`watchdog.disagrees()` is False for whatever the measurement says, and a "
|
||||
"frozen term beside a live unit that is itself resting nets to zero "
|
||||
"circulating power rather than to a false alarm",
|
||||
disagrees(0.0, A_FROZEN_W, True, _cfg3) is False
|
||||
and circulating_w([A_FROZEN_W, 0.0]) == 0.0)
|
||||
|
||||
check("...and one watt is enough, because 0x0518 is a 1 W register that "
|
||||
"dithers +/-1 W while holding (goodwe/NOTES.md §5.11, measured "
|
||||
"2026-08-22) - a working register clears this on its next 10 s publish",
|
||||
_lat.held == A_FROZEN_W + 1.0 and not _lat.stale)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("5. N=1: nothing changes on a healthy install, and what DOES is named")
|
||||
|
||||
_ONE = dict(BASE, inverters=[], setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
bms_soc_floor=A_FLOOR)
|
||||
|
||||
|
||||
def one_site():
|
||||
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": 50.0,
|
||||
"sensor.batt_a": A_FROZEN_W},
|
||||
{"number.sp_a": (-A_RATING, A_RATING, 1.0)})
|
||||
c = Controller(dict(_ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
|
||||
return c, hass
|
||||
|
||||
|
||||
async def _one_healthy():
|
||||
c, hass = one_site()
|
||||
seen = []
|
||||
for t in range(0, 60):
|
||||
# A healthy register under a moving command: it dithers, so it is never
|
||||
# the same integer watt twice running.
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W + (t % 3)
|
||||
c.inverters[0].last_write = 1300.0 if t % 2 else -1200.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
seen.append(c.inverters[0].batt)
|
||||
return c, seen
|
||||
|
||||
|
||||
_c1, _seen1 = run(_one_healthy())
|
||||
check("⚠️ AC: AT N=1 NOTHING CHANGES ON A HEALTHY INSTALL, and it is asserted "
|
||||
"rather than assumed. Sixty reads of a dithering register under a command "
|
||||
"reversing every second: every value reaches the consumers unmodified, "
|
||||
"the gate never arms, and the status page says nothing new",
|
||||
_seen1 == [A_FROZEN_W + (t % 3) for t in range(60)]
|
||||
and not _c1.inverters[0].batt_fresh.stale
|
||||
and _c1.liveness_notes() == [] and _c1.inputs_ok is True)
|
||||
check("...and the shipped roster really is one unit, so this is the install "
|
||||
"that is running in a house today and not a fixture shaped like it",
|
||||
len(_c1.inverters) == 1 and _c1.inverters[0].name == "inverter-a")
|
||||
|
||||
|
||||
async def _one_frozen():
|
||||
c, hass = one_site()
|
||||
for t in range(0, 40):
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W
|
||||
c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
return c
|
||||
|
||||
|
||||
_c1f = run(_one_frozen())
|
||||
check("⚠️ AND WHAT DOES CHANGE AT N=1 IS NAMED RATHER THAN HIDDEN: a FROZEN "
|
||||
"register now stops the site, where before it read as a healthy "
|
||||
"measurement. That is a behaviour change on the shipped roster and it is "
|
||||
"the point of the card - HW-01 was a one-inverter site, and the pack "
|
||||
"drained to its floor with every detector reading healthy for 6 h 25 m",
|
||||
_c1f.inverters[0].batt is None and _c1f.inputs_ok is False)
|
||||
check("...and the direction it fails in is the safe one: a false verdict "
|
||||
"commands 0 W and keeps commanding (§11.1 rule 1), behind the existing "
|
||||
"`stale_input_s` hold, and clears itself on the register's next move",
|
||||
float(_c1f.o.get("stale_input_s")) > 0
|
||||
and _c1f.inverters[0].batt_fresh.stale)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("6. THE BOUNDS, graded against the SHIPPED config.yaml at BOTH ends")
|
||||
|
||||
|
||||
def option_default(key):
|
||||
"""`key`'s default out of config.yaml's `options:` block, at two spaces."""
|
||||
body = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
body = body[body.index("\noptions:"):body.index("\nschema:")]
|
||||
for line in body.splitlines():
|
||||
if line.startswith(f" {key}:") and not line[2:3].isspace():
|
||||
return float(line.split(":", 1)[1].strip())
|
||||
return None
|
||||
|
||||
|
||||
_circ_hold = option_default("circulation_hold_s")
|
||||
_unit_hold = option_default("unit_sign_disagree_s")
|
||||
_unit_min_w = option_default("unit_sign_disagree_w")
|
||||
_ceiling = min(_circ_hold, _unit_hold)
|
||||
print(f" floor {BATT_OBLIGED_S:.1f} s = settle 6.0 + publish 10.0 + read 1.0")
|
||||
print(f" bound {BATT_UNCHANGED_S:.1f} s")
|
||||
print(f" ceil {_ceiling:.1f} s = min(circulation_hold_s {_circ_hold:.0f}, "
|
||||
f"unit_sign_disagree_s {_unit_hold:.0f})")
|
||||
|
||||
check("PRECONDITION: both consumer windows were actually found in config.yaml, "
|
||||
"so a rename cannot turn this part into a check of two Nones",
|
||||
_circ_hold == 75.0 and _unit_hold == 85.0 and _unit_min_w == 250.0)
|
||||
check("⚠️ THE BOUND IS AT LEAST 2x THE PHYSICAL FLOOR. Below 17.0 s a healthy "
|
||||
"register has not yet been OBLIGED to answer - 6.0 s of plant "
|
||||
"(NOTES §5.9), one 10 s ESPHome publish (firmware/goodwe-master.yaml:181) "
|
||||
"and one 1 s add-on read - so a shorter bound trips on a working inverter",
|
||||
BATT_UNCHANGED_S >= 2 * BATT_OBLIGED_S and BATT_OBLIGED_S == 17.0)
|
||||
check("⚠️ AND AT MOST HALF THE TIGHTER CONSUMER WINDOW. A gate slower than its "
|
||||
"consumer hands over the frozen number and explains afterwards. THIS IS "
|
||||
"THE CHECK THAT BITES IF AN OPERATOR'S DEFAULT MOVES: drop "
|
||||
"`circulation_hold_s` to 60 in config.yaml and this goes red",
|
||||
BATT_UNCHANGED_S <= 0.5 * _ceiling)
|
||||
# ⚠️ THE FIRST DRAFT OF THIS CHECK CLAIMED THE BAND CONTAINED EXACTLY ONE
|
||||
# INTEGER AND IT DOES NOT - it contains 34, 35, 36 and 37, and the check went
|
||||
# red on its first run and is written the honest way round here. The band is
|
||||
# what is derived; the value inside it is a choice, and the only defensible
|
||||
# claims about the choice are that it IS inside and that it is not a number
|
||||
# borrowed from somewhere else.
|
||||
check("...and the admissible band computed from config.yaml is [34.0, 37.5], "
|
||||
"which contains four integers. 35 is a CHOICE inside a DERIVED band, not "
|
||||
"a derived number, and it is deliberately not 30 - which is "
|
||||
"FIRMWARE_INGEST_VETO_S and would read as a borrowed constant - or 60, "
|
||||
"which is round",
|
||||
[n for n in range(0, 200)
|
||||
if 2 * BATT_OBLIGED_S <= n <= 0.5 * _ceiling] == [34, 35, 36, 37]
|
||||
and BATT_UNCHANGED_S not in (30.0, 60.0))
|
||||
check("⚠️ AND `MOVE_W` IS PINNED TO config.yaml TOO. It is the largest value "
|
||||
"that still arms the gate everywhere the per-unit sign check can speak; "
|
||||
"raise `unit_sign_disagree_w` and the gate would stop arming in states "
|
||||
"that check still fires in, which this catches",
|
||||
MOVE_W == _unit_min_w)
|
||||
check("...and the module's three floor terms are the numbers this repository "
|
||||
"actually carries, not a transcription of them: the ESPHome publish "
|
||||
"interval is read back out of the shipped firmware",
|
||||
"update_interval: 10s" in io.open(
|
||||
HERE.parent / "firmware" / "goodwe-master.yaml",
|
||||
encoding="utf-8").read()
|
||||
and F.PUBLISH_PERIOD_S == 10.0 and F.READ_PERIOD_S == 1.0
|
||||
and F.PLANT_SETTLE_S == 6.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. THE POLICY TABLE tells the truth about what is WIRED")
|
||||
|
||||
def bounds_users(key):
|
||||
"""Modules that SUBSCRIPT `BOUNDS[key]` in CODE - parsed, not grepped.
|
||||
|
||||
⚠️ THE FIRST DRAFT GREPPED FOR THE STRING AND ANSWERED WRONG, because both
|
||||
`inverter.py` and `main.py` NAME `BOUNDS["soc"]` in a comment explaining why
|
||||
it is not wired. A text search cannot tell a mention from a use, and this
|
||||
part's whole job is to tell them apart - so it walks the AST instead.
|
||||
"""
|
||||
out = []
|
||||
for path in sorted((HERE / "app").glob("*.py")):
|
||||
if path.name == "freshness.py":
|
||||
continue
|
||||
tree = ast.parse(io.open(path, encoding="utf-8").read())
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Subscript)
|
||||
and isinstance(node.slice, ast.Constant)
|
||||
and node.slice.value == key
|
||||
and ((isinstance(node.value, ast.Name)
|
||||
and node.value.id == "BOUNDS")
|
||||
or (isinstance(node.value, ast.Attribute)
|
||||
and node.value.attr == "BOUNDS"))):
|
||||
out.append(path.name)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
_uses_batt = bounds_users("batt")
|
||||
_uses_soc = bounds_users("soc")
|
||||
for _k, _b in BOUNDS.items():
|
||||
print(f" {_k:5} wired={_b.wired!s:5} unchanged={_b.unchanged_s:>6} "
|
||||
f"move={_b.move_w:>6} {_b.signal}")
|
||||
print(f" BOUNDS['batt'] subscripted in CODE by: {_uses_batt}")
|
||||
print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}")
|
||||
|
||||
check("⚠️ `wired=True` MEANS SOMETHING CONSUMES IT. A policy table nobody "
|
||||
"checks is a comment; this is the row `batt` claims and the module that "
|
||||
"claims it",
|
||||
BOUNDS["batt"].wired is True and _uses_batt == ["inverter.py"])
|
||||
check("⚠️ AND `wired=False` MEANS NOTHING DOES. SoC is TEL-08's and it is "
|
||||
"STATED here rather than wired, because its obligation term is energy "
|
||||
"through the pack and the constant that needs has never been measured on "
|
||||
"this site. A guessed constant would fire on a pack resting at its floor",
|
||||
BOUNDS["soc"].wired is False and _uses_soc == [])
|
||||
check("...and the SoC row says what TEL-08 owes, in the row itself, so the "
|
||||
"next reader does not have to reconstruct why it is unwired",
|
||||
"energy" in BOUNDS["soc"].derivation
|
||||
and "153.6 Wh" in BOUNDS["soc"].derivation
|
||||
and "TEL-08" in BOUNDS["soc"].derivation)
|
||||
check("...and every wired bound is a real number - an infinite bound is how a "
|
||||
"row silently becomes decorative",
|
||||
all(b.unchanged_s < float("inf") and b.move_w < float("inf")
|
||||
for b in BOUNDS.values() if b.wired))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("8. THE MEASUREMENT the bound rests on, replayed from the committed data")
|
||||
|
||||
# ⚠️ THIS IS THE PART THAT STOPS THE DERIVATION BECOMING A STORY. The docstring
|
||||
# in freshness.py quotes three numbers off this capture; they are recomputed
|
||||
# here from the committed files, so a figure that drifts from its evidence goes
|
||||
# red instead of being re-quoted forever.
|
||||
#
|
||||
# ⚠️ AND IT IS THE METER'S REGISTER, NOT 0x0518. No 30-day capture of the
|
||||
# inverter's own power exists. What this establishes is the only thing it is
|
||||
# used for: that the legitimate unchanged duration of a 1 W power entity in this
|
||||
# house is MINUTES, so an unchanged threshold alone has no admissible value.
|
||||
_cap = sorted(glob.glob(str(HERE / "scenarios" / "p1-net-30d-2026-08"
|
||||
/ "p1-net-*.jsonl.gz")))
|
||||
_runs, _prev = [], None
|
||||
_t0 = time.monotonic()
|
||||
for _f in _cap:
|
||||
with gzip.open(_f, "rt") as _fh:
|
||||
for _line in _fh:
|
||||
_r = json.loads(_line)
|
||||
_t = datetime.fromisoformat(_r["t"]).timestamp()
|
||||
if _prev is not None:
|
||||
_runs.append((_t - _prev, _pw))
|
||||
_prev, _pw = _t, float(_r["w"])
|
||||
_worst = max(_runs)[0] if _runs else 0.0
|
||||
_over_bound = sum(1 for d, _ in _runs if d >= BATT_UNCHANGED_S)
|
||||
print(f" {len(_cap)} days, {len(_runs) + 1} samples, "
|
||||
f"{time.monotonic() - _t0:.1f}s to replay")
|
||||
print(f" longest unchanged run {_worst:.1f} s; "
|
||||
f"{_over_bound} runs >= {BATT_UNCHANGED_S:.0f} s")
|
||||
|
||||
check("PRECONDITION: the committed capture is all thirty days, so this is not "
|
||||
"a claim about whatever files happened to be present",
|
||||
len(_cap) == 30 and len(_runs) + 1 == 485841)
|
||||
check("⚠️ A REAL 1 W POWER ENTITY HELD ONE VALUE FOR 263.2 s WITH NOTHING "
|
||||
"WRONG. That single measurement is why the bound is a conjunction and "
|
||||
"not a threshold: no unchanged threshold under 263 s is free of false "
|
||||
"positives, and 263 s is 3.5x too slow to protect a 75 s consumer",
|
||||
abs(_worst - 263.2) < 0.1 and _worst > _ceiling)
|
||||
check("...and the two figures freshness.py's docstring quotes off this capture "
|
||||
"are the ones it produces today",
|
||||
_over_bound == 10
|
||||
and sum(1 for d, _ in _runs if d >= BATT_OBLIGED_S) == 386)
|
||||
check("⚠️ AND THE LONG RUNS ARE ALL UNDER AN UNMOVING LOAD, which is what the "
|
||||
"conjunction keys on. The worst one is 263 s of a steady -899 W export - "
|
||||
"a house doing one thing - so a gate that also requires the COMMAND to "
|
||||
"have travelled 250 W is not armed by any of them",
|
||||
max(_runs)[1] == -899.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. KNOWN SURVIVORS - graded, so they are residuals and not surprises")
|
||||
|
||||
# ⚠️ THE REPO'S RECURRING DEFECT IS A CHECK WHOSE DATA CANNOT REACH THE FAILURE
|
||||
# IT NAMES. The honest end of that habit is to build the mutations this file
|
||||
# would NOT see and record them. Each one below is a real hole in the shipped
|
||||
# gate, demonstrated rather than described.
|
||||
|
||||
# S1. A register pinned by the INVERTER's own limit, not by a fault. If the
|
||||
# device is derating and cannot follow a command that ASKS FOR MORE, the
|
||||
# register can legitimately hold while the command travels.
|
||||
_s1 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_s1.submit(2503.0, 2650.0 if t < 1 else 4600.0, float(t))
|
||||
check("⚠️ SURVIVOR 1, FALSE POSITIVE: a saturated or derating inverter pinned "
|
||||
"at 2503 W while the command climbs to 4600 W is declared stale. The "
|
||||
"register is telling the truth and the gate calls it dead",
|
||||
_s1.stale)
|
||||
check("...and its SIZE is bounded and its direction is the safe one: the "
|
||||
"verdict is a site stop, behind the `stale_input_s` hold, and the moment "
|
||||
"the command comes back down the register moves and the latch clears. "
|
||||
"Cost = one spurious stop; it cannot drive the battery anywhere",
|
||||
(lambda f: (f.submit(2400.0, 0.0, 60.0), not f.stale)[-1])(_s1))
|
||||
|
||||
# S2. A unit whose command never travels 250 W. HW-01's own shape, at a
|
||||
# smaller scale, and the gate is deliberately not armed for it.
|
||||
_s2 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 400):
|
||||
_s2.submit(A_FROZEN_W, -1200.0 if t < 1 else -1400.0, float(t))
|
||||
check("⚠️ SURVIVOR 2, MISS: a register frozen under a command that only ever "
|
||||
"moves 200 W is NEVER detected, for 400 s or forever. This is scoped, "
|
||||
"not overlooked - such a unit is one no per-unit sign check can speak "
|
||||
"about (`unit_sign_disagree_w` is 250 W) and one contributing at most "
|
||||
"250 W to a loop whose reporting floor is 700 W",
|
||||
not _s2.stale and _s2.reading() == A_FROZEN_W)
|
||||
|
||||
# S3. A register that dithers on dead data. A publisher that keeps republishing
|
||||
# a stale WINDOW of values rather than one value defeats this entirely.
|
||||
_s3 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 400):
|
||||
_s3.submit(A_FROZEN_W + (t % 2), 1300.0, float(t))
|
||||
check("⚠️ SURVIVOR 3, MISS, AND IT IS THE LARGEST ONE: a dead publisher whose "
|
||||
"last value happens to ALTERNATE - or a cache replaying two old samples - "
|
||||
"never triggers the unchanged clock at all. This gate detects a value "
|
||||
"that STOPPED; it cannot detect a value that is WRONG. Nothing in this "
|
||||
"add-on can, and TEL-06's arrival stamp inside the firmware is the only "
|
||||
"layer that could",
|
||||
not _s3.stale and _s3.reading() is not None)
|
||||
|
||||
# S4. The gate is per unit, so a whole-site publisher failure that freezes both
|
||||
# registers is caught twice rather than missed - recorded because a reader
|
||||
# might expect the opposite.
|
||||
_s4a, _s4b = Freshness(BOUNDS["batt"]), Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_s4a.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_s4b.submit(B_LIVE_W, B_LIVE_W if t < 1 else -1300.0, float(t))
|
||||
check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
|
||||
"that freezes BOTH registers is caught on both units independently, on "
|
||||
"each unit's own command travel and each unit's own number",
|
||||
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1911,6 +1911,10 @@ _AUDITED = {
|
||||
("test_maintenance.py", "shipped_default"),
|
||||
("test_maintenance.py", "schema_range"),
|
||||
("test_freeze.py", "_cfg_default"),
|
||||
# SAFETY-37. Found by this very check on its first run - `option_default()`
|
||||
# reads the two consumer windows the freshness bound is derived from, so it
|
||||
# is a key-taking reader and belongs here rather than outside the audit.
|
||||
("test_freshness.py", "option_default"),
|
||||
("test_price.py", "shipped"),
|
||||
("test_tariff.py", "shipped_option"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user