- During a bound episode an earlier over-rating count stays on the page (amber, under its own title); test_clamp pins it. - test_messages pins that every latch row says how it clears. - pv_entities description no longer claims the page names the entity (en, nl); allowlist hash updated for that option-desc line. - Plainer texts: multi-inverter direction banner, "fine now" after an episode, and the e-stop, peak-forecast and peak-guard-waiting rows move to messages.py. - TEL-12 DECISIONS points to UX-08 item 3 for the retired row rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
23 KiB
Markdown
362 lines
23 KiB
Markdown
# 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.
|
||
|
||
**Superseded in part (UX-08, 2026-09-27):** the PV status rows no longer read 0.11.0's sentences at one entity. The owner ruled out entity ids on status rows, so 2/5 now holds for the control document only. See `UX-08-short-notifications.md`, phase 2b item 3.
|