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
+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")