Files
goodwe-addon/goodwe_controller/test_pv.py
Claude f8d63cc614
checks / unit (pull_request) Successful in 9m37s
infra-pin / fresh (pull_request) Successful in 2s
UX-13 P1: page shell and Home screen on a structured /status
- app/static/{index.html,app.css,app.js}: tabs (Home, Batteries, Settings,
  Setup), hash routes, phone bottom tabs, light/dark. Served inlined at "/";
  the route list is unchanged, and every URL is relative.
- app/homeview.py: /status gains state, now, batteries, plan, attention,
  month_peak and control beside the old fields. The plan is what the add-on
  does today: the arbitrage verdict (the same plan.arbitrage() call as the
  cycle), the monthly cycle, the peak limit, "follows the house".
- The Home plan chart has hover, tap and keyboard-focus tooltips: price bar =
  hour and price; block = what, when, why, kWh and EUR.
- Region preset and year: in config/model (for P2) and on the Home price line.
- test_home.py; test_messages part 8 scans every Home sentence; STYLE plan line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 00:45:01 +02:00

1357 lines
72 KiB
Python

"""Runnable check for TEL-11 - the PV production channel. `python3 test_pv.py`
OPT-10 measured that this site's battery reaches 90 % SoC on 2 days of 16 and
that 20.5 kWh of surplus left it while the pack had room - 12.6 kWh of that with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes: no irradiance
(nothing to fix), a charge-power limit, or the optimiser declining to charge
(the only software defect). This card adds the missing channel. It does not do
the attribution; that is OPT-10's, with a week of history behind it.
⚠️ WHAT THIS FILE REFUSES TO ASSERT, SAID FIRST. It does not grade that a row
EXISTS. `publish()` handing a dict with `pv` in it is satisfied by `pv: 0.0`
forever, and a PV channel that reports 0 W for a dead sensor MANUFACTURES the
conclusion the card exists to test - "there was no surplus to store" - in a week
of history nobody will re-derive. So every check below separates three states
that a weaker one would collapse into one:
NO SOURCE `pv_entity` unset. No read, no entity, no topic, no row.
NO READING configured, and the sensor did not answer. The topic carries
HA's own `None`, the entity's state is `unknown`, the
recorder records a GAP.
ZERO WATTS configured, and the sensor said 0. The topic carries `0.0`.
The last two are the pair the acceptance criterion is about and part 3 asserts
the PAYLOAD BYTES of both, because they differ by exactly one thing that no
check on the VALUE alone can see.
⚠️ AND THE OTHER HALF OF THE CARD IS A NEGATIVE, GRADED AS ONE. This is
instrumentation: it must not become a control input, or the loop would react to
the quantity being measured and the measurement would be worthless. Part 6 is
the in-suite half of that claim (the shipped command is bit-identical across
absent / 0 W / 8 kW of PV, `inputs_ok` never moves, and an AST scan says which
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.
Part 3 ⭐ publication: no source / no reading / zero watts, as payload bytes.
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 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 the mutation tables.
Part 12 TEL-14: the sign, declared per entity (runs before part 11).
⚠️ 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
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md.
"""
import ast
import asyncio
import sys
from pathlib import Path
from app import messages, mqtt, pv
from named_absence import field, nth, present
from app.main import Controller
HERE = Path(__file__).resolve().parent
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def state(value, unit="W", **attrs):
"""A Home Assistant state object, in the shape `hass.state()` returns."""
return {"state": value, "attributes": dict({"unit_of_measurement": unit},
**attrs)}
# ==============================================================================
print("1. pv.watts(): the unit is read off the entity, and a refusal is named")
# ==============================================================================
# ⚠️ LITERALS ON THE RIGHT-HAND SIDE, NOT `raw * WATTS_PER[u]` (habit 2). An
# assertion written against the expression under test cannot tell a correct
# table from a table that is wrong in the same direction twice.
CASES = [
(state("1234", "W"), 1234.0, "W"),
(state("5.2", "kW"), 5200.0, "kW"),
(state("0.0072", "MW"), 7200.0, "MW"),
(state("4500000", "mW"), 4500.0, "mW"),
(state("0.0000072", "GW"), 7200.0, "GW"),
(state("0.0000000072", "TW"), 7200.0, "TW"),
(state("-8", "W"), -8.0, "W"),
]
print(" state unit -> watts")
_bad = []
for st, want, unit in CASES:
got, why = pv.watts(st)
print(f" {st['state']:>14} {unit:<4} -> {got} ({why})")
if got is None or abs(got - want) > 1e-6 or why != unit:
_bad.append((st, got, want))
check(f"every unit in the table converts to watts against a literal, and the "
f"reason string is the unit it was read in. Wrong: {_bad}", not _bad)
check("...and the table is HOME ASSISTANT's own power units, so a card that "
"adds one has to say so here: mW, W, kW, MW, GW, TW",
sorted(pv.WATTS_PER) == sorted(["mW", "W", "kW", "MW", "GW", "TW"]))
check("...and every factor is an exact power of ten - nothing in this table is "
"a number anybody chose",
all(abs(v * 1000 - round(v * 1000)) < 1e-9 and v > 0
for v in pv.WATTS_PER.values()))
# ⚠️ THE CASE THE WHOLE CARD TURNS ON, AND IT IS ASSERTED IN BOTH DIRECTIONS.
check("⭐ ZERO WATTS IS A READING: a sensor that says 0 gives 0.0, not None - "
"the pack can be full and the sun can be down and the honest report of "
"the second one is a number",
pv.watts(state("0", "W")) == (0.0, "W")
and pv.watts(state("0.0", "kW")) == (0.0, "kW"))
REFUSALS = [
("no state object at all (entity missing, or the read failed)", None,
"no state"),
("the entity is `unavailable`", state("unavailable"), "unavailable"),
("the entity is `unknown`", state("unknown"), "unknown"),
("an empty state", state(""), "empty"),
("a state that is not a number", state("sunny"), "not numeric"),
("no unit_of_measurement at all", state("1234", ""), "no unit"),
("a unit that is not power", state("1234", "%"), "'%'"),
# ⚠️ IN HA's `UnitOfPower` ENUM AND DELIBERATELY NOT IN THE TABLE. Its
# factor is a measured conversion rather than a decimal shift, and a wrong
# constant in the channel that exists to attribute a 20.5 kWh shortfall is
# worse than a refusal somebody can read.
("BTU/h, which IS one of HA's power units", state("1234", "BTU/h"),
"'BTU/h'"),
]
_r = []
print(" refusal -> (watts, why)")
for label, st, needle in REFUSALS:
got, why = pv.watts(st)
print(f" {label:<42} -> ({got}, {why!r})")
if got is not None or needle not in why:
_r.append(label)
check(f"⭐ EVERY REFUSAL ANSWERS None AND SAYS WHICH REFUSAL IT WAS - never "
f"0.0, and never a bare 'no'. A reason an operator cannot act on sends "
f"them to look at the wrong box. Wrong: {_r}", not _r)
check("...and the refused states are the SAME list `hass.number()` refuses, "
"imported rather than respelled - one path cannot start accepting what "
"the other rejects",
pv.BAD_STATES is __import__("app.hass", fromlist=["x"]).BAD_STATES)
# habit 1: the precondition, as its own check. Without it the row above could
# be true of a table that had quietly become empty.
check("PRECONDITION: the refusal list and the unit table are both non-empty, "
"so the two checks above quantify over something",
len(REFUSALS) >= 8 and len(pv.WATTS_PER) >= 6)
# ==============================================================================
print()
print("2. the shipped read path")
# ==============================================================================
LIVE = {"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
"deadband_w": 15, "step_w": 10}
BASE_OPTS = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15}
class _Store:
def __init__(self):
self.data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _Pub:
"""Captures what `Controller.publish()` hands the publisher."""
enabled = False
def __init__(self):
self.frames = []
def publish(self, values):
self.frames.append(dict(values))
def close(self):
pass
class _Hass:
"""A scripted meter, an inverter that follows, and a scripted PV sensor.
⚠️ `states` IS A SCRIPT, ONE ENTRY PER CYCLE, so a check can drive a GOOD
read and then a FAILED one and ask what the failure did to the value the
good one left behind. A fixture that answers the same thing forever cannot
reach that failure - MUTATION.md habit 9's shape, one surface over.
"""
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
# input - so every stimulus produces the same ramp and an identity over
# them is free. `grid = base - batt` is the site: a battery discharging
# 610 W into a 600 W import leaves the meter at the -10 W this add-on
# rests at.
self.follow = follow
self.soc = soc
self.batt = 0.0
self.n = 0
self.writes = []
self.state_calls = []
self.states = list(states)
async def limits(self, entity):
return (-LIVE["max_w"], LIVE["max_w"], 1.0)
async def number(self, entity, invert=False):
grid = self.grid - self.batt if self.follow else self.grid
return {"sensor.grid": grid, "sensor.soc": self.soc,
"sensor.batt": self.batt}.get(entity)
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)]
async def set_number(self, entity, value):
self.writes.append(float(value))
self.batt = float(value)
return True
async def call(self, domain, service, data):
return True
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,
by_entity=by_entity)
pub = _Pub()
c = Controller(dict(BASE_OPTS, **LIVE, **over), hass, _Store(), pub,
stop=asyncio.Event())
c.auto = auto
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
for _k in range(cycles):
await c.read_inputs()
await c.cycle()
c.publish()
hass.n += 1
return c, hass, pub
return asyncio.run(go())
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 "
"quietly, no read. This is what 'no warning spam' is made of",
h_off.state_calls == [] and c_off.pv_w is None
and c_off.pv_why == "not configured")
c_on, h_on, _ = run(cycles=3, states=[state("3210", "W")], **PV_ON)
print(f" state calls: {h_on.state_calls}")
check("configured: the entity is read once per cycle, by name, and the value "
"reaches `self.pv_w` in watts",
h_on.state_calls == [(0, "sensor.pv_power"), (1, "sensor.pv_power"),
(2, "sensor.pv_power")]
and c_on.pv_w == 3210.0 and c_on.pv_why == "W")
c_kw, _, _ = run(cycles=1, states=[state("3.21", "kW")], **PV_ON)
check("...and a kW sensor is read in kW: 3.21 kW is 3210 W, not 3.21 W. There "
"is no magnitude heuristic anywhere on this path - 30 W of dusk and "
"30 kW of noon are both ordinary readings",
c_kw.pv_w == 3210.0 and c_kw.pv_why == "kW")
# ⚠️ THE ONE THAT SEPARATES "ABSENT" FROM "STALE", AND IT NEEDS TWO CYCLES.
c_drop, _, p_drop = run(cycles=2, states=[state("4100", "W"), None], **PV_ON)
print(f" published pv by cycle: {[field(f, 'pv') for f in p_drop.frames]}")
check("⭐ A FAILED READ CLEARS THE PREVIOUS GOOD ONE. The cycle after a 4100 W "
"reading disappears publishes an ABSENCE, not 4100 W again - a value "
"that survives its own sensor is indistinguishable from a real one at "
"the moment somebody is working out whether the sun was shining",
[field(f, "pv") for f in p_drop.frames] == [4100.0, None]
and c_drop.pv_w is None and "no state" in c_drop.pv_why)
# ==============================================================================
print()
print("3. ⭐ publication: no source, no reading, zero watts - as payload bytes")
# ==============================================================================
class FakeClient:
def __init__(self):
self.msgs = []
def publish(self, topic, payload=None, retain=False):
self.msgs.append((topic, payload, retain))
def wire(values, include=("pv",)):
"""What the SHIPPED `MqttPublisher.publish()` hands paho for `values`."""
pub = mqtt.MqttPublisher.__new__(mqtt.MqttPublisher)
pub.omit, pub.units, pub.include, pub.enabled = set(), [], list(include), True
pub.client = FakeClient()
pub.publish(values)
return pub.client.msgs
_zero = nth(run(cycles=1, states=[state("0", "W")], **PV_ON)[2].frames, -1)
_absent = nth(run(cycles=1, states=[None], **PV_ON)[2].frames, -1)
_nosrc = nth(p_off.frames, -1)
print(f" no source : 'pv' in frame = {'pv' in _nosrc}")
def pv_msg(values):
"""The `goodwe_ctl/pv` message the shipped publisher emits, or None."""
got = [m for m in wire(values) if m[0] == "goodwe_ctl/pv"]
return got[0] if got else None
print(f" no reading: {field(_absent, 'pv')!r} -> wire {pv_msg(_absent)}")
print(f" zero watts: {field(_zero, 'pv')!r} -> wire {pv_msg(_zero)}")
check("NO SOURCE: an install that never set `pv_entity` publishes no `pv` key "
"at all - there is no entity to feed, so feeding one would be a topic "
"nothing announced",
present(_nosrc) and "pv" not in _nosrc)
check("NO READING: the key is PRESENT and its value is None, which is a "
"different frame from the one above. 'the option is off' and 'the sensor "
"did not answer' are two facts and one field cannot carry both",
"pv" in _absent and _absent["pv"] is None)
check("ZERO WATTS: 0.0, published as a number", field(_zero, "pv") == 0.0)
check("⭐ AND ON THE WIRE THE LAST TWO ARE DIFFERENT BYTES ON THE SAME TOPIC: "
"`None` - which is Home Assistant's own payload for 'this sensor has no "
"value', so the entity goes to `unknown` and the recorder records a GAP "
"- against `0.0`. THIS IS THE ACCEPTANCE CRITERION: a week later, no PV "
"reported is not PV reported 0 W",
pv_msg(_absent) == ("goodwe_ctl/pv", "None", False)
and pv_msg(_zero) == ("goodwe_ctl/pv", "0.0", False)
and pv_msg(_absent) != pv_msg(_zero)
# ...and the pv row is the ONLY thing that differs between the two
# frames on the wire, so the claim above is about that row and not
# about some other number the two fixtures happened to move.
and [m for m in wire(_absent) if m[0] != "goodwe_ctl/pv"]
== [m for m in wire(_zero) if m[0] != "goodwe_ctl/pv"])
check("...and the payload is HA's literal, from the module's own constant, not "
"a string spelled here", mqtt.PAYLOAD_NONE == "None")
# ⚠️ AND THE BLAST RADIUS IS ONE KEY. The None-dropping in `publish()` is what
# `circulation` depends on - its note in main.py says a missing per-unit reading
# must leave the last figure standing rather than publish a zero that reads as
# health - so widening it would be a behaviour change to another card's row.
check("⭐ AND NO OTHER ROW CHANGED BEHAVIOUR: `circulation: None` is still "
"DROPPED, not published as `None` - TEL-11 widened the rule for exactly "
"one key and `ABSENT_AS_UNKNOWN` is the list",
wire({"circulation": None, "grid": None, "loop_reason": None}) == []
and mqtt.ABSENT_AS_UNKNOWN == frozenset({"pv"}))
# ==============================================================================
print()
print("4. announced and published are the same key, from the same predicate")
# ==============================================================================
# mqtt.py's `omit` note: an entity that exists and is never fed is a worse
# signal than one that does not exist. This is that claim for TEL-11's row, and
# it is taken off the SHIPPED announcement and the SHIPPED `publish()`.
def announced(opts):
return {t.rsplit("/", 2)[-2].replace(f"{mqtt.BASE}_", "")
for t, _c in mqtt.discovery_payloads(
include=mqtt.optional_keys(opts))}
check("with a source: `pv` is announced AND published",
"pv" in announced(PV_ON) and "pv" in _absent)
check("without one: announced by nothing and published by nothing",
"pv" not in announced({}) and present(_nosrc) and "pv" not in _nosrc)
check("...and the predicate is ONE expression, `mqtt.optional_keys()`, read by "
"the announcement and by `Controller.publish()` alike - two spellings is "
"how an entity gets announced that nothing feeds",
mqtt.optional_keys({}) == ()
and mqtt.optional_keys({"pv_entity": ""}) == ()
and mqtt.optional_keys({"pv_entity": " "}) == ()
and mqtt.optional_keys({"pv_entity": "sensor.x"}) == ("pv",))
check("...and a whitespace-only option is the SAME as an empty one everywhere "
"- the controller does not read it either",
run(cycles=1, pv_entity=" ")[1].state_calls == [])
# ⚠️ AND THE RESTART, WHICH IS THE OTHER HALF OF "RECORDABLE". Home Assistant
# keeps the entity_id it composed at FIRST discovery and keys its registry on
# `unique_id`, so a row whose id moved between two starts is a NEW entity beside
# a dead one and the history stays behind on the old id. Two independent
# announcements are asserted byte-identical, and the composed id is modelled
# INDEPENDENTLY of app/mqtt.py (the model is `tools/rehearse_roster.py`'s, and
# it is anchored there on the one composed id that was verified against the live
# system).
import re as _re # noqa: E402
def compose(device_name, entity_name):
s_ = _re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
return "sensor." + s_.strip("_")
_a1 = mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON))
_a2 = mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON))
_pv_cfg = [c for _t, c in _a1 if c["unique_id"] == "goodwe_ctl_pv"]
print(f" composed entity_id: "
f"{compose(mqtt.DEVICE['name'], nth(_pv_cfg, 0)['name'])}")
check("a restart re-announces the SAME bytes, so Home Assistant sees the entity "
"it already has rather than a new one beside a dead one - the history is "
"continuous across a restart of the add-on",
[(t, __import__("json").dumps(c, sort_keys=True)) for t, c in _a1]
== [(t, __import__("json").dumps(c, sort_keys=True)) for t, c in _a2]
and len(_pv_cfg) == 1)
check("...and the id Home Assistant composes for it is "
"`sensor.goodwe_pv_power`, which is what DOCS.md tells an operator to put "
"on a dashboard - device name plus entity name, NOT the object_id",
len(_pv_cfg) == 1
and compose(mqtt.DEVICE["name"], _pv_cfg[0]["name"])
== "sensor.goodwe_pv_power"
and _pv_cfg[0]["state_topic"] == "goodwe_ctl/pv")
# ==============================================================================
print()
print("5. the operator surfaces")
# ==============================================================================
def rows_of(c):
return [r["text"] for r in c.checks()]
def pv_rows(c):
"""The solar rows of the status page (UX-08: selected by id, not wording)."""
return [r for r in c.checks() if r.get("id") == "pv"]
def page_rows(c):
return c.status_payload()["rows"]
_off_rows, _on_rows = rows_of(c_off), rows_of(c_on)
print(f" unset: {len(_off_rows)} status rows; set: {len(_on_rows)}")
check("⚠️ AN UNSET OPTION ADDS NO STATUS ROW AND NO PAGE ROW - not a caution, "
"not a 'no PV configured' nag. `peak_forecast_entity` DOES nag when it "
"is unset, because an unset forecast leaves a money guard without an "
"input; this is instrumentation and a permanent caution on every install "
"that never asked for it is the warning spam the card forbids",
not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "Solar production"])
check("...and the whole row list is otherwise the same length, so nothing else "
"moved either", len(_on_rows) == len(_off_rows) + 1)
_pv_row = [r["text"] for r in pv_rows(c_on)]
print(f" {_pv_row}")
# UX-08: the row says the watts, and no entity id (STYLE.md). The unit it was
# read in is DEBUG detail; a refused unit still gets its own caution below.
check("a good read says the watts, as a green row, and names no entity id",
_pv_row == [f"Solar production: {messages.signed_watts(3210)}"]
and "sensor." not in _pv_row[0]
and pv_rows(c_on)[0]["ok"] is True
and ("Solar production", "3 210 W") in page_rows(c_on))
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON)
_bad_row = pv_rows(c_bad)
print(f" {[r['text'] for r in _bad_row]}")
check("a refused read says WHICH refusal - 'no reading' on its own sends an "
"operator to look at a working sensor",
len(_bad_row) == 1
and _bad_row[0]["text"] == ("Solar production: no reading, the entity's "
"unit is not a power unit"))
check("...and every refusal pv.watts() can give has its own plain words",
len({messages.pv_problem(pv.watts(st)[1]) for st in (
None, state("unavailable", "W"), state("abc", "W"),
state("12", ""), state("12", "%"))}) == 5)
check("⭐ ...and it is a CAUTION, never a `bad` row: `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",
len(_bad_row) == 1
and _bad_row[0]["ok"] is False and _bad_row[0]["warn"] is True
and not c_bad.status_payload()["banner"].startswith(messages.NOT_READY)
and c_bad.status_payload()["level"] != "bad"
and c_bad.status_payload()["banner"]
== c_off.status_payload()["banner"])
check("...and the page row renders an absence as an em dash, not as 0 W",
("Solar production", "—") in page_rows(c_bad))
# ==============================================================================
print()
print("6. ⭐ NOT A CONTROL INPUT")
# ==============================================================================
# ⚠️ THE CLAIM IS NOT "pv is not in control.py". It is that the COMMANDS do not
# move, and that is graded by driving the same stimulus with the PV channel
# absent, at zero, and at 8 kW - a spread that would be impossible to miss if
# any term of the control law had learned to read it.
#
# ⚠️ AND THE FIRST VERSION OF THIS FIXTURE COULD NOT HAVE SEEN IT, WHICH IS
# MUTATION.md HABIT 23 AND THE CARD'S OWN BATTERY FOUND IT. The stimulus was a
# 6 kW export over 12 cycles; the card's central mutation - `self.grid -=
# self.pv_w` in `read_inputs()`, +106 B, on disk, purged, genuinely running -
# came back a SURVIVOR. Not because the property held: because at 6 kW of
# export the loop is against its rail and the command is moving at `slew_w`
# every cycle, so -6000 W and -14000 W of apparent error produce the SAME
# ramp, tick for tick. The mutant ran and could not change the answer on the
# data it was given.
#
# The stimulus is now a SMALL import the loop can actually settle on, far
# inside the rail, and the separation is asserted as its own precondition
# below: a check that cannot go red on the one mutation the card is about is
# the card's own habit-4.
_stim = dict(cycles=40, grid=600.0, follow=True)
_c_none, _h_none, _ = run(**_stim)
_c_absent, _h_absent, _ = run(**_stim, states=[None], **PV_ON)
_c_zero, _h_zero, _ = run(**_stim, states=[state("0", "W")], **PV_ON)
_c_big, _h_big, _ = run(**_stim, states=[state("8000", "W")], **PV_ON)
_w_none, _w_big = _h_none.writes, _h_big.writes
print(f" unset : {_w_none} -> settled at {_c_none.target:g} W")
print(f" 8000 W : {_w_big} -> settled at {_c_big.target:g} W")
check("⭐ THE COMMANDS ARE BIT-IDENTICAL ACROSS unset / no reading / 0 W / "
"8 kW of production, over 40 cycles of a real `cycle()` against a 600 W "
"import the loop SETTLES on - every write, in order, and the same "
"resting target. The PV channel changes no number that reaches an "
"inverter",
_w_none == _h_absent.writes == _h_zero.writes == _w_big
and _c_none.target == _c_absent.target == _c_zero.target
== _c_big.target)
check("PRECONDITION: that stimulus really does command something, and RESTS "
"well inside the rail rather than riding the slew limit to it - at the "
"rail every input produces the same ramp and the identity above would "
"be free (habit 1)",
len(_w_none) >= 4 and abs(_c_none.target) > 100
and abs(_c_none.target) < 0.5 * LIVE["max_w"])
# ⚠️ THE HABIT-23 PRECONDITION, AND IT IS THE ONE THAT MAKES THE IDENTITY MEAN
# SOMETHING. "What would the mutation have had to change, and does the fixture
# reach that value?" Here: if the 8 kW HAD been subtracted from the meter
# reading, would this stimulus produce a different command? It is one line to
# ask, and on the first version of this fixture the answer was NO - see the
# note above `_stim`.
_c_shift, _h_shift, _ = run(cycles=40, grid=600.0 - 8000.0, follow=True)
print(f" meter shifted by the same 8 kW -> settled at "
f"{_c_shift.target:g} W")
check("⭐ PRECONDITION (habit 23): the same stimulus with the METER shifted by "
"that 8 kW rests somewhere visibly different, so 'the commands did not "
"move' is a fact about the code and not about a rail that would have "
"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",
# 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",
# TEL-14's.
"pv_invert_entities", "stray_inverts", "NEGATIVE_W")
def touches(src: str) -> bool:
"""Does this module MENTION TEL-11's material, by any spelling?
⚠️ A TEXT SCAN ON PURPOSE, AND IT IS THE CONSERVATIVE DIRECTION HERE. An
AST scan would have to enumerate the forms a reference can take (attribute,
subscript, `getattr`, a string key) and habit 12 says a hand-written list of
forms is the same defect as a hand-written scope. Text over-reports - a
comment counts - and over-reporting is safe for a check whose claim is
"these modules do NOT mention it".
"""
return any(n in src for n in NEEDLES) or "from . import pv" in src \
or "from .pv import" in src
_touch = sorted(f.name for f in APP if touches(f.read_text(encoding="utf-8")))
print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}")
check(f"PRECONDITION: the scan found the whole package, not a subset - "
f"{len(APP)} modules", len(APP) >= 25 and "control.py" in
[f.name for f in APP])
# UX-13 adds the settings surface: labels (messages.py), a settings warning
# (validate.py) and the page's layout and derivation (uilayout.py, uimodel.py).
# None of them decides anything, so the decision modules stay excluded.
# UX-13 P1 adds homeview.py, the Home screen's read-only view: its House tile
# is grid + solar + battery, and it shows nothing without a solar reading.
_DECIDERS = ("control.py", "arbiter.py", "allocator.py", "peakguard.py",
"arbitrage.py", "maintenance.py", "clamp.py", "freeze.py",
"watchdog.py", "delivery.py", "circulation.py")
check("⭐ ONLY THE READER, THE PUBLISHER AND THE SETTINGS SURFACE SEE THE "
"MATERIAL: `pv.py` (the reader), `main.py` (the read and the two operator "
"surfaces), `homeview.py` (the Home screen's view), `mqtt.py` (the "
"publication), and `messages.py`, `validate.py`, `uilayout.py` and "
"`uimodel.py` (settings). "
f"{', '.join(_DECIDERS)} do not mention it in any spelling, comments "
"included",
_touch == ["homeview.py", "main.py", "messages.py", "mqtt.py", "pv.py",
"uilayout.py", "uimodel.py", "validate.py"]
and not set(_DECIDERS) & set(_touch))
# habit 4 in the instrument: a scanner that cannot go red is not a scanner.
check("...and the scan CAN go red - a module body with the material in it is "
"caught, so the green above is the tree speaking and not the scanner "
"being blind",
touches("x = opts['pv_entity']\n") and touches("from . import pv\n")
and not touches("# nothing to see, a pvc pipe\n"))
# ==============================================================================
print()
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 = nth(run(cycles=1, by_entity=FULL, **PV_MANY)[2].frames, -1)
print(f" 3 healthy : {field(_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 = nth(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 = [r["text"] for r in pv_rows(_c_many)]
print(f" {_many_row}")
# UX-08: the row carries the site total and the number of sources; the
# per-source figures are DOCS/DEBUG detail (DECISIONS/UX-08-short-notifications).
check("a healthy roster says the SITE total and how many sources it came from",
_many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 "
f"sources"]
and ("Solar production", "3 750 W") in page_rows(_c_many))
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
_gap_row = pv_rows(_c_gap)
print(f" {[r['text'] for r in _gap_row]}")
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, and names "
"the missing source by its place in the list - 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"] == ("Solar production: no total, source 2 of 3 "
"has no reading"))
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",
len(_gap_row) == 1
and _gap_row[0]["ok"] is False and _gap_row[0]["warn"] is True
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",
("Solar production", "—") in page_rows(_c_gap)
and ("Solar production", "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 ONE PATH, however it was declared: the status "
"sentence, the page row and the published value are the same through "
"`pv_entity` and through a one-member `pv_entities` - no `from 1 sources`",
[r["text"] for r in pv_rows(_c_one_new)]
== [r["text"] for r in pv_rows(c_on)]
== [f"Solar production: {messages.signed_watts(3210)}"]
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
.index("Solar production")] == ("Solar production", "3 210 W"))
check("...and a refused read at a roster of one is the one-sensor refusal "
"too, not the roster one - `no reading, <reason>`",
[r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1],
by_entity={E1: [state("1234", "%")]})[0])]
== [r["text"] for r in _bad_row])
# ⚠️ AND THE REPLACEMENT IS STATED RATHER THAN SILENT.
_c_both, _, _ = run(cycles=1, by_entity=FULL, **dict(PV_ON, **PV_MANY))
_IGN = messages.row_pv_single_ignored()
_ign = [r for r in _c_both.checks() if r["text"] == _IGN]
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 "clear “Solar production entity”" in _ign[0]["text"]
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 r["text"] == _IGN]
and not [r for r in _c_many.checks() if r["text"] == _IGN]
and not [r for r in c_off.checks() if r["text"] == _IGN])
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 pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "Solar production"]
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("12. TEL-14: the sign, declared PER ENTITY")
# ==============================================================================
# Some installs' PV entities read POSITIVE for consumption and NEGATIVE for
# production (a kWh meter or an ESPHome clamp in the PV circuit). The fixture is
# a MIXED roster, because that is the case a global flag gets wrong:
# E1 an inverter, positive = producing 1200 W -> 1200
# E2 a meter, NEGATIVE = producing, flipped -2.1 kW -> 2100
# E3 a meter at night, correctly signed, NOT flipped: -30 W standby -> -30
# and the values keep every wrong reduction apart:
# right 3270; no flip -930; flip all 930; abs() of every member 3330;
# flip E3 instead of E2 -870.
# ⚠️ EVERY CHECK BELOW READS THROUGH `_at`/`_row` AND NEVER INDEXES BLIND, so a
# missing value is a NAMED FAIL rather than a traceback that hides the rest.
def _at(seq, i):
"""`seq[i]`, or None when there is no such element."""
try:
return seq[i]
except (IndexError, KeyError, TypeError):
return None
def _row(c, needle):
"""Every status-row dict whose text contains `needle` (a list, maybe [])."""
try:
return [r for r in c.checks() if needle in r.get("text", "")]
except Exception as e: # noqa: BLE001 - named, below
return [{"text": f"checks() raised {e!r}", "ok": None, "warn": None}]
MIX = {E1: [state("1200", "W")], E2: [state("-2.1", "kW")],
E3: [state("-30", "W")]}
MIX_TOTAL = 3270.0
_MIX_WRONG = {"no flip": -930.0, "flip all": 930.0, "abs": 3330.0,
"flip E3 not E2": -870.0}
PV_FLIP = dict(PV_MANY, pv_invert_entities=[E2])
check(f"PRECONDITION: the mixed fixture's right answer {MIX_TOTAL:g} W is apart "
f"from every wrong reduction {_MIX_WRONG}",
MIX_TOTAL not in _MIX_WRONG.values()
and len(set(_MIX_WRONG.values())) == len(_MIX_WRONG))
# --- the option ---------------------------------------------------------------
check("inverted() is the stripped, non-empty entries of `pv_invert_entities`, "
"and a 0.12.0 blob (no key at all) flips nothing",
pv.inverted({"pv_invert_entities": [" sensor.a ", "", None, "sensor.b"]})
== frozenset({"sensor.a", "sensor.b"})
and pv.inverted({}) == frozenset()
and pv.inverted({"pv_invert_entities": None}) == frozenset())
# --- the arithmetic -----------------------------------------------------------
_t_mix = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)],
pv.inverted(PV_FLIP))
_t_raw = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)])
print(f" mixed, E2 flipped: {_t_mix[0]} no flip: {_t_raw[0]}")
check(f"⭐ A MIXED ROSTER: only the listed entity is flipped, before the sum - "
f"{MIX_TOTAL:g} W, not any of {_MIX_WRONG}",
_t_mix[0] == MIX_TOTAL
and [w for _e, w, _y in _t_mix[2]] == [1200.0, 2100.0, -30.0])
check("⭐ DEFAULT IS 0.12.0: with no flip `total()` answers exactly what it "
"always did - the raw sum, the same `why`, the same parts",
_t_raw == (-930.0, "3 entities",
[(E1, 1200.0, "W"), (E2, -2100.0, "kW"), (E3, -30.0, "W")]))
_t_zero = pv.total([(E2, state("0", "W"))], frozenset({E2}))
check("a flipped 0 W is +0.0, never -0.0 - so the wire says `0.0` and the page "
"says `0 W`, not `-0`",
_t_zero[0] == 0.0 and str(_t_zero[0]) == "0.0"
and f"{_t_zero[0]:.0f}" == "0")
_t_gone = pv.total([(E1, state("1200")), (E2, None)], frozenset({E2}))
check("a flipped member that is ABSENT is still an absence of the total - the "
"flip never turns None into a number",
_t_gone[0] is None and _at(_at(_t_gone[2], 1), 1) is None)
check("a single flipped entity keeps 0.11.0's pass-through `why` (the unit)",
pv.total([(E2, state("-13", "W"))], frozenset({E2}))
== (13.0, "W", [(E2, 13.0, "W")]))
# --- the read path, the wire and the page -------------------------------------
_c_mix, _, _p_mix = run(cycles=1, by_entity=MIX, **PV_FLIP)
_f_mix = _at(_p_mix.frames, -1) or {}
print(f" controller pv_w {_c_mix.pv_w} wire {pv_msg(_f_mix)}")
check("⭐ END TO END: the controller reads the option, and the MQTT `pv` row "
"carries the corrected site total",
_c_mix.pv_w == MIX_TOTAL
and pv_msg(_f_mix) == ("goodwe_ctl/pv", "3270.0", False))
_mix_row = [r["text"] for r in _row(_c_mix, "from 3 sources")]
print(f" {_mix_row}")
check("the status row prints the total AFTER the sign",
_mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} "
f"from 3 sources"])
check("...and the page's PV row is the corrected total",
("Solar production", "3 270 W") in page_rows(_c_mix))
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP)
check("a holed roster with a flipped member names the missing source, not "
"the flipped one",
[r["text"] for r in _row(_c_hole_f, "no total")]
== ["Solar production: no total, source 1 of 3 has no reading"])
# The owner's report, at a roster of one: a meter reading +13 W at night.
_OWN = "sensor.pv_meter_power"
_c_own, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
pv_entity=_OWN, pv_invert_entities=[_OWN])
_c_own0, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
pv_entity=_OWN)
check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is "
"-13 W once flipped, the row says so and marks it - and it raises NO "
"negative caution, because -13 W is standby and not a reversed sign - "
"and NO stray caution, because a lone `pv_entity` is in the roster",
_c_own.pv_w == -13.0
and [r["text"] for r in pv_rows(_c_own)]
== ["Solar production: -13 W, sign reversed"]
and _row(_c_own, "Reversed-sign solar entities") == [])
check("...and WITHOUT the option it is the plain row",
[r["text"] for r in pv_rows(_c_own0)] == ["Solar production: 13 W"])
# Whitespace: the roster entry and the invert entry differ only by padding. Both
# sides must normalise the same way, or the read path leaves the sign unflipped
# while the page marks it, or the page calls a flipped entity a stray.
_c_pad, _, _ = run(cycles=1, by_entity=MIX,
pv_entities=[E1, f" {E2}", E3],
pv_invert_entities=[f"{E2} "])
check("⭐ PADDING: ` sensor.x` in the roster and `sensor.x ` in "
"`pv_invert_entities` are the same entity - flipped on the read path, "
"marked on the page, and no stray caution",
_c_pad.pv_w == MIX_TOTAL
and _row(_c_pad, "Reversed-sign solar entities") == [])
# --- the negative caution -------------------------------------------------------
def _neg(watts_by_entity, flip=()):
opts = dict(PV_MANY, pv_invert_entities=list(flip))
c, _, _ = run(cycles=1, by_entity={e: [state(str(w), "W")]
for e, w in watts_by_entity.items()},
**opts)
return _row(c, "reads ")
_n_add = _neg({E1: 1200, E2: -1500, E3: 450})
_n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2])
print(f" {[r['text'] for r in _n_add + _n_rm]}")
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it by "
"its place in the list, its watts, and says to ADD it to the "
"setting 'Reversed-sign solar entities' (`pv_invert_entities`)",
[r["text"] for r in _n_add]
== ["Solar production: source 2 reads -1 500 W, add it to "
"“Reversed-sign solar entities”"])
check("...and a FLIPPED one far below zero is told to REMOVE it instead",
len(_n_rm) == 1 and "remove it from “Reversed-sign solar entities”"
in (_at(_n_rm, 0) or {}).get("text", ""))
check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot "
"put a battery site into NOT READY",
bool(_n_add) and all(r.get("ok") is False and r.get("warn") is True
for r in _n_add + _n_rm))
check(f"the threshold is {pv.NEGATIVE_W:g} W per ENTITY: -50 W is standby and "
"stays quiet, -51 W cautions",
pv.NEGATIVE_W == -50.0
and _neg({E1: -50, E2: 0, E3: 0}) == []
and len(_neg({E1: -51, E2: 0, E3: 0})) == 1)
check("⭐ ...PER ENTITY, NOT ON THE TOTAL: three correctly signed meters at "
"-20 W standby each (total -60 W) raise nothing, and a reversed -1500 W "
"hidden in a positive total (+1650 W) is still caught",
_neg({E1: -20, E2: -20, E3: -20}) == []
and len(_neg({E1: 2700, E2: -1500, E3: 450})) == 1)
# --- the stray entry ------------------------------------------------------------
_c_typo, _, _ = run(cycles=1, by_entity=FULL,
**dict(PV_MANY, pv_invert_entities=["sensor.pv_wset"]))
check("a `pv_invert_entities` entry naming no PV entity in use is a named "
"caution (a typo would otherwise leave the sign silently unflipped)",
[(r["text"], r.get("ok"), r.get("warn"))
for r in _row(_c_typo, "not a solar entity in use")]
== [("Solar production: entry 1 of “Reversed-sign solar entities” is "
"not a solar entity in use", False, True)]
and _row(_c_mix, "not a solar entity in use") == [])
# --- default: byte-identical to 0.12.0 ------------------------------------------
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
_c_d1, _, _p_d1 = run(cycles=1, by_entity=MIX,
**dict(PV_MANY, pv_invert_entities=[]))
check("⭐ DEFAULT: `pv_invert_entities: []` and a 0.12.0 blob with no such key "
"give the same rows, the same frame and the raw sum",
[r["text"] for r in _c_d0.checks()] == [r["text"] for r in _c_d1.checks()]
and _at(_p_d0.frames, -1) == _at(_p_d1.frames, -1)
and _c_d0.pv_w == -930.0)
# --- still not a control input --------------------------------------------------
_BIG_FLIP = {E1: [state("1500", "W")], E2: [state("-4.5", "kW")],
E3: [state("2000", "W")]}
_c_bf, _h_bf, _ = run(**_stim, by_entity=_BIG_FLIP,
**dict(PV_MANY, pv_invert_entities=[E2]))
check("⭐ NOT A CONTROL INPUT, WITH A FLIP: 40 cycles against the settled 600 W "
"import write exactly what `unset` writes, and the flip really did move "
"the reading (8000 W, part 6's shift precondition)",
_h_bf.writes == _w_none and _c_bf.target == _c_none.target
and _c_bf.pv_w == BIG_TOTAL)
# ==============================================================================
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.
for line in [
"Home Assistant itself. That `None` on the state topic renders as state",
" `unknown`, and that the recorder keeps it, is READ OUT OF HA's source",
" (mqtt/const.py PAYLOAD_NONE, mqtt/sensor.py's `_message_received`,",
" retrieved 2026-09-24) and quoted in app/mqtt.py. No HA runs in this",
" suite, so what is graded here is the BYTES this add-on publishes.",
"Whether the operator's PV sensor is telling the truth. TEL-14 lets the",
" installer declare a reversed one; this file grades the flip and the",
" -50 W caution, never that the declaration is right.",
"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 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.",
]:
print(" " + line)
print()
print(" mutation bytes reds")
print(" ------------------------------------------------ ----- --------")
for row in [
("M1 the pv row moved into SENSORS", "-79 B", "3 test_mqtt + 1 here"),
("M2 publish() drops a None pv row again", "-98 B", "1 here"),
("M3 ABSENT_AS_UNKNOWN widened past the one key", "+23 B", "1 here"),
("M4 a dead sensor reported as 0 W", "-1 B", "4 here"),
("M5 a failed read leaves the last value standing", "+46 B", "2 here"),
("M6 the unit read and then ignored", "-18 B", "2 here"),
("M7 `self.grid -= self.pv_w` in read_inputs()", "+106 B",
"2 here + the trace"),
("M8 the row announced and fed unconditionally", "-52 B", "3 here"),
("M9 the pv caution made a `bad` row (NOT READY)", "+1 B", "1 here"),
("M10 the read gate removed - unset is read anyway", "-10 B",
"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")
print(" place of a coinciding value - and it is in that file now.")
print(" ⚠️ M10's two extra reds are CRASHES, not named checks: every")
print(" fixture in test_inverter.py and test_telemetry.py predates the PV")
print(" read and has no `state()` on its Hass double, so an unconditional")
print(" read is an AttributeError. A red, but an unnamed one - habit 20's")
print(" shape - and it is recorded rather than tidied away.")
print(" Every row above was RUN, in the CI image, on a purged tree, one at a")
print(" time - see DECISIONS/TEL-11-pv-production.md §5 for the transcript.")
print(" ⚠️ A RED COUNT IS A PROPERTY OF THE SUITE AS MUCH AS OF THE MUTATION:")
print(" these name THIS tree. Re-measure rather than inherit.")
print()
print(" TEL-14: 14 mutations, all red, no survivors - bytes and named reds in")
print(" DECISIONS/TEL-14-pv-sign.md §4.")
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")