Compare commits
18
Commits
ci9-gateproof
...
TEL-12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef9565071 | ||
|
|
cccb2fa07b | ||
|
|
b11bea70d3 | ||
|
|
cfafdffaed | ||
|
|
94da37fc72 | ||
|
|
07ba3433ed | ||
|
|
9f6b9f14cd | ||
|
|
c9aaf7f04d | ||
|
|
8a6acd9fa2 | ||
|
|
239e120bf6 | ||
|
|
9d593429d3 | ||
|
|
b0b24e5d41 | ||
|
|
eb88646899 | ||
|
|
b9f7bd0506 | ||
|
|
43c144b8d9 | ||
|
|
6f5e8e378a | ||
|
|
8a5736136a | ||
|
|
5aba220046 |
@@ -33,6 +33,8 @@ estop/rs485_log.py optional RS485 e-stop / bus witness
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
|
||||
docs/build_guide.py its single source - rebuilds the HTML and PDF
|
||||
FIELD-GUIDE.md how to edit and rebuild the guide
|
||||
docs/RELEASE.md how a change reaches the battery - `master` is
|
||||
protected, releases go via PR from `release/1.0`
|
||||
```
|
||||
|
||||
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Releasing — how a change reaches the battery
|
||||
|
||||
`master` is the **default branch**, and the default branch is what the Home
|
||||
Assistant Supervisor clones when it installs or updates this add-on. Whatever
|
||||
sits on `master` is what runs on a real 5 kW inverter. Treat a push to `master`
|
||||
as a deployment, because that is what it is.
|
||||
|
||||
---
|
||||
|
||||
## 1. `master` is protected — releases go through a pull request
|
||||
|
||||
Until 2026-09-23 `master` had **no protection of any kind**. Every gate this
|
||||
repo built (CI-01, CI-9) sat on `release/1.0`, the branch that does *not* ship.
|
||||
On 2026-09-22 version 0.10.1 went straight onto `master` — no pull request, no
|
||||
CI run, no reviewer — and it succeeded (commit `0f14c2a`, the only commit on
|
||||
`master` that carries no `(#NN)` merge suffix). SEC-2 closed that.
|
||||
|
||||
**The release path is now:**
|
||||
|
||||
1. Merge the work into `release/1.0` as usual, via a pull request that goes
|
||||
green. Bump `version:` in `goodwe_controller/config.yaml` on that branch —
|
||||
the Supervisor keys the built image by `version:`, so without a bump nothing
|
||||
is offered and nothing changes.
|
||||
2. Open a pull request **`release/1.0` → `master`**. Do not push to `master`;
|
||||
it will be refused by the pre-receive hook:
|
||||
|
||||
```
|
||||
remote: error: Not allowed to push to protected branch master
|
||||
! [remote rejected] ... (pre-receive hook declined)
|
||||
```
|
||||
|
||||
3. Wait for `checks / unit (pull_request)` to go **green** on that pull request.
|
||||
The workflow in `.gitea/workflows/checks.yml` carries **no `branches:`
|
||||
filter**, so a `master` base matches and a real run happens — verified on
|
||||
throwaway PR #107 (base `master`), which queued a genuine run.
|
||||
|
||||
⚠️ Green means green. A `skipped` status is laundered into a `success`
|
||||
rollup by this Gitea and the merge gate accepts it — see the long note at
|
||||
the top of `.gitea/workflows/checks.yml`. Never add an event filter there.
|
||||
|
||||
4. Merge the pull request. `required_approvals` is **0**, so a green check is
|
||||
the whole gate; there is no second pair of eyes unless someone asks for one.
|
||||
|
||||
5. **Reload the store.** Merging to `master` does not, by itself, offer the
|
||||
update. The Supervisor only sees the new version after:
|
||||
|
||||
```bash
|
||||
ssh root@192.168.2.158 "ha store reload && ha addons update local_goodwe_controller"
|
||||
```
|
||||
|
||||
Until `ha store reload` runs, the add-on page still shows the old version
|
||||
and the release looks like it did nothing.
|
||||
|
||||
Note that `release/1.0` is normally an *ancestor* of `master` between releases,
|
||||
so a `release/1.0 → master` pull request is empty until new commits land on
|
||||
`release/1.0`. That is expected; it means there is nothing to release yet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Branch protection — before and after SEC-2
|
||||
|
||||
Forge is Gitea at `192.168.2.147:3000`, repo `admin/goodwe-addon`. Read it back
|
||||
with `GET /api/v1/repos/admin/goodwe-addon/branch_protections`.
|
||||
|
||||
**Before (2026-09-23):**
|
||||
|
||||
| branch | protected | status check required | context | approvals |
|
||||
|---|---|---|---|---|
|
||||
| `master` (default) | **no rule at all** | — | — | — |
|
||||
| `release/1.0` | yes | yes | `checks / unit (pull_request)` | 0 |
|
||||
|
||||
**After:**
|
||||
|
||||
| branch | protected | status check required | context | approvals |
|
||||
|---|---|---|---|---|
|
||||
| `master` (default) | yes | yes | `checks / unit (pull_request)` | 0 |
|
||||
| `release/1.0` | yes (unchanged) | yes | `checks / unit (pull_request)` | 0 |
|
||||
|
||||
Both rules are otherwise identical: `enable_push: false`,
|
||||
`enable_force_push: false`, `block_admin_merge_override: true`, no whitelists,
|
||||
no file patterns. `required_approvals` stays at **0** on purpose — raising it
|
||||
would block the review agent from merging, and nobody asked for it.
|
||||
|
||||
The default branch was **not** changed. Changing it would change what the
|
||||
Supervisor clones, which is a different decision from this one.
|
||||
|
||||
### How the gate was proven to actually bite
|
||||
|
||||
A protection rule on this forge can *appear* satisfied while having checked
|
||||
nothing, so the rule was tested rather than trusted. The discriminator is a
|
||||
merge `POST` carrying a deliberately wrong `head_commit_id`: Gitea evaluates
|
||||
branch protection *before* it checks the head, so
|
||||
|
||||
* `409 head out of date` → the request **cleared** protection;
|
||||
* `405 Not all required status checks successful` → protection **refused** it.
|
||||
|
||||
Read the *message*, not just the code: `405` is also returned as
|
||||
`Please try again later` for a pull request with **no commits**, which has
|
||||
nothing to do with protection. An empty pull request on an unprotected base
|
||||
gives that same `405` (verified, throwaway PR #110), so an empty
|
||||
`release/1.0 → master` reports it too.
|
||||
|
||||
Validated against a control on an unprotected base (PR #106), which answered
|
||||
`409` while its check was still pending — i.e. protection was no obstacle
|
||||
there. On `master` (PR #107) the same probe answered `405` while pending, and
|
||||
`409` once the check was genuinely green. A direct `git push` to `master` was
|
||||
rejected by the pre-receive hook with `master`'s SHA unmoved.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rolling back
|
||||
|
||||
There is no force-push escape hatch: `enable_force_push` is `false` on both
|
||||
branches. To undo a bad release, land a revert on `release/1.0`, bump the
|
||||
version again, and take it through §1. The `/addons/` rollback for the
|
||||
reference install is in `HANDOVER.md`.
|
||||
@@ -1,5 +1,491 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0
|
||||
|
||||
**A MINOR BUMP, AND ONE NEW OPTION IS THE WHOLE REASON FOR THE SECOND DIGIT:
|
||||
`pv_entity` ([[TEL-11]]).** The option surface moves - **75 keys -> 76**,
|
||||
`options:` and `schema:` in step - which is the same test 0.8.0, 0.9.0 and
|
||||
0.10.0 were each cut on. ⚠️ **AND UNLIKE 0.10.0, NOTHING HERE CAN REFUSE A
|
||||
STORED OPTIONS BLOB.** No key was removed, no default moved, no enum widened,
|
||||
and **no rule was added to `validate.py`** - the validator does not mention
|
||||
`pv_entity` at all, because nothing else reads it and it cannot conflict with
|
||||
anything. The new key is `str?` - **note the trailing `?`, it is OPTIONAL** -
|
||||
with an `options:` default of `""`, so a blob written on 0.10.0 or 0.10.1 does
|
||||
not carry it, the Supervisor's merge supplies the empty string, and the empty
|
||||
string is *off*. ⚠️ **A stored options blob that starts the add-on on 0.10.1
|
||||
starts it on 0.11.0. There is no `SystemExit(78)` to plan around on this hop**,
|
||||
and a site that presses *Update* and changes nothing else gets an add-on that
|
||||
behaves exactly as its 0.10.1 did.
|
||||
|
||||
⚠️ **WHAT YOU ARE ACTUALLY DECIDING, IN ONE LINE EACH. TWO TICKETS HERE ARE
|
||||
OBSERVABLE ON AN INSTALLED ADD-ON, AND NEITHER CHANGES A WATT.**
|
||||
|
||||
* [[SAFETY-56]] - **three messages stop naming your hardware as the fault** in a
|
||||
state where the add-on cannot tell a dead register from a battery that is
|
||||
simply empty at its BMS floor. **Text only; the behaviour is measured
|
||||
byte-identical.** If you have ever opened Developer Tools because the add-on
|
||||
told you a sensor had failed, and found the sensor working, this is that
|
||||
defect. On the reference install the state it misdiagnoses arrives on most
|
||||
nights and on every cloudy morning after a deep discharge.
|
||||
* [[TEL-11]] - **one new optional key, `pv_entity`, and nothing whatever happens
|
||||
until you fill it in.** Set it and the add-on records what your solar is
|
||||
producing beside what the meter and the battery are doing. ⚠️ **It is not a
|
||||
control input and it does not change what the loop does.**
|
||||
|
||||
⚠️ **THERE IS NO CONTROL-LAW CHANGE IN THIS RELEASE, WHICH IS WORTH SAYING
|
||||
BECAUSE 0.10.0 HAD ONE AND NOTHING IN ITS PREAMBLE COULD WARN YOU.**
|
||||
`app/control.py` is not in this release's diff at all, and neither are
|
||||
`allocator.py`, `arbiter.py`, `arbitrage.py`, `clamp.py`, `peakguard.py`,
|
||||
`maintenance.py` or `watchdog.py`. The runtime files that move are
|
||||
`app/freshness.py` and `app/main.py` (SAFETY-56's three sentences), and
|
||||
`app/pv.py` - new - with `app/main.py`, `app/mqtt.py`, `config.yaml` and
|
||||
`translations/` (TEL-11). Contrast [[SAFETY-48]] in `## 0.10.0`, which altered
|
||||
what the inverter is asked to do while touching no option, no default and no
|
||||
validator rule, so that nothing a config check could see would mention it. This
|
||||
release has nothing of that shape in it.
|
||||
|
||||
⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS
|
||||
STILL NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons
|
||||
update` does not walk the versions; it installs 0.11.0 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**, added in 0.8.0, against 0.7.0's own shipped
|
||||
> default `slew_w: 1000` - the ceiling is 2/18 of the site rail, which is
|
||||
> **555.6 W** on a 5000 W rail.
|
||||
>
|
||||
> [[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 0.11.0**, with the same message and for the same
|
||||
> measured reasons: a 404 from a gateway with no route, a TLS handshake against
|
||||
> a hostname the certificate does not cover, and a 404 with an empty body.
|
||||
|
||||
⚠️ **NEITHER OF THOSE IS NEW IN 0.11.0, AND NEITHER IS NEWS TO A SITE ALREADY ON
|
||||
0.10.0 OR 0.10.1 - IT HAS ALREADY MET THEM.** They are restated here only
|
||||
because this is now the newest section, and a reader who opens the newest section
|
||||
and stops would otherwise never meet the one class of thing that can stop the
|
||||
add-on starting. **The full account of both is in `## 0.10.0` below**: what to
|
||||
set instead of each withdrawn id, which tariff a site left on one of them has
|
||||
really been running, why the refusal *is* the migration, and why all three ids
|
||||
stay in `config.yaml`'s `price_provider` enum even though `PROVIDERS` is down to
|
||||
three. **That is the section that withdrew them, it is where the evidence is,
|
||||
and it is not superseded by this one.** ⚠️ The reference install is on
|
||||
`price_provider: off` and at `slew_w: 400`, so neither applies to it.
|
||||
`tools/predeploy_check.py` checks both against your live options blob before you
|
||||
press *Update*, and on this tree it reports a real version change rather than
|
||||
0.10.1's no-op.
|
||||
|
||||
Contents of this release, in merge order since 0.10.1 (`b0b24e5`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `c9aaf7f` | SAFETY-56 - the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can **⚠️ TEXT ONLY, BEHAVIOUR BYTE-IDENTICAL** |
|
||||
| `94da37f` | TEL-11 - PV production stops being invisible, and stays out of the control law **⚠️ NEW OPTIONAL OPTION, UNSET ON EVERY EXISTING INSTALL** |
|
||||
|
||||
⚠️ **TWO MORE MERGES LANDED IN THIS WINDOW AND ARE DELIBERATELY NOT IN THE TABLE
|
||||
ABOVE: [[CI-9]]'S FOLLOW-UP (`9f6b9f1`) AND [[SEC-2]] (`239e120`). THE RULE IS
|
||||
[[PKG-15]]'S - AUDIENCE, NOT IMPORTANCE - AND BOTH FAIL IT THE SAME WAY.** This
|
||||
file is the **add-on's** release note: the text the Supervisor renders beside the
|
||||
version number, read by somebody deciding whether to press *Update* on a battery
|
||||
that is currently regulating. A row here has to be something that reader can act
|
||||
on.
|
||||
|
||||
* **CI-9's follow-up changes one file, `.gitea/workflows/checks.yml`**, exactly
|
||||
as CI-9 itself did, and it is comment-only within that file. It is not under
|
||||
`goodwe_controller/`, it is not in `predeploy_check.py`'s `RUNTIME` tuple, it
|
||||
is not in the built image, and **there is no version of this add-on on which an
|
||||
operator can observe it.** `## 0.10.0` excluded CI-9 on exactly that reasoning;
|
||||
excluding the follow-up on any other reasoning would make the rule mean
|
||||
nothing.
|
||||
* **SEC-2 adds `docs/RELEASE.md` and two lines to `README.md`, and this one is
|
||||
the closer call, so here is the whole of it.** SEC-2 is by some distance the
|
||||
more consequential of the two: it is the ticket that made `master` refuse a
|
||||
direct push, and it is *why this release reaches you as a pull request into
|
||||
`release/1.0` and then into `master`, rather than as a push straight to the
|
||||
branch the Supervisor clones* - which is how 0.10.1 got there. It is also
|
||||
**purely a procedure for whoever cuts a release**; both files sit outside
|
||||
`goodwe_controller/`, neither is in the image, and no install can tell whether
|
||||
that document exists or what it says. An operator reading it learns nothing
|
||||
about 0.11.0 that helps them decide whether to take it. So it is excluded, and
|
||||
it is excluded under the same sentence as CI-9: ⚠️ **importance to this
|
||||
repository is not the test. Observability on an installed add-on is.**
|
||||
|
||||
⚠️ **AND BOTH ARE WRITTEN DOWN, JUST NOT HERE.** CI-9's follow-up is PR #102 plus
|
||||
the comment block at the top of `.gitea/workflows/checks.yml`, which carries the
|
||||
measurements in full; SEC-2 is PR #108 plus `docs/RELEASE.md` itself, which is
|
||||
now the canonical account of the release path and is the procedure this release
|
||||
followed. ⚠️ **If a later CI or packaging ticket changes what the shipped image
|
||||
contains, it belongs in this table.** Neither of these does.
|
||||
|
||||
### SAFETY-56 — three messages stop blaming healthy hardware
|
||||
|
||||
⚠️ **NOTHING THE ADD-ON DOES CHANGES. THREE THINGS IT SAYS DO.** Same
|
||||
conjunction, same limits, same `MISSING`, same 0 W command, same NOT READY row,
|
||||
same [[SAFETY-57]] release at 32 s. `tools/safety56_behaviour_trace.py` is what
|
||||
says so rather than a commit message: it hashes a document with no sentence
|
||||
anywhere in it, built from **2390 one-second reads replayed out of the committed
|
||||
2026-09-21 bus capture**, a **66-cell command × register grid stepped 120 times
|
||||
each**, and **six driven controller worlds**. That document is
|
||||
`6030175ed2ab9a41…` on `release/1.0` and on this tree, byte for byte, while the
|
||||
sentences differ. ⚠️ **And the tool REFUSES to report "behaviour unchanged" when
|
||||
the sentence halves agree**, so an identical pair is a result rather than one
|
||||
tree compared with itself.
|
||||
|
||||
**THE DEFECT, AND IT IS NOT A TYPO.** [[SAFETY-37]]'s freshness gate fires when a
|
||||
battery-power register holds one value while the standing command moves. That
|
||||
conjunction is satisfied by **two disjoint worlds**:
|
||||
|
||||
1. the register has stopped reporting reality - which is what the gate was built
|
||||
to catch; or
|
||||
2. **reality is not moving**, because the source cannot comply with the command.
|
||||
A BMS refusing a discharge at its floor holds the pack still under any
|
||||
setpoint, and a register reporting that honestly publishes the same unchanged
|
||||
number a dead one does.
|
||||
|
||||
**Nothing the gate is handed separates them, and three sentences asserted the
|
||||
first.** ⚠️ **On the one episode this repository has measured, it was the
|
||||
second.** On 2026-09-21 the pack read **11 %** for the whole 850 s hold, **every
|
||||
command inside that hold was a discharge**, and the register **answered the first
|
||||
CHARGE command three seconds later**. All three numbers are read off the
|
||||
committed capture by `test_freshness.py` part 11a rather than typed there. The
|
||||
register was healthy and truthful throughout, and the add-on said it had failed.
|
||||
|
||||
**THE THREE SURFACES, so you can recognise what moved:**
|
||||
|
||||
| where you read it | what 0.10.1 said | what 0.11.0 says |
|
||||
|---|---|---|
|
||||
| the **action log** and the **status page** — both quote `freshness.py`'s `STALE_READING:` line verbatim, through `_read_unit()` and `stale_note()` | `… the reading is not following, so it is reported as MISSING` | `… 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 …` |
|
||||
| the **add-on log**, at ERROR, from `main.py` | `SAFETY-37: <unit> battery-power register is not following its own command` | `SAFETY-37: <unit> battery-power reading did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER` |
|
||||
| the **readiness row** on the diagnostics page | `battery power: <entity> is missing or not numeric` | `battery power: no usable value this cycle from <entity> - and this row cannot tell which of two things that is … Confirm the entity in Developer Tools before replacing it` |
|
||||
|
||||
⚠️ **THE THIRD ROW IS THE ONE THAT COST AN OPERATOR AN EVENING, AND IT IS THE
|
||||
WORST THING A DIAGNOSTIC CAN DO: IT ASSERTED A FALSE FACT ABOUT A NAMED
|
||||
ENTITY.** It read *"is missing or not numeric"* for **any** `None`, and one of
|
||||
the ways that value becomes `None` is the freshness gate withholding a reading
|
||||
that is present, numeric and updating - `Freshness.reading()` returns `None`
|
||||
there **by design**. So the operator opened Developer Tools on the entity the row
|
||||
named, found it working, and had every reason to conclude the diagnostics were
|
||||
broken. **It is fixed by saying less, not by looking harder:** the row does not
|
||||
branch on the gate's state, because 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 your discriminator is *"is that row there?"*,
|
||||
and this row's only job is to stop pre-empting the answer.
|
||||
|
||||
⚠️ **WHAT IT DOES NOT GIVE YOU IS WHICH OF THE TWO IT WAS.** Naming which would
|
||||
need the SoC and the pack floor, and those are deliberately not `freshness.py`'s
|
||||
inputs - they are [[TEL-08]]'s and [[SAFETY-32]]'s numbers, on their own rows.
|
||||
**SoC is STATED in these sentences and never WIRED into them**: part 11b drives
|
||||
the same two worlds at 50 % and at 11 % and the sentence does not move one byte.
|
||||
What changed is that the add-on now tells you it cannot tell, instead of guessing
|
||||
and blaming your hardware. **The reading rule is unchanged from
|
||||
`### SAFETY-57` below**: if the stale/withdrawn pair keeps repeating, the unit
|
||||
really does want looking at; if it happens once at a low SoC and stops, the pack
|
||||
was empty and the site has resumed.
|
||||
|
||||
⚠️ **AND THE THREE WORDINGS SHARE NO HELPER, ON PURPOSE.** Two of them are log
|
||||
lines about a **behaviour** and the third is a banner about an **entity**; they
|
||||
answer different questions ("what did the register do" against "can I trust this
|
||||
entity"), and one 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.
|
||||
|
||||
**Full account: `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`.**
|
||||
|
||||
### TEL-11 — `pv_entity`: PV production gets recorded, and stays out of the control law
|
||||
|
||||
⚠️ **UNSET, THIS IS NOT A CHANGE AT ALL - AND THAT IS MEASURED RATHER THAN
|
||||
ASSERTED.** `pv_entity` ships empty, which is what **every** install that
|
||||
upgrades into this version lands on. Empty means the add-on **reads no entity,
|
||||
announces no entity, publishes no topic, shows no status row and adds no page
|
||||
row.** `tools/tel11_pv_trace.py` drives five grid behaviours × eight
|
||||
PV-sensor behaviours through 24 cycles of the **shipped** `Controller`, on
|
||||
`release/1.0` and on this tree, and hashes two separate documents - one of
|
||||
**control** decisions and one of **telemetry**:
|
||||
|
||||
| document | tree | `pv_entity` | sha256 |
|
||||
|---|---|---|---|
|
||||
| control | `release/1.0` | unset | `846a10a5b89e3210…` |
|
||||
| control | TEL-11 | unset | `846a10a5b89e3210…` |
|
||||
| control | TEL-11 | **set** | `846a10a5b89e3210…` |
|
||||
| telemetry | `release/1.0` | unset | `a443a6ceaffe072b…` |
|
||||
| telemetry | TEL-11 | unset | `a443a6ceaffe072b…` |
|
||||
| telemetry | TEL-11 | **set** | `4e6d1cb289a95722…` |
|
||||
|
||||
**Read the two `unset` rows first.** They are byte-identical in **both**
|
||||
documents, which is a stronger claim than "the control path is untouched": an
|
||||
install that does not set the option sees no change of any kind, announcements
|
||||
and status rows included. **Then read the `set` row of the control document** -
|
||||
identical again, while telemetry moves. ⚠️ The tool **REFUSES** unless the two
|
||||
trees' `app/` fingerprints differ and unless exactly one side reacts to the
|
||||
option, so a matching pair cannot be two runs of the same tree.
|
||||
|
||||
⚠️ **IT IS NOT A CONTROL INPUT, AND IT DOES NOT CHANGE WHAT THE LOOP DOES.** It
|
||||
is instrumentation and nothing else. It is not read by 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 your
|
||||
battery being regulated; and its status row is a **caution** (`warn`), never a
|
||||
`bad` row, so a telemetry channel can never put a battery site into NOT READY.
|
||||
`test_pv.py` part 6 drives 40 cycles of the real `cycle()` and asserts the
|
||||
commands are bit-identical across unset / no reading / 0 W / 8 kW, with a
|
||||
**computed** census of all 33 modules under `app/` showing the material is
|
||||
mentioned in exactly three of them: `pv.py`, `main.py`, `mqtt.py`. 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.
|
||||
|
||||
**WHAT YOU GET IF YOU DO SET IT.** One new MQTT-discovery sensor,
|
||||
`sensor.goodwe_pv_power`, `device_class: power` and ⚠️ **`state_class:
|
||||
measurement`** - which is the point of the row, because that is what puts the
|
||||
number into the recorder's long-term statistics, and a week of statistics is what
|
||||
the attribution needs. **Positive is producing.** The unit is **read off your
|
||||
entity's own `unit_of_measurement`** - `mW`, `W`, `kW`, `MW`, `GW`, `TW`, Home
|
||||
Assistant's own power units, every factor an exact power of ten. A sensor with
|
||||
**no** unit, or with a unit that is not power, is **refused by name, and the
|
||||
status page says which entity and which unit.** There is deliberately no
|
||||
magnitude heuristic: nothing can separate 30 W of dusk from 30 kW of noon, and a
|
||||
silent factor of 1000 in the one channel that exists to be believed a week later
|
||||
is worse than a refusal you can read.
|
||||
|
||||
⚠️ **ABSENCE IS PUBLISHED AS `unknown`, NOT AS 0 AND NOT SKIPPED. THIS IS THE
|
||||
DESIGN DECISION WORTH YOUR ATTENTION.**
|
||||
|
||||
| what happened | `sensor.goodwe_pv_power` | 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 why the key is shaped this way. `publish()` normally
|
||||
**drops** a `None`, which is right for `circulation` - there the last figure
|
||||
standing beats a zero that reads as health - and **exactly wrong here**: a
|
||||
dropped row leaves Home Assistant holding the last number it was told, for ever,
|
||||
and the recorder then draws a **flat line through the outage**, which is
|
||||
indistinguishable from a measurement. Anyone reading that week back would
|
||||
conclude the sun was not shining, ⚠️ **which is precisely the conclusion this
|
||||
channel exists to TEST rather than to manufacture.** So the absence is published,
|
||||
using Home Assistant's own no-value payload (`mqtt/const.py` declares
|
||||
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it *before* the numeric
|
||||
branch), the entity's state becomes **`unknown`**, and the recorder stores a
|
||||
**gap**. A failed read also **clears** the previous good one: the value never
|
||||
outlives its own sensor.
|
||||
|
||||
⚠️ **WHY IT IS A HOME ASSISTANT ENTITY AND NOT A REGISTER.** 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 on its own connection,
|
||||
reporting into Home Assistant through its own integration - so this takes
|
||||
whatever `sensor.*` your PV integration already provides, **any brand Home
|
||||
Assistant can read**, and nothing anywhere assumes it is on the bus.
|
||||
|
||||
**THERE IS NO `pv_invert`, AND THAT IS NOT AN OVERSIGHT.** `meter_invert` and
|
||||
`batt_invert` exist because those two quantities are **bipolar and are read by
|
||||
the control path**, where a reversed sensor hands the site a plausible number it
|
||||
acts on. Production is unipolar and is read by **nothing**, so a reversed sensor
|
||||
cannot mislead a decision, and it announces itself as **−4 kW at noon** in the
|
||||
very history this key exists to create. A flag would only let an installer hide
|
||||
it. There is no negative-value alarm either: a PV inverter's own standby draw
|
||||
makes a small negative reading at night ordinary, so any threshold would be a
|
||||
number nobody has measured.
|
||||
|
||||
⚠️ **WHAT THIS RELEASE DOES NOT DO, AND IT IS THE REASON THE CARD EXISTS.** It
|
||||
does not answer [[OPT-10]]'s question. That card measured this site over sixteen
|
||||
days: 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 - and it could not say **why**, because PV production was
|
||||
recorded **nowhere**. Three causes need three different fixes (no irradiance:
|
||||
nothing to fix; a charge-power limit; the optimiser declining to charge, which is
|
||||
the only software defect), and guessing between them is how an optimiser gets
|
||||
tuned to solve a cloud problem. **0.11.0 adds the channel. The attribution needs
|
||||
a week of history through it, and is OPT-10's own card.** So the reason to set
|
||||
`pv_entity` on the day you take this update is that the clock on that week starts
|
||||
when you do.
|
||||
|
||||
**Full account: `DECISIONS/TEL-11-pv-production.md`, with the operator's version
|
||||
under "PV production" in `DOCS.md`.**
|
||||
|
||||
## 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 were open when this section was cut, both
|
||||
are wording or bound changes rather than deadlocks, and neither was allowed to
|
||||
delay this one. ⚠️ **[[SAFETY-56]] HAS SINCE BEEN FIXED AND IS IN `## 0.11.0`
|
||||
ABOVE.** The wording quoted in this paragraph is what **0.10.1 actually ships**
|
||||
and is left standing for that reason - if the box in front of you reports
|
||||
`0.10.1`, that is the sentence it will print at you. It is not what 0.11.0 says.
|
||||
[[SAFETY-58]] is still open.
|
||||
|
||||
### 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.
|
||||
|
||||
⚠️ **AND THAT IS NOW A STATEMENT ABOUT 0.10.1 ONLY — [[SAFETY-56]] REWROTE THE
|
||||
DIAGNOSIS HALF, AND `## 0.11.0` ABOVE CARRIES THE NEW WORDING.** The quotation
|
||||
above is left exactly as it stands because it is what the 0.10.1 image prints,
|
||||
and a reader on 0.10.1 needs to recognise it. The same applies to the reading
|
||||
rule in the paragraph above: on 0.11.0 the add-on no longer asserts that
|
||||
*"the register really is not following"* — it names both explanations and says
|
||||
it cannot tell them apart — so *"the pair repeats → look at the unit, it happens
|
||||
once and stops → the pack was empty"* stops being a correction of the message
|
||||
and becomes the message's own advice. Nothing about **what** the gate does moved
|
||||
in either card.
|
||||
|
||||
## 0.10.0
|
||||
|
||||
⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE
|
||||
@@ -87,8 +573,20 @@ check.
|
||||
**and** is refused by name (rule 11), so the reinterpretation is never
|
||||
silent; no blob written before 0.10.0 can carry one.
|
||||
|
||||
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL IS -
|
||||
THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
|
||||
⚠️ **AND 0.10.0 CHANGES THE CONTROL LAW ITSELF, WHICH NOTHING ABOVE AND NO
|
||||
CONFIG CHECK WILL TELL YOU ABOUT.** [[SAFETY-48]] bounds how far the
|
||||
integrator may run ahead of the command on the cycles where the slew limit is
|
||||
what stopped the command moving. It adds no option, moves no default and
|
||||
refuses no blob, so every check in this preamble passes it in silence - and it
|
||||
is the one change here that alters what the inverter is ASKED TO DO on a tree
|
||||
that is otherwise configured identically. ⚠️ **What it is measured to buy is
|
||||
less EXPORT. It is NOT a lower capacity-tariff peak, and your billed quarter
|
||||
should not be expected to improve.** The whole account, including what it costs
|
||||
and what it does not establish, is `### SAFETY-48` below.
|
||||
|
||||
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL WAS
|
||||
WHEN THIS WAS WRITTEN, AND HAS NOT BEEN SINCE 2026-09-20 - THE REFUSAL THAT CAN
|
||||
STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
|
||||
addons update` does not walk the versions; it installs 0.10.0 in one step, so
|
||||
**every validator rule added in 0.8.0 and 0.9.0 lands at the same moment as
|
||||
0.10.0's own**, and a config that has not been looked at since 0.7.0 meets all
|
||||
@@ -109,7 +607,10 @@ fire only on a roster entry that USES `capacity_kwh`, a key that did not exist
|
||||
`slew_w` against the ceiling and `price_provider` against the withdrawn list
|
||||
BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
|
||||
|
||||
✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.**
|
||||
✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.** ⚠️ This is the record of
|
||||
a run made on 2026-09-19, when the reference box was still on 0.7.0. That
|
||||
crossing has since been made; the box is on 0.10.0 and the transcript below is
|
||||
history rather than the state of any install.
|
||||
|
||||
docker run --rm -v <repo>:/w:ro -v <dir>:/opt:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
@@ -120,6 +621,21 @@ BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
|
||||
live options pass this version's validator
|
||||
CLEARED for deploy.
|
||||
|
||||
⚠️ **THAT RUN WAS MADE ON PKG-14'S TREE, AND [[PKG-15]] DID NOT RE-RUN IT.
|
||||
HERE IS WHY IT STILL HOLDS, CHECKED RATHER THAN ASSUMED.** The two tickets
|
||||
folded in below touch three files between them - `app/control.py`,
|
||||
`app/main.py` and `app/mqtt.py` - and **`config.yaml` is not among them**, so
|
||||
the option surface and the 75 defaults the blob is merged over are byte-for-
|
||||
byte what that run measured. `app/validate.py` imports neither `main` nor
|
||||
`mqtt`; from `control` it imports exactly `SLEW_RAIL_FRAC`, `LOOP_TICK_S` and
|
||||
`PLANT_SETTLE_S`, and all three are **unchanged** (`2.0`, `18.0`, and their
|
||||
quotient) - [[SAFETY-48]] adds `SLEW_LEAD_STEPS` and reads it inside
|
||||
`compute()`, which the validator never calls. ⚠️ **And the tool's OTHER half -
|
||||
the no-op check - did not run at all in that transcript**, because it is
|
||||
gated on `shipped == deployed` and the box reported `0.7.0` at the time. It is
|
||||
the half that refuses this fold on a box already running 0.10.0 - ⚠️ **which
|
||||
the reference box now is**; see the closing note below the contents table.
|
||||
|
||||
**The option surface: 71 keys -> 75, `options:` and `schema:` in step.** Four
|
||||
new keys, and none of them can refuse a blob written before it existed:
|
||||
`price_formula`, `price_export_formula` and `price_taxes_profile` are `str?` -
|
||||
@@ -158,6 +674,44 @@ image does - PKG-13, UX-04, SAFETY-31, ARCH-02, INSTR-01. The count that
|
||||
matters for the no-op check is `tools/predeploy_check.py`'s own: **28 runtime
|
||||
commits since `86bdf49`**, of which the table below accounts for 21.
|
||||
|
||||
⚠️ **RE-MEASURED BY [[PKG-15]] ON 2026-09-19, AFTER FOLDING [[TEL-10]] AND
|
||||
[[SAFETY-48]] INTO THIS SECTION: 32 RUNTIME COMMITS SINCE `86bdf49`, OF WHICH
|
||||
THE TABLE BELOW NOW ACCOUNTS FOR 24.** Re-derived with `is_runtime()` itself
|
||||
rather than by adding to the old figure. The other eight are the three misfiled
|
||||
tickets in the table above - SAFETY-43 two commits, SAFETY-44 three, TEST-21
|
||||
two - plus `cb3f686`, PKG-14's own bump, which edits `config.yaml` and is
|
||||
therefore a runtime commit by that function's rule; it was `HEAD` when PKG-14
|
||||
counted, so `last..HEAD` excluded it then and includes it now. The three
|
||||
misfiled tickets are still misfiled and still [[PKG-08]]'s card; nothing below
|
||||
moves them.
|
||||
|
||||
⚠️ **THE VERSION WAS NOT BUMPED FOR THE FOLDS ABOVE, AND THAT ARGUMENT HAS
|
||||
NOW EXPIRED. THIS SECTION IS CLOSED.** As written on 2026-09-19 it ran: 0.10.0
|
||||
was cut on 2026-09-16, had never been built or pulled anywhere, the reference
|
||||
install was on 0.7.0 and this repository carried exactly one tag (`v0.2.1`) and
|
||||
no release - so the string `0.10.0` did not yet name a tree anyone was running,
|
||||
the PKG-06/PKG-07 no-op could not apply, and cutting 0.11.0 would have burned a
|
||||
number on a release that never left the repository. ⚠️ **IT WAS STATED AS
|
||||
CONDITIONAL ON THAT REMAINING TRUE, AND IT STOPPED BEING TRUE ON 2026-09-20 AT
|
||||
ABOUT 22:18 CEST**, when 0.10.0 was installed on the reference site as a
|
||||
repository add-on (slug `68285ac7_goodwe_controller`) and began regulating a
|
||||
real battery. **0.10.0 has been built and pulled, and the reference install is
|
||||
on 0.10.0, not on 0.7.0.**
|
||||
|
||||
⚠️ **AND THE FIELD HISTORY IS NOW THE STRONGEST EVIDENCE IN THIS FILE, WHICH
|
||||
IS WHY THE CORRECTION IS WORTH ITS SPACE.** That install has since run a full
|
||||
day-night cycle on 0.10.0 - an empty pack, a guard trip, a 2 h 47 min saturation
|
||||
freeze, the [[SAFETY-57]] deadlock and a clean recovery - and **two defects came
|
||||
out of it that no test in this repository had reached**: [[SAFETY-56]] and
|
||||
[[SAFETY-57]]. So nothing may be folded into this section again, and
|
||||
`tools/predeploy_check.py` enforces that by itself: run against a box already
|
||||
reporting `0.10.0` it refuses with `THE DEPLOY WOULD BE A NO-OP` and names the
|
||||
runtime commits since the bump. **SAFETY-57's fix is in `## 0.10.1` above**,
|
||||
which is what a patch number is for. ⚠️ **AND SAFETY-56'S IS IN `## 0.11.0`**,
|
||||
which is where it went because it landed after 0.10.1 had already been cut. Both
|
||||
defects named in this paragraph are now fixed; this section, and `## 0.10.1`
|
||||
below it, describe images that still contain them, and are left saying so.
|
||||
|
||||
Contents of this release, in merge order since 0.9.0 (`86bdf49`):
|
||||
|
||||
| | |
|
||||
@@ -180,6 +734,176 @@ Contents of this release, in merge order since 0.9.0 (`86bdf49`):
|
||||
| `8f82dd4` | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
|
||||
| `e6da478` | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** |
|
||||
| `587495e` | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** |
|
||||
| `660d079` | TEL-10 - the loop's integrator stops being invisible: three new site sensors **⚠️ THREE NEW ENTITIES** |
|
||||
| `186a044` | SAFETY-48 - the integrator's lead over the command is bounded at one slew step **⚠️ THE CONTROL LAW CHANGED** |
|
||||
| _this branch_ | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** |
|
||||
|
||||
⚠️ **[[CI-9]] ALSO MERGED INTO `release/1.0` IN THIS WINDOW AND IS
|
||||
DELIBERATELY NOT IN THE TABLE ABOVE, NOR ANYWHERE ELSE IN THIS FILE.** It
|
||||
changes exactly one file, `.gitea/workflows/checks.yml`, which is not under
|
||||
`goodwe_controller/` at all: it is not in the built image, it is not in
|
||||
`predeploy_check.py`'s `RUNTIME` list, and there is no version of this add-on
|
||||
on which an operator can observe it. This file is the **add-on's** release note
|
||||
- the text the Supervisor puts beside the version number - and the rule PKG-15
|
||||
applied is that a row here has to be something the person deciding whether to
|
||||
press *Update* could act on. CI-9 is not; the repository's history and PR #95
|
||||
are where it is written down, and they have it in full. ⚠️ **THE RULE IS ABOUT
|
||||
THE AUDIENCE, NOT ABOUT IMPORTANCE.** CI-9 repaired a merge gate that was
|
||||
grading the wrong tree, which is the same class of defect this ticket exists to
|
||||
clean up after - and it is still not an operator-facing change. If a future CI
|
||||
ticket changes what the shipped image contains, it belongs here; this one does
|
||||
not.
|
||||
|
||||
### SAFETY-48 — the integrator's lead over the command is bounded, and the control law changed
|
||||
|
||||
⚠️ **THIS CHANGES WHAT THE INVERTER IS ASKED TO DO.** It is the only entry in
|
||||
0.10.0 that does so without touching an option, a default or the validator, so
|
||||
nothing in this section's preamble and no run of `tools/predeploy_check.py`
|
||||
will warn you about it. `app/control.py` is in the diff; `config.yaml` is not.
|
||||
|
||||
**The defect is a missing case, not a wrong constant.** `compute()` freezes the
|
||||
integrator when the inverter is not following, and the comment beside that
|
||||
block states the requirement outright — *the INTEGRATOR must stop accumulating,
|
||||
not only the command*. But `frozen` was computed from one signal,
|
||||
`abs(prev_w - actual_w) > saturation_w`, and **slew-limiting never entered it**.
|
||||
The integrator moves `gain × error` per cycle while the command may move at
|
||||
most `slew_w`, so the integrator outruns the command whenever
|
||||
`|error| > slew_w / gain` — **667 W** at the live tuning, against a measured
|
||||
median grid error of **1114 W** at the onset of a recorded episode. ⚠️
|
||||
[[SAFETY-39]]'s retune made this **more** frequent, not less: a smaller step is
|
||||
a lower threshold.
|
||||
|
||||
**What ships** bounds the integrator's **lead over the command** at one slew
|
||||
step, inside the branch where the slew is what bound the command, and only in
|
||||
the leading direction. Nothing else in the law moves: the output clamp, the
|
||||
saturation freeze, the deadband and the 10 W quantisation are unchanged, and
|
||||
`test_control.py`'s exhaustive `i_w=None` equivalence sweep against the
|
||||
transcribed release/1.0 law still passes untouched — the cap moves `i_w`, not
|
||||
`target`.
|
||||
|
||||
⚠️ **WHAT IT IS MEASURED TO BUY IS LESS EXPORT — AND EXPLICITLY NOT A LOWER
|
||||
CAPACITY-TARIFF PEAK. DO NOT EXPECT YOUR BILLED QUARTER TO IMPROVE.** Replayed
|
||||
over 256 252 recorded meter instants spanning 16.00 days of this install
|
||||
(`scenarios/loop-2026-09-02_2026-09-17`, committed), same ticks, same
|
||||
disturbance, same plant, same seed:
|
||||
|
||||
| | release/1.0 | SAFETY-48 |
|
||||
|---|---:|---:|
|
||||
| **export kWh** | **39.61** | **34.07** |
|
||||
| wasted throughput kWh | 4.67 | 3.19 |
|
||||
| battery sign reversals | 2992 | 2770 |
|
||||
| median recovery s | 232 | 199 |
|
||||
| unrecovered (>900 s) | 53 | 31 |
|
||||
| worst \|i_w − prev_w\| W | 9200 | 800 |
|
||||
|
||||
That is **5.54 kWh less export — 14 % of the replayed figure**, and ⚠️ **the
|
||||
direction is what is robust, not the size**: across every plant and tuning
|
||||
variant swept it falls, by between ~4 % (at `gain: 0.3`) and ~16 % (at a
|
||||
re-fitted gain/offset), the median variant landing near an eighth. At `gain: 0.3` and at `slew_w` set to the rail the
|
||||
mean-quarter effect is a tie (−0.17 W and **+0.16 W**), because
|
||||
`|error| > slew_w / gain` is rarely satisfied in those regimes and there is
|
||||
little for the cap to bite on.
|
||||
|
||||
⚠️ **AND THE BILLED QUARTER IS NOT A CLAIM THIS RELEASE MAKES.** The A/B above
|
||||
also shows the worst 15-minute average **offtake** moving 3739 → 3434 W, and
|
||||
that figure exists at **one plant model**. `fit_plant()` selects a 7.5 s
|
||||
inverter lag on battery-power RMS, which separates it from 2.5 s by **0.3 %**,
|
||||
while the instrument's own fidelity measure — replayed export against the
|
||||
**29.43 kWh this install actually exported** — prefers 2.5 s by the whole 35 %
|
||||
gap. Swept properly: **at every lag ≤ 5.0 s, including the one that best
|
||||
reproduces the recorded export, release/1.0's worst quarter is already 3434 W
|
||||
and this change moves it by exactly zero.** The lag is not identified by this
|
||||
recording, and identifying it needs a driven step response that does not exist
|
||||
yet. **What this release can say about the billed quantity is that it does not
|
||||
make it worse at any lag tried — not that it improves it.**
|
||||
|
||||
⚠️ **AND EVERY NUMBER ABOVE IS SIMULATED.** The replay is a first-order plant
|
||||
model with a 375 W RMS residual; it prints its own fidelity first and it
|
||||
reproduces **39.6 kWh** of export where the install exported **29.4**, i.e. it
|
||||
is ~35 % pessimistic about the loop's tracking. Only the **delta** is claimed.
|
||||
Nothing here drove an inverter.
|
||||
|
||||
**What it costs, stated rather than smoothed:**
|
||||
|
||||
* ⚠️ **The cap does move a saturation-frozen integrator**, because it runs
|
||||
before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks
|
||||
it changed `i_w` on **1095**, and on **679** of those it moved it further out
|
||||
on the side it was already on — putting back magnitude the freeze had just
|
||||
let fall, which is the one direction the freeze's own rule forbids. Worst
|
||||
case **2424 W**. It never overrides a *same-cycle* refusal.
|
||||
* **Two committed numbers moved.** [[SAFETY-13]]'s released command settles at
|
||||
−800 W and −1200 W where release/1.0 settled at −1190 W and −2390 W, because
|
||||
there is less wound integrator to settle onto; the property asserted is that
|
||||
it stops at the frozen integrator rather than at the rail.
|
||||
* ⚠️ **[[SAFETY-39]]'s claim that "the slew does not own the over-absorption"
|
||||
is now FALSE — inverted**, on the independent first-order plant
|
||||
`test_control.py` writes for itself: 1005/1109/1285/1330 W rising with a
|
||||
slower slew becomes 729/550/424/326 falling. Two plant models that share
|
||||
nothing agree on the sign. SAFETY-39's record carries a forward reference to
|
||||
this one.
|
||||
* **The form the card originally asked for was rejected on measurement**, and
|
||||
this is the least lag-sensitive result in the work: reusing the asymmetric
|
||||
freeze on a carried slew flag moves the worst quarter-hour offtake
|
||||
3739 → **3935 W** at the fitted plant, and across the lag sweep it is the
|
||||
worst of the three at **every lag from 2.5 s upward** — 3732 / 3711 / 3935 /
|
||||
4326 W against release/1.0's 3434 / 3434 / 3739 / 3954 — with a tie at lag
|
||||
0.0, and it is worse at **every top-N cut** of the quarters. It also raises
|
||||
the mean quarter (+2.73 W).
|
||||
|
||||
`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` carries all of it,
|
||||
including §8, *what this card does NOT establish*.
|
||||
|
||||
### TEL-10 — the loop's integrator stops being invisible
|
||||
|
||||
**Three new MQTT sensors, and nothing in the control path moved.**
|
||||
`app/control.py` is not in this diff: `compute()`, its returned `Decision` and
|
||||
every constant it reads are identical to release/1.0.
|
||||
|
||||
`self.i_w` has been a separate quantity from the command since [[SAFETY-04]]
|
||||
and left the process **nowhere at all** — no entity, no attribute, no log line.
|
||||
The investigation behind SAFETY-48 could only *reconstruct* it from the grid
|
||||
trace plus a replay; the reconstruction reproduced about a third of the
|
||||
engagements it was checked against and was discarded, so a control card had to
|
||||
be argued from a measurement nobody could stand behind. It could not have been
|
||||
made to work, either: `i_w` depends on every cycle before it, including the
|
||||
ones where the arbiter overrode the loop and `main.py` re-seeded it — and that
|
||||
re-seed is invisible in the grid trace.
|
||||
|
||||
| entity | topic | unit | what it is |
|
||||
|---|---|---|---|
|
||||
| `Loop integrator` | `goodwe_loop_integrator` | W | the accumulator **after** this cycle — before the output clamp, the slew limit and quantisation |
|
||||
| `Loop integrator lead` | `goodwe_loop_integrator_lead` | W | that value minus **the command `compute()` was handed**, i.e. the one the previous cycle left standing |
|
||||
| `Loop reason` | `goodwe_loop_reason` | — | the control law's own word for this cycle: `tracking`, `deadband`, `i-clamped`, `clamped`, `slew-limited`, `saturated-freeze`, `freeze-released` or `not-running` |
|
||||
|
||||
⚠️ **`Loop integrator lead` IS NOT A SUBTRACTION OF TWO PUBLISHED ROWS, WHICH
|
||||
IS WHY IT IS ITS OWN ENTITY.** `Setpoint` is the **arbiter's** output after
|
||||
every strategy has had its say, and it is **this** cycle's; the lead is
|
||||
measured against the command `compute()` was given, which is the previous one.
|
||||
On any cycle the arbiter overrode the loop the two differ — and that is exactly
|
||||
the cycle on which the integrator was re-seeded, so a dashboard doing the
|
||||
subtraction would get a different number precisely where it matters.
|
||||
|
||||
⚠️ **`Loop reason` IS NOT THE `Why` ROW AND NOT `Controller status`.** Both of
|
||||
those carry the *arbiter's* sentence — `loop -> -5000 W` — which names the
|
||||
winning claim and its watts and has never carried that claim's own reason.
|
||||
`slew-limited`, the value [[SAFETY-48]] above is entirely about, appears in
|
||||
neither. Measured on one cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`.
|
||||
|
||||
⚠️ **NOTHING EXISTING IS RENAMED, RE-TYPED OR RE-ORDERED.** The three are
|
||||
**appended** after the nine messages release/1.0 already announces, and all
|
||||
nine stay **byte-identical**. ⚠️ Eight of them also stay **first**, which is
|
||||
what `test_mqtt.py` part 1's prefix comparison grades; the ninth,
|
||||
`goodwe_ctl/availability`, is byte-identical but deliberately moves to **last**,
|
||||
so a prefix comparison cannot cover it and the check on the next line pins it
|
||||
in its new position instead. **They are SITE rows, not per-unit ones** — there
|
||||
is one integrator, so a per-unit copy would invent N of a thing there is one of
|
||||
— and they therefore appear at **every** roster length, **including a roster of
|
||||
one**, where the per-unit machinery announces nothing new at all.
|
||||
|
||||
Both numbers carry `state_class: measurement`, so the recorder keeps long-term
|
||||
statistics for them. `DOCS.md` has the operator account, including how to read
|
||||
a standing lead: the cycles needed to unwind it are that lead divided by
|
||||
`slew_w`.
|
||||
|
||||
### SAFETY-47 — what a shadow run can show you, and the watts on its status row
|
||||
|
||||
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,359 @@
|
||||
# TEL-12 — several PV inverters, and why a partial total is worse than none
|
||||
|
||||
TEL-11 shipped `pv_entity` as a single `str?` in 0.11.0. The owner of the
|
||||
reference site reported **within minutes of it going live** that they have
|
||||
several PV inverters and could configure one.
|
||||
|
||||
That is not a property of one house. A multi-string or multi-orientation roof
|
||||
routinely carries two or three inverters, each publishing its own power sensor
|
||||
into Home Assistant, and the add-on is sold to clients. So the channel had to
|
||||
become a roster.
|
||||
|
||||
**OPT-10 is blocked on this and cannot start recording until it lands.** Which
|
||||
sets the bar: the number this channel publishes has to be one an attribution can
|
||||
believe, or the week of history OPT-10 is waiting for is worse than no history.
|
||||
|
||||
---
|
||||
|
||||
## 1. The surface: `pv_entities` beside `pv_entity`, and the measurement that
|
||||
decided it
|
||||
|
||||
Two candidates, and they differ on exactly one question nobody in this
|
||||
repository could answer by reading: **does Supervisor accept a stored STRING for
|
||||
a key whose schema has become a LIST?** 0.11.0 shipped `pv_entity: str?`; the
|
||||
live install *has* a string persisted in it; and an upgrade that refuses that
|
||||
string leaves an add-on which will not start — CFG-04 §B's trap, in the other
|
||||
direction, with the schema needed to fix it pinned to the version that cannot be
|
||||
installed.
|
||||
|
||||
It was measured, against the release image, by a new **§G** of
|
||||
`tools/supervisor_shape_probe.py` — the instrument CFG-04 built for exactly this
|
||||
kind of question:
|
||||
|
||||
```
|
||||
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" --entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/probe/supervisor_shape_probe.py
|
||||
```
|
||||
|
||||
```
|
||||
== G. TEL-12: the SHAPE of a several-inverter PV option ==
|
||||
PASS the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED - the baseline every refusal below is varied from
|
||||
pv_entity='sensor.pv_power' pv_entities=[]
|
||||
PASS ⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as a LIST, the string an 0.11.0 install has persisted is REFUSED, and the refusal names the key - so that shape would leave the live add-on unable to start on upgrade
|
||||
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS ...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's list strategy is `override`, so the operator's string survives the merge and is refused anyway
|
||||
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS ...(the list shape itself is fine - it is only the STORED STRING it refuses, which is what makes this a migration and not a schema question)
|
||||
a list value validates
|
||||
PASS ⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, a list passes, and so does the integer 417; and the Configuration tab renders it as a plain text box rather than a repeatable field, so an operator cannot enter a second inverter in the UI at all
|
||||
string=True list=True integer-417=True ui=[{"name": "pv_entity", "optional": true, "type": "string"}]
|
||||
PASS ⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no `pv_entities` key at all, because it did not exist when that blob was written - validates against THIS manifest, with the new key defaulted to the empty list. The live install upgrades without touching its options
|
||||
PASS ...and a three-entity roster validates, per-entry, with both keys set - which the add-on resolves by REPLACEMENT and says so on its status page
|
||||
["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
|
||||
PASS ⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the `pv_entities: []` default is load-bearing, exactly as `inverters: []` is: Supervisor treats a nested list as required unless its element is an OPTIONAL scalar, and `- str` is not one
|
||||
Missing option 'pv_entities' in root in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS ...and the Configuration tab renders it as a REPEATABLE string field (`multiple: true`), so an operator adds their second and third inverter in the UI rather than in YAML
|
||||
[{"name": "pv_entity", "optional": true, "type": "string"}, {"name": "pv_entities", "multiple": true, "required": true, "type": "string"}]
|
||||
PASS a DOWNGRADE to a schema without `pv_entities` drops the key with a warning rather than refusing, and leaves `pv_entity` standing - so rolling back to 0.11.0 returns the install to its single entity rather than bricking it
|
||||
|
||||
== all cases passed ==
|
||||
```
|
||||
|
||||
| Claim | Evidence |
|
||||
|---|---|
|
||||
| **Option 1 — `pv_entity` becomes a list — cannot ship** | §G: `Invalid list for option 'pv_entity'` on the string an 0.11.0 install has stored |
|
||||
| …and a `[]` default does not rescue it | §G: `_OPTIONS_MERGER`'s list strategy is `override`, so the operator's string survives the merge and is refused anyway |
|
||||
| …and it is not a *schema* problem | §G: a list **value** validates fine against a list schema. It is a **migration**, and a migration is a bigger ticket than this |
|
||||
| **One key CAN carry both shapes** — `match(.*)?` | §G, and reported even though it *passes*, because "no spelling accepts both" would have been a **false reason** to reject option 1 |
|
||||
| …and it loses anyway, for a measured reason | §G: it validates nothing at all — a string, a list, and the integer `417` all pass — and the Configuration tab renders it as a plain text box, so an operator cannot add a second inverter in the UI |
|
||||
| **Option 2 — `pv_entities` beside `pv_entity` — ships** | §G: the 0.11.0 blob validates untouched, the new key defaults to `[]`, three entities validate, the UI renders `multiple: true` |
|
||||
| `pv_entities: []` in `options:` is load-bearing | §G: the shipped defaults **minus only that key** are refused with `Missing option 'pv_entities' in root` — CFG-04 §B's isolated counterfactual, for this key |
|
||||
| A downgrade is not the trap in reverse | §G: a schema without the key **drops** it and leaves `pv_entity` standing |
|
||||
|
||||
`p1_phase_import_entities` (`options: []` + `schema: - str`) is the shipped
|
||||
precedent for the pair; nothing new was invented.
|
||||
|
||||
**So the surface is uglier than option 1 and that is the correct trade.** The
|
||||
cleanest surface that breaks the one install this card exists to fix is not the
|
||||
cleanest surface.
|
||||
|
||||
### And `pv_entities` REPLACES `pv_entity` rather than extending it
|
||||
|
||||
CFG-04's rule for `inverters:` ("declared, it REPLACES those six keys"), applied
|
||||
to the only upgrade path that matters. The install being upgraded **has**
|
||||
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural action
|
||||
is to put *all* the inverters in the new repeatable field — and under a union
|
||||
rule that **double-counts A** unless the operator also remembers to clear the old
|
||||
key. A doubled total is the same defect as a partial one pointing the other way,
|
||||
in the one channel that exists to be believed a week later.
|
||||
|
||||
Under replacement the effective roster is exactly what one key says, and no
|
||||
arithmetic depends on a de-duplication rule the operator has to know about. It
|
||||
de-duplicates *within* the list (a site cannot have two sensors with one
|
||||
entity_id, so a repeated id is a typo that would double that inverter), and
|
||||
`Controller.checks()` prints a **caution** whenever both keys are set saying
|
||||
which one won — a replacement nobody is told about is the "one of which silently
|
||||
wins" `config.yaml` refuses elsewhere.
|
||||
|
||||
⚠️ It is a **caution and never a refusal to start**. `validate.py` still does not
|
||||
mention either key. A telemetry channel that could stop a battery site from
|
||||
starting would be observability with a foot in the control path.
|
||||
|
||||
---
|
||||
|
||||
## 2. ⚠️⚠️ The decision the card turns on: absence of ONE is absence of the TOTAL
|
||||
|
||||
Three inverters configured, one `unavailable`. The sum of the other two is **a
|
||||
smaller number indistinguishable from a real one.** Fed into OPT-10's
|
||||
attribution it reads as *"there was less PV than there was"* — which is the "no
|
||||
irradiance" conclusion the channel exists to **test** rather than to manufacture.
|
||||
|
||||
Two designs were genuinely available:
|
||||
|
||||
**(a) publish the total as absent** — `pv.total()` answers `None`, the topic
|
||||
carries HA's own `None`, the entity goes to `unknown`, the recorder stores a gap.
|
||||
|
||||
**(b) publish the partial sum alongside a count of how many contributed.**
|
||||
|
||||
**(a) shipped.** It wins on the one question that matters, which is *who reads
|
||||
the number and when*:
|
||||
|
||||
* The **MQTT row** is read back **cold**, a week later, by OPT-10 and by anything
|
||||
that plots `sensor.goodwe_pv_power` over that week. A partial sum there is a
|
||||
smaller number indistinguishable from a real one, and the count that would
|
||||
have qualified it lives in a **second entity with its own history** — so
|
||||
believing the total requires joining two recorder series by timestamp to find
|
||||
out which samples to distrust. Nobody re-derives that. And it is worse than a
|
||||
gap in exactly the direction that matters: **a gap makes the attribution say
|
||||
"I do not know"; a partial makes it say something false.**
|
||||
* The **status page** is read **live**, by a human who wants the diagnostic. So
|
||||
the partial information is *not* thrown away — the row names every entity that
|
||||
did report, with its watts, and every one that did not, with its reason, and
|
||||
states plainly that there is no site total. Different reader, different
|
||||
instant, different rule.
|
||||
|
||||
A count published beside the total would also carry **no information** in the
|
||||
state (a) admits: the total is published *only* when every entity contributed, so
|
||||
the count would be the constant `len(roster)`.
|
||||
|
||||
And this **extends** TEL-11's rule rather than weakening it. `mqtt.py`'s
|
||||
`ABSENT_AS_UNKNOWN` is still exactly one key and `publish()`'s `None` handling is
|
||||
untouched: the line that turns an absence into HA's `unknown` did not have to
|
||||
change, because "absence is never zero" already covered this one layer up.
|
||||
|
||||
### The mirror, which matters just as much
|
||||
|
||||
A member honestly reporting **0 W** is **summed**, not treated as absent. The
|
||||
test is `watts is None`, never falsiness. Shading, a tripped string and
|
||||
nightfall are all real zeros, and a roster that blanked the site whenever one
|
||||
member read zero would report an absence **on every clear evening** and defeat
|
||||
the channel from the other end.
|
||||
|
||||
### ⚠️ And the answer is NOT "document a template sensor that adds them up"
|
||||
|
||||
An operator may legitimately prefer one, and `pv_entity` will happily point at
|
||||
it. Shipping it as *the* answer pushes the absence problem onto them unseen: a
|
||||
template sensor summing an `unavailable` source silently yields the partial
|
||||
total — the same defect one layer down, in a place this add-on cannot see or
|
||||
report. DOCS.md says so in those terms.
|
||||
|
||||
### The three published states, side by side
|
||||
|
||||
| what happened | frame | `goodwe_ctl/pv` | status page |
|
||||
|---|---|---|---|
|
||||
| no roster at all | no `pv` key | no topic, no entity | no row |
|
||||
| 1200 / 2100 / 450 W, all three | `pv: 3750.0` | `3750.0` | `PV 3 750 W` + one ok row naming all three |
|
||||
| all three answer, one of them `0 W` | `pv: 1650.0` | `1650.0` | `PV 1 650 W` |
|
||||
| one of the three `unavailable` | `pv: None` | `None` → `unknown`, recorder **gap** | `PV —` + a caution naming the missing entity, its reason, and what the others reported |
|
||||
| all three gone | `pv: None` | as above | as above |
|
||||
|
||||
---
|
||||
|
||||
## 3. Still not a control input
|
||||
|
||||
Unchanged, and re-proved at the new roster size.
|
||||
`tools/tel11_pv_trace.py` was **extended rather than replaced** — the file name
|
||||
still says TEL-11 because `DECISIONS/TEL-11-pv-production.md` §5 and
|
||||
`test_pv.py`'s mutation table quote it, and a renamed instrument makes both
|
||||
references dangle for no gain.
|
||||
|
||||
It now produces **three** document sets per tree — `unset`, `one`, `multi` — and
|
||||
grades five claims. The roster cells live in their own document precisely so
|
||||
`unset` and `one` keep the `control` hashes TEL-11 recorded; appending cells to
|
||||
an existing document would have moved a hash whose stability is itself the
|
||||
evidence.
|
||||
|
||||
```
|
||||
$ python3 tools/tel11_pv_trace.py --compare base.json head.json
|
||||
HEAD is B; baseline is A - its roster cells ARE its unset cells, which is what a tree that has never heard of `pv_entities` does with them.
|
||||
supports_roster A False / B True (self-reported; the verdict above is MEASURED)
|
||||
tree A fa92ba6a6fc6d49c0f87a494e03b530a8575212a0b2c3046629cb400ef2ae0d4
|
||||
tree B 0b39198015b6a5a3f7538d4157d77ecadc89311daef18fce883762d94d8351eb
|
||||
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control A one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control B one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
|
||||
control B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
|
||||
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
|
||||
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
|
||||
telemetry A one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
|
||||
telemetry B one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
|
||||
telemetry A multi d269168ffece375027c2c18ea33631a961ff92775781884a0592d6a37d78845c
|
||||
telemetry B multi 550a49a73f39267ba28b1e78cd12b8f5c71ef7269bbbd2e12112696be0fef1ff
|
||||
OK 1/5 UNSET: control AND telemetry are byte-identical to the baseline tree. …
|
||||
OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to 0.11.0's single-entity path - the same commands, the same payloads, the same status rows, the same announcement.
|
||||
OK 3/5 CONTROL, ONE ENTITY: the control document is byte-identical with the channel switched on - PV reaches no decision.
|
||||
OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control document equals its grid world's `unset` one - 30 cells over 5 grid worlds, covering three healthy entities, one dead at EVERY position, all three dead and one honestly reporting 0 W. Neither the total nor any PARTIAL total reaches a decision, and the document is byte-identical to the BASELINE tree's for the same cells.
|
||||
OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES move under a roster (30 stimulus cells differ from their grid world's unset telemetry), so 1/5-4/5 are identities this instrument could have broken. AND in no cell with a dead member does the PV row carry the partial sum of the survivors, while every cell whose members all ANSWER publishes its total - including the one where a member honestly reports 0 W.
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
|
||||
**The control hash is unmoved: `846a10a5b89e…` on `unset` and on `one`, on both
|
||||
trees.** The roster control document `2aafbfedfb9c…` is byte-identical between
|
||||
the two trees, which is the strongest available form of the claim — the baseline
|
||||
runs those cells with a key it has never heard of, so its control document for
|
||||
them *is* 0.11.0's.
|
||||
|
||||
⚠️ **2/5 is a claim TEL-11 could not make.** TEL-11's baseline had no channel at
|
||||
all to be identical to; TEL-12's baseline is 0.11.0, so "one entity behaves
|
||||
exactly as 0.11.0 does" is a real cross-tree identity over **both** documents.
|
||||
`pv.total()` passes a single entity's `(watts, why)` through verbatim for exactly
|
||||
this reason, and `Controller.checks()` keeps 0.11.0's two f-strings in a
|
||||
`len(...) == 1` branch rather than generalising them.
|
||||
|
||||
### ⚠️ TEL-11's `--compare` refusal had to change, and that is a finding
|
||||
|
||||
TEL-11's rule was *"exactly ONE side's `set` document may differ from its own
|
||||
`unset` — that side is the HEAD"*. It was correct evidence when written, because
|
||||
`release/1.0` predated `pv_entity` entirely. `release/1.0` is 0.11.0 now, **both**
|
||||
sides react to `pv_entity`, and that refusal fires on **every honest run**. The
|
||||
discriminator is the roster instead: only the HEAD has heard of `pv_entities`, so
|
||||
only the HEAD's `multi` document fails to reduce to its own `unset` one.
|
||||
|
||||
It is asserted from the **documents**, not from `tree.supports_roster` — a
|
||||
self-report is the tree describing itself, whereas "this side's behaviour did not
|
||||
move when `pv_entities` was set" is the tree being measured. `supports_roster` is
|
||||
printed beside the verdict so a disagreement between the two is visible.
|
||||
|
||||
See MUTATION.md habit 24.
|
||||
|
||||
---
|
||||
|
||||
## 4. Mutation battery
|
||||
|
||||
Twelve mutations, each on a **fresh `cp -a`** of the clean worktree (never
|
||||
reverted — habit 18 / TARIFF-09's method), `__pycache__` purged before the run
|
||||
and the tree checked for one afterwards, byte delta from `stat` and the edit
|
||||
confirmed on disk with `cmp`, suites run in the CI image with `checks.yml`'s own
|
||||
command one file at a time under a `:ro` mount.
|
||||
|
||||
| mutation | bytes | reds |
|
||||
|---|---|---|
|
||||
| N1 `total()` returns the partial sum, not an absence | +42 B | 6 named in `test_pv.py` + 1 crash |
|
||||
| **N1b ⭐ THE REJECTED DESIGN: the partial sum with a contributor count** | −282 B | **10 named + the trace `REFUSED 5/5`** |
|
||||
| N2 absence tested by falsiness (`not w`) | −4 B | 2 named |
|
||||
| N3 `pv_entities` UNIONS with `pv_entity` | −27 B | 3 named |
|
||||
| N4 the de-duplication dropped | −15 B | 1 named |
|
||||
| N5 `sum` → `max` over the roster | **+0 B** | 6 named |
|
||||
| N6 the roster-of-one stops passing `why` through | +13 B | 8 named |
|
||||
| N7 `optional_keys()` back to reading `pv_entity` | −6 B | 1 named + 1 crash |
|
||||
| N8 `self.grid -= self.pv_w` in `read_inputs()` | +106 B | 3 named + the trace (52 cells on 2/5, 26 on 3/5, 10 on 4/5) |
|
||||
| N9 the roster caution made a `bad` row (NOT READY) | +1 B | 1 named |
|
||||
| N10 `entity_is_ignored()` always False | +10 B | 2 named |
|
||||
| N11 `pv_entities: []` deleted from `options:` | −18 B | 2 named in `test_translations.py` + `11 CASE(S) FAILED` in the probe |
|
||||
|
||||
**No survivors.**
|
||||
|
||||
⚠️ **N5 is `+0 B` — the risky delta.** `sum` and `max` are the same three
|
||||
characters, so neither the file size nor its mtime moved, and TEST-15's
|
||||
stale-bytecode defect is exactly what manufactures a survivor there. The run was
|
||||
on a fresh purged copy and `cmp` confirmed the edit on disk before it started.
|
||||
|
||||
⚠️ **N1b is the row that matters, and it is not a defect.** It is the *other*
|
||||
defensible design, implemented and run. The point is that the checks and the
|
||||
instrument can **tell the two apart** — an unexamined partial sum is what the
|
||||
card forbids, so a check that could not see one would be decoration:
|
||||
|
||||
```
|
||||
REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD', '2550.0'),
|
||||
('EXPORT-6K/GARAGE-DEAD', '3300.0'), ('EXPORT-6K/WEST-DEAD', '1650.0'),
|
||||
('IMPORT-3K/EAST-DEAD', '2550.0'), ('IMPORT-3K/GARAGE-DEAD', '3300.0'),
|
||||
('IMPORT-3K/WEST-DEAD', '1650.0')] … - a smaller number indistinguishable from a
|
||||
real one, which is the defect this card exists to prevent.
|
||||
```
|
||||
|
||||
⚠️ **N8's 1/5 stays GREEN and that is correct rather than a miss.** With no PV
|
||||
entity configured `pv_w` is `None` and the leak cannot fire — which is what makes
|
||||
1/5 a claim about the **default** and 2/5–4/5 the claims about the channel.
|
||||
|
||||
### The fixture values are the design (habit 10 and habit 23)
|
||||
|
||||
`test_pv.py` carries **two** rosters over the same three entities, because the
|
||||
two claims need different magnitudes and one fixture cannot serve both.
|
||||
|
||||
* **Parts 8–10: 1200 W + 2.1 kW + 450 W = 3750 W.** The sum is separable from
|
||||
max (2100), min (450), first (1200), last (450), mean (1250), count (3) and
|
||||
every partial of itself (2550 / 1650 / 3300) — the family is computed, printed
|
||||
and asserted non-colliding as its own precondition. One member is in **kW** so
|
||||
"correct against their own units" is exercised. **Not one member is zero**: a
|
||||
member contributing 0 W could go absent without moving the sum of the rest, and
|
||||
a fixture like that cannot tell "published the absence" from "published the
|
||||
partial sum" — habit 23 with a coinciding value in the *fixture* rather than in
|
||||
the plant, which is precisely the trap the brief named. Absence is driven at
|
||||
**every position** of the roster, not at one (habit 10's fixture-as-a-SET), and
|
||||
each position's partial is **derived from the fixture** rather than typed
|
||||
beside it.
|
||||
* **Part 6: 1500 W + 4.5 kW + 2000 W = 8000 W.** Sized so every *partial*
|
||||
(6500 / 3500 / 6000) would still **move** a loop settling on a 600 W import.
|
||||
Habit 23's own question is asked twice — shift the meter by 8000 W, and again
|
||||
by 3500 W, the smallest partial any single dead member could produce — because
|
||||
a leak of the partial is a different mutation leaking a smaller number.
|
||||
|
||||
### Two false results this card produced, and what they were
|
||||
|
||||
Both were in the **instrument**, and both are recorded rather than tidied away.
|
||||
|
||||
1. `partial_w`/`partial_wire` were first written **inside** each telemetry cell.
|
||||
They are a fact about the *stimulus*, produced by the tool and identical on
|
||||
both trees — so every roster cell differed from its unset reference for a
|
||||
reason that was not behaviour, and the HEAD/baseline discriminator fired on
|
||||
*both* sides. They live in a separate top-level `partials` map now.
|
||||
2. The `WEST-ZERO` cell (all three answer, one honestly reporting 0 W) had its
|
||||
**true total** labelled as its "partial". 5/5 then refused the *shipped* tree
|
||||
for publishing 1650 W, which it is required to publish. A cell with no dead
|
||||
member has no partial to leak, and `never` is `None` there.
|
||||
|
||||
---
|
||||
|
||||
## 5. What none of this can see
|
||||
|
||||
* **Home Assistant itself.** That `None` on the state topic renders as state
|
||||
`unknown` and that the recorder keeps a gap is read out of HA's source and
|
||||
quoted in `app/mqtt.py`; whether the long-term statistics table then
|
||||
interpolates across the gap is HA's behaviour, and OPT-10 will be reading it.
|
||||
* **Whether the operator's sensors are telling the truth.** A sensor wired
|
||||
backwards reads −4 kW at noon and is summed as −4 kW. There is deliberately no
|
||||
`pv_invert` — §2 of the TEL-11 record is the argument, and it holds at a roster.
|
||||
* **A roster larger than three.** Three is the smallest size at which the sum
|
||||
separates from the reduction family *and* absence can be driven at every
|
||||
position. Nothing here says what twenty entities do to a cycle's latency — the
|
||||
read is `asyncio.gather`ed across the roster for exactly that reason, so a slow
|
||||
sensor costs what one costs, but that is an argument rather than a measurement.
|
||||
* **A PV roster combined with a two-inverter battery roster.** No fixture
|
||||
crosses CFG-04's `inverters:` with `pv_entities`.
|
||||
* **The real MQTT transport, and wall-clock behaviour.** As TEL-11.
|
||||
|
||||
---
|
||||
|
||||
## 6. Not deployable as merged
|
||||
|
||||
`config.yaml` stays at `0.11.0`, so `tools/predeploy_check.py` reports a NO-OP
|
||||
until PKG cuts a version. A CHANGELOG entry is **wanted** and was deliberately
|
||||
not written: `test_version_gate.py` 11b forbids an `## Unreleased` heading and
|
||||
the bump is PKG's call. The material an entry needs is this file.
|
||||
|
||||
The option surface moves **76 keys → 77**, one new optional key, `pv_entities`,
|
||||
empty on every install that does not set it.
|
||||
@@ -139,6 +139,8 @@ 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. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
|
||||
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) |
|
||||
|
||||
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 +415,165 @@ 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)
|
||||
|
||||
> **More than one solar inverter?** Use `pv_entities` instead — see
|
||||
> [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) below. The
|
||||
> rest of this section applies to both.
|
||||
|
||||
|
||||
**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 |
|
||||
|---|---|---|
|
||||
| neither `pv_entity` nor `pv_entities` 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.
|
||||
|
||||
### Several PV inverters (`pv_entities`, TEL-12)
|
||||
|
||||
A multi-string or multi-orientation roof routinely carries **two or three PV
|
||||
inverters**, each publishing its own power sensor into Home Assistant. List them
|
||||
all and the add-on records the **site total**:
|
||||
|
||||
```yaml
|
||||
pv_entities:
|
||||
- sensor.pv_east_power
|
||||
- sensor.pv_west_power
|
||||
- sensor.pv_garage_power
|
||||
```
|
||||
|
||||
In the Configuration tab this is a repeatable field: press the **+** and add a
|
||||
row per inverter. Each entity's **own** `unit_of_measurement` is read, so a
|
||||
kW inverter beside a W one adds up correctly.
|
||||
|
||||
#### It replaces `pv_entity` — clear that one
|
||||
|
||||
Setting `pv_entities` makes `pv_entity` **ignored**, and the status page says so
|
||||
until you clear it. That is deliberate rather than tidy: if the two were *added*
|
||||
together, an upgrade where you leave your first inverter in `pv_entity` and also
|
||||
list it here would count it **twice**, and a doubled total is as wrong as a
|
||||
missing one in the one channel that exists to be believed a week later.
|
||||
|
||||
#### ⚠️⚠️ If ANY of them stops reporting, there is NO site total
|
||||
|
||||
This is the whole reason the key is shaped this way, so it is worth being plain
|
||||
about:
|
||||
|
||||
| what happened | `sensor.goodwe_pv_power` | on the status page |
|
||||
|---|---|---|
|
||||
| all three reporting 1200 / 2100 / 450 W | `3750.0` | `PV 3 750 W` |
|
||||
| all three reporting, one of them **0 W** | `1650.0` | `PV 1 650 W` |
|
||||
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which entity, why, and what the others reported |
|
||||
| all three gone | `unknown` | as above |
|
||||
|
||||
**It does not publish the sum of the ones that answered.** Two inverters out of
|
||||
three add up to a smaller number that is *indistinguishable from a real one* —
|
||||
and read back a week later that says *"there was less sun than there was"*,
|
||||
which is precisely the conclusion this recording exists to **test** rather than
|
||||
to manufacture. A gap makes the analysis say *"I do not know"*; a partial makes
|
||||
it say something false. So the total goes to `unknown` and the recorder stores a
|
||||
gap, exactly as it does for a single dead sensor.
|
||||
|
||||
The diagnostic is not thrown away — it is put where a person will see it. The
|
||||
status page names the entity that is missing, the reason, **and** what the
|
||||
others reported. That page is read live by a human who wants to fix something;
|
||||
the recorded number is read cold by an analysis that would believe it.
|
||||
|
||||
Note the **third row against the second**: a member honestly reporting `0 W` is
|
||||
**summed**, not treated as missing. Shading, a tripped string and nightfall are
|
||||
all real zeros, and blanking the site whenever one member read zero would report
|
||||
an absence on every clear evening.
|
||||
|
||||
#### ⚠️ Not "just use a template sensor that adds them up"
|
||||
|
||||
You may legitimately prefer a template sensor, and `pv_entity` will happily
|
||||
point at one. But it is **not** the answer to this problem: a template sensor
|
||||
summing an `unavailable` source yields the partial total *silently*, which is
|
||||
the same defect one layer down — in a place this add-on cannot see and cannot
|
||||
report to you.
|
||||
|
||||
#### ⚠️ 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 +687,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,136 @@ 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.
|
||||
|
||||
24. **A REFUSAL DEFINED RELATIVE TO "THE BASELINE LACKS THIS FEATURE" DIES ON
|
||||
THE RELEASE THAT SHIPS THE FEATURE — AND IT DIES AS A FALSE REFUSAL, WHICH
|
||||
IS THE LOUD DIRECTION.** TEL-11 built `tools/tel11_pv_trace.py` to grade a
|
||||
cross-tree identity, and because "the documents are identical" was its
|
||||
*result* rather than its warning sign, it had to prove instead that the two
|
||||
runs came from different trees and that **exactly one side reacts to
|
||||
`pv_entity`**. That was correct evidence: `release/1.0` predated the option
|
||||
entirely, so a side that reacted to it was the HEAD by construction.
|
||||
|
||||
TEL-11 then **merged**, `release/1.0` became 0.11.0, and the baseline for
|
||||
the next card reacts to `pv_entity` too. `--compare` now answers
|
||||
`REFUSED: set differs from unset on ['A', 'B']` on every honest run — the
|
||||
instrument refuses its own shipped tree. TEL-12 found it on the first
|
||||
comparison it ran.
|
||||
|
||||
* **A discriminator is a fact about a MOMENT, not about a card.** "The
|
||||
baseline does not have X" is true until X ships and then false forever.
|
||||
Write the discriminator against something the NEXT baseline still will not
|
||||
have — TEL-12's is the roster key, not the channel — and say in the tool
|
||||
which fact it rests on, so the next card can see what it has to replace.
|
||||
* **Prefer a discriminator that is MEASURED over one the tree REPORTS.**
|
||||
`tree.supports_roster` is the tree describing itself and would have been
|
||||
one line; the shipped test is "this side's roster cells ARE its unset
|
||||
cells", which is the tree being measured. Both are printed, side by side,
|
||||
so a disagreement between the self-report and the measurement is visible
|
||||
rather than silently trusted.
|
||||
* ⚠️ **And this is the SAFE direction of the same defect, which is why it
|
||||
was found in ten minutes rather than in a review.** A stale refusal is a
|
||||
developer chasing a failure they cannot explain (the false-red case at the
|
||||
top of this file). A stale *identity* — an instrument that goes on printing
|
||||
OK because its reference drifted into agreement — is the survivor-
|
||||
manufacturing direction, and nothing announces it. Ask of every cross-tree
|
||||
instrument: *what does this print when the feature it is about has been in
|
||||
the baseline for a year?*
|
||||
|
||||
TEL-12's own battery produced two more instrument defects of the ordinary
|
||||
kind, both recorded in `DECISIONS/TEL-12-several-pv-inverters.md` §4: a
|
||||
stimulus annotation written into the document being compared (so every cell
|
||||
differed for a reason that was not behaviour, and the discriminator fired on
|
||||
both sides), and a fixture cell whose TRUE total was labelled as its
|
||||
"partial" (so the check refused the shipped tree for publishing a number it
|
||||
is required to publish). ⚠️ **Both were caught by running the instrument
|
||||
against the shipped tree BEFORE running it against a mutant** — which is the
|
||||
one ordering rule that separates "the instrument is wrong" from "the tree is
|
||||
wrong", and `test_mqtt.py` part 8's first line ("the shipped module satisfies
|
||||
every property this instrument grades") is the same rule as a check.
|
||||
|
||||
⚠️ **AND TEL-12's SUBJECT WAS HABIT 23 IN THE FIXTURE ITSELF.** The card adds
|
||||
a SUM over a roster, and the defect it exists to prevent is publishing the
|
||||
partial sum when a member is absent. A fixture whose absent member
|
||||
contributes **0 W** cannot see that defect at all: the partial and the full
|
||||
total are **the same number**, so "published the absence" and "published the
|
||||
partial" produce identical output. That is a rail in the *arithmetic* rather
|
||||
than in the plant — the same hazard as TEL-11's saturated actuator, one
|
||||
domain over. `test_pv.py`'s roster is three distinct non-zero contributions
|
||||
in two different units, absence is driven at **every position** rather than
|
||||
at one (habit 10), and each position's partial is **computed from the
|
||||
fixture** and asserted to differ from the true total as its own precondition.
|
||||
|
||||
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,27 @@ 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_roster
|
||||
else "not read yet")
|
||||
# TEL-12. EVERY configured entity's own answer, `(entity, watts, why)`,
|
||||
# in roster order - kept because `checks()` has to be able to name the
|
||||
# sensor that is missing, and `self.pv_w` alone cannot. Empty before the
|
||||
# first read, which is what "no entity has answered yet" is.
|
||||
self.pv_parts = []
|
||||
# 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 +1673,57 @@ class Controller:
|
||||
for inv in self.inverters)
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
@property
|
||||
def pv_roster(self) -> tuple:
|
||||
"""Every configured PV production entity, or `()`. TEL-11/TEL-12.
|
||||
|
||||
⚠️ ONE READING OF THE OPTIONS, and it is `pv.entities()` - the SAME call
|
||||
`mqtt.optional_keys()` makes. The thing that decides whether the row is
|
||||
ANNOUNCED, the thing that decides whether it is READ and the thing that
|
||||
decides WHAT IS SUMMED have to be one predicate, or the add-on ends up
|
||||
with an entity nothing feeds, a topic nothing declared, or a total taken
|
||||
over a different list from the announced one. `announced_units()` in
|
||||
mqtt.py is the same argument for the inverter roster, in its own words.
|
||||
|
||||
⚠️ IT REPLACED A `pv_entity` PROPERTY RATHER THAN JOINING ONE. Two
|
||||
properties where one is a subset of the other is the second spelling
|
||||
arriving by the back door; every gate in this file reads this one.
|
||||
"""
|
||||
return pv_read.entities(self.o)
|
||||
|
||||
async def _read_pv(self) -> None:
|
||||
"""This cycle's SITE PV production, or an ABSENCE with a reason.
|
||||
|
||||
TEL-11 for one entity, TEL-12 for a roster of them. `pv.total()` is where
|
||||
"one of three is absent" becomes an absence of the SITE figure rather
|
||||
than the sum of the two that answered, and its docstring is the argument.
|
||||
|
||||
⚠️ 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.
|
||||
"""
|
||||
# ⚠️ TEL-12: EVERY ENTITY IN ONE GATHER, for the reason the outer
|
||||
# gather exists (see `read_inputs()`). Read serially, a three-inverter
|
||||
# roster with one slow sensor would add up to three of `hass.state()`'s
|
||||
# 10 s timeouts to a cycle that feeds the ESP32 heartbeat - i.e. the
|
||||
# telemetry channel would be able to push a healthy battery towards
|
||||
# layer 1's veto, which is exactly the foot in the control path this
|
||||
# card is not allowed to have. Gathered, N entities cost what one does.
|
||||
roster = self.pv_roster
|
||||
states = await asyncio.gather(
|
||||
*(self.hass.state(entity) for entity in roster))
|
||||
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
||||
list(zip(roster, states)))
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
|
||||
@@ -1682,8 +1755,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 EMPTY PV ROSTER 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_roster:
|
||||
_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 +1860,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 +1869,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 +4488,30 @@ 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` or `pv_entities` has no such entity
|
||||
# announced. ⚠️ TEL-12: AND THE ROW IS STILL ONE NUMBER. At a roster of
|
||||
# three it is the SITE total, `None` unless EVERY entity answered - see
|
||||
# `pv.total()`, whose note is where the decision not to publish a
|
||||
# partial sum is argued. This line did not have to change for that, and
|
||||
# that is the point: TEL-11's `None`-is-an-absence rule already covered
|
||||
# it, one layer up.
|
||||
#
|
||||
# ⚠️ 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 +4793,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 +5029,92 @@ 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 THE ROSTER IS EMPTY, 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.
|
||||
#
|
||||
# --- TEL-12: AND AT A ROSTER OF ONE THESE TWO SENTENCES ARE 0.11.0's --
|
||||
# ⚠️ THE `len(...) == 1` BRANCH IS 0.11.0's CODE UNCHANGED, DOWN TO THE
|
||||
# f-string, so an install that set `pv_entity` reads the row it already
|
||||
# reads. That is an ACCEPTANCE CRITERION rather than a courtesy, and it
|
||||
# is graded across trees by `tools/tel11_pv_trace.py` rather than argued
|
||||
# here: `pv.total()` passes a single entity's `why` through verbatim for
|
||||
# exactly this reason. Multi-entity sentences are separate BECAUSE they
|
||||
# have to say something the single-entity ones never could.
|
||||
roster = self.pv_roster
|
||||
if roster:
|
||||
if len(roster) == 1:
|
||||
if self.pv_w is None:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"PV production: no reading from "
|
||||
f"{roster[0]} - {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 "
|
||||
f"from {roster[0]} (reported "
|
||||
f"in {self.pv_why})"})
|
||||
elif self.pv_w is None:
|
||||
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
|
||||
# THOSE WORDS. `pv.total()`'s note is the argument; this is
|
||||
# where the operator meets it. The partial information is NOT
|
||||
# thrown away - every entity that reported is named with its
|
||||
# watts and every one that did not is named with its reason -
|
||||
# because this surface is read LIVE by a human who wants the
|
||||
# diagnostic, whereas the MQTT row is read back COLD a week
|
||||
# later by an attribution that would believe a smaller number.
|
||||
# Different reader, different instant, different rule.
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": "PV production: NO SITE TOTAL - "
|
||||
+ self.pv_why + ". Reporting: "
|
||||
+ (", ".join(
|
||||
f"{ent} {watt:.0f} W"
|
||||
for ent, watt, _y in self.pv_parts
|
||||
if watt is not None) or "nothing")})
|
||||
else:
|
||||
# habit 14 at a roster: the total in its own slot, then EVERY
|
||||
# contributor in its own slot with its own watts and its own
|
||||
# unit. A sum that disagrees with the addends it is printed
|
||||
# beside is visible in the sentence.
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"PV production: {self.pv_w:.0f} W from "
|
||||
f"{len(self.pv_parts)} entities ("
|
||||
+ ", ".join(
|
||||
f"{ent} {watt:.0f} W in {why}"
|
||||
for ent, watt, why in self.pv_parts)
|
||||
+ ")"})
|
||||
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
|
||||
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is the
|
||||
# right rule (its docstring says why) and is also the rule an
|
||||
# operator upgrading from 0.11.0 is most likely to trip over: they
|
||||
# add all their inverters to the new field and the old key is left
|
||||
# standing, doing nothing. A replacement nobody is told about is the
|
||||
# "one of which silently wins" `config.yaml` refuses elsewhere - and
|
||||
# refusing to START over a telemetry key would be observability with
|
||||
# a foot in the control path, so this is a CAUTION and never a `bad`
|
||||
# row.
|
||||
if pv_read.entity_is_ignored(self.o):
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": "PV production: `pv_entities` is set, so "
|
||||
"`pv_entity` is IGNORED - the "
|
||||
f"{len(roster)} entities in the "
|
||||
"list are what is summed. Clear "
|
||||
"`pv_entity` to remove this caution"})
|
||||
return out
|
||||
|
||||
def status_payload(self) -> dict:
|
||||
@@ -4961,6 +5234,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_roster 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 +5514,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 with no PV roster 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,82 @@ 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.
|
||||
|
||||
⚠️ AND SINCE TEL-12 IT IS `pv.entities()` RATHER THAN A SECOND READING OF
|
||||
THE OPTION - `Controller.pv_roster` is the SAME call. TEL-11 got away with
|
||||
two spellings because both were `bool()` of one string; with `pv_entities`
|
||||
beside `pv_entity`, and `pv_entities` REPLACING it, a second spelling is how
|
||||
the row gets announced off one key and summed off the other. ⚠️ THE ROW IS
|
||||
ONE SITE TOTAL AT EVERY ROSTER SIZE: there is deliberately no per-entity
|
||||
topic, because the recorder statistic OPT-10 reads back is the site figure
|
||||
and a per-inverter breakdown is a dashboard question the operator's own PV
|
||||
integration already answers.
|
||||
"""
|
||||
# ⚠️ THE IMPORT IS FUNCTION-LOCAL AND THAT IS NOT A STYLE CHOICE. This
|
||||
# module is EXEC'D STANDALONE, outside its package, by `test_mqtt.py`
|
||||
# part 8's mutation harness - which says so in its own comment, and whose
|
||||
# honesty depends on it ("`app/mqtt.py` imports nothing from this package,
|
||||
# which is what makes exec'ing it standalone honest rather than a stub").
|
||||
# A module-scope `from . import pv` turns that whole instrument into an
|
||||
# `ImportError` before it grades anything. `tools/tel11_pv_trace.py`'s
|
||||
# `_load()` is the same decision for the same kind of reason. Measured, not
|
||||
# assumed: move this line to module scope and test_mqtt.py part 8 dies.
|
||||
from . import pv as _pv
|
||||
return ("pv",) if _pv.entities(opts) else ()
|
||||
|
||||
|
||||
BASE = "goodwe_ctl"
|
||||
AVAILABILITY = f"{BASE}/availability"
|
||||
|
||||
@@ -299,15 +375,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 +420,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 +444,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 +484,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 +495,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,259 @@
|
||||
"""TEL-11/TEL-12 - the site's PV production, read from Home Assistant entities.
|
||||
|
||||
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_entities` therefore takes whatever
|
||||
`sensor.*` the site's own PV integration provides.
|
||||
|
||||
⚠️ AND THERE IS USUALLY MORE THAN ONE OF THEM, WHICH IS TEL-12. TEL-11 shipped
|
||||
`pv_entity` as a single string and the owner of the reference site reported
|
||||
within minutes of 0.11.0 going live that they have SEVERAL PV inverters and
|
||||
could configure one. That is not a property of one house: a multi-string or
|
||||
multi-orientation roof routinely carries two or three inverters, each
|
||||
publishing its own power sensor. `entities()` below is the roster and `total()`
|
||||
is the site figure over it.
|
||||
|
||||
⚠️ AND A PARTIAL TOTAL IS WORSE THAN NO TOTAL, WHICH IS THE WHOLE OF TEL-12.
|
||||
Three inverters configured, one `unavailable`, and the sum of the other two is A
|
||||
SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Fed into OPT-10's attribution
|
||||
that reads as "there was less PV than there was" - which is the "no irradiance"
|
||||
conclusion the card exists to TEST rather than to manufacture. So `total()`
|
||||
answers `None` the moment ANY configured entity is absent. See the note on
|
||||
`total()` for the alternative that was weighed and why it lost.
|
||||
|
||||
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. ⚠️ TEL-12 EXTENDS THAT RULE RATHER THAN
|
||||
WEAKENING IT: at a roster of three, "one of them is absent" is an absence of the
|
||||
SITE figure, and it publishes as an absence. A partial sum is a second way of
|
||||
reporting 0-where-there-was-production, one layer up, and it is refused for the
|
||||
same reason.
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
|
||||
|
||||
def entities(opts: dict) -> tuple:
|
||||
"""Every PV entity this configuration names, in configured order. TEL-12.
|
||||
|
||||
⚠️ ONE ANSWER TO "WHICH ENTITIES ARE THE PV CHANNEL", and it is the answer
|
||||
BOTH sides read. `Controller.pv_roster` is this call and
|
||||
`mqtt.optional_keys()` is `bool()` of this call, so the thing that decides
|
||||
whether the row is ANNOUNCED, the thing that decides whether it is READ and
|
||||
the thing that decides HOW MANY entities are summed cannot disagree. TEL-11
|
||||
already had that discipline for one entity and spelled the predicate TWICE
|
||||
- `str(opts.get("pv_entity") or "").strip()` in `main.py` and again in
|
||||
`mqtt.py`. Two spellings of one predicate is how an entity gets announced
|
||||
that nothing feeds; a roster makes it how a SUM gets taken over a different
|
||||
list from the one that was announced. It is one function now.
|
||||
|
||||
⚠️ `pv_entities` REPLACES `pv_entity` RATHER THAN EXTENDING IT, and that is
|
||||
CFG-04's shipped precedent (`inverters:` replaces the six flat keys) applied
|
||||
to the only upgrade path that matters. The install this card exists for HAS
|
||||
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural
|
||||
action is to put ALL the inverters in the new repeatable field - and under a
|
||||
UNION rule that double-counts A unless the operator also remembers to clear
|
||||
the old key. A doubled total is the same defect as a partial one in the
|
||||
other direction, in the one channel that exists to be believed a week later.
|
||||
Under REPLACE the effective roster is exactly what one key says, and no
|
||||
arithmetic depends on a de-duplication rule the operator has to know about.
|
||||
⚠️ `Controller.checks()` says WHICH key is in use whenever both are set, so
|
||||
the replacement is stated on the page rather than being silent.
|
||||
|
||||
⚠️ AND IT DE-DUPLICATES WITHIN THE LIST, ORDER PRESERVED. A site cannot have
|
||||
two sensors with one entity_id, so a repeated id in the list is never a
|
||||
second inverter - it is a typo that would DOUBLE that inverter's production
|
||||
in the total. `dict.fromkeys` is the order-preserving spelling.
|
||||
"""
|
||||
listed = [e for e in (str(x or "").strip()
|
||||
for x in (opts.get("pv_entities") or [])) if e]
|
||||
if listed:
|
||||
return tuple(dict.fromkeys(listed))
|
||||
one = str(opts.get("pv_entity") or "").strip()
|
||||
return (one,) if one else ()
|
||||
|
||||
|
||||
def entity_is_ignored(opts: dict) -> bool:
|
||||
"""Is a set `pv_entity` being REPLACED by a set `pv_entities`? TEL-12.
|
||||
|
||||
⚠️ HERE RATHER THAN IN `Controller.checks()`, for the reason `entities()`
|
||||
exists: the rule and the sentence that reports the rule must not be two
|
||||
readings of the options. This is the negative half of `entities()`' first
|
||||
branch and it is spelled once, beside it, where an edit to one is in front
|
||||
of whoever is editing the other.
|
||||
"""
|
||||
return bool(str(opts.get("pv_entity") or "").strip()
|
||||
and [x for x in (opts.get("pv_entities") or [])
|
||||
if str(x or "").strip()])
|
||||
|
||||
|
||||
def total(readings) -> tuple:
|
||||
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
|
||||
|
||||
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
|
||||
per configured entity, each `state` shaped as `hass.state()` returns.
|
||||
|
||||
`parts` is `[(entity_id, watts, why), ...]` - EVERY entity's own answer from
|
||||
`watts()`, kept because the operator surfaces have to be able to name which
|
||||
sensor is missing. `watts` is the sum when every entity answered, and `None`
|
||||
otherwise; `why` always says which of those it was, in words a status page
|
||||
can print.
|
||||
|
||||
⚠️ ONE OR MORE ABSENT -> THE TOTAL IS ABSENT. NOT THE PARTIAL SUM. This is
|
||||
the decision the card turns on and it was taken deliberately against the
|
||||
alternative, which was to publish the partial sum ALONGSIDE a count of how
|
||||
many entities contributed. Both are defensible; this one wins on the ONE
|
||||
question that matters, which is who reads the number and when:
|
||||
|
||||
* the MQTT row is read back COLD, a week later, by OPT-10's attribution
|
||||
and by whatever plots `sensor.goodwe_pv_power` over that week. A partial
|
||||
sum there is a smaller number indistinguishable from a real one, and the
|
||||
count that would have qualified it lives in a SECOND entity with its own
|
||||
history - so believing the total requires joining two recorder series by
|
||||
timestamp to find out which samples to distrust. Nobody re-derives that,
|
||||
which is the same "nobody will re-derive it" TEL-11's note names. And it
|
||||
is worse than a gap in exactly the direction that matters: a gap makes
|
||||
the attribution say "I do not know", a partial sum makes it say "there
|
||||
was less sun than there was".
|
||||
* the STATUS PAGE is read LIVE, by a human, who wants the diagnostic. So
|
||||
the partial information is not thrown away - `Controller.checks()` names
|
||||
every entity that DID report, with its watts, and every one that did
|
||||
not, with its reason, in a row that states plainly that there is no site
|
||||
total. Different reader, different instant, different rule.
|
||||
|
||||
A count published beside the total would also carry no information in the
|
||||
state this function actually admits: the total is published ONLY when every
|
||||
entity contributed, so the count would be the constant `len(parts)`.
|
||||
|
||||
⚠️ AND `0 W` IS STILL A READING AT EVERY ROSTER SIZE - the test is
|
||||
`watts is None`, never falsiness. An inverter honestly reporting 0 W at dusk
|
||||
beside two that are producing must give the SUM of the three, not blank the
|
||||
site: shading, a tripped string and nightfall are all real 0s, and a roster
|
||||
that went to `unknown` whenever one member read zero would report an absence
|
||||
on every clear evening and defeat the channel from the other end.
|
||||
"""
|
||||
parts = [(ent, *watts(st)) for ent, st in readings]
|
||||
if not parts:
|
||||
# No roster. `Controller` does not call this in that state - the gate is
|
||||
# `pv_roster` - but a reader of `parts` should never have to wonder.
|
||||
return None, "not configured", parts
|
||||
if len(parts) == 1:
|
||||
# ⚠️ AT ONE ENTITY THIS IS `watts()` AND NOTHING ELSE: the same value
|
||||
# and the SAME `why` string, passed through rather than re-composed, so
|
||||
# 0.11.0's single-entity surfaces are byte-identical rather than
|
||||
# similar. `tools/tel11_pv_trace.py` grades that identity across trees.
|
||||
return parts[0][1], parts[0][2], parts
|
||||
missing = [(ent, why) for ent, w, why in parts if w is None]
|
||||
if missing:
|
||||
return None, (
|
||||
f"only {len(parts) - len(missing)} of {len(parts)} entities are "
|
||||
f"reporting and a partial sum would understate production: "
|
||||
+ "; ".join(f"{ent} - {why}" for ent, why in missing)), parts
|
||||
return (sum(w for _e, w, _y in parts),
|
||||
f"{len(parts)} entities", parts)
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.10.0"
|
||||
version: "0.11.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -147,6 +147,74 @@ 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: ""
|
||||
|
||||
# TEL-12 - THE SAME CHANNEL WHEN THE ROOF HAS SEVERAL INVERTERS, WHICH IS THE
|
||||
# ORDINARY CASE AND NOT AN EDGE ONE. A multi-string or multi-orientation roof
|
||||
# routinely carries two or three PV inverters, each publishing its own power
|
||||
# sensor; 0.11.0 shipped `pv_entity` as a single string and the reference
|
||||
# site's owner reported within minutes that they could configure one of
|
||||
# theirs. List them all here and the row carries the SITE total.
|
||||
#
|
||||
# ⚠️ DECLARED, THIS REPLACES `pv_entity` - it does not extend it. Set both and
|
||||
# ONLY this list is read, and the status page says so. That is CFG-04's rule
|
||||
# for `inverters:` applied to the upgrade path this key exists for: the
|
||||
# install being upgraded HAS `pv_entity` set, and an operator who adds all
|
||||
# their inverters to this list while the old key still names one of them would
|
||||
# DOUBLE that inverter under a union rule. Clear `pv_entity` when you fill
|
||||
# this in.
|
||||
#
|
||||
# ⚠️ AND IF ANY ONE OF THEM IS NOT REPORTING, THERE IS NO SITE TOTAL - the row
|
||||
# publishes `unknown` and the recorder records a GAP. It does NOT publish the
|
||||
# sum of the ones that answered: two of three inverters add up to a smaller
|
||||
# number that is indistinguishable from a real one, and a week later that
|
||||
# reads as "there was less sun than there was" - the exact conclusion this
|
||||
# channel exists to TEST rather than to manufacture. The status page names
|
||||
# which entity is missing and why, and lists what the others reported, because
|
||||
# a human reading it live wants the diagnostic; the recorded number does not,
|
||||
# because nobody re-derives a week of history. See
|
||||
# DECISIONS/TEL-12-several-pv-inverters.md.
|
||||
#
|
||||
# ⚠️ THE FIX IS NOT A TEMPLATE SENSOR THAT SUMS THEM. You may legitimately
|
||||
# prefer one, but a template sensor summing an `unavailable` source yields the
|
||||
# partial total silently - the same defect one layer down, in a place this
|
||||
# add-on cannot see or report.
|
||||
#
|
||||
# pv_entities:
|
||||
# - sensor.pv_east_power
|
||||
# - sensor.pv_west_power
|
||||
# - sensor.pv_garage_power
|
||||
#
|
||||
# Each entity's OWN `unit_of_measurement` is read, so a kW inverter beside a W
|
||||
# one sums correctly. Empty is the shipped default and means "use `pv_entity`,
|
||||
# which is also empty" - i.e. no PV channel at all, which is every install.
|
||||
pv_entities: []
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
# ==========================================================================
|
||||
@@ -872,6 +940,37 @@ 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?
|
||||
|
||||
# TEL-12. ⚠️ A SECOND KEY RATHER THAN A LIST SHAPE ON `pv_entity`, AND THAT IS
|
||||
# A MEASUREMENT AND NOT A PREFERENCE. `pv_entity` shipped in 0.11.0 as `str?`
|
||||
# and the reference site has a string persisted in it. Declared as a list,
|
||||
# Supervisor 2026.08.0 answers that stored string with
|
||||
#
|
||||
# Invalid list for option 'pv_entity' in GoodWe RS485 Controller
|
||||
#
|
||||
# and `deepmerge`'s list strategy is `override`, so a shipped `[]` default does
|
||||
# NOT rescue it - the operator's string stands and is refused. That is CFG-04
|
||||
# §B's trap in the other direction: an add-on that will not start, and the
|
||||
# schema needed to fix it pinned to the version that cannot be installed. The
|
||||
# only single-key spelling Supervisor accepts for BOTH shapes is `match(.*)?`,
|
||||
# which validates nothing at all (it admits a number, a dict, any list of
|
||||
# anything) and renders as a plain text box. Measured, both of them, by
|
||||
# `tools/supervisor_shape_probe.py` §G - the transcript is in
|
||||
# DECISIONS/TEL-12-several-pv-inverters.md §1.
|
||||
#
|
||||
# ⚠️ AND `pv_entities: []` IN THE `options:` BLOCK IS LOAD-BEARING, exactly as
|
||||
# `inverters: []` is. §G measures the same isolated counterfactual: the
|
||||
# shipped defaults MINUS only this key are refused with `Missing option
|
||||
# 'pv_entities' in root`, which is every options blob written before 0.12.
|
||||
# `p1_phase_import_entities` is the shipped precedent for the pair.
|
||||
pv_entities:
|
||||
- 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")
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -664,8 +716,13 @@ print("\n8. verified by breaking it")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ MUTATED SOURCE, EXEC'D INTO A FRESH NAMESPACE. No file is edited and no
|
||||
# `.pyc` is consulted, so TEST-15's stale-bytecode failure cannot produce a
|
||||
# false survivor here. `app/mqtt.py` imports nothing from this package, which is
|
||||
# what makes exec'ing it standalone honest rather than a stub.
|
||||
# false survivor here. `app/mqtt.py` imports nothing from this package AT MODULE
|
||||
# SCOPE, which is what makes exec'ing it standalone honest rather than a stub.
|
||||
# ⚠️ TEL-12 IS WHY THAT NOW SAYS "AT MODULE SCOPE". `optional_keys()` reads
|
||||
# `pv.entities()` - one spelling of the roster predicate for the announcement and
|
||||
# for the SUM alike - and imports it INSIDE the function precisely so this
|
||||
# harness keeps working; its own comment says so. Nothing graded below calls
|
||||
# `optional_keys()`, so no mutant here is stubbed by that.
|
||||
def load(pairs):
|
||||
"""`app/mqtt.py` with `pairs` applied, executed into a fresh module.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
@@ -65,7 +65,7 @@ import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
from deepmerge import Merger
|
||||
from supervisor.apps.validate import SCHEMA_APP_CONFIG
|
||||
from supervisor.apps.options import AppOptions
|
||||
from supervisor.apps.options import AppOptions, UiOptions
|
||||
from pathlib import Path
|
||||
from supervisor.utils.yaml import read_yaml_file
|
||||
|
||||
@@ -86,13 +86,24 @@ print(" schema declares", len(schema), "keys; inverters ->", json.dumps(schema[
|
||||
# this line must not crash the probe before section B gets to REFUSE it.
|
||||
print(" options ship inverters ->", json.dumps(defaults.get("inverters")))
|
||||
|
||||
def validate(options):
|
||||
v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"], "local_goodwe_controller")))
|
||||
def validate_with(sch, options):
|
||||
"""`(ok, result-or-refusal)` for ONE schema and ONE options blob.
|
||||
|
||||
⚠️ TEL-12 §G VARIES THE SCHEMA rather than only the options, which is what a
|
||||
question about a KEY'S SHAPE requires. `validate()` below is this with the
|
||||
shipped schema and is what every earlier section calls, unchanged.
|
||||
"""
|
||||
v = vol.Schema(vol.All(dict, AppOptions(None, sch, cfg["name"],
|
||||
"local_goodwe_controller")))
|
||||
try:
|
||||
return True, v(copy.deepcopy(options))
|
||||
except vol.Invalid as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def validate(options):
|
||||
return validate_with(schema, options)
|
||||
|
||||
FAILED = []
|
||||
|
||||
def show(t, ok, d=""):
|
||||
@@ -554,6 +565,124 @@ for lang in ("en", "nl"):
|
||||
show(f"...and the coverage audit is clean against the real loader's view too. "
|
||||
f"Problems: {_probs}", not _probs)
|
||||
|
||||
print("\n== G. TEL-12: the SHAPE of a several-inverter PV option ==")
|
||||
# ⚠️ WHY THIS SECTION EXISTS. TEL-12 had a real choice of surface and the two
|
||||
# candidates differ on ONE question nobody could answer by reading: does
|
||||
# Supervisor accept a stored STRING for a key whose schema has become a LIST?
|
||||
# 0.11.0 shipped `pv_entity: str?`, the reference install HAS a string persisted
|
||||
# in it, and an upgrade that refuses that string leaves an add-on which will not
|
||||
# start - CFG-04 §B's trap, in the other direction. So it was measured.
|
||||
#
|
||||
# ⚠️ AND IT IS AN ISOLATED COUNTERFACTUAL LIKE §B AND §D. One key's schema
|
||||
# changes at a time, from a baseline that is ACCEPTED on the line above, and each
|
||||
# refusal is asserted ON THE KEY'S NAME rather than on `not ok` (habit 6).
|
||||
PV_STORED = {"pv_entity": "sensor.pv_power"} # what 0.11.0 persisted
|
||||
_pv_base = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV_STORED))
|
||||
ok, out = validate(_pv_base)
|
||||
show("the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED "
|
||||
"- the baseline every refusal below is varied from", ok,
|
||||
f"pv_entity={out.get('pv_entity')!r} pv_entities={out.get('pv_entities')!r}"
|
||||
if ok else out)
|
||||
|
||||
# --- OPTION 1: `pv_entity` becomes a list. THE ONE THAT LOSES. ---------------
|
||||
_s_list = dict(schema, pv_entity=["str"])
|
||||
ok_l, out_l = validate_with(_s_list, _pv_base)
|
||||
show("⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as "
|
||||
"a LIST, the string an 0.11.0 install has persisted is REFUSED, and the "
|
||||
"refusal names the key - so that shape would leave the live add-on unable "
|
||||
"to start on upgrade",
|
||||
(not ok_l) and "Invalid list for option 'pv_entity'" in str(out_l), out_l)
|
||||
# ⚠️ AND THE `[]` DEFAULT DOES NOT RESCUE IT, which is the half somebody would
|
||||
# reach for. `_OPTIONS_MERGER`'s list strategy is OVERRIDE (CFG-04 §4), so the
|
||||
# operator's string simply stands.
|
||||
_d_list = dict(defaults, pv_entity=[])
|
||||
_m_list = MERGER.merge(copy.deepcopy(_d_list), copy.deepcopy(PV_STORED))
|
||||
ok_l2, out_l2 = validate_with(_s_list, _m_list)
|
||||
show("...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's "
|
||||
"list strategy is `override`, so the operator's string survives the merge "
|
||||
"and is refused anyway",
|
||||
_m_list["pv_entity"] == "sensor.pv_power" and not ok_l2, out_l2)
|
||||
ok_l3, out_l3 = validate_with(_s_list, dict(_pv_base, pv_entity=["sensor.a"]))
|
||||
show("...(the list shape itself is fine - it is only the STORED STRING it "
|
||||
"refuses, which is what makes this a migration and not a schema question)",
|
||||
ok_l3, out_l3 if not ok_l3 else "a list value validates")
|
||||
|
||||
# --- OPTION 1b: one key, both shapes. ACCEPTED, AND STILL WRONG. -------------
|
||||
# ⚠️ REPORTED EVEN THOUGH IT PASSES, because "no single spelling accepts both"
|
||||
# would have been a FALSE reason to reject option 1 - and MUTATION.md's habit
|
||||
# about inventing justifications applies to a probe as much as to a card.
|
||||
_s_match = dict(schema, pv_entity="match(.*)?")
|
||||
try:
|
||||
SCHEMA_APP_CONFIG(dict(raw, schema=dict(raw["schema"],
|
||||
pv_entity="match(.*)?")))
|
||||
_manifest_ok = True
|
||||
except vol.Invalid as e:
|
||||
_manifest_ok, _me = False, str(e)
|
||||
_ok_s, _ = validate_with(_s_match, _pv_base)
|
||||
_ok_li, _ = validate_with(_s_match, dict(_pv_base, pv_entity=["a", "b"]))
|
||||
_ok_num, _ = validate_with(_s_match, dict(_pv_base, pv_entity=417))
|
||||
_ui_match = [e for e in UiOptions(None)(_s_match)
|
||||
if e.get("name") == "pv_entity"]
|
||||
show("⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR "
|
||||
"A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, "
|
||||
"a list passes, and so does the integer 417; and the Configuration tab "
|
||||
"renders it as a plain text box rather than a repeatable field, so an "
|
||||
"operator cannot enter a second inverter in the UI at all",
|
||||
_manifest_ok and _ok_s and _ok_li and _ok_num
|
||||
and _ui_match and _ui_match[0].get("multiple") is not True,
|
||||
f"string={_ok_s} list={_ok_li} integer-417={_ok_num} "
|
||||
f"ui={json.dumps(_ui_match)}")
|
||||
|
||||
# --- OPTION 2: `pv_entities` beside `pv_entity`. WHAT SHIPPED. ---------------
|
||||
show("⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no "
|
||||
"`pv_entities` key at all, because it did not exist when that blob was "
|
||||
"written - validates against THIS manifest, with the new key defaulted to "
|
||||
"the empty list. The live install upgrades without touching its options",
|
||||
ok and out.get("pv_entity") == "sensor.pv_power"
|
||||
and out.get("pv_entities") == [])
|
||||
_three = ["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
|
||||
ok3, out3 = validate(MERGER.merge(copy.deepcopy(defaults),
|
||||
dict(PV_STORED, pv_entities=_three)))
|
||||
show("...and a three-entity roster validates, per-entry, with both keys set - "
|
||||
"which the add-on resolves by REPLACEMENT and says so on its status page",
|
||||
ok3 and out3.get("pv_entities") == _three,
|
||||
json.dumps(out3.get("pv_entities")) if ok3 else out3)
|
||||
# ⚠️ §B's LOAD-BEARING MEASUREMENT, FOR THIS KEY. Exactly one key varies.
|
||||
_no_pve = {k: v for k, v in defaults.items() if k != "pv_entities"}
|
||||
ok_np, out_np = validate(_no_pve)
|
||||
show("⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is "
|
||||
"REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the "
|
||||
"`pv_entities: []` default is load-bearing, exactly as `inverters: []` is: "
|
||||
"Supervisor treats a nested list as required unless its element is an "
|
||||
"OPTIONAL scalar, and `- str` is not one",
|
||||
(not ok_np) and "Missing option 'pv_entities' in root" in str(out_np),
|
||||
out_np)
|
||||
_ui_pv = [e for e in UiOptions(None)(schema)
|
||||
if e.get("name") in ("pv_entity", "pv_entities")]
|
||||
show("...and the Configuration tab renders it as a REPEATABLE string field "
|
||||
"(`multiple: true`), so an operator adds their second and third inverter "
|
||||
"in the UI rather than in YAML",
|
||||
[e["name"] for e in _ui_pv] == ["pv_entity", "pv_entities"]
|
||||
and _ui_pv[1].get("multiple") is True
|
||||
and _ui_pv[0].get("multiple") is not True, json.dumps(_ui_pv))
|
||||
# --- the ROLLBACK direction, as §E does for `inverters` ----------------------
|
||||
_flat = {k: v for k, v in schema.items() if k != "pv_entities"}
|
||||
try:
|
||||
_rolled = vol.Schema(vol.All(dict, AppOptions(
|
||||
None, _flat, cfg["name"], "local_goodwe_controller")))(
|
||||
MERGER.merge(copy.deepcopy(defaults),
|
||||
dict(PV_STORED, pv_entities=_three)))
|
||||
show("a DOWNGRADE to a schema without `pv_entities` drops the key with a "
|
||||
"warning rather than refusing, and leaves `pv_entity` standing - so "
|
||||
"rolling back to 0.11.0 returns the install to its single entity "
|
||||
"rather than bricking it",
|
||||
"pv_entities" not in _rolled
|
||||
and _rolled.get("pv_entity") == "sensor.pv_power")
|
||||
except vol.Invalid as e:
|
||||
show("a DOWNGRADE to a schema without `pv_entities` drops the key", False,
|
||||
str(e))
|
||||
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
"""TEL-11/TEL-12's behaviour-invariance instrument. `tools/tel11_pv_trace.py`
|
||||
|
||||
⚠️ THE FILE NAME STILL SAYS TEL-11 ON PURPOSE. It is quoted by
|
||||
`DECISIONS/TEL-11-pv-production.md` §5 and by `test_pv.py`'s mutation table, and
|
||||
a renamed instrument makes both of those references dangle for no gain. TEL-12
|
||||
EXTENDED it rather than writing a second one, which is also the honest thing: the
|
||||
two cards' claims are the same claim at two roster sizes, and one document set
|
||||
graded by one `--compare` cannot drift apart the way two tools would.
|
||||
|
||||
TEL-11 added 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.
|
||||
|
||||
TEL-12 makes the channel a ROSTER (`pv_entities`, several inverters, one site
|
||||
total) and adds two more, in the same shape:
|
||||
|
||||
3. with `pv_entity` set to ONE entity, the add-on behaves exactly as 0.11.0
|
||||
does - CONTROL AND TELEMETRY BOTH. That is a STRONGER claim than TEL-11
|
||||
could make, because TEL-11's baseline had no channel at all to be identical
|
||||
to, and it is the acceptance criterion for not breaking the live install:
|
||||
`pv.total()` passes a single entity's answer through verbatim for exactly
|
||||
this reason.
|
||||
4. with SEVERAL entities configured, and with one of several ABSENT, the
|
||||
control decisions are still exactly those. A PARTIAL total is a different
|
||||
number from a full one, so "a partial reaches no decision" is a separate
|
||||
claim rather than a corollary.
|
||||
|
||||
⚠️ AND TEL-12 HAD TO CHANGE `--compare`'s REFUSALS, BECAUSE TEL-11's WERE WRITTEN
|
||||
AGAINST A BASELINE THAT NO LONGER EXISTS. TEL-11's rule was "exactly ONE side's
|
||||
`set` document may differ from its own `unset`, and that side is the HEAD" - true
|
||||
when `release/1.0` predated `pv_entity` entirely. `release/1.0` is now 0.11.0 and
|
||||
BOTH sides react to `pv_entity`, so that refusal fires on every honest run. The
|
||||
discriminator is the ROSTER instead: only the HEAD reacts to `pv_entities`, and
|
||||
that is asserted rather than assumed.
|
||||
|
||||
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/TEL-12 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/pv-head.json
|
||||
# …in a release/1.0 checkout, with this file copied in…
|
||||
python3 tools/tel11_pv_trace.py > /tmp/pv-base.json
|
||||
python3 tools/tel11_pv_trace.py --compare /tmp/pv-base.json \\
|
||||
/tmp/pv-head.json
|
||||
|
||||
⚠️ THREE DOCUMENT SETS PER TREE, AND THE THIRD IS WHY THE FIRST TWO STILL HASH
|
||||
THE SAME AS THEY DID UNDER TEL-11. `unset` and `one` drive exactly the stimulus
|
||||
cells TEL-11 drove, so their `control` hashes are comparable across the two
|
||||
cards; the roster cells live in `multi`, a separate document, rather than being
|
||||
appended to those. Adding cells to an existing document would have moved a hash
|
||||
whose stability is itself the evidence.
|
||||
|
||||
⚠️ 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 INVERTER (the battery kind - CFG-04's
|
||||
`inverters:`), and any per-unit surface. The PV roster IS driven, at three;
|
||||
* 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}}
|
||||
|
||||
|
||||
def _by_entity(mapping):
|
||||
"""A `hass.state()` that answers each entity its OWN scripted state."""
|
||||
async def state(entity):
|
||||
return mapping.get(entity)
|
||||
return state
|
||||
|
||||
|
||||
# ⚠️ 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),
|
||||
}
|
||||
# --- TEL-12: THE ROSTER, AND THE VALUES ARE THE DESIGN ------------------------
|
||||
# Three entities, distinct non-zero contributions in two different units, so the
|
||||
# site total is separable from every partial of itself AND from max/min/first/
|
||||
# last/mean over the three. ⚠️ NOT ONE OF THEM IS ZERO: a member contributing 0 W
|
||||
# could go absent without moving the sum of the rest, and a stimulus like that
|
||||
# cannot tell "published the absence" from "published the partial sum" - which is
|
||||
# MUTATION.md habit 23 with a coinciding value in the fixture rather than in the
|
||||
# plant.
|
||||
PV_ENTITIES = ["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
|
||||
_R = {"sensor.pv_east": (1200.0, "W"), "sensor.pv_west": (2.1, "kW"),
|
||||
"sensor.pv_garage": (450.0, "W")}
|
||||
ROSTER_TOTAL_W = 3750.0 # 1200 + 2100 + 450, in watts
|
||||
|
||||
|
||||
def _roster(dead=(), zero=()):
|
||||
"""`{entity: state}` for the three - `dead` answers nothing, `zero` says 0."""
|
||||
return {e: (None if e in dead
|
||||
else _pv_state(0, "W") if e in zero else _pv_state(v, u))
|
||||
for e, (v, u) in _R.items()}
|
||||
|
||||
|
||||
# ⚠️ EACH CELL CARRIES BOTH HALVES OF THE CLAIM, AS LITERALS: the number that
|
||||
# must NEVER appear on the PV row, and the number that MUST. `--compare` asserts
|
||||
# against these rather than re-deriving anything from the code under test
|
||||
# (habit 2), and a cell with no dead member has no partial to leak - which is why
|
||||
# `never` is `None` there rather than the true total. That distinction cost a run:
|
||||
# labelling WEST-ZERO's true total as its "partial" made 5/5 refuse the shipped
|
||||
# tree.
|
||||
#
|
||||
# dead members zero members never / always (W)
|
||||
ROSTERS = {
|
||||
"ALL-THREE": ((), (), None, ROSTER_TOTAL_W),
|
||||
"EAST-DEAD": (("sensor.pv_east",), (), 2550.0, None),
|
||||
"WEST-DEAD": (("sensor.pv_west",), (), 1650.0, None),
|
||||
"GARAGE-DEAD": (("sensor.pv_garage",), (), 3300.0, None),
|
||||
# ⚠️ 0.0 IS THE NUMBER `sum([])` PRODUCES, and it is the one a roster with
|
||||
# nothing reporting must never publish: it is the "there was no sun" the
|
||||
# channel exists to test.
|
||||
"ALL-DEAD": (tuple(_R), (), 0.0, None),
|
||||
# ⚠️ AND THE MIRROR OF THIS CARD'S DEFECT: a roster that blanked the site
|
||||
# whenever one member honestly read 0 W would report an absence on every
|
||||
# clear evening. All three ANSWER here, so 1650 W MUST be published.
|
||||
"WEST-ZERO": ((), ("sensor.pv_west",), None, 1650.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, roster=None):
|
||||
hass = Hass(grid_of, soc, pv_of)
|
||||
if roster is not None:
|
||||
# ⚠️ PER-ENTITY ANSWERS, which a roster needs and `pv_of` cannot give:
|
||||
# a scripted function of the tick alone answers every entity the same
|
||||
# thing, and three equal numbers cannot separate a sum from any other
|
||||
# reduction over three, nor drive ONE member absent.
|
||||
hass.state = _by_entity(roster)
|
||||
pub = Pub()
|
||||
opts = dict(ONE)
|
||||
if roster is not None:
|
||||
opts["pv_entities"] = list(PV_ENTITIES)
|
||||
elif 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, roster: bool = False) -> dict:
|
||||
"""`{"control": …, "telemetry": …}` for one setting of the PV channel.
|
||||
|
||||
⚠️ `roster=True` DRIVES ITS OWN STIMULUS CELLS AND LANDS IN ITS OWN DOCUMENT
|
||||
(TEL-12). It does NOT append cells to the `unset`/`one` documents, because
|
||||
those two documents' `control` hashes are the evidence TEL-11 recorded and a
|
||||
new cell would move them for a reason that is not behaviour. Same reason
|
||||
`_supports_include()` lives in `tree` rather than in a document.
|
||||
"""
|
||||
_load()
|
||||
logging.disable(logging.CRITICAL)
|
||||
loop = asyncio.new_event_loop()
|
||||
control, tele, partials = {}, {}, {}
|
||||
_cells = ([(f"{g}/{r}", gof, None, _roster(dead, zero), (never, always))
|
||||
for g, gof in GRIDS.items()
|
||||
for r, (dead, zero, never, always) in ROSTERS.items()]
|
||||
if roster else
|
||||
[(f"{g}/{pn}", gof, pof, None, None)
|
||||
for g, gof in GRIDS.items() for pn, pof in PVS.items()])
|
||||
for key, gof, pof, rmap, partial in _cells:
|
||||
c, hass, pub, ctrl = loop.run_until_complete(
|
||||
_run(gof, pof, pv_set, roster=rmap))
|
||||
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"],
|
||||
}
|
||||
if partial is not None:
|
||||
# ⚠️ TEL-12. THE NUMBER THAT MUST NEVER APPEAR ON THE PV ROW,
|
||||
# carried in the output so `--compare` asserts against a literal
|
||||
# rather than re-deriving it from the code under test (habit 2).
|
||||
# `wire` is what `str()` of it looks like on the topic, because that
|
||||
# is the comparison that matters.
|
||||
#
|
||||
# ⚠️ IN ITS OWN TOP-LEVEL MAP AND *NOT* INSIDE THE TELEMETRY CELL.
|
||||
# It is a fact about the STIMULUS, produced by this tool and
|
||||
# identical on both trees, so putting it in the telemetry document
|
||||
# made every roster cell differ from its unset reference for a
|
||||
# reason that is not behaviour - and the HEAD/baseline discriminator
|
||||
# then fired on both sides. Measured, on the first run.
|
||||
never, always = partial
|
||||
partials[key] = {
|
||||
"never": None if never is None else str(round(never, 1)),
|
||||
"always": None if always is None else str(round(always, 1))}
|
||||
# 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,
|
||||
roster=dict(_roster()) if roster else None))
|
||||
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, roster)
|
||||
out = {"control": control, "telemetry": tele}
|
||||
if partials:
|
||||
out["partials"] = partials
|
||||
return out
|
||||
|
||||
|
||||
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 _supports_roster() -> bool:
|
||||
"""Does THIS tree know `pv_entities` at all? TEL-12.
|
||||
|
||||
⚠️ IN `tree` FOR `_supports_include()`'s OWN REASON. A tree that predates
|
||||
TEL-12 answers False; a tree that predates TEL-11 has no `app/pv.py` either,
|
||||
so the import is guarded rather than assumed.
|
||||
"""
|
||||
try:
|
||||
from app import pv as _p
|
||||
except ImportError:
|
||||
return False
|
||||
return callable(getattr(_p, "entities", None))
|
||||
|
||||
|
||||
def _announcement(pv_set: bool, roster: bool = False) -> 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.
|
||||
|
||||
⚠️ AND AT `roster` TRUE THE OPTS CARRY `pv_entities` AND NOTHING ELSE. On a
|
||||
tree that predates TEL-12 `optional_keys()` reads only `pv_entity`, so it
|
||||
answers `()` and no PV row is announced - which is exactly how a baseline is
|
||||
supposed to fail to react to a key it has never heard of, and is what
|
||||
`--compare` 4/5 asserts rather than assumes.
|
||||
"""
|
||||
if (pv_set or roster) and _supports_include():
|
||||
payloads = mqtt.discovery_payloads(
|
||||
include=mqtt.optional_keys({"pv_entities": list(PV_ENTITIES)}
|
||||
if roster
|
||||
else {"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(),
|
||||
# TEL-12. Does THIS tree know the roster at all? A property of the
|
||||
# tree, recorded beside the file hashes for `_supports_include()`'s
|
||||
# own reason: putting it in a document would make `multi` differ
|
||||
# between trees for something that is not behaviour.
|
||||
"supports_roster": _supports_roster()}
|
||||
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()
|
||||
|
||||
|
||||
# ⚠️ WHY `multi` IS GRADED PER GRID WORLD AND `one` IS NOT. `one` drives the same
|
||||
# stimulus cells `unset` does - `GRID/pv-schedule` - so the two documents have the
|
||||
# same keys and compare directly. `multi`'s cells are `GRID/roster-state`, a
|
||||
# different naming over a different stimulus, so a whole-document comparison
|
||||
# would report a difference that is only the key names. The comparable thing is
|
||||
# the GRID: for one grid world, `unset` produces the SAME control ticks for every
|
||||
# PV schedule (nothing is read), so that grid has ONE control document and each
|
||||
# roster cell in the same grid must equal it. That per-grid collapse is asserted
|
||||
# as a precondition before it is used - without it "equal to the unset document
|
||||
# for this grid" names nothing.
|
||||
def _grid(key: str) -> str:
|
||||
return key.split("/")[0]
|
||||
|
||||
|
||||
def _collapse(doc_part: dict):
|
||||
"""`{grid: value}` if every cell in a grid agrees, else `None`.
|
||||
|
||||
⚠️ RETURNS `None` RATHER THAN A BEST GUESS. A caller that got a dict has a
|
||||
well-defined per-grid reference; a caller that got `None` has to say so and
|
||||
refuse, which is what `compare()` does.
|
||||
"""
|
||||
out = {}
|
||||
for key, value in doc_part.items():
|
||||
if "/" not in key:
|
||||
continue
|
||||
if _grid(key) in out and out[_grid(key)] != value:
|
||||
return None
|
||||
out[_grid(key)] = value
|
||||
return out
|
||||
|
||||
|
||||
def _reduces_to_unset(doc: dict, what: str) -> bool:
|
||||
"""Is this side's `multi` document its own `unset` one, grid for grid?
|
||||
|
||||
True of a tree that has never heard of `pv_entities`: it reads nothing,
|
||||
announces nothing and shows no row, so the roster cells are the unset cells.
|
||||
False of a tree that implements TEL-12. This is the HEAD/baseline
|
||||
discriminator, and it is MEASURED rather than taken from
|
||||
`tree.supports_roster`, which is the tree describing itself.
|
||||
"""
|
||||
ref = _collapse(doc["unset"][what])
|
||||
if ref is None:
|
||||
return False
|
||||
return all(value == ref.get(_grid(key))
|
||||
for key, value in doc["multi"][what].items() if "/" in key)
|
||||
|
||||
|
||||
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
|
||||
# ⚠️ TEL-12 HAD TO CHANGE THIS DISCRIMINATOR AND HERE IS WHY. TEL-11's rule
|
||||
# was "exactly ONE side's `set` document differs from its own `unset`" -
|
||||
# true while `release/1.0` predated `pv_entity` entirely. `release/1.0` is
|
||||
# 0.11.0 now, BOTH sides react to `pv_entity`, and that refusal would fire
|
||||
# on every honest run. The ROSTER is the key only the HEAD has heard of.
|
||||
for side, doc in (("A", a), ("B", b)):
|
||||
for which in ("unset", "one", "multi"):
|
||||
if which not in doc:
|
||||
print(f"REFUSED: side {side} has no {which!r} document. Both "
|
||||
f"sides must be produced by THIS version of the tool - "
|
||||
f"copy it into the baseline checkout rather than using "
|
||||
f"the one that is committed there.")
|
||||
return 2
|
||||
for side, doc in (("A", a), ("B", b)):
|
||||
for what in ("control", "telemetry"):
|
||||
if _collapse(doc["unset"][what]) is None:
|
||||
print(f"REFUSED: on side {side} the `unset` {what} document "
|
||||
f"does NOT collapse per grid world - two PV schedules in "
|
||||
f"one grid gave different answers with the channel off. "
|
||||
f"Nothing is read in that state, so this is the "
|
||||
f"instrument being wrong rather than the tree.")
|
||||
return 2
|
||||
moved = [side for side, doc in (("A", a), ("B", b))
|
||||
if not _reduces_to_unset(doc, "telemetry")]
|
||||
if len(moved) != 1:
|
||||
print(f"REFUSED: `multi` fails to reduce to `unset` on "
|
||||
f"{moved or 'NEITHER'} side. Exactly one side may react to "
|
||||
f"`pv_entities` - the HEAD. Neither means the instrument cannot "
|
||||
f"see TEL-12's material at all; both means the baseline is not a "
|
||||
f"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} - its roster cells ARE its "
|
||||
f"unset cells, which is what a tree that has never heard of "
|
||||
f"`pv_entities` does with them.")
|
||||
print(f" supports_roster {blabel} "
|
||||
f"{base['tree'].get('supports_roster')} / {hlabel} "
|
||||
f"{head['tree'].get('supports_roster')} (self-reported; the verdict "
|
||||
f"above is MEASURED)")
|
||||
print(f" tree {blabel} {_h(base['tree'])}")
|
||||
print(f" tree {hlabel} {_h(head['tree'])}")
|
||||
for what in ("control", "telemetry"):
|
||||
for side, doc, which in ((blabel, base, "unset"),
|
||||
(hlabel, head, "unset"),
|
||||
(blabel, base, "one"), (hlabel, head, "one"),
|
||||
(blabel, base, "multi"),
|
||||
(hlabel, head, "multi")):
|
||||
print(f" {what:<9} {side} {which:<5} {_h(doc[which][what])}")
|
||||
|
||||
rc = 0
|
||||
# 1. TEL-11's acceptance criterion: unset is the baseline, in full.
|
||||
if head["unset"] == base["unset"]:
|
||||
print("OK 1/5 UNSET: control AND telemetry are byte-identical to the "
|
||||
"baseline tree. An install that configures no 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/5 UNSET {what} MOVED at {k!r}")
|
||||
# 2. ⚠️ TEL-12's OWN ACCEPTANCE CRITERION, AND IT IS STRONGER THAN ANYTHING
|
||||
# TEL-11 COULD ASSERT. The baseline is 0.11.0, which HAS the channel, so
|
||||
# "one entity behaves exactly as 0.11.0 does" is a real cross-tree
|
||||
# identity over BOTH documents rather than an argument about f-strings.
|
||||
# The install that reported this defect has `pv_entity` set; this is the
|
||||
# line that says the upgrade does not move it.
|
||||
if head["one"] == base["one"]:
|
||||
print("OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to "
|
||||
"0.11.0's single-entity path - the same commands, the same "
|
||||
"payloads, the same status rows, the same announcement.")
|
||||
else:
|
||||
rc = 1
|
||||
for what in ("control", "telemetry"):
|
||||
for k in sorted(set(head["one"][what]) | set(base["one"][what])):
|
||||
if head["one"][what].get(k) != base["one"][what].get(k):
|
||||
print(f"FAIL 2/5 ONE-ENTITY {what} MOVED at {k!r}")
|
||||
print(f" base {base['one'][what].get(k)!r}")
|
||||
print(f" head {head['one'][what].get(k)!r}")
|
||||
# 3. the card's other half at ONE entity - TEL-11's 2/3, kept verbatim in
|
||||
# substance because it is the claim that must not decay.
|
||||
if head["one"]["control"] == head["unset"]["control"]:
|
||||
print("OK 3/5 CONTROL, ONE ENTITY: the control document is "
|
||||
"byte-identical with the channel switched on - PV reaches no "
|
||||
"decision.")
|
||||
else:
|
||||
rc = 1
|
||||
for k in sorted(head["one"]["control"]):
|
||||
if head["one"]["control"][k] != head["unset"]["control"].get(k):
|
||||
print(f"FAIL 3/5 CONTROL MOVED at {k!r} with one entity set")
|
||||
# 4. ⚠️ AND AT A ROSTER OF THREE, AND WITH A HOLE IN IT. A PARTIAL total is
|
||||
# a DIFFERENT number from a full one, so "a partial reaches no decision"
|
||||
# is a separate claim and not a corollary of 3/5.
|
||||
_cref = _collapse(head["unset"]["control"])
|
||||
_bad = [k for k, v in head["multi"]["control"].items()
|
||||
if "/" in k and v != _cref.get(_grid(k))]
|
||||
# ⚠️ AND THE SAME DOCUMENT ACROSS THE TWO TREES, which is the strongest form
|
||||
# of the claim available: the baseline runs the roster cells with a key it
|
||||
# has never heard of, so its control document for them is 0.11.0's. If
|
||||
# HEAD's matches it byte for byte, the control path under a roster IS
|
||||
# 0.11.0's control path.
|
||||
_xtree = head["multi"]["control"] == base["multi"]["control"]
|
||||
if not _xtree:
|
||||
rc = 1
|
||||
print("FAIL 4/5 the roster control document is NOT the baseline "
|
||||
"tree's. A roster changed a decision.")
|
||||
if not _bad and _xtree and (head["multi"]["control"].get("STOPPED")
|
||||
== head["unset"]["control"].get("STOPPED")):
|
||||
print(f"OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control "
|
||||
f"document equals its grid world's `unset` one - "
|
||||
f"{len(head['multi']['control']) - 1} cells over "
|
||||
f"{len(_cref)} grid worlds, covering three healthy entities, one "
|
||||
f"dead at EVERY position, all three dead and one honestly "
|
||||
f"reporting 0 W. Neither the total nor any PARTIAL total reaches "
|
||||
f"a decision, and the document is byte-identical to the BASELINE "
|
||||
f"tree's for the same cells.")
|
||||
else:
|
||||
rc = 1
|
||||
for k in _bad or ["STOPPED"]:
|
||||
print(f"FAIL 4/5 CONTROL MOVED at {k!r} under a roster")
|
||||
# 5. …and the instrument could have seen it, twice over: the telemetry
|
||||
# document DOES move, and the partial sum a defect would have published
|
||||
# is on no PV row anywhere in it.
|
||||
n = sum(1 for k, v in head["multi"]["telemetry"].items()
|
||||
if "/" in k
|
||||
and v != _collapse(head["unset"]["telemetry"]).get(_grid(k)))
|
||||
leaks, missing = [], []
|
||||
for key, spec in head["multi"].get("partials", {}).items():
|
||||
rows = [str(f["pv"]) for f in head["multi"]["telemetry"][key]["frames"]
|
||||
if "pv" in f]
|
||||
if spec["never"] is not None and spec["never"] in rows:
|
||||
leaks.append((key, spec["never"]))
|
||||
# ⚠️ AND THE OTHER DIRECTION, because "never publishes 1650" is also
|
||||
# satisfied by a channel that publishes nothing ever. A cell whose
|
||||
# members all ANSWER must publish its total, including the one where a
|
||||
# member honestly reports 0 W.
|
||||
if spec["always"] is not None and set(rows) != {spec["always"]}:
|
||||
missing.append((key, spec["always"], sorted(set(rows))[:4]))
|
||||
if not head["multi"].get("partials"):
|
||||
print("REFUSED 5/5: the HEAD run carries no `partials` map, so the "
|
||||
"partial-sum assertion quantified over nothing.")
|
||||
return 2
|
||||
if n and not leaks and not missing:
|
||||
print(f"OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES "
|
||||
f"move under a roster ({n} stimulus cells differ from their grid "
|
||||
f"world's unset telemetry), so 1/5-4/5 are identities this "
|
||||
f"instrument could have broken. AND in no cell with a dead member "
|
||||
f"does the PV row carry the partial sum of the survivors, while "
|
||||
f"every cell whose members all ANSWER publishes its total - "
|
||||
f"including the one where a member honestly reports 0 W.")
|
||||
else:
|
||||
rc = 2
|
||||
if leaks:
|
||||
print(f"REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at "
|
||||
f"{leaks[:6]}{' …' if len(leaks) > 6 else ''} - a smaller "
|
||||
f"number indistinguishable from a real one, which is the "
|
||||
f"defect this card exists to prevent.")
|
||||
if missing:
|
||||
print(f"REFUSED 5/5: a cell whose members ALL ANSWERED did not "
|
||||
f"publish its total: {missing[:6]}. An absence published for "
|
||||
f"a healthy roster defeats the channel from the other end - "
|
||||
f"'no PV reported' on every clear evening.")
|
||||
if not leaks and not missing:
|
||||
print("REFUSED 5/5: the telemetry document did NOT move under a "
|
||||
"roster. This instrument cannot see TEL-12'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),
|
||||
"rosters": sorted(ROSTERS), "entities": PV_ENTITIES,
|
||||
"roster_total_w": ROSTER_TOTAL_W, "cycles": CYCLES},
|
||||
"unset": documents(False),
|
||||
# ⚠️ `one` WAS CALLED `set` UNDER TEL-11 AND IS THE SAME STIMULUS SET,
|
||||
# renamed because there are now two ways to switch the channel on and
|
||||
# "set" no longer says which. Its `control` hashes are comparable to the
|
||||
# ones TEL-11 recorded; nothing about the cells moved.
|
||||
"one": documents(True),
|
||||
"multi": documents(False, roster=True),
|
||||
}, sort_keys=True, indent=1))
|
||||
@@ -84,6 +84,12 @@ 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."
|
||||
pv_entities:
|
||||
name: "Hardware · PV production entities, one per inverter (W)"
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page names which entity is missing, why, and what the others reported. 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,12 @@ 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."
|
||||
pv_entities:
|
||||
name: "Hardware · Zonneproductie-entiteiten, één per omvormer (W)"
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Gebruik dit in plaats van de losse entiteit hierboven wanneer uw dak MEERDERE zonne-omvormers heeft: voeg één rij per omvormer toe, elk verwijzend naar haar eigen vermogenssensor, en de add-on registreert het totaal van de installatie. De eenheid van elke entiteit wordt afzonderlijk gelezen, dus een omvormer in kW naast een in W telt correct op. ⚠️ ALS U DIT INVULT WORDT DE LOSSE ENTITEIT HIERBOVEN GENEGEERD - maak die leeg, anders verschijnt er een waarschuwing op de statuspagina die dat meldt. ⚠️ EN ALS ÉÉN VAN DEZE ENTITEITEN NIETS MEER DOORGEEFT, IS ER GEEN TOTAAL: de sensor gaat naar 'unknown' en de geschiedenis bewaart een gat, in plaats van de som van de entiteiten die wél antwoordden te publiceren. Twee omvormers van de drie tellen op tot een kleiner getal dat precies op een echt getal lijkt, en een week later leest dat als 'er was minder zon dan er was' - net de conclusie die deze registratie moet kunnen testen. De statuspagina noemt welke entiteit ontbreekt, waarom, en wat de andere doorgaven. 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