Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6acd9fa2 | ||
|
|
239e120bf6 | ||
|
|
9d593429d3 | ||
|
|
b0b24e5d41 | ||
|
|
eb88646899 | ||
|
|
b9f7bd0506 | ||
|
|
43c144b8d9 | ||
|
|
6f5e8e378a | ||
|
|
8a5736136a | ||
|
|
5aba220046 |
@@ -33,6 +33,8 @@ estop/rs485_log.py optional RS485 e-stop / bus witness
|
|||||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
|
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
|
||||||
docs/build_guide.py its single source - rebuilds the HTML and PDF
|
docs/build_guide.py its single source - rebuilds the HTML and PDF
|
||||||
FIELD-GUIDE.md how to edit and rebuild the guide
|
FIELD-GUIDE.md how to edit and rebuild the guide
|
||||||
|
docs/RELEASE.md how a change reaches the battery - `master` is
|
||||||
|
protected, releases go via PR from `release/1.0`
|
||||||
```
|
```
|
||||||
|
|
||||||
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
|
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
# Releasing — how a change reaches the battery
|
||||||
|
|
||||||
|
`master` is the **default branch**, and the default branch is what the Home
|
||||||
|
Assistant Supervisor clones when it installs or updates this add-on. Whatever
|
||||||
|
sits on `master` is what runs on a real 5 kW inverter. Treat a push to `master`
|
||||||
|
as a deployment, because that is what it is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `master` is protected — releases go through a pull request
|
||||||
|
|
||||||
|
Until 2026-09-23 `master` had **no protection of any kind**. Every gate this
|
||||||
|
repo built (CI-01, CI-9) sat on `release/1.0`, the branch that does *not* ship.
|
||||||
|
On 2026-09-22 version 0.10.1 went straight onto `master` — no pull request, no
|
||||||
|
CI run, no reviewer — and it succeeded (commit `0f14c2a`, the only commit on
|
||||||
|
`master` that carries no `(#NN)` merge suffix). SEC-2 closed that.
|
||||||
|
|
||||||
|
**The release path is now:**
|
||||||
|
|
||||||
|
1. Merge the work into `release/1.0` as usual, via a pull request that goes
|
||||||
|
green. Bump `version:` in `goodwe_controller/config.yaml` on that branch —
|
||||||
|
the Supervisor keys the built image by `version:`, so without a bump nothing
|
||||||
|
is offered and nothing changes.
|
||||||
|
2. Open a pull request **`release/1.0` → `master`**. Do not push to `master`;
|
||||||
|
it will be refused by the pre-receive hook:
|
||||||
|
|
||||||
|
```
|
||||||
|
remote: error: Not allowed to push to protected branch master
|
||||||
|
! [remote rejected] ... (pre-receive hook declined)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Wait for `checks / unit (pull_request)` to go **green** on that pull request.
|
||||||
|
The workflow in `.gitea/workflows/checks.yml` carries **no `branches:`
|
||||||
|
filter**, so a `master` base matches and a real run happens — verified on
|
||||||
|
throwaway PR #107 (base `master`), which queued a genuine run.
|
||||||
|
|
||||||
|
⚠️ Green means green. A `skipped` status is laundered into a `success`
|
||||||
|
rollup by this Gitea and the merge gate accepts it — see the long note at
|
||||||
|
the top of `.gitea/workflows/checks.yml`. Never add an event filter there.
|
||||||
|
|
||||||
|
4. Merge the pull request. `required_approvals` is **0**, so a green check is
|
||||||
|
the whole gate; there is no second pair of eyes unless someone asks for one.
|
||||||
|
|
||||||
|
5. **Reload the store.** Merging to `master` does not, by itself, offer the
|
||||||
|
update. The Supervisor only sees the new version after:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.2.158 "ha store reload && ha addons update local_goodwe_controller"
|
||||||
|
```
|
||||||
|
|
||||||
|
Until `ha store reload` runs, the add-on page still shows the old version
|
||||||
|
and the release looks like it did nothing.
|
||||||
|
|
||||||
|
Note that `release/1.0` is normally an *ancestor* of `master` between releases,
|
||||||
|
so a `release/1.0 → master` pull request is empty until new commits land on
|
||||||
|
`release/1.0`. That is expected; it means there is nothing to release yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Branch protection — before and after SEC-2
|
||||||
|
|
||||||
|
Forge is Gitea at `192.168.2.147:3000`, repo `admin/goodwe-addon`. Read it back
|
||||||
|
with `GET /api/v1/repos/admin/goodwe-addon/branch_protections`.
|
||||||
|
|
||||||
|
**Before (2026-09-23):**
|
||||||
|
|
||||||
|
| branch | protected | status check required | context | approvals |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `master` (default) | **no rule at all** | — | — | — |
|
||||||
|
| `release/1.0` | yes | yes | `checks / unit (pull_request)` | 0 |
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
| branch | protected | status check required | context | approvals |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `master` (default) | yes | yes | `checks / unit (pull_request)` | 0 |
|
||||||
|
| `release/1.0` | yes (unchanged) | yes | `checks / unit (pull_request)` | 0 |
|
||||||
|
|
||||||
|
Both rules are otherwise identical: `enable_push: false`,
|
||||||
|
`enable_force_push: false`, `block_admin_merge_override: true`, no whitelists,
|
||||||
|
no file patterns. `required_approvals` stays at **0** on purpose — raising it
|
||||||
|
would block the review agent from merging, and nobody asked for it.
|
||||||
|
|
||||||
|
The default branch was **not** changed. Changing it would change what the
|
||||||
|
Supervisor clones, which is a different decision from this one.
|
||||||
|
|
||||||
|
### How the gate was proven to actually bite
|
||||||
|
|
||||||
|
A protection rule on this forge can *appear* satisfied while having checked
|
||||||
|
nothing, so the rule was tested rather than trusted. The discriminator is a
|
||||||
|
merge `POST` carrying a deliberately wrong `head_commit_id`: Gitea evaluates
|
||||||
|
branch protection *before* it checks the head, so
|
||||||
|
|
||||||
|
* `409 head out of date` → the request **cleared** protection;
|
||||||
|
* `405 Not all required status checks successful` → protection **refused** it.
|
||||||
|
|
||||||
|
Read the *message*, not just the code: `405` is also returned as
|
||||||
|
`Please try again later` for a pull request with **no commits**, which has
|
||||||
|
nothing to do with protection. An empty pull request on an unprotected base
|
||||||
|
gives that same `405` (verified, throwaway PR #110), so an empty
|
||||||
|
`release/1.0 → master` reports it too.
|
||||||
|
|
||||||
|
Validated against a control on an unprotected base (PR #106), which answered
|
||||||
|
`409` while its check was still pending — i.e. protection was no obstacle
|
||||||
|
there. On `master` (PR #107) the same probe answered `405` while pending, and
|
||||||
|
`409` once the check was genuinely green. A direct `git push` to `master` was
|
||||||
|
rejected by the pre-receive hook with `master`'s SHA unmoved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Rolling back
|
||||||
|
|
||||||
|
There is no force-push escape hatch: `enable_force_push` is `false` on both
|
||||||
|
branches. To undo a bad release, land a revert on `release/1.0`, bump the
|
||||||
|
version again, and take it through §1. The `/addons/` rollback for the
|
||||||
|
reference install is in `HANDOVER.md`.
|
||||||
@@ -1,5 +1,173 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.10.1
|
||||||
|
|
||||||
|
**A PATCH RELEASE, AND ONE TICKET IS THE WHOLE OF IT: [[SAFETY-57]].** The last
|
||||||
|
place moves because this is a bug fix to a version that has shipped and is
|
||||||
|
regulating a battery, not a change to what the add-on offers. **The option
|
||||||
|
surface does not move at all** - no key added, none removed, no default changed,
|
||||||
|
no enum widened, no rule added to `validate.py` - so ⚠️ **a stored options blob
|
||||||
|
that starts the add-on on 0.10.0 starts it on 0.10.1**, and there is no
|
||||||
|
`SystemExit(78)` to plan around on this hop. If you are on 0.10.0, the only
|
||||||
|
question worth your time is whether you have been hit by what it fixes, and
|
||||||
|
`### SAFETY-57` below is written to answer exactly that.
|
||||||
|
|
||||||
|
⚠️ **WHY THIS IS A NEW NUMBER RATHER THAN A FOLD INTO 0.10.0, WHICH IS THE
|
||||||
|
REASON THE TICKET EXISTS.** The Supervisor decides that an update is available
|
||||||
|
by comparing `config.yaml`'s version string with the one the box reports.
|
||||||
|
0.10.0 is installed and running, so a fix delivered inside `## 0.10.0` would
|
||||||
|
leave every box reporting `0.10.0` believing it was already current - the
|
||||||
|
PKG-06/PKG-07 no-op, in the form where the tree it hides is a safety fix.
|
||||||
|
0.10.0's own section argued it could still be folded into; ⚠️ **that argument
|
||||||
|
was explicitly conditional on 0.10.0 never having been installed, and the
|
||||||
|
condition has fired** - see the closing note at the end of `## 0.10.0` below.
|
||||||
|
|
||||||
|
⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS
|
||||||
|
NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons update`
|
||||||
|
does not walk the versions; it installs 0.10.1 in one step, so every validator
|
||||||
|
rule added in 0.8.0, 0.9.0 and 0.10.0 arrives at the same moment as this
|
||||||
|
section's nothing. Two of them can refuse a config that nobody has edited:
|
||||||
|
[[SAFETY-39]]'s **rule 10** against 0.7.0's own shipped default `slew_w: 1000`,
|
||||||
|
and [[TARIFF-07]]'s three withdrawn `price_provider` ids - **`engie_dynamic`,
|
||||||
|
`totalenergies_dynamic` and `eneco_eddy`** - each of which is still refused by
|
||||||
|
name at startup on this version, with the same message. Both are written out in
|
||||||
|
full under `## 0.10.0`, and `tools/predeploy_check.py` checks both against your
|
||||||
|
live options before you press *Update*.
|
||||||
|
|
||||||
|
Contents of this release, in merge order since 0.10.0 (`cb3f686`):
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `b9f7bd0` | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** |
|
||||||
|
|
||||||
|
⚠️ **AND THAT IS THE WHOLE TABLE, WHICH IS THE POINT OF A PATCH NUMBER.**
|
||||||
|
[[TEL-10]] and [[SAFETY-48]] also merged after `cb3f686`, but [[PKG-15]] folded
|
||||||
|
both into `## 0.10.0` **before 0.10.0 was installed**, so they shipped under
|
||||||
|
that number and are recorded there rather than here. [[CI-9]] and [[PKG-15]]
|
||||||
|
itself touch `.gitea/workflows/checks.yml` and this file only - nothing an
|
||||||
|
operator can observe on an installed add-on - and are excluded for the reason
|
||||||
|
`## 0.10.0` already gives. ⚠️ **Two defects that the reference install's own
|
||||||
|
field history found are NOT in this release**: [[SAFETY-56]] (the stale-reading
|
||||||
|
message says *"is not following its own command"* when the true cause is an
|
||||||
|
empty pack, and the NOT READY row that goes with it) and [[SAFETY-58]] (a trip
|
||||||
|
on the first read after a rest). Both are open, both are wording or bound
|
||||||
|
changes rather than deadlocks, and neither was allowed to delay this one.
|
||||||
|
|
||||||
|
### SAFETY-57 — the stale-reading verdict gets an exit
|
||||||
|
|
||||||
|
⚠️ **WHETHER YOU WANT THIS UPDATE, AND HOW TO TELL.** On 0.10.0 the add-on
|
||||||
|
can enter a state it cannot leave: the command is parked at **0 W**, the
|
||||||
|
battery-power reading is gated to **MISSING**, and the only condition that
|
||||||
|
would clear the gate is one that a parked command cannot produce. **It is a
|
||||||
|
deadlock, not a slow recovery.** On the reference site on 2026-09-21 it held
|
||||||
|
for about **thirteen minutes while the house exported 2.4 kW**, and it ended
|
||||||
|
only because a human restarted the add-on.
|
||||||
|
|
||||||
|
**Recognising it costs one look at the status page and one at the log:**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| the setpoint | sits at **exactly 0 W** and does not move, while the house is plainly importing or exporting |
|
||||||
|
| the battery-power sensor | is **flat at one number** for the whole of it - on the live episode, −3 W for 850 s |
|
||||||
|
| the action log | carries a `STALE batt` line and **no** clearing line after it |
|
||||||
|
|
||||||
|
**What ends it on 0.10.0 is a restart of the add-on**, and a restart is enough -
|
||||||
|
nothing is latched to disk and the pack is not harmed. ⚠️ **But nothing ends it
|
||||||
|
by itself**, and the replay below says the wait can run to hours. On 0.10.1 the
|
||||||
|
state releases itself after a bounded **32 s** and the register is re-graded
|
||||||
|
from scratch.
|
||||||
|
|
||||||
|
⚠️ **IT IS NOT AN EMERGENCY AND IT IS NOT COSMETIC.** The pack is not at
|
||||||
|
risk in this state - 0 W is the safe command, and it is the command §11.1 rule 1
|
||||||
|
asks for on a missing input. What is lost is the site's own control while it
|
||||||
|
lasts: the battery neither charges nor discharges, so a solar export goes to the
|
||||||
|
grid and an evening load comes off it.
|
||||||
|
|
||||||
|
⚠️ **WHAT IT FIXES, ON THE OWNER'S OWN BATTERY.** [[SAFETY-37]]'s freshness gate
|
||||||
|
reports a battery-power register as MISSING when it stops following its own
|
||||||
|
command, and a missing input is `command 0 W and keep commanding` (§11.1
|
||||||
|
rule 1). The gate's only exit was *"an arrival carrying a different number"* —
|
||||||
|
**and at 0 W nothing is asking the register to be anywhere.** The state had no
|
||||||
|
exit reachable from inside it, and the log line said the opposite.
|
||||||
|
|
||||||
|
Measured off the RS-485 bus, 2026-09-21, and committed as
|
||||||
|
`scenarios/safety57-2026-09-21/`:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 10:51:24 | 0x0518 reaches **−3 W**. The pack is at 11 %, the loop has just reversed out of a charge, and the BMS refuses the discharge. The register is **healthy and truthful**. |
|
||||||
|
| 10:51:29 | the command reaches **+1060 W**, 800 W of travel |
|
||||||
|
| 10:52:00 | the gate arms. `Inverter.batt` becomes `None` |
|
||||||
|
| 10:52:21 | **the site commands 0 W — on the wire, in the capture** |
|
||||||
|
| … | the register does not change value for **850 s**. The setpoint does not change value for **790 s**. The house exports 2.4 kW throughout |
|
||||||
|
| ~11:04 | **a human restarts the add-on.** That is what ends it |
|
||||||
|
|
||||||
|
It had been escaping by luck - and ⚠️ **on this episode it did not escape at
|
||||||
|
all.** `0x0518` answered the same −3 W on 90 consecutive responses and never
|
||||||
|
once carried a different number between 10:51:24 and 11:05:35. The register did
|
||||||
|
not move.
|
||||||
|
|
||||||
|
⚠️ **AND WHY IT USUALLY DOES MOVE IS NOT SOMETHING THIS REPOSITORY HAS
|
||||||
|
MEASURED.** An earlier draft of this entry named a BMS keep-alive pulse as the
|
||||||
|
thing that twitches the register, stated as fact; **no committed capture
|
||||||
|
identifies such a pulse**, distinguishes one from the register simply following
|
||||||
|
a command, or sizes it. The honest statement is that the register changes value
|
||||||
|
for reasons that belong to the battery's firmware. What IS measured is how long
|
||||||
|
it goes without changing: replaying the committed 16-day loop capture through
|
||||||
|
the shipped conjunction finds **93 trips** whose wait has a **median of 143 s**
|
||||||
|
and a worst case of **5 h 57 m**, with **16 of the 93** at or beyond this
|
||||||
|
episode's **790 s**. That figure is stronger than the firmware story it
|
||||||
|
replaces, and it needs no firmware story at all.
|
||||||
|
|
||||||
|
**The rule, and it is [[SAFETY-13]]'s shape one module over** — which latches
|
||||||
|
get an exit, not how long the block lasts:
|
||||||
|
|
||||||
|
> the verdict is withdrawn when the obligation that produced it has been gone
|
||||||
|
> for `RELEASE_S`, and the register is then re-graded from scratch.
|
||||||
|
|
||||||
|
`RELEASE_S` is **17.0 s**, the obligation window already derived in
|
||||||
|
`freshness.py` — 6.0 s of plant, one 10 s ESPHome publish, one 1 s add-on read.
|
||||||
|
It is the **floor** of its band rather than a preference: below it the release
|
||||||
|
pre-empts the cheaper exit (the register answering by itself), and being wrong
|
||||||
|
*long* is the failure being fixed. **The whole parked interval is now
|
||||||
|
`stale_input_s` + `RELEASE_S` = 32 s**, bounded by two shipped numbers and by
|
||||||
|
nothing the battery does. On the live episode: telemetry back at **10:52:39
|
||||||
|
instead of 11:05:35**, with **0x0518 still answering the same −3 W**.
|
||||||
|
|
||||||
|
⚠️ **WHAT IT COSTS, AND IT IS NOT SMALL.** A register that is dead for good is
|
||||||
|
re-graded for ever. Driven over 1201 reads at the shipped defaults: the held
|
||||||
|
number reaches a consumer on **630 reads (52.5 %)** against 35 (2.9 %) before.
|
||||||
|
That is payable only because **one re-grading window is 35 s and the two
|
||||||
|
consumers hold for 75 s and 85 s** — neither can reach a verdict inside one,
|
||||||
|
and the re-trip that ends each window hands them a `None`, which *clears* their
|
||||||
|
windows. **No detection SAFETY-37 had is given back**: a permanent `None`
|
||||||
|
clears those same windows for ever too. What the site gets back is its
|
||||||
|
meter-derived control, and the command was never derived from `batt`.
|
||||||
|
|
||||||
|
⚠️ **THAT 35 s HOLDS WHILE THE OBLIGATION COMES BACK, AND ONLY THEN - IT IS NOT
|
||||||
|
A UNIVERSAL BOUND, AND THE FIXTURE THAT GRADES IT CANNOT SHOW OTHERWISE.** A
|
||||||
|
window closes when the gate re-arms, and the gate needs a command that is
|
||||||
|
*moving* to re-arm against. If the release lands where the site then regulates
|
||||||
|
below `MOVE_W`, nothing re-arms, the window never closes, and the held number
|
||||||
|
reaches a consumer for an unbounded time - where before this card a `None` did.
|
||||||
|
The driver behind the 35 < 75 < 85 inequality puts a 1300 W obligation back on
|
||||||
|
every release, so it is the one shape that cannot produce that case. On the
|
||||||
|
shipped roster of **one** inverter neither consumer can reach it anyway - the
|
||||||
|
per-unit sign watchdog is compared against the **meter** at `n_units <= 1`, and
|
||||||
|
`circulating_w()` is identically `0.0` there - so this is **a stated gap rather
|
||||||
|
than a covered one**, and it needs a second inverter to bite.
|
||||||
|
|
||||||
|
⚠️ **WHAT YOU WILL SEE.** A new `STALE WITHDRAWN <unit>` line in the action log
|
||||||
|
beside the existing `STALE <unit>` one, and a log warning that says in its own
|
||||||
|
words that **nothing has been learned about the register**. If the pair repeats
|
||||||
|
every ~70 s, the register really is not following and the unit needs looking at;
|
||||||
|
if it happens once and stops, the pack was simply empty and the site has
|
||||||
|
resumed. The stale line's escape clause was also corrected — it used to promise
|
||||||
|
a clearance the state could not produce. ⚠️ Its **diagnosis** half still says
|
||||||
|
*"is not following its own command"*, which is wrong in a different way when the
|
||||||
|
pack is merely empty; that wording is [[SAFETY-56]]'s and is deliberately
|
||||||
|
untouched here.
|
||||||
|
|
||||||
## 0.10.0
|
## 0.10.0
|
||||||
|
|
||||||
⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE
|
⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE
|
||||||
@@ -87,8 +255,20 @@ check.
|
|||||||
**and** is refused by name (rule 11), so the reinterpretation is never
|
**and** is refused by name (rule 11), so the reinterpretation is never
|
||||||
silent; no blob written before 0.10.0 can carry one.
|
silent; no blob written before 0.10.0 can carry one.
|
||||||
|
|
||||||
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL IS -
|
⚠️ **AND 0.10.0 CHANGES THE CONTROL LAW ITSELF, WHICH NOTHING ABOVE AND NO
|
||||||
THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
|
CONFIG CHECK WILL TELL YOU ABOUT.** [[SAFETY-48]] bounds how far the
|
||||||
|
integrator may run ahead of the command on the cycles where the slew limit is
|
||||||
|
what stopped the command moving. It adds no option, moves no default and
|
||||||
|
refuses no blob, so every check in this preamble passes it in silence - and it
|
||||||
|
is the one change here that alters what the inverter is ASKED TO DO on a tree
|
||||||
|
that is otherwise configured identically. ⚠️ **What it is measured to buy is
|
||||||
|
less EXPORT. It is NOT a lower capacity-tariff peak, and your billed quarter
|
||||||
|
should not be expected to improve.** The whole account, including what it costs
|
||||||
|
and what it does not establish, is `### SAFETY-48` below.
|
||||||
|
|
||||||
|
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL WAS
|
||||||
|
WHEN THIS WAS WRITTEN, AND HAS NOT BEEN SINCE 2026-09-20 - THE REFUSAL THAT CAN
|
||||||
|
STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
|
||||||
addons update` does not walk the versions; it installs 0.10.0 in one step, so
|
addons update` does not walk the versions; it installs 0.10.0 in one step, so
|
||||||
**every validator rule added in 0.8.0 and 0.9.0 lands at the same moment as
|
**every validator rule added in 0.8.0 and 0.9.0 lands at the same moment as
|
||||||
0.10.0's own**, and a config that has not been looked at since 0.7.0 meets all
|
0.10.0's own**, and a config that has not been looked at since 0.7.0 meets all
|
||||||
@@ -109,7 +289,10 @@ fire only on a roster entry that USES `capacity_kwh`, a key that did not exist
|
|||||||
`slew_w` against the ceiling and `price_provider` against the withdrawn list
|
`slew_w` against the ceiling and `price_provider` against the withdrawn list
|
||||||
BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
|
BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
|
||||||
|
|
||||||
✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.**
|
✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.** ⚠️ This is the record of
|
||||||
|
a run made on 2026-09-19, when the reference box was still on 0.7.0. That
|
||||||
|
crossing has since been made; the box is on 0.10.0 and the transcript below is
|
||||||
|
history rather than the state of any install.
|
||||||
|
|
||||||
docker run --rm -v <repo>:/w:ro -v <dir>:/opt:ro goodwe-addon-checks:1 \
|
docker run --rm -v <repo>:/w:ro -v <dir>:/opt:ro goodwe-addon-checks:1 \
|
||||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||||
@@ -120,6 +303,21 @@ BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
|
|||||||
live options pass this version's validator
|
live options pass this version's validator
|
||||||
CLEARED for deploy.
|
CLEARED for deploy.
|
||||||
|
|
||||||
|
⚠️ **THAT RUN WAS MADE ON PKG-14'S TREE, AND [[PKG-15]] DID NOT RE-RUN IT.
|
||||||
|
HERE IS WHY IT STILL HOLDS, CHECKED RATHER THAN ASSUMED.** The two tickets
|
||||||
|
folded in below touch three files between them - `app/control.py`,
|
||||||
|
`app/main.py` and `app/mqtt.py` - and **`config.yaml` is not among them**, so
|
||||||
|
the option surface and the 75 defaults the blob is merged over are byte-for-
|
||||||
|
byte what that run measured. `app/validate.py` imports neither `main` nor
|
||||||
|
`mqtt`; from `control` it imports exactly `SLEW_RAIL_FRAC`, `LOOP_TICK_S` and
|
||||||
|
`PLANT_SETTLE_S`, and all three are **unchanged** (`2.0`, `18.0`, and their
|
||||||
|
quotient) - [[SAFETY-48]] adds `SLEW_LEAD_STEPS` and reads it inside
|
||||||
|
`compute()`, which the validator never calls. ⚠️ **And the tool's OTHER half -
|
||||||
|
the no-op check - did not run at all in that transcript**, because it is
|
||||||
|
gated on `shipped == deployed` and the box reported `0.7.0` at the time. It is
|
||||||
|
the half that refuses this fold on a box already running 0.10.0 - ⚠️ **which
|
||||||
|
the reference box now is**; see the closing note below the contents table.
|
||||||
|
|
||||||
**The option surface: 71 keys -> 75, `options:` and `schema:` in step.** Four
|
**The option surface: 71 keys -> 75, `options:` and `schema:` in step.** Four
|
||||||
new keys, and none of them can refuse a blob written before it existed:
|
new keys, and none of them can refuse a blob written before it existed:
|
||||||
`price_formula`, `price_export_formula` and `price_taxes_profile` are `str?` -
|
`price_formula`, `price_export_formula` and `price_taxes_profile` are `str?` -
|
||||||
@@ -158,6 +356,41 @@ image does - PKG-13, UX-04, SAFETY-31, ARCH-02, INSTR-01. The count that
|
|||||||
matters for the no-op check is `tools/predeploy_check.py`'s own: **28 runtime
|
matters for the no-op check is `tools/predeploy_check.py`'s own: **28 runtime
|
||||||
commits since `86bdf49`**, of which the table below accounts for 21.
|
commits since `86bdf49`**, of which the table below accounts for 21.
|
||||||
|
|
||||||
|
⚠️ **RE-MEASURED BY [[PKG-15]] ON 2026-09-19, AFTER FOLDING [[TEL-10]] AND
|
||||||
|
[[SAFETY-48]] INTO THIS SECTION: 32 RUNTIME COMMITS SINCE `86bdf49`, OF WHICH
|
||||||
|
THE TABLE BELOW NOW ACCOUNTS FOR 24.** Re-derived with `is_runtime()` itself
|
||||||
|
rather than by adding to the old figure. The other eight are the three misfiled
|
||||||
|
tickets in the table above - SAFETY-43 two commits, SAFETY-44 three, TEST-21
|
||||||
|
two - plus `cb3f686`, PKG-14's own bump, which edits `config.yaml` and is
|
||||||
|
therefore a runtime commit by that function's rule; it was `HEAD` when PKG-14
|
||||||
|
counted, so `last..HEAD` excluded it then and includes it now. The three
|
||||||
|
misfiled tickets are still misfiled and still [[PKG-08]]'s card; nothing below
|
||||||
|
moves them.
|
||||||
|
|
||||||
|
⚠️ **THE VERSION WAS NOT BUMPED FOR THE FOLDS ABOVE, AND THAT ARGUMENT HAS
|
||||||
|
NOW EXPIRED. THIS SECTION IS CLOSED.** As written on 2026-09-19 it ran: 0.10.0
|
||||||
|
was cut on 2026-09-16, had never been built or pulled anywhere, the reference
|
||||||
|
install was on 0.7.0 and this repository carried exactly one tag (`v0.2.1`) and
|
||||||
|
no release - so the string `0.10.0` did not yet name a tree anyone was running,
|
||||||
|
the PKG-06/PKG-07 no-op could not apply, and cutting 0.11.0 would have burned a
|
||||||
|
number on a release that never left the repository. ⚠️ **IT WAS STATED AS
|
||||||
|
CONDITIONAL ON THAT REMAINING TRUE, AND IT STOPPED BEING TRUE ON 2026-09-20 AT
|
||||||
|
ABOUT 22:18 CEST**, when 0.10.0 was installed on the reference site as a
|
||||||
|
repository add-on (slug `68285ac7_goodwe_controller`) and began regulating a
|
||||||
|
real battery. **0.10.0 has been built and pulled, and the reference install is
|
||||||
|
on 0.10.0, not on 0.7.0.**
|
||||||
|
|
||||||
|
⚠️ **AND THE FIELD HISTORY IS NOW THE STRONGEST EVIDENCE IN THIS FILE, WHICH
|
||||||
|
IS WHY THE CORRECTION IS WORTH ITS SPACE.** That install has since run a full
|
||||||
|
day-night cycle on 0.10.0 - an empty pack, a guard trip, a 2 h 47 min saturation
|
||||||
|
freeze, the [[SAFETY-57]] deadlock and a clean recovery - and **two defects came
|
||||||
|
out of it that no test in this repository had reached**: [[SAFETY-56]] and
|
||||||
|
[[SAFETY-57]]. So nothing may be folded into this section again, and
|
||||||
|
`tools/predeploy_check.py` enforces that by itself: run against a box already
|
||||||
|
reporting `0.10.0` it refuses with `THE DEPLOY WOULD BE A NO-OP` and names the
|
||||||
|
runtime commits since the bump. **SAFETY-57's fix is in `## 0.10.1` above**,
|
||||||
|
which is what a patch number is for.
|
||||||
|
|
||||||
Contents of this release, in merge order since 0.9.0 (`86bdf49`):
|
Contents of this release, in merge order since 0.9.0 (`86bdf49`):
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -180,6 +413,176 @@ Contents of this release, in merge order since 0.9.0 (`86bdf49`):
|
|||||||
| `8f82dd4` | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
|
| `8f82dd4` | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
|
||||||
| `e6da478` | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** |
|
| `e6da478` | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** |
|
||||||
| `587495e` | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** |
|
| `587495e` | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** |
|
||||||
|
| `660d079` | TEL-10 - the loop's integrator stops being invisible: three new site sensors **⚠️ THREE NEW ENTITIES** |
|
||||||
|
| `186a044` | SAFETY-48 - the integrator's lead over the command is bounded at one slew step **⚠️ THE CONTROL LAW CHANGED** |
|
||||||
|
| _this branch_ | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** |
|
||||||
|
|
||||||
|
⚠️ **[[CI-9]] ALSO MERGED INTO `release/1.0` IN THIS WINDOW AND IS
|
||||||
|
DELIBERATELY NOT IN THE TABLE ABOVE, NOR ANYWHERE ELSE IN THIS FILE.** It
|
||||||
|
changes exactly one file, `.gitea/workflows/checks.yml`, which is not under
|
||||||
|
`goodwe_controller/` at all: it is not in the built image, it is not in
|
||||||
|
`predeploy_check.py`'s `RUNTIME` list, and there is no version of this add-on
|
||||||
|
on which an operator can observe it. This file is the **add-on's** release note
|
||||||
|
- the text the Supervisor puts beside the version number - and the rule PKG-15
|
||||||
|
applied is that a row here has to be something the person deciding whether to
|
||||||
|
press *Update* could act on. CI-9 is not; the repository's history and PR #95
|
||||||
|
are where it is written down, and they have it in full. ⚠️ **THE RULE IS ABOUT
|
||||||
|
THE AUDIENCE, NOT ABOUT IMPORTANCE.** CI-9 repaired a merge gate that was
|
||||||
|
grading the wrong tree, which is the same class of defect this ticket exists to
|
||||||
|
clean up after - and it is still not an operator-facing change. If a future CI
|
||||||
|
ticket changes what the shipped image contains, it belongs here; this one does
|
||||||
|
not.
|
||||||
|
|
||||||
|
### SAFETY-48 — the integrator's lead over the command is bounded, and the control law changed
|
||||||
|
|
||||||
|
⚠️ **THIS CHANGES WHAT THE INVERTER IS ASKED TO DO.** It is the only entry in
|
||||||
|
0.10.0 that does so without touching an option, a default or the validator, so
|
||||||
|
nothing in this section's preamble and no run of `tools/predeploy_check.py`
|
||||||
|
will warn you about it. `app/control.py` is in the diff; `config.yaml` is not.
|
||||||
|
|
||||||
|
**The defect is a missing case, not a wrong constant.** `compute()` freezes the
|
||||||
|
integrator when the inverter is not following, and the comment beside that
|
||||||
|
block states the requirement outright — *the INTEGRATOR must stop accumulating,
|
||||||
|
not only the command*. But `frozen` was computed from one signal,
|
||||||
|
`abs(prev_w - actual_w) > saturation_w`, and **slew-limiting never entered it**.
|
||||||
|
The integrator moves `gain × error` per cycle while the command may move at
|
||||||
|
most `slew_w`, so the integrator outruns the command whenever
|
||||||
|
`|error| > slew_w / gain` — **667 W** at the live tuning, against a measured
|
||||||
|
median grid error of **1114 W** at the onset of a recorded episode. ⚠️
|
||||||
|
[[SAFETY-39]]'s retune made this **more** frequent, not less: a smaller step is
|
||||||
|
a lower threshold.
|
||||||
|
|
||||||
|
**What ships** bounds the integrator's **lead over the command** at one slew
|
||||||
|
step, inside the branch where the slew is what bound the command, and only in
|
||||||
|
the leading direction. Nothing else in the law moves: the output clamp, the
|
||||||
|
saturation freeze, the deadband and the 10 W quantisation are unchanged, and
|
||||||
|
`test_control.py`'s exhaustive `i_w=None` equivalence sweep against the
|
||||||
|
transcribed release/1.0 law still passes untouched — the cap moves `i_w`, not
|
||||||
|
`target`.
|
||||||
|
|
||||||
|
⚠️ **WHAT IT IS MEASURED TO BUY IS LESS EXPORT — AND EXPLICITLY NOT A LOWER
|
||||||
|
CAPACITY-TARIFF PEAK. DO NOT EXPECT YOUR BILLED QUARTER TO IMPROVE.** Replayed
|
||||||
|
over 256 252 recorded meter instants spanning 16.00 days of this install
|
||||||
|
(`scenarios/loop-2026-09-02_2026-09-17`, committed), same ticks, same
|
||||||
|
disturbance, same plant, same seed:
|
||||||
|
|
||||||
|
| | release/1.0 | SAFETY-48 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| **export kWh** | **39.61** | **34.07** |
|
||||||
|
| wasted throughput kWh | 4.67 | 3.19 |
|
||||||
|
| battery sign reversals | 2992 | 2770 |
|
||||||
|
| median recovery s | 232 | 199 |
|
||||||
|
| unrecovered (>900 s) | 53 | 31 |
|
||||||
|
| worst \|i_w − prev_w\| W | 9200 | 800 |
|
||||||
|
|
||||||
|
That is **5.54 kWh less export — 14 % of the replayed figure**, and ⚠️ **the
|
||||||
|
direction is what is robust, not the size**: across every plant and tuning
|
||||||
|
variant swept it falls, by between ~4 % (at `gain: 0.3`) and ~16 % (at a
|
||||||
|
re-fitted gain/offset), the median variant landing near an eighth. At `gain: 0.3` and at `slew_w` set to the rail the
|
||||||
|
mean-quarter effect is a tie (−0.17 W and **+0.16 W**), because
|
||||||
|
`|error| > slew_w / gain` is rarely satisfied in those regimes and there is
|
||||||
|
little for the cap to bite on.
|
||||||
|
|
||||||
|
⚠️ **AND THE BILLED QUARTER IS NOT A CLAIM THIS RELEASE MAKES.** The A/B above
|
||||||
|
also shows the worst 15-minute average **offtake** moving 3739 → 3434 W, and
|
||||||
|
that figure exists at **one plant model**. `fit_plant()` selects a 7.5 s
|
||||||
|
inverter lag on battery-power RMS, which separates it from 2.5 s by **0.3 %**,
|
||||||
|
while the instrument's own fidelity measure — replayed export against the
|
||||||
|
**29.43 kWh this install actually exported** — prefers 2.5 s by the whole 35 %
|
||||||
|
gap. Swept properly: **at every lag ≤ 5.0 s, including the one that best
|
||||||
|
reproduces the recorded export, release/1.0's worst quarter is already 3434 W
|
||||||
|
and this change moves it by exactly zero.** The lag is not identified by this
|
||||||
|
recording, and identifying it needs a driven step response that does not exist
|
||||||
|
yet. **What this release can say about the billed quantity is that it does not
|
||||||
|
make it worse at any lag tried — not that it improves it.**
|
||||||
|
|
||||||
|
⚠️ **AND EVERY NUMBER ABOVE IS SIMULATED.** The replay is a first-order plant
|
||||||
|
model with a 375 W RMS residual; it prints its own fidelity first and it
|
||||||
|
reproduces **39.6 kWh** of export where the install exported **29.4**, i.e. it
|
||||||
|
is ~35 % pessimistic about the loop's tracking. Only the **delta** is claimed.
|
||||||
|
Nothing here drove an inverter.
|
||||||
|
|
||||||
|
**What it costs, stated rather than smoothed:**
|
||||||
|
|
||||||
|
* ⚠️ **The cap does move a saturation-frozen integrator**, because it runs
|
||||||
|
before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks
|
||||||
|
it changed `i_w` on **1095**, and on **679** of those it moved it further out
|
||||||
|
on the side it was already on — putting back magnitude the freeze had just
|
||||||
|
let fall, which is the one direction the freeze's own rule forbids. Worst
|
||||||
|
case **2424 W**. It never overrides a *same-cycle* refusal.
|
||||||
|
* **Two committed numbers moved.** [[SAFETY-13]]'s released command settles at
|
||||||
|
−800 W and −1200 W where release/1.0 settled at −1190 W and −2390 W, because
|
||||||
|
there is less wound integrator to settle onto; the property asserted is that
|
||||||
|
it stops at the frozen integrator rather than at the rail.
|
||||||
|
* ⚠️ **[[SAFETY-39]]'s claim that "the slew does not own the over-absorption"
|
||||||
|
is now FALSE — inverted**, on the independent first-order plant
|
||||||
|
`test_control.py` writes for itself: 1005/1109/1285/1330 W rising with a
|
||||||
|
slower slew becomes 729/550/424/326 falling. Two plant models that share
|
||||||
|
nothing agree on the sign. SAFETY-39's record carries a forward reference to
|
||||||
|
this one.
|
||||||
|
* **The form the card originally asked for was rejected on measurement**, and
|
||||||
|
this is the least lag-sensitive result in the work: reusing the asymmetric
|
||||||
|
freeze on a carried slew flag moves the worst quarter-hour offtake
|
||||||
|
3739 → **3935 W** at the fitted plant, and across the lag sweep it is the
|
||||||
|
worst of the three at **every lag from 2.5 s upward** — 3732 / 3711 / 3935 /
|
||||||
|
4326 W against release/1.0's 3434 / 3434 / 3739 / 3954 — with a tie at lag
|
||||||
|
0.0, and it is worse at **every top-N cut** of the quarters. It also raises
|
||||||
|
the mean quarter (+2.73 W).
|
||||||
|
|
||||||
|
`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` carries all of it,
|
||||||
|
including §8, *what this card does NOT establish*.
|
||||||
|
|
||||||
|
### TEL-10 — the loop's integrator stops being invisible
|
||||||
|
|
||||||
|
**Three new MQTT sensors, and nothing in the control path moved.**
|
||||||
|
`app/control.py` is not in this diff: `compute()`, its returned `Decision` and
|
||||||
|
every constant it reads are identical to release/1.0.
|
||||||
|
|
||||||
|
`self.i_w` has been a separate quantity from the command since [[SAFETY-04]]
|
||||||
|
and left the process **nowhere at all** — no entity, no attribute, no log line.
|
||||||
|
The investigation behind SAFETY-48 could only *reconstruct* it from the grid
|
||||||
|
trace plus a replay; the reconstruction reproduced about a third of the
|
||||||
|
engagements it was checked against and was discarded, so a control card had to
|
||||||
|
be argued from a measurement nobody could stand behind. It could not have been
|
||||||
|
made to work, either: `i_w` depends on every cycle before it, including the
|
||||||
|
ones where the arbiter overrode the loop and `main.py` re-seeded it — and that
|
||||||
|
re-seed is invisible in the grid trace.
|
||||||
|
|
||||||
|
| entity | topic | unit | what it is |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Loop integrator` | `goodwe_loop_integrator` | W | the accumulator **after** this cycle — before the output clamp, the slew limit and quantisation |
|
||||||
|
| `Loop integrator lead` | `goodwe_loop_integrator_lead` | W | that value minus **the command `compute()` was handed**, i.e. the one the previous cycle left standing |
|
||||||
|
| `Loop reason` | `goodwe_loop_reason` | — | the control law's own word for this cycle: `tracking`, `deadband`, `i-clamped`, `clamped`, `slew-limited`, `saturated-freeze`, `freeze-released` or `not-running` |
|
||||||
|
|
||||||
|
⚠️ **`Loop integrator lead` IS NOT A SUBTRACTION OF TWO PUBLISHED ROWS, WHICH
|
||||||
|
IS WHY IT IS ITS OWN ENTITY.** `Setpoint` is the **arbiter's** output after
|
||||||
|
every strategy has had its say, and it is **this** cycle's; the lead is
|
||||||
|
measured against the command `compute()` was given, which is the previous one.
|
||||||
|
On any cycle the arbiter overrode the loop the two differ — and that is exactly
|
||||||
|
the cycle on which the integrator was re-seeded, so a dashboard doing the
|
||||||
|
subtraction would get a different number precisely where it matters.
|
||||||
|
|
||||||
|
⚠️ **`Loop reason` IS NOT THE `Why` ROW AND NOT `Controller status`.** Both of
|
||||||
|
those carry the *arbiter's* sentence — `loop -> -5000 W` — which names the
|
||||||
|
winning claim and its watts and has never carried that claim's own reason.
|
||||||
|
`slew-limited`, the value [[SAFETY-48]] above is entirely about, appears in
|
||||||
|
neither. Measured on one cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`.
|
||||||
|
|
||||||
|
⚠️ **NOTHING EXISTING IS RENAMED, RE-TYPED OR RE-ORDERED.** The three are
|
||||||
|
**appended** after the nine messages release/1.0 already announces, and all
|
||||||
|
nine stay **byte-identical**. ⚠️ Eight of them also stay **first**, which is
|
||||||
|
what `test_mqtt.py` part 1's prefix comparison grades; the ninth,
|
||||||
|
`goodwe_ctl/availability`, is byte-identical but deliberately moves to **last**,
|
||||||
|
so a prefix comparison cannot cover it and the check on the next line pins it
|
||||||
|
in its new position instead. **They are SITE rows, not per-unit ones** — there
|
||||||
|
is one integrator, so a per-unit copy would invent N of a thing there is one of
|
||||||
|
— and they therefore appear at **every** roster length, **including a roster of
|
||||||
|
one**, where the per-unit machinery announces nothing new at all.
|
||||||
|
|
||||||
|
Both numbers carry `state_class: measurement`, so the recorder keeps long-term
|
||||||
|
statistics for them. `DOCS.md` has the operator account, including how to read
|
||||||
|
a standing lead: the cycles needed to unwind it are that lead divided by
|
||||||
|
`slew_w`.
|
||||||
|
|
||||||
### SAFETY-47 — what a shadow run can show you, and the watts on its status row
|
### SAFETY-47 — what a shadow run can show you, and the watts on its status row
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# SAFETY-56 — what the gate cannot tell apart, said in the sentence
|
||||||
|
|
||||||
|
**Text only. No behaviour changes in this card**, and §5 is the measurement that
|
||||||
|
says so: the same stimuli through the tree before and after produce a
|
||||||
|
**byte-identical** trace of every verdict, every `reading()`, every `inputs_ok`
|
||||||
|
and every readiness row's `(ok, warn)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The defect
|
||||||
|
|
||||||
|
0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that
|
||||||
|
healthy hardware has failed.
|
||||||
|
|
||||||
|
**The log line**, as emitted on the reference site on 2026-09-21:
|
||||||
|
|
||||||
|
> SAFETY-37: inverter-a battery-power register **is not following its own
|
||||||
|
> command** - STALE_READING: -3 unchanged for 36 s while the standing command
|
||||||
|
> moved 800 W to +1600 W - **the reading is not following**, so it is reported
|
||||||
|
> as MISSING rather than as a measurement … Treating it as MISSING, which
|
||||||
|
> commands 0 W.
|
||||||
|
|
||||||
|
**The readiness row**, at the same moment:
|
||||||
|
|
||||||
|
> NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power`
|
||||||
|
> **is missing or not numeric**
|
||||||
|
|
||||||
|
That entity was present, numeric and updating.
|
||||||
|
|
||||||
|
**SAFETY-37's conjunction is correct and is not touched by this card** —
|
||||||
|
`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register
|
||||||
|
genuinely stuck against a real command is exactly what it should catch, and a
|
||||||
|
pack that cannot comply is a state the site must stop in too. What was wrong is
|
||||||
|
what the sentences **concluded** from it.
|
||||||
|
|
||||||
|
## 2. Two worlds satisfy the conjunction
|
||||||
|
|
||||||
|
| | the register | the pack |
|
||||||
|
|---|---|---|
|
||||||
|
| **DEAD** | has stopped reporting reality | is doing what it was told |
|
||||||
|
| **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor |
|
||||||
|
|
||||||
|
A refused pack holds still under any setpoint, so an honest register publishes
|
||||||
|
the same unchanged number a dead one does. **Nothing `freshness.py` is handed
|
||||||
|
separates them.** All three sentences asserted DEAD.
|
||||||
|
|
||||||
|
## 3. The live episode was REFUSED, and it is measured, not argued
|
||||||
|
|
||||||
|
Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`,
|
||||||
|
whose bytes and decoder are already graded by `test_freshness.py` part 10a:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 |
|
||||||
|
| the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge |
|
||||||
|
| pack SoC throughout | **11 %** |
|
||||||
|
| first **charge** command after the restart | 11:05:31 at **-400 W** |
|
||||||
|
| the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window |
|
||||||
|
|
||||||
|
**A register that answers a reversed command inside one obligation window had
|
||||||
|
not stopped reporting.** What would not move was the pack. The sentence the
|
||||||
|
operator read named the one thing in the loop that was telling the truth.
|
||||||
|
|
||||||
|
⚠️ What this does **not** establish: that no register is ever intermittently
|
||||||
|
wrong. It excludes a register that had simply stopped; it cannot exclude one
|
||||||
|
that was wrong and then right. That is the point — the new sentence claims
|
||||||
|
neither.
|
||||||
|
|
||||||
|
## 4. The fix, and why it is three sentences and not one helper
|
||||||
|
|
||||||
|
Each message states **what was observed** and names **both** explanations.
|
||||||
|
|
||||||
|
| where | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART**: the register may have stopped reporting reality, or the source may be unable to comply with the command - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* |
|
||||||
|
| `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* |
|
||||||
|
| `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* |
|
||||||
|
|
||||||
|
**Three sentences, three wordings, no shared helper.** The first two are a log
|
||||||
|
line about a **behaviour**; the third is a banner about a named **entity**, and
|
||||||
|
the two answer different questions ("what did the register do" against "can I
|
||||||
|
trust this entity"). A helper serving both would have to drop whichever half
|
||||||
|
the other caller does not need, and the half it dropped would be the
|
||||||
|
operator's next step. The log line names the pair **once**, inside the `%s` it
|
||||||
|
already interpolates, rather than in both of its own halves.
|
||||||
|
|
||||||
|
**No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT
|
||||||
|
WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number)
|
||||||
|
and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs
|
||||||
|
neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and
|
||||||
|
asserts the sentence does not move one byte.
|
||||||
|
|
||||||
|
**The readiness row does not branch.** `inv.batt_fresh.stale` is on the same
|
||||||
|
object at the same moment, and the row deliberately does not ask it: the same
|
||||||
|
loop renders the grid and SoC rows, which have no gate, and a branch per row
|
||||||
|
would be a second place where "why is this None" is decided. The gate already
|
||||||
|
has a surface — `stale_note()` rides `liveness_notes()` a few rows below with
|
||||||
|
the whole STALE_READING sentence in it — so the operator's discriminator is
|
||||||
|
*"is that row there?"*, and part 11d asserts that row is **present in REFUSED
|
||||||
|
and absent in ABSENT**.
|
||||||
|
|
||||||
|
## 5. Behaviour is byte-identical, measured
|
||||||
|
|
||||||
|
`tools/safety56_behaviour_trace.py` prints two documents from one stimulus set.
|
||||||
|
The `behaviour` half contains **no sentence**: only `stale`, `reading()`,
|
||||||
|
`releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness
|
||||||
|
row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against
|
||||||
|
`origin/release/1.0` **archived out of git** and against this branch:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| the committed live capture | **2390 reads**, 7 verdict edges |
|
||||||
|
| a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases |
|
||||||
|
| six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` |
|
||||||
|
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** |
|
||||||
|
|
||||||
|
⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES
|
||||||
|
THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour
|
||||||
|
unchanged" unless the `sentences` halves **differ**. This card's first
|
||||||
|
invariance run was exactly that mistake — it archived a branch whose edits were
|
||||||
|
not committed yet, so `before` and `after` were the same code and the identity
|
||||||
|
was perfect and worthless.
|
||||||
|
|
||||||
|
Both of the tool's refusals are **driven**, not asserted:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** |
|
||||||
|
| `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** |
|
||||||
|
| this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** |
|
||||||
|
|
||||||
|
## 6. The mutation battery
|
||||||
|
|
||||||
|
Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`,
|
||||||
|
graded by `tools/mutation_run.sh` in the CI image.
|
||||||
|
|
||||||
|
| | mutation | Δ bytes | named reds |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** |
|
||||||
|
| M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** |
|
||||||
|
| M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** |
|
||||||
|
| M4 | *register* and *source* transposed between the two explanations | **0** | **1** |
|
||||||
|
| M5 | the second explanation deleted, the first kept | -216 | **1** |
|
||||||
|
| M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** |
|
||||||
|
| M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** |
|
||||||
|
| M8 | the row's second cause deleted | -309 | **1** |
|
||||||
|
| M9 | `label` and `entity` transposed in the row | **0** | **3** |
|
||||||
|
| M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** |
|
||||||
|
| M11 | the accusation literals replaced by one that matches nothing | 0 | **1** |
|
||||||
|
|
||||||
|
⚠️ **M9's first run was a defect in this card's own check, and it is the one
|
||||||
|
worth reading.** The row was fetched with `[...][0]`. Transposing `label` and
|
||||||
|
`entity` makes the row unfindable, so the run died on an `IndexError` — a red
|
||||||
|
with **no name attached**, with every check after that line **ungraded**. That
|
||||||
|
is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of
|
||||||
|
raising, and a precondition grades it: **0 named reds before, 3 after.**
|
||||||
|
|
||||||
|
⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is
|
||||||
|
True, and so is one over three mistyped literals. The positive control runs the
|
||||||
|
same predicate against the three sentences **as 0.10.1 shipped them** and
|
||||||
|
requires it to find an accusation in every one, and every literal to match at
|
||||||
|
least one of them (habit 7: vary one thing from an accepted baseline).
|
||||||
|
|
||||||
|
## 7. What part 11 does not grade
|
||||||
|
|
||||||
|
* It does not show the live episode was a BMS refusal beyond doubt — see §3.
|
||||||
|
* It does not grade English. A sentence that named both explanations and then
|
||||||
|
said something useless about them would pass.
|
||||||
|
* The `no entity configured` branch beside the rewritten row is untouched and
|
||||||
|
ungraded: a blank entity really is the only thing it can be.
|
||||||
|
* The **grid-power and battery-SoC rows** go through the same rewritten line and
|
||||||
|
have no freshness gate today, so for them the second cause is a hypothesis
|
||||||
|
about this controller rather than a live possibility. That is weaker than the
|
||||||
|
battery-power case and is the price of not branching per row. A gate on either
|
||||||
|
signal — TEL-08 would be one — makes it exact.
|
||||||
|
* **SAFETY-58 is not touched** (the first read after a long rest trips before a
|
||||||
|
healthy register could answer), and neither is the 710/790 disagreement
|
||||||
|
between `test_freshness.py` and the changelog. Both are separate cards; the
|
||||||
|
second has a **fourth site** at
|
||||||
|
`DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written
|
||||||
|
off the PR table alone would miss.
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
|
||||||
|
|
||||||
|
**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3`
|
||||||
|
(content-identical to `release/1.0` @ `8a57361`).
|
||||||
|
**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*.
|
||||||
|
**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or
|
||||||
|
192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a
|
||||||
|
**passive** RS-485 listener with no address on the bus, and it was read only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The five things a reader should take away
|
||||||
|
|
||||||
|
1. **The live episode is reproduced from the wire, not modelled.** The capture
|
||||||
|
is committed, the decoder is committed, and the box that produced it was
|
||||||
|
running the defect — so the `0 W` in the trace *is* the add-on's own
|
||||||
|
`inputs missing → command 0 W`. §2.
|
||||||
|
2. **Option 2 from the card — "do not arm the gate when the command is 0 W" —
|
||||||
|
already holds, and it is not the bug.** `_grade()` has required
|
||||||
|
`|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and
|
||||||
|
the stuck state is at **0 W**; they are different moments. §3.
|
||||||
|
3. **The decision is not "how long", it is "when".** The verdict is withdrawn
|
||||||
|
when the **obligation that produced it is no longer standing** — SAFETY-13's
|
||||||
|
shape one module over. The 17.0 s is the obligation window that was already
|
||||||
|
derived in the file, taken at the **floor** of its band, and which half is
|
||||||
|
safe is the **opposite** of SAFETY-13's. §4.
|
||||||
|
4. **It costs 52 % exposure on a permanently dead register, and that number is
|
||||||
|
in the suite so it cannot be forgotten.** It is payable only because one
|
||||||
|
re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s).
|
||||||
|
**No detection SAFETY-37 had is given back.** §5.
|
||||||
|
5. **The battery had been rescuing this code by accident.** 93 trips in the
|
||||||
|
committed 16-day capture; median wait for the BMS keep-alive pulse that
|
||||||
|
cleared them **143 s**, worst **5 h 57 m**. §6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What was there, in the terms the code uses
|
||||||
|
|
||||||
|
`freshness.py`, `Freshness.submit()` and `_grade()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.changed_mono is None or value != self.held:
|
||||||
|
...
|
||||||
|
self.stale = False # the only place the latch is cleared
|
||||||
|
def _grade(self, standing_w, now_mono):
|
||||||
|
if self.stale or self.changed_mono is None:
|
||||||
|
return # sets the latch; never clears it
|
||||||
|
```
|
||||||
|
|
||||||
|
and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()`
|
||||||
|
answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for
|
||||||
|
`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs
|
||||||
|
missing")` and keeps filing it.
|
||||||
|
|
||||||
|
The reason the latch exists is written in the class docstring and it is a good
|
||||||
|
one: without it the verdict oscillates at about 1 Hz, because declaring the
|
||||||
|
reading stale drops the command below `MOVE_W`, which un-arms the conjunction,
|
||||||
|
which hands the held number straight back.
|
||||||
|
|
||||||
|
**The defect is that the only thing that ends it is an arrival carrying a
|
||||||
|
different number — and the state's own output is what stops the register being
|
||||||
|
asked for one.** The log line promised the opposite, in the same sentence that
|
||||||
|
announced the 0 W:
|
||||||
|
|
||||||
|
> *Treating it as MISSING, which commands 0 W. It clears itself the moment the
|
||||||
|
> register reports a different value.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Reproducing it, from the wire
|
||||||
|
|
||||||
|
`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840
|
||||||
|
verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by
|
||||||
|
`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC,
|
||||||
|
0 unknown shapes**, and its mode→sign convention is **graded off the capture**
|
||||||
|
rather than trusted: 298 commands at or under −1000 W are each answered one
|
||||||
|
obligation window later by a negative register reading, and not one positive.
|
||||||
|
|
||||||
|
| capture time | on the wire |
|
||||||
|
|---|---|
|
||||||
|
| 10:51:14 | `0x0518` = **−447 W**; the loop is charging |
|
||||||
|
| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge |
|
||||||
|
| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** |
|
||||||
|
| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel |
|
||||||
|
| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire |
|
||||||
|
| …850.0 s | `0x0518` does not change value |
|
||||||
|
| …790.0 s | the setpoint does not change value |
|
||||||
|
| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode |
|
||||||
|
| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % |
|
||||||
|
| 11:05:34.7 | `0x0518` = **−137 W** |
|
||||||
|
|
||||||
|
⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why
|
||||||
|
this is a reproduction and not a simulation: the box was running 0.10.0, so
|
||||||
|
every command in the trace is the shipped code's own. The only event inside the
|
||||||
|
window the file cannot account for is the human restart.
|
||||||
|
|
||||||
|
Replaying the shipped `_read_unit` loop at 1 Hz against those two series
|
||||||
|
(`test_freshness.py` part 10b):
|
||||||
|
|
||||||
|
| | stale at | clear at | parked |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them |
|
||||||
|
| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** |
|
||||||
|
|
||||||
|
⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN
|
||||||
|
CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is
|
||||||
|
only ever called where the register moves, and the window pair is the
|
||||||
|
change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b
|
||||||
|
asserts that by driving part 1's own fresh/stale pair through both bounds and
|
||||||
|
comparing the verdict streams.
|
||||||
|
|
||||||
|
The replay also found a **one-read trip at 10:48:14** that clears one second
|
||||||
|
later. That is a pre-existing false positive and it is now recorded as
|
||||||
|
survivor 5 — §7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What the card's option 2 actually buys, which is nothing here
|
||||||
|
|
||||||
|
The card offered *"do not arm the gate when the command being graded is 0 W"*
|
||||||
|
and warned that it might hold already. **It does, and it has since SAFETY-37**:
|
||||||
|
`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and
|
||||||
|
`test_freshness.py` part 4 has a named check on that term, added because a
|
||||||
|
mutation removing it survived.
|
||||||
|
|
||||||
|
The warning in the card is the right one and it is worth writing down: **the
|
||||||
|
trip and the stuck state are different moments.** The gate armed at **+1060 W**,
|
||||||
|
where the conjunction is entirely correct — a command travelled 800 W and the
|
||||||
|
register did not move for 35 s. The site then went to **0 W** *because of the
|
||||||
|
verdict*. An entry-side rule cannot reach a state that is entered legitimately
|
||||||
|
and then persists. **The defect is the exit, and nothing else in the conjunction
|
||||||
|
is touched by this card.**
|
||||||
|
|
||||||
|
Option 3 — probing with a small non-zero command — was not taken and does not
|
||||||
|
need to be. It writes to a real actuator to test an assumption, and §4's rule
|
||||||
|
does not need a probe: the site's *normal* control resuming is the test, and it
|
||||||
|
is gated behind the same `inputs_ok` everything else is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The decision: which verdicts get an exit, and what the bound is
|
||||||
|
|
||||||
|
> The stale verdict holds as before, **unless** the obligation that produced it
|
||||||
|
> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone
|
||||||
|
> longer than the register would have needed to answer it. On a release the
|
||||||
|
> latch clears and a **new grading window** opens at that instant.
|
||||||
|
|
||||||
|
This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card
|
||||||
|
established the house answer to "a latch with no exit" and its answer was *not*
|
||||||
|
a plain time bound but a **predicate plus a time bound**, where the predicate
|
||||||
|
says whether the state can end on its own. Here the predicate is that **the
|
||||||
|
question is no longer being asked.** A register under a 0 W command is obliged
|
||||||
|
to be nowhere, so a verdict about whether it is following cannot be tested, and
|
||||||
|
a verdict that cannot be tested may not stand for ever.
|
||||||
|
|
||||||
|
### 4.1 Why the window has to re-open, and what happens if it does not
|
||||||
|
|
||||||
|
`_grade()` compares the register's unchanged time against `unchanged_s` and the
|
||||||
|
command's travel against the command standing when the window opened. If a
|
||||||
|
release cleared the latch **without** re-opening the window, the first read at
|
||||||
|
which the resumed command travels 250 W would re-trip immediately — the
|
||||||
|
register would never get the 17 s in which it could answer, and the live
|
||||||
|
episode's recovery (a charge the pack **accepts**) would be killed inside two
|
||||||
|
seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`,
|
||||||
|
+82 B) takes four named checks red.
|
||||||
|
|
||||||
|
The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry,
|
||||||
|
because it is the same question — *does this register follow the commands issued
|
||||||
|
from now on?* — and there is no principled shorter answer to it. It is also the
|
||||||
|
number the exposure argument in §5 rests on, so it is not free to move.
|
||||||
|
|
||||||
|
### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe
|
||||||
|
|
||||||
|
The quantity needed is *has the register had its chance to answer the command
|
||||||
|
standing now?*, and `freshness.py` already derives it as the floor of
|
||||||
|
`BATT_UNCHANGED_S`'s band:
|
||||||
|
|
||||||
|
```
|
||||||
|
6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22
|
||||||
|
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
|
||||||
|
1.0 s this add-on run_control()'s await asyncio.sleep(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of
|
||||||
|
`MOVE_W` taking the **ceiling** of its own for the mirrored reason.
|
||||||
|
|
||||||
|
| | the question | the safe half | why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses |
|
||||||
|
| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that |
|
||||||
|
|
||||||
|
Below 17.0 s the release pre-empts the **cheaper exit** — the register answering
|
||||||
|
the new 0 W command by itself, which clears the latch on evidence rather than on
|
||||||
|
a timer. That is graded behaviourally rather than asserted: a register that
|
||||||
|
answers at +16 s clears by **movement** at `release_s = 17.0` with `releases`
|
||||||
|
still 0, and by **timer** at `release_s = 5.0`.
|
||||||
|
|
||||||
|
The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give
|
||||||
|
six distinct release instants, so no check in the file can confuse two of them.
|
||||||
|
|
||||||
|
### 4.3 Two clocks, and the operator reads the register's
|
||||||
|
|
||||||
|
`changed_mono` / `unchanged_s()` remain facts about the **register**: after a
|
||||||
|
release they go on reporting that it has not moved for 850 s, because it has
|
||||||
|
not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide
|
||||||
|
until the first release. Collapsing them either lies to the operator or defeats
|
||||||
|
the re-grading, and both directions are mutated red.
|
||||||
|
|
||||||
|
The stale sentence's `unchanged for N s` slot carries the **register's** number;
|
||||||
|
a re-trip appends a clause naming the window and the grade count, so the two
|
||||||
|
durations cannot be read as one number that moved (habit 14).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What it costs, measured
|
||||||
|
|
||||||
|
Driven against a register frozen for good, 1201 reads, shipped defaults
|
||||||
|
(`test_freshness.py` part 10c):
|
||||||
|
|
||||||
|
| | parked | the held number reaches a consumer | releases |
|
||||||
|
|---|---|---|---|
|
||||||
|
| before | 1166 s | **35 reads (2.9 %)** | 0 |
|
||||||
|
| after | 571 s | **630 reads (52.5 %)** | 17 |
|
||||||
|
|
||||||
|
⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is
|
||||||
|
`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window.
|
||||||
|
|
||||||
|
It is payable because of one inequality, and it is the inequality
|
||||||
|
`test_freshness.py` part 6 has graded against the shipped `config.yaml` since
|
||||||
|
SAFETY-37:
|
||||||
|
|
||||||
|
```
|
||||||
|
35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every unbroken run of exposure is **exactly one window**, so neither consumer
|
||||||
|
can reach a verdict inside one, and the re-trip that ends each run hands them a
|
||||||
|
`None`, which **clears** their windows rather than accumulating into them.
|
||||||
|
|
||||||
|
**And nothing SAFETY-37 detects is given back**, which is the half that makes
|
||||||
|
this a trade rather than a regression: a permanent `None` clears those same two
|
||||||
|
windows for ever as well, so on the shipped-before module those detectors are
|
||||||
|
*equally* unable to speak about the unit. What the held number does reach for
|
||||||
|
35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which
|
||||||
|
answers a large unexplained divergence by **freezing the output**, the
|
||||||
|
conservative direction and SAFETY-13's business — and the command itself is
|
||||||
|
derived from the **P1 meter**, never from `batt`.
|
||||||
|
|
||||||
|
### 5.1 The narrowing this does NOT take, named so the next card can
|
||||||
|
|
||||||
|
The live episode's register refused a **discharge** into an 11 % pack and then
|
||||||
|
followed a **charge** three seconds later. A refusal in one direction is not
|
||||||
|
evidence about the other, and a gate that knew the pack's floor could re-grade
|
||||||
|
on the first command that crosses it instead of on a timer — which would shrink
|
||||||
|
the 52 % above. That needs SoC, which this module deliberately does not take
|
||||||
|
(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number.
|
||||||
|
It is **not needed to give the state an exit**, and folding it in here would
|
||||||
|
make the exit depend on a second measurement being right.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The measurement that says the old exit was luck
|
||||||
|
|
||||||
|
`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped
|
||||||
|
conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only
|
||||||
|
thing that could clear any of them was the register carrying a different number,
|
||||||
|
and that took:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| minimum | 0.6 s |
|
||||||
|
| **median** | **143.4 s** |
|
||||||
|
| maximum | **21440.1 s** (5 h 57 m) |
|
||||||
|
| at or over the live episode's 710 s | **17 of 93** |
|
||||||
|
|
||||||
|
The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at
|
||||||
|
508 W — twitching `0x0518` and satisfying the clearing rule **by accident**.
|
||||||
|
Over a fifth of the trips are cleared in under 20 s by something nothing in this
|
||||||
|
add-on asked for.
|
||||||
|
|
||||||
|
⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and
|
||||||
|
0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the
|
||||||
|
setpoint column is an **ungated** controller's. That makes each trip an honest
|
||||||
|
register-versus-command divergence off the real bus, and it makes the post-trip
|
||||||
|
command column something a gated box would **not** have written. The figure
|
||||||
|
taken from it is therefore the only one that does not depend on the command at
|
||||||
|
all — how long this register goes without changing value — and it **under**-states
|
||||||
|
the wait, because a site parked at 0 W gives the register even less reason to
|
||||||
|
move than the one recorded here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Survivors, and the two things the battery found
|
||||||
|
|
||||||
|
The battery is 22 mutations over `app/freshness.py`, `app/main.py` and
|
||||||
|
`tools/decode_bus_capture.py`, plus one over the committed capture itself. All
|
||||||
|
run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of
|
||||||
|
the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`.
|
||||||
|
Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per
|
||||||
|
invocation and the tree is fingerprinted **after the revert** as well as before;
|
||||||
|
every revert restored the recorded sha256 and byte count.
|
||||||
|
|
||||||
|
**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the
|
||||||
|
PR body. Two of them are worth keeping here.
|
||||||
|
|
||||||
|
### 7.1 The equivalent mutant, argued rather than rounded off
|
||||||
|
|
||||||
|
Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and
|
||||||
|
survived the whole battery. **It is not a missing fixture and no fixture can
|
||||||
|
close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()`
|
||||||
|
only when `|standing| < move_w`, so at most one of them can do anything on any
|
||||||
|
read, whichever runs first.
|
||||||
|
|
||||||
|
⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a
|
||||||
|
`⚠️` comment, with a reason that sounded right. It was false, and the battery is
|
||||||
|
what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in
|
||||||
|
a comment is a claim, and this one had not been run.** The comment now states
|
||||||
|
the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and
|
||||||
|
1300 W — instead of pinning an order that is not observable.
|
||||||
|
|
||||||
|
### 7.2 The sibling that survived, which is habit 11 earning its place
|
||||||
|
|
||||||
|
`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`.
|
||||||
|
Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived
|
||||||
|
the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is
|
||||||
|
below both 35 and 250. They separate only in the band between them, and a site
|
||||||
|
can sit there: a peak-guard floor, a shadow substitution, any claim that wins
|
||||||
|
with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W
|
||||||
|
that asserts the 100 W case releases and the 300 W case does not.
|
||||||
|
|
||||||
|
### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half
|
||||||
|
|
||||||
|
The first form of the `graded_from` mutation was
|
||||||
|
`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and
|
||||||
|
the tree fingerprint showed the edit really was on disk and really did run.
|
||||||
|
`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` —
|
||||||
|
so the mutant was a *no-op on the fixture*, for a Python truthiness reason that
|
||||||
|
has nothing to do with the property under test. Rewritten as an explicit
|
||||||
|
`is None` test (+82 B) it is red on four named checks.
|
||||||
|
|
||||||
|
**A mutation is subject to every habit in MUTATION.md, including habit 4.** A
|
||||||
|
purge, a fingerprint and a byte delta all say the edit is present; none of them
|
||||||
|
says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19
|
||||||
|
for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. What this does NOT fix, said plainly
|
||||||
|
|
||||||
|
**It does not tell a BMS refusal apart from a dead register**, and it does not
|
||||||
|
try to. Both are re-graded on the same timer. §5.1 names what telling them apart
|
||||||
|
would need.
|
||||||
|
|
||||||
|
**It does not make the diagnosis right.** The line the operator reads still says
|
||||||
|
*"battery-power register is not following its own command"*, which is wrong in a
|
||||||
|
different way when the pack is simply empty. That wording is **SAFETY-56's**,
|
||||||
|
together with `main.py`'s NOT READY row, and it is deliberately untouched here —
|
||||||
|
two cards editing one sentence is how one of them silently loses. Only the
|
||||||
|
**escape clause** was rewritten, because this card is what made the old one
|
||||||
|
false, and part 10h has a named check asserting the diagnosis half is still
|
||||||
|
there so that rewrite has to be a deliberate act.
|
||||||
|
|
||||||
|
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
|
||||||
|
> the deliberate act: all three sentences now name **both** explanations and
|
||||||
|
> accuse neither, and part 10h's check is inverted — it requires the accusation
|
||||||
|
> to be **gone**. Nothing else in this section moved; the escape clause above is
|
||||||
|
> still this card's and is untouched by it. See
|
||||||
|
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
|
||||||
|
> `test_freshness.py` part 11 for the graded pair of worlds.
|
||||||
|
|
||||||
|
**Survivor 5 is older than this card and is not closed by it.** After a long
|
||||||
|
rest the unchanged clock is already past 35 s, so the **first** read carrying a
|
||||||
|
travelled command trips the gate — before the 17.0 s in which a healthy register
|
||||||
|
could have answered it. The live capture contains one (10:48:14, cleared one
|
||||||
|
second later). Its size is one `stale_input_s` hold, during which `cycle()`
|
||||||
|
returns at `inputs missing {n}s` and commands nothing differently. Fixing it
|
||||||
|
means measuring the unchanged clock from when the **obligation** appeared rather
|
||||||
|
than from the register's last move, which re-derives the 35.0 s bound and is a
|
||||||
|
different card.
|
||||||
|
|
||||||
|
**Nothing was measured on two inverters.** Inverter B does not physically exist
|
||||||
|
(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two
|
||||||
|
copies of A's behaviour with different numbers passed in.
|
||||||
|
|
||||||
|
**No deploy is proposed.** 0.10.0 is on the reference install and this branch's
|
||||||
|
CHANGELOG row is filed under it only because `test_version_gate.py` part 11b
|
||||||
|
requires the newest heading to be a version. Moving it, and deciding whether a
|
||||||
|
version is cut, is a PKG card's.
|
||||||
@@ -526,6 +526,35 @@ 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,
|
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.
|
and the add-on's own checks refuse a value that does not.
|
||||||
|
|
||||||
|
⚠️ **AND IF THE REGISTER DOES NOT REPORT A DIFFERENT VALUE, THE ADD-ON STOPS
|
||||||
|
WAITING AFTER 17 s (SAFETY-57).** This paragraph used to end at the sentence
|
||||||
|
above, and on 2026-09-21 that turned out to be a promise the state could not
|
||||||
|
keep: at 0 W **nothing is asking the register to be anywhere**, so "report a
|
||||||
|
different value" is evidence a parked site cannot ask for. The live site sat at
|
||||||
|
0 W for **790 s** while the house exported 2.4 kW, on a register that was
|
||||||
|
working perfectly — the pack was simply at its floor and the BMS was refusing
|
||||||
|
the discharge — and it was freed by a person restarting the add-on.
|
||||||
|
|
||||||
|
So: once the standing command has been **under 250 W for 17 s**, the verdict is
|
||||||
|
**withdrawn** and the register is graded again from scratch over a fresh 35 s.
|
||||||
|
The whole parked interval is now `stale_input_s` + 17 s — **32 s at the
|
||||||
|
defaults** — and it does not depend on what the battery decides to do.
|
||||||
|
|
||||||
|
| in the action log | what it means | what to do |
|
||||||
|
|---|---|---|
|
||||||
|
| `STALE <unit>` alone, then nothing | the register stopped following and has not come back | look at the unit — comms, the ESP32, the inverter |
|
||||||
|
| `STALE <unit>` then `STALE WITHDRAWN <unit>`, **once** | the pack was probably empty or full and the BMS refused the command. The site has resumed | nothing |
|
||||||
|
| the pair repeating every ~70 s | the register really is not following, and the add-on is re-testing it | look at the unit; the site is regulating on the meter in between |
|
||||||
|
|
||||||
|
⚠️ **`STALE WITHDRAWN` IS NOT "RECOVERED", AND THE LOG LINE SAYS SO ITSELF.**
|
||||||
|
Nothing has been learned about the register when it is printed; the verdict is
|
||||||
|
withdrawn because it can no longer be *tested*, and the next 35 s are the test.
|
||||||
|
During those 35 s the held number reaches the site's telemetry again. That is
|
||||||
|
deliberate and it is bounded: 35 s is shorter than both the circulating-power
|
||||||
|
hold (75 s) and the per-unit sign hold (85 s), so neither of those alarms can be
|
||||||
|
made to say anything by a held number, and the command itself is derived from
|
||||||
|
the **meter**, never from this register.
|
||||||
|
|
||||||
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
|
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
|
||||||
STOPPED**, including a dead publisher whose last two samples happen to
|
STOPPED**, including a dead publisher whose last two samples happen to
|
||||||
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
|
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
|
||||||
|
|||||||
@@ -852,6 +852,50 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
|||||||
invented justification for a correct decision is still a defect, and it is
|
invented justification for a correct decision is still a defect, and it is
|
||||||
the kind a reviewer cannot see because the code is right.
|
the kind a reviewer cannot see because the code is right.
|
||||||
|
|
||||||
|
23. ⚠️ **THE MUTATION IS SUBJECT TO EVERY HABIT ABOVE, HABIT 4 INCLUDED - AND
|
||||||
|
THE PURGE, THE FINGERPRINT AND THE BYTE DELTA ALL SAY IT TOOK.** SAFETY-57's
|
||||||
|
battery reported a **survivor at +21 B** for a mutation of
|
||||||
|
`Freshness._open_window()`: `self.graded_from = float(now_mono)` rewritten
|
||||||
|
as `float(self.changed_mono or now_mono)`, which is meant to re-anchor the
|
||||||
|
verdict's window to the register's own history and so delete the card's
|
||||||
|
whole exit. The harness purged, the tree fingerprint showed the edit on
|
||||||
|
disk, the delta was non-zero, and the mutant really did run. It was still a
|
||||||
|
survivor for a reason that has nothing to do with the property: in the
|
||||||
|
fixture the register never moves after `t = 0`, so `changed_mono` is
|
||||||
|
**`0.0`** - and `0.0 or x` is `x`. **The mutant was a no-op on that
|
||||||
|
stimulus.** Rewritten with an explicit `is None` test (+82 B) it is red on
|
||||||
|
four named checks.
|
||||||
|
|
||||||
|
Stale bytecode is a mutation that never *ran*. This is a mutation that ran
|
||||||
|
and could not *change the answer* on the data it was given, which is habit 4
|
||||||
|
exactly, one level up - and none of the three things a mutation report is
|
||||||
|
required to state (MUTATION.md's own list) can see it, because all three are
|
||||||
|
about the file rather than about the stimulus. Two rules:
|
||||||
|
* **Ask of every survivor what it would have had to change, and check that
|
||||||
|
the fixture reaches that value.** Here: "does any fixture have a non-zero
|
||||||
|
`changed_mono` at the moment `_open_window()` is called?" The answer was
|
||||||
|
no, and it is one line to ask.
|
||||||
|
* **Prefer a mutation that CANNOT be a no-op.** `or`, `and`, truthiness on
|
||||||
|
a numeric, a default argument and a `getattr` fallback all have a value at
|
||||||
|
which the mutant and the shipped tree coincide. An explicit comparison,
|
||||||
|
a constant swap or a deleted statement does not.
|
||||||
|
|
||||||
|
⚠️ Note where this one was found: in the battery of a card whose whole
|
||||||
|
subject is a check that cannot reach the failure it names. **Second
|
||||||
|
instance in an instrument rather than in a check since habit 16, and the
|
||||||
|
third overall.**
|
||||||
|
|
||||||
|
⚠️ **AND THE SAME CARD'S BATTERY CAUGHT A HABIT-22 COMMENT IN THE FIX
|
||||||
|
ITSELF, WHICH IS WHY THESE TWO BELONG NEXT TO EACH OTHER.**
|
||||||
|
`freshness.py` shipped `⚠️ RELEASE BEFORE GRADE, AND THE ORDER IS
|
||||||
|
LOAD-BEARING` with a worked reason. Swapping the two calls is **zero bytes**
|
||||||
|
and survived the battery: `_grade()` acts only when `|standing| >= move_w`
|
||||||
|
and `_release()` only when `|standing| < move_w`, so at most one can act on
|
||||||
|
any read and the order is **not observable**. Habit 22 says build X and
|
||||||
|
measure it; the battery is what builds X. The comment now states the
|
||||||
|
disjointness and the suite pins **that** - at 0, 100, 249, 250, 251 and
|
||||||
|
1300 W - rather than an order nothing depends on.
|
||||||
|
|
||||||
And the mutation-table consequence: **a variant is not covered because a
|
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
|
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
|
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||||
|
|||||||
@@ -175,6 +175,124 @@ only one of them is WIRED - see `BOUNDS` below and the note on SoC.
|
|||||||
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
|
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
|
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.
|
integral can be handed in where command travel is handed in here.
|
||||||
|
|
||||||
|
|
||||||
|
THE EXIT, AND WHY THE FIRST VERSION OF THIS MODULE DID NOT HAVE ONE (SAFETY-57)
|
||||||
|
==============================================================================
|
||||||
|
⚠️ THE VERDICT ABOVE IS ABOUT AN OBLIGATION, AND ITS OWN CONSEQUENCE REMOVES
|
||||||
|
THE OBLIGATION. Declaring the reading stale makes `Controller.inputs_ok` False,
|
||||||
|
which commands 0 W (§11.1 rule 1). At 0 W nothing is asking the register to be
|
||||||
|
anywhere - `|standing| >= MOVE_W` is false, so the conjunction cannot arm - and
|
||||||
|
the class docstring's clearing rule, "an arrival carrying a DIFFERENT number",
|
||||||
|
asks the register for evidence that only a command could have produced. A pack
|
||||||
|
sitting at its BMS floor answers the same integer watt for hours. **The state
|
||||||
|
had no exit reachable from inside it**, and the operator-facing line said the
|
||||||
|
opposite: *"It clears itself the moment the register reports a different
|
||||||
|
value."*
|
||||||
|
|
||||||
|
MEASURED ON THE LIVE SITE, 2026-09-21, and committed as
|
||||||
|
`scenarios/safety57-2026-09-21/` - a passive RS-485 capture, decoded by
|
||||||
|
`tools/decode_bus_capture.py`:
|
||||||
|
|
||||||
|
10:51:24.7 0x0518 reaches -3 W. The pack is at 11 % and the loop has just
|
||||||
|
reversed from -447 W of charge into discharge; the BMS refuses
|
||||||
|
and the inverter idles. The register is HEALTHY and TRUTHFUL.
|
||||||
|
10:51:29.7 the standing command reaches +1060 W. 800 W of travel, and the
|
||||||
|
register is obliged to follow it.
|
||||||
|
10:52:00 the conjunction arms. `Inverter.batt` becomes None.
|
||||||
|
10:52:21.7 the site commands 0 W - on the wire, in the capture.
|
||||||
|
... 0x0518 does not change value for 850.0 s. The setpoint does not
|
||||||
|
change value for 790.0 s. Nothing in this add-on can end it.
|
||||||
|
11:04 a human restarts the add-on.
|
||||||
|
11:05:31.7 the restarted loop commands -400 W of charge, which the pack
|
||||||
|
ACCEPTS at 11 %, and the register moves 3 s later.
|
||||||
|
|
||||||
|
⚠️ AND IT HAD BEEN ESCAPING BY LUCK. This BMS emits a keep-alive pulse of a few
|
||||||
|
hundred watts, which twitches 0x0518 and satisfies the clearing rule by
|
||||||
|
accident. Replaying the committed 16-day loop capture
|
||||||
|
(`scenarios/loop-2026-09-02_2026-09-17/`) through the shipped conjunction at
|
||||||
|
1 Hz gives **93 trips**, and the time from each trip to the register's next
|
||||||
|
change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
|
||||||
|
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
|
||||||
|
property of this battery's firmware. It is not a property of this code, and the
|
||||||
|
add-on must not depend on it.
|
||||||
|
|
||||||
|
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
|
||||||
|
exit, not how long the block lasts":
|
||||||
|
|
||||||
|
RELEASE <=> the verdict is standing
|
||||||
|
AND |standing command| < MOVE_W has held continuously for
|
||||||
|
>= RELEASE_S
|
||||||
|
|
||||||
|
On a release the latch clears and a NEW grading window opens at that instant:
|
||||||
|
the register is re-graded, from scratch, against the commands issued AFTER it.
|
||||||
|
Nothing about the register's own history is rewritten - `changed_mono`,
|
||||||
|
`held` and `unchanged_s()` go on reporting that the register has not moved for
|
||||||
|
850 s, because it has not.
|
||||||
|
|
||||||
|
⚠️ `RELEASE_S` IS `BATT_OBLIGED_S`, 17.0 s, AND IT IS THE FLOOR OF ITS BAND
|
||||||
|
RATHER THAN A PREFERENCE - the mirror of `MOVE_W` taking the ceiling of its
|
||||||
|
own. The question the bound answers is *has the register had its chance to
|
||||||
|
answer the command that is now standing?*, and that is exactly the 17.0 s
|
||||||
|
already derived above: 6.0 s of plant, one 10 s publish, one 1 s read. Below it
|
||||||
|
the release happens before the cheaper exit - the register moving by itself -
|
||||||
|
could have occurred. Which half is safe is the opposite of SAFETY-13's, and for
|
||||||
|
the reason that card states in its §4.2 table: being wrong LONG here is the
|
||||||
|
failure being fixed (a site parked while it exports), and being wrong SHORT
|
||||||
|
costs one re-grading window, which is bounded below.
|
||||||
|
|
||||||
|
WHAT A RELEASE COSTS, MEASURED AND NOT ABSORBED - AND IT IS NOT SMALL. For the
|
||||||
|
duration of the new window the consumers get the held number again, which is
|
||||||
|
the exposure this module was written to close. Driven at the live install's
|
||||||
|
shape against a register frozen for good (`test_freshness.py` part 10c), over
|
||||||
|
1200 s:
|
||||||
|
|
||||||
|
shipped parked 1166 s, the held number reaches a consumer 35 s 2.9 %
|
||||||
|
SAFETY-57 parked 571 s, the held number reaches a consumer 630 s 52.5 %
|
||||||
|
|
||||||
|
⚠️ **52 % IS THE PRICE OF THE EXIT AND IT IS PAID EVERY CYCLE**, not an
|
||||||
|
occasional slip. The period is the whole of `stale_input_s` (15 s) + `RELEASE_S`
|
||||||
|
(17 s) parked, then one `BATT_UNCHANGED_S` (35 s) window of re-grading. It is
|
||||||
|
bounded by construction, and that bound is the one thing that makes it payable:
|
||||||
|
|
||||||
|
the new window is AT MOST BATT_UNCHANGED_S = 35 s, and 35 < 75 < 85 - the
|
||||||
|
circulation hold and the per-unit sign hold. NEITHER CONSUMER CAN REACH A
|
||||||
|
VERDICT INSIDE ONE WINDOW, and a re-trip returns them a None, which CLEARS
|
||||||
|
both windows rather than accumulating into them.
|
||||||
|
|
||||||
|
That inequality is the same one part 6 already grades, and it is why the window
|
||||||
|
is `BATT_UNCHANGED_S` rather than a number chosen to make the table above look
|
||||||
|
better. Neither detector can be made to say anything by a held number under
|
||||||
|
this scheme - which is exactly the state the shipped code leaves them in as
|
||||||
|
well, since a permanent None clears both windows for ever. **So nothing that
|
||||||
|
SAFETY-37 detects is given back.** What the held number does reach for those
|
||||||
|
35 s is `site_batt_w`, the anti-windup input of `control.compute()`, which
|
||||||
|
responds to a large unexplained divergence by FREEZING the output - the
|
||||||
|
conservative direction, and SAFETY-13's business. And the command itself is
|
||||||
|
derived from the P1 meter, never from `batt`, so a site regulating through a
|
||||||
|
re-grading window is regulating on the signal it always regulates on.
|
||||||
|
|
||||||
|
**A register frozen under a command that never travels 250 W is still not
|
||||||
|
detected at all** (survivor 2, unchanged), and a dead publisher whose value
|
||||||
|
alternates is still invisible (survivor 3).
|
||||||
|
|
||||||
|
⚠️ AND THE NARROWING THIS DOES NOT TAKE, named so the next card can. The live
|
||||||
|
episode's register refused a DISCHARGE into an 11 % pack and then followed a
|
||||||
|
CHARGE immediately; a refusal in one direction is not evidence about the other,
|
||||||
|
and a gate that knew the pack's floor could re-grade on the first command that
|
||||||
|
crosses it instead of on a timer. That needs SoC, which this module deliberately
|
||||||
|
does not take (see the `soc` row), and the pack floor, which is SAFETY-32's
|
||||||
|
number. It would shrink the 52 % above; it is not needed to give the state an
|
||||||
|
exit, and mixing it in here would make the exit depend on a second measurement
|
||||||
|
being right.
|
||||||
|
|
||||||
|
⚠️ WHAT THIS DOES NOT DO, because the temptation is to call it a fix for the
|
||||||
|
whole episode: it does not tell a BMS refusal apart from a dead register. The
|
||||||
|
site above was parked for 790 s by a register that was working perfectly, and
|
||||||
|
after this change it is parked for `stale_input_s + RELEASE_S` instead - about
|
||||||
|
32 s on the shipped default - and then regrades. Telling the two apart needs
|
||||||
|
the pack's own floor, which is SAFETY-32's number and not this module's input;
|
||||||
|
the wording the operator reads about it is SAFETY-56's.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@@ -211,6 +329,19 @@ BATT_UNCHANGED_S = 35.0
|
|||||||
# and goes red if the two drift.
|
# and goes red if the two drift.
|
||||||
MOVE_W = 250.0
|
MOVE_W = 250.0
|
||||||
|
|
||||||
|
# SAFETY-57. How long the obligation must have been ABSENT before a standing
|
||||||
|
# verdict is withdrawn and the register re-graded. See the module docstring's
|
||||||
|
# last section for the live episode this exists for and for which half of the
|
||||||
|
# band is the safe one.
|
||||||
|
#
|
||||||
|
# ⚠️ IT IS `BATT_OBLIGED_S` AND NOT A NEW NUMBER, and that identity is the
|
||||||
|
# derivation rather than a convenience. The question is "has the register had
|
||||||
|
# its chance to answer the command standing NOW?", which is the same physical
|
||||||
|
# quantity the floor of `BATT_UNCHANGED_S`'s band is: 6.0 s of plant, one 10 s
|
||||||
|
# ESPHome publish, one 1 s add-on read. Releasing sooner would throw away the
|
||||||
|
# cheaper exit - the register moving by itself - before it could have happened.
|
||||||
|
RELEASE_S = BATT_OBLIGED_S # 17.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Bound:
|
class Bound:
|
||||||
@@ -219,6 +350,7 @@ class Bound:
|
|||||||
signal: str
|
signal: str
|
||||||
unchanged_s: float
|
unchanged_s: float
|
||||||
move_w: float
|
move_w: float
|
||||||
|
release_s: float
|
||||||
wired: bool
|
wired: bool
|
||||||
derivation: str
|
derivation: str
|
||||||
|
|
||||||
@@ -233,13 +365,19 @@ BOUNDS = {
|
|||||||
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
|
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
|
||||||
unchanged_s=BATT_UNCHANGED_S,
|
unchanged_s=BATT_UNCHANGED_S,
|
||||||
move_w=MOVE_W,
|
move_w=MOVE_W,
|
||||||
|
release_s=RELEASE_S,
|
||||||
wired=True,
|
wired=True,
|
||||||
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
|
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]"),
|
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]. "
|
||||||
|
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
|
||||||
|
"standing command below 250 W, because at 0 W the register "
|
||||||
|
"is obliged to nothing and the verdict cannot be tested "
|
||||||
|
"again from inside the state it creates"),
|
||||||
"soc": Bound(
|
"soc": Bound(
|
||||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||||
unchanged_s=float("inf"),
|
unchanged_s=float("inf"),
|
||||||
move_w=float("inf"),
|
move_w=float("inf"),
|
||||||
|
release_s=float("inf"),
|
||||||
wired=False,
|
wired=False,
|
||||||
derivation="TEL-08. Command travel is the WRONG obligation term for "
|
derivation="TEL-08. Command travel is the WRONG obligation term for "
|
||||||
"SoC - a rested pack holds a percent for hours under a 0 W "
|
"SoC - a rested pack holds a percent for hours under a 0 W "
|
||||||
@@ -263,10 +401,33 @@ class Freshness:
|
|||||||
Declaring the reading stale makes `Controller.inputs_ok` False, which
|
Declaring the reading stale makes `Controller.inputs_ok` False, which
|
||||||
commands 0 W (§11.1 rule 1), which drops the standing command below
|
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
|
`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
|
to the consumers - and round again every read. So an arrival carrying a
|
||||||
the latch is an arrival carrying a DIFFERENT number, which is exactly the
|
DIFFERENT number clears the latch, which is exactly the evidence the verdict
|
||||||
evidence the verdict was about. A register that is working clears it on its
|
was about. A register that is working clears it on its next 10 s publish; a
|
||||||
next 10 s publish; a register that is not, does not.
|
register that is not, does not.
|
||||||
|
|
||||||
|
⚠️ AND IT IS NOT THE ONLY THING THAT CLEARS IT ANY MORE (SAFETY-57), BECAUSE
|
||||||
|
THAT MADE THE STATE INESCAPABLE FROM INSIDE. The command the verdict itself
|
||||||
|
produces is 0 W, and at 0 W the register is obliged to be nowhere - so
|
||||||
|
"report a different value" is evidence a parked site cannot ask for. The
|
||||||
|
live site sat at 0 W for 790 s on 2026-09-21 with a register that was
|
||||||
|
working correctly, and was freed by a human. `_release()` below withdraws a
|
||||||
|
verdict whose obligation has been gone for `bound.release_s`, and opens a
|
||||||
|
new grading window at that instant. The 1 Hz oscillation the latch exists to
|
||||||
|
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
|
||||||
|
no re-trip can land sooner than 35 s after a release, rather than on the
|
||||||
|
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
|
||||||
|
the site rests for longer than the window before it asks for anything, the
|
||||||
|
window is already old and the re-trip lands on the first read at which the
|
||||||
|
command travels 250 W again. That is survivor 5's shape, it predates this
|
||||||
|
card, and its cost is one `stale_input_s` hold.
|
||||||
|
|
||||||
|
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
|
||||||
|
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
|
||||||
|
different number. `graded_from` / `standing_at_grade` are the window the
|
||||||
|
VERDICT is computed over, which a release re-opens. They are the same thing
|
||||||
|
until the first release and diverge afterwards, and `unchanged_s()` - the
|
||||||
|
number an operator reads - is always the register's.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, bound: Bound):
|
def __init__(self, bound: Bound):
|
||||||
@@ -281,10 +442,20 @@ class Freshness:
|
|||||||
# ability to fetch it.
|
# ability to fetch it.
|
||||||
self.held = None
|
self.held = None
|
||||||
self.changed_mono = None
|
self.changed_mono = None
|
||||||
# The standing command at that same instant. The conjunction compares
|
# SAFETY-57. The window the VERDICT is graded over, as opposed to the
|
||||||
# against THIS, not against the previous cycle's command: what matters
|
# register's own history above, and the standing command at the instant
|
||||||
# is whether the register has followed anything since it last moved.
|
# it opened. The conjunction compares against THIS, not against the
|
||||||
self.standing_at_change = None
|
# previous cycle's command: what matters is whether the register has
|
||||||
|
# followed anything since the current question was put to it. A register
|
||||||
|
# that moves opens a window, so until the first release these two are
|
||||||
|
# the change-stamps; `_release()` is where they diverge.
|
||||||
|
self.graded_from = None
|
||||||
|
self.standing_at_grade = None
|
||||||
|
# The instant the standing command last FELL below `move_w`, or None
|
||||||
|
# while an obligation is standing. The release timer runs off this.
|
||||||
|
self.rested_since = None
|
||||||
|
self.released_at = None
|
||||||
|
self.releases = 0
|
||||||
self.stale = False
|
self.stale = False
|
||||||
self.reason = ""
|
self.reason = ""
|
||||||
self.arrivals = 0
|
self.arrivals = 0
|
||||||
@@ -311,20 +482,67 @@ class Freshness:
|
|||||||
if self.changed_mono is None or value != self.held:
|
if self.changed_mono is None or value != self.held:
|
||||||
self.held = value
|
self.held = value
|
||||||
self.changed_mono = float(now_mono)
|
self.changed_mono = float(now_mono)
|
||||||
self.standing_at_change = (None if standing_w is None
|
self._open_window(standing_w, now_mono)
|
||||||
else float(standing_w))
|
# Movement clears the latch, because it is exactly the evidence the
|
||||||
# Movement is the ONLY thing that clears the latch. See the class
|
# verdict was about. See the class docstring on the 1 Hz oscillation
|
||||||
# docstring on the 1 Hz oscillation this prevents.
|
# the latch prevents, and `_release()` for the other way out.
|
||||||
self.stale = False
|
self.stale = False
|
||||||
self.reason = ""
|
self.reason = ""
|
||||||
self.last = value
|
self.last = value
|
||||||
|
# ⚠️ THE ORDER OF THESE TWO IS NOT OBSERVABLE, AND THIS COMMENT SAYS SO
|
||||||
|
# BECAUSE THE FIRST VERSION OF IT CLAIMED THE OPPOSITE. Swapping them is
|
||||||
|
# a ZERO-BYTE edit and it SURVIVED the battery; the reason is structural
|
||||||
|
# rather than a missing fixture, so no fixture could have closed it:
|
||||||
|
# `_grade()` acts only when `|standing| >= move_w` and `_release()` only
|
||||||
|
# when `|standing| < move_w`, so at most one of them can do anything on
|
||||||
|
# any given read, whichever runs first. Release is written first because
|
||||||
|
# it reads in the order the state machine moves - withdraw, then
|
||||||
|
# re-grade - not because the code depends on it.
|
||||||
|
self._release(standing_w, now_mono)
|
||||||
self._grade(standing_w, now_mono)
|
self._grade(standing_w, now_mono)
|
||||||
|
|
||||||
|
def _open_window(self, standing_w, now_mono) -> None:
|
||||||
|
"""Start grading the register again, from this instant (SAFETY-57).
|
||||||
|
|
||||||
|
Called when the register moves - the window and the register's history
|
||||||
|
coincide there - and when a verdict is withdrawn, where they do not.
|
||||||
|
"""
|
||||||
|
self.graded_from = float(now_mono)
|
||||||
|
self.standing_at_grade = (None if standing_w is None
|
||||||
|
else float(standing_w))
|
||||||
|
|
||||||
|
def _release(self, standing_w, now_mono) -> None:
|
||||||
|
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
|
||||||
|
|
||||||
|
⚠️ THE TIMER RUNS ON THE COMMAND, NOT ON THE VERDICT. `rested_since` is
|
||||||
|
maintained on every arrival, latched or not, so the elapsed rest is the
|
||||||
|
real one and not "time since we last looked". An obligation reappearing
|
||||||
|
resets it to None, which is the whole point: a site that goes back to
|
||||||
|
asking the register for 250 W of movement has put the question back, and
|
||||||
|
the verdict stands until the question is withdrawn again.
|
||||||
|
"""
|
||||||
|
obliged = (standing_w is not None
|
||||||
|
and abs(float(standing_w)) >= self.bound.move_w)
|
||||||
|
if obliged:
|
||||||
|
self.rested_since = None
|
||||||
|
return
|
||||||
|
if self.rested_since is None:
|
||||||
|
self.rested_since = float(now_mono)
|
||||||
|
if not self.stale:
|
||||||
|
return
|
||||||
|
if float(now_mono) - self.rested_since < self.bound.release_s:
|
||||||
|
return
|
||||||
|
self.released_at = float(now_mono)
|
||||||
|
self.releases += 1
|
||||||
|
self.stale = False
|
||||||
|
self.reason = ""
|
||||||
|
self._open_window(standing_w, now_mono)
|
||||||
|
|
||||||
def _grade(self, standing_w, now_mono) -> None:
|
def _grade(self, standing_w, now_mono) -> None:
|
||||||
"""The conjunction. Sets the latch; never clears it."""
|
"""The conjunction. Sets the latch; never clears it."""
|
||||||
if self.stale or self.changed_mono is None:
|
if self.stale or self.graded_from is None:
|
||||||
return
|
return
|
||||||
held_s = self.unchanged_s(now_mono)
|
held_s = self.graded_s(now_mono)
|
||||||
if held_s < self.bound.unchanged_s:
|
if held_s < self.bound.unchanged_s:
|
||||||
return
|
return
|
||||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||||
@@ -333,30 +551,98 @@ class Freshness:
|
|||||||
# consumer today. They are here for TEL-08, whose obligation term is an
|
# 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
|
# energy integral that has nothing to say until it has a window - and
|
||||||
# "nothing to say" must withhold the verdict, not manufacture one.
|
# "nothing to say" must withhold the verdict, not manufacture one.
|
||||||
if standing_w is None or self.standing_at_change is None:
|
if standing_w is None or self.standing_at_grade is None:
|
||||||
return
|
return
|
||||||
standing_w = float(standing_w)
|
standing_w = float(standing_w)
|
||||||
if abs(standing_w) < self.bound.move_w:
|
if abs(standing_w) < self.bound.move_w:
|
||||||
return
|
return
|
||||||
moved_w = abs(standing_w - self.standing_at_change)
|
moved_w = abs(standing_w - self.standing_at_grade)
|
||||||
if moved_w < self.bound.move_w:
|
if moved_w < self.bound.move_w:
|
||||||
return
|
return
|
||||||
self.stale = True
|
self.stale = True
|
||||||
|
# ⚠️ THE SENTENCE SAYS "unchanged", SO IT GETS THE REGISTER'S OWN
|
||||||
|
# NUMBER, NOT THE WINDOW'S. They are the same until the first release
|
||||||
|
# and different afterwards, and an operator comparing this line against
|
||||||
|
# a history graph is reading the register.
|
||||||
|
shown_s = self.unchanged_s(now_mono)
|
||||||
|
# ⚠️ SAFETY-56. THE OBSERVATION IS A FACT, THE DIAGNOSIS WAS NOT, AND
|
||||||
|
# THE SENTENCE NOW STOPS WHERE THE EVIDENCE DOES. The conjunction above
|
||||||
|
# is satisfied by TWO disjoint worlds, and nothing this object is handed
|
||||||
|
# separates them:
|
||||||
|
# (1) the register has stopped reporting reality - what the gate was
|
||||||
|
# built for - or
|
||||||
|
# (2) reality is not moving, because the SOURCE cannot comply with
|
||||||
|
# the command. A pack the BMS is refusing at its floor holds
|
||||||
|
# still under any setpoint, and a register reporting that
|
||||||
|
# truthfully publishes the same unchanged number a dead one does.
|
||||||
|
# The live 2026-09-21 episode was (2) - the pack read 11 % for the
|
||||||
|
# whole 850 s hold, the commands were discharge-only, and the register
|
||||||
|
# answered the first CHARGE command within one obligation window
|
||||||
|
# (test_freshness.py 11a measures all three off the committed capture)
|
||||||
|
# - and this sentence asserted (1), which sent the operator to look at
|
||||||
|
# healthy hardware.
|
||||||
|
# Naming both is what the module can honestly say. Naming WHICH would
|
||||||
|
# need SoC and the pack floor - deliberately not this module's inputs
|
||||||
|
# (see the `soc` row in BOUNDS and the docstring's last section), and
|
||||||
|
# TEL-08's and SAFETY-32's numbers rather than this sentence's.
|
||||||
|
#
|
||||||
|
# ⚠️ AND IT IS NOT SHARED WITH `main.py`'s NOT READY ROW, WHICH SAYS
|
||||||
|
# THE SAME AMBIGUITY IN DIFFERENT WORDS ON PURPOSE. This sentence is a
|
||||||
|
# LOG LINE about a behaviour, quoted verbatim by `note()` onto the
|
||||||
|
# status page and by `_read_unit()` into the action log; that one is a
|
||||||
|
# BANNER about a named ENTITY, and the two answer different questions
|
||||||
|
# ("what did the register do" against "can I trust this entity"). One
|
||||||
|
# helper serving both would have to drop whichever half the other does
|
||||||
|
# not need, and the half it dropped would be the operator's next step.
|
||||||
self.reason = (
|
self.reason = (
|
||||||
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
|
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
|
||||||
f"while the standing command moved {moved_w:.0f} W to "
|
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"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
|
||||||
|
f"CANNOT TELL THEM APART: the register may have stopped reporting "
|
||||||
|
f"reality, or the source may be unable to comply with the command "
|
||||||
|
f"- a pack the BMS is refusing holds still under any setpoint, and "
|
||||||
|
f"an honest register then reads exactly like a frozen one. Either "
|
||||||
|
f"way the number cannot be read as this command's effect, so it is "
|
||||||
f"reported as MISSING rather than as a measurement (limits: "
|
f"reported as MISSING rather than as a measurement (limits: "
|
||||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
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 and moved by over "
|
||||||
f"{self.bound.move_w:.0f} W)")
|
f"{self.bound.move_w:.0f} W)")
|
||||||
|
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
|
||||||
|
# in its own slot: the register's unchanged time is in the sentence
|
||||||
|
# above, the WINDOW this verdict actually rests on is here, and so is
|
||||||
|
# how many times this unit has been round the loop. Without the second
|
||||||
|
# one the sentence reads as though 92 s of unchanged register were what
|
||||||
|
# was compared against the 35 s limit, and it was not.
|
||||||
|
if self.releases:
|
||||||
|
self.reason += (
|
||||||
|
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
|
||||||
|
f"opened when the previous one was withdrawn, not when the "
|
||||||
|
f"register last moved - this unit has now been graded "
|
||||||
|
f"{self.releases + 1} times]")
|
||||||
|
|
||||||
def unchanged_s(self, now_mono) -> float | None:
|
def unchanged_s(self, now_mono) -> float | None:
|
||||||
"""Seconds since an arrival carried a different number. Status page."""
|
"""Seconds since an arrival carried a different number. Status page.
|
||||||
|
|
||||||
|
⚠️ THE REGISTER'S CLOCK, NEVER THE VERDICT'S (SAFETY-57). A release
|
||||||
|
re-opens the grading window; it does not make the register younger, and
|
||||||
|
an operator reading "unchanged for 12 s" off a register that has held
|
||||||
|
one value for 850 s would be reading this module's bookkeeping back as a
|
||||||
|
measurement. `graded_s()` is the other one and is named differently for
|
||||||
|
that reason.
|
||||||
|
"""
|
||||||
if self.changed_mono is None:
|
if self.changed_mono is None:
|
||||||
return None
|
return None
|
||||||
return max(0.0, float(now_mono) - self.changed_mono)
|
return max(0.0, float(now_mono) - self.changed_mono)
|
||||||
|
|
||||||
|
def graded_s(self, now_mono) -> float | None:
|
||||||
|
"""Seconds the CURRENT verdict window has been open (SAFETY-57).
|
||||||
|
|
||||||
|
Equal to `unchanged_s()` until the first release, and shorter after one.
|
||||||
|
"""
|
||||||
|
if self.graded_from is None:
|
||||||
|
return None
|
||||||
|
return max(0.0, float(now_mono) - self.graded_from)
|
||||||
|
|
||||||
def reading(self):
|
def reading(self):
|
||||||
"""What the consumers get: the value, or None when it cannot be trusted.
|
"""What the consumers get: the value, or None when it cannot be trusted.
|
||||||
|
|
||||||
@@ -366,6 +652,13 @@ class Freshness:
|
|||||||
rather than accumulating it, and `circulation.circulating_w` returns None
|
rather than accumulating it, and `circulation.circulating_w` returns None
|
||||||
for a roster with a missing term, which clears the circulation window
|
for a roster with a missing term, which clears the circulation window
|
||||||
too. This module is the thing that was missing between them.
|
too. This module is the thing that was missing between them.
|
||||||
|
|
||||||
|
⚠️ AND IT CAN GO BACK TO BEING A NUMBER WITHOUT THE REGISTER MOVING
|
||||||
|
(SAFETY-57). That is the exit, and it is bounded rather than free: the
|
||||||
|
window a release opens is `bound.unchanged_s` = 35 s, shorter than both
|
||||||
|
consumer holds (75 s and 85 s), so neither can reach a verdict inside
|
||||||
|
one - and a re-trip hands them a None, which CLEARS their windows. See
|
||||||
|
the module docstring's last section.
|
||||||
"""
|
"""
|
||||||
return None if self.stale else self.last
|
return None if self.stale else self.last
|
||||||
|
|
||||||
@@ -373,6 +666,26 @@ class Freshness:
|
|||||||
"""One line for the status page. Empty when there is nothing to say."""
|
"""One line for the status page. Empty when there is nothing to say."""
|
||||||
return f"{unit_name}: {self.reason}" if self.stale else ""
|
return f"{unit_name}: {self.reason}" if self.stale else ""
|
||||||
|
|
||||||
|
def release_note(self, unit_name: str) -> str:
|
||||||
|
"""SAFETY-57's release, for the log. Empty when nothing was withdrawn.
|
||||||
|
|
||||||
|
⚠️ IT NAMES WHAT IS AND IS NOT BEING CLAIMED, because a line reading
|
||||||
|
"recovered" would be a lie: nothing has been learned about the register.
|
||||||
|
The verdict was withdrawn because it can no longer be tested, and the
|
||||||
|
next 35 s are the test.
|
||||||
|
"""
|
||||||
|
if self.released_at is None:
|
||||||
|
return ""
|
||||||
|
return (f"{unit_name}: STALE_WITHDRAWN after the standing command sat "
|
||||||
|
f"below {self.bound.move_w:.0f} W for "
|
||||||
|
f"{self.bound.release_s:.0f} s. NOTHING HAS BEEN LEARNED ABOUT "
|
||||||
|
f"THE REGISTER - it is still holding {self.held:+.0f} - but a "
|
||||||
|
f"verdict about a command that is no longer standing cannot be "
|
||||||
|
f"tested, and the 0 W this one produces is what stopped it "
|
||||||
|
f"being tested. The register is now re-graded over a fresh "
|
||||||
|
f"{self.bound.unchanged_s:.0f} s window; if it still does not "
|
||||||
|
f"follow, this returns")
|
||||||
|
|
||||||
|
|
||||||
def monotonic() -> float:
|
def monotonic() -> float:
|
||||||
"""The clock this module's callers use, in one place so tests can find it.
|
"""The clock this module's callers use, in one place so tests can find it.
|
||||||
|
|||||||
@@ -1769,6 +1769,7 @@ class Controller:
|
|||||||
inv.soc = await self.hass.number(inv.soc_entity)
|
inv.soc = await self.hass.number(inv.soc_entity)
|
||||||
raw = 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
|
was_stale = inv.batt_fresh.stale
|
||||||
|
was_releases = inv.batt_fresh.releases
|
||||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||||
# ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input,
|
# ⚠️ `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
|
# not a measurement - `inputs_ok` sees the None, the site holds for
|
||||||
@@ -1777,11 +1778,52 @@ class Controller:
|
|||||||
inv.batt = inv.batt_fresh.reading()
|
inv.batt = inv.batt_fresh.reading()
|
||||||
if inv.batt_fresh.stale and not was_stale:
|
if inv.batt_fresh.stale and not was_stale:
|
||||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
|
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
|
||||||
_LOG.error("SAFETY-37: %s battery-power register is not following "
|
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
|
||||||
"its own command - %s. Treating it as MISSING, which "
|
# ELSE IN IT. The escape clause used to read "It clears itself the
|
||||||
|
# moment the register reports a different value", full stop - and
|
||||||
|
# that was FALSE for the state this same line announces, because the
|
||||||
|
# 0 W it announces in the sentence before is exactly what stops the
|
||||||
|
# register ever being asked to report anything different. The live
|
||||||
|
# site sat in it for 790 s on 2026-09-21 and was freed by a human.
|
||||||
|
# ⚠️ SAFETY-56 REWROTE THE DIAGNOSIS HALF, WHICH SAFETY-57 LEFT
|
||||||
|
# STANDING ON PURPOSE. It used to open "battery-power register is
|
||||||
|
# not following its own command" - an accusation against a named
|
||||||
|
# piece of hardware, and FALSE on the very episode above. The
|
||||||
|
# pack read 11 % for the whole hold, every command in it was a
|
||||||
|
# DISCHARGE, and the register answered the first CHARGE command
|
||||||
|
# three seconds later - so the BMS was refusing, the register was
|
||||||
|
# publishing that refusal correctly, and the operator was sent to
|
||||||
|
# look at a working sensor. All three numbers are measured off the
|
||||||
|
# committed capture by test_freshness.py part 11a. The gate's
|
||||||
|
# conjunction is right and is NOT touched; only its conclusion was.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE TWO EXPLANATIONS ARE NAMED ONCE, IN `%s`, NOT TWICE.
|
||||||
|
# This line's own words are the OBSERVATION plus the one thing a
|
||||||
|
# log reader has to be told before acting - that the observation is
|
||||||
|
# not yet a verdict - and `freshness._grade()`'s sentence, which
|
||||||
|
# this line already interpolates, is where the pair is spelled out.
|
||||||
|
# Saying it in both halves of one sentence is how an operator
|
||||||
|
# learns to skim it.
|
||||||
|
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
|
||||||
|
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
|
||||||
|
"ON THE REGISTER - %s. Treating it as MISSING, which "
|
||||||
"commands 0 W. It clears itself the moment the register "
|
"commands 0 W. It clears itself the moment the register "
|
||||||
"reports a different value.",
|
"reports a different value, and failing that %.0f s "
|
||||||
inv.name, inv.batt_fresh.reason)
|
"after the standing command falls below %.0f W "
|
||||||
|
"(SAFETY-57: a verdict whose own consequence is a 0 W "
|
||||||
|
"command cannot be tested again from inside it).",
|
||||||
|
inv.name, inv.batt_fresh.reason,
|
||||||
|
inv.batt_fresh.bound.release_s,
|
||||||
|
inv.batt_fresh.bound.move_w)
|
||||||
|
elif inv.batt_fresh.releases > was_releases:
|
||||||
|
# ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a
|
||||||
|
# timer and says nothing is a latch no operator can audit - and this
|
||||||
|
# one is withdrawing a SITE STOP. `release_note()` is written to say
|
||||||
|
# that nothing has been learned about the register, because nothing
|
||||||
|
# has.
|
||||||
|
self.log_event(f"STALE WITHDRAWN {inv.name}")
|
||||||
|
_LOG.warning("SAFETY-57: %s",
|
||||||
|
inv.batt_fresh.release_note(inv.name))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def shadow(self) -> bool:
|
def shadow(self) -> bool:
|
||||||
@@ -4636,8 +4678,38 @@ class Controller:
|
|||||||
if not entity:
|
if not entity:
|
||||||
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
|
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
|
||||||
elif value is None:
|
elif value is None:
|
||||||
|
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
|
||||||
|
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read
|
||||||
|
# "<entity> is missing or not numeric" for ANY None, and one of
|
||||||
|
# the ways this value becomes None is SAFETY-37's freshness gate
|
||||||
|
# withholding a reading that is present, numeric and updating -
|
||||||
|
# `Freshness.reading()` returns None BY DESIGN there. On the
|
||||||
|
# live site on 2026-09-21 the operator opened Developer Tools on
|
||||||
|
# the entity this row named, found it working, and had every
|
||||||
|
# reason to conclude the diagnostics were broken.
|
||||||
|
#
|
||||||
|
# ⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The
|
||||||
|
# row could ask `inv.batt_fresh.stale` - it is on the same
|
||||||
|
# object at the same moment - but this loop also renders the
|
||||||
|
# grid and SoC rows, which have no gate, and a branch per row
|
||||||
|
# would be a second place where "why is this None" is decided.
|
||||||
|
# The gate already has a surface of its own: `stale_note()`
|
||||||
|
# rides `liveness_notes()` a few rows below with the whole
|
||||||
|
# STALE_READING sentence in it, so the operator's discriminator
|
||||||
|
# is "is that row there?" and this row's job is only to stop
|
||||||
|
# pre-empting the answer. Same reason the wording is not shared
|
||||||
|
# with `freshness._grade()`: that one is a log line about a
|
||||||
|
# BEHAVIOUR, this one is a banner about an ENTITY.
|
||||||
out.append({"ok": False, "warn": False,
|
out.append({"ok": False, "warn": False,
|
||||||
"text": f"{label}: {entity} is missing or not numeric"})
|
"text": f"{label}: no usable value this cycle from "
|
||||||
|
f"{entity} - and this row cannot tell which "
|
||||||
|
f"of two things that is: the entity is "
|
||||||
|
f"missing or not numeric, or it is "
|
||||||
|
f"publishing a number that this controller "
|
||||||
|
f"is withholding (a freshness gate does "
|
||||||
|
f"exactly that, and names itself in a row "
|
||||||
|
f"of its own). Confirm the entity in "
|
||||||
|
f"Developer Tools before replacing it"})
|
||||||
else:
|
else:
|
||||||
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
|
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: GoodWe RS485 Controller
|
name: GoodWe RS485 Controller
|
||||||
version: "0.10.0"
|
version: "0.10.1"
|
||||||
slug: goodwe_controller
|
slug: goodwe_controller
|
||||||
description: >-
|
description: >-
|
||||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"generated": "2026-09-21T11:30:00+02:00",
|
||||||
|
"card": "SAFETY-57",
|
||||||
|
"what": "The 2026-09-21 episode: a healthy 0x0518 declared stale, the site parked at 0 W, and no exit reachable from inside the state.",
|
||||||
|
"source": {
|
||||||
|
"host": "sniffer@192.168.2.167 (goodwe-sniff)",
|
||||||
|
"nature": "PASSIVE listener on the RS-485 pair between the ESP32 master and the GW5000S-BP. It has no address on the bus and writes nothing.",
|
||||||
|
"file": "~/bus-a.log",
|
||||||
|
"window_local": [
|
||||||
|
"2026-09-21T10:30:00+02:00",
|
||||||
|
"2026-09-21T11:10:00+02:00"
|
||||||
|
],
|
||||||
|
"timezone": "Europe/Brussels (the capture stamps a time of day and no date; the date is asserted here, not derivable from the file)",
|
||||||
|
"extraction": "lines whose time-of-day field lies in the window, verbatim, in capture order"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"name": "bus-a-2026-09-21-1030-1110.log.gz",
|
||||||
|
"sha256_gz": "876fdaaf6d087125b184bfb4dd7ef4c3d1dd71ccad0fc271f8d268e8b489d134",
|
||||||
|
"sha256_plain": "fa07e13b60e230626a8c0964b9539783a71b5627a522fc5930fd9fda54438a48",
|
||||||
|
"bytes_gz": 17181,
|
||||||
|
"bytes_plain": 182400,
|
||||||
|
"lines": 3840
|
||||||
|
},
|
||||||
|
"decoder": "tools/decode_bus_capture.py - and it is the ONLY interpretation of these bytes this repository carries. Re-run it to re-derive every number below.",
|
||||||
|
"frame_census": {
|
||||||
|
"lines": 3840,
|
||||||
|
"unparsed": 0,
|
||||||
|
"bad_crc": 0,
|
||||||
|
"requests": 720,
|
||||||
|
"responses": 720,
|
||||||
|
"writes": 1200,
|
||||||
|
"write_echoes": 1200,
|
||||||
|
"unknown": 0
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"setpoint_w": {
|
||||||
|
"frames": 1200,
|
||||||
|
"changes": 224,
|
||||||
|
"first_s": 37801.687,
|
||||||
|
"last_s": 40199.693
|
||||||
|
},
|
||||||
|
"batt_w": {
|
||||||
|
"frames": 240,
|
||||||
|
"changes": 133,
|
||||||
|
"first_s": 37804.716,
|
||||||
|
"last_s": 40194.724
|
||||||
|
},
|
||||||
|
"soc_pct": {
|
||||||
|
"frames": 240,
|
||||||
|
"changes": 4,
|
||||||
|
"first_s": 37806.725,
|
||||||
|
"last_s": 40196.732
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"the_episode_in_the_capture": {
|
||||||
|
"10:51:24.736": "0x0518 reaches -3 W. The pack is at 11 %, the loop has just reversed out of -447 W of charge, and the BMS refuses the discharge.",
|
||||||
|
"10:51:29.695": "the setpoint reaches +1060 W of discharge - 800 W of travel from the command standing when the register last moved.",
|
||||||
|
"10:52:21.695": "the setpoint is written as 0 W and stays there. This is the add-on's own `inputs missing -> command 0 W`, on the wire.",
|
||||||
|
"10:51:24.736..11:05:34.734": "0x0518 does not change value. 850.0 s.",
|
||||||
|
"10:52:21.695..11:05:31.698": "the setpoint does not change value. 790.0 s.",
|
||||||
|
"~11:04": "a human restarts the add-on. NOT VISIBLE IN THIS FILE - it is an action on 192.168.2.158 - and it is the only reason the episode ends.",
|
||||||
|
"11:05:31.698": "the restarted loop commands -400 W of CHARGE, which the pack accepts at 11 %.",
|
||||||
|
"11:05:34.734": "0x0518 answers -137 W. The register was healthy throughout."
|
||||||
|
},
|
||||||
|
"not_in_this_file": [
|
||||||
|
"the grid meter - a P1 telegram on a different wire. The 2.4 kW of export during the episode is from Home Assistant history and is NOT re-derivable here.",
|
||||||
|
"anything the add-on believed: Inverter.batt, inputs_ok, log lines. Those are INFERRED by replaying the shipped modules against these series (test_freshness.py part 10).",
|
||||||
|
"the restart at ~11:04."
|
||||||
|
]
|
||||||
|
}
|
||||||
+1114
-16
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SAFETY-57 - turn a passive RS-485 capture into the three series the loop sees.
|
||||||
|
|
||||||
|
`python3 tools/decode_bus_capture.py scenarios/safety57-2026-09-21/bus-a-*.log.gz`
|
||||||
|
|
||||||
|
WHY THE RAW CAPTURE IS WHAT IS COMMITTED, AND NOT A DECODED TABLE. The other
|
||||||
|
scenario directories in this repository hold Home Assistant recorder exports -
|
||||||
|
a number per row, already interpreted. This one holds MODBUS FRAMES off the
|
||||||
|
wire, because the episode it records is one in which the add-on's own view and
|
||||||
|
the wire's disagree, and a decoded table would be this decoder's opinion with
|
||||||
|
the evidence thrown away. MUTATION.md habit 8: a committed transcript is a
|
||||||
|
check, and a check has to be re-derivable from something.
|
||||||
|
|
||||||
|
WHAT THE CAPTURE IS. `sniffer@192.168.2.167` is a passive listener on the
|
||||||
|
RS-485 pair between the ESP32 master and the GW5000S-BP. It writes nothing; it
|
||||||
|
has no address on the bus. Every line is one frame it heard:
|
||||||
|
|
||||||
|
a|10:51:29.695 ok F7 10 05 6E 00 02 04 00 03 04 24 ...
|
||||||
|
^ ^ ^ ^
|
||||||
|
| | | the frame, hex
|
||||||
|
| | CRC verdict as the capture tool computed it
|
||||||
|
| local wall clock, Europe/Brussels, no date - see `decode()`
|
||||||
|
bus label
|
||||||
|
|
||||||
|
THE FOUR FRAME SHAPES THIS FILE UNDERSTANDS, and it deliberately understands no
|
||||||
|
others - an unrecognised frame is COUNTED and dropped, never guessed at:
|
||||||
|
|
||||||
|
F7 03 <addr:2> <count:2> <crc:2> a read REQUEST
|
||||||
|
F7 03 <2*count> <data...> <crc:2> its response, matched to the request
|
||||||
|
immediately preceding it
|
||||||
|
F7 10 05 6E 00 02 04 <data:4> <crc:2> a write of the two EMS registers
|
||||||
|
F7 10 05 6E 00 02 <crc:2> the inverter's echo of that write
|
||||||
|
|
||||||
|
⚠️ THE SETPOINT IS A MODE AND A MAGNITUDE, NOT A SIGNED NUMBER, and getting
|
||||||
|
that backwards is the one way this decoder could quietly invert the whole
|
||||||
|
episode. `0x056E` is the EMS mode and `0x056F` the magnitude in watts,
|
||||||
|
unsigned. Mode 2 is charge and mode 3 is discharge, and that is READ OFF THIS
|
||||||
|
CAPTURE rather than off a datasheet: in the committed window, every
|
||||||
|
`mode 2, 2170 W` write is followed within one 10 s publish by an 0x0518
|
||||||
|
reading near **-2287 W**, and `mode 3, 1060 W` by a POSITIVE-side idle. The
|
||||||
|
add-on's own sign convention (`Inverter.standing_w()`, + = discharge) is
|
||||||
|
therefore `+magnitude` for mode 3 and `-magnitude` for mode 2, and
|
||||||
|
`test_freshness.py` part 10a grades that correspondence on the capture itself
|
||||||
|
rather than trusting this paragraph.
|
||||||
|
|
||||||
|
WHAT THIS DECODER CANNOT SEE, largest hole first (habit 13/16):
|
||||||
|
|
||||||
|
* **THE GRID METER.** It is not on this bus - it is a P1 telegram on a
|
||||||
|
different wire - so nothing here says what the site was importing or
|
||||||
|
exporting. The 2.4 kW export during the episode is from the operator's
|
||||||
|
Home Assistant history and is NOT re-derivable from this file.
|
||||||
|
* **WHAT THE ADD-ON BELIEVED.** The capture is the wire. It shows the
|
||||||
|
commands that arrived and the readings that were answered; it does not show
|
||||||
|
`Inverter.batt`, `inputs_ok`, or any log line. Everything about the add-on's
|
||||||
|
internal state in this episode is INFERRED by replaying the shipped modules
|
||||||
|
against these series, which is what part 10 does.
|
||||||
|
* **DATE AND YEAR.** The capture tool stamps a time of day and nothing else.
|
||||||
|
The date is supplied by the caller and is recorded in the scenario's
|
||||||
|
`manifest.json` beside the sha256 of the file it came from.
|
||||||
|
* **A FRAME THE CAPTURE TOOL SCORED BAD.** Lines whose CRC verdict is not
|
||||||
|
`ok` are counted and dropped. In the committed window there are none.
|
||||||
|
* **ANY OTHER REGISTER.** 0x0511 (a two-register read this master issues) is
|
||||||
|
recognised as a read and its values are returned untouched under its own
|
||||||
|
address; nothing here claims to know what it means.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# The registers this file will name. Anything else is returned by raw address.
|
||||||
|
REG_SETPOINT_MODE = 0x056E # written as a pair with 0x056F
|
||||||
|
REG_BATT_W = 0x0518 # S16, watts, + = discharge (SAFETY-37)
|
||||||
|
REG_SOC_PCT = 0x050E # U16, percent
|
||||||
|
MODE_CHARGE, MODE_DISCHARGE = 2, 3
|
||||||
|
|
||||||
|
_LINE = re.compile(r"^(\w+)\|(\d\d):(\d\d):(\d\d(?:\.\d+)?)\s+(\S+)\s+(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def signed_setpoint_w(mode: int, magnitude: int) -> float:
|
||||||
|
"""The EMS pair as `Inverter.standing_w()` spells it: + = discharge.
|
||||||
|
|
||||||
|
⚠️ AN UNKNOWN MODE RAISES rather than defaulting. A default here would turn
|
||||||
|
a firmware that started using a third mode into a silently mis-signed
|
||||||
|
series, which is the failure this whole scenario is about one layer down.
|
||||||
|
"""
|
||||||
|
if mode == MODE_DISCHARGE:
|
||||||
|
return float(magnitude)
|
||||||
|
if mode == MODE_CHARGE:
|
||||||
|
return -float(magnitude)
|
||||||
|
raise ValueError(f"unknown EMS mode {mode} at 0x{REG_SETPOINT_MODE:04X}; "
|
||||||
|
f"this decoder knows {MODE_CHARGE} (charge) and "
|
||||||
|
f"{MODE_DISCHARGE} (discharge) only")
|
||||||
|
|
||||||
|
|
||||||
|
def _s16(v: int) -> int:
|
||||||
|
return v - 65536 if v >= 32768 else v
|
||||||
|
|
||||||
|
|
||||||
|
def decode(path):
|
||||||
|
"""Decode one capture file. Returns `(events, stats)`.
|
||||||
|
|
||||||
|
`events` is a list of `(t, kind, value)` in capture order, where `t` is
|
||||||
|
SECONDS SINCE LOCAL MIDNIGHT (the capture carries no date) and `kind` is
|
||||||
|
one of `"setpoint_w"`, `"batt_w"`, `"soc_pct"`, or `"reg_0xNNNN"`.
|
||||||
|
|
||||||
|
`stats` counts every line by what became of it, so a caller can assert that
|
||||||
|
the frames it cares about were actually present rather than that the parse
|
||||||
|
produced no error.
|
||||||
|
"""
|
||||||
|
opener = gzip.open if str(path).endswith(".gz") else io.open
|
||||||
|
events = []
|
||||||
|
stats = {"lines": 0, "unparsed": 0, "bad_crc": 0, "requests": 0,
|
||||||
|
"responses": 0, "writes": 0, "write_echoes": 0, "unknown": 0}
|
||||||
|
pending = None # the read request awaiting its response
|
||||||
|
with opener(path, "rt") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
stats["lines"] += 1
|
||||||
|
m = _LINE.match(line)
|
||||||
|
if not m:
|
||||||
|
stats["unparsed"] += 1
|
||||||
|
continue
|
||||||
|
_bus, hh, mm, ss, verdict, hexs = m.groups()
|
||||||
|
if verdict != "ok":
|
||||||
|
stats["bad_crc"] += 1
|
||||||
|
continue
|
||||||
|
t = int(hh) * 3600 + int(mm) * 60 + float(ss)
|
||||||
|
try:
|
||||||
|
b = [int(x, 16) for x in hexs.split()]
|
||||||
|
except ValueError:
|
||||||
|
stats["unparsed"] += 1
|
||||||
|
continue
|
||||||
|
# --- a read request: remember it so its response can be addressed
|
||||||
|
if len(b) == 8 and b[1] == 0x03:
|
||||||
|
pending = ((b[2] << 8) | b[3], (b[4] << 8) | b[5])
|
||||||
|
stats["requests"] += 1
|
||||||
|
continue
|
||||||
|
# --- a read response, addressed by the request before it
|
||||||
|
if (b[1] == 0x03 and pending is not None and len(b) >= 5
|
||||||
|
and b[2] == 2 * pending[1] and len(b) == b[2] + 5):
|
||||||
|
data = b[3:3 + b[2]]
|
||||||
|
regs = [(data[i] << 8) | data[i + 1]
|
||||||
|
for i in range(0, len(data), 2)]
|
||||||
|
addr = pending[0]
|
||||||
|
pending = None
|
||||||
|
stats["responses"] += 1
|
||||||
|
if addr == REG_BATT_W:
|
||||||
|
events.append((t, "batt_w", float(_s16(regs[0]))))
|
||||||
|
elif addr == REG_SOC_PCT:
|
||||||
|
events.append((t, "soc_pct", float(regs[0])))
|
||||||
|
else:
|
||||||
|
events.append((t, f"reg_0x{addr:04X}", tuple(regs)))
|
||||||
|
continue
|
||||||
|
# --- a write of the EMS pair
|
||||||
|
if (len(b) > 8 and b[1] == 0x10 and b[2] == 0x05 and b[3] == 0x6E
|
||||||
|
and b[6] == 4):
|
||||||
|
data = b[7:11]
|
||||||
|
mode = (data[0] << 8) | data[1]
|
||||||
|
mag = (data[2] << 8) | data[3]
|
||||||
|
events.append((t, "setpoint_w", signed_setpoint_w(mode, mag)))
|
||||||
|
stats["writes"] += 1
|
||||||
|
continue
|
||||||
|
if len(b) == 8 and b[1] == 0x10:
|
||||||
|
stats["write_echoes"] += 1
|
||||||
|
continue
|
||||||
|
stats["unknown"] += 1
|
||||||
|
return events, stats
|
||||||
|
|
||||||
|
|
||||||
|
def series(events, kind):
|
||||||
|
"""`[(t, value)]` for one kind, in capture order."""
|
||||||
|
return [(t, v) for t, k, v in events if k == kind]
|
||||||
|
|
||||||
|
|
||||||
|
def held_at(rows, t):
|
||||||
|
"""The last value at or before `t`, or None before the first row.
|
||||||
|
|
||||||
|
This is how the add-on sees an entity: Home Assistant answers with whatever
|
||||||
|
the publisher last put there, and a value that has not been re-published is
|
||||||
|
still the value. See `freshness.py` on why that is the whole problem.
|
||||||
|
"""
|
||||||
|
out = None
|
||||||
|
for ts, v in rows:
|
||||||
|
if ts > t:
|
||||||
|
break
|
||||||
|
out = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hms(t):
|
||||||
|
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if len(argv) != 2:
|
||||||
|
print(__doc__.splitlines()[2].strip())
|
||||||
|
return 2
|
||||||
|
events, stats = decode(argv[1])
|
||||||
|
print(f"== {argv[1]}")
|
||||||
|
for k, v in stats.items():
|
||||||
|
print(f" {k:14} {v}")
|
||||||
|
for kind in ("setpoint_w", "batt_w", "soc_pct"):
|
||||||
|
rows = series(events, kind)
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
changes = [r for i, r in enumerate(rows)
|
||||||
|
if i == 0 or r[1] != rows[i - 1][1]]
|
||||||
|
print(f"== {kind}: {len(rows)} frames, {len(changes)} changes, "
|
||||||
|
f"{_hms(rows[0][0])} .. {_hms(rows[-1][0])}")
|
||||||
|
for t, v in changes:
|
||||||
|
print(f" {_hms(t)} {v:+10.1f}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""SAFETY-56's behaviour-invariance instrument. `tools/safety56_behaviour_trace.py`
|
||||||
|
|
||||||
|
SAFETY-56 rewrote three operator-facing sentences and nothing else. This is what
|
||||||
|
says so: it drives a fixed stimulus set through the SHIPPED modules and prints
|
||||||
|
two documents -
|
||||||
|
|
||||||
|
`behaviour` every verdict, every `reading()`, every `releases`, every
|
||||||
|
`inputs_ok` and every readiness row's (ok, warn). NO SENTENCE
|
||||||
|
REACHES IT, so a tree whose only change is wording must produce
|
||||||
|
this document byte for byte.
|
||||||
|
`sentences` the four operator-facing surfaces, from the same stimulus.
|
||||||
|
|
||||||
|
Run it in two trees and compare:
|
||||||
|
|
||||||
|
python3 tools/safety56_behaviour_trace.py > /tmp/after.json
|
||||||
|
python3 tools/safety56_behaviour_trace.py --compare /tmp/before.json /tmp/after.json
|
||||||
|
|
||||||
|
⚠️ THE CONTROL IS PART OF THE TOOL AND NOT A SEPARATE STEP, WHICH IS MUTATION.md
|
||||||
|
HABIT 4 IN AN INSTRUMENT. `--compare` REFUSES to report "behaviour unchanged"
|
||||||
|
unless the `sentences` halves DIFFER. Without that, comparing a tree with itself
|
||||||
|
- which this card's first run did, by archiving a branch whose edits were not
|
||||||
|
committed yet - gives a perfect result that means nothing. An identity is
|
||||||
|
evidence only when the two sides could have differed, and ARCH-02's rehearsal
|
||||||
|
harness is the worked example of what happens when nobody checks.
|
||||||
|
|
||||||
|
⚠️ IT GRADES BEHAVIOUR, NEVER WORDING. Two trees that agree here can still print
|
||||||
|
different sentences - that is the point. `test_freshness.py` part 11 grades the
|
||||||
|
sentences themselves.
|
||||||
|
|
||||||
|
⚠️ WHAT IT CANNOT SEE, with shapes (habit 13): anything outside `freshness.py`'s
|
||||||
|
verdict, `_read_unit()`'s gating and `checks()`'s rows; a roster of more than
|
||||||
|
one; the MQTT and ingress surfaces; and wall-clock behaviour, since every clock
|
||||||
|
here is the caller's monotonic argument. It is a pin on the three things this
|
||||||
|
card is forbidden to move, not a full behavioural equivalence.
|
||||||
|
|
||||||
|
⚠️ PURGE BEFORE USE on a tree you have just edited. `tools/mutation_run.sh`
|
||||||
|
does, and MUTATION.md says why `python3 -B` alone does not.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent.parent # the add-on directory
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
|
||||||
|
BOUNDS = Freshness = Controller = DEFAULTS = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
"""The add-on's modules, imported LAZILY so that `--compare` runs anywhere.
|
||||||
|
|
||||||
|
⚠️ NOT A STYLE CHOICE. `--compare` is the half a reviewer runs on a laptop,
|
||||||
|
against two JSON files, in a directory that has no `app/` in it - and a
|
||||||
|
top-level import would make the comparison impossible exactly where it is
|
||||||
|
most useful.
|
||||||
|
"""
|
||||||
|
global BOUNDS, Freshness, Controller, DEFAULTS
|
||||||
|
from app.freshness import BOUNDS, Freshness # noqa: E402,F401
|
||||||
|
from app.main import Controller # noqa: E402,F401
|
||||||
|
from app.store import DEFAULTS # noqa: E402,F401
|
||||||
|
|
||||||
|
A = 5000.0
|
||||||
|
ONE = dict(meter_source="off", meter_entity="sensor.grid",
|
||||||
|
setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||||
|
batt_entity="sensor.batt_a", max_w=A, gain=0.6, slew_w=0,
|
||||||
|
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
|
||||||
|
stale_input_s=15, inverters=[], bms_soc_floor=10)
|
||||||
|
|
||||||
|
|
||||||
|
class MemStore:
|
||||||
|
def __init__(self, d=None):
|
||||||
|
self.data = dict(DEFAULTS, **(d 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:
|
||||||
|
def __init__(self, numbers=None, limits=None):
|
||||||
|
self.numbers = dict(numbers or {})
|
||||||
|
self.limits_by = dict(limits or {})
|
||||||
|
self.served = []
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def one_site(soc=50.0):
|
||||||
|
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": soc,
|
||||||
|
"sensor.batt_a": -1200.0},
|
||||||
|
{"number.sp_a": (-A, A, 1.0)})
|
||||||
|
c = Controller(dict(ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||||
|
c.inverters[0].dev_min, c.inverters[0].dev_max = -A, A
|
||||||
|
return c, hass
|
||||||
|
|
||||||
|
|
||||||
|
async def _world(name, soc, reg_of, cmd_of, n):
|
||||||
|
c, hass = one_site(soc)
|
||||||
|
seen = []
|
||||||
|
for t in range(n):
|
||||||
|
hass.numbers["sensor.batt_a"] = reg_of(t)
|
||||||
|
c.inverters[0].last_write = cmd_of(t)
|
||||||
|
await c.read_inputs(now_mono=float(t))
|
||||||
|
seen.append([c.inverters[0].batt, c.inputs_ok,
|
||||||
|
c.inverters[0].batt_fresh.stale])
|
||||||
|
return {"name": name, "reads": seen,
|
||||||
|
"rows": [[r["ok"], r["warn"]] for r in c.checks()],
|
||||||
|
"n_rows": len(c.checks()), "events": len(c.events),
|
||||||
|
"inputs_bad": c.inputs_bad_since is not None,
|
||||||
|
"releases": [i.batt_fresh.releases for i in c.inverters]}
|
||||||
|
|
||||||
|
|
||||||
|
# The command and register schedules. Each of the conjunction's three terms is
|
||||||
|
# exercised alone and with the others, and the last two schedules take a verdict
|
||||||
|
# all the way through SAFETY-57's release and back into a re-trip.
|
||||||
|
CMDS = [lambda t: 0.0, lambda t: 1300.0, lambda t: -1200.0,
|
||||||
|
lambda t: 1300.0 if t >= 1 else -1200.0,
|
||||||
|
lambda t: 249.0 if t >= 1 else 0.0,
|
||||||
|
lambda t: 251.0 if t >= 1 else 0.0,
|
||||||
|
lambda t: 1300.0 if t < 40 else 0.0,
|
||||||
|
lambda t: 5.0 if t >= 1 else 1300.0,
|
||||||
|
lambda t: (t % 7) * 200.0 - 600.0,
|
||||||
|
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else (
|
||||||
|
0.0 if t < 90 else 1300.0),
|
||||||
|
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else 100.0]
|
||||||
|
REGS = [lambda t: -3.0, lambda t: -1200.0, lambda t: -1200.0 + (t % 3),
|
||||||
|
lambda t: None if t % 5 == 0 else -1200.0,
|
||||||
|
lambda t: 0.0, lambda t: float((t * 37) % 11) - 5.0]
|
||||||
|
|
||||||
|
WORLDS = [
|
||||||
|
("DEAD", 50.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
|
||||||
|
("REFUSED", 11.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
|
||||||
|
("ABSENT", 50.0, lambda t: None, lambda t: 0.0, 3),
|
||||||
|
("HEALTHY", 50.0, lambda t: -1200.0 + (t % 3),
|
||||||
|
lambda t: 1300.0 if t % 2 else -1200.0, 60),
|
||||||
|
("RELEASE", 50.0, lambda t: -1200.0,
|
||||||
|
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else 0.0, 60),
|
||||||
|
("RETRIP", 50.0, lambda t: -1200.0,
|
||||||
|
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else
|
||||||
|
(0.0 if t < 80 else 1300.0), 130),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def behaviour():
|
||||||
|
"""Everything this card is forbidden to move. No sentence may enter it."""
|
||||||
|
_load()
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
out = {}
|
||||||
|
|
||||||
|
# 1. the committed live capture, read by read.
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"_s56decode", HERE / "tools" / "decode_bus_capture.py")
|
||||||
|
dec = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(dec)
|
||||||
|
cap_file = (HERE / "scenarios" / "safety57-2026-09-21"
|
||||||
|
/ "bus-a-2026-09-21-1030-1110.log.gz")
|
||||||
|
events, _census = dec.decode(cap_file)
|
||||||
|
sp, bt = dec.series(events, "setpoint_w"), dec.series(events, "batt_w")
|
||||||
|
f = Freshness(BOUNDS["batt"])
|
||||||
|
t = float(int(max(sp[0][0], bt[0][0])) + 1)
|
||||||
|
t1 = int(min(sp[-1][0], bt[-1][0]))
|
||||||
|
cap = []
|
||||||
|
while t <= t1:
|
||||||
|
f.submit(dec.held_at(bt, t), dec.held_at(sp, t), t)
|
||||||
|
cap.append([t, f.stale, f.reading(), f.releases, f.held,
|
||||||
|
f.rested_since, f.graded_from, f.standing_at_grade,
|
||||||
|
f.released_at])
|
||||||
|
t += 1.0
|
||||||
|
out["capture_reads"] = len(cap)
|
||||||
|
out["capture_trace"] = hashlib.sha256(
|
||||||
|
json.dumps(cap, sort_keys=True).encode()).hexdigest()
|
||||||
|
out["capture_edges"] = [r[:4] for i, r in enumerate(cap)
|
||||||
|
if i == 0 or r[1] != cap[i - 1][1]]
|
||||||
|
|
||||||
|
# 2. the swept grid over the policy object.
|
||||||
|
sweep = []
|
||||||
|
for ci, cf in enumerate(CMDS):
|
||||||
|
for ri, rf in enumerate(REGS):
|
||||||
|
g = Freshness(BOUNDS["batt"])
|
||||||
|
row = []
|
||||||
|
for n in range(120):
|
||||||
|
g.submit(rf(n), cf(n), float(n))
|
||||||
|
row.append([g.stale, g.reading(), g.releases])
|
||||||
|
sweep.append([ci, ri, row])
|
||||||
|
out["sweep_cells"] = len(sweep)
|
||||||
|
out["sweep_trace"] = hashlib.sha256(
|
||||||
|
json.dumps(sweep, sort_keys=True).encode()).hexdigest()
|
||||||
|
out["sweep_stale_reads"] = sum(1 for _c, _r, row in sweep
|
||||||
|
for s, _v, _n in row if s)
|
||||||
|
out["sweep_missing_reads"] = sum(1 for _c, _r, row in sweep
|
||||||
|
for _s, v, _n in row if v is None)
|
||||||
|
out["sweep_releases"] = sum(row[-1][2] for _c, _r, row in sweep)
|
||||||
|
|
||||||
|
# 3. the shipped read path and every readiness row's verdict.
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
out["worlds"] = [loop.run_until_complete(_world(*w)) for w in WORLDS]
|
||||||
|
loop.close()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sentences():
|
||||||
|
"""The four operator-facing surfaces, from the REFUSED world."""
|
||||||
|
_load()
|
||||||
|
recs = []
|
||||||
|
|
||||||
|
class _H(logging.Handler):
|
||||||
|
def emit(self, record):
|
||||||
|
recs.append(record.getMessage())
|
||||||
|
|
||||||
|
logging.disable(logging.NOTSET)
|
||||||
|
log = logging.getLogger("goodwe")
|
||||||
|
log.addHandler(_H())
|
||||||
|
log.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
c, hass = one_site(11.0)
|
||||||
|
for t in range(40):
|
||||||
|
hass.numbers["sensor.batt_a"] = -3.0
|
||||||
|
c.inverters[0].last_write = 800.0 if t < 1 else 1600.0
|
||||||
|
await c.read_inputs(now_mono=float(t))
|
||||||
|
return c
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
c = loop.run_until_complete(go())
|
||||||
|
loop.close()
|
||||||
|
return {
|
||||||
|
"reason": c.inverters[0].batt_fresh.reason,
|
||||||
|
"log": [m for m in recs if "SAFETY-37" in m],
|
||||||
|
"stale_note": c.inverters[0].stale_note(),
|
||||||
|
"not_ready_row": [r["text"] for r in c.checks()
|
||||||
|
if r["text"].startswith("battery power")],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compare(a_path, b_path):
|
||||||
|
a = json.loads(Path(a_path).read_text(encoding="utf-8"))
|
||||||
|
b = json.loads(Path(b_path).read_text(encoding="utf-8"))
|
||||||
|
beh_same = a["behaviour"] == b["behaviour"]
|
||||||
|
sent_same = a["sentences"] == b["sentences"]
|
||||||
|
ha = hashlib.sha256(json.dumps(a["behaviour"], sort_keys=True).encode())
|
||||||
|
hb = hashlib.sha256(json.dumps(b["behaviour"], sort_keys=True).encode())
|
||||||
|
print(f"behaviour A {ha.hexdigest()}")
|
||||||
|
print(f"behaviour B {hb.hexdigest()}")
|
||||||
|
if sent_same:
|
||||||
|
# ⚠️ THE REFUSAL, AND IT IS THE POINT OF THE TOOL. Two runs whose
|
||||||
|
# sentences agree are either the same tree twice or a card that changed
|
||||||
|
# nothing an operator reads. Either way "behaviour unchanged" is not a
|
||||||
|
# result, and reporting it would be an identity measured against itself.
|
||||||
|
print("REFUSED: the `sentences` halves are IDENTICAL, so these two runs "
|
||||||
|
"cannot be evidence that behaviour survived a WORDING change - "
|
||||||
|
"they may be the same tree twice. Compare a tree against one "
|
||||||
|
"whose sentences differ.")
|
||||||
|
return 2
|
||||||
|
if not beh_same:
|
||||||
|
for k in sorted(set(a["behaviour"]) | set(b["behaviour"])):
|
||||||
|
if a["behaviour"].get(k) != b["behaviour"].get(k):
|
||||||
|
print(f"BEHAVIOUR MOVED at {k!r}")
|
||||||
|
return 1
|
||||||
|
print("OK: sentences differ, behaviour is byte-identical.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) == 4 and sys.argv[1] == "--compare":
|
||||||
|
sys.exit(compare(sys.argv[2], sys.argv[3]))
|
||||||
|
print(json.dumps({"behaviour": behaviour(), "sentences": sentences()},
|
||||||
|
sort_keys=True, indent=1))
|
||||||
Reference in New Issue
Block a user