- Details link: /config/app/<slug>/documentation on HA 2026.2+ (the old /hassio/addon path is a 404 from 2026.5), /hassio/addon on 2026.1 and older. Slug from GET /addons/self/info, version from GET /info, hostname as fallback. - Shadow mode: the over-rating alarm and row say what would have been sent, the peak alarm no longer says the battery is giving all it can. - "controller" -> "the add-on" / "control". Settings named by their labels; four labels changed to match (Peak reserve, Battery level entity, Solar production ..., Reversed-sign solar entities). - Shadow banner and inverter link notes moved to messages.py and checked. - DOCS Notifications and Status page rewritten for home owners, with a "For installers" part each. Allowlist 168 -> 167. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
1346 lines
71 KiB
Python
1346 lines
71 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] == "PV"])
|
|
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 ("PV", "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",
|
|
("PV", "—") 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])
|
|
check("⭐ EXACTLY THREE MODULES CAN SEE THE MATERIAL: `pv.py` (the reader), "
|
|
"`main.py` (the read and the two operator surfaces) and `mqtt.py` (the "
|
|
"publication). `control.py`, `arbiter.py`, `allocator.py`, `peakguard.py`, "
|
|
"`arbitrage.py`, `maintenance.py`, `clamp.py`, `freeze.py`, "
|
|
"`watchdog.py`, `delivery.py` and `circulation.py` do not mention it in "
|
|
"any spelling, comments included",
|
|
_touch == ["main.py", "mqtt.py", "pv.py"])
|
|
# 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 ("PV", "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",
|
|
("PV", "—") in page_rows(_c_gap)
|
|
and ("PV", "3 300 W") not in page_rows(_c_gap))
|
|
|
|
# ⚠️ THE ACCEPTANCE CRITERION THE REST OF THIS FILE CANNOT STATE: at a roster of
|
|
# ONE the surfaces are 0.11.0's, character for character. `c_on` above is the
|
|
# 0.11.0 configuration (`pv_entity` set) and this is the same site declared the
|
|
# new way.
|
|
_c_one_new, _, _ = run(cycles=3, by_entity={E1: [state("3210", "W")]},
|
|
pv_entities=[E1])
|
|
print(f" one entity via pv_entities : "
|
|
f"{[t for t in rows_of(_c_one_new) if 'PV' in t]}")
|
|
check("⭐ ONE ENTITY IS 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("PV")] == ("PV", "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] == "PV"]
|
|
and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
|
|
and run(cycles=1)[1].state_calls == []
|
|
and run(cycles=1, pv_entities=[])[1].state_calls == []
|
|
and run(cycles=1, pv_entities=[" "])[1].state_calls == [])
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("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",
|
|
("PV", "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")
|