Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ba3433ed | ||
|
|
9f6b9f14cd | ||
|
|
8f9c2cad57 | ||
|
|
c9aaf7f04d | ||
|
|
8a6acd9fa2 | ||
|
|
628279a68a | ||
|
|
239e120bf6 | ||
|
|
9d593429d3 | ||
|
|
b0b24e5d41 | ||
|
|
eb88646899 | ||
|
|
b9f7bd0506 | ||
|
|
43c144b8d9 | ||
|
|
33e17dba0e |
@@ -110,6 +110,37 @@ name: checks
|
||||
# trigger` step below then keeps it that way mechanically rather than by asking
|
||||
# the next editor to read this comment.
|
||||
#
|
||||
# ⚠️ READ THE BLIND SPOTS ON THAT STEP BEFORE TRUSTING THE SENTENCE ABOVE.
|
||||
# "Mechanically" holds only for a reintroduced filter that still names the
|
||||
# PR's own base. For blind spots 1 and 3 the defence IS this comment, because
|
||||
# the guard lives inside the workflow that a filter makes Gitea skip. Blind
|
||||
# spot 3 is the one that matters: a filter naming a branch THIS pull request
|
||||
# does not target is itself skipped, lands green and untested, and then every
|
||||
# subsequent pull request the filter DOES NOT NAME is skipped too. That is the
|
||||
# gate off, silently.
|
||||
#
|
||||
# ⚠️ THAT DIRECTION IS THE WHOLE POINT AND IT IS EASY TO WRITE BACKWARDS. A
|
||||
# pull request that MATCHES the filter still runs; it is the one the filter
|
||||
# excludes that Gitea sits out, reports `skipped`, and the rollup launders
|
||||
# into `success`. An earlier draft of this very clause said "matching it is
|
||||
# skipped", which is the inverse of the measurement and contradicted the
|
||||
# paragraph directly below.
|
||||
#
|
||||
# ⚠️ AND THE DANGEROUS ONE IS NOT `branches: [main]`, WHICH AT LEAST LOOKS
|
||||
# WRONG. It is `branches: [release/1.0]`, which looks like tightening onto the
|
||||
# protected branch and is what a careful person would write. Every day-to-day
|
||||
# pull request still runs and still goes green, so nothing appears broken -
|
||||
# and the only thing switched off is the `release/1.0` -> `master` release
|
||||
# PR, i.e. the one whose content reaches an installed system. SEC-2 made that
|
||||
# PR the mandated release path (docs/RELEASE.md), so this is now a live route
|
||||
# rather than a hypothetical one.
|
||||
#
|
||||
# Closing it needs a SECOND permanently unfiltered workflow posting its own
|
||||
# context, with that context added to the `status_check_contexts` of BOTH
|
||||
# `release/1.0` AND `master` - both carry the rule since SEC-2, and adding it
|
||||
# to one alone leaves the hole open on the other. A forge change, with the
|
||||
# owner.
|
||||
#
|
||||
# THE MEASUREMENT, because the obvious guess is wrong and was nearly written
|
||||
# here as fact. A skip-ci keyword in a commit message (`[skip ci]`, `[ci skip]`,
|
||||
# `[no ci]`, `[skip actions]`, `[actions skip]`) does NOT behave like a filter
|
||||
|
||||
@@ -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`.
|
||||
+203
-18
@@ -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
|
||||
@@ -98,8 +266,9 @@ 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 IS -
|
||||
THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
|
||||
⚠️ **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
|
||||
@@ -120,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 \
|
||||
@@ -141,10 +313,10 @@ byte what that run measured. `app/validate.py` imports neither `main` nor
|
||||
`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 - does not run at all here**, because it is gated on
|
||||
`shipped == deployed` and the box reports `0.7.0`. It is the half that would
|
||||
refuse this fold on a box already running 0.10.0; see the note below the
|
||||
contents table.
|
||||
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:
|
||||
@@ -195,17 +367,29 @@ 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.
|
||||
|
||||
⚠️ **AND THE VERSION IS NOT BUMPED FOR THIS FOLD, DELIBERATELY.** 0.10.0 was
|
||||
cut on 2026-09-16 and **has never been built or pulled anywhere** - the
|
||||
reference install is on 0.7.0, this repository carries exactly one tag
|
||||
(`v0.2.1`) and no release - so the string `0.10.0` does not yet name a tree
|
||||
anyone is running, and the PKG-06/PKG-07 no-op that forbids re-cutting 0.9.0
|
||||
does not apply to it. Cutting 0.11.0 instead would burn a number on a release
|
||||
that never left the repository. ⚠️ **THE ARGUMENT IS CONDITIONAL AND THE
|
||||
CONDITION IS CHECKABLE**: if 0.10.0 has reached an install by the time you read
|
||||
this, it may not be folded into again, and `tools/predeploy_check.py` says so
|
||||
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.
|
||||
⚠️ **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`):
|
||||
|
||||
@@ -231,6 +415,7 @@ Contents of this release, in merge order since 0.9.0 (`86bdf49`):
|
||||
| `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
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# SAFETY-56 — what the gate cannot tell apart, said in the sentence
|
||||
|
||||
**Text only. No behaviour changes in this card**, and §5 is the measurement that
|
||||
says so: the same stimuli through the tree before and after produce a
|
||||
**byte-identical** trace of every verdict, every `reading()`, every `inputs_ok`
|
||||
and every readiness row's `(ok, warn)`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect
|
||||
|
||||
0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that
|
||||
healthy hardware has failed.
|
||||
|
||||
**The log line**, as emitted on the reference site on 2026-09-21:
|
||||
|
||||
> SAFETY-37: inverter-a battery-power register **is not following its own
|
||||
> command** - STALE_READING: -3 unchanged for 36 s while the standing command
|
||||
> moved 800 W to +1600 W - **the reading is not following**, so it is reported
|
||||
> as MISSING rather than as a measurement … Treating it as MISSING, which
|
||||
> commands 0 W.
|
||||
|
||||
**The readiness row**, at the same moment:
|
||||
|
||||
> NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power`
|
||||
> **is missing or not numeric**
|
||||
|
||||
That entity was present, numeric and updating.
|
||||
|
||||
**SAFETY-37's conjunction is correct and is not touched by this card** —
|
||||
`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register
|
||||
genuinely stuck against a real command is exactly what it should catch, and a
|
||||
pack that cannot comply is a state the site must stop in too. What was wrong is
|
||||
what the sentences **concluded** from it.
|
||||
|
||||
## 2. Two worlds satisfy the conjunction
|
||||
|
||||
| | the register | the pack |
|
||||
|---|---|---|
|
||||
| **DEAD** | has stopped reporting reality | is doing what it was told |
|
||||
| **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor |
|
||||
|
||||
A refused pack holds still under any setpoint, so an honest register publishes
|
||||
the same unchanged number a dead one does. **Nothing `freshness.py` is handed
|
||||
separates them.** All three sentences asserted DEAD.
|
||||
|
||||
## 3. The live episode was REFUSED, and it is measured, not argued
|
||||
|
||||
Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`,
|
||||
whose bytes and decoder are already graded by `test_freshness.py` part 10a:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 |
|
||||
| the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge |
|
||||
| pack SoC throughout | **11 %** |
|
||||
| first **charge** command after the restart | 11:05:31 at **-400 W** |
|
||||
| the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window |
|
||||
|
||||
**A register that answers a reversed command inside one obligation window had
|
||||
not stopped reporting.** What would not move was the pack. The sentence the
|
||||
operator read named the one thing in the loop that was telling the truth.
|
||||
|
||||
⚠️ What this does **not** establish: that no register is ever intermittently
|
||||
wrong. It excludes a register that had simply stopped; it cannot exclude one
|
||||
that was wrong and then right. That is the point — the new sentence claims
|
||||
neither.
|
||||
|
||||
## 4. The fix, and why it is three sentences and not one helper
|
||||
|
||||
Each message states **what was observed** and names **both** explanations.
|
||||
|
||||
| where | before | after |
|
||||
|---|---|---|
|
||||
| `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART**: the register may have stopped reporting reality, or the source may be unable to comply with the command - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* |
|
||||
| `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* |
|
||||
| `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* |
|
||||
|
||||
**Three sentences, three wordings, no shared helper.** The first two are a log
|
||||
line about a **behaviour**; the third is a banner about a named **entity**, and
|
||||
the two answer different questions ("what did the register do" against "can I
|
||||
trust this entity"). A helper serving both would have to drop whichever half
|
||||
the other caller does not need, and the half it dropped would be the
|
||||
operator's next step. The log line names the pair **once**, inside the `%s` it
|
||||
already interpolates, rather than in both of its own halves.
|
||||
|
||||
**No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT
|
||||
WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number)
|
||||
and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs
|
||||
neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and
|
||||
asserts the sentence does not move one byte.
|
||||
|
||||
**The readiness row does not branch.** `inv.batt_fresh.stale` is on the same
|
||||
object at the same moment, and the row deliberately does not ask it: the same
|
||||
loop renders the grid and SoC rows, which have no gate, and a branch per row
|
||||
would be a second place where "why is this None" is decided. The gate already
|
||||
has a surface — `stale_note()` rides `liveness_notes()` a few rows below with
|
||||
the whole STALE_READING sentence in it — so the operator's discriminator is
|
||||
*"is that row there?"*, and part 11d asserts that row is **present in REFUSED
|
||||
and absent in ABSENT**.
|
||||
|
||||
## 5. Behaviour is byte-identical, measured
|
||||
|
||||
`tools/safety56_behaviour_trace.py` prints two documents from one stimulus set.
|
||||
The `behaviour` half contains **no sentence**: only `stale`, `reading()`,
|
||||
`releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness
|
||||
row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against
|
||||
`origin/release/1.0` **archived out of git** and against this branch:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the committed live capture | **2390 reads**, 7 verdict edges |
|
||||
| a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases |
|
||||
| six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` |
|
||||
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** |
|
||||
|
||||
⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES
|
||||
THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour
|
||||
unchanged" unless the `sentences` halves **differ**. This card's first
|
||||
invariance run was exactly that mistake — it archived a branch whose edits were
|
||||
not committed yet, so `before` and `after` were the same code and the identity
|
||||
was perfect and worthless.
|
||||
|
||||
Both of the tool's refusals are **driven**, not asserted:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** |
|
||||
| `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** |
|
||||
| this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** |
|
||||
|
||||
## 6. The mutation battery
|
||||
|
||||
Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`,
|
||||
graded by `tools/mutation_run.sh` in the CI image.
|
||||
|
||||
| | mutation | Δ bytes | named reds |
|
||||
|---|---|---|---|
|
||||
| M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** |
|
||||
| M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** |
|
||||
| M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** |
|
||||
| M4 | *register* and *source* transposed between the two explanations | **0** | **1** |
|
||||
| M5 | the second explanation deleted, the first kept | -216 | **1** |
|
||||
| M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** |
|
||||
| M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** |
|
||||
| M8 | the row's second cause deleted | -309 | **1** |
|
||||
| M9 | `label` and `entity` transposed in the row | **0** | **3** |
|
||||
| M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** |
|
||||
| M11 | the accusation literals replaced by one that matches nothing | 0 | **1** |
|
||||
|
||||
⚠️ **M9's first run was a defect in this card's own check, and it is the one
|
||||
worth reading.** The row was fetched with `[...][0]`. Transposing `label` and
|
||||
`entity` makes the row unfindable, so the run died on an `IndexError` — a red
|
||||
with **no name attached**, with every check after that line **ungraded**. That
|
||||
is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of
|
||||
raising, and a precondition grades it: **0 named reds before, 3 after.**
|
||||
|
||||
⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is
|
||||
True, and so is one over three mistyped literals. The positive control runs the
|
||||
same predicate against the three sentences **as 0.10.1 shipped them** and
|
||||
requires it to find an accusation in every one, and every literal to match at
|
||||
least one of them (habit 7: vary one thing from an accepted baseline).
|
||||
|
||||
## 7. What part 11 does not grade
|
||||
|
||||
* It does not show the live episode was a BMS refusal beyond doubt — see §3.
|
||||
* It does not grade English. A sentence that named both explanations and then
|
||||
said something useless about them would pass.
|
||||
* The `no entity configured` branch beside the rewritten row is untouched and
|
||||
ungraded: a blank entity really is the only thing it can be.
|
||||
* The **grid-power and battery-SoC rows** go through the same rewritten line and
|
||||
have no freshness gate today, so for them the second cause is a hypothesis
|
||||
about this controller rather than a live possibility. That is weaker than the
|
||||
battery-power case and is the price of not branching per row. A gate on either
|
||||
signal — TEL-08 would be one — makes it exact.
|
||||
* **SAFETY-58 is not touched** (the first read after a long rest trips before a
|
||||
healthy register could answer), and neither is the 710/790 disagreement
|
||||
between `test_freshness.py` and the changelog. Both are separate cards; the
|
||||
second has a **fourth site** at
|
||||
`DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written
|
||||
off the PR table alone would miss.
|
||||
@@ -0,0 +1,388 @@
|
||||
# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3`
|
||||
(content-identical to `release/1.0` @ `8a57361`).
|
||||
**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*.
|
||||
**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or
|
||||
192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a
|
||||
**passive** RS-485 listener with no address on the bus, and it was read only.
|
||||
|
||||
---
|
||||
|
||||
## 0. The five things a reader should take away
|
||||
|
||||
1. **The live episode is reproduced from the wire, not modelled.** The capture
|
||||
is committed, the decoder is committed, and the box that produced it was
|
||||
running the defect — so the `0 W` in the trace *is* the add-on's own
|
||||
`inputs missing → command 0 W`. §2.
|
||||
2. **Option 2 from the card — "do not arm the gate when the command is 0 W" —
|
||||
already holds, and it is not the bug.** `_grade()` has required
|
||||
`|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and
|
||||
the stuck state is at **0 W**; they are different moments. §3.
|
||||
3. **The decision is not "how long", it is "when".** The verdict is withdrawn
|
||||
when the **obligation that produced it is no longer standing** — SAFETY-13's
|
||||
shape one module over. The 17.0 s is the obligation window that was already
|
||||
derived in the file, taken at the **floor** of its band, and which half is
|
||||
safe is the **opposite** of SAFETY-13's. §4.
|
||||
4. **It costs 52 % exposure on a permanently dead register, and that number is
|
||||
in the suite so it cannot be forgotten.** It is payable only because one
|
||||
re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s).
|
||||
**No detection SAFETY-37 had is given back.** §5.
|
||||
5. **The battery had been rescuing this code by accident.** 93 trips in the
|
||||
committed 16-day capture; median wait for the BMS keep-alive pulse that
|
||||
cleared them **143 s**, worst **5 h 57 m**. §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was there, in the terms the code uses
|
||||
|
||||
`freshness.py`, `Freshness.submit()` and `_grade()`:
|
||||
|
||||
```python
|
||||
if self.changed_mono is None or value != self.held:
|
||||
...
|
||||
self.stale = False # the only place the latch is cleared
|
||||
def _grade(self, standing_w, now_mono):
|
||||
if self.stale or self.changed_mono is None:
|
||||
return # sets the latch; never clears it
|
||||
```
|
||||
|
||||
and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()`
|
||||
answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for
|
||||
`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs
|
||||
missing")` and keeps filing it.
|
||||
|
||||
The reason the latch exists is written in the class docstring and it is a good
|
||||
one: without it the verdict oscillates at about 1 Hz, because declaring the
|
||||
reading stale drops the command below `MOVE_W`, which un-arms the conjunction,
|
||||
which hands the held number straight back.
|
||||
|
||||
**The defect is that the only thing that ends it is an arrival carrying a
|
||||
different number — and the state's own output is what stops the register being
|
||||
asked for one.** The log line promised the opposite, in the same sentence that
|
||||
announced the 0 W:
|
||||
|
||||
> *Treating it as MISSING, which commands 0 W. It clears itself the moment the
|
||||
> register reports a different value.*
|
||||
|
||||
---
|
||||
|
||||
## 2. Reproducing it, from the wire
|
||||
|
||||
`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840
|
||||
verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by
|
||||
`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC,
|
||||
0 unknown shapes**, and its mode→sign convention is **graded off the capture**
|
||||
rather than trusted: 298 commands at or under −1000 W are each answered one
|
||||
obligation window later by a negative register reading, and not one positive.
|
||||
|
||||
| capture time | on the wire |
|
||||
|---|---|
|
||||
| 10:51:14 | `0x0518` = **−447 W**; the loop is charging |
|
||||
| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge |
|
||||
| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** |
|
||||
| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel |
|
||||
| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire |
|
||||
| …850.0 s | `0x0518` does not change value |
|
||||
| …790.0 s | the setpoint does not change value |
|
||||
| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode |
|
||||
| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % |
|
||||
| 11:05:34.7 | `0x0518` = **−137 W** |
|
||||
|
||||
⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why
|
||||
this is a reproduction and not a simulation: the box was running 0.10.0, so
|
||||
every command in the trace is the shipped code's own. The only event inside the
|
||||
window the file cannot account for is the human restart.
|
||||
|
||||
Replaying the shipped `_read_unit` loop at 1 Hz against those two series
|
||||
(`test_freshness.py` part 10b):
|
||||
|
||||
| | stale at | clear at | parked |
|
||||
|---|---|---|---|
|
||||
| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them |
|
||||
| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** |
|
||||
|
||||
⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN
|
||||
CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is
|
||||
only ever called where the register moves, and the window pair is the
|
||||
change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b
|
||||
asserts that by driving part 1's own fresh/stale pair through both bounds and
|
||||
comparing the verdict streams.
|
||||
|
||||
The replay also found a **one-read trip at 10:48:14** that clears one second
|
||||
later. That is a pre-existing false positive and it is now recorded as
|
||||
survivor 5 — §7.
|
||||
|
||||
---
|
||||
|
||||
## 3. What the card's option 2 actually buys, which is nothing here
|
||||
|
||||
The card offered *"do not arm the gate when the command being graded is 0 W"*
|
||||
and warned that it might hold already. **It does, and it has since SAFETY-37**:
|
||||
`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and
|
||||
`test_freshness.py` part 4 has a named check on that term, added because a
|
||||
mutation removing it survived.
|
||||
|
||||
The warning in the card is the right one and it is worth writing down: **the
|
||||
trip and the stuck state are different moments.** The gate armed at **+1060 W**,
|
||||
where the conjunction is entirely correct — a command travelled 800 W and the
|
||||
register did not move for 35 s. The site then went to **0 W** *because of the
|
||||
verdict*. An entry-side rule cannot reach a state that is entered legitimately
|
||||
and then persists. **The defect is the exit, and nothing else in the conjunction
|
||||
is touched by this card.**
|
||||
|
||||
Option 3 — probing with a small non-zero command — was not taken and does not
|
||||
need to be. It writes to a real actuator to test an assumption, and §4's rule
|
||||
does not need a probe: the site's *normal* control resuming is the test, and it
|
||||
is gated behind the same `inputs_ok` everything else is.
|
||||
|
||||
---
|
||||
|
||||
## 4. The decision: which verdicts get an exit, and what the bound is
|
||||
|
||||
> The stale verdict holds as before, **unless** the obligation that produced it
|
||||
> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone
|
||||
> longer than the register would have needed to answer it. On a release the
|
||||
> latch clears and a **new grading window** opens at that instant.
|
||||
|
||||
This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card
|
||||
established the house answer to "a latch with no exit" and its answer was *not*
|
||||
a plain time bound but a **predicate plus a time bound**, where the predicate
|
||||
says whether the state can end on its own. Here the predicate is that **the
|
||||
question is no longer being asked.** A register under a 0 W command is obliged
|
||||
to be nowhere, so a verdict about whether it is following cannot be tested, and
|
||||
a verdict that cannot be tested may not stand for ever.
|
||||
|
||||
### 4.1 Why the window has to re-open, and what happens if it does not
|
||||
|
||||
`_grade()` compares the register's unchanged time against `unchanged_s` and the
|
||||
command's travel against the command standing when the window opened. If a
|
||||
release cleared the latch **without** re-opening the window, the first read at
|
||||
which the resumed command travels 250 W would re-trip immediately — the
|
||||
register would never get the 17 s in which it could answer, and the live
|
||||
episode's recovery (a charge the pack **accepts**) would be killed inside two
|
||||
seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`,
|
||||
+82 B) takes four named checks red.
|
||||
|
||||
The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry,
|
||||
because it is the same question — *does this register follow the commands issued
|
||||
from now on?* — and there is no principled shorter answer to it. It is also the
|
||||
number the exposure argument in §5 rests on, so it is not free to move.
|
||||
|
||||
### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe
|
||||
|
||||
The quantity needed is *has the register had its chance to answer the command
|
||||
standing now?*, and `freshness.py` already derives it as the floor of
|
||||
`BATT_UNCHANGED_S`'s band:
|
||||
|
||||
```
|
||||
6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22
|
||||
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
|
||||
1.0 s this add-on run_control()'s await asyncio.sleep(1)
|
||||
```
|
||||
|
||||
⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of
|
||||
`MOVE_W` taking the **ceiling** of its own for the mirrored reason.
|
||||
|
||||
| | the question | the safe half | why |
|
||||
|---|---|---|---|
|
||||
| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses |
|
||||
| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that |
|
||||
|
||||
Below 17.0 s the release pre-empts the **cheaper exit** — the register answering
|
||||
the new 0 W command by itself, which clears the latch on evidence rather than on
|
||||
a timer. That is graded behaviourally rather than asserted: a register that
|
||||
answers at +16 s clears by **movement** at `release_s = 17.0` with `releases`
|
||||
still 0, and by **timer** at `release_s = 5.0`.
|
||||
|
||||
The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give
|
||||
six distinct release instants, so no check in the file can confuse two of them.
|
||||
|
||||
### 4.3 Two clocks, and the operator reads the register's
|
||||
|
||||
`changed_mono` / `unchanged_s()` remain facts about the **register**: after a
|
||||
release they go on reporting that it has not moved for 850 s, because it has
|
||||
not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide
|
||||
until the first release. Collapsing them either lies to the operator or defeats
|
||||
the re-grading, and both directions are mutated red.
|
||||
|
||||
The stale sentence's `unchanged for N s` slot carries the **register's** number;
|
||||
a re-trip appends a clause naming the window and the grade count, so the two
|
||||
durations cannot be read as one number that moved (habit 14).
|
||||
|
||||
---
|
||||
|
||||
## 5. What it costs, measured
|
||||
|
||||
Driven against a register frozen for good, 1201 reads, shipped defaults
|
||||
(`test_freshness.py` part 10c):
|
||||
|
||||
| | parked | the held number reaches a consumer | releases |
|
||||
|---|---|---|---|
|
||||
| before | 1166 s | **35 reads (2.9 %)** | 0 |
|
||||
| after | 571 s | **630 reads (52.5 %)** | 17 |
|
||||
|
||||
⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is
|
||||
`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window.
|
||||
|
||||
It is payable because of one inequality, and it is the inequality
|
||||
`test_freshness.py` part 6 has graded against the shipped `config.yaml` since
|
||||
SAFETY-37:
|
||||
|
||||
```
|
||||
35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s)
|
||||
```
|
||||
|
||||
Every unbroken run of exposure is **exactly one window**, so neither consumer
|
||||
can reach a verdict inside one, and the re-trip that ends each run hands them a
|
||||
`None`, which **clears** their windows rather than accumulating into them.
|
||||
|
||||
**And nothing SAFETY-37 detects is given back**, which is the half that makes
|
||||
this a trade rather than a regression: a permanent `None` clears those same two
|
||||
windows for ever as well, so on the shipped-before module those detectors are
|
||||
*equally* unable to speak about the unit. What the held number does reach for
|
||||
35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which
|
||||
answers a large unexplained divergence by **freezing the output**, the
|
||||
conservative direction and SAFETY-13's business — and the command itself is
|
||||
derived from the **P1 meter**, never from `batt`.
|
||||
|
||||
### 5.1 The narrowing this does NOT take, named so the next card can
|
||||
|
||||
The live episode's register refused a **discharge** into an 11 % pack and then
|
||||
followed a **charge** three seconds later. A refusal in one direction is not
|
||||
evidence about the other, and a gate that knew the pack's floor could re-grade
|
||||
on the first command that crosses it instead of on a timer — which would shrink
|
||||
the 52 % above. That needs SoC, which this module deliberately does not take
|
||||
(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number.
|
||||
It is **not needed to give the state an exit**, and folding it in here would
|
||||
make the exit depend on a second measurement being right.
|
||||
|
||||
---
|
||||
|
||||
## 6. The measurement that says the old exit was luck
|
||||
|
||||
`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped
|
||||
conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only
|
||||
thing that could clear any of them was the register carrying a different number,
|
||||
and that took:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| minimum | 0.6 s |
|
||||
| **median** | **143.4 s** |
|
||||
| maximum | **21440.1 s** (5 h 57 m) |
|
||||
| at or over the live episode's 710 s | **17 of 93** |
|
||||
|
||||
The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at
|
||||
508 W — twitching `0x0518` and satisfying the clearing rule **by accident**.
|
||||
Over a fifth of the trips are cleared in under 20 s by something nothing in this
|
||||
add-on asked for.
|
||||
|
||||
⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and
|
||||
0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the
|
||||
setpoint column is an **ungated** controller's. That makes each trip an honest
|
||||
register-versus-command divergence off the real bus, and it makes the post-trip
|
||||
command column something a gated box would **not** have written. The figure
|
||||
taken from it is therefore the only one that does not depend on the command at
|
||||
all — how long this register goes without changing value — and it **under**-states
|
||||
the wait, because a site parked at 0 W gives the register even less reason to
|
||||
move than the one recorded here.
|
||||
|
||||
---
|
||||
|
||||
## 7. Survivors, and the two things the battery found
|
||||
|
||||
The battery is 22 mutations over `app/freshness.py`, `app/main.py` and
|
||||
`tools/decode_bus_capture.py`, plus one over the committed capture itself. All
|
||||
run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of
|
||||
the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`.
|
||||
Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per
|
||||
invocation and the tree is fingerprinted **after the revert** as well as before;
|
||||
every revert restored the recorded sha256 and byte count.
|
||||
|
||||
**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the
|
||||
PR body. Two of them are worth keeping here.
|
||||
|
||||
### 7.1 The equivalent mutant, argued rather than rounded off
|
||||
|
||||
Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and
|
||||
survived the whole battery. **It is not a missing fixture and no fixture can
|
||||
close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()`
|
||||
only when `|standing| < move_w`, so at most one of them can do anything on any
|
||||
read, whichever runs first.
|
||||
|
||||
⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a
|
||||
`⚠️` comment, with a reason that sounded right. It was false, and the battery is
|
||||
what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in
|
||||
a comment is a claim, and this one had not been run.** The comment now states
|
||||
the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and
|
||||
1300 W — instead of pinning an order that is not observable.
|
||||
|
||||
### 7.2 The sibling that survived, which is habit 11 earning its place
|
||||
|
||||
`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`.
|
||||
Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived
|
||||
the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is
|
||||
below both 35 and 250. They separate only in the band between them, and a site
|
||||
can sit there: a peak-guard floor, a shadow substitution, any claim that wins
|
||||
with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W
|
||||
that asserts the 100 W case releases and the 300 W case does not.
|
||||
|
||||
### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half
|
||||
|
||||
The first form of the `graded_from` mutation was
|
||||
`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and
|
||||
the tree fingerprint showed the edit really was on disk and really did run.
|
||||
`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` —
|
||||
so the mutant was a *no-op on the fixture*, for a Python truthiness reason that
|
||||
has nothing to do with the property under test. Rewritten as an explicit
|
||||
`is None` test (+82 B) it is red on four named checks.
|
||||
|
||||
**A mutation is subject to every habit in MUTATION.md, including habit 4.** A
|
||||
purge, a fingerprint and a byte delta all say the edit is present; none of them
|
||||
says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19
|
||||
for it.
|
||||
|
||||
---
|
||||
|
||||
## 8. What this does NOT fix, said plainly
|
||||
|
||||
**It does not tell a BMS refusal apart from a dead register**, and it does not
|
||||
try to. Both are re-graded on the same timer. §5.1 names what telling them apart
|
||||
would need.
|
||||
|
||||
**It does not make the diagnosis right.** The line the operator reads still says
|
||||
*"battery-power register is not following its own command"*, which is wrong in a
|
||||
different way when the pack is simply empty. That wording is **SAFETY-56's**,
|
||||
together with `main.py`'s NOT READY row, and it is deliberately untouched here —
|
||||
two cards editing one sentence is how one of them silently loses. Only the
|
||||
**escape clause** was rewritten, because this card is what made the old one
|
||||
false, and part 10h has a named check asserting the diagnosis half is still
|
||||
there so that rewrite has to be a deliberate act.
|
||||
|
||||
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
|
||||
> the deliberate act: all three sentences now name **both** explanations and
|
||||
> accuse neither, and part 10h's check is inverted — it requires the accusation
|
||||
> to be **gone**. Nothing else in this section moved; the escape clause above is
|
||||
> still this card's and is untouched by it. See
|
||||
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
|
||||
> `test_freshness.py` part 11 for the graded pair of worlds.
|
||||
|
||||
**Survivor 5 is older than this card and is not closed by it.** After a long
|
||||
rest the unchanged clock is already past 35 s, so the **first** read carrying a
|
||||
travelled command trips the gate — before the 17.0 s in which a healthy register
|
||||
could have answered it. The live capture contains one (10:48:14, cleared one
|
||||
second later). Its size is one `stale_input_s` hold, during which `cycle()`
|
||||
returns at `inputs missing {n}s` and commands nothing differently. Fixing it
|
||||
means measuring the unchanged clock from when the **obligation** appeared rather
|
||||
than from the register's last move, which re-derives the 35.0 s bound and is a
|
||||
different card.
|
||||
|
||||
**Nothing was measured on two inverters.** Inverter B does not physically exist
|
||||
(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two
|
||||
copies of A's behaviour with different numbers passed in.
|
||||
|
||||
**No deploy is proposed.** 0.10.0 is on the reference install and this branch's
|
||||
CHANGELOG row is filed under it only because `test_version_gate.py` part 11b
|
||||
requires the newest heading to be a version. Moving it, and deciding whether a
|
||||
version is cut, is a PKG card's.
|
||||
@@ -0,0 +1,351 @@
|
||||
# TEL-11 — the PV production channel
|
||||
|
||||
*What was decided, what it was decided against, and what the evidence was.*
|
||||
|
||||
## 0. The problem, in OPT-10's numbers
|
||||
|
||||
Over sixteen days at the reference site the pack reached 90 % SoC on **two** of
|
||||
them, and **20.5 kWh of surplus left the site while the pack still had room** —
|
||||
**12.6 kWh** of it with the battery **not charging at all**. That single
|
||||
measurement is upstream of most of what else was found that week: the nightly
|
||||
floor, SAFETY-37's misdiagnosis, the SAFETY-57 deadlock, a 2 h 47 min saturation
|
||||
freeze.
|
||||
|
||||
It cannot be **attributed**, because **PV production is recorded nowhere in this
|
||||
system**. Three causes need three different fixes:
|
||||
|
||||
| cause | fix |
|
||||
|---|---|
|
||||
| there was no irradiance | none — the site behaved correctly |
|
||||
| production was there, charge power was limited | a limit, somewhere |
|
||||
| production was there, the pack had room, the optimiser declined | **the only software defect** |
|
||||
|
||||
Guessing between them is how an optimiser gets tuned to solve a cloud problem.
|
||||
**This card adds the channel and does not do the attribution.** Ship it, let it
|
||||
record a week, OPT-10 picks it up with data.
|
||||
|
||||
## 1. It is an HA entity, not a register — and that is a product decision
|
||||
|
||||
The RS485 bus this add-on speaks carries the **battery** inverter it drives. On
|
||||
nearly every site the PV inverter is a different box from a different vendor,
|
||||
reporting into Home Assistant through its own integration; it is **not** on this
|
||||
bus and there is no reason it would be. So the signal comes from an entity:
|
||||
`pv_entity` takes whatever `sensor.*` the site's PV integration provides, any
|
||||
brand HA can read.
|
||||
|
||||
Reading a register would have been cheaper on one house and wrong for a fleet —
|
||||
the trade [[product-not-one-house]] names.
|
||||
|
||||
## 2. The sign convention: **positive is production**, and there is no `pv_invert`
|
||||
|
||||
### The convention is derived, not assumed
|
||||
|
||||
Two sign rules already ship, and both are recorded in `DOCS.md`'s *Sources*
|
||||
table rather than inferred here:
|
||||
|
||||
* `meter_entity` — **positive must mean importing** (`meter_invert` flips it);
|
||||
* `batt_entity` — **`+` = discharging** (`batt_invert` flips it).
|
||||
|
||||
Both are *"positive = power arriving at the house's AC bus"*. Production arrives
|
||||
there too, so **positive = producing** is the continuation of the shipped
|
||||
convention rather than a fresh choice.
|
||||
|
||||
### Why an invert flag would be over-building
|
||||
|
||||
The question is not "could a sensor be reversed" — it could. It is what a flag
|
||||
would **buy**, and the answer is nothing, for two reasons that do not apply to
|
||||
the two keys it would sit beside:
|
||||
|
||||
1. **Those two quantities are read by the control path; this one is read by
|
||||
nothing.** `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
|
||||
circulating-power sensor (`_read_unit()`'s own docstring says so), so a
|
||||
reversed battery sensor hands the site a plausible number it then **acts**
|
||||
on — a latch that trips when it should not, or does not trip when it should.
|
||||
`pv_w` reaches no decision at all (§4), so a reversed PV sensor cannot
|
||||
mislead one.
|
||||
2. **Production is unipolar, so the error is self-announcing.** Battery power
|
||||
and grid power are legitimately negative half the time, which is exactly why
|
||||
a flipped sign is invisible in them. A PV array does not consume: a reversed
|
||||
sensor reads **−4 kW at noon**, in the first day of the very history this card
|
||||
exists to create. An invert flag would let an installer *hide* that by
|
||||
flipping it to something that looks right, and a second place for the sign to
|
||||
be wrong is a second thing to get wrong.
|
||||
|
||||
So: the convention is documented in `config.yaml`, both translations, `DOCS.md`
|
||||
and `app/pv.py`, the value is published **exactly as the sensor reports it**,
|
||||
and there is no flag. `test_pv.py` part 1 pins `-8 W` through unchanged.
|
||||
|
||||
### And no negative-value alarm either
|
||||
|
||||
The tempting middle path — warn when the reading is negative — needs a
|
||||
threshold, because a PV inverter's own standby draw makes a small negative
|
||||
reading at night entirely ordinary. Any threshold here would be a number nobody
|
||||
measured, in a card whose whole subject is not fabricating numbers. The
|
||||
published row is the detector.
|
||||
|
||||
## 3. Absence is never zero, and it is not "dropped" either
|
||||
|
||||
This is the constraint that shaped the publication, and the obvious
|
||||
implementation fails it.
|
||||
|
||||
`MqttPublisher.publish()` **skips** a `None` value. That is right for
|
||||
`circulation` — its note in `main.py` says a missing per-unit reading should
|
||||
leave the last figure standing rather than publish a zero that reads as health —
|
||||
and it is **exactly wrong here**: a skipped row leaves Home Assistant holding
|
||||
the last number it was told, *forever*, and the recorder then draws a **flat
|
||||
line at that value through the outage**. Read back a week later that is
|
||||
indistinguishable from a real measurement, and if the last value happened to be
|
||||
small it manufactures precisely the *"there was no surplus to store"* conclusion
|
||||
the card exists to **test**.
|
||||
|
||||
So TEL-11 publishes the absence, using Home Assistant's own mechanism:
|
||||
|
||||
> `homeassistant/components/mqtt/const.py`: `PAYLOAD_NONE = "None"`.
|
||||
> `homeassistant/components/mqtt/sensor.py`, in the state-message handler:
|
||||
> `if payload == PAYLOAD_NONE: self._attr_native_value = None; return` — and it
|
||||
> is tested **before** the numeric branch, so it holds for a row carrying
|
||||
> `device_class: power`.
|
||||
> (github.com/home-assistant/core, branch `dev`, retrieved 2026-09-24. The
|
||||
> shipped MQTT-sensor documentation says the same thing in its `state_topic`
|
||||
> entry: *"a 'None' value will set the sensor to an unknown state"*.)
|
||||
|
||||
The entity therefore goes to **`unknown`** and the recorder stores a **gap**.
|
||||
`mqtt.ABSENT_AS_UNKNOWN` is a one-key set and `test_pv.py` part 3 asserts both
|
||||
that the pv row gets this treatment **and that `circulation` still does not** —
|
||||
widening the rule silently would be a behaviour change to another card's row.
|
||||
|
||||
⚠️ **What was NOT built, and why.** A per-entity `availability` block
|
||||
(`availability_mode: all`, an extra retained topic) would also produce a
|
||||
distinguishable state — `unavailable` rather than `unknown` — but it needs a
|
||||
second topic, a second retained message, a change to `_payload()`'s
|
||||
byte-order-sensitive key construction, and it survives restarts only if the
|
||||
retention is right. The payload route is one branch in `publish()` and one
|
||||
constant, and it is graded as **bytes on the topic** rather than as a claim
|
||||
about HA's availability handling.
|
||||
|
||||
### Three states, not two
|
||||
|
||||
| state | frame | wire | page |
|
||||
|---|---|---|---|
|
||||
| no source (`pv_entity` unset) | no `pv` key | no topic, no entity | no row |
|
||||
| no reading | `pv: None` | `goodwe_ctl/pv` = `None` | `PV —` + a caution naming the reason |
|
||||
| zero watts | `pv: 0.0` | `goodwe_ctl/pv` = `0.0` | `PV 0 W` |
|
||||
|
||||
A failed read also **clears** the last good value (`_read_pv()` assigns on every
|
||||
call), so a reading never outlives its own sensor. `test_pv.py` part 2 drives a
|
||||
good read followed by a dead one, because a fixture that answers the same thing
|
||||
forever cannot reach that failure — MUTATION.md habit 9's shape.
|
||||
|
||||
## 4. It must not become a control input, and that is graded twice
|
||||
|
||||
### In the suite
|
||||
|
||||
`test_pv.py` part 6 drives twelve cycles of the real `Controller.cycle()`
|
||||
against a 6 kW export and asserts the commands written to the inverter are
|
||||
**bit-identical** across `unset` / `no reading` / `0 W` / `8 kW` of production,
|
||||
with a precondition check that the stimulus commands something at all (habit 1).
|
||||
`inputs_ok` is asserted not to move either: a broken PV integration is **not** a
|
||||
missing input, because nothing computes with it.
|
||||
|
||||
A computed census (habit 12 — the scope is derived from the tree, not typed)
|
||||
then reports which of the 33 modules under `app/` mention the material in any
|
||||
spelling, comments included. It is **three**: `pv.py`, `main.py`, `mqtt.py`. The
|
||||
scanner's own red direction is exercised on every run.
|
||||
|
||||
### Across trees, by hash
|
||||
|
||||
`tools/safety56_behaviour_trace.py` is the pattern; `tools/tel11_pv_trace.py` is
|
||||
this card's version of it, and **its control clause had to be different**, which
|
||||
is worth stating because the difference is the interesting part.
|
||||
|
||||
SAFETY-56 changed wording and had to show behaviour did not move, so its
|
||||
`--compare` **refuses** unless the `sentences` halves **differ** — otherwise the
|
||||
two runs might be one tree twice. TEL-11's headline claim is the opposite shape:
|
||||
with the option unset, **nothing** differs. "The documents are identical" is the
|
||||
*result*, so it cannot also be the control. The two things proved instead are:
|
||||
|
||||
* the two runs came from **different trees** (`app/*.py` sha256 + byte count,
|
||||
compared, and an identity is **refused**);
|
||||
* **exactly one** side reacts to `pv_entity` — that side is the HEAD, the other
|
||||
is a tree that does not have the option. Neither, or both, is refused.
|
||||
|
||||
It drives 41 stimulus cells (5 grid worlds × 8 PV schedules, plus a run stopped
|
||||
part-way) and prints two documents per setting: `control` — every command
|
||||
written, every `self.target`, `inputs_ok`, the loop's three fields, the
|
||||
arbiter's resolution, the maintenance phase, tick by tick, **with no TEL-11
|
||||
material in it** — and `telemetry`, which is everything the card is allowed to
|
||||
move.
|
||||
|
||||
Measured, in the CI image, on purged trees:
|
||||
|
||||
```
|
||||
HEAD is B; baseline is A (it does not react to `pv_entity`).
|
||||
tree A 73296909170ec789237d6e704cce9ab0d9f83b3df1495a14d2497285c3041a12
|
||||
tree B d5011b6a18e2e3803dc09cac0214b0378fa4c7388fc0ad8ad89b832a5c92af48
|
||||
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control B set 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
|
||||
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
|
||||
telemetry B set 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
|
||||
OK 1/3 UNSET: control AND telemetry are byte-identical to the baseline tree.
|
||||
OK 2/3 SET: the control document is byte-identical with the channel switched on.
|
||||
OK 3/3 CONTROL OF THE CONTROL: the telemetry document DOES move when the
|
||||
option is set (42 stimulus cells differ).
|
||||
```
|
||||
|
||||
Note what clause 1 claims and clause 2 does not: **unset** is identical in
|
||||
`control` *and* `telemetry`, which is stronger than "the control path is
|
||||
untouched" — an install that does not set the option sees no change of any kind,
|
||||
including its announcement and its status rows. **Set** is identical in
|
||||
`control` only, which is the whole of "instrumentation, not an input".
|
||||
|
||||
## 5. Genuinely optional — and not a second `price_now_entity`
|
||||
|
||||
`price_now_entity` / `price_avg_entity` are legal **only** while
|
||||
`price_provider` is `off`, and the add-on **refuses to start** otherwise. That
|
||||
is the trap this card was told not to rebuild, and it is avoided structurally
|
||||
rather than by intention:
|
||||
|
||||
* `validate.py` does not mention `pv_entity` at all. It cannot conflict with
|
||||
anything, because nothing else reads it.
|
||||
* the MQTT row lives in `OPTIONAL_SENSORS`, not `SENSORS`, and
|
||||
`discovery_payloads()`'s new `include` argument **defaults to empty**. So a
|
||||
caller that has never heard of `pv_entity` — every fixture in `test_mqtt.py`,
|
||||
`tools/rehearse_roster.py`, anything written next year — announces exactly
|
||||
what it announced before. `p1_age` solves the same problem the other way
|
||||
round, with `omit`, because it is announced by default and withdrawn when the
|
||||
meter is off; getting that direction wrong here would have left most installs
|
||||
with an entity nothing feeds, which is the hazard `omit`'s own note names.
|
||||
* the read is gated on the option, so an unset install makes **no state call at
|
||||
all** — not a read that fails quietly.
|
||||
* there is **no status row** when it is unset. `peak_forecast_entity` nags when
|
||||
it is unset because an unset forecast leaves a money guard without an input;
|
||||
this is instrumentation, and a permanent caution on every install that never
|
||||
asked for a PV channel is the warning spam the card forbids.
|
||||
* when it **is** set and the read fails, the row is `ok=False, warn=True` — a
|
||||
**caution**. `status_payload()` builds `NOT READY` out of rows that are
|
||||
neither `ok` nor `warn`, so a telemetry channel can never put a battery site
|
||||
into `NOT READY`.
|
||||
|
||||
## 6. The unit is read off the entity, and an unreadable one is refused
|
||||
|
||||
`read_inputs()` guesses kW-or-W for `peak_forecast_entity` with
|
||||
`abs(raw) < 50`, and that is safe there: nobody's quarter-hour demand forecast
|
||||
is 50 W. It is **not** safe here — 30 W of real production at dusk is an
|
||||
ordinary reading, and that heuristic would publish it as 30 kW.
|
||||
|
||||
So `pv.watts()` reads the entity's own `unit_of_measurement` and converts
|
||||
against Home Assistant's own `UnitOfPower` table (`mW`, `W`, `kW`, `MW`, `GW`,
|
||||
`TW`; every factor an exact power of ten). A sensor with **no** unit, or a unit
|
||||
outside that table, is **refused by name** and the status page says which. The
|
||||
refusal is loud; a guess would be silent, and a factor of 1000 in the one
|
||||
channel that exists to be believed a week later is the worse failure.
|
||||
|
||||
⚠️ `BTU/h` is in HA's enum and is deliberately **not** in the table: its factor
|
||||
is a measured conversion rather than a decimal shift, no PV integration
|
||||
publishes in it, and a fabricated constant is what this repository's whole
|
||||
process exists to prevent. It lands in the refusal, by name, and `test_pv.py`
|
||||
part 1 pins that.
|
||||
|
||||
## 7. The mutation battery
|
||||
|
||||
Every check this card adds was driven by the mutation that defeats the claim it
|
||||
names. Method: `cp -a` of the tree per mutation into a **unique** directory,
|
||||
the edit applied there, `__pycache__` purged, the suites run in the CI image
|
||||
with the tree bind-mounted `:ro`, one file at a time. **The shipped tree is
|
||||
never edited**, so habit 18's revert hazard — SAFETY-37's harness killed between
|
||||
the edit and the `cp` that put the file back, and two harnesses sharing one
|
||||
backup path — is closed by construction rather than by a verified revert. Each
|
||||
row below records the byte delta, the file's sha256 before and after, and that
|
||||
the mutated line was **read back off disk** before the run started.
|
||||
|
||||
| # | mutation | file | bytes | sha256 (first 16) before → after | red on |
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | the `pv` row moved into `SENSORS`, i.e. announced on every install | `mqtt.py` | −79 B | `328d249b9d698ef6` → `532d3954c023bf3d` | 3 named in `test_mqtt.py`, 1 in `test_pv.py` |
|
||||
| M2 | `publish()` drops a `None` `pv` row again, so HA keeps the last number | `mqtt.py` | −98 B | `328d249b9d698ef6` → `dd54be458f446475` | 1 named (the acceptance criterion itself) |
|
||||
| M3 | `ABSENT_AS_UNKNOWN` widened past the one key it was written for | `mqtt.py` | +23 B | `328d249b9d698ef6` → `8fe571ebff3dacf4` | 1 named (`circulation` must still be dropped) |
|
||||
| M4 | a dead sensor reported as **0 W** instead of an absence | `pv.py` | −1 B | `55ba7471e9995436` → `9eecc34152b7ac12` | 4 named |
|
||||
| M5 | `_read_pv()` assigns only on a good read — a value outlives its sensor | `main.py` | +46 B | `c4d2cab34e50b5fd` → `189802aa69ce166d` | 2 named |
|
||||
| M6 | the unit is read and then ignored — a kW sensor published as watts | `pv.py` | −18 B | `55ba7471e9995436` → `ab19ef66c52f9379` | 2 named |
|
||||
| **M7** | **`self.grid = self.grid − self.pv_w` in `read_inputs()` — PV wired into the control path** | `main.py` | +106 B | `c4d2cab34e50b5fd` → `c38216edb7c5c8db` | **2 named, plus `tel11_pv_trace.py` clause 2/3 on 26 of the 41 stimulus cells** |
|
||||
| M8 | `optional_keys()` returns `("pv",)` unconditionally | `mqtt.py` | −52 B | `328d249b9d698ef6` → `86ff23efe3088ad8` | 3 named |
|
||||
| M9 | the PV caution made a `bad` row, i.e. a dead sensor is a `NOT READY` | `main.py` | +1 B | `c4d2cab34e50b5fd` → `45c74cab89943564` | 1 named |
|
||||
| M10 | the read gate removed — an unset option is read once a second forever | `main.py` | −10 B | `c4d2cab34e50b5fd` → `b67baa8aba3019e8` | 2 named, **plus two crashes** |
|
||||
|
||||
`on_disk = True` for all ten: the mutated line was grepped back out of the file
|
||||
after the edit and before the run (MUTATION.md reporting rule 2), and no
|
||||
mutation is size-preserving, so rule 1's `+0 B` caveat does not arise.
|
||||
|
||||
⚠️ **M10's two extra reds are CRASHES, not named checks, and that is recorded
|
||||
rather than tidied away.** Every `Hass` double in `test_inverter.py` and
|
||||
`test_telemetry.py` predates this read and has no `state()` method, so an
|
||||
unconditional PV read is an `AttributeError` rather than a failed assertion —
|
||||
habit 20's shape, a red with no name attached. Fixing those fixtures would be a
|
||||
change to what *their* checks report and belongs to whoever next touches them;
|
||||
this card's own fixture has `state()` and grades the property by name.
|
||||
|
||||
⚠️ **M7's trace limb is where the two halves meet.** The mutant moves the
|
||||
control document on 26 of 41 cells — every cell whose PV schedule produces a
|
||||
READING. The five `none` cells and the five `zero` cells do **not** move, which
|
||||
is correct and is worth seeing: subtracting nothing, and subtracting zero,
|
||||
change nothing. A battery that only drove a dead sensor would have reported this
|
||||
mutation as a survivor for the third time.
|
||||
|
||||
The clean tree those `before` hashes belong to: `app/main.py`
|
||||
`c4d2cab34e50b5fd`, `app/mqtt.py` `328d249b9d698ef6`, `app/pv.py`
|
||||
`55ba7471e9995436`.
|
||||
|
||||
### ⚠️ M7 was a SURVIVOR on the first battery, and the fixture was the reason
|
||||
|
||||
The card's **central** mutation — `self.grid = self.grid - self.pv_w` in
|
||||
`read_inputs()`, i.e. exactly the defect the whole card forbids — came back
|
||||
**green** on a six-file run at **+106 B**, on disk, purged, genuinely running.
|
||||
|
||||
The property did not hold; the fixture could not reach it. `test_pv.py` part 6
|
||||
drove a **6 kW export over 12 cycles**, which puts the loop **against its
|
||||
rail**: the command moves `slew_w` every cycle and −6000 W and −14 000 W of
|
||||
apparent error produce the **same ramp, tick for tick**. MUTATION.md habit 23,
|
||||
with a saturated actuator in place of a coinciding value — and the habit's list
|
||||
of no-op-prone constructs is all *expressions*, so this is worth its own entry,
|
||||
which it now has.
|
||||
|
||||
The fixture now drives a **600 W import the loop settles on**, with a
|
||||
responsive meter (`grid = base − batt`) so the error actually closes, asserts
|
||||
the resting target is well inside the rail, and asks habit 23's own question as
|
||||
its own check: **shift the meter by the same 8 kW and confirm the commands
|
||||
really would have moved.** On the first fixture that question answers *no*, in
|
||||
one line. Every number in the table above is from the battery re-run against
|
||||
the fixed tree.
|
||||
|
||||
⚠️ **A red count is a property of the suite as much as of the mutation.** These
|
||||
name **this** tree at this commit; a later card that adds or re-bases a check
|
||||
moves the number with the mutation untouched. Re-measure rather than inherit.
|
||||
|
||||
## 8. What none of this covers, with shapes
|
||||
|
||||
* **Home Assistant itself.** That `None` renders as `unknown` and that the
|
||||
recorder keeps a gap is read out of HA's source and quoted in §3; no HA runs
|
||||
in this suite. What is graded here is the bytes this add-on publishes.
|
||||
* **Whether the operator's sensor tells the truth.** A sensor wired backwards,
|
||||
or reporting DC where AC was meant, or a string instead of the array, is
|
||||
published faithfully. §2 is the argument for why that is the right failure
|
||||
mode; it is not a claim that it cannot happen.
|
||||
* **A roster of more than one.** The row is a **site** row — there is one PV
|
||||
array as far as this add-on is concerned — so nothing per-unit exists to
|
||||
separate, and every fixture is the shipped roster of one.
|
||||
* **The attribution.** This card records production. Whether the 20.5 kWh was
|
||||
cloud, a limit or the optimiser is OPT-10's, and it needs a week of this
|
||||
channel's history before it can be asked.
|
||||
* **`tools/`, the firmware and the ingress HTML** are outside part 6's census.
|
||||
* **The identity is only as wide as the stimulus.** `tel11_pv_trace.py` prints
|
||||
its 41 cells with the documents for exactly that reason.
|
||||
|
||||
## 9. No `CHANGELOG.md` entry
|
||||
|
||||
`test_version_gate.py` 11b refuses a newest `## ` heading that is not
|
||||
`config.yaml`'s `version:`, and opening an `## Unreleased` section would mean
|
||||
taking a packaging decision this card does not get to take. The operator account
|
||||
is in `DOCS.md`; the release note belongs to the next PKG ticket, which should
|
||||
say that **0.10.1 → next** adds one optional key, announces no new entity on an
|
||||
install that does not set it, and changes no control behaviour.
|
||||
@@ -139,6 +139,7 @@ either direction, and the check is mutation-tested both ways.
|
||||
| `batt_invert` | | Flip if needed |
|
||||
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
|
||||
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
|
||||
| `pv_entity` | | Optional. Your **solar** inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
|
||||
|
||||
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
||||
those serve cached values, and a stale reading here ends the maintenance charge
|
||||
@@ -413,6 +414,97 @@ and `Inverter B` — are two names to the validator and one id once slugged. The
|
||||
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
|
||||
get then depend on roster order. Give your inverters plainly different names.
|
||||
|
||||
### PV production (`pv_entity`, TEL-11)
|
||||
|
||||
**Optional, off by default, and it changes no control behaviour whatsoever.**
|
||||
Set it and the add-on records what your solar is producing beside what the
|
||||
meter and the battery are doing. Leave it empty and the add-on reads no entity,
|
||||
announces no entity, publishes no topic and shows no row — which is what every
|
||||
install that upgrades into this version does.
|
||||
|
||||
#### Why it is a Home Assistant entity and not a register
|
||||
|
||||
This add-on's RS485 bus carries the **battery** inverter it drives. On nearly
|
||||
every site the PV inverter is a different box from a different vendor, on its
|
||||
own connection, reporting into Home Assistant through its own integration. So
|
||||
`pv_entity` takes whatever `sensor.*` your PV integration already provides —
|
||||
any brand Home Assistant can read. It does not have to be on the bus, and there
|
||||
is no assumption anywhere that it is.
|
||||
|
||||
#### What it is for
|
||||
|
||||
OPT-10 measured this site over sixteen days: the battery reached 90 % on **two
|
||||
of them**, and **20.5 kWh of surplus left the site while the pack still had
|
||||
room** — 12.6 kWh of it with the battery not charging at all. Three different
|
||||
things produce that number and they need three different fixes:
|
||||
|
||||
* there was no irradiance to store — nothing to fix;
|
||||
* production was there and something limited the charge power;
|
||||
* production was there, the battery could take it, and the optimiser declined.
|
||||
|
||||
Only the third is a software defect, and **none of them can be told apart
|
||||
without a record of production.** This entity is that record. The attribution
|
||||
itself is OPT-10's, with a week of history behind it.
|
||||
|
||||
#### The sign, and why there is no `pv_invert`
|
||||
|
||||
**Positive must mean producing.** That is the same convention as the two sign
|
||||
rules the add-on already ships — `meter_entity` is positive when **importing**
|
||||
and `batt_entity` is positive when **discharging**, both of which are "power
|
||||
arriving at the house" — and production arrives there too.
|
||||
|
||||
`meter_invert` and `batt_invert` exist because those two quantities are
|
||||
**bipolar and are read by the control path**: a reversed battery sensor is a
|
||||
perfectly plausible number that the per-inverter sign failsafe then acts on.
|
||||
Production is unipolar and is read by **nothing**, so a reversed sensor here
|
||||
cannot mislead a decision — and it announces itself immediately as −4 kW at
|
||||
noon in the very history this entity creates. A flag would only hide it, so
|
||||
there is not one. If your sensor really is inverted, fix it where it is
|
||||
produced.
|
||||
|
||||
#### The unit is read off the entity
|
||||
|
||||
The add-on reads your entity's own `unit_of_measurement` and converts: `mW`,
|
||||
`W`, `kW`, `MW`, `GW`, `TW` — Home Assistant's own power units. A sensor with
|
||||
**no** unit, or with a unit that is not one of those, is **refused**, and the
|
||||
status page says which entity and which unit. That is deliberate: there is no
|
||||
magnitude test that can separate 30 W of dusk from 30 kW of noon, so a guess
|
||||
here would be a silent factor of 1000 in the one channel that exists to be
|
||||
believed a week later.
|
||||
|
||||
#### ⚠️ Absence is never zero
|
||||
|
||||
| what happened | `sensor.goodwe_pv_power` | on the status page |
|
||||
|---|---|---|
|
||||
| `pv_entity` not set | the entity does not exist | no row at all |
|
||||
| set, sensor answered `4100 W` | `4100.0` | `PV 4 100 W` |
|
||||
| set, sensor answered `0 W` | `0.0` | `PV 0 W` |
|
||||
| set, sensor unavailable / missing / bad unit | `unknown` | `PV —`, plus a caution naming the reason |
|
||||
|
||||
The last two rows are the point. A dead sensor publishes Home Assistant's own
|
||||
"no value" payload, so the entity goes to **`unknown`** and the recorder stores
|
||||
a **gap** — not a flat line at the last number it saw. Reading a week of
|
||||
history back, *"no PV was reported"* and *"PV reported 0 W"* are different
|
||||
pictures, which is exactly the distinction the whole card exists to preserve.
|
||||
|
||||
A failed read also **clears** the previous good one: the value never outlives
|
||||
its own sensor.
|
||||
|
||||
#### ⚠️ What it does NOT do
|
||||
|
||||
It is **instrumentation**. It is not an input to the control law, the
|
||||
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog;
|
||||
it is not part of `inputs_ok`, so a PV integration that breaks cannot stop the
|
||||
battery being regulated; and its status row is a **caution**, never a
|
||||
`NOT READY`. Wiring production into the control law would be a behaviour change
|
||||
on a system driving a real battery — and it would destroy the measurement,
|
||||
because the loop would then be reacting to the thing being measured.
|
||||
|
||||
`tools/tel11_pv_trace.py` is what says so rather than claims it: it drives 41
|
||||
stimulus worlds through the shipped controller and hashes a document of control
|
||||
decisions containing none of this material, on this tree and on `release/1.0`,
|
||||
with the option set and unset.
|
||||
|
||||
### P1 meter ingestion
|
||||
|
||||
`meter_entity` above expects one signed sensor, which usually means a template
|
||||
@@ -526,6 +618,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,77 @@ 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.
|
||||
|
||||
⚠️ **SECOND INSTANCE, TEL-11, AND THE NO-OP WAS A SATURATED RAIL RATHER
|
||||
THAN A VALUE.** The card's whole subject is that a PV reading must never
|
||||
reach the control law, so its central mutation is
|
||||
`self.grid = self.grid - self.pv_w` in `read_inputs()` - **+106 B**, on
|
||||
disk, purged, genuinely running - and it came back a **SURVIVOR** of a
|
||||
six-file battery. The property held in no sense: the fixture drove a 6 kW
|
||||
export over 12 cycles, which puts the loop **against its rail**, and a
|
||||
command moving `slew_w` every cycle produces the SAME ramp for -6000 W and
|
||||
for -14000 W of apparent error. The mutant ran and could not change the
|
||||
answer.
|
||||
Two things generalise past PV:
|
||||
* **A SATURATED ACTUATOR IS A VALUE AT WHICH EVERY INPUT COINCIDES.** The
|
||||
habit-23 list above names `or`, `and`, truthiness, default arguments and
|
||||
`getattr` fallbacks - all *expressions* with a coinciding value. A rail,
|
||||
a clamp, a slew limit, a deadband and a latched alarm are the same hazard
|
||||
in the **plant**: they are where a controller's output stops being a
|
||||
function of its input. A fixture that parks there cannot separate any two
|
||||
inputs, and this repository's control fixtures park there by default,
|
||||
because the dramatic stimulus is the saturating one.
|
||||
* **SO THE PRECONDITION IS NOT "DOES IT COMMAND SOMETHING", IT IS "DOES IT
|
||||
REST SOMEWHERE THE MUTATION COULD MOVE IT FROM".** `test_pv.py` part 6
|
||||
now drives a 600 W import the loop SETTLES on, asserts the resting target
|
||||
is well inside the rail, and asks habit 23's own question as a check:
|
||||
shift the METER by the same 8 kW and confirm the commands really would
|
||||
have moved. On the first fixture that question answers NO, in one line,
|
||||
and it is the line that turns the identity into evidence.
|
||||
|
||||
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,98 @@ 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)
|
||||
# ⚠️ SAFETY-56. THE OBSERVATION IS A FACT, THE DIAGNOSIS WAS NOT, AND
|
||||
# THE SENTENCE NOW STOPS WHERE THE EVIDENCE DOES. The conjunction above
|
||||
# is satisfied by TWO disjoint worlds, and nothing this object is handed
|
||||
# separates them:
|
||||
# (1) the register has stopped reporting reality - what the gate was
|
||||
# built for - or
|
||||
# (2) reality is not moving, because the SOURCE cannot comply with
|
||||
# the command. A pack the BMS is refusing at its floor holds
|
||||
# still under any setpoint, and a register reporting that
|
||||
# truthfully publishes the same unchanged number a dead one does.
|
||||
# The live 2026-09-21 episode was (2) - the pack read 11 % for the
|
||||
# whole 850 s hold, the commands were discharge-only, and the register
|
||||
# answered the first CHARGE command within one obligation window
|
||||
# (test_freshness.py 11a measures all three off the committed capture)
|
||||
# - and this sentence asserted (1), which sent the operator to look at
|
||||
# healthy hardware.
|
||||
# Naming both is what the module can honestly say. Naming WHICH would
|
||||
# need SoC and the pack floor - deliberately not this module's inputs
|
||||
# (see the `soc` row in BOUNDS and the docstring's last section), and
|
||||
# TEL-08's and SAFETY-32's numbers rather than this sentence's.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT SHARED WITH `main.py`'s NOT READY ROW, WHICH SAYS
|
||||
# THE SAME AMBIGUITY IN DIFFERENT WORDS ON PURPOSE. This sentence is a
|
||||
# LOG LINE about a behaviour, quoted verbatim by `note()` onto the
|
||||
# status page and by `_read_unit()` into the action log; that one is a
|
||||
# BANNER about a named ENTITY, and the two answer different questions
|
||||
# ("what did the register do" against "can I trust this entity"). One
|
||||
# helper serving both would have to drop whichever half the other does
|
||||
# not need, and the half it dropped would be the operator's next step.
|
||||
self.reason = (
|
||||
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"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
|
||||
f"CANNOT TELL THEM APART: the register may have stopped reporting "
|
||||
f"reality, or the source may be unable to comply with the command "
|
||||
f"- a pack the BMS is refusing holds still under any setpoint, and "
|
||||
f"an honest register then reads exactly like a frozen one. Either "
|
||||
f"way the number cannot be read as this command's effect, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"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 +652,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 +666,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.
|
||||
|
||||
@@ -62,10 +62,11 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||
from .maintenance import (
|
||||
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
|
||||
)
|
||||
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs,
|
||||
unit_state_key)
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from . import pv as pv_read
|
||||
from . import shadow
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option, provider_configured
|
||||
@@ -872,6 +873,22 @@ class Controller:
|
||||
self.loop_i_lead = 0.0
|
||||
self.loop_reason = "not-running"
|
||||
self.loop_w = None # what the loop asked for last cycle, or None
|
||||
# --- TEL-11: the site's PV production, and NOTHING READS IT ----------
|
||||
# ⚠️ INSTRUMENTATION. It is published, it is shown on the status page,
|
||||
# and it reaches no decision: not `inputs_ok`, not the allocator, not
|
||||
# the arbiter, not a claim, not a watchdog. Wiring it into the control
|
||||
# law would be an unrequested behaviour change on a system driving a
|
||||
# real battery AND would destroy the measurement, because the loop would
|
||||
# then react to the quantity being measured. `tools/tel11_pv_trace.py`
|
||||
# is the instrument that says so, on both trees and both settings.
|
||||
#
|
||||
# ⚠️ `None` IS A READING THAT DOES NOT EXIST, AND IT IS SEEDED AS ONE.
|
||||
# Nothing here ever becomes 0.0 by default; see `app/pv.py`.
|
||||
self.pv_w = None
|
||||
# Why there is no reading, in words the page prints - or, on a good
|
||||
# read, the UNIT the value came in. Never empty.
|
||||
self.pv_why = ("not configured" if not self.pv_entity
|
||||
else "not read yet")
|
||||
# Did the grid-following loop actually win the LAST arbitration? That is
|
||||
# the question SAFETY-02 has to ask about the command standing on the
|
||||
# wire right now, so it is recorded when the arbitration happens rather
|
||||
@@ -1651,6 +1668,38 @@ class Controller:
|
||||
for inv in self.inverters)
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
@property
|
||||
def pv_entity(self) -> str:
|
||||
"""The configured PV production entity, or `""`. TEL-11.
|
||||
|
||||
⚠️ ONE READING OF THE OPTION, and `mqtt.optional_keys()` is graded
|
||||
against it: the thing that decides whether the row is ANNOUNCED and the
|
||||
thing that decides whether it is READ have to be the same predicate, or
|
||||
the add-on ends up with an entity nothing feeds or a topic nothing
|
||||
declared. `announced_units()` in mqtt.py is the same argument for the
|
||||
roster, in its own words.
|
||||
"""
|
||||
return str(self.o.get("pv_entity") or "").strip()
|
||||
|
||||
async def _read_pv(self) -> None:
|
||||
"""This cycle's PV production, or an ABSENCE with a reason. TEL-11.
|
||||
|
||||
⚠️ IT ASSIGNS ON EVERY CALL, INCLUDING THE FAILING ONES. Leaving the
|
||||
last good number standing through an outage is what `circulation` does
|
||||
on purpose - there, a stale figure beats a zero that reads as health -
|
||||
and it is exactly wrong here: this row is read back as HISTORY, and a
|
||||
value that persists through an outage is indistinguishable from a real
|
||||
one at the moment somebody is trying to work out whether the sun was
|
||||
shining.
|
||||
|
||||
⚠️ `hass.state()` RATHER THAN `hass.number()`, because the UNIT is half
|
||||
the reading. See `pv.watts()`: a PV integration may publish kW, and
|
||||
there is no magnitude heuristic that can separate 30 W of dusk from
|
||||
30 kW of noon.
|
||||
"""
|
||||
self.pv_w, self.pv_why = pv_read.watts(
|
||||
await self.hass.state(self.pv_entity))
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
|
||||
@@ -1682,8 +1731,26 @@ class Controller:
|
||||
# names for the WRITE path, one function up. Within a unit the order is
|
||||
# unchanged (SoC, then battery power), so at the shipped roster of one
|
||||
# this is the same two calls in the same order.
|
||||
await asyncio.gather(*(self._read_unit(inv, now_mono)
|
||||
for inv in self.inverters))
|
||||
#
|
||||
# --- TEL-11: AND THE PV READ RIDES IN THIS SAME GATHER ---------------
|
||||
# ⚠️ IN THE GATHER RATHER THAN ON A LINE OF ITS OWN, AND THAT IS THE
|
||||
# WHOLE "OBSERVABILITY MUST NOT BE ABLE TO DELAY CONTROL" DECISION.
|
||||
# This coroutine feeds the ESP32 heartbeat; a sequential read would add
|
||||
# its own worst case - `hass.state()`'s 10 s timeout - to every cycle,
|
||||
# so a hung PV sensor at a client site would push a healthy battery
|
||||
# towards layer 1's ~30 s veto for the sake of a telemetry row.
|
||||
# Gathered, its latency OVERLAPS reads that already happen and the
|
||||
# added worst case is zero. `peak_forecast_entity` below is read
|
||||
# serially and stays that way because it is a CONTROL input (the
|
||||
# capacity guard sizes off it), so the site is entitled to wait for it.
|
||||
#
|
||||
# ⚠️ AND AN UNSET `pv_entity` APPENDS NOTHING. Not a coroutine, not an
|
||||
# HTTP call, not a log line: at the shipped default this gather is the
|
||||
# same list of the same coroutines it has always been.
|
||||
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
|
||||
if self.pv_entity:
|
||||
_reads.append(self._read_pv())
|
||||
await asyncio.gather(*_reads)
|
||||
if o.get("peak_forecast_entity"):
|
||||
raw = await self.hass.number(o["peak_forecast_entity"])
|
||||
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
|
||||
@@ -1769,6 +1836,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 +1845,52 @@ 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)
|
||||
_LOG.error("SAFETY-37: %s battery-power register is not following "
|
||||
"its own command - %s. Treating it as MISSING, which "
|
||||
# ⚠️ 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.
|
||||
# ⚠️ SAFETY-56 REWROTE THE DIAGNOSIS HALF, WHICH SAFETY-57 LEFT
|
||||
# STANDING ON PURPOSE. It used to open "battery-power register is
|
||||
# not following its own command" - an accusation against a named
|
||||
# piece of hardware, and FALSE on the very episode above. The
|
||||
# pack read 11 % for the whole hold, every command in it was a
|
||||
# DISCHARGE, and the register answered the first CHARGE command
|
||||
# three seconds later - so the BMS was refusing, the register was
|
||||
# publishing that refusal correctly, and the operator was sent to
|
||||
# look at a working sensor. All three numbers are measured off the
|
||||
# committed capture by test_freshness.py part 11a. The gate's
|
||||
# conjunction is right and is NOT touched; only its conclusion was.
|
||||
#
|
||||
# ⚠️ AND THE TWO EXPLANATIONS ARE NAMED ONCE, IN `%s`, NOT TWICE.
|
||||
# This line's own words are the OBSERVATION plus the one thing a
|
||||
# log reader has to be told before acting - that the observation is
|
||||
# not yet a verdict - and `freshness._grade()`'s sentence, which
|
||||
# this line already interpolates, is where the pair is spelled out.
|
||||
# Saying it in both halves of one sentence is how an operator
|
||||
# learns to skim it.
|
||||
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
|
||||
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
|
||||
"ON THE REGISTER - %s. Treating it as MISSING, which "
|
||||
"commands 0 W. It clears itself the moment the register "
|
||||
"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:
|
||||
@@ -4355,6 +4464,24 @@ class Controller:
|
||||
"loop_lead": round(self.loop_i_lead, 1),
|
||||
"loop_reason": self.loop_reason,
|
||||
}
|
||||
# --- TEL-11 ----------------------------------------------------------
|
||||
# ⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO READING,
|
||||
# AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE. Those are two different
|
||||
# states and the publisher treats them as two: `mqtt.ABSENT_AS_UNKNOWN`
|
||||
# turns the first into HA's own `None` payload, so the entity's state
|
||||
# goes to `unknown` and the recorder records a GAP - which is how a
|
||||
# reader of a week of history tells "no PV reported" from "PV reported
|
||||
# 0 W". The second never reaches the broker at all, because an install
|
||||
# that never set `pv_entity` has no such entity announced.
|
||||
#
|
||||
# ⚠️ AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE
|
||||
# ANNOUNCEMENT USES. Written as a second predicate here the two could
|
||||
# disagree, and both directions are bad - an announced entity nothing
|
||||
# feeds, or a topic nothing declared. `announced_units()` two blocks
|
||||
# down is the same discipline for the roster.
|
||||
if "pv" in optional_keys(self.o):
|
||||
values["pv"] = (None if self.pv_w is None
|
||||
else round(self.pv_w, 1))
|
||||
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
||||
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
|
||||
# note in mqtt.py; the composed name is not the object_id - and forces layer-1
|
||||
@@ -4636,8 +4763,38 @@ class Controller:
|
||||
if not entity:
|
||||
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
|
||||
elif value is None:
|
||||
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
|
||||
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read
|
||||
# "<entity> is missing or not numeric" for ANY None, and one of
|
||||
# the ways this value becomes None is SAFETY-37's freshness gate
|
||||
# withholding a reading that is present, numeric and updating -
|
||||
# `Freshness.reading()` returns None BY DESIGN there. On the
|
||||
# live site on 2026-09-21 the operator opened Developer Tools on
|
||||
# the entity this row named, found it working, and had every
|
||||
# reason to conclude the diagnostics were broken.
|
||||
#
|
||||
# ⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The
|
||||
# row could ask `inv.batt_fresh.stale` - it is on the same
|
||||
# object at the same moment - but this loop also renders the
|
||||
# grid and SoC rows, which have no gate, and a branch per row
|
||||
# would be a second place where "why is this None" is decided.
|
||||
# The gate already has a surface of its own: `stale_note()`
|
||||
# rides `liveness_notes()` a few rows below with the whole
|
||||
# STALE_READING sentence in it, so the operator's discriminator
|
||||
# is "is that row there?" and this row's job is only to stop
|
||||
# pre-empting the answer. Same reason the wording is not shared
|
||||
# with `freshness._grade()`: that one is a log line about a
|
||||
# BEHAVIOUR, this one is a banner about an ENTITY.
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": f"{label}: {entity} is missing or not numeric"})
|
||||
"text": f"{label}: no usable value this cycle from "
|
||||
f"{entity} - and this row cannot tell which "
|
||||
f"of two things that is: the entity is "
|
||||
f"missing or not numeric, or it is "
|
||||
f"publishing a number that this controller "
|
||||
f"is withholding (a freshness gate does "
|
||||
f"exactly that, and names itself in a row "
|
||||
f"of its own). Confirm the entity in "
|
||||
f"Developer Tools before replacing it"})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
|
||||
|
||||
@@ -4842,6 +4999,34 @@ class Controller:
|
||||
# is both is a check nobody reads.
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": "no peak forecast: maintenance charging is not capacity-capped"})
|
||||
# --- TEL-11 ----------------------------------------------------------
|
||||
# ⚠️ NO ROW AT ALL WHEN `pv_entity` IS UNSET, AND THAT IS A DELIBERATE
|
||||
# DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one
|
||||
# nags because an unset forecast entity leaves a MONEY GUARD without an
|
||||
# input; this one is pure instrumentation, and a permanent caution on
|
||||
# every install that never asked for a PV channel is exactly the
|
||||
# "no warning spam, no degraded status row" the card forbids.
|
||||
#
|
||||
# ⚠️ AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a
|
||||
# CAUTION, never `bad`. `status_payload()` builds "NOT READY" from rows
|
||||
# that are neither ok nor warn, and a telemetry channel that could put a
|
||||
# battery site into NOT READY would be observability with a foot in the
|
||||
# control path. The reason comes from `pv.watts()` rather than being
|
||||
# re-derived here, so the page cannot disagree with the reading.
|
||||
if self.pv_entity:
|
||||
if self.pv_w is None:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"PV production: no reading from "
|
||||
f"{self.pv_entity} - {self.pv_why}"})
|
||||
else:
|
||||
# habit 14: the value sits inside its own literal text, in its
|
||||
# own slot, beside the entity it was read from and the unit it
|
||||
# was read in - so a transposition of any two of the three is
|
||||
# visible in the sentence rather than merely present in it.
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"PV production: {self.pv_w:.0f} W from "
|
||||
f"{self.pv_entity} (reported in "
|
||||
f"{self.pv_why})"})
|
||||
return out
|
||||
|
||||
def status_payload(self) -> dict:
|
||||
@@ -4961,6 +5146,13 @@ class Controller:
|
||||
# identically zero and a permanent "0 W" row would be noise.
|
||||
*(() if len(self.inverters) <= 1
|
||||
else ((("Circulating", w(self.site_circulating_w)),))),
|
||||
# TEL-11, and ONLY when a source is configured - see `checks()`.
|
||||
# `w(None)` renders "—", which is how this row says "no reading"
|
||||
# rather than "0 W". It sits beside Grid and Battery because the
|
||||
# question it answers is the one those two cannot: at "Grid 0 W,
|
||||
# Battery 0 W" the site is either becalmed or throwing away every
|
||||
# watt it makes, and only this row separates them.
|
||||
*(() if not self.pv_entity else ((("PV", w(self.pv_w)),))),
|
||||
("State of charge", "—" if self.pack_floor_soc is None
|
||||
else f"{self.pack_floor_soc:g} %"),
|
||||
("Commanded", w(self.target)),
|
||||
@@ -5234,6 +5426,11 @@ async def amain() -> None:
|
||||
broker.get("username") if broker else None,
|
||||
broker.get("password") if broker else None,
|
||||
omit=() if is_enabled(opts) else ("p1_age",),
|
||||
# TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE
|
||||
# OPTION - `Controller.publish()` gates the row on this same
|
||||
# call. An install that never set `pv_entity` announces exactly
|
||||
# what it announced before: no topic, no entity, no row.
|
||||
include=optional_keys(opts),
|
||||
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
|
||||
# would construct a SECOND set of `Inverter`s off the same
|
||||
# Store, minutes before `Controller.__init__` constructs the
|
||||
|
||||
@@ -117,6 +117,62 @@ SENSORS = [
|
||||
("loop_reason", "goodwe_loop_reason", "Loop reason", None, None, None, "mdi:sigma"),
|
||||
]
|
||||
|
||||
# --- TEL-11: rows that exist ONLY when the operator configured a source ------
|
||||
#
|
||||
# ⚠️ A SECOND LIST RATHER THAN A TENTH ENTRY IN `SENSORS`, AND THE DEFAULT IS
|
||||
# WHAT MAKES IT ONE. `SENSORS` is announced by every caller of
|
||||
# `discovery_payloads()`; `OPTIONAL_SENSORS` is announced only by a caller that
|
||||
# ASKS, so a caller that has not been taught about `pv_entity` - `test_mqtt.py`'s
|
||||
# fixtures, `tools/rehearse_roster.py`, anything written next year - announces
|
||||
# exactly what it announced before. `p1_age` solves the same problem the other
|
||||
# way round, with `omit`, because `p1_age` is announced by DEFAULT and withdrawn
|
||||
# when the meter is off; this one does not exist by default and appears when a
|
||||
# source for it does. Getting that direction wrong is the hazard `omit`'s own
|
||||
# note names: an entity that exists and is never fed is a worse signal than one
|
||||
# that does not exist at all, and MOST installs will never set `pv_entity`.
|
||||
#
|
||||
# ⚠️ `state_class: measurement` IS THE WHOLE POINT OF THE ROW. It is what puts
|
||||
# the number into the recorder's long-term statistics, and a week of statistics
|
||||
# is what OPT-10 needs to say whether the surplus that left this site was cloud,
|
||||
# a charge-power limit, or the optimiser declining to charge.
|
||||
OPTIONAL_SENSORS = [
|
||||
("pv", "goodwe_pv_power", "PV power", "W", "power", "measurement", None),
|
||||
]
|
||||
|
||||
# ⚠️ HA's OWN "this sensor has no value" PAYLOAD, and the one place this add-on
|
||||
# spells it. `homeassistant/components/mqtt/const.py` declares
|
||||
# `PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric
|
||||
# branch - `if payload == PAYLOAD_NONE: self._attr_native_value = None` - so the
|
||||
# entity's state becomes `unknown` rather than a number. (Read out of
|
||||
# github.com/home-assistant/core `dev` on 2026-09-24; the shipped MQTT-sensor
|
||||
# documentation says the same thing in its `state_topic` entry: "a 'None' value
|
||||
# will set the sensor to an unknown state".)
|
||||
PAYLOAD_NONE = "None"
|
||||
|
||||
# The keys whose ABSENCE MUST BE PUBLISHED rather than skipped. ⚠️ THIS IS THE
|
||||
# DIFFERENCE BETWEEN "no PV reported" AND "PV reported 0 W" IN A WEEK OF
|
||||
# HISTORY, AND WITHOUT IT THE CHANNEL LIES IN THE WORSE DIRECTION. `publish()`
|
||||
# below drops a `None` - which is right for `circulation`, where the last figure
|
||||
# standing is better than a zero that reads as health - but a DROPPED pv row
|
||||
# leaves Home Assistant holding the last number it was told, forever, and the
|
||||
# recorder then draws a flat line at that value through an outage. Someone
|
||||
# reading it back would conclude the sun was not shining, which is precisely the
|
||||
# conclusion this card exists to TEST rather than to manufacture.
|
||||
ABSENT_AS_UNKNOWN = frozenset({"pv"})
|
||||
|
||||
|
||||
def optional_keys(opts: dict) -> tuple:
|
||||
"""Which `OPTIONAL_SENSORS` rows this configuration has a source for.
|
||||
|
||||
⚠️ ONE ANSWER TO "IS THE PV ROW LIVE", read by the ANNOUNCEMENT (through
|
||||
`MqttPublisher`) and by `Controller.publish()`. `announced_units()` above
|
||||
exists for exactly this reason and says it in full: two spellings of one
|
||||
predicate is how an entity gets announced that nothing feeds, or fed on a
|
||||
topic nothing announced.
|
||||
"""
|
||||
return ("pv",) if str(opts.get("pv_entity") or "").strip() else ()
|
||||
|
||||
|
||||
BASE = "goodwe_ctl"
|
||||
AVAILABILITY = f"{BASE}/availability"
|
||||
|
||||
@@ -299,15 +355,23 @@ def _payload(name, object_id, unique_id, state_topic, device,
|
||||
return cfg
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=()) -> list:
|
||||
def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
"""Every `(topic, payload)` this add-on announces, in announcement order.
|
||||
|
||||
Split out of `_announce()` so that the acceptance criterion can be graded as
|
||||
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
|
||||
reading of the code. `_announce()` publishes exactly this list and nothing
|
||||
else, which is itself checked.
|
||||
|
||||
`include` names `OPTIONAL_SENSORS` keys to announce. ⚠️ IT DEFAULTS TO
|
||||
NOTHING AND THE ROWS GO LAST, so no existing row's POSITION moves under any
|
||||
configuration - not the site rows, not the per-unit ones. Order is part of
|
||||
the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an
|
||||
ordered LIST precisely because a re-order is a zero-byte edit that
|
||||
re-announces every entity on a live install).
|
||||
"""
|
||||
omit = set(omit)
|
||||
include = [k for k in include if k not in omit]
|
||||
out = []
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in omit:
|
||||
@@ -336,12 +400,19 @@ def discovery_payloads(units=(), omit=()) -> list:
|
||||
unique_id,
|
||||
f"{BASE}/{unit_state_key(slug, key)}",
|
||||
device, unit, dev_class, state_class, icon)))
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in OPTIONAL_SENSORS:
|
||||
if key not in include:
|
||||
continue
|
||||
out.append((
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
return out
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
def __init__(self, host, port, username=None, password=None, omit=(),
|
||||
units=()):
|
||||
units=(), include=()):
|
||||
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
||||
# sensor that nothing will ever publish to is not harmless here:
|
||||
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
||||
@@ -353,6 +424,10 @@ class MqttPublisher:
|
||||
# per-unit is announced, so every caller that has not been taught about
|
||||
# the roster keeps publishing what it published before.
|
||||
self.units = [str(u) for u in units]
|
||||
# TEL-11. `mqtt.optional_keys(opts)` - the shipped predicate, not a
|
||||
# second reading of the option. Defaults to empty, so a caller that has
|
||||
# not been taught about it announces exactly what it announced before.
|
||||
self.include = [str(k) for k in include]
|
||||
self.enabled = mqtt is not None and bool(host)
|
||||
self.client = None
|
||||
if not self.enabled:
|
||||
@@ -389,7 +464,8 @@ class MqttPublisher:
|
||||
# announcement, so the check that diffs it against `release/1.0` is
|
||||
# diffing what actually goes on the wire - and a payload built here
|
||||
# instead would be one this add-on publishes and no check has ever seen.
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit):
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit,
|
||||
self.include):
|
||||
self.client.publish(topic, json.dumps(cfg), retain=True)
|
||||
self.client.publish(AVAILABILITY, "online", retain=True)
|
||||
|
||||
@@ -399,7 +475,16 @@ class MqttPublisher:
|
||||
try:
|
||||
for key, value in values.items():
|
||||
if value is None:
|
||||
continue
|
||||
# ⚠️ TEL-11. A DROPPED ROW IS NOT AN ABSENT ROW. Skipping
|
||||
# leaves Home Assistant holding the last value it was told,
|
||||
# which is what `circulation` wants (its note in main.py
|
||||
# says why) and is exactly wrong for a measurement somebody
|
||||
# will read back as history: a flat line at the last number
|
||||
# is indistinguishable from a real one. See
|
||||
# `ABSENT_AS_UNKNOWN`.
|
||||
if key not in ABSENT_AS_UNKNOWN:
|
||||
continue
|
||||
value = PAYLOAD_NONE
|
||||
self.client.publish(f"{BASE}/{key}", str(value))
|
||||
except OSError as err: # pragma: no cover
|
||||
_LOG.debug("MQTT publish failed: %s", err)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""TEL-11 - the site's PV production, read from a Home Assistant entity.
|
||||
|
||||
WHY THIS IS AN ENTITY AND NOT A REGISTER. This add-on's RS485 bus carries the
|
||||
BATTERY inverter it drives. A household's PV inverter is, on nearly every site,
|
||||
a different box from a different vendor that reports into Home Assistant
|
||||
through its own integration - so the only place a product can read production
|
||||
for an arbitrary install is an HA entity. `pv_entity` therefore takes whatever
|
||||
`sensor.*` the site's own PV integration provides.
|
||||
|
||||
WHY THE CHANNEL EXISTS AT ALL. OPT-10 measured that 20.5 kWh of surplus left the
|
||||
site in 16 days while the pack had room, 12.6 kWh of it with the battery not
|
||||
charging at all - and could not say WHY, because PV production is recorded
|
||||
NOWHERE. Three causes need three different fixes (no irradiance: nothing to fix;
|
||||
a charge-power limit; the optimiser declining to charge - the only software
|
||||
defect), and guessing between them is how an optimiser gets tuned to solve a
|
||||
cloud problem. This module is the missing measurement and NOTHING ELSE.
|
||||
|
||||
⚠️ NOTHING HERE MAY EVER REACH A CONTROL DECISION, AND THAT IS THE CARD'S FIRST
|
||||
CONSTRAINT RATHER THAN A STYLE NOTE. The add-on regulates a real battery; adding
|
||||
production as a term of the control law would be an unrequested behaviour change
|
||||
AND it would destroy the measurement, because the loop would then be reacting to
|
||||
the quantity being measured. `tools/tel11_pv_trace.py` is what says the path is
|
||||
untouched: it grades a control document that contains none of this material on
|
||||
both trees and with `pv_entity` set and unset.
|
||||
|
||||
⚠️ ABSENCE IS NEVER ZERO, AND THAT IS THE SECOND CONSTRAINT. Every refusal below
|
||||
returns `None` with a REASON, never `0.0`. A PV channel that reported 0 W for a
|
||||
dead sensor would manufacture exactly the "there was no surplus to store"
|
||||
conclusion the card exists to TEST - and it would do it silently, in a week of
|
||||
recorder history nobody would re-derive. `app/hass.py`'s own note on `state()`
|
||||
is the same rule one layer down.
|
||||
|
||||
THE SIGN CONVENTION: POSITIVE IS PRODUCTION. Derived from the two conventions
|
||||
this add-on already ships rather than assumed:
|
||||
|
||||
`meter_entity` positive = IMPORTING from the grid (DOCS.md, Sources)
|
||||
`batt_entity` positive = DISCHARGING the pack (DOCS.md, Sources)
|
||||
|
||||
Both are "positive = power arriving at the house's AC bus", and production
|
||||
arrives there too. There is deliberately NO `pv_invert` to go with
|
||||
`meter_invert` and `batt_invert`: those two quantities are BIPOLAR and are READ
|
||||
BY THE CONTROL PATH - a reversed `batt_entity` feeds SAFETY-30's per-unit sign
|
||||
check and SAFETY-31's circulating-power sensor with a plausible number the site
|
||||
then acts on - whereas production is unipolar and is read by nothing. A reversed
|
||||
PV sensor announces itself as -4 kW at noon in the very history this card
|
||||
creates, and flipping it here would only hide it. See
|
||||
`DECISIONS/TEL-11-pv-production.md` §2.
|
||||
"""
|
||||
|
||||
from .hass import BAD_STATES
|
||||
|
||||
# Home Assistant's OWN power units, each as the number of watts one of it is.
|
||||
# ⚠️ READ OUT OF HA, NOT LISTED FROM MEMORY: `UnitOfPower` in
|
||||
# `homeassistant/const.py` (github.com/home-assistant/core, `dev`, retrieved
|
||||
# 2026-09-24) declares mW, W, kW, MW, GW, TW and BTU/h. Every factor here is an
|
||||
# exact power of ten, so none of them is a number anybody chose.
|
||||
#
|
||||
# ⚠️ BTU/h IS IN HA's ENUM AND DELIBERATELY NOT HERE. Its factor (0.293071…)
|
||||
# is a measured conversion rather than a decimal shift, no PV integration
|
||||
# publishes in it, and a wrong constant in the one channel that exists to
|
||||
# attribute a 20.5 kWh shortfall is worse than a refusal an operator can read.
|
||||
# It lands in the "not a power unit" refusal below, BY NAME.
|
||||
WATTS_PER = {
|
||||
"mW": 0.001,
|
||||
"W": 1.0,
|
||||
"kW": 1000.0,
|
||||
"MW": 1000000.0,
|
||||
"GW": 1000000000.0,
|
||||
"TW": 1000000000000.0,
|
||||
}
|
||||
|
||||
# What `watts()` answers when there is nothing to report. ⚠️ THE REASON IS PART
|
||||
# OF THE RETURN VALUE, not a log line: it is what the status page prints, and an
|
||||
# absence whose cause is invisible sends an operator to look at the wrong box.
|
||||
UNIT_KEY = "unit_of_measurement"
|
||||
|
||||
|
||||
def watts(state: dict | None) -> tuple:
|
||||
"""`(watts, why)` from a Home Assistant state object.
|
||||
|
||||
`watts` is `None` whenever there is no reading - and `why` then says which
|
||||
of the five refusals it was, in words an operator can act on. On a good read
|
||||
`why` is the unit the value was read in, because "1234 W" and "1234, unit
|
||||
unknown" are different claims and the page has to be able to make the first.
|
||||
|
||||
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
|
||||
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
|
||||
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
|
||||
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
|
||||
real production at dusk is an ordinary reading, and that heuristic would
|
||||
publish it as 30 kW - a fabricated number, in the channel whose entire
|
||||
purpose is to be believed a week later. A sensor whose unit this cannot read
|
||||
is REFUSED rather than guessed at.
|
||||
"""
|
||||
if state is None:
|
||||
# `hass.state()` answers None for a non-existent entity, a 404 and a
|
||||
# transport failure alike. All three are "no reading", and HA does not
|
||||
# consider a non-existent entity an error - see `hass.state()`.
|
||||
return None, "no state (entity missing, or the read failed)"
|
||||
raw = str(state.get("state", "")).strip()
|
||||
if raw.lower() in BAD_STATES:
|
||||
# ⚠️ THE SAME LIST `hass.number()` REFUSES ON, IMPORTED RATHER THAN
|
||||
# RESPELLED. A second copy of it is how one path starts treating
|
||||
# `unavailable` as a number the other one rejects.
|
||||
return None, f"entity is {raw or 'empty'}"
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return None, f"state {raw!r} is not numeric"
|
||||
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
|
||||
if not unit:
|
||||
return None, ("no unit_of_measurement on the entity - it cannot be "
|
||||
"read in watts")
|
||||
if unit not in WATTS_PER:
|
||||
return None, (f"unit {unit!r} is not one of "
|
||||
f"{', '.join(WATTS_PER)} - it cannot be read in watts")
|
||||
return value * WATTS_PER[unit], unit
|
||||
@@ -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
|
||||
@@ -147,6 +147,33 @@ options:
|
||||
inverters: []
|
||||
estop_fitted: false
|
||||
|
||||
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
|
||||
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
|
||||
# when it is empty the add-on reads no entity, announces no entity, publishes
|
||||
# no topic and shows no status row.
|
||||
#
|
||||
# ⚠️ AN HA ENTITY AND NOT A REGISTER, BECAUSE MOST SOLAR INVERTERS ARE NOT ON
|
||||
# THIS BUS. The RS485 bus this add-on speaks carries the BATTERY inverter it
|
||||
# drives; a household's PV inverter is normally a different box from a
|
||||
# different vendor, reporting into Home Assistant through its own integration.
|
||||
# So this takes whatever `sensor.*` the site's PV integration provides, any
|
||||
# brand Home Assistant can read.
|
||||
#
|
||||
# ⚠️ POSITIVE IS PRODUCTION, and there is deliberately no `pv_invert` beside
|
||||
# `meter_invert` and `batt_invert`. Those two quantities are bipolar AND are
|
||||
# read by the control path, so a reversed sensor there is a plausible number
|
||||
# the site acts on; production is unipolar and is read by nothing, so a
|
||||
# reversed sensor announces itself as -4 kW at noon in the history this key
|
||||
# exists to create. See DECISIONS/TEL-11-pv-production.md.
|
||||
#
|
||||
# ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW
|
||||
# and the rest of Home Assistant's power units. A sensor with no unit, or with
|
||||
# a unit that is not power, is REFUSED and the status page says which: there
|
||||
# is no magnitude heuristic that can tell 30 W of dusk from 30 kW of noon, and
|
||||
# a factor of 1000 in the one channel that exists to be believed a week later
|
||||
# is worse than a refusal an operator can read.
|
||||
pv_entity: ""
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
# ==========================================================================
|
||||
@@ -872,6 +899,12 @@ schema:
|
||||
capacity_kwh: float(0.1,200)?
|
||||
|
||||
estop_fitted: bool
|
||||
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
|
||||
# `price_now_entity`/`price_avg_entity`, which are legal only while
|
||||
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
|
||||
# Nothing in validate.py mentions this key: it cannot conflict with anything,
|
||||
# because nothing else reads it.
|
||||
pv_entity: str?
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"generated": "2026-09-21T11:30:00+02:00",
|
||||
"card": "SAFETY-57",
|
||||
"what": "The 2026-09-21 episode: a healthy 0x0518 declared stale, the site parked at 0 W, and no exit reachable from inside the state.",
|
||||
"source": {
|
||||
"host": "sniffer@192.168.2.167 (goodwe-sniff)",
|
||||
"nature": "PASSIVE listener on the RS-485 pair between the ESP32 master and the GW5000S-BP. It has no address on the bus and writes nothing.",
|
||||
"file": "~/bus-a.log",
|
||||
"window_local": [
|
||||
"2026-09-21T10:30:00+02:00",
|
||||
"2026-09-21T11:10:00+02:00"
|
||||
],
|
||||
"timezone": "Europe/Brussels (the capture stamps a time of day and no date; the date is asserted here, not derivable from the file)",
|
||||
"extraction": "lines whose time-of-day field lies in the window, verbatim, in capture order"
|
||||
},
|
||||
"file": {
|
||||
"name": "bus-a-2026-09-21-1030-1110.log.gz",
|
||||
"sha256_gz": "876fdaaf6d087125b184bfb4dd7ef4c3d1dd71ccad0fc271f8d268e8b489d134",
|
||||
"sha256_plain": "fa07e13b60e230626a8c0964b9539783a71b5627a522fc5930fd9fda54438a48",
|
||||
"bytes_gz": 17181,
|
||||
"bytes_plain": 182400,
|
||||
"lines": 3840
|
||||
},
|
||||
"decoder": "tools/decode_bus_capture.py - and it is the ONLY interpretation of these bytes this repository carries. Re-run it to re-derive every number below.",
|
||||
"frame_census": {
|
||||
"lines": 3840,
|
||||
"unparsed": 0,
|
||||
"bad_crc": 0,
|
||||
"requests": 720,
|
||||
"responses": 720,
|
||||
"writes": 1200,
|
||||
"write_echoes": 1200,
|
||||
"unknown": 0
|
||||
},
|
||||
"series": {
|
||||
"setpoint_w": {
|
||||
"frames": 1200,
|
||||
"changes": 224,
|
||||
"first_s": 37801.687,
|
||||
"last_s": 40199.693
|
||||
},
|
||||
"batt_w": {
|
||||
"frames": 240,
|
||||
"changes": 133,
|
||||
"first_s": 37804.716,
|
||||
"last_s": 40194.724
|
||||
},
|
||||
"soc_pct": {
|
||||
"frames": 240,
|
||||
"changes": 4,
|
||||
"first_s": 37806.725,
|
||||
"last_s": 40196.732
|
||||
}
|
||||
},
|
||||
"the_episode_in_the_capture": {
|
||||
"10:51:24.736": "0x0518 reaches -3 W. The pack is at 11 %, the loop has just reversed out of -447 W of charge, and the BMS refuses the discharge.",
|
||||
"10:51:29.695": "the setpoint reaches +1060 W of discharge - 800 W of travel from the command standing when the register last moved.",
|
||||
"10:52:21.695": "the setpoint is written as 0 W and stays there. This is the add-on's own `inputs missing -> command 0 W`, on the wire.",
|
||||
"10:51:24.736..11:05:34.734": "0x0518 does not change value. 850.0 s.",
|
||||
"10:52:21.695..11:05:31.698": "the setpoint does not change value. 790.0 s.",
|
||||
"~11:04": "a human restarts the add-on. NOT VISIBLE IN THIS FILE - it is an action on 192.168.2.158 - and it is the only reason the episode ends.",
|
||||
"11:05:31.698": "the restarted loop commands -400 W of CHARGE, which the pack accepts at 11 %.",
|
||||
"11:05:34.734": "0x0518 answers -137 W. The register was healthy throughout."
|
||||
},
|
||||
"not_in_this_file": [
|
||||
"the grid meter - a P1 telegram on a different wire. The 2.4 kW of export during the episode is from Home Assistant history and is NOT re-derivable here.",
|
||||
"anything the add-on believed: Inverter.batt, inputs_ok, log lines. Those are INFERRED by replaying the shipped modules against these series (test_freshness.py part 10).",
|
||||
"the restart at ~11:04."
|
||||
]
|
||||
}
|
||||
+1114
-16
File diff suppressed because it is too large
Load Diff
@@ -203,15 +203,21 @@ class FakeClient:
|
||||
self.msgs.append((topic, payload, retain))
|
||||
|
||||
|
||||
def announce(units=(), omit=(), module=mqtt):
|
||||
def announce(units=(), omit=(), module=mqtt, include=()):
|
||||
"""The messages `_announce()` publishes, through the SHIPPED method.
|
||||
|
||||
⚠️ Not `discovery_payloads()` directly. Part 2 proves the two agree; every
|
||||
other part goes through this one, so what is diffed is what paho is told.
|
||||
|
||||
`include` is TEL-11's optional-row set and DEFAULTS TO EMPTY here for the
|
||||
same reason it does in the shipped constructor: every check in this file
|
||||
written before that key existed must go on grading the announcement an
|
||||
install that never set `pv_entity` receives, which is all of them.
|
||||
"""
|
||||
pub = module.MqttPublisher.__new__(module.MqttPublisher)
|
||||
pub.omit = set(omit)
|
||||
pub.units = [str(u) for u in units]
|
||||
pub.include = [str(k) for k in include]
|
||||
pub.enabled = True
|
||||
pub.client = FakeClient()
|
||||
pub._announce()
|
||||
@@ -316,6 +322,52 @@ check("...and the `omit` path is unchanged too: with P1 ingestion off the "
|
||||
"watchdog input is still not announced, and nothing else moved",
|
||||
(m, c, a) == ([], [], []))
|
||||
|
||||
# --- TEL-11 ------------------------------------------------------------------
|
||||
# ⚠️ THE OPTIONAL ROW'S DEFAULT IS THE CLAIM, AND IT IS THE OPPOSITE DEFAULT
|
||||
# FROM `omit`'s. `p1_age` is announced unless withdrawn; `pv` does not exist
|
||||
# unless asked for. Every check above this line passes `include` nothing, so
|
||||
# they are all grading the announcement an install that never set `pv_entity`
|
||||
# receives - which is MOST installs, and is the acceptance criterion "unset
|
||||
# behaves exactly as today".
|
||||
check("⭐ TEL-11 ADDS NOTHING AT THE SHIPPED DEFAULT: a publisher that is told "
|
||||
"nothing about `pv_entity` announces the same list, in the same order, "
|
||||
"with the same bytes - so an install that never sets the option sees no "
|
||||
"new entity, no new topic and no re-announcement of an old one",
|
||||
announce(units=roster_names({})) == SITE_NOW)
|
||||
_PV_TOPIC = "homeassistant/sensor/goodwe_ctl_pv/config"
|
||||
_PV_PAYLOAD = ('{"name": "PV power", "object_id": "goodwe_pv_power", '
|
||||
'"unique_id": "goodwe_ctl_pv", "state_topic": "goodwe_ctl/pv", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": '
|
||||
+ _DEV + ', "unit_of_measurement": "W", "device_class": '
|
||||
'"power", "state_class": "measurement"}')
|
||||
_pv_one = announce(units=roster_names({}), include=("pv",))
|
||||
_m2, _c2, _a2 = diff(_pv_one, SITE_NOW)
|
||||
check("⭐ AND WITH `pv_entity` SET IT IS EXACTLY ONE NEW TOPIC AND NOTHING "
|
||||
"ELSE MOVES: same eleven configs first, same bytes, same order - the "
|
||||
"additions this card makes are additions, not a re-composition",
|
||||
(_m2, _c2) == ([], []) and _a2 == [_PV_TOPIC]
|
||||
and _pv_one[:len(SITE_NOW) - 1] == SITE_NOW[:-1])
|
||||
check("...and the payload is the site device, in watts, with "
|
||||
"`state_class: measurement` - which is what puts it in the recorder's "
|
||||
"statistics, and a week of statistics is the entire point of the card",
|
||||
(_PV_TOPIC, _PV_PAYLOAD, True) in _pv_one)
|
||||
# ⚠️ AND IT GOES LAST, WHICH IS WHY A ROSTER OF TWO IS ASKED THE SAME QUESTION.
|
||||
# Placed among the site rows it would shift every per-unit entity's position in
|
||||
# the retained announcement - a zero-byte edit that re-announces inverter B.
|
||||
_pv_two = announce(units=NAMES2, include=("pv",))
|
||||
check("...and at a roster of two it is STILL the last config, after every "
|
||||
"per-unit row, so no existing entity's position in the retained "
|
||||
"announcement moves when the option is turned on",
|
||||
[t for t, _p, _r in _pv_two if t.startswith("homeassistant/")][-1]
|
||||
== _PV_TOPIC
|
||||
and _pv_two == announce(units=NAMES2)[:-1] + [
|
||||
(_PV_TOPIC, _PV_PAYLOAD, True)] + announce(units=NAMES2)[-1:])
|
||||
check("...and `omit` still wins over `include`, so the two arguments cannot "
|
||||
"disagree about one key",
|
||||
[t for t, _p, _r in announce(units=roster_names({}), include=("pv",),
|
||||
omit=("pv",))
|
||||
if t == _PV_TOPIC] == [])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n2. what is diffed is what goes on the wire")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
"""Runnable check for TEL-11 - the PV production channel. `python3 test_pv.py`
|
||||
|
||||
OPT-10 measured that this site's battery reaches 90 % SoC on 2 days of 16 and
|
||||
that 20.5 kWh of surplus left it while the pack had room - 12.6 kWh of that with
|
||||
the battery not charging at all - and could not say WHY, because PV production
|
||||
is recorded NOWHERE. Three causes need three different fixes: no irradiance
|
||||
(nothing to fix), a charge-power limit, or the optimiser declining to charge
|
||||
(the only software defect). This card adds the missing channel. It does not do
|
||||
the attribution; that is OPT-10's, with a week of history behind it.
|
||||
|
||||
⚠️ WHAT THIS FILE REFUSES TO ASSERT, SAID FIRST. It does not grade that a row
|
||||
EXISTS. `publish()` handing a dict with `pv` in it is satisfied by `pv: 0.0`
|
||||
forever, and a PV channel that reports 0 W for a dead sensor MANUFACTURES the
|
||||
conclusion the card exists to test - "there was no surplus to store" - in a week
|
||||
of history nobody will re-derive. So every check below separates three states
|
||||
that a weaker one would collapse into one:
|
||||
|
||||
NO SOURCE `pv_entity` unset. No read, no entity, no topic, no row.
|
||||
NO READING configured, and the sensor did not answer. The topic carries
|
||||
HA's own `None`, the entity's state is `unknown`, the
|
||||
recorder records a GAP.
|
||||
ZERO WATTS configured, and the sensor said 0. The topic carries `0.0`.
|
||||
|
||||
The last two are the pair the acceptance criterion is about and part 3 asserts
|
||||
the PAYLOAD BYTES of both, because they differ by exactly one thing that no
|
||||
check on the VALUE alone can see.
|
||||
|
||||
⚠️ AND THE OTHER HALF OF THE CARD IS A NEGATIVE, GRADED AS ONE. This is
|
||||
instrumentation: it must not become a control input, or the loop would react to
|
||||
the quantity being measured and the measurement would be worthless. Part 6 is
|
||||
the in-suite half of that claim (the shipped command is bit-identical across
|
||||
absent / 0 W / 8 kW of PV, `inputs_ok` never moves, and an AST scan says which
|
||||
modules can even see the material). `tools/tel11_pv_trace.py` is the other half:
|
||||
it hashes a control document on THIS tree and on `release/1.0`, with the option
|
||||
set and unset.
|
||||
|
||||
Part 1 `pv.watts()`: the unit table, and every refusal, by name.
|
||||
Part 2 the shipped read path, including what a FAILED read does to the
|
||||
value a good one left behind.
|
||||
Part 3 ⭐ publication: no source / no reading / zero watts, as payload bytes.
|
||||
Part 4 announced and published are the same key, from the same predicate.
|
||||
Part 5 the operator surfaces, and that an unset option adds none of them.
|
||||
Part 6 ⭐ NOT A CONTROL INPUT.
|
||||
Part 7 what this file cannot see, with sizes, and the mutation table.
|
||||
|
||||
⚠️ MUTATING A MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app import mqtt, pv
|
||||
from app.main import Controller
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def state(value, unit="W", **attrs):
|
||||
"""A Home Assistant state object, in the shape `hass.state()` returns."""
|
||||
return {"state": value, "attributes": dict({"unit_of_measurement": unit},
|
||||
**attrs)}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print("1. pv.watts(): the unit is read off the entity, and a refusal is named")
|
||||
# ==============================================================================
|
||||
# ⚠️ LITERALS ON THE RIGHT-HAND SIDE, NOT `raw * WATTS_PER[u]` (habit 2). An
|
||||
# assertion written against the expression under test cannot tell a correct
|
||||
# table from a table that is wrong in the same direction twice.
|
||||
CASES = [
|
||||
(state("1234", "W"), 1234.0, "W"),
|
||||
(state("5.2", "kW"), 5200.0, "kW"),
|
||||
(state("0.0072", "MW"), 7200.0, "MW"),
|
||||
(state("4500000", "mW"), 4500.0, "mW"),
|
||||
(state("0.0000072", "GW"), 7200.0, "GW"),
|
||||
(state("0.0000000072", "TW"), 7200.0, "TW"),
|
||||
(state("-8", "W"), -8.0, "W"),
|
||||
]
|
||||
print(" state unit -> watts")
|
||||
_bad = []
|
||||
for st, want, unit in CASES:
|
||||
got, why = pv.watts(st)
|
||||
print(f" {st['state']:>14} {unit:<4} -> {got} ({why})")
|
||||
if got is None or abs(got - want) > 1e-6 or why != unit:
|
||||
_bad.append((st, got, want))
|
||||
check(f"every unit in the table converts to watts against a literal, and the "
|
||||
f"reason string is the unit it was read in. Wrong: {_bad}", not _bad)
|
||||
check("...and the table is HOME ASSISTANT's own power units, so a card that "
|
||||
"adds one has to say so here: mW, W, kW, MW, GW, TW",
|
||||
sorted(pv.WATTS_PER) == sorted(["mW", "W", "kW", "MW", "GW", "TW"]))
|
||||
check("...and every factor is an exact power of ten - nothing in this table is "
|
||||
"a number anybody chose",
|
||||
all(abs(v * 1000 - round(v * 1000)) < 1e-9 and v > 0
|
||||
for v in pv.WATTS_PER.values()))
|
||||
|
||||
# ⚠️ THE CASE THE WHOLE CARD TURNS ON, AND IT IS ASSERTED IN BOTH DIRECTIONS.
|
||||
check("⭐ ZERO WATTS IS A READING: a sensor that says 0 gives 0.0, not None - "
|
||||
"the pack can be full and the sun can be down and the honest report of "
|
||||
"the second one is a number",
|
||||
pv.watts(state("0", "W")) == (0.0, "W")
|
||||
and pv.watts(state("0.0", "kW")) == (0.0, "kW"))
|
||||
|
||||
REFUSALS = [
|
||||
("no state object at all (entity missing, or the read failed)", None,
|
||||
"no state"),
|
||||
("the entity is `unavailable`", state("unavailable"), "unavailable"),
|
||||
("the entity is `unknown`", state("unknown"), "unknown"),
|
||||
("an empty state", state(""), "empty"),
|
||||
("a state that is not a number", state("sunny"), "not numeric"),
|
||||
("no unit_of_measurement at all", state("1234", ""), "no unit"),
|
||||
("a unit that is not power", state("1234", "%"), "'%'"),
|
||||
# ⚠️ IN HA's `UnitOfPower` ENUM AND DELIBERATELY NOT IN THE TABLE. Its
|
||||
# factor is a measured conversion rather than a decimal shift, and a wrong
|
||||
# constant in the channel that exists to attribute a 20.5 kWh shortfall is
|
||||
# worse than a refusal somebody can read.
|
||||
("BTU/h, which IS one of HA's power units", state("1234", "BTU/h"),
|
||||
"'BTU/h'"),
|
||||
]
|
||||
_r = []
|
||||
print(" refusal -> (watts, why)")
|
||||
for label, st, needle in REFUSALS:
|
||||
got, why = pv.watts(st)
|
||||
print(f" {label:<42} -> ({got}, {why!r})")
|
||||
if got is not None or needle not in why:
|
||||
_r.append(label)
|
||||
check(f"⭐ EVERY REFUSAL ANSWERS None AND SAYS WHICH REFUSAL IT WAS - never "
|
||||
f"0.0, and never a bare 'no'. A reason an operator cannot act on sends "
|
||||
f"them to look at the wrong box. Wrong: {_r}", not _r)
|
||||
check("...and the refused states are the SAME list `hass.number()` refuses, "
|
||||
"imported rather than respelled - one path cannot start accepting what "
|
||||
"the other rejects",
|
||||
pv.BAD_STATES is __import__("app.hass", fromlist=["x"]).BAD_STATES)
|
||||
# habit 1: the precondition, as its own check. Without it the row above could
|
||||
# be true of a table that had quietly become empty.
|
||||
check("PRECONDITION: the refusal list and the unit table are both non-empty, "
|
||||
"so the two checks above quantify over something",
|
||||
len(REFUSALS) >= 8 and len(pv.WATTS_PER) >= 6)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("2. the shipped read path")
|
||||
# ==============================================================================
|
||||
LIVE = {"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
|
||||
"deadband_w": 15, "step_w": 10}
|
||||
BASE_OPTS = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15}
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
"""Captures what `Controller.publish()` hands the publisher."""
|
||||
enabled = False
|
||||
|
||||
def __init__(self):
|
||||
self.frames = []
|
||||
|
||||
def publish(self, values):
|
||||
self.frames.append(dict(values))
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
"""A scripted meter, an inverter that follows, and a scripted PV sensor.
|
||||
|
||||
⚠️ `states` IS A SCRIPT, ONE ENTRY PER CYCLE, so a check can drive a GOOD
|
||||
read and then a FAILED one and ask what the failure did to the value the
|
||||
good one left behind. A fixture that answers the same thing forever cannot
|
||||
reach that failure - MUTATION.md habit 9's shape, one surface over.
|
||||
"""
|
||||
|
||||
def __init__(self, grid=0.0, soc=50.0, states=(), follow=False):
|
||||
self.grid = grid
|
||||
# ⚠️ `follow` MAKES THE METER RESPOND TO THE BATTERY, and part 6 needs
|
||||
# it. With a FIXED meter the error never shrinks, the integrator winds
|
||||
# to its bound and the command rides `slew_w` to the rail on every
|
||||
# input - so every stimulus produces the same ramp and an identity over
|
||||
# them is free. `grid = base - batt` is the site: a battery discharging
|
||||
# 610 W into a 600 W import leaves the meter at the -10 W this add-on
|
||||
# rests at.
|
||||
self.follow = follow
|
||||
self.soc = soc
|
||||
self.batt = 0.0
|
||||
self.n = 0
|
||||
self.writes = []
|
||||
self.state_calls = []
|
||||
self.states = list(states)
|
||||
|
||||
async def limits(self, entity):
|
||||
return (-LIVE["max_w"], LIVE["max_w"], 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
grid = self.grid - self.batt if self.follow else self.grid
|
||||
return {"sensor.grid": grid, "sensor.soc": self.soc,
|
||||
"sensor.batt": self.batt}.get(entity)
|
||||
|
||||
async def state(self, entity):
|
||||
self.state_calls.append((self.n, entity))
|
||||
if not self.states:
|
||||
return None
|
||||
return self.states[min(self.n, len(self.states) - 1)]
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(float(value))
|
||||
self.batt = float(value)
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
def run(cycles=3, grid=0.0, states=(), auto=True, follow=False, **over):
|
||||
"""`cycles` real read/cycle/publish turns. Returns (controller, hass, pub)."""
|
||||
async def go():
|
||||
hass = _Hass(grid=grid, states=states, follow=follow)
|
||||
pub = _Pub()
|
||||
c = Controller(dict(BASE_OPTS, **LIVE, **over), hass, _Store(), pub,
|
||||
stop=asyncio.Event())
|
||||
c.auto = auto
|
||||
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
|
||||
for _k in range(cycles):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
c.publish()
|
||||
hass.n += 1
|
||||
return c, hass, pub
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
PV_ON = {"pv_entity": "sensor.pv_power"}
|
||||
|
||||
c_off, h_off, p_off = run(cycles=3)
|
||||
check("⚠️ UNSET IS NOT READ AT ALL: with no `pv_entity` the add-on makes NO "
|
||||
"state call, for any entity, on any cycle - not a read that fails "
|
||||
"quietly, no read. This is what 'no warning spam' is made of",
|
||||
h_off.state_calls == [] and c_off.pv_w is None
|
||||
and c_off.pv_why == "not configured")
|
||||
|
||||
c_on, h_on, _ = run(cycles=3, states=[state("3210", "W")], **PV_ON)
|
||||
print(f" state calls: {h_on.state_calls}")
|
||||
check("configured: the entity is read once per cycle, by name, and the value "
|
||||
"reaches `self.pv_w` in watts",
|
||||
h_on.state_calls == [(0, "sensor.pv_power"), (1, "sensor.pv_power"),
|
||||
(2, "sensor.pv_power")]
|
||||
and c_on.pv_w == 3210.0 and c_on.pv_why == "W")
|
||||
|
||||
c_kw, _, _ = run(cycles=1, states=[state("3.21", "kW")], **PV_ON)
|
||||
check("...and a kW sensor is read in kW: 3.21 kW is 3210 W, not 3.21 W. There "
|
||||
"is no magnitude heuristic anywhere on this path - 30 W of dusk and "
|
||||
"30 kW of noon are both ordinary readings",
|
||||
c_kw.pv_w == 3210.0 and c_kw.pv_why == "kW")
|
||||
|
||||
# ⚠️ THE ONE THAT SEPARATES "ABSENT" FROM "STALE", AND IT NEEDS TWO CYCLES.
|
||||
c_drop, _, p_drop = run(cycles=2, states=[state("4100", "W"), None], **PV_ON)
|
||||
print(f" published pv by cycle: {[f['pv'] for f in p_drop.frames]}")
|
||||
check("⭐ A FAILED READ CLEARS THE PREVIOUS GOOD ONE. The cycle after a 4100 W "
|
||||
"reading disappears publishes an ABSENCE, not 4100 W again - a value "
|
||||
"that survives its own sensor is indistinguishable from a real one at "
|
||||
"the moment somebody is working out whether the sun was shining",
|
||||
[f["pv"] for f in p_drop.frames] == [4100.0, None]
|
||||
and c_drop.pv_w is None and "no state" in c_drop.pv_why)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("3. ⭐ publication: no source, no reading, zero watts - as payload bytes")
|
||||
# ==============================================================================
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.msgs = []
|
||||
|
||||
def publish(self, topic, payload=None, retain=False):
|
||||
self.msgs.append((topic, payload, retain))
|
||||
|
||||
|
||||
def wire(values, include=("pv",)):
|
||||
"""What the SHIPPED `MqttPublisher.publish()` hands paho for `values`."""
|
||||
pub = mqtt.MqttPublisher.__new__(mqtt.MqttPublisher)
|
||||
pub.omit, pub.units, pub.include, pub.enabled = set(), [], list(include), True
|
||||
pub.client = FakeClient()
|
||||
pub.publish(values)
|
||||
return pub.client.msgs
|
||||
|
||||
|
||||
_zero = run(cycles=1, states=[state("0", "W")], **PV_ON)[2].frames[-1]
|
||||
_absent = run(cycles=1, states=[None], **PV_ON)[2].frames[-1]
|
||||
_nosrc = p_off.frames[-1]
|
||||
print(f" no source : 'pv' in frame = {'pv' in _nosrc}")
|
||||
def pv_msg(values):
|
||||
"""The `goodwe_ctl/pv` message the shipped publisher emits, or None."""
|
||||
got = [m for m in wire(values) if m[0] == "goodwe_ctl/pv"]
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
print(f" no reading: {_absent['pv']!r} -> wire {pv_msg(_absent)}")
|
||||
print(f" zero watts: {_zero['pv']!r} -> wire {pv_msg(_zero)}")
|
||||
check("NO SOURCE: an install that never set `pv_entity` publishes no `pv` key "
|
||||
"at all - there is no entity to feed, so feeding one would be a topic "
|
||||
"nothing announced",
|
||||
"pv" not in _nosrc)
|
||||
check("NO READING: the key is PRESENT and its value is None, which is a "
|
||||
"different frame from the one above. 'the option is off' and 'the sensor "
|
||||
"did not answer' are two facts and one field cannot carry both",
|
||||
"pv" in _absent and _absent["pv"] is None)
|
||||
check("ZERO WATTS: 0.0, published as a number", _zero["pv"] == 0.0)
|
||||
check("⭐ AND ON THE WIRE THE LAST TWO ARE DIFFERENT BYTES ON THE SAME TOPIC: "
|
||||
"`None` - which is Home Assistant's own payload for 'this sensor has no "
|
||||
"value', so the entity goes to `unknown` and the recorder records a GAP "
|
||||
"- against `0.0`. THIS IS THE ACCEPTANCE CRITERION: a week later, no PV "
|
||||
"reported is not PV reported 0 W",
|
||||
pv_msg(_absent) == ("goodwe_ctl/pv", "None", False)
|
||||
and pv_msg(_zero) == ("goodwe_ctl/pv", "0.0", False)
|
||||
and pv_msg(_absent) != pv_msg(_zero)
|
||||
# ...and the pv row is the ONLY thing that differs between the two
|
||||
# frames on the wire, so the claim above is about that row and not
|
||||
# about some other number the two fixtures happened to move.
|
||||
and [m for m in wire(_absent) if m[0] != "goodwe_ctl/pv"]
|
||||
== [m for m in wire(_zero) if m[0] != "goodwe_ctl/pv"])
|
||||
check("...and the payload is HA's literal, from the module's own constant, not "
|
||||
"a string spelled here", mqtt.PAYLOAD_NONE == "None")
|
||||
# ⚠️ AND THE BLAST RADIUS IS ONE KEY. The None-dropping in `publish()` is what
|
||||
# `circulation` depends on - its note in main.py says a missing per-unit reading
|
||||
# must leave the last figure standing rather than publish a zero that reads as
|
||||
# health - so widening it would be a behaviour change to another card's row.
|
||||
check("⭐ AND NO OTHER ROW CHANGED BEHAVIOUR: `circulation: None` is still "
|
||||
"DROPPED, not published as `None` - TEL-11 widened the rule for exactly "
|
||||
"one key and `ABSENT_AS_UNKNOWN` is the list",
|
||||
wire({"circulation": None, "grid": None, "loop_reason": None}) == []
|
||||
and mqtt.ABSENT_AS_UNKNOWN == frozenset({"pv"}))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("4. announced and published are the same key, from the same predicate")
|
||||
# ==============================================================================
|
||||
# mqtt.py's `omit` note: an entity that exists and is never fed is a worse
|
||||
# signal than one that does not exist. This is that claim for TEL-11's row, and
|
||||
# it is taken off the SHIPPED announcement and the SHIPPED `publish()`.
|
||||
def announced(opts):
|
||||
return {t.rsplit("/", 2)[-2].replace(f"{mqtt.BASE}_", "")
|
||||
for t, _c in mqtt.discovery_payloads(
|
||||
include=mqtt.optional_keys(opts))}
|
||||
|
||||
|
||||
check("with a source: `pv` is announced AND published",
|
||||
"pv" in announced(PV_ON) and "pv" in _absent)
|
||||
check("without one: announced by nothing and published by nothing",
|
||||
"pv" not in announced({}) and "pv" not in _nosrc)
|
||||
check("...and the predicate is ONE expression, `mqtt.optional_keys()`, read by "
|
||||
"the announcement and by `Controller.publish()` alike - two spellings is "
|
||||
"how an entity gets announced that nothing feeds",
|
||||
mqtt.optional_keys({}) == ()
|
||||
and mqtt.optional_keys({"pv_entity": ""}) == ()
|
||||
and mqtt.optional_keys({"pv_entity": " "}) == ()
|
||||
and mqtt.optional_keys({"pv_entity": "sensor.x"}) == ("pv",))
|
||||
check("...and a whitespace-only option is the SAME as an empty one everywhere "
|
||||
"- the controller does not read it either",
|
||||
run(cycles=1, pv_entity=" ")[1].state_calls == [])
|
||||
|
||||
# ⚠️ AND THE RESTART, WHICH IS THE OTHER HALF OF "RECORDABLE". Home Assistant
|
||||
# keeps the entity_id it composed at FIRST discovery and keys its registry on
|
||||
# `unique_id`, so a row whose id moved between two starts is a NEW entity beside
|
||||
# a dead one and the history stays behind on the old id. Two independent
|
||||
# announcements are asserted byte-identical, and the composed id is modelled
|
||||
# INDEPENDENTLY of app/mqtt.py (the model is `tools/rehearse_roster.py`'s, and
|
||||
# it is anchored there on the one composed id that was verified against the live
|
||||
# system).
|
||||
import re as _re # noqa: E402
|
||||
|
||||
|
||||
def compose(device_name, entity_name):
|
||||
s_ = _re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
|
||||
return "sensor." + s_.strip("_")
|
||||
|
||||
|
||||
_a1 = mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON))
|
||||
_a2 = mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON))
|
||||
_pv_cfg = [c for _t, c in _a1 if c["unique_id"] == "goodwe_ctl_pv"]
|
||||
print(f" composed entity_id: "
|
||||
f"{compose(mqtt.DEVICE['name'], _pv_cfg[0]['name'])}")
|
||||
check("a restart re-announces the SAME bytes, so Home Assistant sees the entity "
|
||||
"it already has rather than a new one beside a dead one - the history is "
|
||||
"continuous across a restart of the add-on",
|
||||
[(t, __import__("json").dumps(c, sort_keys=True)) for t, c in _a1]
|
||||
== [(t, __import__("json").dumps(c, sort_keys=True)) for t, c in _a2]
|
||||
and len(_pv_cfg) == 1)
|
||||
check("...and the id Home Assistant composes for it is "
|
||||
"`sensor.goodwe_pv_power`, which is what DOCS.md tells an operator to put "
|
||||
"on a dashboard - device name plus entity name, NOT the object_id",
|
||||
compose(mqtt.DEVICE["name"], _pv_cfg[0]["name"])
|
||||
== "sensor.goodwe_pv_power"
|
||||
and _pv_cfg[0]["state_topic"] == "goodwe_ctl/pv")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("5. the operator surfaces")
|
||||
# ==============================================================================
|
||||
def rows_of(c):
|
||||
return [r["text"] for r in c.checks()]
|
||||
|
||||
|
||||
def page_rows(c):
|
||||
return c.status_payload()["rows"]
|
||||
|
||||
|
||||
_off_rows, _on_rows = rows_of(c_off), rows_of(c_on)
|
||||
print(f" unset: {len(_off_rows)} status rows; set: {len(_on_rows)}")
|
||||
check("⚠️ AN UNSET OPTION ADDS NO STATUS ROW AND NO PAGE ROW - not a caution, "
|
||||
"not a 'no PV configured' nag. `peak_forecast_entity` DOES nag when it "
|
||||
"is unset, because an unset forecast leaves a money guard without an "
|
||||
"input; this is instrumentation and a permanent caution on every install "
|
||||
"that never asked for it is the warning spam the card forbids",
|
||||
not [t for t in _off_rows if "PV" in t]
|
||||
and not [r for r in page_rows(c_off) if r[0] == "PV"])
|
||||
check("...and the whole row list is otherwise the same length, so nothing else "
|
||||
"moved either", len(_on_rows) == len(_off_rows) + 1)
|
||||
_pv_row = [t for t in _on_rows if t.startswith("PV production")]
|
||||
print(f" {_pv_row}")
|
||||
# habit 14: the ordered pair - value, entity and unit each in its OWN slot in
|
||||
# the literal sentence, so a transposition is visible rather than merely
|
||||
# present.
|
||||
check("a good read says the watts, the entity it came from and the unit it was "
|
||||
"reported in, each in its own slot",
|
||||
_pv_row == ["PV production: 3210 W from sensor.pv_power "
|
||||
"(reported in W)"]
|
||||
and ("PV", "3 210 W") in page_rows(c_on))
|
||||
|
||||
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON)
|
||||
_bad_row = [r for r in c_bad.checks() if r["text"].startswith("PV production")]
|
||||
print(f" {[r['text'] for r in _bad_row]}")
|
||||
check("a refused read says WHICH refusal, and names the entity - 'no reading' "
|
||||
"on its own sends an operator to look at a working sensor",
|
||||
len(_bad_row) == 1
|
||||
and _bad_row[0]["text"] == ("PV production: no reading from "
|
||||
"sensor.pv_power - unit '%' is not one of "
|
||||
"mW, W, kW, MW, GW, TW - it cannot be read "
|
||||
"in watts"))
|
||||
check("⭐ ...and it is a CAUTION, never a `bad` row: `status_payload()` builds "
|
||||
"NOT READY out of rows that are neither ok nor warn, and a telemetry "
|
||||
"channel that could put a battery site into NOT READY would be "
|
||||
"observability with a foot in the control path",
|
||||
_bad_row[0] == {"ok": False, "warn": True, "text": _bad_row[0]["text"]}
|
||||
and not c_bad.status_payload()["banner"].startswith("NOT READY")
|
||||
and c_bad.status_payload()["level"] != "bad"
|
||||
and c_bad.status_payload()["banner"]
|
||||
== c_off.status_payload()["banner"])
|
||||
check("...and the page row renders an absence as an em dash, not as 0 W",
|
||||
("PV", "—") in page_rows(c_bad))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("6. ⭐ NOT A CONTROL INPUT")
|
||||
# ==============================================================================
|
||||
# ⚠️ THE CLAIM IS NOT "pv is not in control.py". It is that the COMMANDS do not
|
||||
# move, and that is graded by driving the same stimulus with the PV channel
|
||||
# absent, at zero, and at 8 kW - a spread that would be impossible to miss if
|
||||
# any term of the control law had learned to read it.
|
||||
#
|
||||
# ⚠️ AND THE FIRST VERSION OF THIS FIXTURE COULD NOT HAVE SEEN IT, WHICH IS
|
||||
# MUTATION.md HABIT 23 AND THE CARD'S OWN BATTERY FOUND IT. The stimulus was a
|
||||
# 6 kW export over 12 cycles; the card's central mutation - `self.grid -=
|
||||
# self.pv_w` in `read_inputs()`, +106 B, on disk, purged, genuinely running -
|
||||
# came back a SURVIVOR. Not because the property held: because at 6 kW of
|
||||
# export the loop is against its rail and the command is moving at `slew_w`
|
||||
# every cycle, so -6000 W and -14000 W of apparent error produce the SAME
|
||||
# ramp, tick for tick. The mutant ran and could not change the answer on the
|
||||
# data it was given.
|
||||
#
|
||||
# The stimulus is now a SMALL import the loop can actually settle on, far
|
||||
# inside the rail, and the separation is asserted as its own precondition
|
||||
# below: a check that cannot go red on the one mutation the card is about is
|
||||
# the card's own habit-4.
|
||||
_stim = dict(cycles=40, grid=600.0, follow=True)
|
||||
_c_none, _h_none, _ = run(**_stim)
|
||||
_c_absent, _h_absent, _ = run(**_stim, states=[None], **PV_ON)
|
||||
_c_zero, _h_zero, _ = run(**_stim, states=[state("0", "W")], **PV_ON)
|
||||
_c_big, _h_big, _ = run(**_stim, states=[state("8000", "W")], **PV_ON)
|
||||
_w_none, _w_big = _h_none.writes, _h_big.writes
|
||||
print(f" unset : {_w_none} -> settled at {_c_none.target:g} W")
|
||||
print(f" 8000 W : {_w_big} -> settled at {_c_big.target:g} W")
|
||||
check("⭐ THE COMMANDS ARE BIT-IDENTICAL ACROSS unset / no reading / 0 W / "
|
||||
"8 kW of production, over 40 cycles of a real `cycle()` against a 600 W "
|
||||
"import the loop SETTLES on - every write, in order, and the same "
|
||||
"resting target. The PV channel changes no number that reaches an "
|
||||
"inverter",
|
||||
_w_none == _h_absent.writes == _h_zero.writes == _w_big
|
||||
and _c_none.target == _c_absent.target == _c_zero.target
|
||||
== _c_big.target)
|
||||
check("PRECONDITION: that stimulus really does command something, and RESTS "
|
||||
"well inside the rail rather than riding the slew limit to it - at the "
|
||||
"rail every input produces the same ramp and the identity above would "
|
||||
"be free (habit 1)",
|
||||
len(_w_none) >= 4 and abs(_c_none.target) > 100
|
||||
and abs(_c_none.target) < 0.5 * LIVE["max_w"])
|
||||
# ⚠️ THE HABIT-23 PRECONDITION, AND IT IS THE ONE THAT MAKES THE IDENTITY MEAN
|
||||
# SOMETHING. "What would the mutation have had to change, and does the fixture
|
||||
# reach that value?" Here: if the 8 kW HAD been subtracted from the meter
|
||||
# reading, would this stimulus produce a different command? It is one line to
|
||||
# ask, and on the first version of this fixture the answer was NO - see the
|
||||
# note above `_stim`.
|
||||
_c_shift, _h_shift, _ = run(cycles=40, grid=600.0 - 8000.0, follow=True)
|
||||
print(f" meter shifted by the same 8 kW -> settled at "
|
||||
f"{_c_shift.target:g} W")
|
||||
check("⭐ PRECONDITION (habit 23): the same stimulus with the METER shifted by "
|
||||
"that 8 kW rests somewhere visibly different, so 'the commands did not "
|
||||
"move' is a fact about the code and not about a rail that would have "
|
||||
"flattened any input",
|
||||
_h_shift.writes != _w_big
|
||||
and abs(_c_shift.target - _c_big.target) > 4000)
|
||||
check("...and `inputs_ok` does not move either: a PV sensor that never answers "
|
||||
"is not a missing INPUT, because nothing computes with it. An install "
|
||||
"whose solar integration breaks keeps regulating",
|
||||
_c_none.inputs_ok is True and _c_absent.inputs_ok is True)
|
||||
|
||||
# --- the structural half, and its scope is COMPUTED (habit 12) ---------------
|
||||
APP = sorted((HERE / "app").glob("*.py"))
|
||||
NEEDLES = ("pv_entity", "pv_w", "pv_why", "optional_keys", "ABSENT_AS_UNKNOWN")
|
||||
|
||||
|
||||
def touches(src: str) -> bool:
|
||||
"""Does this module MENTION TEL-11's material, by any spelling?
|
||||
|
||||
⚠️ A TEXT SCAN ON PURPOSE, AND IT IS THE CONSERVATIVE DIRECTION HERE. An
|
||||
AST scan would have to enumerate the forms a reference can take (attribute,
|
||||
subscript, `getattr`, a string key) and habit 12 says a hand-written list of
|
||||
forms is the same defect as a hand-written scope. Text over-reports - a
|
||||
comment counts - and over-reporting is safe for a check whose claim is
|
||||
"these modules do NOT mention it".
|
||||
"""
|
||||
return any(n in src for n in NEEDLES) or "from . import pv" in src \
|
||||
or "from .pv import" in src
|
||||
|
||||
|
||||
_touch = sorted(f.name for f in APP if touches(f.read_text(encoding="utf-8")))
|
||||
print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}")
|
||||
check(f"PRECONDITION: the scan found the whole package, not a subset - "
|
||||
f"{len(APP)} modules", len(APP) >= 25 and "control.py" in
|
||||
[f.name for f in APP])
|
||||
check("⭐ EXACTLY THREE MODULES CAN SEE THE MATERIAL: `pv.py` (the reader), "
|
||||
"`main.py` (the read and the two operator surfaces) and `mqtt.py` (the "
|
||||
"publication). `control.py`, `arbiter.py`, `allocator.py`, `peakguard.py`, "
|
||||
"`arbitrage.py`, `maintenance.py`, `clamp.py`, `freeze.py`, "
|
||||
"`watchdog.py`, `delivery.py` and `circulation.py` do not mention it in "
|
||||
"any spelling, comments included",
|
||||
_touch == ["main.py", "mqtt.py", "pv.py"])
|
||||
# habit 4 in the instrument: a scanner that cannot go red is not a scanner.
|
||||
check("...and the scan CAN go red - a module body with the material in it is "
|
||||
"caught, so the green above is the tree speaking and not the scanner "
|
||||
"being blind",
|
||||
touches("x = opts['pv_entity']\n") and touches("from . import pv\n")
|
||||
and not touches("# nothing to see, a pvc pipe\n"))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. what this file CANNOT see, with sizes - and the mutation table")
|
||||
# ==============================================================================
|
||||
# habit 13. An enumerated list with an honest gap is worth more than a claim of
|
||||
# completeness a reviewer falsifies in ten minutes.
|
||||
for line in [
|
||||
"Home Assistant itself. That `None` on the state topic renders as state",
|
||||
" `unknown`, and that the recorder keeps it, is READ OUT OF HA's source",
|
||||
" (mqtt/const.py PAYLOAD_NONE, mqtt/sensor.py's `_message_received`,",
|
||||
" retrieved 2026-09-24) and quoted in app/mqtt.py. No HA runs in this",
|
||||
" suite, so what is graded here is the BYTES this add-on publishes.",
|
||||
"Whether the operator's PV sensor is telling the truth. A sensor wired",
|
||||
" backwards reads -4 kW at noon; this file asserts that the sign is",
|
||||
" published unchanged, never that it is right. That is what the history",
|
||||
" the card creates is FOR - see DECISIONS/TEL-11-pv-production.md §2.",
|
||||
"The control-path claim OUTSIDE app/. `tools/`, the ESPHome firmware and",
|
||||
" the ingress page are not scanned by part 6's census.",
|
||||
"A roster of more than one. Every fixture here is the shipped roster of",
|
||||
" one; the row is a SITE row and there is nothing per-unit to separate.",
|
||||
"Wall-clock behaviour: no fixture here sleeps, and the read is driven by",
|
||||
" the caller's loop rather than by the 1 s tick.",
|
||||
]:
|
||||
print(" " + line)
|
||||
|
||||
print()
|
||||
print(" mutation bytes reds")
|
||||
print(" ------------------------------------------------ ----- --------")
|
||||
for row in [
|
||||
("M1 the pv row moved into SENSORS", "-79 B", "3 test_mqtt + 1 here"),
|
||||
("M2 publish() drops a None pv row again", "-98 B", "1 here"),
|
||||
("M3 ABSENT_AS_UNKNOWN widened past the one key", "+23 B", "1 here"),
|
||||
("M4 a dead sensor reported as 0 W", "-1 B", "4 here"),
|
||||
("M5 a failed read leaves the last value standing", "+46 B", "2 here"),
|
||||
("M6 the unit read and then ignored", "-18 B", "2 here"),
|
||||
("M7 `self.grid -= self.pv_w` in read_inputs()", "+106 B",
|
||||
"2 here + the trace"),
|
||||
("M8 the row announced and fed unconditionally", "-52 B", "3 here"),
|
||||
("M9 the pv caution made a `bad` row (NOT READY)", "+1 B", "1 here"),
|
||||
("M10 the read gate removed - unset is read anyway", "-10 B",
|
||||
"2 here + 2 crashes"),
|
||||
]:
|
||||
print(f" {row[0]:<48} {row[1]:>5} {row[2]}")
|
||||
print(" ⚠️ M7 WAS A SURVIVOR ON THE FIRST BATTERY and part 6's note says why:")
|
||||
print(" the fixture drove the loop to its RAIL, where every input produces")
|
||||
print(" the same ramp. MUTATION.md habit 23, with a saturated actuator in")
|
||||
print(" place of a coinciding value - and it is in that file now.")
|
||||
print(" ⚠️ M10's two extra reds are CRASHES, not named checks: every")
|
||||
print(" fixture in test_inverter.py and test_telemetry.py predates the PV")
|
||||
print(" read and has no `state()` on its Hass double, so an unconditional")
|
||||
print(" read is an AttributeError. A red, but an unnamed one - habit 20's")
|
||||
print(" shape - and it is recorded rather than tidied away.")
|
||||
print(" Every row above was RUN, in the CI image, on a purged tree, one at a")
|
||||
print(" time - see DECISIONS/TEL-11-pv-production.md §5 for the transcript.")
|
||||
print(" ⚠️ A RED COUNT IS A PROPERTY OF THE SUITE AS MUCH AS OF THE MUTATION:")
|
||||
print(" these name THIS tree. Re-measure rather than inherit.")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -371,8 +371,14 @@ print("\n7. no behaviour and no value moved")
|
||||
# test_watchdog.py's six, test_price.py's seven, test_freeze.py's, and
|
||||
# test_peakguard.py's. This line is the cheap one that says the reorder did not
|
||||
# lose or duplicate a key on the way.
|
||||
check("no option was lost, gained or duplicated by the reordering: the two "
|
||||
"halves of config.yaml hold the same 72 keys, once each",
|
||||
# ⚠️ THE COUNT IS READ OFF THE TREE, NOT TYPED. It was typed - "the same 72
|
||||
# keys" - and was 75 by the time TEL-11 read it, because three cards had added
|
||||
# an option each and none of them was looking at this sentence. A number in a
|
||||
# check's own message is a claim like any other (MUTATION.md habit 22) and this
|
||||
# one had been false for three cards without a single run going red, because
|
||||
# nothing asserts it. Derived, it cannot rot.
|
||||
check(f"no option was lost, gained or duplicated by the reordering: the two "
|
||||
f"halves of config.yaml hold the same {len(ORDER)} keys, once each",
|
||||
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
|
||||
check("...and `inverters: []` is still shipped in the options half, which is "
|
||||
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-57 - turn a passive RS-485 capture into the three series the loop sees.
|
||||
|
||||
`python3 tools/decode_bus_capture.py scenarios/safety57-2026-09-21/bus-a-*.log.gz`
|
||||
|
||||
WHY THE RAW CAPTURE IS WHAT IS COMMITTED, AND NOT A DECODED TABLE. The other
|
||||
scenario directories in this repository hold Home Assistant recorder exports -
|
||||
a number per row, already interpreted. This one holds MODBUS FRAMES off the
|
||||
wire, because the episode it records is one in which the add-on's own view and
|
||||
the wire's disagree, and a decoded table would be this decoder's opinion with
|
||||
the evidence thrown away. MUTATION.md habit 8: a committed transcript is a
|
||||
check, and a check has to be re-derivable from something.
|
||||
|
||||
WHAT THE CAPTURE IS. `sniffer@192.168.2.167` is a passive listener on the
|
||||
RS-485 pair between the ESP32 master and the GW5000S-BP. It writes nothing; it
|
||||
has no address on the bus. Every line is one frame it heard:
|
||||
|
||||
a|10:51:29.695 ok F7 10 05 6E 00 02 04 00 03 04 24 ...
|
||||
^ ^ ^ ^
|
||||
| | | the frame, hex
|
||||
| | CRC verdict as the capture tool computed it
|
||||
| local wall clock, Europe/Brussels, no date - see `decode()`
|
||||
bus label
|
||||
|
||||
THE FOUR FRAME SHAPES THIS FILE UNDERSTANDS, and it deliberately understands no
|
||||
others - an unrecognised frame is COUNTED and dropped, never guessed at:
|
||||
|
||||
F7 03 <addr:2> <count:2> <crc:2> a read REQUEST
|
||||
F7 03 <2*count> <data...> <crc:2> its response, matched to the request
|
||||
immediately preceding it
|
||||
F7 10 05 6E 00 02 04 <data:4> <crc:2> a write of the two EMS registers
|
||||
F7 10 05 6E 00 02 <crc:2> the inverter's echo of that write
|
||||
|
||||
⚠️ THE SETPOINT IS A MODE AND A MAGNITUDE, NOT A SIGNED NUMBER, and getting
|
||||
that backwards is the one way this decoder could quietly invert the whole
|
||||
episode. `0x056E` is the EMS mode and `0x056F` the magnitude in watts,
|
||||
unsigned. Mode 2 is charge and mode 3 is discharge, and that is READ OFF THIS
|
||||
CAPTURE rather than off a datasheet: in the committed window, every
|
||||
`mode 2, 2170 W` write is followed within one 10 s publish by an 0x0518
|
||||
reading near **-2287 W**, and `mode 3, 1060 W` by a POSITIVE-side idle. The
|
||||
add-on's own sign convention (`Inverter.standing_w()`, + = discharge) is
|
||||
therefore `+magnitude` for mode 3 and `-magnitude` for mode 2, and
|
||||
`test_freshness.py` part 10a grades that correspondence on the capture itself
|
||||
rather than trusting this paragraph.
|
||||
|
||||
WHAT THIS DECODER CANNOT SEE, largest hole first (habit 13/16):
|
||||
|
||||
* **THE GRID METER.** It is not on this bus - it is a P1 telegram on a
|
||||
different wire - so nothing here says what the site was importing or
|
||||
exporting. The 2.4 kW export during the episode is from the operator's
|
||||
Home Assistant history and is NOT re-derivable from this file.
|
||||
* **WHAT THE ADD-ON BELIEVED.** The capture is the wire. It shows the
|
||||
commands that arrived and the readings that were answered; it does not show
|
||||
`Inverter.batt`, `inputs_ok`, or any log line. Everything about the add-on's
|
||||
internal state in this episode is INFERRED by replaying the shipped modules
|
||||
against these series, which is what part 10 does.
|
||||
* **DATE AND YEAR.** The capture tool stamps a time of day and nothing else.
|
||||
The date is supplied by the caller and is recorded in the scenario's
|
||||
`manifest.json` beside the sha256 of the file it came from.
|
||||
* **A FRAME THE CAPTURE TOOL SCORED BAD.** Lines whose CRC verdict is not
|
||||
`ok` are counted and dropped. In the committed window there are none.
|
||||
* **ANY OTHER REGISTER.** 0x0511 (a two-register read this master issues) is
|
||||
recognised as a read and its values are returned untouched under its own
|
||||
address; nothing here claims to know what it means.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
|
||||
# The registers this file will name. Anything else is returned by raw address.
|
||||
REG_SETPOINT_MODE = 0x056E # written as a pair with 0x056F
|
||||
REG_BATT_W = 0x0518 # S16, watts, + = discharge (SAFETY-37)
|
||||
REG_SOC_PCT = 0x050E # U16, percent
|
||||
MODE_CHARGE, MODE_DISCHARGE = 2, 3
|
||||
|
||||
_LINE = re.compile(r"^(\w+)\|(\d\d):(\d\d):(\d\d(?:\.\d+)?)\s+(\S+)\s+(.*)$")
|
||||
|
||||
|
||||
def signed_setpoint_w(mode: int, magnitude: int) -> float:
|
||||
"""The EMS pair as `Inverter.standing_w()` spells it: + = discharge.
|
||||
|
||||
⚠️ AN UNKNOWN MODE RAISES rather than defaulting. A default here would turn
|
||||
a firmware that started using a third mode into a silently mis-signed
|
||||
series, which is the failure this whole scenario is about one layer down.
|
||||
"""
|
||||
if mode == MODE_DISCHARGE:
|
||||
return float(magnitude)
|
||||
if mode == MODE_CHARGE:
|
||||
return -float(magnitude)
|
||||
raise ValueError(f"unknown EMS mode {mode} at 0x{REG_SETPOINT_MODE:04X}; "
|
||||
f"this decoder knows {MODE_CHARGE} (charge) and "
|
||||
f"{MODE_DISCHARGE} (discharge) only")
|
||||
|
||||
|
||||
def _s16(v: int) -> int:
|
||||
return v - 65536 if v >= 32768 else v
|
||||
|
||||
|
||||
def decode(path):
|
||||
"""Decode one capture file. Returns `(events, stats)`.
|
||||
|
||||
`events` is a list of `(t, kind, value)` in capture order, where `t` is
|
||||
SECONDS SINCE LOCAL MIDNIGHT (the capture carries no date) and `kind` is
|
||||
one of `"setpoint_w"`, `"batt_w"`, `"soc_pct"`, or `"reg_0xNNNN"`.
|
||||
|
||||
`stats` counts every line by what became of it, so a caller can assert that
|
||||
the frames it cares about were actually present rather than that the parse
|
||||
produced no error.
|
||||
"""
|
||||
opener = gzip.open if str(path).endswith(".gz") else io.open
|
||||
events = []
|
||||
stats = {"lines": 0, "unparsed": 0, "bad_crc": 0, "requests": 0,
|
||||
"responses": 0, "writes": 0, "write_echoes": 0, "unknown": 0}
|
||||
pending = None # the read request awaiting its response
|
||||
with opener(path, "rt") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
stats["lines"] += 1
|
||||
m = _LINE.match(line)
|
||||
if not m:
|
||||
stats["unparsed"] += 1
|
||||
continue
|
||||
_bus, hh, mm, ss, verdict, hexs = m.groups()
|
||||
if verdict != "ok":
|
||||
stats["bad_crc"] += 1
|
||||
continue
|
||||
t = int(hh) * 3600 + int(mm) * 60 + float(ss)
|
||||
try:
|
||||
b = [int(x, 16) for x in hexs.split()]
|
||||
except ValueError:
|
||||
stats["unparsed"] += 1
|
||||
continue
|
||||
# --- a read request: remember it so its response can be addressed
|
||||
if len(b) == 8 and b[1] == 0x03:
|
||||
pending = ((b[2] << 8) | b[3], (b[4] << 8) | b[5])
|
||||
stats["requests"] += 1
|
||||
continue
|
||||
# --- a read response, addressed by the request before it
|
||||
if (b[1] == 0x03 and pending is not None and len(b) >= 5
|
||||
and b[2] == 2 * pending[1] and len(b) == b[2] + 5):
|
||||
data = b[3:3 + b[2]]
|
||||
regs = [(data[i] << 8) | data[i + 1]
|
||||
for i in range(0, len(data), 2)]
|
||||
addr = pending[0]
|
||||
pending = None
|
||||
stats["responses"] += 1
|
||||
if addr == REG_BATT_W:
|
||||
events.append((t, "batt_w", float(_s16(regs[0]))))
|
||||
elif addr == REG_SOC_PCT:
|
||||
events.append((t, "soc_pct", float(regs[0])))
|
||||
else:
|
||||
events.append((t, f"reg_0x{addr:04X}", tuple(regs)))
|
||||
continue
|
||||
# --- a write of the EMS pair
|
||||
if (len(b) > 8 and b[1] == 0x10 and b[2] == 0x05 and b[3] == 0x6E
|
||||
and b[6] == 4):
|
||||
data = b[7:11]
|
||||
mode = (data[0] << 8) | data[1]
|
||||
mag = (data[2] << 8) | data[3]
|
||||
events.append((t, "setpoint_w", signed_setpoint_w(mode, mag)))
|
||||
stats["writes"] += 1
|
||||
continue
|
||||
if len(b) == 8 and b[1] == 0x10:
|
||||
stats["write_echoes"] += 1
|
||||
continue
|
||||
stats["unknown"] += 1
|
||||
return events, stats
|
||||
|
||||
|
||||
def series(events, kind):
|
||||
"""`[(t, value)]` for one kind, in capture order."""
|
||||
return [(t, v) for t, k, v in events if k == kind]
|
||||
|
||||
|
||||
def held_at(rows, t):
|
||||
"""The last value at or before `t`, or None before the first row.
|
||||
|
||||
This is how the add-on sees an entity: Home Assistant answers with whatever
|
||||
the publisher last put there, and a value that has not been re-published is
|
||||
still the value. See `freshness.py` on why that is the whole problem.
|
||||
"""
|
||||
out = None
|
||||
for ts, v in rows:
|
||||
if ts > t:
|
||||
break
|
||||
out = v
|
||||
return out
|
||||
|
||||
|
||||
def _hms(t):
|
||||
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}"
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2:
|
||||
print(__doc__.splitlines()[2].strip())
|
||||
return 2
|
||||
events, stats = decode(argv[1])
|
||||
print(f"== {argv[1]}")
|
||||
for k, v in stats.items():
|
||||
print(f" {k:14} {v}")
|
||||
for kind in ("setpoint_w", "batt_w", "soc_pct"):
|
||||
rows = series(events, kind)
|
||||
if not rows:
|
||||
continue
|
||||
changes = [r for i, r in enumerate(rows)
|
||||
if i == 0 or r[1] != rows[i - 1][1]]
|
||||
print(f"== {kind}: {len(rows)} frames, {len(changes)} changes, "
|
||||
f"{_hms(rows[0][0])} .. {_hms(rows[-1][0])}")
|
||||
for t, v in changes:
|
||||
print(f" {_hms(t)} {v:+10.1f}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,300 @@
|
||||
"""SAFETY-56's behaviour-invariance instrument. `tools/safety56_behaviour_trace.py`
|
||||
|
||||
SAFETY-56 rewrote three operator-facing sentences and nothing else. This is what
|
||||
says so: it drives a fixed stimulus set through the SHIPPED modules and prints
|
||||
two documents -
|
||||
|
||||
`behaviour` every verdict, every `reading()`, every `releases`, every
|
||||
`inputs_ok` and every readiness row's (ok, warn). NO SENTENCE
|
||||
REACHES IT, so a tree whose only change is wording must produce
|
||||
this document byte for byte.
|
||||
`sentences` the four operator-facing surfaces, from the same stimulus.
|
||||
|
||||
Run it in two trees and compare:
|
||||
|
||||
python3 tools/safety56_behaviour_trace.py > /tmp/after.json
|
||||
python3 tools/safety56_behaviour_trace.py --compare /tmp/before.json /tmp/after.json
|
||||
|
||||
⚠️ THE CONTROL IS PART OF THE TOOL AND NOT A SEPARATE STEP, WHICH IS MUTATION.md
|
||||
HABIT 4 IN AN INSTRUMENT. `--compare` REFUSES to report "behaviour unchanged"
|
||||
unless the `sentences` halves DIFFER. Without that, comparing a tree with itself
|
||||
- which this card's first run did, by archiving a branch whose edits were not
|
||||
committed yet - gives a perfect result that means nothing. An identity is
|
||||
evidence only when the two sides could have differed, and ARCH-02's rehearsal
|
||||
harness is the worked example of what happens when nobody checks.
|
||||
|
||||
⚠️ IT GRADES BEHAVIOUR, NEVER WORDING. Two trees that agree here can still print
|
||||
different sentences - that is the point. `test_freshness.py` part 11 grades the
|
||||
sentences themselves.
|
||||
|
||||
⚠️ WHAT IT CANNOT SEE, with shapes (habit 13): anything outside `freshness.py`'s
|
||||
verdict, `_read_unit()`'s gating and `checks()`'s rows; a roster of more than
|
||||
one; the MQTT and ingress surfaces; and wall-clock behaviour, since every clock
|
||||
here is the caller's monotonic argument. It is a pin on the three things this
|
||||
card is forbidden to move, not a full behavioural equivalence.
|
||||
|
||||
⚠️ PURGE BEFORE USE on a tree you have just edited. `tools/mutation_run.sh`
|
||||
does, and MUTATION.md says why `python3 -B` alone does not.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent # the add-on directory
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
BOUNDS = Freshness = Controller = DEFAULTS = None
|
||||
|
||||
|
||||
def _load():
|
||||
"""The add-on's modules, imported LAZILY so that `--compare` runs anywhere.
|
||||
|
||||
⚠️ NOT A STYLE CHOICE. `--compare` is the half a reviewer runs on a laptop,
|
||||
against two JSON files, in a directory that has no `app/` in it - and a
|
||||
top-level import would make the comparison impossible exactly where it is
|
||||
most useful.
|
||||
"""
|
||||
global BOUNDS, Freshness, Controller, DEFAULTS
|
||||
from app.freshness import BOUNDS, Freshness # noqa: E402,F401
|
||||
from app.main import Controller # noqa: E402,F401
|
||||
from app.store import DEFAULTS # noqa: E402,F401
|
||||
|
||||
A = 5000.0
|
||||
ONE = dict(meter_source="off", meter_entity="sensor.grid",
|
||||
setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a", max_w=A, gain=0.6, slew_w=0,
|
||||
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
|
||||
stale_input_s=15, inverters=[], bms_soc_floor=10)
|
||||
|
||||
|
||||
class MemStore:
|
||||
def __init__(self, d=None):
|
||||
self.data = dict(DEFAULTS, **(d or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
def set_time(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, numbers=None, limits=None):
|
||||
self.numbers = dict(numbers or {})
|
||||
self.limits_by = dict(limits or {})
|
||||
self.served = []
|
||||
self.writes = []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
self.served.append((entity, v))
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
def one_site(soc=50.0):
|
||||
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": soc,
|
||||
"sensor.batt_a": -1200.0},
|
||||
{"number.sp_a": (-A, A, 1.0)})
|
||||
c = Controller(dict(ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -A, A
|
||||
return c, hass
|
||||
|
||||
|
||||
async def _world(name, soc, reg_of, cmd_of, n):
|
||||
c, hass = one_site(soc)
|
||||
seen = []
|
||||
for t in range(n):
|
||||
hass.numbers["sensor.batt_a"] = reg_of(t)
|
||||
c.inverters[0].last_write = cmd_of(t)
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
seen.append([c.inverters[0].batt, c.inputs_ok,
|
||||
c.inverters[0].batt_fresh.stale])
|
||||
return {"name": name, "reads": seen,
|
||||
"rows": [[r["ok"], r["warn"]] for r in c.checks()],
|
||||
"n_rows": len(c.checks()), "events": len(c.events),
|
||||
"inputs_bad": c.inputs_bad_since is not None,
|
||||
"releases": [i.batt_fresh.releases for i in c.inverters]}
|
||||
|
||||
|
||||
# The command and register schedules. Each of the conjunction's three terms is
|
||||
# exercised alone and with the others, and the last two schedules take a verdict
|
||||
# all the way through SAFETY-57's release and back into a re-trip.
|
||||
CMDS = [lambda t: 0.0, lambda t: 1300.0, lambda t: -1200.0,
|
||||
lambda t: 1300.0 if t >= 1 else -1200.0,
|
||||
lambda t: 249.0 if t >= 1 else 0.0,
|
||||
lambda t: 251.0 if t >= 1 else 0.0,
|
||||
lambda t: 1300.0 if t < 40 else 0.0,
|
||||
lambda t: 5.0 if t >= 1 else 1300.0,
|
||||
lambda t: (t % 7) * 200.0 - 600.0,
|
||||
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else (
|
||||
0.0 if t < 90 else 1300.0),
|
||||
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else 100.0]
|
||||
REGS = [lambda t: -3.0, lambda t: -1200.0, lambda t: -1200.0 + (t % 3),
|
||||
lambda t: None if t % 5 == 0 else -1200.0,
|
||||
lambda t: 0.0, lambda t: float((t * 37) % 11) - 5.0]
|
||||
|
||||
WORLDS = [
|
||||
("DEAD", 50.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
|
||||
("REFUSED", 11.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
|
||||
("ABSENT", 50.0, lambda t: None, lambda t: 0.0, 3),
|
||||
("HEALTHY", 50.0, lambda t: -1200.0 + (t % 3),
|
||||
lambda t: 1300.0 if t % 2 else -1200.0, 60),
|
||||
("RELEASE", 50.0, lambda t: -1200.0,
|
||||
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else 0.0, 60),
|
||||
("RETRIP", 50.0, lambda t: -1200.0,
|
||||
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else
|
||||
(0.0 if t < 80 else 1300.0), 130),
|
||||
]
|
||||
|
||||
|
||||
def behaviour():
|
||||
"""Everything this card is forbidden to move. No sentence may enter it."""
|
||||
_load()
|
||||
logging.disable(logging.CRITICAL)
|
||||
out = {}
|
||||
|
||||
# 1. the committed live capture, read by read.
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_s56decode", HERE / "tools" / "decode_bus_capture.py")
|
||||
dec = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(dec)
|
||||
cap_file = (HERE / "scenarios" / "safety57-2026-09-21"
|
||||
/ "bus-a-2026-09-21-1030-1110.log.gz")
|
||||
events, _census = dec.decode(cap_file)
|
||||
sp, bt = dec.series(events, "setpoint_w"), dec.series(events, "batt_w")
|
||||
f = Freshness(BOUNDS["batt"])
|
||||
t = float(int(max(sp[0][0], bt[0][0])) + 1)
|
||||
t1 = int(min(sp[-1][0], bt[-1][0]))
|
||||
cap = []
|
||||
while t <= t1:
|
||||
f.submit(dec.held_at(bt, t), dec.held_at(sp, t), t)
|
||||
cap.append([t, f.stale, f.reading(), f.releases, f.held,
|
||||
f.rested_since, f.graded_from, f.standing_at_grade,
|
||||
f.released_at])
|
||||
t += 1.0
|
||||
out["capture_reads"] = len(cap)
|
||||
out["capture_trace"] = hashlib.sha256(
|
||||
json.dumps(cap, sort_keys=True).encode()).hexdigest()
|
||||
out["capture_edges"] = [r[:4] for i, r in enumerate(cap)
|
||||
if i == 0 or r[1] != cap[i - 1][1]]
|
||||
|
||||
# 2. the swept grid over the policy object.
|
||||
sweep = []
|
||||
for ci, cf in enumerate(CMDS):
|
||||
for ri, rf in enumerate(REGS):
|
||||
g = Freshness(BOUNDS["batt"])
|
||||
row = []
|
||||
for n in range(120):
|
||||
g.submit(rf(n), cf(n), float(n))
|
||||
row.append([g.stale, g.reading(), g.releases])
|
||||
sweep.append([ci, ri, row])
|
||||
out["sweep_cells"] = len(sweep)
|
||||
out["sweep_trace"] = hashlib.sha256(
|
||||
json.dumps(sweep, sort_keys=True).encode()).hexdigest()
|
||||
out["sweep_stale_reads"] = sum(1 for _c, _r, row in sweep
|
||||
for s, _v, _n in row if s)
|
||||
out["sweep_missing_reads"] = sum(1 for _c, _r, row in sweep
|
||||
for _s, v, _n in row if v is None)
|
||||
out["sweep_releases"] = sum(row[-1][2] for _c, _r, row in sweep)
|
||||
|
||||
# 3. the shipped read path and every readiness row's verdict.
|
||||
loop = asyncio.new_event_loop()
|
||||
out["worlds"] = [loop.run_until_complete(_world(*w)) for w in WORLDS]
|
||||
loop.close()
|
||||
return out
|
||||
|
||||
|
||||
def sentences():
|
||||
"""The four operator-facing surfaces, from the REFUSED world."""
|
||||
_load()
|
||||
recs = []
|
||||
|
||||
class _H(logging.Handler):
|
||||
def emit(self, record):
|
||||
recs.append(record.getMessage())
|
||||
|
||||
logging.disable(logging.NOTSET)
|
||||
log = logging.getLogger("goodwe")
|
||||
log.addHandler(_H())
|
||||
log.setLevel(logging.DEBUG)
|
||||
|
||||
async def go():
|
||||
c, hass = one_site(11.0)
|
||||
for t in range(40):
|
||||
hass.numbers["sensor.batt_a"] = -3.0
|
||||
c.inverters[0].last_write = 800.0 if t < 1 else 1600.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
return c
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
c = loop.run_until_complete(go())
|
||||
loop.close()
|
||||
return {
|
||||
"reason": c.inverters[0].batt_fresh.reason,
|
||||
"log": [m for m in recs if "SAFETY-37" in m],
|
||||
"stale_note": c.inverters[0].stale_note(),
|
||||
"not_ready_row": [r["text"] for r in c.checks()
|
||||
if r["text"].startswith("battery power")],
|
||||
}
|
||||
|
||||
|
||||
def compare(a_path, b_path):
|
||||
a = json.loads(Path(a_path).read_text(encoding="utf-8"))
|
||||
b = json.loads(Path(b_path).read_text(encoding="utf-8"))
|
||||
beh_same = a["behaviour"] == b["behaviour"]
|
||||
sent_same = a["sentences"] == b["sentences"]
|
||||
ha = hashlib.sha256(json.dumps(a["behaviour"], sort_keys=True).encode())
|
||||
hb = hashlib.sha256(json.dumps(b["behaviour"], sort_keys=True).encode())
|
||||
print(f"behaviour A {ha.hexdigest()}")
|
||||
print(f"behaviour B {hb.hexdigest()}")
|
||||
if sent_same:
|
||||
# ⚠️ THE REFUSAL, AND IT IS THE POINT OF THE TOOL. Two runs whose
|
||||
# sentences agree are either the same tree twice or a card that changed
|
||||
# nothing an operator reads. Either way "behaviour unchanged" is not a
|
||||
# result, and reporting it would be an identity measured against itself.
|
||||
print("REFUSED: the `sentences` halves are IDENTICAL, so these two runs "
|
||||
"cannot be evidence that behaviour survived a WORDING change - "
|
||||
"they may be the same tree twice. Compare a tree against one "
|
||||
"whose sentences differ.")
|
||||
return 2
|
||||
if not beh_same:
|
||||
for k in sorted(set(a["behaviour"]) | set(b["behaviour"])):
|
||||
if a["behaviour"].get(k) != b["behaviour"].get(k):
|
||||
print(f"BEHAVIOUR MOVED at {k!r}")
|
||||
return 1
|
||||
print("OK: sentences differ, behaviour is byte-identical.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 4 and sys.argv[1] == "--compare":
|
||||
sys.exit(compare(sys.argv[2], sys.argv[3]))
|
||||
print(json.dumps({"behaviour": behaviour(), "sentences": sentences()},
|
||||
sort_keys=True, indent=1))
|
||||
@@ -0,0 +1,390 @@
|
||||
"""TEL-11's behaviour-invariance instrument. `tools/tel11_pv_trace.py`
|
||||
|
||||
TEL-11 adds a PV production channel and claims two things a reviewer cannot
|
||||
check by reading a diff:
|
||||
|
||||
1. with `pv_entity` UNSET - which is every install today and most installs
|
||||
ever - the add-on behaves exactly as `release/1.0` does. Not "the control
|
||||
law is untouched": the whole observable surface, commands and telemetry
|
||||
alike.
|
||||
2. with `pv_entity` SET, the CONTROL decisions are still exactly those. The
|
||||
channel is instrumentation and must never become a control input, because
|
||||
a loop that reacted to production would destroy the very measurement the
|
||||
card exists to make.
|
||||
|
||||
This is what says so. It drives a fixed stimulus set through the SHIPPED
|
||||
`Controller` and prints two documents per setting -
|
||||
|
||||
`control` every command written to an actuator, every `self.target`, every
|
||||
`inputs_ok`, the loop's own three fields and the arbiter's
|
||||
resolution, tick by tick. NO TEL-11 MATERIAL REACHES IT.
|
||||
`telemetry` the published frames, the status rows and the announcement -
|
||||
i.e. everything the card is ALLOWED to move.
|
||||
|
||||
- and a `tree` fingerprint: sha256 and byte count of every `app/*.py` it
|
||||
imported, so the comparison can say WHICH TREES it graded.
|
||||
|
||||
python3 tools/tel11_pv_trace.py > /tmp/tel11-head.json
|
||||
# …in a release/1.0 checkout, with this file copied in…
|
||||
python3 tools/tel11_pv_trace.py > /tmp/tel11-base.json
|
||||
python3 tools/tel11_pv_trace.py --compare /tmp/tel11-base.json \\
|
||||
/tmp/tel11-head.json
|
||||
|
||||
⚠️ THE CONTROL IS PART OF THE TOOL, WHICH IS MUTATION.md HABIT 4 IN AN
|
||||
INSTRUMENT - AND IT IS A DIFFERENT CONTROL FROM `safety56_behaviour_trace.py`'s,
|
||||
ON PURPOSE. That card CHANGED sentences, so its `--compare` refuses to report
|
||||
"behaviour unchanged" unless the sentences differ. THIS card's headline claim is
|
||||
that with the option unset NOTHING differs at all, so "the documents are
|
||||
identical" is the result rather than the warning sign - and the thing that has
|
||||
to be proved instead is that the two runs came from DIFFERENT TREES and that the
|
||||
instrument can see this card's material when it is switched on. So `--compare`
|
||||
refuses unless:
|
||||
|
||||
* the two `tree` fingerprints DIFFER (otherwise it is one tree twice, which
|
||||
is the trap ARCH-02's rehearsal harness walked into and SAFETY-56 names);
|
||||
* exactly ONE side's `set` document differs from its own `unset` document.
|
||||
That side is the HEAD; the other is the baseline, which has no such option
|
||||
and cannot react to it. Both, or neither, is ambiguous and is refused.
|
||||
|
||||
⚠️ WHAT IT CANNOT SEE, with shapes (habit 13):
|
||||
* a roster of more than one, and any per-unit surface;
|
||||
* the real MQTT transport, Home Assistant, and the recorder. The publication
|
||||
is graded as the dict `Controller.publish()` hands the publisher plus the
|
||||
announcement `discovery_payloads()` returns - not as bytes on a broker;
|
||||
* wall-clock behaviour and the 1 s tick: every clock here is an argument;
|
||||
* the ingress HTML, the action log and the firmware;
|
||||
* anything a `pv_entity` whose sensor is HEALTHY does not exercise - the
|
||||
stimulus set drives a good read, a refused unit and a dead sensor, and
|
||||
nothing else;
|
||||
* ⚠️ and it is an IDENTITY over a fixed stimulus, so a divergence that needs
|
||||
an input this set never produces is invisible to it by construction. The
|
||||
stimulus is printed with the documents for exactly that reason.
|
||||
|
||||
⚠️ PURGE `__pycache__` BEFORE USE on a tree you have just edited.
|
||||
`tools/mutation_run.sh` does, and MUTATION.md says why `python3 -B` alone does
|
||||
not.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent # the add-on directory
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
Controller = mqtt = None
|
||||
|
||||
|
||||
def _load():
|
||||
"""The add-on's modules, imported LAZILY so `--compare` runs anywhere.
|
||||
|
||||
⚠️ NOT A STYLE CHOICE - `safety56_behaviour_trace.py`'s note is this one's
|
||||
too. `--compare` is the half a reviewer runs against two JSON files, in a
|
||||
directory with no `app/` in it, and a top-level import would make the
|
||||
comparison impossible exactly where it is most useful.
|
||||
"""
|
||||
global Controller, mqtt
|
||||
from app.main import Controller # noqa: E402,F401
|
||||
from app import mqtt # noqa: E402,F401
|
||||
|
||||
|
||||
A = 5000.0
|
||||
TUNING = {"max_w": A, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
|
||||
"deadband_w": 15, "step_w": 10}
|
||||
ONE = dict(TUNING, meter_source="off", meter_entity="sensor.grid",
|
||||
setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a", heartbeat_s=10, stale_input_s=15,
|
||||
inverters=[], bms_soc_floor=10)
|
||||
PV_ENTITY = "sensor.pv_power"
|
||||
|
||||
|
||||
class MemStore:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
def set_time(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def __init__(self):
|
||||
self.frames = []
|
||||
|
||||
def publish(self, values):
|
||||
self.frames.append(dict(values))
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
"""A scripted meter, a following inverter, and a scripted PV sensor."""
|
||||
|
||||
def __init__(self, grid_of, soc, pv_of):
|
||||
self.grid_of, self.soc, self.pv_of = grid_of, soc, pv_of
|
||||
self.batt = 0.0
|
||||
self.n = 0
|
||||
self.writes = []
|
||||
|
||||
async def limits(self, entity):
|
||||
return (-A, A, 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = {"sensor.grid": self.grid_of(self.n), "sensor.soc": self.soc,
|
||||
"sensor.soc_a": self.soc, "sensor.batt_a": self.batt,
|
||||
"sensor.batt": self.batt}.get(entity)
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def state(self, entity):
|
||||
return self.pv_of(self.n)
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, float(value)))
|
||||
self.batt = float(value)
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
def _pv_state(watts, unit="W"):
|
||||
return {"state": str(watts),
|
||||
"attributes": {"unit_of_measurement": unit}}
|
||||
|
||||
|
||||
# ⚠️ THE STIMULUS SET, PRINTED WITH THE DOCUMENTS. An identity is only as wide
|
||||
# as what was driven through it, so the worlds and the PV schedules are part of
|
||||
# the output rather than a fact about the source.
|
||||
#
|
||||
# Each PV schedule is exercised against each grid world. A schedule that only
|
||||
# ever answered healthily could not separate "the loop ignores production" from
|
||||
# "the loop ignores a production channel that is always saying the same thing".
|
||||
GRIDS = {
|
||||
"EXPORT-6K": lambda t: -6000.0,
|
||||
"IMPORT-3K": lambda t: 3000.0,
|
||||
"REST": lambda t: -10.0,
|
||||
"SWINGING": lambda t: (-4000.0 if (t // 5) % 2 else 4000.0),
|
||||
"METER-GAPS": lambda t: (None if t % 7 == 3 else -1500.0),
|
||||
}
|
||||
PVS = {
|
||||
"none": lambda t: None,
|
||||
"zero": lambda t: _pv_state(0),
|
||||
"noon": lambda t: _pv_state(8000),
|
||||
"ramp": lambda t: _pv_state(t * 250),
|
||||
"kilowatts": lambda t: _pv_state(4.25, "kW"),
|
||||
"bad-unit": lambda t: _pv_state(1234, "%"),
|
||||
"flapping": lambda t: (None if t % 3 else _pv_state(3300)),
|
||||
"negative": lambda t: _pv_state(-4200),
|
||||
}
|
||||
CYCLES = 24
|
||||
|
||||
|
||||
async def _run(grid_of, pv_of, pv_set, soc=50.0, stop_at=None):
|
||||
hass = Hass(grid_of, soc, pv_of)
|
||||
pub = Pub()
|
||||
opts = dict(ONE)
|
||||
if pv_set:
|
||||
opts["pv_entity"] = PV_ENTITY
|
||||
c = Controller(opts, hass, MemStore(), pub, stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -A, A
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -A, A
|
||||
ctrl = []
|
||||
for t in range(CYCLES):
|
||||
if stop_at is not None and t == stop_at:
|
||||
c.auto = False
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
c.publish()
|
||||
# ⚠️ THE CONTROL DOCUMENT, AND NOTHING FROM THIS CARD IS IN IT. Every
|
||||
# field here existed on release/1.0 and is a DECISION: what was
|
||||
# commanded, what the site is aiming at, whether the inputs were good
|
||||
# enough to act on, what the loop did and which claim won.
|
||||
ctrl.append([hass.writes[-1] if hass.writes else None,
|
||||
c.target, c.inputs_ok, round(c.i_w, 6),
|
||||
round(c.loop_i_w, 6), round(c.loop_i_lead, 6),
|
||||
c.loop_reason, c.reason, c.following,
|
||||
c.maint.phase, len(c.events)])
|
||||
hass.n += 1
|
||||
return c, hass, pub, ctrl
|
||||
|
||||
|
||||
def documents(pv_set: bool) -> dict:
|
||||
"""`{"control": …, "telemetry": …}` for one setting of `pv_entity`."""
|
||||
_load()
|
||||
logging.disable(logging.CRITICAL)
|
||||
loop = asyncio.new_event_loop()
|
||||
control, tele = {}, {}
|
||||
for gname, gof in GRIDS.items():
|
||||
for pname, pof in PVS.items():
|
||||
key = f"{gname}/{pname}"
|
||||
c, hass, pub, ctrl = loop.run_until_complete(
|
||||
_run(gof, pof, pv_set))
|
||||
control[key] = {
|
||||
"ticks": ctrl,
|
||||
"writes": hass.writes,
|
||||
"sha": hashlib.sha256(
|
||||
json.dumps(ctrl, sort_keys=True).encode()).hexdigest(),
|
||||
}
|
||||
rows = c.checks()
|
||||
tele[key] = {
|
||||
"frames": pub.frames,
|
||||
"rows": [[r["ok"], r["warn"], r["text"]] for r in rows],
|
||||
"n_rows": len(rows),
|
||||
"page": c.status_payload()["rows"],
|
||||
"level": c.status_payload()["level"],
|
||||
"banner": c.status_payload()["banner"],
|
||||
}
|
||||
# A stopped run, because the "stopped or blind" branch writes its own rows.
|
||||
c, hass, pub, ctrl = loop.run_until_complete(
|
||||
_run(GRIDS["EXPORT-6K"], PVS["noon"], pv_set, stop_at=8))
|
||||
control["STOPPED"] = {"ticks": ctrl, "writes": hass.writes,
|
||||
"sha": hashlib.sha256(
|
||||
json.dumps(ctrl, sort_keys=True).encode()
|
||||
).hexdigest()}
|
||||
tele["STOPPED"] = {"frames": pub.frames,
|
||||
"rows": [[r["ok"], r["warn"], r["text"]]
|
||||
for r in c.checks()]}
|
||||
loop.close()
|
||||
# The announcement is telemetry too, and it is the surface an install's
|
||||
# entity registry is keyed on.
|
||||
tele["ANNOUNCEMENT"] = _announcement(pv_set)
|
||||
return {"control": control, "telemetry": tele}
|
||||
|
||||
|
||||
def _supports_include() -> bool:
|
||||
"""Does THIS tree's `discovery_payloads()` take TEL-11's `include`?
|
||||
|
||||
⚠️ A PROPERTY OF THE TREE, AND IT IS RECORDED IN `tree` RATHER THAN IN A
|
||||
DOCUMENT. `release/1.0` has neither the argument nor `optional_keys()`, so
|
||||
the tool has to run on a tree that predates both or it cannot produce a
|
||||
baseline at all. Putting the answer in the documents instead would make
|
||||
`unset` differ between the two trees for a reason that is not behaviour -
|
||||
which is the "check that goes red for the wrong reason" MUTATION.md keeps
|
||||
finding - so it goes beside the file hashes, where a reader comparing two
|
||||
trees is already looking.
|
||||
"""
|
||||
import inspect
|
||||
return ("include"
|
||||
in inspect.signature(mqtt.discovery_payloads).parameters
|
||||
and hasattr(mqtt, "optional_keys"))
|
||||
|
||||
|
||||
def _announcement(pv_set: bool) -> list:
|
||||
"""The retained discovery set, in announcement order.
|
||||
|
||||
⚠️ AT `pv_set` FALSE THIS IS THE BARE CALL ON BOTH TREES, which is the
|
||||
point: an install that never set the option must receive the SAME
|
||||
announcement from both, and that identity is graded rather than argued.
|
||||
"""
|
||||
if pv_set and _supports_include():
|
||||
payloads = mqtt.discovery_payloads(
|
||||
include=mqtt.optional_keys({"pv_entity": PV_ENTITY}))
|
||||
else:
|
||||
payloads = mqtt.discovery_payloads()
|
||||
return [[t, json.dumps(cfg, sort_keys=True)] for t, cfg in payloads]
|
||||
|
||||
|
||||
def tree() -> dict:
|
||||
"""sha256 and byte count of every `app/*.py` this run imported from."""
|
||||
_load()
|
||||
out = {"supports_include": _supports_include()}
|
||||
for f in sorted((HERE / "app").glob("*.py")):
|
||||
raw = f.read_bytes()
|
||||
out[f"app/{f.name}"] = [hashlib.sha256(raw).hexdigest(), len(raw)]
|
||||
return out
|
||||
|
||||
|
||||
def _h(obj) -> str:
|
||||
return hashlib.sha256(json.dumps(obj, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def compare(a_path, b_path) -> int:
|
||||
a = json.loads(Path(a_path).read_text(encoding="utf-8"))
|
||||
b = json.loads(Path(b_path).read_text(encoding="utf-8"))
|
||||
|
||||
# --- the control, first: two DIFFERENT trees, and one of them reacts -----
|
||||
if a["tree"] == b["tree"]:
|
||||
print("REFUSED: the two runs have the SAME `app/` fingerprint, so they "
|
||||
"are one tree twice. An identity is evidence only when the two "
|
||||
"sides could have differed.")
|
||||
return 2
|
||||
moved = [side for side, doc in (("A", a), ("B", b))
|
||||
if doc["set"] != doc["unset"]]
|
||||
if len(moved) != 1:
|
||||
print(f"REFUSED: `set` differs from `unset` on {moved or 'NEITHER'} "
|
||||
"side. Exactly one side must react to `pv_entity` - the HEAD. "
|
||||
"Neither means the instrument cannot see this card's material "
|
||||
"at all, and both means the baseline is not a baseline.")
|
||||
return 2
|
||||
head, base = (b, a) if moved == ["B"] else (a, b)
|
||||
hlabel = "B" if moved == ["B"] else "A"
|
||||
blabel = "A" if hlabel == "B" else "B"
|
||||
print(f" HEAD is {hlabel}; baseline is {blabel} (it does not react to "
|
||||
f"`pv_entity`, which is what a tree without the option does).")
|
||||
print(f" tree {blabel} {_h(base['tree'])}")
|
||||
print(f" tree {hlabel} {_h(head['tree'])}")
|
||||
for what in ("control", "telemetry"):
|
||||
print(f" {what:<9} {blabel} unset {_h(base['unset'][what])}")
|
||||
print(f" {what:<9} {hlabel} unset {_h(head['unset'][what])}")
|
||||
print(f" {what:<9} {hlabel} set {_h(head['set'][what])}")
|
||||
|
||||
rc = 0
|
||||
# 1. the acceptance criterion: unset is the baseline, in full.
|
||||
if head["unset"] == base["unset"]:
|
||||
print("OK 1/3 UNSET: control AND telemetry are byte-identical to the "
|
||||
"baseline tree. An install that does not set `pv_entity` sees "
|
||||
"no change of any kind.")
|
||||
else:
|
||||
rc = 1
|
||||
for what in ("control", "telemetry"):
|
||||
for k in sorted(set(head["unset"][what]) | set(base["unset"][what])):
|
||||
if head["unset"][what].get(k) != base["unset"][what].get(k):
|
||||
print(f"FAIL 1/3 UNSET {what} MOVED at {k!r}")
|
||||
# 2. the card's other half: setting it moves no DECISION.
|
||||
if head["set"]["control"] == head["unset"]["control"]:
|
||||
print("OK 2/3 SET: the control document is byte-identical with the "
|
||||
"channel switched on - PV reaches no decision.")
|
||||
else:
|
||||
rc = 1
|
||||
for k in sorted(head["set"]["control"]):
|
||||
if head["set"]["control"][k] != head["unset"]["control"][k]:
|
||||
print(f"FAIL 2/3 CONTROL MOVED at {k!r} when `pv_entity` was "
|
||||
f"set")
|
||||
# 3. …and the instrument could have seen it. Without this the first two
|
||||
# are an identity measured against a document that reaches nothing.
|
||||
if head["set"]["telemetry"] != head["unset"]["telemetry"]:
|
||||
n = sum(1 for k in head["set"]["telemetry"]
|
||||
if head["set"]["telemetry"][k]
|
||||
!= head["unset"]["telemetry"].get(k))
|
||||
print(f"OK 3/3 CONTROL OF THE CONTROL: the telemetry document DOES "
|
||||
f"move when the option is set ({n} stimulus cells differ), so "
|
||||
f"1/3 and 2/3 are identities the instrument could have broken.")
|
||||
else:
|
||||
rc = 2
|
||||
print("REFUSED 3/3: the telemetry document did NOT move when "
|
||||
"`pv_entity` was set. This instrument cannot see the card's "
|
||||
"material, so nothing above it means anything.")
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 4 and sys.argv[1] == "--compare":
|
||||
sys.exit(compare(sys.argv[2], sys.argv[3]))
|
||||
print(json.dumps({
|
||||
"tree": tree(),
|
||||
"stimulus": {"grids": sorted(GRIDS), "pv": sorted(PVS),
|
||||
"cycles": CYCLES},
|
||||
"unset": documents(False),
|
||||
"set": documents(True),
|
||||
}, sort_keys=True, indent=1))
|
||||
@@ -84,6 +84,9 @@ configuration:
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485 emergency stop is fitted"
|
||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
||||
pv_entity:
|
||||
name: "Hardware · PV production entity (W)"
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
meter_source:
|
||||
name: "Meter · Where the meter reading comes from"
|
||||
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
|
||||
|
||||
@@ -64,6 +64,9 @@ configuration:
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485-noodstop is geplaatst"
|
||||
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
|
||||
pv_entity:
|
||||
name: "Hardware · Zonneproductie-entiteit (W)"
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
meter_source:
|
||||
name: "Meter · Waar de meterstand vandaan komt"
|
||||
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."
|
||||
|
||||
Reference in New Issue
Block a user