TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s

0.11.0 shipped `pv_entity` as a single string. The reference site's owner
reported within minutes of it going live that they have several PV inverters
and could configure one - which is ordinary for a multi-string roof, not a
property of one house. `pv_entities` is the roster and the row carries the
SITE total.

⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`,
and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL
ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no
irradiance" conclusion the channel exists to TEST. So `pv.total()` answers
`None` the moment any configured entity is absent: the topic carries HA's own
`None`, the entity goes to `unknown`, the recorder records a GAP. The
alternative (the partial sum beside a contributor count) was implemented and
run as mutation N1b; it loses because the MQTT row is read COLD a week later by
an attribution that would believe it, while the qualifying count would live in a
second entity with its own history. The partial information is not thrown away -
the STATUS PAGE names every entity that reported, with its watts, and every one
that did not, with its reason, because that surface is read LIVE by a human who
wants the diagnostic.

⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is
`is None`, never falsiness - a roster that blanked the site on a zero would
report an absence on every clear evening.

THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list:
tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an
0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and
deepmerge's list strategy is `override` so a `[]` default does not rescue it -
the live add-on would not start. `match(.*)?` does accept both shapes and is
rejected for a different, measured reason: it validates nothing (a string, a
list and the integer 417 all pass) and renders as a plain text box. So
`pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for
`inverters:`, with a status caution saying which key won - a union would
double-count the inverter the old key still names.

STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than
replaced and now grades five claims over three document sets. The control hash
is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the
roster control document is byte-identical between the two trees. 2/5 is a claim
TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly
as 0.11.0 does" is a real cross-tree identity over control AND telemetry.

⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit
24: a discriminator defined as "the baseline lacks this feature" dies on the
release that ships the feature, and dies as a false refusal.

Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B
(`sum` -> `max`, the same three characters) and was run on a fresh copy with
`cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO
contributions in two units with absence driven at EVERY position - a member
contributing 0 W could go absent without moving the sum of the rest, which is
habit 23 with a coinciding value in the arithmetic rather than in the plant.

31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0,
so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG
entry is wanted and deliberately not written - 11b forbids `## Unreleased` and
the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the
material it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-24 22:13:25 +02:00
co-authored by Claude Opus 5
parent cccb2fa07b
commit 0ef9565071
13 changed files with 1893 additions and 125 deletions
@@ -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.
+71 -2
View File
@@ -139,7 +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. Your **solar** inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
| `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
@@ -416,6 +417,11 @@ 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,
@@ -476,7 +482,7 @@ believed a week later.
| what happened | `sensor.goodwe_pv_power` | on the status page |
|---|---|---|
| `pv_entity` not set | the entity does not exist | no row at all |
| 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 |
@@ -490,6 +496,69 @@ 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
+59
View File
@@ -923,6 +923,65 @@ Cheap habits fall out of it, and all of them are now used in this repository:
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
+117 -29
View File
@@ -887,8 +887,13 @@ class Controller:
self.pv_w = None
# Why there is no reading, in words the page prints - or, on a good
# read, the UNIT the value came in. Never empty.
self.pv_why = ("not configured" if not self.pv_entity
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
@@ -1669,20 +1674,29 @@ class Controller:
# -- io ------------------------------------------------------------------
@property
def pv_entity(self) -> str:
"""The configured PV production entity, or `""`. TEL-11.
def pv_roster(self) -> tuple:
"""Every configured PV production entity, or `()`. TEL-11/TEL-12.
⚠️ ONE READING OF THE OPTION, and `mqtt.optional_keys()` is graded
against it: the thing that decides whether the row is ANNOUNCED and the
thing that decides whether it is READ have to be the same predicate, or
the add-on ends up with an entity nothing feeds or a topic nothing
declared. `announced_units()` in mqtt.py is the same argument for the
roster, in its own words.
⚠️ 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 str(self.o.get("pv_entity") or "").strip()
return pv_read.entities(self.o)
async def _read_pv(self) -> None:
"""This cycle's PV production, or an ABSENCE with a reason. TEL-11.
"""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
@@ -1697,8 +1711,18 @@ class Controller:
there is no magnitude heuristic that can separate 30 W of dusk from
30 kW of noon.
"""
self.pv_w, self.pv_why = pv_read.watts(
await self.hass.state(self.pv_entity))
# ⚠️ 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.
@@ -1744,11 +1768,11 @@ class Controller:
# serially and stays that way because it is a CONTROL input (the
# capacity guard sizes off it), so the site is entitled to wait for it.
#
# ⚠️ AND AN UNSET `pv_entity` APPENDS NOTHING. Not a coroutine, not an
# ⚠️ 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_entity:
if self.pv_roster:
_reads.append(self._read_pv())
await asyncio.gather(*_reads)
if o.get("peak_forecast_entity"):
@@ -4472,7 +4496,13 @@ class Controller:
# goes to `unknown` and the recorder records a GAP - which is how a
# reader of a week of history tells "no PV reported" from "PV reported
# 0 W". The second never reaches the broker at all, because an install
# that never set `pv_entity` has no such entity announced.
# 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
@@ -5000,7 +5030,7 @@ class Controller:
out.append({"ok": False, "warn": True,
"text": "no peak forecast: maintenance charging is not capacity-capped"})
# --- TEL-11 ----------------------------------------------------------
# ⚠️ NO ROW AT ALL WHEN `pv_entity` IS UNSET, AND THAT IS A DELIBERATE
# ⚠️ 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
@@ -5013,20 +5043,78 @@ class Controller:
# battery site into NOT READY would be observability with a foot in the
# control path. The reason comes from `pv.watts()` rather than being
# re-derived here, so the page cannot disagree with the reading.
if self.pv_entity:
if self.pv_w is None:
#
# --- 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": f"PV production: no reading from "
f"{self.pv_entity} - {self.pv_why}"})
"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: 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.
# 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"{self.pv_entity} (reported in "
f"{self.pv_why})"})
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:
@@ -5152,7 +5240,7 @@ class Controller:
# question it answers is the one those two cannot: at "Grid 0 W,
# Battery 0 W" the site is either becalmed or throwing away every
# watt it makes, and only this row separates them.
*(() if not self.pv_entity else ((("PV", w(self.pv_w)),))),
*(() 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)),
@@ -5428,7 +5516,7 @@ async def amain() -> None:
omit=() if is_enabled(opts) else ("p1_age",),
# TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE
# OPTION - `Controller.publish()` gates the row on this same
# call. An install that never set `pv_entity` announces exactly
# 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
+21 -1
View File
@@ -169,8 +169,28 @@ def optional_keys(opts: dict) -> tuple:
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.
"""
return ("pv",) if str(opts.get("pv_entity") or "").strip() else ()
# ⚠️ 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"
+145 -3
View File
@@ -1,12 +1,28 @@
"""TEL-11 - the site's PV production, read from a Home Assistant entity.
"""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_entity` therefore takes whatever
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
@@ -28,7 +44,11 @@ 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.
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:
@@ -115,3 +135,125 @@ def watts(state: dict | None) -> tuple:
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)
+66
View File
@@ -174,6 +174,47 @@ options:
# 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
# ==========================================================================
@@ -906,6 +947,31 @@ schema:
# 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
# ==========================================================================
+7 -2
View File
@@ -716,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.
+512 -8
View File
@@ -34,6 +34,17 @@ modules can even see the material). `tools/tel11_pv_trace.py` is the other half:
it hashes a control document on THIS tree and on `release/1.0`, with the option
set and unset.
⚠️ AND TEL-12 ADDED A SECOND ABSENCE, ONE LAYER UP. `pv_entities` lets a site
declare several PV inverters and the row carries the SITE total, so there is now
a state TEL-11 never had: SOME configured, SOME reporting. The sum of the ones
that answered is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE, which is the
"there was less sun than there was" conclusion the channel exists to test. Parts
8-10 are that, driven: absence at EVERY position of the roster, each with the
partial it would have produced computed beside it, and the payload BYTES asserted
against both. Part 8's mirror matters just as much - a member honestly reporting
0 W is SUMMED, because a roster that blanked the site on a zero would report an
absence every clear evening.
Part 1 `pv.watts()`: the unit table, and every refusal, by name.
Part 2 the shipped read path, including what a FAILED read does to the
value a good one left behind.
@@ -41,7 +52,11 @@ set and unset.
Part 4 announced and published are the same key, from the same predicate.
Part 5 the operator surfaces, and that an unset option adds none of them.
Part 6 ⭐ NOT A CONTROL INPUT.
Part 7 what this file cannot see, with sizes, and the mutation table.
Part 7 TEL-12: the ROSTER, and that ONE predicate decides it.
Part 8 ⭐⭐ THE SUM, AND WHAT ONE ABSENT MEMBER DOES TO IT.
Part 9 ⭐ publication at a roster of three, as payload bytes.
Part 10 the operator surfaces at three - and 0.11.0's, unchanged, at one.
Part 11 what this file cannot see, with sizes, and both mutation tables.
⚠️ MUTATING A MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
@@ -193,8 +208,16 @@ class _Hass:
reach that failure - MUTATION.md habit 9's shape, one surface over.
"""
def __init__(self, grid=0.0, soc=50.0, states=(), follow=False):
def __init__(self, grid=0.0, soc=50.0, states=(), follow=False,
by_entity=None):
self.grid = grid
# ⚠️ TEL-12. `by_entity` IS `{entity: [state per cycle]}` AND IT IS WHAT
# A ROSTER NEEDS. The `states` script above answers the same thing to
# every entity, which at a roster of three cannot separate "the total is
# the sum" from any other reduction over three equal numbers, and cannot
# drive ONE member absent at all - habit 10's fixture problem and habit
# 23's no-op problem in one. Each entity gets its own script here.
self.by_entity = dict(by_entity or {})
# ⚠️ `follow` MAKES THE METER RESPOND TO THE BATTERY, and part 6 needs
# it. With a FIXED meter the error never shrinks, the integrator winds
# to its bound and the command rides `slew_w` to the rail on every
@@ -220,6 +243,11 @@ class _Hass:
async def state(self, entity):
self.state_calls.append((self.n, entity))
if self.by_entity:
script = self.by_entity.get(entity)
if not script:
return None
return script[min(self.n, len(script) - 1)]
if not self.states:
return None
return self.states[min(self.n, len(self.states) - 1)]
@@ -233,10 +261,12 @@ class _Hass:
return True
def run(cycles=3, grid=0.0, states=(), auto=True, follow=False, **over):
def run(cycles=3, grid=0.0, states=(), auto=True, follow=False,
by_entity=None, **over):
"""`cycles` real read/cycle/publish turns. Returns (controller, hass, pub)."""
async def go():
hass = _Hass(grid=grid, states=states, follow=follow)
hass = _Hass(grid=grid, states=states, follow=follow,
by_entity=by_entity)
pub = _Pub()
c = Controller(dict(BASE_OPTS, **LIVE, **over), hass, _Store(), pub,
stop=asyncio.Event())
@@ -253,6 +283,48 @@ def run(cycles=3, grid=0.0, states=(), auto=True, follow=False, **over):
PV_ON = {"pv_entity": "sensor.pv_power"}
# --- TEL-12's fixtures, and their VALUES are the whole design ----------------
# ⚠️ TWO ROSTERS OVER THE SAME THREE ENTITIES, BECAUSE THE TWO CLAIMS NEED
# DIFFERENT MAGNITUDES AND ONE FIXTURE CANNOT SERVE BOTH.
#
# `BIG` is part 6's, and it is sized so every PARTIAL sum would still MOVE the
# loop. Part 6's identity is "the commands do not change"; habit 23 says that is
# evidence only where the mutation could have changed them, and the mutation
# here has two forms - subtract the TOTAL, or subtract a PARTIAL. So the smallest
# partial (3500 W, west dead) is still far larger than the 600 W import the loop
# settles on, and the shift preconditions below are measured at BOTH ends.
#
# `FULL` is parts 8-10's, and it is sized so the SUM is separable from every
# other reduction over three members (habit 10) and from every partial of itself:
# 1200 + 2100 + 450 = 3750; max 2100, min 450, first 1200, last 450,
# mean 1250, count 3, and the three partials 2550 / 1650 / 3300.
# ⚠️ NONE OF THE THREE IS ZERO, AND THAT IS THE HABIT-23 TRAP THIS TICKET IS
# ABOUT. A member reading 0 W could go absent without changing the sum of the
# rest - partial and full would coincide - so a fixture that drove absence on a
# zero member could not tell "published the absence" from "published the partial
# sum". Every member here contributes a distinct non-zero number, and absence is
# driven at EVERY POSITION rather than at one.
# ⚠️ AND ONE OF THEM IS IN kW, so "the sum is correct against their own units" is
# exercised rather than assumed.
E1, E2, E3 = "sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"
PV_MANY = {"pv_entities": [E1, E2, E3]}
BIG = {E1: [state("1500", "W")], E2: [state("4.5", "kW")],
E3: [state("2000", "W")]}
BIG_TOTAL = 8000.0
BIG_HOLE = dict(BIG, **{E2: [None]}) # the partial would be 3500 W
FULL = {E1: [state("1200", "W")], E2: [state("2.1", "kW")],
E3: [state("450", "W")]}
FULL_TOTAL = 3750.0
# absence at EVERY position of the roster, with the partial each one would have
# produced - COMPUTED from the fixture rather than typed, so a change to the
# values above cannot leave a stale literal standing (habit 12).
WATTS_OF = {E1: 1200.0, E2: 2100.0, E3: 450.0}
HOLES = {miss: (dict(FULL, **{miss: [None]}),
sum(v for k, v in WATTS_OF.items() if k != miss))
for miss in (E1, E2, E3)}
c_off, h_off, p_off = run(cycles=3)
check("⚠️ UNSET IS NOT READ AT ALL: with no `pv_entity` the add-on makes NO "
"state call, for any entity, on any cycle - not a read that fails "
@@ -533,14 +605,65 @@ check("⭐ PRECONDITION (habit 23): the same stimulus with the METER shifted by
"flattened any input",
_h_shift.writes != _w_big
and abs(_c_shift.target - _c_big.target) > 4000)
# ⚠️ AND TEL-12 ADDS A SECOND MAGNITUDE TO THE SAME QUESTION. A leak of the
# PARTIAL total is a different mutation from a leak of the full one, and it
# leaks a SMALLER number - 3500 W here, not 8000 - so the precondition has to be
# asked at that end too. If a 3500 W shift could not move this stimulus, "the
# commands did not move under a partial total" would be free.
_c_shift_min, _h_shift_min, _ = run(cycles=40, grid=600.0 - 3500.0, follow=True)
print(f" meter shifted by the SMALLEST partial (3500 W) -> settled at "
f"{_c_shift_min.target:g} W")
check("⭐ PRECONDITION (habit 23, at the partial): a shift of 3500 W - the "
"smallest partial any single dead member of the roster could produce - "
"also rests somewhere visibly different. So the roster identity below "
"covers a PARTIAL leak and not only a full one",
_h_shift_min.writes != _w_none
and abs(_c_shift_min.target - _c_none.target) > 1500)
check("...and `inputs_ok` does not move either: a PV sensor that never answers "
"is not a missing INPUT, because nothing computes with it. An install "
"whose solar integration breaks keeps regulating",
_c_none.inputs_ok is True and _c_absent.inputs_ok is True)
# --- TEL-12: AND THE SAME AT A ROSTER OF THREE -------------------------------
# ⚠️ THE ROSTER IS THE NEW WAY THIS COULD BREAK, so it is driven rather than
# argued: three entities summing to 8000 W, and the SAME three with the biggest
# of them dead (a 3500 W partial), against the SAME settled 600 W import. If any
# term of the control law had learned to read the total - or to read a PARTIAL
# total - the commands would move between these two, and between either and
# `unset`.
_c_three, _h_three, _ = run(**_stim, by_entity=BIG, **PV_MANY)
_c_hole, _h_hole, _ = run(**_stim, by_entity=BIG_HOLE, **PV_MANY)
print(f" 3 entities ({BIG_TOTAL:g} W): {_h_three.writes} -> settled at "
f"{_c_three.target:g} W")
print(f" 3 entities, west dead : {_h_hole.writes} -> settled at "
f"{_c_hole.target:g} W")
check("⭐ AND AT A ROSTER OF THREE, THE SAME IDENTITY: every write and the "
"resting target are bit-identical to `unset` with three healthy entities "
"summing to 8000 W AND with the biggest of the three dead. A partial "
"total "
"reaches no decision either - which is a separate claim from the first, "
"because a partial sum is a DIFFERENT number and could have moved a loop "
"the full one did not",
_h_three.writes == _w_none and _h_hole.writes == _w_none
and _c_three.target == _c_none.target == _c_hole.target)
check("PRECONDITION (habit 23, at a roster): the three entities really did "
"report, and the holed run really did lose one - so the identity above "
"is over two DIFFERENT PV states and not over two runs that read nothing",
_c_three.pv_w == BIG_TOTAL and _c_hole.pv_w is None
and len(_c_three.pv_parts) == len(_c_hole.pv_parts) == 3
and len(_h_three.state_calls) == 3 * _stim["cycles"])
check("...and `inputs_ok` is still True with a hole in the roster: an install "
"whose second solar inverter drops off the network keeps regulating its "
"battery, because nothing computes with production",
_c_three.inputs_ok is True and _c_hole.inputs_ok is True)
# --- the structural half, and its scope is COMPUTED (habit 12) ---------------
APP = sorted((HERE / "app").glob("*.py"))
NEEDLES = ("pv_entity", "pv_w", "pv_why", "optional_keys", "ABSENT_AS_UNKNOWN")
NEEDLES = ("pv_entity", "pv_w", "pv_why", "optional_keys", "ABSENT_AS_UNKNOWN",
# TEL-12's own names. `pv_entities` and `pv_entity` share a prefix so
# the first would have caught it anyway; spelled out because a scope
# that only works by accident stops working when a name is renamed.
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored")
def touches(src: str) -> bool:
@@ -579,7 +702,323 @@ check("...and the scan CAN go red - a module body with the material in it is "
# ==============================================================================
print()
print("7. what this file CANNOT see, with sizes - and the mutation table")
print("7. TEL-12: the ROSTER - which entities, from ONE predicate")
# ==============================================================================
# ⚠️ `pv_entities` REPLACES `pv_entity`. `app/pv.py`'s `entities()` says why at
# length; the short version is that the install this card exists for HAS
# `pv_entity` set, so a UNION rule would double-count that inverter the moment
# the operator adds all of them to the new list. A doubled total is the same
# defect as a partial one, pointing the other way.
print(" opts -> roster")
ROSTER_CASES = [
("neither key", {}, ()),
("pv_entity only (0.11.0)", PV_ON, ("sensor.pv_power",)),
("pv_entities only", PV_MANY, (E1, E2, E3)),
("BOTH - the list REPLACES the single key", dict(PV_ON, **PV_MANY),
(E1, E2, E3)),
("pv_entities empty falls back to pv_entity",
dict(PV_ON, pv_entities=[]), ("sensor.pv_power",)),
("whitespace-only members are not entities",
{"pv_entities": [" ", "", None, E1]}, (E1,)),
("whitespace-only pv_entity is not an entity", {"pv_entity": " "}, ()),
("a repeated id is ONE entity, order preserved",
{"pv_entities": [E1, E2, E1, E3]}, (E1, E2, E3)),
("order is the operator's, not sorted",
{"pv_entities": [E3, E1, E2]}, (E3, E1, E2)),
]
_bad_r = []
for label, opts, want in ROSTER_CASES:
got = pv.entities(opts)
print(f" {label:<42} -> {got}")
if got != want:
_bad_r.append((label, got, want))
check(f"⭐ the roster is what the operator declared, against literals - the list "
f"REPLACES the single key, whitespace is not an entity, a repeated id is "
f"counted ONCE and the order is theirs. Wrong: {_bad_r}", not _bad_r)
check("...and the ANNOUNCEMENT predicate is the SAME function, not a second "
"reading of the options. TEL-11 spelled it twice and got away with it "
"because both spellings were `bool()` of one string; with a list in play, "
"two spellings is how a row gets announced off one key and SUMMED off "
"the other",
all(mqtt.optional_keys(o) == (("pv",) if w else ())
for _l, o, w in ROSTER_CASES)
and "pv" in announced(PV_MANY) and "pv" not in announced({}))
check("...and the controller reads that same roster - three entities named, in "
"order, once per cycle each, and NOT the ignored `pv_entity` beside them",
run(cycles=2, by_entity=FULL, **dict(PV_ON, **PV_MANY))[1].state_calls
== [(0, E1), (0, E2), (0, E3), (1, E1), (1, E2), (1, E3)])
check("...and `entity_is_ignored()` is the one place the both-set condition is "
"spelled, so the caution and the rule cannot disagree",
pv.entity_is_ignored(dict(PV_ON, **PV_MANY)) is True
and pv.entity_is_ignored(PV_ON) is False
and pv.entity_is_ignored(PV_MANY) is False
and pv.entity_is_ignored(dict(PV_ON, pv_entities=[" "])) is False)
# ==============================================================================
print()
print("8. ⭐⭐ THE SUM, AND WHAT ONE ABSENT MEMBER DOES TO IT")
# ==============================================================================
# THIS IS THE TICKET. 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 it reads as "there was less PV than there was" -
# which is the "no irradiance" conclusion the channel exists to TEST.
def total_of(by_entity, roster=(E1, E2, E3)):
"""`pv.total()` over one cycle's worth of scripted states."""
return pv.total([(e, by_entity.get(e, [None])[0]) for e in roster])
_w, _why, _parts = total_of(FULL)
print(f" 3 healthy -> {_w} W ({_why})")
for _e, _pw, _py in _parts:
print(f" {_e:<18} {_pw} W (read in {_py})")
# habit 2: the literal, not `sum(WATTS_OF.values())`.
check("⭐ THREE HEALTHY ENTITIES GIVE THE SUM, against a literal: "
"1200 W + 2.1 kW + 450 W is 3750.0 W. Each entity's OWN unit is read, so "
"a kW inverter beside two W ones adds up correctly rather than being "
"1200 + 2.1 + 450",
_w == 3750.0 and _parts == [(E1, 1200.0, "W"), (E2, 2100.0, "kW"),
(E3, 450.0, "W")])
# habit 10: three members cannot separate every reduction, so the ones they CAN
# separate are computed and printed, and the fixture was chosen to separate them.
_FAMILY = {"max": 2100.0, "min": 450.0, "first": 1200.0, "last": 450.0,
"mean": 1250.0, "count": 3.0, "sum of the first two": 3300.0,
"sum of the last two": 2550.0, "product/1000": 1134.0}
print(f" the reductions this fixture SEPARATES from the sum: "
f"{sorted(_FAMILY)}")
check(f"PRECONDITION (habit 10): every one of those reductions over these three "
f"members is a DIFFERENT number from 3750, so the check above grades the "
f"SUM and not 'some function of three numbers'. Colliding: "
f"{[k for k, v in _FAMILY.items() if v == 3750.0]}",
all(v != 3750.0 for v in _FAMILY.values()) and len(_FAMILY) >= 8)
# ⚠️ ABSENCE AT EVERY POSITION OF THE ROSTER, WHICH IS HABIT 10's FIXTURE-AS-A-
# SET. One roster with the hole in the middle cannot separate "any absent" from
# "the last one absent", "the first one absent", or a slice.
print(" member absent partial it WOULD have produced -> published")
_hb = []
for _miss, (_script, _partial) in HOLES.items():
_w2, _why2, _parts2 = total_of(_script)
print(f" {_miss:<18} {_partial:>8.0f} W -> {_w2} "
f"({_why2})")
if _w2 is not None or _miss not in _why2 or len(_parts2) != 3:
_hb.append(_miss)
check(f"⭐⭐ ONE ABSENT MEMBER MAKES THE TOTAL ABSENT, AT EVERY POSITION OF THE "
f"ROSTER - `None`, never the partial sum of the ones that answered, and "
f"the reason NAMES the entity that is missing. Wrong: {_hb}", not _hb)
check("⭐⭐ ...and the published value is NOT ANY OF THOSE PARTIALS. Stated as "
"its own check because 'is None' would also be satisfied by a total that "
"went absent for some unrelated reason, and because a partial sum is the "
"one wrong answer that cannot be spotted downstream",
all(total_of(_s)[0] not in (_p, 3750.0)
for _s, _p in HOLES.values()))
check(f"PRECONDITION: those partials are PLAUSIBLE READINGS and they are all "
f"DIFFERENT from the true total and from each other - "
f"{sorted(p for _s, p in HOLES.values())} against 3750. A fixture whose "
f"absent member contributed 0 W could not tell an absence from a partial "
f"sum at all, because the two would be the same number (habit 23)",
sorted(p for _s, p in HOLES.values()) == [1650.0, 2550.0, 3300.0]
and all(v > 0 for v in WATTS_OF.values()))
check("...and the partial each hole would have produced is DERIVED FROM THE "
"FIXTURE, not typed beside it, so changing a value above cannot leave a "
"stale literal standing (habit 12)",
HOLES[E1][1] == WATTS_OF[E2] + WATTS_OF[E3]
and HOLES[E2][1] == WATTS_OF[E1] + WATTS_OF[E3]
and HOLES[E3][1] == WATTS_OF[E1] + WATTS_OF[E2])
# ⚠️ AND THE MIRROR TRAP, WHICH WOULD DEFEAT THE CHANNEL FROM THE OTHER END.
_zero_script = dict(FULL, **{E2: [state("0", "W")]})
_wz, _whyz, _ = total_of(_zero_script)
print(f" one member honestly reporting 0 W -> {_wz} W ({_whyz})")
check("⭐ ZERO WATTS IS STILL A READING AT A ROSTER OF THREE: a member honestly "
"reporting 0 W is SUMMED, not treated as absent - 1200 + 0 + 450 = 1650. "
"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. The test is `is None`, never falsiness",
_wz == 1650.0 and _whyz == "3 entities")
check("...and ALL of them absent is an absence too, with all three named - not "
"an empty sum of 0.0, which is what `sum([])` would have given",
total_of({})[0] is None
and all(e in total_of({})[1] for e in (E1, E2, E3)))
check("...and a REFUSED member counts as absent exactly like a dead one: a "
"sensor reporting 1234 % is not 1234 W and is not 0 W either",
total_of(dict(FULL, **{E3: [state("1234", "%")]}))[0] is None
and "'%'" in total_of(dict(FULL, **{E3: [state("1234", "%")]}))[1])
check("...and the reason SAYS how many of how many reported, so an operator "
"reading the page knows the size of the hole",
"2 of 3" in total_of(HOLES[E1][0])[1]
and "0 of 3" in total_of({})[1])
# ⚠️ AND AT A ROSTER OF ONE IT IS `watts()` AND NOTHING ELSE, which is an
# ACCEPTANCE CRITERION and not a nicety - see part 10 for the surfaces.
check("⭐ AT A ROSTER OF ONE, `total()` IS `watts()`: the same value AND the "
"same `why` string, passed through rather than re-composed, on a good "
"read and on every refusal alike. This is what makes 0.11.0's "
"single-entity sentences byte-identical rather than merely similar",
all(pv.total([("sensor.x", st)])[:2] == pv.watts(st)
for st in [state("3210", "W"), state("0", "kW"), state("1234", "%"),
state("unavailable"), state(""), state("9", ""), None])
and pv.total([]) == (None, "not configured", []))
# ==============================================================================
print()
print("9. ⭐ publication at a roster of three, as PAYLOAD BYTES")
# ==============================================================================
# Part 3 did this for one entity. The new state is "several configured, one of
# them gone", and the number that must NOT appear on the topic is a specific
# plausible number rather than a wrong shape.
_m_full = run(cycles=1, by_entity=FULL, **PV_MANY)[2].frames[-1]
print(f" 3 healthy : {_m_full['pv']!r} -> wire {pv_msg(_m_full)}")
check("⭐ THREE HEALTHY: one topic, one number, the SITE total - 3750.0. There "
"is deliberately no per-entity topic: 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",
pv_msg(_m_full) == ("goodwe_ctl/pv", "3750.0", False)
and [m for m in wire(_m_full) if m[0].startswith("goodwe_ctl/pv")]
== [pv_msg(_m_full)])
_holed = []
for _miss, (_script, _partial) in HOLES.items():
_fr = run(cycles=1, by_entity=_script, **PV_MANY)[2].frames[-1]
_msg = pv_msg(_fr)
print(f" {_miss} dead -> wire {_msg} (partial would have been "
f"{_partial:.0f} W -> {str(_partial)!r})")
if _msg != ("goodwe_ctl/pv", "None", False) or str(_partial) in str(_msg):
_holed.append(_miss)
check(f"⭐⭐ THIS IS THE ACCEPTANCE CRITERION. With one of three entities gone "
f"the topic carries Home Assistant's own `None` - the entity goes to "
f"`unknown` and the recorder records a GAP - and NOT the partial sum, at "
f"any position of the roster. A quietly understated total is worse than "
f"no total: a gap makes OPT-10's attribution say 'I do not know', a "
f"partial makes it say 'there was less sun than there was'. Wrong: "
f"{_holed}", not _holed)
check("⭐ AND IT IS THE SAME LINE TEL-11 ALREADY SHIPPED, which is the point: "
"`ABSENT_AS_UNKNOWN` is still exactly one key and `publish()` still drops "
"every other `None`. TEL-12 EXTENDED that rule to a roster rather than "
"weakening it - no new absence mechanism was invented",
mqtt.ABSENT_AS_UNKNOWN == frozenset({"pv"})
and wire({"circulation": None, "grid": None}) == []
and pv_msg(_m_full) != pv_msg(
run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)[2].frames[-1]))
check("...and a roster whose members ALL answer 0 W still publishes a NUMBER, "
"so the gap above means 'no reading' and never 'no production'",
pv_msg(run(cycles=1, **PV_MANY,
by_entity={e: [state("0", "W")] for e in (E1, E2, E3)}
)[2].frames[-1]) == ("goodwe_ctl/pv", "0.0", False))
check("...and the announcement is byte-identical at a roster of three to what "
"one entity announces - one `sensor.goodwe_pv_power`, same unique_id, so "
"an install that MOVES from one entity to three keeps its history rather "
"than starting a new entity beside a dead one",
mqtt.discovery_payloads(include=mqtt.optional_keys(PV_MANY))
== mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON)))
# ==============================================================================
print()
print("10. the operator surfaces at a roster of three - and 0.11.0 at one")
# ==============================================================================
_c_many, _, _ = run(cycles=1, by_entity=FULL, **PV_MANY)
_many_row = [t for t in rows_of(_c_many) if t.startswith("PV production")]
print(f" {_many_row}")
# habit 14 at a roster: the total in its own slot and EVERY contributor in its
# own slot with its own watts and its own unit, so a sum that disagrees with the
# addends printed beside it is visible in the sentence.
check("a healthy roster says the SITE total, how many entities it came from, "
"and then every entity with its own watts and its own unit - so a total "
"that disagrees with the numbers printed beside it is visible rather than "
"merely present",
_many_row == ["PV production: 3750 W from 3 entities "
f"({E1} 1200 W in W, {E2} 2100 W in kW, "
f"{E3} 450 W in W)"]
and ("PV", "3 750 W") in page_rows(_c_many))
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
_gap_row = [r for r in _c_gap.checks() if r["text"].startswith("PV production")]
print(f" {[r['text'] for r in _gap_row]}")
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, names the "
"entity that is missing and why - AND names what the others reported. The "
"partial information is not thrown away, it is put where a HUMAN reading "
"the page live will see it, and kept out of the number a recorder hands "
"back cold a week later",
len(_gap_row) == 1
and _gap_row[0]["text"] == (
"PV production: NO SITE TOTAL - only 2 of 3 entities are reporting "
"and a partial sum would understate production: "
f"{E2} - no state (entity missing, or the read failed). "
f"Reporting: {E1} 1200 W, {E3} 450 W"))
check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at "
"one entity: `status_payload()` builds NOT READY out of rows that are "
"neither ok nor warn, and a telemetry channel that could put a battery "
"site into NOT READY would be observability with a foot in the control "
"path",
_gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]}
and _c_gap.status_payload()["level"] != "bad"
and _c_gap.status_payload()["banner"]
== c_off.status_payload()["banner"])
check("...and the page row is an em dash, not a smaller number: the one row an "
"operator glances at cannot show a partial total either",
("PV", "—") in page_rows(_c_gap)
and ("PV", "3 300 W") not in page_rows(_c_gap))
# ⚠️ THE ACCEPTANCE CRITERION THE REST OF THIS FILE CANNOT STATE: at a roster of
# ONE the surfaces are 0.11.0's, character for character. `c_on` above is the
# 0.11.0 configuration (`pv_entity` set) and this is the same site declared the
# new way.
_c_one_new, _, _ = run(cycles=3, by_entity={E1: [state("3210", "W")]},
pv_entities=[E1])
print(f" one entity via pv_entities : "
f"{[t for t in rows_of(_c_one_new) if 'PV' in t]}")
check("⭐ ONE ENTITY IS 0.11.0's PATH, however it was declared: the status "
"sentence, the page row and the published value are the same shape "
"through `pv_entity` and through a one-member `pv_entities`, and the "
"sentence is 0.11.0's own - `(reported in W)`, not `from 1 entities`",
[t.replace(E1, "sensor.pv_power")
for t in rows_of(_c_one_new) if "PV" in t]
== [t for t in rows_of(c_on) if "PV" in t]
== ["PV production: 3210 W from sensor.pv_power (reported in W)"]
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
.index("PV")] == ("PV", "3 210 W"))
check("...and a refused read at a roster of one is 0.11.0's refusal sentence "
"too, not the roster one - `no reading from <entity> - <reason>`",
[r["text"] for r in run(cycles=1, pv_entities=[E1],
by_entity={E1: [state("1234", "%")]})[0].checks()
if r["text"].startswith("PV production")]
== [f"PV production: no reading from {E1} - unit '%' is not one of "
"mW, W, kW, MW, GW, TW - it cannot be read in watts"])
# ⚠️ AND THE REPLACEMENT IS STATED RATHER THAN SILENT.
_c_both, _, _ = run(cycles=1, by_entity=FULL, **dict(PV_ON, **PV_MANY))
_ign = [r for r in _c_both.checks() if "IGNORED" in r["text"]]
print(f" {[r['text'] for r in _ign]}")
check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
"entities are summed instead. `entities()` REPLACES rather than unions - "
"which is right, because a union would double-count the inverter the old "
"key still names - but a replacement nobody is told about is the 'one of "
"which silently wins' this config refuses elsewhere",
len(_ign) == 1 and _ign[0]["ok"] is False and _ign[0]["warn"] is True
and _ign[0]["text"] == ("PV production: `pv_entities` is set, so "
"`pv_entity` is IGNORED - the 3 entities in the "
"list are what is summed. Clear `pv_entity` to "
"remove this caution")
and _c_both.pv_w == 3750.0)
check("...and that caution appears ONLY when both are set - not on the 0.11.0 "
"configuration, not on a list-only one, and not on an install with "
"neither",
not [r for r in c_on.checks() if "IGNORED" in r["text"]]
and not [r for r in _c_many.checks() if "IGNORED" in r["text"]]
and not [r for r in c_off.checks() if "IGNORED" in r["text"]])
check("...and an install with NO PV roster still adds no row of any kind - the "
"whole of TEL-12 is invisible at the shipped default",
not [t for t in rows_of(c_off) if "PV" in t]
and not [r for r in page_rows(c_off) if r[0] == "PV"]
and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
and run(cycles=1)[1].state_calls == []
and run(cycles=1, pv_entities=[])[1].state_calls == []
and run(cycles=1, pv_entities=[" "])[1].state_calls == [])
# ==============================================================================
print()
print("11. what this file CANNOT see, with sizes - and the mutation table")
# ==============================================================================
# habit 13. An enumerated list with an honest gap is worth more than a claim of
# completeness a reviewer falsifies in ten minutes.
@@ -595,8 +1034,23 @@ for line in [
" the card creates is FOR - see DECISIONS/TEL-11-pv-production.md §2.",
"The control-path claim OUTSIDE app/. `tools/`, the ESPHome firmware and",
" the ingress page are not scanned by part 6's census.",
"A roster of more than one. Every fixture here is the shipped roster of",
" one; the row is a SITE row and there is nothing per-unit to separate.",
"A roster of more than one INVERTER (CFG-04's battery kind). Every",
" fixture here is the shipped roster of one; the PV roster IS driven, at",
" three, but no fixture combines two battery inverters with three PV ones.",
"TEL-12: THE SUPERVISOR'S OWN VALIDATOR, which is what decided the SHAPE",
" of the option. `pv_entity` could not become a list because 2026.08.0",
" refuses an 0.11.0 install's stored STRING against a list schema, and no",
" check in this directory can run that validator. It is measured by",
" tools/supervisor_shape_probe.py section G, against the release image,",
" and the transcript is in DECISIONS/TEL-12-several-pv-inverters.md S1.",
"TEL-12: A ROSTER LARGER THAN THREE. Three is the smallest size at which",
" the sum separates from max/min/first/last/mean AND absence can be",
" driven at every position; nothing here says what twenty entities do to",
" a cycle's latency, and the read is GATHERED for exactly that reason.",
"TEL-12: WHETHER THE GAP SURVIVES HOME ASSISTANT'S LONG-TERM STATISTICS.",
" What is graded is that the add-on publishes `None` rather than a",
" partial. Whether the statistics table then interpolates across the gap",
" is HA's behaviour and OPT-10 will be reading it, not this file.",
"Wall-clock behaviour: no fixture here sleeps, and the read is driven by",
" the caller's loop rather than by the 1 s tick.",
]:
@@ -620,6 +1074,56 @@ for row in [
"2 here + 2 crashes"),
]:
print(f" {row[0]:<48} {row[1]:>5} {row[2]}")
# --- TEL-12's OWN BATTERY -----------------------------------------------------
# ⚠️ EVERY ROW BELOW WAS RUN ON A FRESH `cp -a` OF THE CLEAN WORKTREE, purged,
# never reverted (MUTATION.md habit 18 / TARIFF-09's method), and the suites were
# run in the CI image with `checks.yml`'s own command, one file at a time, under
# a `:ro` mount. Byte deltas are `stat` of the mutated file against the clean one
# and the change was confirmed on disk with `cmp` before the run.
print()
print(" TEL-12 mutation bytes reds")
print(" ------------------------------------------------ ----- --------")
for row in [
("N1 total() returns the partial sum, not an absence", "+42 B",
"6 here + 1 crash"),
("N1b ⭐ THE REJECTED DESIGN: partial sum + a count", "-282 B",
"10 here + the trace"),
("N2 absence tested by falsiness (`not w`)", "-4 B", "2 here"),
("N3 `pv_entities` UNIONS with `pv_entity`", "-27 B", "3 here"),
("N4 the de-duplication dropped", "-15 B", "1 here"),
("N5 `sum` -> `max` over the roster", "+0 B", "6 here"),
("N6 roster-of-one stops passing `why` through", "+13 B", "8 here"),
("N7 optional_keys() back to reading `pv_entity`", "-6 B",
"1 here + 1 crash"),
("N8 `self.grid -= self.pv_w` in read_inputs()", "+106 B",
"3 here + the trace"),
("N9 the roster caution made a `bad` row (NOT READY)", "+1 B", "1 here"),
("N10 `entity_is_ignored()` always False", "+10 B", "2 here"),
("N11 `pv_entities: []` deleted from `options:`", "-18 B",
"2 test_translations + 11 probe"),
]:
print(f" {row[0]:<48} {row[1]:>5} {row[2]}")
print(" ⚠️ N5 IS `+0 B` - THE RISKY DELTA. `sum` and `max` are the same three")
print(" characters, so neither size nor mtime moved and TEST-15's stale-")
print(" bytecode defect would have manufactured a survivor. The run was on a")
print(" fresh purged copy and `cmp` confirmed the edit on disk first.")
print(" ⚠️ N1b IS THE ROW THAT MATTERS AND IT IS NOT A DEFECT - it is the")
print(" OTHER defensible design (publish the partial sum beside a count of")
print(" contributors), implemented and run. The point is that this file and")
print(" the trace tool can TELL THE TWO APART: an unexamined partial sum is")
print(" what the card forbids, so a check that could not see one would be")
print(" decoration. `--compare` answers REFUSED 5/5 naming the exact")
print(" partials on the wire - 2550.0, 1650.0, 3300.0.")
print(" ⚠️ N8's TRACE REDS ARE 52 CELLS ON 2/5, 26 ON 3/5 AND 10 ON 4/5.")
print(" 1/5 stays GREEN and that is correct rather than a miss: with no PV")
print(" entity configured `pv_w` is None and the leak cannot fire, which is")
print(" what makes 1/5 a claim about the DEFAULT and 2/5-4/5 the claims")
print(" about the channel.")
print(" ⚠️ N11 IS GRADED BY A TRANSCRIPT (habit 8), not by a suite in this")
print(" directory: the `pv_entities: []` default is load-bearing for the")
print(" same reason `inverters: []` is, and only Supervisor's own validator")
print(" can say so. Deleting it takes the probe to `11 CASE(S) FAILED`.")
print()
print(" ⚠️ M7 WAS A SURVIVOR ON THE FIRST BATTERY and part 6's note says why:")
print(" the fixture drove the loop to its RAIL, where every input produces")
print(" the same ramp. MUTATION.md habit 23, with a saturated actuator in")
@@ -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.
+398 -77
View File
@@ -1,6 +1,13 @@
"""TEL-11's behaviour-invariance instrument. `tools/tel11_pv_trace.py`
"""TEL-11/TEL-12's behaviour-invariance instrument. `tools/tel11_pv_trace.py`
TEL-11 adds a PV production channel and claims two things a reviewer cannot
⚠️ 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
@@ -12,23 +19,52 @@ check by reading a diff:
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 MATERIAL REACHES IT.
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/tel11-head.json
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/tel11-base.json
python3 tools/tel11_pv_trace.py --compare /tmp/tel11-base.json \\
/tmp/tel11-head.json
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,
@@ -47,7 +83,8 @@ refuses unless:
and cannot react to it. Both, or neither, is ambiguous and is refused.
⚠️ WHAT IT CANNOT SEE, with shapes (habit 13):
* a roster of more than one, and any per-unit surface;
* 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;
@@ -162,6 +199,13 @@ def _pv_state(watts, unit="W"):
"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.
@@ -176,6 +220,51 @@ GRIDS = {
"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),
@@ -189,11 +278,19 @@ PVS = {
CYCLES = 24
async def _run(grid_of, pv_of, pv_set, soc=50.0, stop_at=None):
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 pv_set:
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
@@ -219,35 +316,64 @@ async def _run(grid_of, pv_of, pv_set, soc=50.0, stop_at=None):
return c, hass, pub, ctrl
def documents(pv_set: bool) -> dict:
"""`{"control": …, "telemetry": …}` for one setting of `pv_entity`."""
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 = {}, {}
for gname, gof in GRIDS.items():
for pname, pof in PVS.items():
key = f"{gname}/{pname}"
c, hass, pub, ctrl = loop.run_until_complete(
_run(gof, pof, pv_set))
control[key] = {
"ticks": ctrl,
"writes": hass.writes,
"sha": hashlib.sha256(
json.dumps(ctrl, sort_keys=True).encode()).hexdigest(),
}
rows = c.checks()
tele[key] = {
"frames": pub.frames,
"rows": [[r["ok"], r["warn"], r["text"]] for r in rows],
"n_rows": len(rows),
"page": c.status_payload()["rows"],
"level": c.status_payload()["level"],
"banner": c.status_payload()["banner"],
}
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))
_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()
@@ -258,8 +384,11 @@ def documents(pv_set: bool) -> dict:
loop.close()
# The announcement is telemetry too, and it is the surface an install's
# entity registry is keyed on.
tele["ANNOUNCEMENT"] = _announcement(pv_set)
return {"control": control, "telemetry": tele}
tele["ANNOUNCEMENT"] = _announcement(pv_set, roster)
out = {"control": control, "telemetry": tele}
if partials:
out["partials"] = partials
return out
def _supports_include() -> bool:
@@ -280,16 +409,38 @@ def _supports_include() -> bool:
and hasattr(mqtt, "optional_keys"))
def _announcement(pv_set: bool) -> list:
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 and _supports_include():
if (pv_set or roster) and _supports_include():
payloads = mqtt.discovery_payloads(
include=mqtt.optional_keys({"pv_entity": PV_ENTITY}))
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]
@@ -298,7 +449,12 @@ def _announcement(pv_set: bool) -> list:
def tree() -> dict:
"""sha256 and byte count of every `app/*.py` this run imported from."""
_load()
out = {"supports_include": _supports_include()}
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)]
@@ -309,6 +465,53 @@ 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"))
@@ -319,62 +522,174 @@ def compare(a_path, b_path) -> int:
"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 doc["set"] != doc["unset"]]
if not _reduces_to_unset(doc, "telemetry")]
if len(moved) != 1:
print(f"REFUSED: `set` differs from `unset` on {moved or 'NEITHER'} "
"side. Exactly one side must react to `pv_entity` - the HEAD. "
"Neither means the instrument cannot see this card's material "
"at all, and both means the baseline is not a baseline.")
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} (it does not react to "
f"`pv_entity`, which is what a tree without the option does).")
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"):
print(f" {what:<9} {blabel} unset {_h(base['unset'][what])}")
print(f" {what:<9} {hlabel} unset {_h(head['unset'][what])}")
print(f" {what:<9} {hlabel} set {_h(head['set'][what])}")
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. the acceptance criterion: unset is the baseline, in full.
# 1. TEL-11's acceptance criterion: unset is the baseline, in full.
if head["unset"] == base["unset"]:
print("OK 1/3 UNSET: control AND telemetry are byte-identical to the "
"baseline tree. An install that does not set `pv_entity` sees "
"no change of any kind.")
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/3 UNSET {what} MOVED at {k!r}")
# 2. the card's other half: setting it moves no DECISION.
if head["set"]["control"] == head["unset"]["control"]:
print("OK 2/3 SET: the control document is byte-identical with the "
"channel switched on - PV reaches no decision.")
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 k in sorted(head["set"]["control"]):
if head["set"]["control"][k] != head["unset"]["control"][k]:
print(f"FAIL 2/3 CONTROL MOVED at {k!r} when `pv_entity` was "
f"set")
# 3. …and the instrument could have seen it. Without this the first two
# are an identity measured against a document that reaches nothing.
if head["set"]["telemetry"] != head["unset"]["telemetry"]:
n = sum(1 for k in head["set"]["telemetry"]
if head["set"]["telemetry"][k]
!= head["unset"]["telemetry"].get(k))
print(f"OK 3/3 CONTROL OF THE CONTROL: the telemetry document DOES "
f"move when the option is set ({n} stimulus cells differ), so "
f"1/3 and 2/3 are identities the instrument could have broken.")
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
print("REFUSED 3/3: the telemetry document did NOT move when "
"`pv_entity` was set. This instrument cannot see the card's "
"material, so nothing above it means anything.")
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
@@ -384,7 +699,13 @@ if __name__ == "__main__":
print(json.dumps({
"tree": tree(),
"stimulus": {"grids": sorted(GRIDS), "pv": sorted(PVS),
"cycles": CYCLES},
"rosters": sorted(ROSTERS), "entities": PV_ENTITIES,
"roster_total_w": ROSTER_TOTAL_W, "cycles": CYCLES},
"unset": documents(False),
"set": documents(True),
# ⚠️ `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))
+3
View File
@@ -87,6 +87,9 @@ configuration:
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."
+3
View File
@@ -67,6 +67,9 @@ configuration:
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."