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
@@ -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))