TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
checks / unit (pull_request) Successful in 7m59s
0.11.0 shipped `pv_entity` as a single string. The reference site's owner reported within minutes of it going live that they have several PV inverters and could configure one - which is ordinary for a multi-string roof, not a property of one house. `pv_entities` is the roster and the row carries the SITE total. ⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`, and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no irradiance" conclusion the channel exists to TEST. So `pv.total()` answers `None` the moment any configured entity is absent: the topic carries HA's own `None`, the entity goes to `unknown`, the recorder records a GAP. The alternative (the partial sum beside a contributor count) was implemented and run as mutation N1b; it loses because the MQTT row is read COLD a week later by an attribution that would believe it, while the qualifying count would live in a second entity with its own history. The partial information is not thrown away - the STATUS PAGE names every entity that reported, with its watts, and every one that did not, with its reason, because that surface is read LIVE by a human who wants the diagnostic. ⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is `is None`, never falsiness - a roster that blanked the site on a zero would report an absence on every clear evening. THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list: tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an 0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and deepmerge's list strategy is `override` so a `[]` default does not rescue it - the live add-on would not start. `match(.*)?` does accept both shapes and is rejected for a different, measured reason: it validates nothing (a string, a list and the integer 417 all pass) and renders as a plain text box. So `pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for `inverters:`, with a status caution saying which key won - a union would double-count the inverter the old key still names. STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than replaced and now grades five claims over three document sets. The control hash is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the roster control document is byte-identical between the two trees. 2/5 is a claim TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly as 0.11.0 does" is a real cross-tree identity over control AND telemetry. ⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit 24: a discriminator defined as "the baseline lacks this feature" dies on the release that ships the feature, and dies as a false refusal. Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B (`sum` -> `max`, the same three characters) and was run on a fresh copy with `cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO contributions in two units with absence driven at EVERY position - a member contributing 0 W could go absent without moving the sum of the rest, which is habit 23 with a coinciding value in the arithmetic rather than in the plant. 31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0, so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG entry is wanted and deliberately not written - 11b forbids `## Unreleased` and the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the material it needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -34,6 +34,17 @@ modules can even see the material). `tools/tel11_pv_trace.py` is the other half:
|
||||
it hashes a control document on THIS tree and on `release/1.0`, with the option
|
||||
set and unset.
|
||||
|
||||
⚠️ AND TEL-12 ADDED A SECOND ABSENCE, ONE LAYER UP. `pv_entities` lets a site
|
||||
declare several PV inverters and the row carries the SITE total, so there is now
|
||||
a state TEL-11 never had: SOME configured, SOME reporting. The sum of the ones
|
||||
that answered is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE, which is the
|
||||
"there was less sun than there was" conclusion the channel exists to test. Parts
|
||||
8-10 are that, driven: absence at EVERY position of the roster, each with the
|
||||
partial it would have produced computed beside it, and the payload BYTES asserted
|
||||
against both. Part 8's mirror matters just as much - a member honestly reporting
|
||||
0 W is SUMMED, because a roster that blanked the site on a zero would report an
|
||||
absence every clear evening.
|
||||
|
||||
Part 1 `pv.watts()`: the unit table, and every refusal, by name.
|
||||
Part 2 the shipped read path, including what a FAILED read does to the
|
||||
value a good one left behind.
|
||||
@@ -41,7 +52,11 @@ set and unset.
|
||||
Part 4 announced and published are the same key, from the same predicate.
|
||||
Part 5 the operator surfaces, and that an unset option adds none of them.
|
||||
Part 6 ⭐ NOT A CONTROL INPUT.
|
||||
Part 7 what this file cannot see, with sizes, and the mutation table.
|
||||
Part 7 TEL-12: the ROSTER, and that ONE predicate decides it.
|
||||
Part 8 ⭐⭐ THE SUM, AND WHAT ONE ABSENT MEMBER DOES TO IT.
|
||||
Part 9 ⭐ publication at a roster of three, as payload bytes.
|
||||
Part 10 the operator surfaces at three - and 0.11.0's, unchanged, at one.
|
||||
Part 11 what this file cannot see, with sizes, and both mutation tables.
|
||||
|
||||
⚠️ MUTATING A MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
@@ -193,8 +208,16 @@ class _Hass:
|
||||
reach that failure - MUTATION.md habit 9's shape, one surface over.
|
||||
"""
|
||||
|
||||
def __init__(self, grid=0.0, soc=50.0, states=(), follow=False):
|
||||
def __init__(self, grid=0.0, soc=50.0, states=(), follow=False,
|
||||
by_entity=None):
|
||||
self.grid = grid
|
||||
# ⚠️ TEL-12. `by_entity` IS `{entity: [state per cycle]}` AND IT IS WHAT
|
||||
# A ROSTER NEEDS. The `states` script above answers the same thing to
|
||||
# every entity, which at a roster of three cannot separate "the total is
|
||||
# the sum" from any other reduction over three equal numbers, and cannot
|
||||
# drive ONE member absent at all - habit 10's fixture problem and habit
|
||||
# 23's no-op problem in one. Each entity gets its own script here.
|
||||
self.by_entity = dict(by_entity or {})
|
||||
# ⚠️ `follow` MAKES THE METER RESPOND TO THE BATTERY, and part 6 needs
|
||||
# it. With a FIXED meter the error never shrinks, the integrator winds
|
||||
# to its bound and the command rides `slew_w` to the rail on every
|
||||
@@ -220,6 +243,11 @@ class _Hass:
|
||||
|
||||
async def state(self, entity):
|
||||
self.state_calls.append((self.n, entity))
|
||||
if self.by_entity:
|
||||
script = self.by_entity.get(entity)
|
||||
if not script:
|
||||
return None
|
||||
return script[min(self.n, len(script) - 1)]
|
||||
if not self.states:
|
||||
return None
|
||||
return self.states[min(self.n, len(self.states) - 1)]
|
||||
@@ -233,10 +261,12 @@ class _Hass:
|
||||
return True
|
||||
|
||||
|
||||
def run(cycles=3, grid=0.0, states=(), auto=True, follow=False, **over):
|
||||
def run(cycles=3, grid=0.0, states=(), auto=True, follow=False,
|
||||
by_entity=None, **over):
|
||||
"""`cycles` real read/cycle/publish turns. Returns (controller, hass, pub)."""
|
||||
async def go():
|
||||
hass = _Hass(grid=grid, states=states, follow=follow)
|
||||
hass = _Hass(grid=grid, states=states, follow=follow,
|
||||
by_entity=by_entity)
|
||||
pub = _Pub()
|
||||
c = Controller(dict(BASE_OPTS, **LIVE, **over), hass, _Store(), pub,
|
||||
stop=asyncio.Event())
|
||||
@@ -253,6 +283,48 @@ def run(cycles=3, grid=0.0, states=(), auto=True, follow=False, **over):
|
||||
|
||||
PV_ON = {"pv_entity": "sensor.pv_power"}
|
||||
|
||||
# --- TEL-12's fixtures, and their VALUES are the whole design ----------------
|
||||
# ⚠️ TWO ROSTERS OVER THE SAME THREE ENTITIES, BECAUSE THE TWO CLAIMS NEED
|
||||
# DIFFERENT MAGNITUDES AND ONE FIXTURE CANNOT SERVE BOTH.
|
||||
#
|
||||
# `BIG` is part 6's, and it is sized so every PARTIAL sum would still MOVE the
|
||||
# loop. Part 6's identity is "the commands do not change"; habit 23 says that is
|
||||
# evidence only where the mutation could have changed them, and the mutation
|
||||
# here has two forms - subtract the TOTAL, or subtract a PARTIAL. So the smallest
|
||||
# partial (3500 W, west dead) is still far larger than the 600 W import the loop
|
||||
# settles on, and the shift preconditions below are measured at BOTH ends.
|
||||
#
|
||||
# `FULL` is parts 8-10's, and it is sized so the SUM is separable from every
|
||||
# other reduction over three members (habit 10) and from every partial of itself:
|
||||
# 1200 + 2100 + 450 = 3750; max 2100, min 450, first 1200, last 450,
|
||||
# mean 1250, count 3, and the three partials 2550 / 1650 / 3300.
|
||||
# ⚠️ NONE OF THE THREE IS ZERO, AND THAT IS THE HABIT-23 TRAP THIS TICKET IS
|
||||
# ABOUT. A member reading 0 W could go absent without changing the sum of the
|
||||
# rest - partial and full would coincide - so a fixture that drove absence on a
|
||||
# zero member could not tell "published the absence" from "published the partial
|
||||
# sum". Every member here contributes a distinct non-zero number, and absence is
|
||||
# driven at EVERY POSITION rather than at one.
|
||||
# ⚠️ AND ONE OF THEM IS IN kW, so "the sum is correct against their own units" is
|
||||
# exercised rather than assumed.
|
||||
E1, E2, E3 = "sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"
|
||||
PV_MANY = {"pv_entities": [E1, E2, E3]}
|
||||
|
||||
BIG = {E1: [state("1500", "W")], E2: [state("4.5", "kW")],
|
||||
E3: [state("2000", "W")]}
|
||||
BIG_TOTAL = 8000.0
|
||||
BIG_HOLE = dict(BIG, **{E2: [None]}) # the partial would be 3500 W
|
||||
|
||||
FULL = {E1: [state("1200", "W")], E2: [state("2.1", "kW")],
|
||||
E3: [state("450", "W")]}
|
||||
FULL_TOTAL = 3750.0
|
||||
# absence at EVERY position of the roster, with the partial each one would have
|
||||
# produced - COMPUTED from the fixture rather than typed, so a change to the
|
||||
# values above cannot leave a stale literal standing (habit 12).
|
||||
WATTS_OF = {E1: 1200.0, E2: 2100.0, E3: 450.0}
|
||||
HOLES = {miss: (dict(FULL, **{miss: [None]}),
|
||||
sum(v for k, v in WATTS_OF.items() if k != miss))
|
||||
for miss in (E1, E2, E3)}
|
||||
|
||||
c_off, h_off, p_off = run(cycles=3)
|
||||
check("⚠️ UNSET IS NOT READ AT ALL: with no `pv_entity` the add-on makes NO "
|
||||
"state call, for any entity, on any cycle - not a read that fails "
|
||||
@@ -533,14 +605,65 @@ check("⭐ PRECONDITION (habit 23): the same stimulus with the METER shifted by
|
||||
"flattened any input",
|
||||
_h_shift.writes != _w_big
|
||||
and abs(_c_shift.target - _c_big.target) > 4000)
|
||||
# ⚠️ AND TEL-12 ADDS A SECOND MAGNITUDE TO THE SAME QUESTION. A leak of the
|
||||
# PARTIAL total is a different mutation from a leak of the full one, and it
|
||||
# leaks a SMALLER number - 3500 W here, not 8000 - so the precondition has to be
|
||||
# asked at that end too. If a 3500 W shift could not move this stimulus, "the
|
||||
# commands did not move under a partial total" would be free.
|
||||
_c_shift_min, _h_shift_min, _ = run(cycles=40, grid=600.0 - 3500.0, follow=True)
|
||||
print(f" meter shifted by the SMALLEST partial (3500 W) -> settled at "
|
||||
f"{_c_shift_min.target:g} W")
|
||||
check("⭐ PRECONDITION (habit 23, at the partial): a shift of 3500 W - the "
|
||||
"smallest partial any single dead member of the roster could produce - "
|
||||
"also rests somewhere visibly different. So the roster identity below "
|
||||
"covers a PARTIAL leak and not only a full one",
|
||||
_h_shift_min.writes != _w_none
|
||||
and abs(_c_shift_min.target - _c_none.target) > 1500)
|
||||
check("...and `inputs_ok` does not move either: a PV sensor that never answers "
|
||||
"is not a missing INPUT, because nothing computes with it. An install "
|
||||
"whose solar integration breaks keeps regulating",
|
||||
_c_none.inputs_ok is True and _c_absent.inputs_ok is True)
|
||||
|
||||
# --- TEL-12: AND THE SAME AT A ROSTER OF THREE -------------------------------
|
||||
# ⚠️ THE ROSTER IS THE NEW WAY THIS COULD BREAK, so it is driven rather than
|
||||
# argued: three entities summing to 8000 W, and the SAME three with the biggest
|
||||
# of them dead (a 3500 W partial), against the SAME settled 600 W import. If any
|
||||
# term of the control law had learned to read the total - or to read a PARTIAL
|
||||
# total - the commands would move between these two, and between either and
|
||||
# `unset`.
|
||||
_c_three, _h_three, _ = run(**_stim, by_entity=BIG, **PV_MANY)
|
||||
_c_hole, _h_hole, _ = run(**_stim, by_entity=BIG_HOLE, **PV_MANY)
|
||||
print(f" 3 entities ({BIG_TOTAL:g} W): {_h_three.writes} -> settled at "
|
||||
f"{_c_three.target:g} W")
|
||||
print(f" 3 entities, west dead : {_h_hole.writes} -> settled at "
|
||||
f"{_c_hole.target:g} W")
|
||||
check("⭐ AND AT A ROSTER OF THREE, THE SAME IDENTITY: every write and the "
|
||||
"resting target are bit-identical to `unset` with three healthy entities "
|
||||
"summing to 8000 W AND with the biggest of the three dead. A partial "
|
||||
"total "
|
||||
"reaches no decision either - which is a separate claim from the first, "
|
||||
"because a partial sum is a DIFFERENT number and could have moved a loop "
|
||||
"the full one did not",
|
||||
_h_three.writes == _w_none and _h_hole.writes == _w_none
|
||||
and _c_three.target == _c_none.target == _c_hole.target)
|
||||
check("PRECONDITION (habit 23, at a roster): the three entities really did "
|
||||
"report, and the holed run really did lose one - so the identity above "
|
||||
"is over two DIFFERENT PV states and not over two runs that read nothing",
|
||||
_c_three.pv_w == BIG_TOTAL and _c_hole.pv_w is None
|
||||
and len(_c_three.pv_parts) == len(_c_hole.pv_parts) == 3
|
||||
and len(_h_three.state_calls) == 3 * _stim["cycles"])
|
||||
check("...and `inputs_ok` is still True with a hole in the roster: an install "
|
||||
"whose second solar inverter drops off the network keeps regulating its "
|
||||
"battery, because nothing computes with production",
|
||||
_c_three.inputs_ok is True and _c_hole.inputs_ok is True)
|
||||
|
||||
# --- the structural half, and its scope is COMPUTED (habit 12) ---------------
|
||||
APP = sorted((HERE / "app").glob("*.py"))
|
||||
NEEDLES = ("pv_entity", "pv_w", "pv_why", "optional_keys", "ABSENT_AS_UNKNOWN")
|
||||
NEEDLES = ("pv_entity", "pv_w", "pv_why", "optional_keys", "ABSENT_AS_UNKNOWN",
|
||||
# TEL-12's own names. `pv_entities` and `pv_entity` share a prefix so
|
||||
# the first would have caught it anyway; spelled out because a scope
|
||||
# that only works by accident stops working when a name is renamed.
|
||||
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored")
|
||||
|
||||
|
||||
def touches(src: str) -> bool:
|
||||
@@ -579,7 +702,323 @@ check("...and the scan CAN go red - a module body with the material in it is "
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. what this file CANNOT see, with sizes - and the mutation table")
|
||||
print("7. TEL-12: the ROSTER - which entities, from ONE predicate")
|
||||
# ==============================================================================
|
||||
# ⚠️ `pv_entities` REPLACES `pv_entity`. `app/pv.py`'s `entities()` says why at
|
||||
# length; the short version is that the install this card exists for HAS
|
||||
# `pv_entity` set, so a UNION rule would double-count that inverter the moment
|
||||
# the operator adds all of them to the new list. A doubled total is the same
|
||||
# defect as a partial one, pointing the other way.
|
||||
print(" opts -> roster")
|
||||
ROSTER_CASES = [
|
||||
("neither key", {}, ()),
|
||||
("pv_entity only (0.11.0)", PV_ON, ("sensor.pv_power",)),
|
||||
("pv_entities only", PV_MANY, (E1, E2, E3)),
|
||||
("BOTH - the list REPLACES the single key", dict(PV_ON, **PV_MANY),
|
||||
(E1, E2, E3)),
|
||||
("pv_entities empty falls back to pv_entity",
|
||||
dict(PV_ON, pv_entities=[]), ("sensor.pv_power",)),
|
||||
("whitespace-only members are not entities",
|
||||
{"pv_entities": [" ", "", None, E1]}, (E1,)),
|
||||
("whitespace-only pv_entity is not an entity", {"pv_entity": " "}, ()),
|
||||
("a repeated id is ONE entity, order preserved",
|
||||
{"pv_entities": [E1, E2, E1, E3]}, (E1, E2, E3)),
|
||||
("order is the operator's, not sorted",
|
||||
{"pv_entities": [E3, E1, E2]}, (E3, E1, E2)),
|
||||
]
|
||||
_bad_r = []
|
||||
for label, opts, want in ROSTER_CASES:
|
||||
got = pv.entities(opts)
|
||||
print(f" {label:<42} -> {got}")
|
||||
if got != want:
|
||||
_bad_r.append((label, got, want))
|
||||
check(f"⭐ the roster is what the operator declared, against literals - the list "
|
||||
f"REPLACES the single key, whitespace is not an entity, a repeated id is "
|
||||
f"counted ONCE and the order is theirs. Wrong: {_bad_r}", not _bad_r)
|
||||
check("...and the ANNOUNCEMENT predicate is the SAME function, not a second "
|
||||
"reading of the options. TEL-11 spelled it twice and got away with it "
|
||||
"because both spellings were `bool()` of one string; with a list in play, "
|
||||
"two spellings is how a row gets announced off one key and SUMMED off "
|
||||
"the other",
|
||||
all(mqtt.optional_keys(o) == (("pv",) if w else ())
|
||||
for _l, o, w in ROSTER_CASES)
|
||||
and "pv" in announced(PV_MANY) and "pv" not in announced({}))
|
||||
check("...and the controller reads that same roster - three entities named, in "
|
||||
"order, once per cycle each, and NOT the ignored `pv_entity` beside them",
|
||||
run(cycles=2, by_entity=FULL, **dict(PV_ON, **PV_MANY))[1].state_calls
|
||||
== [(0, E1), (0, E2), (0, E3), (1, E1), (1, E2), (1, E3)])
|
||||
check("...and `entity_is_ignored()` is the one place the both-set condition is "
|
||||
"spelled, so the caution and the rule cannot disagree",
|
||||
pv.entity_is_ignored(dict(PV_ON, **PV_MANY)) is True
|
||||
and pv.entity_is_ignored(PV_ON) is False
|
||||
and pv.entity_is_ignored(PV_MANY) is False
|
||||
and pv.entity_is_ignored(dict(PV_ON, pv_entities=[" "])) is False)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("8. ⭐⭐ THE SUM, AND WHAT ONE ABSENT MEMBER DOES TO IT")
|
||||
# ==============================================================================
|
||||
# THIS IS THE TICKET. Three inverters configured, one `unavailable`, and the sum
|
||||
# of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Fed
|
||||
# into OPT-10's attribution it reads as "there was less PV than there was" -
|
||||
# which is the "no irradiance" conclusion the channel exists to TEST.
|
||||
def total_of(by_entity, roster=(E1, E2, E3)):
|
||||
"""`pv.total()` over one cycle's worth of scripted states."""
|
||||
return pv.total([(e, by_entity.get(e, [None])[0]) for e in roster])
|
||||
|
||||
_w, _why, _parts = total_of(FULL)
|
||||
print(f" 3 healthy -> {_w} W ({_why})")
|
||||
for _e, _pw, _py in _parts:
|
||||
print(f" {_e:<18} {_pw} W (read in {_py})")
|
||||
# habit 2: the literal, not `sum(WATTS_OF.values())`.
|
||||
check("⭐ THREE HEALTHY ENTITIES GIVE THE SUM, against a literal: "
|
||||
"1200 W + 2.1 kW + 450 W is 3750.0 W. Each entity's OWN unit is read, so "
|
||||
"a kW inverter beside two W ones adds up correctly rather than being "
|
||||
"1200 + 2.1 + 450",
|
||||
_w == 3750.0 and _parts == [(E1, 1200.0, "W"), (E2, 2100.0, "kW"),
|
||||
(E3, 450.0, "W")])
|
||||
# habit 10: three members cannot separate every reduction, so the ones they CAN
|
||||
# separate are computed and printed, and the fixture was chosen to separate them.
|
||||
_FAMILY = {"max": 2100.0, "min": 450.0, "first": 1200.0, "last": 450.0,
|
||||
"mean": 1250.0, "count": 3.0, "sum of the first two": 3300.0,
|
||||
"sum of the last two": 2550.0, "product/1000": 1134.0}
|
||||
print(f" the reductions this fixture SEPARATES from the sum: "
|
||||
f"{sorted(_FAMILY)}")
|
||||
check(f"PRECONDITION (habit 10): every one of those reductions over these three "
|
||||
f"members is a DIFFERENT number from 3750, so the check above grades the "
|
||||
f"SUM and not 'some function of three numbers'. Colliding: "
|
||||
f"{[k for k, v in _FAMILY.items() if v == 3750.0]}",
|
||||
all(v != 3750.0 for v in _FAMILY.values()) and len(_FAMILY) >= 8)
|
||||
|
||||
# ⚠️ ABSENCE AT EVERY POSITION OF THE ROSTER, WHICH IS HABIT 10's FIXTURE-AS-A-
|
||||
# SET. One roster with the hole in the middle cannot separate "any absent" from
|
||||
# "the last one absent", "the first one absent", or a slice.
|
||||
print(" member absent partial it WOULD have produced -> published")
|
||||
_hb = []
|
||||
for _miss, (_script, _partial) in HOLES.items():
|
||||
_w2, _why2, _parts2 = total_of(_script)
|
||||
print(f" {_miss:<18} {_partial:>8.0f} W -> {_w2} "
|
||||
f"({_why2})")
|
||||
if _w2 is not None or _miss not in _why2 or len(_parts2) != 3:
|
||||
_hb.append(_miss)
|
||||
check(f"⭐⭐ ONE ABSENT MEMBER MAKES THE TOTAL ABSENT, AT EVERY POSITION OF THE "
|
||||
f"ROSTER - `None`, never the partial sum of the ones that answered, and "
|
||||
f"the reason NAMES the entity that is missing. Wrong: {_hb}", not _hb)
|
||||
check("⭐⭐ ...and the published value is NOT ANY OF THOSE PARTIALS. Stated as "
|
||||
"its own check because 'is None' would also be satisfied by a total that "
|
||||
"went absent for some unrelated reason, and because a partial sum is the "
|
||||
"one wrong answer that cannot be spotted downstream",
|
||||
all(total_of(_s)[0] not in (_p, 3750.0)
|
||||
for _s, _p in HOLES.values()))
|
||||
check(f"PRECONDITION: those partials are PLAUSIBLE READINGS and they are all "
|
||||
f"DIFFERENT from the true total and from each other - "
|
||||
f"{sorted(p for _s, p in HOLES.values())} against 3750. A fixture whose "
|
||||
f"absent member contributed 0 W could not tell an absence from a partial "
|
||||
f"sum at all, because the two would be the same number (habit 23)",
|
||||
sorted(p for _s, p in HOLES.values()) == [1650.0, 2550.0, 3300.0]
|
||||
and all(v > 0 for v in WATTS_OF.values()))
|
||||
check("...and the partial each hole would have produced is DERIVED FROM THE "
|
||||
"FIXTURE, not typed beside it, so changing a value above cannot leave a "
|
||||
"stale literal standing (habit 12)",
|
||||
HOLES[E1][1] == WATTS_OF[E2] + WATTS_OF[E3]
|
||||
and HOLES[E2][1] == WATTS_OF[E1] + WATTS_OF[E3]
|
||||
and HOLES[E3][1] == WATTS_OF[E1] + WATTS_OF[E2])
|
||||
|
||||
# ⚠️ AND THE MIRROR TRAP, WHICH WOULD DEFEAT THE CHANNEL FROM THE OTHER END.
|
||||
_zero_script = dict(FULL, **{E2: [state("0", "W")]})
|
||||
_wz, _whyz, _ = total_of(_zero_script)
|
||||
print(f" one member honestly reporting 0 W -> {_wz} W ({_whyz})")
|
||||
check("⭐ ZERO WATTS IS STILL A READING AT A ROSTER OF THREE: a member honestly "
|
||||
"reporting 0 W is SUMMED, not treated as absent - 1200 + 0 + 450 = 1650. "
|
||||
"Shading, a tripped string and nightfall are all real zeros, and a roster "
|
||||
"that blanked the site whenever one member read zero would report an "
|
||||
"absence on every clear evening. The test is `is None`, never falsiness",
|
||||
_wz == 1650.0 and _whyz == "3 entities")
|
||||
check("...and ALL of them absent is an absence too, with all three named - not "
|
||||
"an empty sum of 0.0, which is what `sum([])` would have given",
|
||||
total_of({})[0] is None
|
||||
and all(e in total_of({})[1] for e in (E1, E2, E3)))
|
||||
check("...and a REFUSED member counts as absent exactly like a dead one: a "
|
||||
"sensor reporting 1234 % is not 1234 W and is not 0 W either",
|
||||
total_of(dict(FULL, **{E3: [state("1234", "%")]}))[0] is None
|
||||
and "'%'" in total_of(dict(FULL, **{E3: [state("1234", "%")]}))[1])
|
||||
check("...and the reason SAYS how many of how many reported, so an operator "
|
||||
"reading the page knows the size of the hole",
|
||||
"2 of 3" in total_of(HOLES[E1][0])[1]
|
||||
and "0 of 3" in total_of({})[1])
|
||||
|
||||
# ⚠️ AND AT A ROSTER OF ONE IT IS `watts()` AND NOTHING ELSE, which is an
|
||||
# ACCEPTANCE CRITERION and not a nicety - see part 10 for the surfaces.
|
||||
check("⭐ AT A ROSTER OF ONE, `total()` IS `watts()`: the same value AND the "
|
||||
"same `why` string, passed through rather than re-composed, on a good "
|
||||
"read and on every refusal alike. This is what makes 0.11.0's "
|
||||
"single-entity sentences byte-identical rather than merely similar",
|
||||
all(pv.total([("sensor.x", st)])[:2] == pv.watts(st)
|
||||
for st in [state("3210", "W"), state("0", "kW"), state("1234", "%"),
|
||||
state("unavailable"), state(""), state("9", ""), None])
|
||||
and pv.total([]) == (None, "not configured", []))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. ⭐ publication at a roster of three, as PAYLOAD BYTES")
|
||||
# ==============================================================================
|
||||
# Part 3 did this for one entity. The new state is "several configured, one of
|
||||
# them gone", and the number that must NOT appear on the topic is a specific
|
||||
# plausible number rather than a wrong shape.
|
||||
_m_full = run(cycles=1, by_entity=FULL, **PV_MANY)[2].frames[-1]
|
||||
print(f" 3 healthy : {_m_full['pv']!r} -> wire {pv_msg(_m_full)}")
|
||||
check("⭐ THREE HEALTHY: one topic, one number, the SITE total - 3750.0. There "
|
||||
"is deliberately no per-entity topic: the recorder statistic OPT-10 reads "
|
||||
"back is the site figure, and a per-inverter breakdown is a dashboard "
|
||||
"question the operator's own PV integration already answers",
|
||||
pv_msg(_m_full) == ("goodwe_ctl/pv", "3750.0", False)
|
||||
and [m for m in wire(_m_full) if m[0].startswith("goodwe_ctl/pv")]
|
||||
== [pv_msg(_m_full)])
|
||||
_holed = []
|
||||
for _miss, (_script, _partial) in HOLES.items():
|
||||
_fr = run(cycles=1, by_entity=_script, **PV_MANY)[2].frames[-1]
|
||||
_msg = pv_msg(_fr)
|
||||
print(f" {_miss} dead -> wire {_msg} (partial would have been "
|
||||
f"{_partial:.0f} W -> {str(_partial)!r})")
|
||||
if _msg != ("goodwe_ctl/pv", "None", False) or str(_partial) in str(_msg):
|
||||
_holed.append(_miss)
|
||||
check(f"⭐⭐ THIS IS THE ACCEPTANCE CRITERION. With one of three entities gone "
|
||||
f"the topic carries Home Assistant's own `None` - the entity goes to "
|
||||
f"`unknown` and the recorder records a GAP - and NOT the partial sum, at "
|
||||
f"any position of the roster. A quietly understated total is worse than "
|
||||
f"no total: a gap makes OPT-10's attribution say 'I do not know', a "
|
||||
f"partial makes it say 'there was less sun than there was'. Wrong: "
|
||||
f"{_holed}", not _holed)
|
||||
check("⭐ AND IT IS THE SAME LINE TEL-11 ALREADY SHIPPED, which is the point: "
|
||||
"`ABSENT_AS_UNKNOWN` is still exactly one key and `publish()` still drops "
|
||||
"every other `None`. TEL-12 EXTENDED that rule to a roster rather than "
|
||||
"weakening it - no new absence mechanism was invented",
|
||||
mqtt.ABSENT_AS_UNKNOWN == frozenset({"pv"})
|
||||
and wire({"circulation": None, "grid": None}) == []
|
||||
and pv_msg(_m_full) != pv_msg(
|
||||
run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)[2].frames[-1]))
|
||||
check("...and a roster whose members ALL answer 0 W still publishes a NUMBER, "
|
||||
"so the gap above means 'no reading' and never 'no production'",
|
||||
pv_msg(run(cycles=1, **PV_MANY,
|
||||
by_entity={e: [state("0", "W")] for e in (E1, E2, E3)}
|
||||
)[2].frames[-1]) == ("goodwe_ctl/pv", "0.0", False))
|
||||
check("...and the announcement is byte-identical at a roster of three to what "
|
||||
"one entity announces - one `sensor.goodwe_pv_power`, same unique_id, so "
|
||||
"an install that MOVES from one entity to three keeps its history rather "
|
||||
"than starting a new entity beside a dead one",
|
||||
mqtt.discovery_payloads(include=mqtt.optional_keys(PV_MANY))
|
||||
== mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON)))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("10. the operator surfaces at a roster of three - and 0.11.0 at one")
|
||||
# ==============================================================================
|
||||
_c_many, _, _ = run(cycles=1, by_entity=FULL, **PV_MANY)
|
||||
_many_row = [t for t in rows_of(_c_many) if t.startswith("PV production")]
|
||||
print(f" {_many_row}")
|
||||
# habit 14 at a roster: the total in its own slot and EVERY contributor in its
|
||||
# own slot with its own watts and its own unit, so a sum that disagrees with the
|
||||
# addends printed beside it is visible in the sentence.
|
||||
check("a healthy roster says the SITE total, how many entities it came from, "
|
||||
"and then every entity with its own watts and its own unit - so a total "
|
||||
"that disagrees with the numbers printed beside it is visible rather than "
|
||||
"merely present",
|
||||
_many_row == ["PV production: 3750 W from 3 entities "
|
||||
f"({E1} 1200 W in W, {E2} 2100 W in kW, "
|
||||
f"{E3} 450 W in W)"]
|
||||
and ("PV", "3 750 W") in page_rows(_c_many))
|
||||
|
||||
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
|
||||
_gap_row = [r for r in _c_gap.checks() if r["text"].startswith("PV production")]
|
||||
print(f" {[r['text'] for r in _gap_row]}")
|
||||
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, names the "
|
||||
"entity that is missing and why - AND names what the others reported. The "
|
||||
"partial information is not thrown away, it is put where a HUMAN reading "
|
||||
"the page live will see it, and kept out of the number a recorder hands "
|
||||
"back cold a week later",
|
||||
len(_gap_row) == 1
|
||||
and _gap_row[0]["text"] == (
|
||||
"PV production: NO SITE TOTAL - only 2 of 3 entities are reporting "
|
||||
"and a partial sum would understate production: "
|
||||
f"{E2} - no state (entity missing, or the read failed). "
|
||||
f"Reporting: {E1} 1200 W, {E3} 450 W"))
|
||||
check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at "
|
||||
"one entity: `status_payload()` builds NOT READY out of rows that are "
|
||||
"neither ok nor warn, and a telemetry channel that could put a battery "
|
||||
"site into NOT READY would be observability with a foot in the control "
|
||||
"path",
|
||||
_gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]}
|
||||
and _c_gap.status_payload()["level"] != "bad"
|
||||
and _c_gap.status_payload()["banner"]
|
||||
== c_off.status_payload()["banner"])
|
||||
check("...and the page row is an em dash, not a smaller number: the one row an "
|
||||
"operator glances at cannot show a partial total either",
|
||||
("PV", "—") in page_rows(_c_gap)
|
||||
and ("PV", "3 300 W") not in page_rows(_c_gap))
|
||||
|
||||
# ⚠️ THE ACCEPTANCE CRITERION THE REST OF THIS FILE CANNOT STATE: at a roster of
|
||||
# ONE the surfaces are 0.11.0's, character for character. `c_on` above is the
|
||||
# 0.11.0 configuration (`pv_entity` set) and this is the same site declared the
|
||||
# new way.
|
||||
_c_one_new, _, _ = run(cycles=3, by_entity={E1: [state("3210", "W")]},
|
||||
pv_entities=[E1])
|
||||
print(f" one entity via pv_entities : "
|
||||
f"{[t for t in rows_of(_c_one_new) if 'PV' in t]}")
|
||||
check("⭐ ONE ENTITY IS 0.11.0's PATH, however it was declared: the status "
|
||||
"sentence, the page row and the published value are the same shape "
|
||||
"through `pv_entity` and through a one-member `pv_entities`, and the "
|
||||
"sentence is 0.11.0's own - `(reported in W)`, not `from 1 entities`",
|
||||
[t.replace(E1, "sensor.pv_power")
|
||||
for t in rows_of(_c_one_new) if "PV" in t]
|
||||
== [t for t in rows_of(c_on) if "PV" in t]
|
||||
== ["PV production: 3210 W from sensor.pv_power (reported in W)"]
|
||||
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
|
||||
.index("PV")] == ("PV", "3 210 W"))
|
||||
check("...and a refused read at a roster of one is 0.11.0's refusal sentence "
|
||||
"too, not the roster one - `no reading from <entity> - <reason>`",
|
||||
[r["text"] for r in run(cycles=1, pv_entities=[E1],
|
||||
by_entity={E1: [state("1234", "%")]})[0].checks()
|
||||
if r["text"].startswith("PV production")]
|
||||
== [f"PV production: no reading from {E1} - unit '%' is not one of "
|
||||
"mW, W, kW, MW, GW, TW - it cannot be read in watts"])
|
||||
|
||||
# ⚠️ AND THE REPLACEMENT IS STATED RATHER THAN SILENT.
|
||||
_c_both, _, _ = run(cycles=1, by_entity=FULL, **dict(PV_ON, **PV_MANY))
|
||||
_ign = [r for r in _c_both.checks() if "IGNORED" in r["text"]]
|
||||
print(f" {[r['text'] for r in _ign]}")
|
||||
check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
|
||||
"entities are summed instead. `entities()` REPLACES rather than unions - "
|
||||
"which is right, because a union would double-count the inverter the old "
|
||||
"key still names - but a replacement nobody is told about is the 'one of "
|
||||
"which silently wins' this config refuses elsewhere",
|
||||
len(_ign) == 1 and _ign[0]["ok"] is False and _ign[0]["warn"] is True
|
||||
and _ign[0]["text"] == ("PV production: `pv_entities` is set, so "
|
||||
"`pv_entity` is IGNORED - the 3 entities in the "
|
||||
"list are what is summed. Clear `pv_entity` to "
|
||||
"remove this caution")
|
||||
and _c_both.pv_w == 3750.0)
|
||||
check("...and that caution appears ONLY when both are set - not on the 0.11.0 "
|
||||
"configuration, not on a list-only one, and not on an install with "
|
||||
"neither",
|
||||
not [r for r in c_on.checks() if "IGNORED" in r["text"]]
|
||||
and not [r for r in _c_many.checks() if "IGNORED" in r["text"]]
|
||||
and not [r for r in c_off.checks() if "IGNORED" in r["text"]])
|
||||
check("...and an install with NO PV roster still adds no row of any kind - the "
|
||||
"whole of TEL-12 is invisible at the shipped default",
|
||||
not [t for t in rows_of(c_off) if "PV" in t]
|
||||
and not [r for r in page_rows(c_off) if r[0] == "PV"]
|
||||
and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
|
||||
and run(cycles=1)[1].state_calls == []
|
||||
and run(cycles=1, pv_entities=[])[1].state_calls == []
|
||||
and run(cycles=1, pv_entities=[" "])[1].state_calls == [])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("11. what this file CANNOT see, with sizes - and the mutation table")
|
||||
# ==============================================================================
|
||||
# habit 13. An enumerated list with an honest gap is worth more than a claim of
|
||||
# completeness a reviewer falsifies in ten minutes.
|
||||
@@ -595,8 +1034,23 @@ for line in [
|
||||
" the card creates is FOR - see DECISIONS/TEL-11-pv-production.md §2.",
|
||||
"The control-path claim OUTSIDE app/. `tools/`, the ESPHome firmware and",
|
||||
" the ingress page are not scanned by part 6's census.",
|
||||
"A roster of more than one. Every fixture here is the shipped roster of",
|
||||
" one; the row is a SITE row and there is nothing per-unit to separate.",
|
||||
"A roster of more than one INVERTER (CFG-04's battery kind). Every",
|
||||
" fixture here is the shipped roster of one; the PV roster IS driven, at",
|
||||
" three, but no fixture combines two battery inverters with three PV ones.",
|
||||
"TEL-12: THE SUPERVISOR'S OWN VALIDATOR, which is what decided the SHAPE",
|
||||
" of the option. `pv_entity` could not become a list because 2026.08.0",
|
||||
" refuses an 0.11.0 install's stored STRING against a list schema, and no",
|
||||
" check in this directory can run that validator. It is measured by",
|
||||
" tools/supervisor_shape_probe.py section G, against the release image,",
|
||||
" and the transcript is in DECISIONS/TEL-12-several-pv-inverters.md S1.",
|
||||
"TEL-12: A ROSTER LARGER THAN THREE. Three is the smallest size at which",
|
||||
" the sum separates from max/min/first/last/mean AND absence can be",
|
||||
" driven at every position; nothing here says what twenty entities do to",
|
||||
" a cycle's latency, and the read is GATHERED for exactly that reason.",
|
||||
"TEL-12: WHETHER THE GAP SURVIVES HOME ASSISTANT'S LONG-TERM STATISTICS.",
|
||||
" What is graded is that the add-on publishes `None` rather than a",
|
||||
" partial. Whether the statistics table then interpolates across the gap",
|
||||
" is HA's behaviour and OPT-10 will be reading it, not this file.",
|
||||
"Wall-clock behaviour: no fixture here sleeps, and the read is driven by",
|
||||
" the caller's loop rather than by the 1 s tick.",
|
||||
]:
|
||||
@@ -620,6 +1074,56 @@ for row in [
|
||||
"2 here + 2 crashes"),
|
||||
]:
|
||||
print(f" {row[0]:<48} {row[1]:>5} {row[2]}")
|
||||
# --- TEL-12's OWN BATTERY -----------------------------------------------------
|
||||
# ⚠️ EVERY ROW BELOW WAS RUN ON A FRESH `cp -a` OF THE CLEAN WORKTREE, purged,
|
||||
# never reverted (MUTATION.md habit 18 / TARIFF-09's method), and the suites were
|
||||
# run in the CI image with `checks.yml`'s own command, one file at a time, under
|
||||
# a `:ro` mount. Byte deltas are `stat` of the mutated file against the clean one
|
||||
# and the change was confirmed on disk with `cmp` before the run.
|
||||
print()
|
||||
print(" TEL-12 mutation bytes reds")
|
||||
print(" ------------------------------------------------ ----- --------")
|
||||
for row in [
|
||||
("N1 total() returns the partial sum, not an absence", "+42 B",
|
||||
"6 here + 1 crash"),
|
||||
("N1b ⭐ THE REJECTED DESIGN: partial sum + a count", "-282 B",
|
||||
"10 here + the trace"),
|
||||
("N2 absence tested by falsiness (`not w`)", "-4 B", "2 here"),
|
||||
("N3 `pv_entities` UNIONS with `pv_entity`", "-27 B", "3 here"),
|
||||
("N4 the de-duplication dropped", "-15 B", "1 here"),
|
||||
("N5 `sum` -> `max` over the roster", "+0 B", "6 here"),
|
||||
("N6 roster-of-one stops passing `why` through", "+13 B", "8 here"),
|
||||
("N7 optional_keys() back to reading `pv_entity`", "-6 B",
|
||||
"1 here + 1 crash"),
|
||||
("N8 `self.grid -= self.pv_w` in read_inputs()", "+106 B",
|
||||
"3 here + the trace"),
|
||||
("N9 the roster caution made a `bad` row (NOT READY)", "+1 B", "1 here"),
|
||||
("N10 `entity_is_ignored()` always False", "+10 B", "2 here"),
|
||||
("N11 `pv_entities: []` deleted from `options:`", "-18 B",
|
||||
"2 test_translations + 11 probe"),
|
||||
]:
|
||||
print(f" {row[0]:<48} {row[1]:>5} {row[2]}")
|
||||
print(" ⚠️ N5 IS `+0 B` - THE RISKY DELTA. `sum` and `max` are the same three")
|
||||
print(" characters, so neither size nor mtime moved and TEST-15's stale-")
|
||||
print(" bytecode defect would have manufactured a survivor. The run was on a")
|
||||
print(" fresh purged copy and `cmp` confirmed the edit on disk first.")
|
||||
print(" ⚠️ N1b IS THE ROW THAT MATTERS AND IT IS NOT A DEFECT - it is the")
|
||||
print(" OTHER defensible design (publish the partial sum beside a count of")
|
||||
print(" contributors), implemented and run. The point is that this file and")
|
||||
print(" the trace tool can TELL THE TWO APART: an unexamined partial sum is")
|
||||
print(" what the card forbids, so a check that could not see one would be")
|
||||
print(" decoration. `--compare` answers REFUSED 5/5 naming the exact")
|
||||
print(" partials on the wire - 2550.0, 1650.0, 3300.0.")
|
||||
print(" ⚠️ N8's TRACE REDS ARE 52 CELLS ON 2/5, 26 ON 3/5 AND 10 ON 4/5.")
|
||||
print(" 1/5 stays GREEN and that is correct rather than a miss: with no PV")
|
||||
print(" entity configured `pv_w` is None and the leak cannot fire, which is")
|
||||
print(" what makes 1/5 a claim about the DEFAULT and 2/5-4/5 the claims")
|
||||
print(" about the channel.")
|
||||
print(" ⚠️ N11 IS GRADED BY A TRANSCRIPT (habit 8), not by a suite in this")
|
||||
print(" directory: the `pv_entities: []` default is load-bearing for the")
|
||||
print(" same reason `inverters: []` is, and only Supervisor's own validator")
|
||||
print(" can say so. Deleting it takes the probe to `11 CASE(S) FAILED`.")
|
||||
print()
|
||||
print(" ⚠️ M7 WAS A SURVIVOR ON THE FIRST BATTERY and part 6's note says why:")
|
||||
print(" the fixture drove the loop to its RAIL, where every input produces")
|
||||
print(" the same ramp. MUTATION.md habit 23, with a saturated actuator in")
|
||||
|
||||
Reference in New Issue
Block a user