Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/build_guide.py its single source - rebuilds the HTML and PDF
|
||||
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,
|
||||
|
||||
+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
|
||||
|
||||
## 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
|
||||
|
||||
⚠️ **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
|
||||
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 -
|
||||
THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
|
||||
⚠️ **AND 0.10.0 CHANGES THE CONTROL LAW ITSELF, WHICH NOTHING ABOVE AND NO
|
||||
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
|
||||
**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
|
||||
@@ -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
|
||||
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 \
|
||||
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
|
||||
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
|
||||
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?` -
|
||||
@@ -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
|
||||
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`):
|
||||
|
||||
| | |
|
||||
@@ -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** |
|
||||
| `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** |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
# 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.
|
||||
|
||||
**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,
|
||||
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
|
||||
STOPPED**, including a dead publisher whose last two samples happen to
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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`
|
||||
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.
|
||||
|
||||
|
||||
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
|
||||
@@ -211,6 +329,19 @@ BATT_UNCHANGED_S = 35.0
|
||||
# and goes red if the two drift.
|
||||
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)
|
||||
class Bound:
|
||||
@@ -219,6 +350,7 @@ class Bound:
|
||||
signal: str
|
||||
unchanged_s: float
|
||||
move_w: float
|
||||
release_s: float
|
||||
wired: bool
|
||||
derivation: str
|
||||
|
||||
@@ -233,13 +365,19 @@ BOUNDS = {
|
||||
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
|
||||
unchanged_s=BATT_UNCHANGED_S,
|
||||
move_w=MOVE_W,
|
||||
release_s=RELEASE_S,
|
||||
wired=True,
|
||||
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
|
||||
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
|
||||
"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(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
move_w=float("inf"),
|
||||
release_s=float("inf"),
|
||||
wired=False,
|
||||
derivation="TEL-08. Command travel is the WRONG obligation term for "
|
||||
"SoC - a rested pack holds a percent for hours under a 0 W "
|
||||
@@ -263,10 +401,33 @@ class Freshness:
|
||||
Declaring the reading stale makes `Controller.inputs_ok` False, which
|
||||
commands 0 W (§11.1 rule 1), which drops the standing command below
|
||||
`MOVE_W`, which un-arms the conjunction, which would return the held number
|
||||
to the consumers - and round again every read. So the ONLY thing that clears
|
||||
the latch is an arrival carrying a DIFFERENT number, which is exactly the
|
||||
evidence the verdict was about. A register that is working clears it on its
|
||||
next 10 s publish; a register that is not, does not.
|
||||
to the consumers - and round again every read. So an arrival carrying a
|
||||
DIFFERENT number clears the latch, which is exactly the evidence the verdict
|
||||
was about. A register that is working clears it on its next 10 s publish; a
|
||||
register that is not, does not.
|
||||
|
||||
⚠️ 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):
|
||||
@@ -281,10 +442,20 @@ class Freshness:
|
||||
# ability to fetch it.
|
||||
self.held = None
|
||||
self.changed_mono = None
|
||||
# The standing command at that same instant. The conjunction compares
|
||||
# against THIS, not against the previous cycle's command: what matters
|
||||
# is whether the register has followed anything since it last moved.
|
||||
self.standing_at_change = None
|
||||
# SAFETY-57. The window the VERDICT is graded over, as opposed to the
|
||||
# register's own history above, and the standing command at the instant
|
||||
# it opened. The conjunction compares against THIS, not against the
|
||||
# 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.reason = ""
|
||||
self.arrivals = 0
|
||||
@@ -311,20 +482,67 @@ class Freshness:
|
||||
if self.changed_mono is None or value != self.held:
|
||||
self.held = value
|
||||
self.changed_mono = float(now_mono)
|
||||
self.standing_at_change = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# Movement is the ONLY thing that clears the latch. See the class
|
||||
# docstring on the 1 Hz oscillation this prevents.
|
||||
self._open_window(standing_w, now_mono)
|
||||
# Movement clears the latch, because it is exactly the evidence the
|
||||
# verdict was about. See the class docstring on the 1 Hz oscillation
|
||||
# the latch prevents, and `_release()` for the other way out.
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
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)
|
||||
|
||||
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:
|
||||
"""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
|
||||
held_s = self.unchanged_s(now_mono)
|
||||
held_s = self.graded_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
@@ -333,30 +551,64 @@ class Freshness:
|
||||
# consumer today. They are here for TEL-08, whose obligation term is an
|
||||
# energy integral that has nothing to say until it has a window - and
|
||||
# "nothing to say" must withhold the verdict, not manufacture one.
|
||||
if standing_w is None or self.standing_at_change is None:
|
||||
if standing_w is None or self.standing_at_grade is None:
|
||||
return
|
||||
standing_w = float(standing_w)
|
||||
if abs(standing_w) < self.bound.move_w:
|
||||
return
|
||||
moved_w = abs(standing_w - self.standing_at_change)
|
||||
moved_w = abs(standing_w - self.standing_at_grade)
|
||||
if moved_w < self.bound.move_w:
|
||||
return
|
||||
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)
|
||||
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"{standing_w:+.0f} W - the reading is not following, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
# ⚠️ 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:
|
||||
"""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:
|
||||
return None
|
||||
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):
|
||||
"""What the consumers get: the value, or None when it cannot be trusted.
|
||||
|
||||
@@ -366,6 +618,13 @@ class Freshness:
|
||||
rather than accumulating it, and `circulation.circulating_w` returns None
|
||||
for a roster with a missing term, which clears the circulation window
|
||||
too. This module is the thing that was missing between them.
|
||||
|
||||
⚠️ 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
|
||||
|
||||
@@ -373,6 +632,26 @@ class Freshness:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
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:
|
||||
"""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)
|
||||
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
was_stale = inv.batt_fresh.stale
|
||||
was_releases = inv.batt_fresh.releases
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
# ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input,
|
||||
# not a measurement - `inputs_ok` sees the None, the site holds for
|
||||
@@ -1777,11 +1778,37 @@ class Controller:
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
|
||||
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
|
||||
# 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.
|
||||
# ⚠️ THE DIAGNOSIS HALF IS SAFETY-56'S AND IS DELIBERATELY UNTOUCHED
|
||||
# HERE - "is not following its own command" is wrong in a different
|
||||
# way (a pack the BMS is refusing is not a faulty register) and that
|
||||
# card is rewriting it. Two cards editing one sentence is how one of
|
||||
# them silently loses.
|
||||
_LOG.error("SAFETY-37: %s battery-power register is not following "
|
||||
"its own command - %s. Treating it as MISSING, which "
|
||||
"commands 0 W. It clears itself the moment the register "
|
||||
"reports a different value.",
|
||||
inv.name, inv.batt_fresh.reason)
|
||||
"reports a different value, and failing that %.0f s "
|
||||
"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
|
||||
def shadow(self) -> bool:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.10.0"
|
||||
version: "0.10.1"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
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."
|
||||
]
|
||||
}
|
||||
@@ -49,10 +49,14 @@ before you report a survivor.
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import glob
|
||||
import gzip
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -500,11 +504,13 @@ for t in range(40, 50):
|
||||
_after_zero.append(_lat.reading())
|
||||
check("PRECONDITION: it is stale before the site is stopped",
|
||||
_was and _lat.reading() is None)
|
||||
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W. The conjunction is no "
|
||||
"longer armed - |standing| is 0 W, below the 250 W term - so an unlatched "
|
||||
"verdict would hand the held -1200 W straight back and the site would "
|
||||
"restart into it, then stop, then restart, once per read",
|
||||
all(v is None for v in _after_zero))
|
||||
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W, for RELEASE_S. The "
|
||||
"conjunction is no longer armed - |standing| is 0 W, below the 250 W "
|
||||
"term - so an unlatched verdict would hand the held -1200 W straight "
|
||||
"back and the site would restart into it, then stop, then restart, once "
|
||||
"per read. The ten reads here are inside the 17.0 s SAFETY-57 waits",
|
||||
all(v is None for v in _after_zero) and len(_after_zero) == 10
|
||||
and 10 < F.RELEASE_S)
|
||||
# ⚠️ AND A FAILED READ MUST NOT CLEAR IT EITHER. This check exists because the
|
||||
# mutation that added `self.stale = False` to `submit()`'s None branch SURVIVED
|
||||
# the whole sweep - no fixture had ever injected a read failure while latched.
|
||||
@@ -526,11 +532,26 @@ check("⚠️ A FAILED READ DOES NOT CLEAR THE LATCH - found by a SURVIVING "
|
||||
_after_fail is None and _lat_fail.stale
|
||||
and _lat_fail.reading() is None)
|
||||
|
||||
check("⚠️ AND ONLY MOVEMENT CLEARS IT - a different number arriving, which is "
|
||||
"exactly the evidence the verdict was about. Not a timer, not a restart, "
|
||||
"not the operator's start button",
|
||||
(lambda f: (f.submit(A_FROZEN_W + 1.0, 0.0, 60.0),
|
||||
(not f.stale) and f.reading() == A_FROZEN_W + 1.0)[-1])(_lat))
|
||||
# ⚠️ THIS CHECK USED TO READ "AND ONLY MOVEMENT CLEARS IT ... Not a timer, not
|
||||
# a restart, not the operator's start button", AND SAFETY-57 IS THE CARD THAT
|
||||
# FALSIFIED IT. It was driven on `_lat` - already 20 s into a 0 W rest - so
|
||||
# under the shipped exit it would now pass having never tested movement at all,
|
||||
# which is habit 9: a check that goes on being true for a reason it does not
|
||||
# name. It is rebuilt here on a FRESH object that has never rested, so the only
|
||||
# thing that can clear it is the arrival.
|
||||
_mov = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_mov.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_mov_was = _mov.stale
|
||||
_mov.submit(A_FROZEN_W + 1.0, 1300.0, 40.0) # the register moves one watt
|
||||
check("⚠️ MOVEMENT CLEARS IT WHILE THE OBLIGATION IS STILL STANDING - a "
|
||||
"different number arriving under a command that has NOT rested, which is "
|
||||
"exactly the evidence the verdict was about. Not a restart, not the "
|
||||
"operator's start button. SAFETY-57's timer cannot be what cleared this "
|
||||
"one: the command was +1300 W on every read, so the release clock never "
|
||||
"started",
|
||||
_mov_was and not _mov.stale and _mov.reading() == A_FROZEN_W + 1.0
|
||||
and _mov.releases == 0 and _mov.rested_since is None)
|
||||
# ⚠️ THE MAGNITUDE TERM OF THE CONJUNCTION, AND THIS CHECK EXISTS BECAUSE A
|
||||
# MUTATION SURVIVED WITHOUT IT. Deleting `abs(standing_w) < move_w` from
|
||||
# `_grade()` left every other check in this file green, because in all of them a
|
||||
@@ -542,12 +563,12 @@ _rest = Freshness(BOUNDS["batt"])
|
||||
# BEFORE the command settles - and then the site is commanded to 0 W and the
|
||||
# register sits at +5 W, correctly, for a minute.
|
||||
_rest.submit(1290.0, 1300.0, 0.0)
|
||||
_rest.submit(5.0, 1300.0, 1.0) # standing_at_change captures 1300 W
|
||||
_rest.submit(5.0, 1300.0, 1.0) # standing_at_grade captures 1300 W
|
||||
for t in range(2, 60):
|
||||
_rest.submit(5.0, 0.0, float(t)) # site at rest; register truthfully +5 W
|
||||
check("⚠️ THE MAGNITUDE TERM IS LOAD-BEARING, and it took a surviving mutation "
|
||||
"to show it. A register that reached +5 W while the old +1300 W command "
|
||||
"was still standing has a `standing_at_change` of 1300 W, so the TRAVEL "
|
||||
"was still standing has a `standing_at_grade` of 1300 W, so the TRAVEL "
|
||||
"term alone reads 1300 W of unfollowed command for the whole minute the "
|
||||
"site rests - and calls a perfectly truthful reading dead. Requiring "
|
||||
"|standing| >= 250 W as well is what says 'a site at rest is not owed "
|
||||
@@ -564,7 +585,7 @@ check("...and the hole that term leaves CANNOT MISLEAD EITHER CONSUMER, which "
|
||||
check("...and one watt is enough, because 0x0518 is a 1 W register that "
|
||||
"dithers +/-1 W while holding (goodwe/NOTES.md §5.11, measured "
|
||||
"2026-08-22) - a working register clears this on its next 10 s publish",
|
||||
_lat.held == A_FROZEN_W + 1.0 and not _lat.stale)
|
||||
_mov.held == A_FROZEN_W + 1.0 and not _mov.stale)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -655,6 +676,7 @@ _unit_min_w = option_default("unit_sign_disagree_w")
|
||||
_ceiling = min(_circ_hold, _unit_hold)
|
||||
print(f" floor {BATT_OBLIGED_S:.1f} s = settle 6.0 + publish 10.0 + read 1.0")
|
||||
print(f" bound {BATT_UNCHANGED_S:.1f} s")
|
||||
print(f" exit {F.RELEASE_S:.1f} s (SAFETY-57, the same obligation window)")
|
||||
print(f" ceil {_ceiling:.1f} s = min(circulation_hold_s {_circ_hold:.0f}, "
|
||||
f"unit_sign_disagree_s {_unit_hold:.0f})")
|
||||
|
||||
@@ -690,6 +712,24 @@ check("⚠️ AND `MOVE_W` IS PINNED TO config.yaml TOO. It is the largest value
|
||||
"raise `unit_sign_disagree_w` and the gate would stop arming in states "
|
||||
"that check still fires in, which this catches",
|
||||
MOVE_W == _unit_min_w)
|
||||
check("⚠️ AND SAFETY-57'S EXIT SITS INSIDE THAT SAME BAND, AT ITS FLOOR - the "
|
||||
"mirror of MOVE_W taking its own band's ceiling. The question a release "
|
||||
"answers is 'has the register had its chance to answer the command "
|
||||
"standing now?', which is the obligation window and nothing else, so the "
|
||||
"exit is exactly the floor and strictly shorter than the bound whose "
|
||||
"verdict it withdraws. An exit at or above the bound would mean the site "
|
||||
"spends longer parked than the evidence that parked it took to gather",
|
||||
F.RELEASE_S == BATT_OBLIGED_S and F.RELEASE_S < BATT_UNCHANGED_S
|
||||
and BOUNDS["batt"].release_s == F.RELEASE_S)
|
||||
check("⚠️ AND THE WHOLE PARKED INTERVAL IS NOW BOUNDED BY TWO SHIPPED NUMBERS "
|
||||
"AND NOTHING ELSE: `stale_input_s` (15 s, config.yaml) then `release_s` "
|
||||
"(17.0 s) = 32 s. The schema caps `stale_input_s` at 120, so even the "
|
||||
"worst accepted configuration parks for 137 s - where the live episode "
|
||||
"parked for 790 s and had no upper bound at all",
|
||||
float(option_default("stale_input_s")) + F.RELEASE_S == 32.0
|
||||
and "stale_input_s: int(5,120)" in io.open(
|
||||
HERE / "config.yaml", encoding="utf-8").read()
|
||||
and 120.0 + F.RELEASE_S == 137.0)
|
||||
check("...and the module's three floor terms are the numbers this repository "
|
||||
"actually carries, not a transcription of them: the ESPHome publish "
|
||||
"interval is read back out of the shipped firmware",
|
||||
@@ -734,7 +774,7 @@ _uses_batt = bounds_users("batt")
|
||||
_uses_soc = bounds_users("soc")
|
||||
for _k, _b in BOUNDS.items():
|
||||
print(f" {_k:5} wired={_b.wired!s:5} unchanged={_b.unchanged_s:>6} "
|
||||
f"move={_b.move_w:>6} {_b.signal}")
|
||||
f"move={_b.move_w:>6} release={_b.release_s:>6} {_b.signal}")
|
||||
print(f" BOUNDS['batt'] subscripted in CODE by: {_uses_batt}")
|
||||
print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}")
|
||||
|
||||
@@ -752,10 +792,17 @@ check("...and the SoC row says what TEL-08 owes, in the row itself, so the "
|
||||
"energy" in BOUNDS["soc"].derivation
|
||||
and "153.6 Wh" in BOUNDS["soc"].derivation
|
||||
and "TEL-08" in BOUNDS["soc"].derivation)
|
||||
check("...and every wired bound is a real number - an infinite bound is how a "
|
||||
"row silently becomes decorative",
|
||||
check("...and every wired bound is a real number IN ALL THREE COLUMNS - an "
|
||||
"infinite bound is how a row silently becomes decorative, and SAFETY-57's "
|
||||
"`release_s` is the newest place that can happen: at `inf` the module is "
|
||||
"byte-for-byte the one that parked the live site for 790 s",
|
||||
all(b.unchanged_s < float("inf") and b.move_w < float("inf")
|
||||
and b.release_s < float("inf")
|
||||
for b in BOUNDS.values() if b.wired))
|
||||
check("...and the UNWIRED row is infinite in all three, so nothing about "
|
||||
"SoC's absent bound can be read as a policy that exists",
|
||||
all(b.unchanged_s == b.move_w == b.release_s == float("inf")
|
||||
for b in BOUNDS.values() if not b.wired))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -809,6 +856,96 @@ check("⚠️ AND THE LONG RUNS ARE ALL UNDER AN UNMOVING LOAD, which is what th
|
||||
max(_runs)[1] == -899.0)
|
||||
|
||||
|
||||
# --- 8b. AND THE EXIT THE BOUND USED TO HAVE, ON THE SAME HOUSE -------------
|
||||
# ⚠️ THIS IS THE MEASUREMENT SAFETY-57 RESTS ON, and it is about the REGISTER
|
||||
# rather than about any code: before this card the only way out of the verdict
|
||||
# was "an arrival carrying a different number", so the length of the state IS
|
||||
# the time to 0x0518's next change. The committed 16-day loop capture is
|
||||
# replayed at 1 Hz through the SHIPPED conjunction to find the trips, and each
|
||||
# trip is then measured against the register's own next move.
|
||||
#
|
||||
# ⚠️ WHAT THIS 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 only the one that does not depend on the
|
||||
# command at all: how long this register goes without changing value. If
|
||||
# anything 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.
|
||||
_LOOPDIR = HERE / "scenarios" / "loop-2026-09-02_2026-09-17"
|
||||
_loop_days = sorted({Path(f).name.rsplit("-", 1)[0]
|
||||
for f in glob.glob(str(_LOOPDIR / "*-batt.jsonl.gz"))})
|
||||
|
||||
|
||||
def _loop_series(day, sig):
|
||||
out = []
|
||||
with gzip.open(_LOOPDIR / f"{day}-{sig}.jsonl.gz", "rt") as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
try:
|
||||
v = float(row["v"])
|
||||
except ValueError:
|
||||
continue # 'unavailable' / 'unknown' are not readings
|
||||
out.append((datetime.fromisoformat(row["t"]).timestamp(), v))
|
||||
return out
|
||||
|
||||
|
||||
_t8b = time.monotonic()
|
||||
_waits, _trips8b = [], 0
|
||||
for _day in _loop_days:
|
||||
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
|
||||
if not _b or not _s:
|
||||
continue
|
||||
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
|
||||
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
|
||||
_ib = _is = 0
|
||||
_t = max(_b[0][0], _s[0][0])
|
||||
_end = min(_b[-1][0], _s[-1][0])
|
||||
while _t <= _end:
|
||||
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
|
||||
_ib += 1
|
||||
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
|
||||
_is += 1
|
||||
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
|
||||
if _f8b.stale and not _prev8b:
|
||||
_trips8b += 1
|
||||
_nxt = next((m for m in _moves if m > _t), None)
|
||||
if _nxt is not None:
|
||||
_waits.append(_nxt - _t)
|
||||
_prev8b = _f8b.stale
|
||||
_t += 1.0
|
||||
_waits.sort()
|
||||
_p50 = _waits[len(_waits) // 2]
|
||||
print(f" {len(_loop_days)} days replayed at 1 Hz in "
|
||||
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
|
||||
print(f" wait for the register's next change after a trip: "
|
||||
f"min {_waits[0]:.1f} s median {_p50:.1f} s max {_waits[-1]:.1f} s; "
|
||||
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
|
||||
f"710 s")
|
||||
|
||||
check("PRECONDITION: all sixteen committed days were replayed, not whichever "
|
||||
"files happened to be present",
|
||||
len(_loop_days) == 16 and _loop_days[0] == "2026-09-02"
|
||||
and _loop_days[-1] == "2026-09-17" and len(_waits) == _trips8b)
|
||||
check("⚠️ THE OLD EXIT WAS NOT RARE AND IT WAS NOT SHORT. Ninety-three trips in "
|
||||
"sixteen days of this house, and the only thing that could clear any of "
|
||||
"them - the register carrying a different number - took a MEDIAN of "
|
||||
"143.4 s and as long as 21440.1 s. Seventeen of the ninety-three are at "
|
||||
"or beyond the 710 s the live site was parked for on 2026-09-21",
|
||||
_trips8b == 93 and abs(_p50 - 143.4) < 0.1
|
||||
and abs(_waits[-1] - 21440.1) < 0.1
|
||||
and sum(1 for w in _waits if w >= 710.0) == 17)
|
||||
check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE - "
|
||||
"which is the whole reason the exit could not stay as it was. The short "
|
||||
"waits are the pack's keep-alive pulse twitching 0x0518 and satisfying "
|
||||
"the clearing rule BY ACCIDENT: a quarter of them are under 20 s, and "
|
||||
"nothing in this add-on asked for any of them. After SAFETY-57 the wait "
|
||||
"is `stale_input_s + release_s` = 32 s whatever the register does",
|
||||
sum(1 for w in _waits if w < 20.0) >= 20
|
||||
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. KNOWN SURVIVORS - graded, so they are residuals and not surprises")
|
||||
@@ -871,6 +1008,627 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
|
||||
"each unit's own command travel and each unit's own number",
|
||||
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
|
||||
|
||||
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card.
|
||||
# The unchanged clock keeps running while the site rests, so the FIRST read
|
||||
# at which a command travels 250 W after a long quiet already satisfies all
|
||||
# three terms - the register is given no time at all to answer the new
|
||||
# command. Present on the shipped tree; the live capture in part 10 contains
|
||||
# one (10:48:14, cleared one second later).
|
||||
_s5 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 300): # five minutes of a resting site
|
||||
_s5.submit(-3.0, 0.0, float(t))
|
||||
_s5_quiet = _s5.stale
|
||||
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
|
||||
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a "
|
||||
"long rest the unchanged clock is already past 35 s, so the very FIRST "
|
||||
"read carrying a travelled command trips the gate - before the 17.0 s in "
|
||||
"which a healthy register could have answered it. The gate accuses on "
|
||||
"read one and the register answers on read eleven",
|
||||
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0)
|
||||
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded "
|
||||
"here rather than fixed here: `cycle()` returns at `inputs missing "
|
||||
"{n}s` for the whole of it, so nothing is commanded differently, and the "
|
||||
"register's next publish clears it. 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",
|
||||
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5)
|
||||
and float(option_default("stale_input_s")) >= 5.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("10. SAFETY-57: THE EXIT. The state the verdict creates, and the way out")
|
||||
|
||||
# ⚠️ THE ACCEPTANCE CRITERION OF THIS CARD, IN ONE SENTENCE, AND EVERY CHECK
|
||||
# BELOW IS GRADED AGAINST IT: a scenario in which the register is held CONSTANT
|
||||
# and the command is gated must recover WITH NO CHANGE TO THE REGISTER. Parts 1
|
||||
# to 9 grade whether the gate can be ENTERED. A check that only shows a latch
|
||||
# latching is decoration for this card.
|
||||
#
|
||||
# ⚠️ THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS NOT A TRANSCRIPTION.
|
||||
# `_release()` with an infinite bound never fires, `_open_window()` is then only
|
||||
# ever called where the register moves, and `graded_from`/`standing_at_grade`
|
||||
# are `changed_mono`/the command at that change - which is byte-for-byte the
|
||||
# pre-SAFETY-57 conjunction. That equivalence is ASSERTED in 10b against part
|
||||
# 1's own fixtures rather than claimed here, because a counterfactual nobody
|
||||
# graded is a second implementation.
|
||||
_BEFORE = dataclasses.replace(BOUNDS["batt"], release_s=float("inf"))
|
||||
|
||||
|
||||
def _decoder():
|
||||
"""The committed decoder, loaded from `tools/` rather than transcribed."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_s57decode", HERE / "tools" / "decode_bus_capture.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def hms(t):
|
||||
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{int(t % 60):02d}"
|
||||
|
||||
|
||||
def _raises(fn):
|
||||
try:
|
||||
fn()
|
||||
except ValueError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# `stale_input_s` is READ from the shipped config.yaml, not typed here: the
|
||||
# parked half of every period below is that option, and a check that carried its
|
||||
# own copy would go on being true after an operator moved it.
|
||||
_S57_HOLD = float(option_default("stale_input_s"))
|
||||
|
||||
|
||||
def _dead_site(bound, reads=1201, dead_w=A_FROZEN_W, hold_s=None):
|
||||
"""A register frozen for good, with §11.1 rule 1 driving the command.
|
||||
|
||||
Returns `(freshness, exposure)` where `exposure[i]` is True when read `i`
|
||||
handed the consumers a number. The command rule is the one `cycle()` uses -
|
||||
hold the standing command for `stale_input_s`, then 0 W - and the loop's
|
||||
own ask when telemetry is available; it is written here rather than driven
|
||||
through `cycle()` because that hold runs on the WALL clock and the bound
|
||||
under test runs on the injected one. 10g drives the real path for one
|
||||
period instead.
|
||||
"""
|
||||
hold_s = _S57_HOLD if hold_s is None else hold_s
|
||||
f = Freshness(bound)
|
||||
standing, bad_since, exposure = dead_w, None, []
|
||||
for t in range(0, reads):
|
||||
f.submit(dead_w, standing, float(t))
|
||||
live = f.reading() is not None
|
||||
exposure.append(live)
|
||||
if live:
|
||||
bad_since = None
|
||||
standing = 1300.0 if t >= 1 else dead_w
|
||||
else:
|
||||
bad_since = t if bad_since is None else bad_since
|
||||
standing = standing if t - bad_since < hold_s else 0.0
|
||||
return f, exposure
|
||||
|
||||
|
||||
def _run_lengths(exposure):
|
||||
"""The lengths of the unbroken runs of True in `exposure`."""
|
||||
out, n = [], 0
|
||||
for live in exposure:
|
||||
if live:
|
||||
n += 1
|
||||
elif n:
|
||||
out.append(float(n))
|
||||
n = 0
|
||||
if n:
|
||||
out.append(float(n))
|
||||
return out
|
||||
|
||||
|
||||
def _release_instant(release_s):
|
||||
"""Seconds after the command rests at which the verdict is withdrawn.
|
||||
|
||||
One stimulus, one varied term (habit 7): the same 40 s of unfollowed
|
||||
+1300 W command, then a resting site, under each candidate bound.
|
||||
"""
|
||||
f = Freshness(dataclasses.replace(BOUNDS["batt"], release_s=release_s))
|
||||
for t in range(0, 40):
|
||||
f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 200):
|
||||
f.submit(A_FROZEN_W, 0.0, float(t))
|
||||
if not f.stale:
|
||||
return float(t) - 40.0
|
||||
return None
|
||||
|
||||
|
||||
# --- 10a. the capture, and what it is allowed to be evidence of --------------
|
||||
_D = _decoder()
|
||||
_CAPDIR = HERE / "scenarios" / "safety57-2026-09-21"
|
||||
_CAPFILE = _CAPDIR / "bus-a-2026-09-21-1030-1110.log.gz"
|
||||
_man = json.loads(io.open(_CAPDIR / "manifest.json", encoding="utf-8").read())
|
||||
_events, _census = _D.decode(_CAPFILE)
|
||||
_sp = _D.series(_events, "setpoint_w")
|
||||
_bt = _D.series(_events, "batt_w")
|
||||
_soc = _D.series(_events, "soc_pct")
|
||||
print(f" capture {_CAPFILE.name}: {_census}")
|
||||
print(f" setpoint {len(_sp)} frames batt {len(_bt)} frames "
|
||||
f"soc {len(_soc)} frames, "
|
||||
f"{hms(_sp[0][0])}..{hms(_sp[-1][0])} Europe/Brussels")
|
||||
|
||||
check("PRECONDITION: the committed capture is the file the manifest names, "
|
||||
"byte for byte. A scenario whose bytes can drift from the numbers quoted "
|
||||
"off them is a story",
|
||||
hashlib.sha256(io.open(_CAPFILE, "rb").read()).hexdigest()
|
||||
== _man["file"]["sha256_gz"]
|
||||
and _man["file"]["bytes_gz"] == _CAPFILE.stat().st_size)
|
||||
check("PRECONDITION: every line of it was understood. 3840 frames, and the "
|
||||
"decoder's own census reports ZERO unparsed, ZERO bad CRC and ZERO "
|
||||
"unknown shapes - so nothing in the episode below was dropped on the "
|
||||
"floor by the instrument",
|
||||
_census["lines"] == 3840 and _census["unparsed"] == 0
|
||||
and _census["bad_crc"] == 0 and _census["unknown"] == 0
|
||||
and _census["writes"] == 1200 and _census["responses"] == 720)
|
||||
# ⚠️ THE SIGN IS THE ONE THING THIS DECODER COULD GET EXACTLY BACKWARDS, and a
|
||||
# backwards sign would invert the whole episode - a discharge the BMS refused
|
||||
# would read as a charge it accepted. So the mode/sign correspondence is GRADED
|
||||
# OFF THE CAPTURE rather than trusted: every charge command is followed by a
|
||||
# negative register reading, and there is no reading of the other sign.
|
||||
_after_charge = [
|
||||
_D.held_at(_bt, t + F.BATT_OBLIGED_S)
|
||||
for t, v in _sp if v <= -1000.0]
|
||||
_after_charge = [v for v in _after_charge if v is not None]
|
||||
print(f" readings {F.BATT_OBLIGED_S:.0f} s after a <= -1000 W command: "
|
||||
f"{len(_after_charge)} of them, "
|
||||
f"max {max(_after_charge):+.0f} W, min {min(_after_charge):+.0f} W")
|
||||
check("⚠️ THE DECODER'S SIGN CONVENTION IS GRADED, NOT ASSERTED. EMS mode 2 is "
|
||||
"read as charge and mode 3 as discharge; if that were backwards the "
|
||||
"episode would read as a refused CHARGE. Every command at or under "
|
||||
"-1000 W in this capture is answered, one obligation window later, by a "
|
||||
"NEGATIVE register reading - and not one positive one",
|
||||
len(_after_charge) > 200 and max(_after_charge) < 0.0
|
||||
and _D.signed_setpoint_w(2, 2170) == -2170.0
|
||||
and _D.signed_setpoint_w(3, 1060) == +1060.0)
|
||||
check("...and an EMS mode this decoder has never seen RAISES rather than "
|
||||
"defaulting to a sign. A silent default is how a firmware change becomes "
|
||||
"a mis-signed series nobody re-derives",
|
||||
_raises(lambda: _D.signed_setpoint_w(1, 500))
|
||||
and _raises(lambda: _D.signed_setpoint_w(0, 0)))
|
||||
|
||||
|
||||
# --- 10b. THE LIVE EPISODE, RED BEFORE AND GREEN AFTER -----------------------
|
||||
def _replay_capture(bound):
|
||||
"""The shipped `_read_unit` loop, at 1 Hz, against the capture.
|
||||
|
||||
⚠️ THE COMMAND COLUMN IS NOT A MODEL. The capture was taken off a box
|
||||
running 0.10.0, i.e. running the defect: the 0 W at 10:52:21 IS the site's
|
||||
own `inputs missing -> command 0 W`, recorded on the wire. So this replay is
|
||||
closed-loop for the whole of the parked interval, and the only thing in the
|
||||
window it cannot account for is the human restart at ~11:04.
|
||||
"""
|
||||
f = Freshness(bound)
|
||||
t0 = int(max(_sp[0][0], _bt[0][0])) + 1
|
||||
t1 = int(min(_sp[-1][0], _bt[-1][0]))
|
||||
rows, t = [], float(t0)
|
||||
while t <= t1:
|
||||
f.submit(_D.held_at(_bt, t), _D.held_at(_sp, t), t)
|
||||
rows.append((t, f.stale, f.held, _D.held_at(_sp, t), f.releases))
|
||||
t += 1.0
|
||||
return f, rows
|
||||
|
||||
|
||||
def _edges(rows):
|
||||
return [r for i, r in enumerate(rows) if i == 0 or r[1] != rows[i - 1][1]]
|
||||
|
||||
|
||||
_f_before, _rows_before = _replay_capture(_BEFORE)
|
||||
_f_after, _rows_after = _replay_capture(BOUNDS["batt"])
|
||||
|
||||
# ⚠️ THE EPISODE'S BOUNDARIES ARE READ OFF THE CAPTURE, NOT TYPED. It is
|
||||
# defined by the thing it is about - the register's LONGEST unbroken hold in
|
||||
# the window - and every other instant is derived from that one. A fixture that
|
||||
# named 10:52:00 by hand would go on naming it after the file changed.
|
||||
# ⚠️ CHANGE POINTS, NOT FRAMES. 0x0518 is POLLED every 10 s and answers every
|
||||
# poll, so consecutive frames are always 10 s apart whatever the register is
|
||||
# doing - a gap computed over frames measures the poll period and nothing else,
|
||||
# which is `freshness.py`'s own first paragraph in miniature.
|
||||
_bt_moves = [r for i, r in enumerate(_bt) if i == 0 or r[1] != _bt[i - 1][1]]
|
||||
_holds = [(_bt_moves[i + 1][0] - _bt_moves[i][0], _bt_moves[i][0],
|
||||
_bt_moves[i + 1][0], _bt_moves[i][1])
|
||||
for i in range(len(_bt_moves) - 1)]
|
||||
_HOLD_S, _HOLD_FROM, _REG_MOVES, _HOLD_W = max(_holds)
|
||||
_TRIP = next(t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM)
|
||||
_PARK = next(t for t, v in _sp if t > _TRIP and v == 0.0)
|
||||
_HUMAN = next(t for t, v in _sp if t > _PARK and v != 0.0)
|
||||
_blips = [t for t, s, _h, _sw, _r in _edges(_rows_after)
|
||||
if s and t < _HOLD_FROM]
|
||||
print(f" longest register hold {_HOLD_S:.1f} s at {_HOLD_W:+.0f} W, "
|
||||
f"{hms(_HOLD_FROM)}..{hms(_REG_MOVES)}")
|
||||
print(f" trip {hms(_TRIP)} site commands 0 W {hms(_PARK)} "
|
||||
f"human restart's first command {hms(_HUMAN)} "
|
||||
f"register moves {hms(_REG_MOVES)}")
|
||||
print(f" earlier one-read trips in this window (survivor 5): "
|
||||
f"{[hms(t) for t in _blips]}")
|
||||
|
||||
|
||||
def _first_clear_after(rows, t_from):
|
||||
return next((t for t, s, _h, _sw, _r in rows if t > t_from and not s), None)
|
||||
|
||||
|
||||
_clear_before = _first_clear_after(_rows_before, _TRIP)
|
||||
_clear_after = _first_clear_after(_rows_after, _TRIP)
|
||||
print(f" BEFORE (release_s=inf): stale {hms(_TRIP)} -> clear "
|
||||
f"{hms(_clear_before)} {_clear_before - _TRIP:.0f} s")
|
||||
print(f" AFTER (release_s={BOUNDS['batt'].release_s:.0f}): stale "
|
||||
f"{hms(_TRIP)} -> clear {hms(_clear_after)} "
|
||||
f"{_clear_after - _TRIP:.0f} s")
|
||||
|
||||
check("PRECONDITION: `release_s=inf` REPRODUCES the pre-SAFETY-57 module and is "
|
||||
"not a second implementation of it. Part 1's own fresh/stale pair, driven "
|
||||
"through both bounds, gives bit-identical verdict streams - the release "
|
||||
"is the only behaviour the counterfactual removes",
|
||||
drive(_BEFORE, STREAM, lambda t: A_FROZEN_W)[2] == _fresh_read
|
||||
and drive(_BEFORE, STREAM,
|
||||
lambda t: A_FROZEN_W if t < 1 else 1300.0)[2] == _stale_read)
|
||||
check("PRECONDITION: the episode is in the capture where the manifest says it "
|
||||
"is, and it is the window's WORST hold rather than one picked out of "
|
||||
"several. 0x0518 answers -3 W for 850.0 s from 10:51:24, the gate arms "
|
||||
"at 10:52:00, the site's own 0 W reaches the wire at 10:52:21, and "
|
||||
"nothing else moves until a human does at 11:05:31",
|
||||
abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0
|
||||
and hms(_HOLD_FROM) == "10:51:24" and hms(_TRIP) == "10:52:00"
|
||||
and hms(_PARK) == "10:52:21" and hms(_HUMAN) == "11:05:31"
|
||||
and hms(_REG_MOVES) == "11:05:34"
|
||||
and sorted(_holds)[-2][0] < 0.5 * _HOLD_S)
|
||||
check("⚠️ RED BEFORE. On the shipped-before module the verdict outlives the "
|
||||
"whole parked interval: from the trip to the first command a HUMAN put "
|
||||
"on the wire, 811 s later, `reading()` is None on every one of the 811 "
|
||||
"reads - and the register never changes value in any of them, so there "
|
||||
"is nothing the module could have cleared on",
|
||||
all(s for t, s, _h, _sw, _r in _rows_before if _TRIP <= t < _HUMAN)
|
||||
and len([1 for t, s, _h, _sw, _r in _rows_before
|
||||
if _TRIP <= t < _HUMAN]) == 812
|
||||
and {v for t, v in _bt if _TRIP <= t < _HUMAN} <= {-3.0}
|
||||
and _clear_before > _HUMAN)
|
||||
check("⚠️ AND WHAT ENDS IT ON THE SHIPPED-BEFORE MODULE IS OUTSIDE THE MODULE. "
|
||||
"The clear lands three seconds after the register moves, and the register "
|
||||
"moves because a person restarted the add-on and the restarted loop "
|
||||
"commanded a CHARGE the pack accepted. Nothing inside this add-on "
|
||||
"produced that command",
|
||||
_clear_before > _REG_MOVES and _clear_before - _REG_MOVES <= 2.0
|
||||
and _HUMAN < _REG_MOVES)
|
||||
check("⚠️ GREEN AFTER, AND THE REGISTER NEVER MOVES - THIS IS THE CARD. The "
|
||||
"verdict is withdrawn 17 s after the site's own 0 W reaches the wire, "
|
||||
"while 0x0518 is still answering the same -3 W it has answered since "
|
||||
"10:51:24, and `reading()` is a number again with no external change of "
|
||||
"any kind",
|
||||
_PARK + BOUNDS["batt"].release_s <= _clear_after
|
||||
< _PARK + BOUNDS["batt"].release_s + 1.0
|
||||
and hms(_clear_after) == "10:52:39" and _f_after.releases >= 1
|
||||
and _D.held_at(_bt, _clear_after) == _D.held_at(_bt, _TRIP) == -3.0
|
||||
and next(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0)
|
||||
check("...and the recovery is worth 776 s of this one episode: the site is "
|
||||
"handed its telemetry back at 10:52:39 instead of at 11:05:35, and 772 "
|
||||
"of those seconds are BEFORE the human touched anything. That is the "
|
||||
"13 minutes it spent parked while the house exported 2.4 kW",
|
||||
_clear_before - _clear_after == 776.0
|
||||
and abs((_HUMAN - _clear_after) - 772.7) < 0.01
|
||||
and all(not s for t, s, _h, _sw, _r in _rows_after
|
||||
if _clear_after <= t < _HUMAN))
|
||||
check("⚠️ AND THE RELEASE STICKS RATHER THAN CHATTERING. From the withdrawal to "
|
||||
"the human's first command the verdict is withdrawn on every one of the "
|
||||
"773 reads, and exactly ONE release was taken in the whole episode - the "
|
||||
"1 Hz oscillation the latch exists to prevent is still prevented, "
|
||||
"because the window a release opens is 35 s wide",
|
||||
len([1 for t, s, _h, _sw, _r in _rows_after
|
||||
if _clear_after <= t < _HUMAN]) == 773
|
||||
and _f_after.releases == 1)
|
||||
|
||||
|
||||
# --- 10c. what a release costs, measured -------------------------------------
|
||||
# ⚠️ THE EXPOSURE IS THE NUMBER THE SAFETY ARGUMENT RESTS ON, so it is counted
|
||||
# rather than reasoned about: how many reads hand a consumer the held number
|
||||
# after a release, and how long the longest unbroken run of them is. The whole
|
||||
# argument is that one run is shorter than either consumer's hold.
|
||||
_dead, _exp57 = _dead_site(BOUNDS["batt"])
|
||||
_deadb, _expb = _dead_site(_BEFORE)
|
||||
_runs57 = _run_lengths(_exp57)
|
||||
print(f" register dead for good, {len(_exp57)} reads, "
|
||||
f"stale_input_s={_S57_HOLD:.0f}")
|
||||
print(f" before : held number reached a consumer on {sum(_expb)} reads "
|
||||
f"({100 * sum(_expb) / len(_expb):.1f} %), runs={_run_lengths(_expb)}")
|
||||
print(f" after : held number reached a consumer on {sum(_exp57)} reads "
|
||||
f"({100 * sum(_exp57) / len(_exp57):.1f} %), releases={_dead.releases}, "
|
||||
f"longest run={max(_runs57):.0f} s")
|
||||
|
||||
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
|
||||
"that is dead for good is re-graded for ever: over 1201 reads the held "
|
||||
"number reaches a consumer on 630 of them against 35 before, and 17 "
|
||||
"releases are taken. This check exists so that cost cannot be quietly "
|
||||
"forgotten, and so a change that makes it worse has to move a number "
|
||||
"somebody reads",
|
||||
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17
|
||||
and _deadb.releases == 0)
|
||||
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
|
||||
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
|
||||
"grading window, and 35 < 75 (circulation_hold_s) < 85 "
|
||||
"(unit_sign_disagree_s) - so NEITHER detector 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",
|
||||
set(_runs57) == {BOUNDS["batt"].unchanged_s}
|
||||
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold)
|
||||
check("...and nothing SAFETY-37 detects is given back by that, which is the "
|
||||
"half that makes the trade a trade. A permanent None clears both windows "
|
||||
"for ever too, so on the shipped-before module the same two detectors are "
|
||||
"equally unable to speak about this unit. The exposure buys the site its "
|
||||
"meter-derived control back and costs no detection that existed",
|
||||
disagrees(1300.0, None, True, _cfg3) is False
|
||||
and circulating_w([1300.0, None]) is None)
|
||||
|
||||
|
||||
# --- 10d. the bound, graded as a family --------------------------------------
|
||||
# ⚠️ ASSERTING `release_s == BATT_OBLIGED_S` WOULD BE COMPARING THE SHIPPED
|
||||
# EXPRESSION AGAINST ITSELF (habit 2, and MAINT-05's whole card). The literal is
|
||||
# asserted, the three terms it is built from are asserted, and then the BAND is
|
||||
# graded behaviourally against its neighbours.
|
||||
print(f" RELEASE_S {F.RELEASE_S:.1f} s = settle {F.PLANT_SETTLE_S:.1f} "
|
||||
f"+ publish {F.PUBLISH_PERIOD_S:.1f} + read {F.READ_PERIOD_S:.1f}")
|
||||
check("⚠️ THE BOUND IS 17.0 s AND IT IS THE PHYSICAL OBLIGATION WINDOW, "
|
||||
"asserted against the literal rather than against the expression that "
|
||||
"produces it. Same three terms as the floor of `BATT_UNCHANGED_S`'s "
|
||||
"band, because it is the same question: has the register had its chance "
|
||||
"to answer the command standing NOW?",
|
||||
BOUNDS["batt"].release_s == 17.0 and F.RELEASE_S == 17.0
|
||||
and F.PLANT_SETTLE_S + F.PUBLISH_PERIOD_S + F.READ_PERIOD_S == 17.0)
|
||||
_family = {r: _release_instant(r) for r in (5.0, 16.0, 17.0, 18.0, 35.0,
|
||||
float("inf"))}
|
||||
for _r, _at in sorted(_family.items(), key=lambda kv: kv[0]):
|
||||
print(f" release_s {_r:>5} -> withdrawn at t+{_at}"
|
||||
if _at is not None else f" release_s {_r:>5} -> never")
|
||||
check("⚠️ THE FAMILY IS COMPUTED AND PRINTED, so a bound no fixture separates "
|
||||
"shows up as a named survivor. Each candidate withdraws the verdict "
|
||||
"exactly its own number of seconds after the command rests, and they are "
|
||||
"six DISTINCT answers - a check that could not tell 17 from 35 could not "
|
||||
"tell this card from a slower version of the bug",
|
||||
_family == {5.0: 5.0, 16.0: 16.0, 17.0: 17.0, 18.0: 18.0, 35.0: 35.0,
|
||||
float("inf"): None})
|
||||
# ⚠️ AND THE FLOOR IS GRADED BEHAVIOURALLY, WHICH IS THE HALF A CONSTANT CHECK
|
||||
# CANNOT DO. Below 17.0 s the timer fires before a healthy register could have
|
||||
# published its answer to the 0 W command - so it takes the expensive exit when
|
||||
# the free one was one read away. The register here answers at the obligation
|
||||
# window's own edge, which is the case the bound is derived from.
|
||||
_free_exit = {}
|
||||
for _r in (5.0, 17.0):
|
||||
_f = Freshness(dataclasses.replace(BOUNDS["batt"], release_s=_r))
|
||||
for t in range(0, 40):
|
||||
_f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 40 + int(F.BATT_OBLIGED_S) - 1):
|
||||
_f.submit(A_FROZEN_W, 0.0, float(t)) # resting, register not yet in
|
||||
_f.submit(-1150.0, 0.0, float(40 + int(F.BATT_OBLIGED_S) - 1))
|
||||
_free_exit[_r] = (_f.releases, _f.stale, _f.held)
|
||||
print(f" register answers the 0 W command at +16 s: {_free_exit}")
|
||||
check("⚠️ THE FLOOR BITES, AND IT BITES ON BEHAVIOUR. A register that answers "
|
||||
"the new 0 W command inside its own obligation window clears the latch "
|
||||
"BY MOVING - the free exit - and at 17.0 s that is what happens, with "
|
||||
"`releases` still 0. At 5.0 s the timer has already fired and the site "
|
||||
"has paid for a re-grading window it did not need",
|
||||
_free_exit[17.0][0] == 0 and _free_exit[5.0][0] == 1
|
||||
and _free_exit[17.0][2] == _free_exit[5.0][2] == -1150.0)
|
||||
|
||||
|
||||
# --- 10e. the release does not rewrite the register's history ----------------
|
||||
_hist = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_hist.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 120):
|
||||
_hist.submit(A_FROZEN_W, 0.0, float(t))
|
||||
print(f" at t=119 after one release: unchanged_s={_hist.unchanged_s(119.0)} "
|
||||
f"graded_s={_hist.graded_s(119.0)} releases={_hist.releases}")
|
||||
check("⚠️ TWO CLOCKS, AND THE ONE THE OPERATOR READS IS THE REGISTER'S. A "
|
||||
"release re-opens the VERDICT's window; it does not make the register "
|
||||
"younger. At t=119 the register has not carried a different number for "
|
||||
"119 s and `unchanged_s` says 119 s, while the grading window opened at "
|
||||
"the release at t=57 and says 62 s. A single clock here would either lie "
|
||||
"to the operator or defeat the re-grading",
|
||||
_hist.unchanged_s(119.0) == 119.0 and _hist.graded_s(119.0) == 62.0
|
||||
and _hist.releases == 1 and _hist.held == A_FROZEN_W
|
||||
and _hist.changed_mono == 0.0 and _hist.graded_from == 57.0)
|
||||
|
||||
|
||||
# --- 10f. the obligation coming back cancels the timer -----------------------
|
||||
_resume = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_resume.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 40 + int(BOUNDS["batt"].release_s) - 1):
|
||||
_resume.submit(A_FROZEN_W, 0.0, float(t)) # 16 s of rest
|
||||
_resume.submit(A_FROZEN_W, 1300.0, 56.0) # the command comes back
|
||||
_resume_mid = (_resume.stale, _resume.rested_since)
|
||||
for t in range(57, 57 + int(BOUNDS["batt"].release_s) - 1):
|
||||
_resume.submit(A_FROZEN_W, 1300.0, float(t)) # and stays
|
||||
check("⚠️ A TIMER THAT DOES NOT RESET IS NOT A TIMER. Sixteen seconds of rest, "
|
||||
"one second short of the bound, and then the site puts the obligation "
|
||||
"back: the clock is cleared, not carried, so the verdict stands. "
|
||||
"Carrying it would withdraw a verdict about a command that is standing "
|
||||
"RIGHT NOW",
|
||||
_resume_mid == (True, None) and _resume.stale
|
||||
and _resume.releases == 0 and _resume.reading() is None)
|
||||
_resume.submit(A_FROZEN_W, 0.0, 80.0)
|
||||
for t in range(81, 81 + int(BOUNDS["batt"].release_s)):
|
||||
_resume.submit(A_FROZEN_W, 0.0, float(t))
|
||||
check("...and it starts again from the SECOND rest, not from the first: the "
|
||||
"release lands 17.0 s after t=80, which is 41 s after the first rest "
|
||||
"began. The total time spent resting is not what the bound is about",
|
||||
_resume.releases == 1 and _resume.released_at == 97.0
|
||||
and not _resume.stale)
|
||||
|
||||
# ⚠️ AND "NO LONGER STANDING" IS `MOVE_W`, NOT SOME OTHER THRESHOLD IN THE SAME
|
||||
# OBJECT. Found by mutating the SIBLING (habit 11): `_release()`'s test against
|
||||
# `bound.move_w` swapped for `bound.unchanged_s` SURVIVED the whole file at
|
||||
# +5 B, because every fixture above rests at exactly 0 W and 0 is below both
|
||||
# 35 and 250. The band between them is where they separate, and a site can sit
|
||||
# in it - a peak-guard floor, a shadow substitution or any claim that wins with
|
||||
# a small non-zero number leaves a command there while the verdict stands.
|
||||
_band = {}
|
||||
for _w in (0.0, 100.0, 300.0):
|
||||
_f = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 40 + 2 * int(BOUNDS["batt"].release_s)):
|
||||
_f.submit(A_FROZEN_W, _w, float(t))
|
||||
_band[_w] = _f.releases
|
||||
print(f" releases after 34 s of a standing command of 0 / 100 / 300 W: "
|
||||
f"{_band}")
|
||||
check("⚠️ THE OBLIGATION TEST IS THE 250 W ONE, AND THE 100 W CASE IS WHAT "
|
||||
"SAYS SO. A command of 100 W is below `move_w`, so the register is owed "
|
||||
"nothing and the verdict is withdrawn exactly as at 0 W; a command of "
|
||||
"300 W is above it, so the question is still standing and the verdict "
|
||||
"is too. Every other fixture in this file rests at 0 W, where `move_w` "
|
||||
"and `unchanged_s` cannot be told apart",
|
||||
_band == {0.0: 1, 100.0: 1, 300.0: 0}
|
||||
and BOUNDS["batt"].unchanged_s < 100.0 < BOUNDS["batt"].move_w < 300.0)
|
||||
|
||||
# ⚠️ AND THE ONE EQUIVALENT MUTANT THIS CARD FOUND, ARGUED RATHER THAN ROUNDED
|
||||
# OFF. Swapping `_release()` and `_grade()` in `submit()` is ZERO BYTES and
|
||||
# survived the whole battery. It is not a missing fixture and no fixture can
|
||||
# close it: the two act on MUTUALLY EXCLUSIVE predicates - `_grade()` only when
|
||||
# `|standing| >= move_w`, `_release()` only when `|standing| < move_w` - so at
|
||||
# most one of them can do anything on any read. This check pins the disjointness
|
||||
# rather than the order, which is the property that actually holds.
|
||||
_disjoint = []
|
||||
for _w in (0.0, 100.0, 249.0, 250.0, 251.0, 1300.0):
|
||||
_a = Freshness(BOUNDS["batt"])
|
||||
_b = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
for _f in (_a, _b):
|
||||
_f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 80):
|
||||
_a._release(_w, float(t))
|
||||
_a._grade(_w, float(t))
|
||||
_b._grade(_w, float(t))
|
||||
_b._release(_w, float(t))
|
||||
_disjoint.append((_w, _a.stale == _b.stale
|
||||
and _a.releases == _b.releases
|
||||
and _a.graded_from == _b.graded_from))
|
||||
print(f" release-then-grade vs grade-then-release, by standing command: "
|
||||
f"{_disjoint}")
|
||||
check("⚠️ AN ARGUED EQUIVALENT MUTANT, NOT A SURVIVOR NOBODY LOOKED AT. "
|
||||
"Swapping the two calls in `submit()` is zero bytes and changes nothing "
|
||||
"on either side of `move_w`, including AT it - because the two are gated "
|
||||
"on mutually exclusive tests and cannot both act on one read. The "
|
||||
"docstring used to claim the order was load-bearing; it is not, and the "
|
||||
"battery is what said so",
|
||||
all(same for _w, same in _disjoint) and len(_disjoint) == 6)
|
||||
|
||||
|
||||
# --- 10g. through the REAL read path, and the operator is told ---------------
|
||||
_LOGREC = []
|
||||
|
||||
|
||||
class _S57Handler(logging.Handler):
|
||||
def emit(self, record):
|
||||
_LOGREC.append(record)
|
||||
|
||||
|
||||
logging.getLogger("goodwe").addHandler(_S57Handler())
|
||||
logging.getLogger("goodwe").setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
async def _wired_release():
|
||||
"""The whole chain at N=1: real `read_inputs`, real `Inverter`, real log."""
|
||||
c, hass = one_site()
|
||||
seen = []
|
||||
for t in range(0, 40):
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W
|
||||
c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
seen.append((t, c.inverters[0].batt, c.inputs_ok))
|
||||
for t in range(40, 40 + int(BOUNDS["batt"].release_s) + 2):
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W # the register NEVER moves
|
||||
c.inverters[0].last_write = 0.0 # the site's own 0 W
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
seen.append((t, c.inverters[0].batt, c.inputs_ok))
|
||||
return c, seen
|
||||
|
||||
|
||||
_c57, _seen57 = run(_wired_release())
|
||||
_trip57 = next(t for t, b, _ok in _seen57 if b is None)
|
||||
_back57 = next(t for t, b, _ok in _seen57 if t > _trip57 and b is not None)
|
||||
print(f" real read path: batt None from t={_trip57}, a number again at "
|
||||
f"t={_back57}, register held {A_FROZEN_W:+.0f} throughout")
|
||||
check("⚠️ THROUGH THE SHIPPED READ PATH, NOT THE POLICY OBJECT. "
|
||||
"`Inverter.batt` goes None at the bound, `inputs_ok` goes False - and "
|
||||
"17.0 s after the site's own 0 W it is a NUMBER again and `inputs_ok` is "
|
||||
"True, with `sensor.batt_a` serving the identical -1200.0 on every one "
|
||||
"of those reads",
|
||||
_trip57 == 35 and _back57 == 57
|
||||
and all(v == A_FROZEN_W for _e, v in _c57.hass.served
|
||||
if _e == "sensor.batt_a")
|
||||
and _seen57[_trip57][2] is False and _seen57[-1][2] is True
|
||||
and _c57.inverters[0].batt == A_FROZEN_W)
|
||||
check("...and the site stops holding inputs bad, which is what ends the "
|
||||
"`inputs missing -> command 0 W` claim §11.1 rule 1 files",
|
||||
_c57.inputs_bad_since is None and _c57.inputs_ok is True)
|
||||
_ev57 = [e for e in _c57.events if "STALE" in e]
|
||||
_msgs57 = [r.getMessage() for r in _LOGREC]
|
||||
_rel57 = [m for m in _msgs57 if "STALE_WITHDRAWN" in m]
|
||||
print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}")
|
||||
check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR "
|
||||
"CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach "
|
||||
"the event log and the logger, in order, naming the unit",
|
||||
len(_ev57) == 2 and "STALE inverter-a" in _ev57[1]
|
||||
and "STALE WITHDRAWN inverter-a" in _ev57[0]
|
||||
and len(_rel57) == 1 and "inverter-a" in _rel57[0])
|
||||
check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has "
|
||||
"happened: it says in its own words that nothing has been learned about "
|
||||
"the register, and it prints the value the register is STILL holding",
|
||||
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in _rel57[0]
|
||||
and f"still holding {A_FROZEN_W:+.0f}" in _rel57[0])
|
||||
|
||||
|
||||
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
|
||||
# ⚠️ AN `in` TEST AGAINST A FORMATTED SENTENCE GRADES PRESENCE, NEVER
|
||||
# ARRANGEMENT. Both of the sentences this card writes carry two numbers that
|
||||
# could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new
|
||||
# escape clause, and 250 W and 17.0 s again in the release note - so each is
|
||||
# asserted with its value embedded in its own literal position.
|
||||
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m]
|
||||
print(f" escape clause: ...{_stale57[0][-150:]}")
|
||||
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
|
||||
"PAIR. It used to end 'It clears itself the moment the register reports "
|
||||
"a different value.' - which was FALSE, because the 0 W in the sentence "
|
||||
"before it is what stops the register being asked. The new clause names "
|
||||
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
|
||||
"red",
|
||||
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing "
|
||||
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in _stale57[0])
|
||||
check("...and the release note names 250 W in the POWER slot and 17 s in the "
|
||||
"TIME slot, in that order, which is the reverse arrangement of the same "
|
||||
"two numbers",
|
||||
f"sat below {BOUNDS['batt'].move_w:.0f} W for "
|
||||
f"{BOUNDS['batt'].release_s:.0f} s" in _rel57[0])
|
||||
check("...and the 35.0 s the register is re-graded over is named in the note "
|
||||
"too, because it is what the operator is being told to wait out",
|
||||
f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window"
|
||||
in _rel57[0])
|
||||
check("⚠️ AND THE DIAGNOSIS HALF OF THE STALE LINE IS UNTOUCHED, WHICH IS "
|
||||
"SAFETY-56'S. That card is rewriting 'is not following its own command' "
|
||||
"- the live episode's register was following perfectly and the pack was "
|
||||
"simply empty - and two cards editing one sentence is how one of them "
|
||||
"silently loses. This check is here so that rewrite is a deliberate act",
|
||||
"battery-power register is not following its own command" in _stale57[0])
|
||||
check("...and a re-trip's reason names the TWO durations separately, each "
|
||||
"beside its own value, because after a release the verdict's window and "
|
||||
"the register's own unchanged time are different facts",
|
||||
(lambda f: (
|
||||
[f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(0, 40)],
|
||||
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
|
||||
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
|
||||
f.stale and f.releases == 1
|
||||
and "unchanged for 92 s" in f.reason
|
||||
and "the 35 s window this verdict rests on opened when the previous "
|
||||
"one was withdrawn" in f.reason
|
||||
and "graded 2 times" in f.reason)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user