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:
@@ -65,7 +65,7 @@ import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
from deepmerge import Merger
|
||||
from supervisor.apps.validate import SCHEMA_APP_CONFIG
|
||||
from supervisor.apps.options import AppOptions
|
||||
from supervisor.apps.options import AppOptions, UiOptions
|
||||
from pathlib import Path
|
||||
from supervisor.utils.yaml import read_yaml_file
|
||||
|
||||
@@ -86,13 +86,24 @@ print(" schema declares", len(schema), "keys; inverters ->", json.dumps(schema[
|
||||
# this line must not crash the probe before section B gets to REFUSE it.
|
||||
print(" options ship inverters ->", json.dumps(defaults.get("inverters")))
|
||||
|
||||
def validate(options):
|
||||
v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"], "local_goodwe_controller")))
|
||||
def validate_with(sch, options):
|
||||
"""`(ok, result-or-refusal)` for ONE schema and ONE options blob.
|
||||
|
||||
⚠️ TEL-12 §G VARIES THE SCHEMA rather than only the options, which is what a
|
||||
question about a KEY'S SHAPE requires. `validate()` below is this with the
|
||||
shipped schema and is what every earlier section calls, unchanged.
|
||||
"""
|
||||
v = vol.Schema(vol.All(dict, AppOptions(None, sch, cfg["name"],
|
||||
"local_goodwe_controller")))
|
||||
try:
|
||||
return True, v(copy.deepcopy(options))
|
||||
except vol.Invalid as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def validate(options):
|
||||
return validate_with(schema, options)
|
||||
|
||||
FAILED = []
|
||||
|
||||
def show(t, ok, d=""):
|
||||
@@ -554,6 +565,124 @@ for lang in ("en", "nl"):
|
||||
show(f"...and the coverage audit is clean against the real loader's view too. "
|
||||
f"Problems: {_probs}", not _probs)
|
||||
|
||||
print("\n== G. TEL-12: the SHAPE of a several-inverter PV option ==")
|
||||
# ⚠️ WHY THIS SECTION EXISTS. TEL-12 had a real choice of surface and the two
|
||||
# candidates differ on ONE question nobody could answer by reading: does
|
||||
# Supervisor accept a stored STRING for a key whose schema has become a LIST?
|
||||
# 0.11.0 shipped `pv_entity: str?`, the reference install HAS a string persisted
|
||||
# in it, and an upgrade that refuses that string leaves an add-on which will not
|
||||
# start - CFG-04 §B's trap, in the other direction. So it was measured.
|
||||
#
|
||||
# ⚠️ AND IT IS AN ISOLATED COUNTERFACTUAL LIKE §B AND §D. One key's schema
|
||||
# changes at a time, from a baseline that is ACCEPTED on the line above, and each
|
||||
# refusal is asserted ON THE KEY'S NAME rather than on `not ok` (habit 6).
|
||||
PV_STORED = {"pv_entity": "sensor.pv_power"} # what 0.11.0 persisted
|
||||
_pv_base = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV_STORED))
|
||||
ok, out = validate(_pv_base)
|
||||
show("the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED "
|
||||
"- the baseline every refusal below is varied from", ok,
|
||||
f"pv_entity={out.get('pv_entity')!r} pv_entities={out.get('pv_entities')!r}"
|
||||
if ok else out)
|
||||
|
||||
# --- OPTION 1: `pv_entity` becomes a list. THE ONE THAT LOSES. ---------------
|
||||
_s_list = dict(schema, pv_entity=["str"])
|
||||
ok_l, out_l = validate_with(_s_list, _pv_base)
|
||||
show("⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as "
|
||||
"a LIST, the string an 0.11.0 install has persisted is REFUSED, and the "
|
||||
"refusal names the key - so that shape would leave the live add-on unable "
|
||||
"to start on upgrade",
|
||||
(not ok_l) and "Invalid list for option 'pv_entity'" in str(out_l), out_l)
|
||||
# ⚠️ AND THE `[]` DEFAULT DOES NOT RESCUE IT, which is the half somebody would
|
||||
# reach for. `_OPTIONS_MERGER`'s list strategy is OVERRIDE (CFG-04 §4), so the
|
||||
# operator's string simply stands.
|
||||
_d_list = dict(defaults, pv_entity=[])
|
||||
_m_list = MERGER.merge(copy.deepcopy(_d_list), copy.deepcopy(PV_STORED))
|
||||
ok_l2, out_l2 = validate_with(_s_list, _m_list)
|
||||
show("...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's "
|
||||
"list strategy is `override`, so the operator's string survives the merge "
|
||||
"and is refused anyway",
|
||||
_m_list["pv_entity"] == "sensor.pv_power" and not ok_l2, out_l2)
|
||||
ok_l3, out_l3 = validate_with(_s_list, dict(_pv_base, pv_entity=["sensor.a"]))
|
||||
show("...(the list shape itself is fine - it is only the STORED STRING it "
|
||||
"refuses, which is what makes this a migration and not a schema question)",
|
||||
ok_l3, out_l3 if not ok_l3 else "a list value validates")
|
||||
|
||||
# --- OPTION 1b: one key, both shapes. ACCEPTED, AND STILL WRONG. -------------
|
||||
# ⚠️ REPORTED EVEN THOUGH IT PASSES, because "no single spelling accepts both"
|
||||
# would have been a FALSE reason to reject option 1 - and MUTATION.md's habit
|
||||
# about inventing justifications applies to a probe as much as to a card.
|
||||
_s_match = dict(schema, pv_entity="match(.*)?")
|
||||
try:
|
||||
SCHEMA_APP_CONFIG(dict(raw, schema=dict(raw["schema"],
|
||||
pv_entity="match(.*)?")))
|
||||
_manifest_ok = True
|
||||
except vol.Invalid as e:
|
||||
_manifest_ok, _me = False, str(e)
|
||||
_ok_s, _ = validate_with(_s_match, _pv_base)
|
||||
_ok_li, _ = validate_with(_s_match, dict(_pv_base, pv_entity=["a", "b"]))
|
||||
_ok_num, _ = validate_with(_s_match, dict(_pv_base, pv_entity=417))
|
||||
_ui_match = [e for e in UiOptions(None)(_s_match)
|
||||
if e.get("name") == "pv_entity"]
|
||||
show("⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR "
|
||||
"A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, "
|
||||
"a list passes, and so does the integer 417; and the Configuration tab "
|
||||
"renders it as a plain text box rather than a repeatable field, so an "
|
||||
"operator cannot enter a second inverter in the UI at all",
|
||||
_manifest_ok and _ok_s and _ok_li and _ok_num
|
||||
and _ui_match and _ui_match[0].get("multiple") is not True,
|
||||
f"string={_ok_s} list={_ok_li} integer-417={_ok_num} "
|
||||
f"ui={json.dumps(_ui_match)}")
|
||||
|
||||
# --- OPTION 2: `pv_entities` beside `pv_entity`. WHAT SHIPPED. ---------------
|
||||
show("⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no "
|
||||
"`pv_entities` key at all, because it did not exist when that blob was "
|
||||
"written - validates against THIS manifest, with the new key defaulted to "
|
||||
"the empty list. The live install upgrades without touching its options",
|
||||
ok and out.get("pv_entity") == "sensor.pv_power"
|
||||
and out.get("pv_entities") == [])
|
||||
_three = ["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
|
||||
ok3, out3 = validate(MERGER.merge(copy.deepcopy(defaults),
|
||||
dict(PV_STORED, pv_entities=_three)))
|
||||
show("...and a three-entity roster validates, per-entry, with both keys set - "
|
||||
"which the add-on resolves by REPLACEMENT and says so on its status page",
|
||||
ok3 and out3.get("pv_entities") == _three,
|
||||
json.dumps(out3.get("pv_entities")) if ok3 else out3)
|
||||
# ⚠️ §B's LOAD-BEARING MEASUREMENT, FOR THIS KEY. Exactly one key varies.
|
||||
_no_pve = {k: v for k, v in defaults.items() if k != "pv_entities"}
|
||||
ok_np, out_np = validate(_no_pve)
|
||||
show("⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is "
|
||||
"REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the "
|
||||
"`pv_entities: []` default is load-bearing, exactly as `inverters: []` is: "
|
||||
"Supervisor treats a nested list as required unless its element is an "
|
||||
"OPTIONAL scalar, and `- str` is not one",
|
||||
(not ok_np) and "Missing option 'pv_entities' in root" in str(out_np),
|
||||
out_np)
|
||||
_ui_pv = [e for e in UiOptions(None)(schema)
|
||||
if e.get("name") in ("pv_entity", "pv_entities")]
|
||||
show("...and the Configuration tab renders it as a REPEATABLE string field "
|
||||
"(`multiple: true`), so an operator adds their second and third inverter "
|
||||
"in the UI rather than in YAML",
|
||||
[e["name"] for e in _ui_pv] == ["pv_entity", "pv_entities"]
|
||||
and _ui_pv[1].get("multiple") is True
|
||||
and _ui_pv[0].get("multiple") is not True, json.dumps(_ui_pv))
|
||||
# --- the ROLLBACK direction, as §E does for `inverters` ----------------------
|
||||
_flat = {k: v for k, v in schema.items() if k != "pv_entities"}
|
||||
try:
|
||||
_rolled = vol.Schema(vol.All(dict, AppOptions(
|
||||
None, _flat, cfg["name"], "local_goodwe_controller")))(
|
||||
MERGER.merge(copy.deepcopy(defaults),
|
||||
dict(PV_STORED, pv_entities=_three)))
|
||||
show("a DOWNGRADE to a schema without `pv_entities` drops the key with a "
|
||||
"warning rather than refusing, and leaves `pv_entity` standing - so "
|
||||
"rolling back to 0.11.0 returns the install to its single entity "
|
||||
"rather than bricking it",
|
||||
"pv_entities" not in _rolled
|
||||
and _rolled.get("pv_entity") == "sensor.pv_power")
|
||||
except vol.Invalid as e:
|
||||
show("a DOWNGRADE to a schema without `pv_entities` drops the key", False,
|
||||
str(e))
|
||||
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"""TEL-11's behaviour-invariance instrument. `tools/tel11_pv_trace.py`
|
||||
"""TEL-11/TEL-12's behaviour-invariance instrument. `tools/tel11_pv_trace.py`
|
||||
|
||||
TEL-11 adds a PV production channel and claims two things a reviewer cannot
|
||||
⚠️ THE FILE NAME STILL SAYS TEL-11 ON PURPOSE. It is quoted by
|
||||
`DECISIONS/TEL-11-pv-production.md` §5 and by `test_pv.py`'s mutation table, and
|
||||
a renamed instrument makes both of those references dangle for no gain. TEL-12
|
||||
EXTENDED it rather than writing a second one, which is also the honest thing: the
|
||||
two cards' claims are the same claim at two roster sizes, and one document set
|
||||
graded by one `--compare` cannot drift apart the way two tools would.
|
||||
|
||||
TEL-11 added a PV production channel and claims two things a reviewer cannot
|
||||
check by reading a diff:
|
||||
|
||||
1. with `pv_entity` UNSET - which is every install today and most installs
|
||||
@@ -12,23 +19,52 @@ check by reading a diff:
|
||||
a loop that reacted to production would destroy the very measurement the
|
||||
card exists to make.
|
||||
|
||||
TEL-12 makes the channel a ROSTER (`pv_entities`, several inverters, one site
|
||||
total) and adds two more, in the same shape:
|
||||
|
||||
3. with `pv_entity` set to ONE entity, the add-on behaves exactly as 0.11.0
|
||||
does - CONTROL AND TELEMETRY BOTH. That is a STRONGER claim than TEL-11
|
||||
could make, because TEL-11's baseline had no channel at all to be identical
|
||||
to, and it is the acceptance criterion for not breaking the live install:
|
||||
`pv.total()` passes a single entity's answer through verbatim for exactly
|
||||
this reason.
|
||||
4. with SEVERAL entities configured, and with one of several ABSENT, the
|
||||
control decisions are still exactly those. A PARTIAL total is a different
|
||||
number from a full one, so "a partial reaches no decision" is a separate
|
||||
claim rather than a corollary.
|
||||
|
||||
⚠️ AND TEL-12 HAD TO CHANGE `--compare`'s REFUSALS, BECAUSE TEL-11's WERE WRITTEN
|
||||
AGAINST A BASELINE THAT NO LONGER EXISTS. TEL-11's rule was "exactly ONE side's
|
||||
`set` document may differ from its own `unset`, and that side is the HEAD" - true
|
||||
when `release/1.0` predated `pv_entity` entirely. `release/1.0` is now 0.11.0 and
|
||||
BOTH sides react to `pv_entity`, so that refusal fires on every honest run. The
|
||||
discriminator is the ROSTER instead: only the HEAD reacts to `pv_entities`, and
|
||||
that is asserted rather than assumed.
|
||||
|
||||
This is what says so. It drives a fixed stimulus set through the SHIPPED
|
||||
`Controller` and prints two documents per setting -
|
||||
|
||||
`control` every command written to an actuator, every `self.target`, every
|
||||
`inputs_ok`, the loop's own three fields and the arbiter's
|
||||
resolution, tick by tick. NO TEL-11 MATERIAL REACHES IT.
|
||||
resolution, tick by tick. NO TEL-11/TEL-12 MATERIAL REACHES IT.
|
||||
`telemetry` the published frames, the status rows and the announcement -
|
||||
i.e. everything the card is ALLOWED to move.
|
||||
|
||||
- and a `tree` fingerprint: sha256 and byte count of every `app/*.py` it
|
||||
imported, so the comparison can say WHICH TREES it graded.
|
||||
|
||||
python3 tools/tel11_pv_trace.py > /tmp/tel11-head.json
|
||||
python3 tools/tel11_pv_trace.py > /tmp/pv-head.json
|
||||
# …in a release/1.0 checkout, with this file copied in…
|
||||
python3 tools/tel11_pv_trace.py > /tmp/tel11-base.json
|
||||
python3 tools/tel11_pv_trace.py --compare /tmp/tel11-base.json \\
|
||||
/tmp/tel11-head.json
|
||||
python3 tools/tel11_pv_trace.py > /tmp/pv-base.json
|
||||
python3 tools/tel11_pv_trace.py --compare /tmp/pv-base.json \\
|
||||
/tmp/pv-head.json
|
||||
|
||||
⚠️ THREE DOCUMENT SETS PER TREE, AND THE THIRD IS WHY THE FIRST TWO STILL HASH
|
||||
THE SAME AS THEY DID UNDER TEL-11. `unset` and `one` drive exactly the stimulus
|
||||
cells TEL-11 drove, so their `control` hashes are comparable across the two
|
||||
cards; the roster cells live in `multi`, a separate document, rather than being
|
||||
appended to those. Adding cells to an existing document would have moved a hash
|
||||
whose stability is itself the evidence.
|
||||
|
||||
⚠️ THE CONTROL IS PART OF THE TOOL, WHICH IS MUTATION.md HABIT 4 IN AN
|
||||
INSTRUMENT - AND IT IS A DIFFERENT CONTROL FROM `safety56_behaviour_trace.py`'s,
|
||||
@@ -47,7 +83,8 @@ refuses unless:
|
||||
and cannot react to it. Both, or neither, is ambiguous and is refused.
|
||||
|
||||
⚠️ WHAT IT CANNOT SEE, with shapes (habit 13):
|
||||
* a roster of more than one, and any per-unit surface;
|
||||
* a roster of more than one INVERTER (the battery kind - CFG-04's
|
||||
`inverters:`), and any per-unit surface. The PV roster IS driven, at three;
|
||||
* the real MQTT transport, Home Assistant, and the recorder. The publication
|
||||
is graded as the dict `Controller.publish()` hands the publisher plus the
|
||||
announcement `discovery_payloads()` returns - not as bytes on a broker;
|
||||
@@ -162,6 +199,13 @@ def _pv_state(watts, unit="W"):
|
||||
"attributes": {"unit_of_measurement": unit}}
|
||||
|
||||
|
||||
def _by_entity(mapping):
|
||||
"""A `hass.state()` that answers each entity its OWN scripted state."""
|
||||
async def state(entity):
|
||||
return mapping.get(entity)
|
||||
return state
|
||||
|
||||
|
||||
# ⚠️ THE STIMULUS SET, PRINTED WITH THE DOCUMENTS. An identity is only as wide
|
||||
# as what was driven through it, so the worlds and the PV schedules are part of
|
||||
# the output rather than a fact about the source.
|
||||
@@ -176,6 +220,51 @@ GRIDS = {
|
||||
"SWINGING": lambda t: (-4000.0 if (t // 5) % 2 else 4000.0),
|
||||
"METER-GAPS": lambda t: (None if t % 7 == 3 else -1500.0),
|
||||
}
|
||||
# --- TEL-12: THE ROSTER, AND THE VALUES ARE THE DESIGN ------------------------
|
||||
# Three entities, distinct non-zero contributions in two different units, so the
|
||||
# site total is separable from every partial of itself AND from max/min/first/
|
||||
# last/mean over the three. ⚠️ NOT ONE OF THEM IS ZERO: a member contributing 0 W
|
||||
# could go absent without moving the sum of the rest, and a stimulus like that
|
||||
# cannot tell "published the absence" from "published the partial sum" - which is
|
||||
# MUTATION.md habit 23 with a coinciding value in the fixture rather than in the
|
||||
# plant.
|
||||
PV_ENTITIES = ["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
|
||||
_R = {"sensor.pv_east": (1200.0, "W"), "sensor.pv_west": (2.1, "kW"),
|
||||
"sensor.pv_garage": (450.0, "W")}
|
||||
ROSTER_TOTAL_W = 3750.0 # 1200 + 2100 + 450, in watts
|
||||
|
||||
|
||||
def _roster(dead=(), zero=()):
|
||||
"""`{entity: state}` for the three - `dead` answers nothing, `zero` says 0."""
|
||||
return {e: (None if e in dead
|
||||
else _pv_state(0, "W") if e in zero else _pv_state(v, u))
|
||||
for e, (v, u) in _R.items()}
|
||||
|
||||
|
||||
# ⚠️ EACH CELL CARRIES BOTH HALVES OF THE CLAIM, AS LITERALS: the number that
|
||||
# must NEVER appear on the PV row, and the number that MUST. `--compare` asserts
|
||||
# against these rather than re-deriving anything from the code under test
|
||||
# (habit 2), and a cell with no dead member has no partial to leak - which is why
|
||||
# `never` is `None` there rather than the true total. That distinction cost a run:
|
||||
# labelling WEST-ZERO's true total as its "partial" made 5/5 refuse the shipped
|
||||
# tree.
|
||||
#
|
||||
# dead members zero members never / always (W)
|
||||
ROSTERS = {
|
||||
"ALL-THREE": ((), (), None, ROSTER_TOTAL_W),
|
||||
"EAST-DEAD": (("sensor.pv_east",), (), 2550.0, None),
|
||||
"WEST-DEAD": (("sensor.pv_west",), (), 1650.0, None),
|
||||
"GARAGE-DEAD": (("sensor.pv_garage",), (), 3300.0, None),
|
||||
# ⚠️ 0.0 IS THE NUMBER `sum([])` PRODUCES, and it is the one a roster with
|
||||
# nothing reporting must never publish: it is the "there was no sun" the
|
||||
# channel exists to test.
|
||||
"ALL-DEAD": (tuple(_R), (), 0.0, None),
|
||||
# ⚠️ AND THE MIRROR OF THIS CARD'S DEFECT: a roster that blanked the site
|
||||
# whenever one member honestly read 0 W would report an absence on every
|
||||
# clear evening. All three ANSWER here, so 1650 W MUST be published.
|
||||
"WEST-ZERO": ((), ("sensor.pv_west",), None, 1650.0),
|
||||
}
|
||||
|
||||
PVS = {
|
||||
"none": lambda t: None,
|
||||
"zero": lambda t: _pv_state(0),
|
||||
@@ -189,11 +278,19 @@ PVS = {
|
||||
CYCLES = 24
|
||||
|
||||
|
||||
async def _run(grid_of, pv_of, pv_set, soc=50.0, stop_at=None):
|
||||
async def _run(grid_of, pv_of, pv_set, soc=50.0, stop_at=None, roster=None):
|
||||
hass = Hass(grid_of, soc, pv_of)
|
||||
if roster is not None:
|
||||
# ⚠️ PER-ENTITY ANSWERS, which a roster needs and `pv_of` cannot give:
|
||||
# a scripted function of the tick alone answers every entity the same
|
||||
# thing, and three equal numbers cannot separate a sum from any other
|
||||
# reduction over three, nor drive ONE member absent.
|
||||
hass.state = _by_entity(roster)
|
||||
pub = Pub()
|
||||
opts = dict(ONE)
|
||||
if pv_set:
|
||||
if roster is not None:
|
||||
opts["pv_entities"] = list(PV_ENTITIES)
|
||||
elif pv_set:
|
||||
opts["pv_entity"] = PV_ENTITY
|
||||
c = Controller(opts, hass, MemStore(), pub, stop=asyncio.Event())
|
||||
c.auto = True
|
||||
@@ -219,35 +316,64 @@ async def _run(grid_of, pv_of, pv_set, soc=50.0, stop_at=None):
|
||||
return c, hass, pub, ctrl
|
||||
|
||||
|
||||
def documents(pv_set: bool) -> dict:
|
||||
"""`{"control": …, "telemetry": …}` for one setting of `pv_entity`."""
|
||||
def documents(pv_set: bool, roster: bool = False) -> dict:
|
||||
"""`{"control": …, "telemetry": …}` for one setting of the PV channel.
|
||||
|
||||
⚠️ `roster=True` DRIVES ITS OWN STIMULUS CELLS AND LANDS IN ITS OWN DOCUMENT
|
||||
(TEL-12). It does NOT append cells to the `unset`/`one` documents, because
|
||||
those two documents' `control` hashes are the evidence TEL-11 recorded and a
|
||||
new cell would move them for a reason that is not behaviour. Same reason
|
||||
`_supports_include()` lives in `tree` rather than in a document.
|
||||
"""
|
||||
_load()
|
||||
logging.disable(logging.CRITICAL)
|
||||
loop = asyncio.new_event_loop()
|
||||
control, tele = {}, {}
|
||||
for gname, gof in GRIDS.items():
|
||||
for pname, pof in PVS.items():
|
||||
key = f"{gname}/{pname}"
|
||||
c, hass, pub, ctrl = loop.run_until_complete(
|
||||
_run(gof, pof, pv_set))
|
||||
control[key] = {
|
||||
"ticks": ctrl,
|
||||
"writes": hass.writes,
|
||||
"sha": hashlib.sha256(
|
||||
json.dumps(ctrl, sort_keys=True).encode()).hexdigest(),
|
||||
}
|
||||
rows = c.checks()
|
||||
tele[key] = {
|
||||
"frames": pub.frames,
|
||||
"rows": [[r["ok"], r["warn"], r["text"]] for r in rows],
|
||||
"n_rows": len(rows),
|
||||
"page": c.status_payload()["rows"],
|
||||
"level": c.status_payload()["level"],
|
||||
"banner": c.status_payload()["banner"],
|
||||
}
|
||||
control, tele, partials = {}, {}, {}
|
||||
_cells = ([(f"{g}/{r}", gof, None, _roster(dead, zero), (never, always))
|
||||
for g, gof in GRIDS.items()
|
||||
for r, (dead, zero, never, always) in ROSTERS.items()]
|
||||
if roster else
|
||||
[(f"{g}/{pn}", gof, pof, None, None)
|
||||
for g, gof in GRIDS.items() for pn, pof in PVS.items()])
|
||||
for key, gof, pof, rmap, partial in _cells:
|
||||
c, hass, pub, ctrl = loop.run_until_complete(
|
||||
_run(gof, pof, pv_set, roster=rmap))
|
||||
control[key] = {
|
||||
"ticks": ctrl,
|
||||
"writes": hass.writes,
|
||||
"sha": hashlib.sha256(
|
||||
json.dumps(ctrl, sort_keys=True).encode()).hexdigest(),
|
||||
}
|
||||
rows = c.checks()
|
||||
tele[key] = {
|
||||
"frames": pub.frames,
|
||||
"rows": [[r["ok"], r["warn"], r["text"]] for r in rows],
|
||||
"n_rows": len(rows),
|
||||
"page": c.status_payload()["rows"],
|
||||
"level": c.status_payload()["level"],
|
||||
"banner": c.status_payload()["banner"],
|
||||
}
|
||||
if partial is not None:
|
||||
# ⚠️ TEL-12. THE NUMBER THAT MUST NEVER APPEAR ON THE PV ROW,
|
||||
# carried in the output so `--compare` asserts against a literal
|
||||
# rather than re-deriving it from the code under test (habit 2).
|
||||
# `wire` is what `str()` of it looks like on the topic, because that
|
||||
# is the comparison that matters.
|
||||
#
|
||||
# ⚠️ IN ITS OWN TOP-LEVEL MAP AND *NOT* INSIDE THE TELEMETRY CELL.
|
||||
# It is a fact about the STIMULUS, produced by this tool and
|
||||
# identical on both trees, so putting it in the telemetry document
|
||||
# made every roster cell differ from its unset reference for a
|
||||
# reason that is not behaviour - and the HEAD/baseline discriminator
|
||||
# then fired on both sides. Measured, on the first run.
|
||||
never, always = partial
|
||||
partials[key] = {
|
||||
"never": None if never is None else str(round(never, 1)),
|
||||
"always": None if always is None else str(round(always, 1))}
|
||||
# A stopped run, because the "stopped or blind" branch writes its own rows.
|
||||
c, hass, pub, ctrl = loop.run_until_complete(
|
||||
_run(GRIDS["EXPORT-6K"], PVS["noon"], pv_set, stop_at=8))
|
||||
_run(GRIDS["EXPORT-6K"], PVS["noon"], pv_set, stop_at=8,
|
||||
roster=dict(_roster()) if roster else None))
|
||||
control["STOPPED"] = {"ticks": ctrl, "writes": hass.writes,
|
||||
"sha": hashlib.sha256(
|
||||
json.dumps(ctrl, sort_keys=True).encode()
|
||||
@@ -258,8 +384,11 @@ def documents(pv_set: bool) -> dict:
|
||||
loop.close()
|
||||
# The announcement is telemetry too, and it is the surface an install's
|
||||
# entity registry is keyed on.
|
||||
tele["ANNOUNCEMENT"] = _announcement(pv_set)
|
||||
return {"control": control, "telemetry": tele}
|
||||
tele["ANNOUNCEMENT"] = _announcement(pv_set, roster)
|
||||
out = {"control": control, "telemetry": tele}
|
||||
if partials:
|
||||
out["partials"] = partials
|
||||
return out
|
||||
|
||||
|
||||
def _supports_include() -> bool:
|
||||
@@ -280,16 +409,38 @@ def _supports_include() -> bool:
|
||||
and hasattr(mqtt, "optional_keys"))
|
||||
|
||||
|
||||
def _announcement(pv_set: bool) -> list:
|
||||
def _supports_roster() -> bool:
|
||||
"""Does THIS tree know `pv_entities` at all? TEL-12.
|
||||
|
||||
⚠️ IN `tree` FOR `_supports_include()`'s OWN REASON. A tree that predates
|
||||
TEL-12 answers False; a tree that predates TEL-11 has no `app/pv.py` either,
|
||||
so the import is guarded rather than assumed.
|
||||
"""
|
||||
try:
|
||||
from app import pv as _p
|
||||
except ImportError:
|
||||
return False
|
||||
return callable(getattr(_p, "entities", None))
|
||||
|
||||
|
||||
def _announcement(pv_set: bool, roster: bool = False) -> list:
|
||||
"""The retained discovery set, in announcement order.
|
||||
|
||||
⚠️ AT `pv_set` FALSE THIS IS THE BARE CALL ON BOTH TREES, which is the
|
||||
point: an install that never set the option must receive the SAME
|
||||
announcement from both, and that identity is graded rather than argued.
|
||||
|
||||
⚠️ AND AT `roster` TRUE THE OPTS CARRY `pv_entities` AND NOTHING ELSE. On a
|
||||
tree that predates TEL-12 `optional_keys()` reads only `pv_entity`, so it
|
||||
answers `()` and no PV row is announced - which is exactly how a baseline is
|
||||
supposed to fail to react to a key it has never heard of, and is what
|
||||
`--compare` 4/5 asserts rather than assumes.
|
||||
"""
|
||||
if pv_set and _supports_include():
|
||||
if (pv_set or roster) and _supports_include():
|
||||
payloads = mqtt.discovery_payloads(
|
||||
include=mqtt.optional_keys({"pv_entity": PV_ENTITY}))
|
||||
include=mqtt.optional_keys({"pv_entities": list(PV_ENTITIES)}
|
||||
if roster
|
||||
else {"pv_entity": PV_ENTITY}))
|
||||
else:
|
||||
payloads = mqtt.discovery_payloads()
|
||||
return [[t, json.dumps(cfg, sort_keys=True)] for t, cfg in payloads]
|
||||
@@ -298,7 +449,12 @@ def _announcement(pv_set: bool) -> list:
|
||||
def tree() -> dict:
|
||||
"""sha256 and byte count of every `app/*.py` this run imported from."""
|
||||
_load()
|
||||
out = {"supports_include": _supports_include()}
|
||||
out = {"supports_include": _supports_include(),
|
||||
# TEL-12. Does THIS tree know the roster at all? A property of the
|
||||
# tree, recorded beside the file hashes for `_supports_include()`'s
|
||||
# own reason: putting it in a document would make `multi` differ
|
||||
# between trees for something that is not behaviour.
|
||||
"supports_roster": _supports_roster()}
|
||||
for f in sorted((HERE / "app").glob("*.py")):
|
||||
raw = f.read_bytes()
|
||||
out[f"app/{f.name}"] = [hashlib.sha256(raw).hexdigest(), len(raw)]
|
||||
@@ -309,6 +465,53 @@ def _h(obj) -> str:
|
||||
return hashlib.sha256(json.dumps(obj, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
# ⚠️ WHY `multi` IS GRADED PER GRID WORLD AND `one` IS NOT. `one` drives the same
|
||||
# stimulus cells `unset` does - `GRID/pv-schedule` - so the two documents have the
|
||||
# same keys and compare directly. `multi`'s cells are `GRID/roster-state`, a
|
||||
# different naming over a different stimulus, so a whole-document comparison
|
||||
# would report a difference that is only the key names. The comparable thing is
|
||||
# the GRID: for one grid world, `unset` produces the SAME control ticks for every
|
||||
# PV schedule (nothing is read), so that grid has ONE control document and each
|
||||
# roster cell in the same grid must equal it. That per-grid collapse is asserted
|
||||
# as a precondition before it is used - without it "equal to the unset document
|
||||
# for this grid" names nothing.
|
||||
def _grid(key: str) -> str:
|
||||
return key.split("/")[0]
|
||||
|
||||
|
||||
def _collapse(doc_part: dict):
|
||||
"""`{grid: value}` if every cell in a grid agrees, else `None`.
|
||||
|
||||
⚠️ RETURNS `None` RATHER THAN A BEST GUESS. A caller that got a dict has a
|
||||
well-defined per-grid reference; a caller that got `None` has to say so and
|
||||
refuse, which is what `compare()` does.
|
||||
"""
|
||||
out = {}
|
||||
for key, value in doc_part.items():
|
||||
if "/" not in key:
|
||||
continue
|
||||
if _grid(key) in out and out[_grid(key)] != value:
|
||||
return None
|
||||
out[_grid(key)] = value
|
||||
return out
|
||||
|
||||
|
||||
def _reduces_to_unset(doc: dict, what: str) -> bool:
|
||||
"""Is this side's `multi` document its own `unset` one, grid for grid?
|
||||
|
||||
True of a tree that has never heard of `pv_entities`: it reads nothing,
|
||||
announces nothing and shows no row, so the roster cells are the unset cells.
|
||||
False of a tree that implements TEL-12. This is the HEAD/baseline
|
||||
discriminator, and it is MEASURED rather than taken from
|
||||
`tree.supports_roster`, which is the tree describing itself.
|
||||
"""
|
||||
ref = _collapse(doc["unset"][what])
|
||||
if ref is None:
|
||||
return False
|
||||
return all(value == ref.get(_grid(key))
|
||||
for key, value in doc["multi"][what].items() if "/" in key)
|
||||
|
||||
|
||||
def compare(a_path, b_path) -> int:
|
||||
a = json.loads(Path(a_path).read_text(encoding="utf-8"))
|
||||
b = json.loads(Path(b_path).read_text(encoding="utf-8"))
|
||||
@@ -319,62 +522,174 @@ def compare(a_path, b_path) -> int:
|
||||
"are one tree twice. An identity is evidence only when the two "
|
||||
"sides could have differed.")
|
||||
return 2
|
||||
# ⚠️ TEL-12 HAD TO CHANGE THIS DISCRIMINATOR AND HERE IS WHY. TEL-11's rule
|
||||
# was "exactly ONE side's `set` document differs from its own `unset`" -
|
||||
# true while `release/1.0` predated `pv_entity` entirely. `release/1.0` is
|
||||
# 0.11.0 now, BOTH sides react to `pv_entity`, and that refusal would fire
|
||||
# on every honest run. The ROSTER is the key only the HEAD has heard of.
|
||||
for side, doc in (("A", a), ("B", b)):
|
||||
for which in ("unset", "one", "multi"):
|
||||
if which not in doc:
|
||||
print(f"REFUSED: side {side} has no {which!r} document. Both "
|
||||
f"sides must be produced by THIS version of the tool - "
|
||||
f"copy it into the baseline checkout rather than using "
|
||||
f"the one that is committed there.")
|
||||
return 2
|
||||
for side, doc in (("A", a), ("B", b)):
|
||||
for what in ("control", "telemetry"):
|
||||
if _collapse(doc["unset"][what]) is None:
|
||||
print(f"REFUSED: on side {side} the `unset` {what} document "
|
||||
f"does NOT collapse per grid world - two PV schedules in "
|
||||
f"one grid gave different answers with the channel off. "
|
||||
f"Nothing is read in that state, so this is the "
|
||||
f"instrument being wrong rather than the tree.")
|
||||
return 2
|
||||
moved = [side for side, doc in (("A", a), ("B", b))
|
||||
if doc["set"] != doc["unset"]]
|
||||
if not _reduces_to_unset(doc, "telemetry")]
|
||||
if len(moved) != 1:
|
||||
print(f"REFUSED: `set` differs from `unset` on {moved or 'NEITHER'} "
|
||||
"side. Exactly one side must react to `pv_entity` - the HEAD. "
|
||||
"Neither means the instrument cannot see this card's material "
|
||||
"at all, and both means the baseline is not a baseline.")
|
||||
print(f"REFUSED: `multi` fails to reduce to `unset` on "
|
||||
f"{moved or 'NEITHER'} side. Exactly one side may react to "
|
||||
f"`pv_entities` - the HEAD. Neither means the instrument cannot "
|
||||
f"see TEL-12's material at all; both means the baseline is not a "
|
||||
f"baseline.")
|
||||
return 2
|
||||
head, base = (b, a) if moved == ["B"] else (a, b)
|
||||
hlabel = "B" if moved == ["B"] else "A"
|
||||
blabel = "A" if hlabel == "B" else "B"
|
||||
print(f" HEAD is {hlabel}; baseline is {blabel} (it does not react to "
|
||||
f"`pv_entity`, which is what a tree without the option does).")
|
||||
print(f" HEAD is {hlabel}; baseline is {blabel} - its roster cells ARE its "
|
||||
f"unset cells, which is what a tree that has never heard of "
|
||||
f"`pv_entities` does with them.")
|
||||
print(f" supports_roster {blabel} "
|
||||
f"{base['tree'].get('supports_roster')} / {hlabel} "
|
||||
f"{head['tree'].get('supports_roster')} (self-reported; the verdict "
|
||||
f"above is MEASURED)")
|
||||
print(f" tree {blabel} {_h(base['tree'])}")
|
||||
print(f" tree {hlabel} {_h(head['tree'])}")
|
||||
for what in ("control", "telemetry"):
|
||||
print(f" {what:<9} {blabel} unset {_h(base['unset'][what])}")
|
||||
print(f" {what:<9} {hlabel} unset {_h(head['unset'][what])}")
|
||||
print(f" {what:<9} {hlabel} set {_h(head['set'][what])}")
|
||||
for side, doc, which in ((blabel, base, "unset"),
|
||||
(hlabel, head, "unset"),
|
||||
(blabel, base, "one"), (hlabel, head, "one"),
|
||||
(blabel, base, "multi"),
|
||||
(hlabel, head, "multi")):
|
||||
print(f" {what:<9} {side} {which:<5} {_h(doc[which][what])}")
|
||||
|
||||
rc = 0
|
||||
# 1. the acceptance criterion: unset is the baseline, in full.
|
||||
# 1. TEL-11's acceptance criterion: unset is the baseline, in full.
|
||||
if head["unset"] == base["unset"]:
|
||||
print("OK 1/3 UNSET: control AND telemetry are byte-identical to the "
|
||||
"baseline tree. An install that does not set `pv_entity` sees "
|
||||
"no change of any kind.")
|
||||
print("OK 1/5 UNSET: control AND telemetry are byte-identical to the "
|
||||
"baseline tree. An install that configures no PV entity sees no "
|
||||
"change of any kind.")
|
||||
else:
|
||||
rc = 1
|
||||
for what in ("control", "telemetry"):
|
||||
for k in sorted(set(head["unset"][what]) | set(base["unset"][what])):
|
||||
if head["unset"][what].get(k) != base["unset"][what].get(k):
|
||||
print(f"FAIL 1/3 UNSET {what} MOVED at {k!r}")
|
||||
# 2. the card's other half: setting it moves no DECISION.
|
||||
if head["set"]["control"] == head["unset"]["control"]:
|
||||
print("OK 2/3 SET: the control document is byte-identical with the "
|
||||
"channel switched on - PV reaches no decision.")
|
||||
print(f"FAIL 1/5 UNSET {what} MOVED at {k!r}")
|
||||
# 2. ⚠️ TEL-12's OWN ACCEPTANCE CRITERION, AND IT IS STRONGER THAN ANYTHING
|
||||
# TEL-11 COULD ASSERT. The baseline is 0.11.0, which HAS the channel, so
|
||||
# "one entity behaves exactly as 0.11.0 does" is a real cross-tree
|
||||
# identity over BOTH documents rather than an argument about f-strings.
|
||||
# The install that reported this defect has `pv_entity` set; this is the
|
||||
# line that says the upgrade does not move it.
|
||||
if head["one"] == base["one"]:
|
||||
print("OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to "
|
||||
"0.11.0's single-entity path - the same commands, the same "
|
||||
"payloads, the same status rows, the same announcement.")
|
||||
else:
|
||||
rc = 1
|
||||
for k in sorted(head["set"]["control"]):
|
||||
if head["set"]["control"][k] != head["unset"]["control"][k]:
|
||||
print(f"FAIL 2/3 CONTROL MOVED at {k!r} when `pv_entity` was "
|
||||
f"set")
|
||||
# 3. …and the instrument could have seen it. Without this the first two
|
||||
# are an identity measured against a document that reaches nothing.
|
||||
if head["set"]["telemetry"] != head["unset"]["telemetry"]:
|
||||
n = sum(1 for k in head["set"]["telemetry"]
|
||||
if head["set"]["telemetry"][k]
|
||||
!= head["unset"]["telemetry"].get(k))
|
||||
print(f"OK 3/3 CONTROL OF THE CONTROL: the telemetry document DOES "
|
||||
f"move when the option is set ({n} stimulus cells differ), so "
|
||||
f"1/3 and 2/3 are identities the instrument could have broken.")
|
||||
for what in ("control", "telemetry"):
|
||||
for k in sorted(set(head["one"][what]) | set(base["one"][what])):
|
||||
if head["one"][what].get(k) != base["one"][what].get(k):
|
||||
print(f"FAIL 2/5 ONE-ENTITY {what} MOVED at {k!r}")
|
||||
print(f" base {base['one'][what].get(k)!r}")
|
||||
print(f" head {head['one'][what].get(k)!r}")
|
||||
# 3. the card's other half at ONE entity - TEL-11's 2/3, kept verbatim in
|
||||
# substance because it is the claim that must not decay.
|
||||
if head["one"]["control"] == head["unset"]["control"]:
|
||||
print("OK 3/5 CONTROL, ONE ENTITY: the control document is "
|
||||
"byte-identical with the channel switched on - PV reaches no "
|
||||
"decision.")
|
||||
else:
|
||||
rc = 1
|
||||
for k in sorted(head["one"]["control"]):
|
||||
if head["one"]["control"][k] != head["unset"]["control"].get(k):
|
||||
print(f"FAIL 3/5 CONTROL MOVED at {k!r} with one entity set")
|
||||
# 4. ⚠️ AND AT A ROSTER OF THREE, AND WITH A HOLE IN IT. A PARTIAL total is
|
||||
# a DIFFERENT number from a full one, so "a partial reaches no decision"
|
||||
# is a separate claim and not a corollary of 3/5.
|
||||
_cref = _collapse(head["unset"]["control"])
|
||||
_bad = [k for k, v in head["multi"]["control"].items()
|
||||
if "/" in k and v != _cref.get(_grid(k))]
|
||||
# ⚠️ AND THE SAME DOCUMENT ACROSS THE TWO TREES, which is the strongest form
|
||||
# of the claim available: the baseline runs the roster cells with a key it
|
||||
# has never heard of, so its control document for them is 0.11.0's. If
|
||||
# HEAD's matches it byte for byte, the control path under a roster IS
|
||||
# 0.11.0's control path.
|
||||
_xtree = head["multi"]["control"] == base["multi"]["control"]
|
||||
if not _xtree:
|
||||
rc = 1
|
||||
print("FAIL 4/5 the roster control document is NOT the baseline "
|
||||
"tree's. A roster changed a decision.")
|
||||
if not _bad and _xtree and (head["multi"]["control"].get("STOPPED")
|
||||
== head["unset"]["control"].get("STOPPED")):
|
||||
print(f"OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control "
|
||||
f"document equals its grid world's `unset` one - "
|
||||
f"{len(head['multi']['control']) - 1} cells over "
|
||||
f"{len(_cref)} grid worlds, covering three healthy entities, one "
|
||||
f"dead at EVERY position, all three dead and one honestly "
|
||||
f"reporting 0 W. Neither the total nor any PARTIAL total reaches "
|
||||
f"a decision, and the document is byte-identical to the BASELINE "
|
||||
f"tree's for the same cells.")
|
||||
else:
|
||||
rc = 1
|
||||
for k in _bad or ["STOPPED"]:
|
||||
print(f"FAIL 4/5 CONTROL MOVED at {k!r} under a roster")
|
||||
# 5. …and the instrument could have seen it, twice over: the telemetry
|
||||
# document DOES move, and the partial sum a defect would have published
|
||||
# is on no PV row anywhere in it.
|
||||
n = sum(1 for k, v in head["multi"]["telemetry"].items()
|
||||
if "/" in k
|
||||
and v != _collapse(head["unset"]["telemetry"]).get(_grid(k)))
|
||||
leaks, missing = [], []
|
||||
for key, spec in head["multi"].get("partials", {}).items():
|
||||
rows = [str(f["pv"]) for f in head["multi"]["telemetry"][key]["frames"]
|
||||
if "pv" in f]
|
||||
if spec["never"] is not None and spec["never"] in rows:
|
||||
leaks.append((key, spec["never"]))
|
||||
# ⚠️ AND THE OTHER DIRECTION, because "never publishes 1650" is also
|
||||
# satisfied by a channel that publishes nothing ever. A cell whose
|
||||
# members all ANSWER must publish its total, including the one where a
|
||||
# member honestly reports 0 W.
|
||||
if spec["always"] is not None and set(rows) != {spec["always"]}:
|
||||
missing.append((key, spec["always"], sorted(set(rows))[:4]))
|
||||
if not head["multi"].get("partials"):
|
||||
print("REFUSED 5/5: the HEAD run carries no `partials` map, so the "
|
||||
"partial-sum assertion quantified over nothing.")
|
||||
return 2
|
||||
if n and not leaks and not missing:
|
||||
print(f"OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES "
|
||||
f"move under a roster ({n} stimulus cells differ from their grid "
|
||||
f"world's unset telemetry), so 1/5-4/5 are identities this "
|
||||
f"instrument could have broken. AND in no cell with a dead member "
|
||||
f"does the PV row carry the partial sum of the survivors, while "
|
||||
f"every cell whose members all ANSWER publishes its total - "
|
||||
f"including the one where a member honestly reports 0 W.")
|
||||
else:
|
||||
rc = 2
|
||||
print("REFUSED 3/3: the telemetry document did NOT move when "
|
||||
"`pv_entity` was set. This instrument cannot see the card's "
|
||||
"material, so nothing above it means anything.")
|
||||
if leaks:
|
||||
print(f"REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at "
|
||||
f"{leaks[:6]}{' …' if len(leaks) > 6 else ''} - a smaller "
|
||||
f"number indistinguishable from a real one, which is the "
|
||||
f"defect this card exists to prevent.")
|
||||
if missing:
|
||||
print(f"REFUSED 5/5: a cell whose members ALL ANSWERED did not "
|
||||
f"publish its total: {missing[:6]}. An absence published for "
|
||||
f"a healthy roster defeats the channel from the other end - "
|
||||
f"'no PV reported' on every clear evening.")
|
||||
if not leaks and not missing:
|
||||
print("REFUSED 5/5: the telemetry document did NOT move under a "
|
||||
"roster. This instrument cannot see TEL-12's material, so "
|
||||
"nothing above it means anything.")
|
||||
return rc
|
||||
|
||||
|
||||
@@ -384,7 +699,13 @@ if __name__ == "__main__":
|
||||
print(json.dumps({
|
||||
"tree": tree(),
|
||||
"stimulus": {"grids": sorted(GRIDS), "pv": sorted(PVS),
|
||||
"cycles": CYCLES},
|
||||
"rosters": sorted(ROSTERS), "entities": PV_ENTITIES,
|
||||
"roster_total_w": ROSTER_TOTAL_W, "cycles": CYCLES},
|
||||
"unset": documents(False),
|
||||
"set": documents(True),
|
||||
# ⚠️ `one` WAS CALLED `set` UNDER TEL-11 AND IS THE SAME STIMULUS SET,
|
||||
# renamed because there are now two ways to switch the channel on and
|
||||
# "set" no longer says which. Its `control` hashes are comparable to the
|
||||
# ones TEL-11 recorded; nothing about the cells moved.
|
||||
"one": documents(True),
|
||||
"multi": documents(False, roster=True),
|
||||
}, sort_keys=True, indent=1))
|
||||
|
||||
Reference in New Issue
Block a user