The foundation for the new settings page. No visible UI change. - app/uilayout.py: screen -> group -> part -> field for every option in config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display scale, step, entity kind, show conditions, Simple/wizard placement and "applies after a restart". Ranges stay in the schema, words in translations and the new app/ui_text/en.json. - app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one inverter vs the list, solar rows, static_profile from simple prices, a year-stamped region preset for the levies figure, and an OPT-11 hook for arbitrage_enabled that changes nothing yet). Unchanged derivations write the stored values back, so the round trip is exact. - validate.py: validate.Note, a warn/info tier. Each logged warning is also a note when a list is passed; config_notes() adds checks that need live data (missing entity, a kW sensor read as W, a limit below this month's peak). /config/validate returns them as "notes". No refusal changed: 261 recorded validator calls, 0 differ (tools/ux13_validator_calls.py). - GET ha/entities: Home Assistant's entity list for the picker, read through hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200 with the old list marked old, or an empty list that says why. - DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI code": keys may appear in uilayout.py and uimodel.py only, both tested. - Tests: test_uilayout, test_uimodel (round trip over the recorded calls, the TEST-23 option files and config.yaml defaults), test_haentities, a warning-tier part in test_validate; test_messages scans notes and ui_text; test_pv and test_selfconfig part 7 widened on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
1858 lines
104 KiB
Python
1858 lines
104 KiB
Python
"""Runnable check for the startup gate. `python3 test_validate.py`
|
|
|
|
Two halves, and the second is the one that matters. The first asserts that the
|
|
three refusable configurations are refused. The second asserts that everything
|
|
on validate.py's "deliberately not refused" list still STARTS - because the way
|
|
a validator dies is not by missing a fault, it is by refusing something harmless
|
|
until somebody deletes the call.
|
|
|
|
⚠️ MUTATING THE 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, and a size-preserving
|
|
edit reverted inside one second is invisible to CPython's staleness test. That
|
|
is TEST-15, and it has already turned one live mutation into a false survivor.
|
|
Read MUTATION.md before you report a survivor.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
|
|
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
|
|
from app.price import (
|
|
PRICE_OPTION_DEFAULTS, SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
|
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
|
|
from app import shadow
|
|
from app.validate import (
|
|
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
|
|
actuator_writers, refuse, site_rail_w, validate_startup_config,
|
|
)
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def base(**over):
|
|
"""A configuration that must start. Everything else is this, spoilt."""
|
|
# ⚠️ The maintenance rates are named EXPLICITLY, at the values config.yaml
|
|
# ships (MAINT-04). Under MAINT-04 that was load-bearing: with the key
|
|
# absent, rule 6 fell back to a 2500 W literal that against this 2000 W
|
|
# ceiling is a genuine refusal, so a base() without them was a config that
|
|
# does not start and every check below would have been measuring the wrong
|
|
# thing. CFG-03 closed that - the fallback is now config.yaml's own 2000 -
|
|
# so they are here to say out loud what this fixture asks for rather than to
|
|
# rescue it. The CFG-03 section at the foot of this file is what asserts
|
|
# that absent and present are now THE SAME NUMBER; do not delete these two
|
|
# keys, because a base() that agrees with the default by accident is a
|
|
# fixture that cannot tell you when the default moves.
|
|
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off",
|
|
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
|
o.update(over)
|
|
return o
|
|
|
|
|
|
def why(problems, needle):
|
|
return any(needle in p for p in problems)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the baseline must start, or nothing below means anything")
|
|
check("a good config with a 5000 W device is accepted",
|
|
validate_startup_config(base(), -5000, 5000) == [])
|
|
check("...and with no device reachable yet, too",
|
|
validate_startup_config(base(), None, None) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. max_w against the device's own rating")
|
|
# ⚠️ THE ORIGINAL BUG. main.py used to log 'the device wins' and run anyway,
|
|
# with write_setpoint() clamping, so the configured ceiling was never the real
|
|
# one and the only evidence had scrolled off the log.
|
|
p = validate_startup_config(base(max_w=8000), -5000, 5000)
|
|
check("max_w above the device maximum is refused", len(p) == 1)
|
|
check("...and the message names the configured value", why(p, "8000"))
|
|
check("...and the device's value", why(p, "5000"))
|
|
check("...and says what to lower it to", why(p, "Lower the rating to 5000 W"))
|
|
check("max_w exactly at the device maximum is fine",
|
|
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
|
|
check("max_w under it is fine",
|
|
validate_startup_config(base(max_w=2000), -5000, 5000) == [])
|
|
# The charge side is a separate rail and an asymmetric device is legal.
|
|
p = validate_startup_config(base(max_w=4000), -2500, 5000)
|
|
check("a max_w the device cannot take on the CHARGE side is refused too",
|
|
len(p) == 1 and why(p, "when charging"))
|
|
check("...naming the device's floor", why(p, "-2500"))
|
|
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
|
|
# re-runs this the moment the real rating arrives.
|
|
check("an unreachable device skips the range check rather than assuming",
|
|
validate_startup_config(base(max_w=8000), None, None) == [])
|
|
check("a non-numeric max_w is refused",
|
|
why(validate_startup_config(base(max_w="lots"), -5000, 5000), "not a number"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 1b. SAFETY-20's AC4 decision, PINNED HERE RATHER THAN ONLY WRITTEN DOWN
|
|
# --------------------------------------------------------------------------- #
|
|
# AC4 asked whether a device rating that disarms SAFETY-18's freeze rail
|
|
# additionally warrants a refusal of its own, the way `sign_disagree_w >= max_w`
|
|
# is refused. THE ANSWER IS NO, AND THE REASON IS RULE 2 ABOVE: the configuration
|
|
# is `max_w` above the device rating, which rule 2 already refuses on the
|
|
# identical predicate. A rail-specific rule (`0.95 * max_w > dev_max`) would fire
|
|
# on a strict SUBSET of what rule 2 refuses, so it could never be the rule that
|
|
# stopped a start.
|
|
#
|
|
# ⚠️ THAT DECISION HAS A PREMISE, AND THIS IS THE PREMISE, EXECUTED. If rule 2 is
|
|
# ever softened to a warning - which is exactly what main.py used to do before
|
|
# CFG-01 - the reasoning recorded in validate.py's "deliberately not refused"
|
|
# list silently stops being true, and SAFETY-18 is left with only its runtime
|
|
# NOT COVERED row. Then somebody has to come back and re-decide AC4.
|
|
from app.freeze import RAIL_FRAC # noqa: E402 - imported here, beside its use
|
|
|
|
_DEV = 4700.0 # the ticket's inverter
|
|
_CEIL = 5000.0 # the ticket's configured ceiling
|
|
check("SAFETY-20's premise: a rating below max_w disarms the freeze rail, so "
|
|
f"{RAIL_FRAC:g} * max_w is unreachable on the ticket's install",
|
|
_DEV < RAIL_FRAC * _CEIL)
|
|
check("...and rule 2 REFUSES that whole configuration at startup, which is why "
|
|
"SAFETY-20 added no rule of its own (AC4)",
|
|
len(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV)) == 1)
|
|
check("...on the rating side specifically, not incidentally through some other "
|
|
"rule", why(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV),
|
|
"Lower the rating to"))
|
|
check("...and the case the refusal CANNOT reach - the rating unread because "
|
|
"ESPHome was not up - still starts, which is precisely why SAFETY-20's "
|
|
"answer had to be a runtime status row and not a startup gate",
|
|
validate_startup_config(base(max_w=_CEIL), None, None) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("2. an actuator to write to")
|
|
# A controller with no actuator: write_setpoint() returns on the blank entity,
|
|
# so no rating is read, no heartbeat is sent, layer 1 pins the inverter at 0 W,
|
|
# and the add-on looks perfectly healthy the whole time.
|
|
for blank in ("", " ", None):
|
|
p = validate_startup_config(base(setpoint_entity=blank), None, None)
|
|
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
|
|
check("...and the message says what it costs: nothing is controlled",
|
|
why(validate_startup_config(base(setpoint_entity=""), None, None),
|
|
"cannot control the inverter"))
|
|
# --------------------------------------------------------------------------- #
|
|
# specs §1.5: TWO WRITERS ON ONE ACTUATOR, AND WHAT ACTUALLY GRADES IT
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ WHAT USED TO BE HERE, AND WHY IT WAS NOT ENOUGH. This was one line:
|
|
#
|
|
# check("there is exactly one way to reach the actuator",
|
|
# ACTUATOR_WRITE_KEYS == ("setpoint_entity",))
|
|
#
|
|
# and validate.py's comment said the §1.5 non-goal was ENFORCED by construction -
|
|
# exactly one such key, so a second writer is not expressible in the options
|
|
# schema at all. Both were true. Both graded THE SHAPE OF THE OPTIONS SCHEMA
|
|
# rather than the writes, and the difference is not academic: the config below
|
|
# has TWO WRITERS ON ONE ACTUATOR and leaves that tuple's length at 1. The check
|
|
# stays green while the property it protects is gone, which is this repository's
|
|
# named failure mode - a check that observes something other than what it claims
|
|
# to observe.
|
|
#
|
|
# So the first thing this section does is DEMONSTRATE the old check passing on
|
|
# the hazardous config, rather than assert in prose that it would have.
|
|
_PER_INVERTER_HAZARD = base(inverters=[
|
|
{"id": "inverter-a", "setpoint_entity": "number.goodwe_setpoint"},
|
|
# ⚠️ INVERTER B DOES NOT EXIST. Nothing is measured from it here; it is a
|
|
# config fixture, and the only thing that matters about it is that its
|
|
# `setpoint_entity` NAMES THE SAME ENTITY as the scalar key above - which is
|
|
# the shape of the mistake, and the shape CFG-04 makes reachable.
|
|
{"id": "inverter-b", "setpoint_entity": "number.goodwe_setpoint"}])
|
|
|
|
check("⚠️ THE DEFECT, DEMONSTRATED: on a per-inverter config with two writers "
|
|
"aimed at ONE entity, the old proxy is still green - it counts option "
|
|
"KEYS, and there is still exactly one key called setpoint_entity. This "
|
|
"is the check that could not see the thing it was named after",
|
|
len(ACTUATOR_WRITE_KEYS) == 1)
|
|
|
|
_census = actuator_writers(_PER_INVERTER_HAZARD)
|
|
print(f" the same config, censused by writer: "
|
|
f"{ {k: len(v) for k, v in _census.items()} }")
|
|
check("...and the replacement SEES it, because it counts writers per ACTUATOR "
|
|
"rather than keys: three declarations, one entity",
|
|
list(_census) == ["number.goodwe_setpoint"]
|
|
and len(_census["number.goodwe_setpoint"]) == 3)
|
|
_hazard_problems = validate_startup_config(_PER_INVERTER_HAZARD, -5000, 5000)
|
|
check("AC 3: and the config is REFUSED, where today it starts",
|
|
why(_hazard_problems, "point at the same inverter entity"))
|
|
check("...and the refusal names the actuator and every writer aimed at it, so "
|
|
"an operator can see WHICH declarations to remove rather than being told "
|
|
"only that something is wrong",
|
|
why(_hazard_problems, "number.goodwe_setpoint")
|
|
and why(_hazard_problems, "inverters[0].setpoint_entity")
|
|
and why(_hazard_problems, "inverters[1].setpoint_entity"))
|
|
|
|
# ⚠️ AND THE GATE MUST NOT REFUSE THE CONFIG CFG-04 IS ACTUALLY FOR. A validator
|
|
# that fires on the legitimate case gets switched off by the first person it
|
|
# inconveniences, and then none of this is enforced. Two inverters with two
|
|
# entities are two writers on TWO actuators, which is not the non-goal - it is
|
|
# the whole point of the per-inverter roster.
|
|
#
|
|
# ⚠️ THE ENTRIES BELOW GAINED `name` AND `max_w` WHEN CFG-04 LANDED, AND THE
|
|
# HAZARD FIXTURE ABOVE DELIBERATELY DID NOT. SAFETY-28 wrote both against a
|
|
# shape that did not exist yet and guessed `id`; CFG-04 chose `name`, and made
|
|
# `name`/`setpoint_entity`/`max_w` required of every entry (rule 9). The hazard
|
|
# fixture is left in the OLD guessed shape on purpose - the claim it makes is
|
|
# that `actuator_writers()` finds writers STRUCTURALLY, under a container and a
|
|
# key spelling this file was never told, and rewriting it into today's shape
|
|
# would quietly turn it into a check that the census knows about `inverters`.
|
|
# This one is the opposite claim - that a real, valid, two-inverter config is
|
|
# ACCEPTED - so it has to be a config the shipped validator actually accepts.
|
|
_PER_INVERTER_OK = {k: v for k, v in base().items() if k != "setpoint_entity"}
|
|
_PER_INVERTER_OK["inverters"] = [
|
|
{"name": "inverter-a", "setpoint_entity": "number.goodwe_setpoint",
|
|
"max_w": 2000, "bms_soc_floor": 10},
|
|
# ⚠️ INVERTER B DOES NOT EXIST. 2000 W here is the SHIPPED default repeated,
|
|
# not a rating measured off a second device - SPIKE-04 measures that when
|
|
# there is one to measure. Nothing in this file depends on the number.
|
|
# ⚠️ SAFETY-32 made `bms_soc_floor` a REQUIRED per-entry key, and the two
|
|
# values here DIFFER (10 / 15) rather than being copied. 15 % is SIMULATED -
|
|
# there is no second pack to read a cut-out off - and the reason it is not
|
|
# simply 10 is MUTATION.md habit 1: a fixture whose two per-unit numbers are
|
|
# equal cannot show a check reading the wrong unit's.
|
|
{"name": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b",
|
|
"max_w": 2000, "bms_soc_floor": 15}]
|
|
check("two inverters with their own entities are ACCEPTED - one writer each, "
|
|
"no shared actuator, and that is CFG-04's whole purpose",
|
|
validate_startup_config(_PER_INVERTER_OK, -5000, 5000) == [])
|
|
check("...and today's single-inverter config is untouched: one writer, one "
|
|
"actuator, and the census finds it wherever it is declared",
|
|
validate_startup_config(base(), -5000, 5000) == []
|
|
and actuator_writers(base()) ==
|
|
{"number.goodwe_setpoint": ["setpoint_entity"]})
|
|
|
|
# The census is found STRUCTURALLY, not by looking for a container called
|
|
# `inverters` - CFG-04 has not chosen that name yet, and a census that guessed
|
|
# wrong would go green while two writers existed, which is the same defect one
|
|
# step along.
|
|
check("a writer nested under a container this file has never heard of is "
|
|
"still counted, because the walk is over the options TREE and not over a "
|
|
"list of blessed key names",
|
|
len(actuator_writers({"setpoint_entity": "number.x",
|
|
"whatever_cfg04_calls_it": {
|
|
"unit_b": {"setpoint_entity": "NUMBER.X"}}})
|
|
["number.x"]) == 2)
|
|
check("...and entity ids are folded to lower case, so a second writer cannot "
|
|
"be smuggled past the census on Number.X versus number.x",
|
|
why(validate_startup_config(base(inverters=[{"setpoint_entity":
|
|
"NUMBER.GOODWE_SETPOINT"}]),
|
|
-5000, 5000),
|
|
"point at the same inverter entity"))
|
|
# ⚠️ THIS CHECK CHANGED SHAPE IN CFG-04, AND THE REASON IS A DECISION RATHER
|
|
# THAN AN ADJUSTMENT. It used to assert that a blank per-inverter entity left
|
|
# the whole config ACCEPTED - "blank is how an operator leaves a slot
|
|
# unconfigured". CFG-04 decided that is not true of a ROSTER ENTRY: an entry
|
|
# with no `setpoint_entity` is a unit that `allocator.plan()` still gives a
|
|
# share of the site target to and that nothing can command, on a status page
|
|
# reporting a site at full strength. Rule 9 refuses it by name. The claim about
|
|
# the CENSUS is untouched and is now asserted directly on the census, where it
|
|
# always belonged - a blank contributes no writer, so it cannot manufacture a
|
|
# two-writer refusal out of two unconfigured slots.
|
|
check("a BLANK per-inverter entity is not a writer - the same rule the scalar "
|
|
"key gets, since write_setpoint() returns immediately on a blank entity",
|
|
actuator_writers(base(inverters=[{"setpoint_entity": " "},
|
|
{"setpoint_entity": ""}]))
|
|
== {"number.goodwe_setpoint": ["setpoint_entity"]})
|
|
check("...but the ENTRY is refused all the same, by rule 9 and not by the "
|
|
"census: a roster slot nothing can write still takes a share of the site "
|
|
"target every cycle and delivers none of it",
|
|
why(validate_startup_config(base(inverters=[{"name": "inverter-b",
|
|
"max_w": 2000,
|
|
"setpoint_entity": " "}]),
|
|
-5000, 5000),
|
|
"has no “Inverter setpoint entity” (setpoint_entity)"))
|
|
|
|
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
|
|
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
|
|
# RUN, which is the bar every clause of this rule is held to. A capacity that is
|
|
# not a usable number is a pack the split would quietly stop preferring while
|
|
# the operator can see a value in the box; a capacity on SOME entries is worse,
|
|
# because `packshare.weights()` answers "no opinion" for the WHOLE roster in
|
|
# that case - so every key the operator filled in does exactly nothing and
|
|
# nothing says so.
|
|
def _with_cap(*caps):
|
|
o = {k: v for k, v in _PER_INVERTER_OK.items()}
|
|
o["inverters"] = [dict(e) for e in _PER_INVERTER_OK["inverters"]]
|
|
for entry, cap in zip(o["inverters"], caps):
|
|
if cap is not None:
|
|
entry["capacity_kwh"] = cap
|
|
return o
|
|
|
|
|
|
check("a roster stating a capacity on EVERY entry is accepted - and the two "
|
|
"differ (15.36 / 20.48, FROM DATASHEET for B), because a fixture whose "
|
|
"two per-unit numbers are equal cannot show a check reading the wrong "
|
|
"unit's",
|
|
validate_startup_config(_with_cap(15.36, 20.48), -5000, 5000) == [])
|
|
check("...and a roster stating NONE is accepted too, which is what OPTIONAL "
|
|
"means: the add-on has no opinion about the split and every unit weighs "
|
|
"1.0, exactly as it did before this card",
|
|
validate_startup_config(_with_cap(None, None), -5000, 5000) == [])
|
|
check("a capacity that is not a usable number of kWh is REFUSED, and the "
|
|
"refusal names the entry - zero, negative and a word alike",
|
|
all(why(validate_startup_config(_with_cap(15.36, bad), -5000, 5000),
|
|
"capacity_kwh") for bad in (0, -3, "twenty", 0.0)))
|
|
check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left empty "
|
|
"on the other. It is the clause that costs an operator something, and it "
|
|
"is the one worth having: a pack measured in kWh beside a sibling "
|
|
"weighing a dimensionless 1.0 is not a comparison, so the key would "
|
|
"silently change nothing at all",
|
|
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
|
"Set it on every inverter, or on none")
|
|
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
|
|
"Set it on every inverter, or on none"))
|
|
check("...and the refusal names WHICH entry is which, because an operator with "
|
|
"three inverters told 'one of them is wrong' is sent to the wrong "
|
|
"cupboard",
|
|
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
|
"inverter-a")
|
|
and why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
|
"inverter-b"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("3. a meter that can never read")
|
|
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
|
|
# also uses. Asserted here to prove the gate actually consults it - one entry
|
|
# point, not two.
|
|
check("meter_source off asks nothing of the P1 keys",
|
|
validate_startup_config(base(meter_source="off"), -5000, 5000) == [])
|
|
check("homewizard_local with no p1_host is refused",
|
|
why(validate_startup_config(base(meter_source="homewizard_local"),
|
|
-5000, 5000), "p1_host"))
|
|
check("...and with a host it is accepted",
|
|
validate_startup_config(base(meter_source="homewizard_local",
|
|
p1_host="192.168.2.250"), -5000, 5000) == [])
|
|
check("a poll cadence at meter_max_age_s is refused at STARTUP, not just disabled",
|
|
why(validate_startup_config(base(meter_source="homewizard_local",
|
|
p1_host="m", meter_poll_s=30,
|
|
meter_max_age_s=30), -5000, 5000),
|
|
"meter_max_age_s"))
|
|
check("ha_signed with no p1_net_entity is refused",
|
|
why(validate_startup_config(base(meter_source="ha_signed"), -5000, 5000),
|
|
"p1_net_entity"))
|
|
check("a phase list disagreeing with meter_phases is refused",
|
|
why(validate_startup_config(base(meter_source="ha_signed",
|
|
p1_net_entity="sensor.n", meter_phases=3,
|
|
p1_phase_net_entities=["a", "b"]),
|
|
-5000, 5000), "Make them match"))
|
|
check("mqtt_p1 with no topic is refused",
|
|
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
|
|
"meter_mqtt_topic"))
|
|
check("ha_dsmr with only half its entities is refused",
|
|
why(validate_startup_config(base(meter_source="ha_dsmr",
|
|
p1_import_entity="sensor.i"), -5000, 5000),
|
|
"p1_export_entity"))
|
|
check("a misspelt meter_source is refused rather than silently ignored",
|
|
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
|
|
"Choose one of"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("4. meter_max_age_s against the firmware's compile-time veto")
|
|
# ⚠️ The schema allows int(5,300); the firmware's threshold is baked in at 30 s
|
|
# and wins silently. Same shape as the max_w case: a configured bound overridden
|
|
# by a lower layer, with nothing saying so.
|
|
hw = dict(meter_source="homewizard_local", p1_host="m")
|
|
p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
|
|
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
|
|
check("...naming the configured value", why(p, "120"))
|
|
check("...naming the firmware's value", why(p, "30"))
|
|
check("...and saying that the control board stops the inverter first",
|
|
why(p, "control board stops the inverter after 30 s"))
|
|
check("the tested pair 30/30 is accepted",
|
|
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
|
|
check("one second above it is refused",
|
|
validate_startup_config(base(**hw, meter_max_age_s=31), -5000, 5000) != [])
|
|
# Below the veto the add-on gives up FIRST, which is fail-safe and visible on
|
|
# its own status page. Only above is the silent case.
|
|
check("below the veto is fine - the add-on being stricter is not a conflict",
|
|
validate_startup_config(base(**hw, meter_max_age_s=10,
|
|
meter_poll_s=4), -5000, 5000) == [])
|
|
# ⚠️ Scope: meter_max_age_s is read by nothing but P1Ingest, and the veto keys
|
|
# off a sensor only published when ingestion runs. With the meter off it governs
|
|
# nothing, so refusing would be pure trivia.
|
|
check("with meter_source off the number governs nothing and is not refused",
|
|
validate_startup_config(base(meter_source="off", meter_max_age_s=300),
|
|
-5000, 5000) == [])
|
|
check("the constant is the firmware's, not a rounded guess",
|
|
FIRMWARE_INGEST_VETO_S == 30.0)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("6. a maintenance charge the ceiling can actually accommodate (MAINT-04)")
|
|
# ⚠️ THE SHIPPED BUG. config.yaml shipped max_w 2000 against
|
|
# maintenance_charge_w 2500, so the cheap-window shaping asked for hi=-2500
|
|
# against the supervised lo=-2000 - lo > hi, arbiter clause 3, 0 W. Maintenance
|
|
# silently never charged in a cheap window on a default install.
|
|
p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
|
-5000, 5000)
|
|
check("maintenance_charge_w above max_w is refused", len(p) == 1)
|
|
check("...and the message names the charge rate", why(p, "2500"))
|
|
check("...and the ceiling", why(p, "2000"))
|
|
check("...and says what it blocks", why(p, "monthly recharge cannot run"))
|
|
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
|
|
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
|
|
# deleted, and it is the boundary the rule is written `>` for.
|
|
check("maintenance_charge_w exactly at max_w starts",
|
|
validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
|
-5000, 5000) == [])
|
|
check("below it starts",
|
|
validate_startup_config(base(max_w=2000, maintenance_charge_w=1500),
|
|
-5000, 5000) == [])
|
|
# Not scoped to maintenance_enabled: the pair is contradictory either way, and
|
|
# scoping would leave the trap armed for the one action - ticking the box - that
|
|
# cannot be expected to change what a charge rate means.
|
|
check("...and it is refused with maintenance_enabled off, as shipped",
|
|
why(validate_startup_config(
|
|
base(max_w=2000, maintenance_charge_w=2500,
|
|
maintenance_enabled=False), -5000, 5000),
|
|
"monthly recharge cannot run"))
|
|
|
|
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
|
|
# The comment this replaces in validate.py said, in as many words, that it took
|
|
# the FIRST unit's cap because there was exactly one `Maintenance` and it could
|
|
# only ever recalibrate `inverters[0]` - and that "when `maint_unit` stops being
|
|
# `inverters[0]`, this line moves with it". It has. Every pack is now
|
|
# recalibrated on its own schedule, so the shaping claim can be filed on any of
|
|
# them and the cap that contradicts it first is the smallest.
|
|
#
|
|
# ⚠️ THE FIXTURE'S TWO CAPS ARE DELIBERATELY DIFFERENT AND THE RATE SITS
|
|
# BETWEEN THEM. Two equal caps could not tell "grades the first" from "grades
|
|
# the smallest", and a rate above both would be refused by either rule - so
|
|
# 3000 W against caps of 5000 W and 2000 W is the one arrangement where the old
|
|
# answer and the new one disagree. ⚠️ 2000 W for the second pack is a
|
|
# HYPOTHESIS: inverter B does not physically exist and nothing here is measured
|
|
# from one.
|
|
def _roster(a_max, b_max, **over):
|
|
o = base(**over)
|
|
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
|
|
o.pop(k, None)
|
|
o["inverters"] = [
|
|
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
|
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
|
max_w=a_max),
|
|
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
|
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
|
max_w=b_max)]
|
|
return o
|
|
|
|
|
|
_two = _roster(5000, 2000, maintenance_charge_w=3000, maintenance_discharge_w=1000,
|
|
sign_disagree_w=300, unit_sign_disagree_w=300)
|
|
_p_two = [p for p in validate_startup_config(_two, -5000, 5000)
|
|
if "maintenance_charge_w" in p]
|
|
print(f" 5000 W and 2000 W packs, charge 3000 W -> {_p_two}")
|
|
check("⚠️ A CHARGE RATE THE SMALLEST PACK CANNOT TAKE IS REFUSED, even though "
|
|
"the FIRST pack could. Before MAINT-05 the second pack was never "
|
|
"recalibrated, so its cap could not contradict anything; it is now, and "
|
|
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
|
|
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
|
|
len(_p_two) == 1 and why(_p_two, "for inverter-b (inverters.max_w) is 2000 W"))
|
|
check("...and the message names the pack the number came off, not the first "
|
|
"entry - an operator told to lower a number on the wrong device is being "
|
|
"sent to the wrong device by the message that exists to stop that",
|
|
not why(_p_two, "for inverter-a (inverters.max_w)"))
|
|
check("...while the same rate against two packs that can both take it starts, "
|
|
"so this is a comparison and not a blanket refusal of rosters",
|
|
[p for p in validate_startup_config(
|
|
_roster(5000, 4000, maintenance_charge_w=3000,
|
|
maintenance_discharge_w=1000, sign_disagree_w=300,
|
|
unit_sign_disagree_w=300), -5000, 5000)
|
|
if "maintenance_charge_w" in p] == [])
|
|
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
|
|
"message, the same boundary, the same refusal the flat config always got",
|
|
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
|
-5000, 5000), "monthly recharge cannot run")
|
|
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
|
-5000, 5000) == [])
|
|
|
|
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
|
|
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
|
|
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
|
|
# names the clamp every cycle. Loud, and it still moves energy - so warned, not
|
|
# refused. If someone later "fixes the asymmetry" by making this a refusal,
|
|
# this check goes red and they have to justify it.
|
|
check("maintenance_discharge_w above max_w WARNS but starts",
|
|
validate_startup_config(base(max_w=2000, maintenance_discharge_w=2500),
|
|
-5000, 5000) == [])
|
|
|
|
# The demonstration the rule is derived from, asserted rather than described.
|
|
from app.arbiter import ( # noqa: E402
|
|
Claim, P_LOOP, P_MAINTENANCE, P_MAINT_SHAPE, P_SAFETY, resolve,
|
|
)
|
|
|
|
def _charge(max_w, charge_w):
|
|
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
|
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-charge_w,
|
|
reason="cheap-window charge"),
|
|
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
|
|
|
def _drain(max_w, disch_w):
|
|
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
|
Claim.set("maintenance", P_MAINTENANCE, disch_w, "drain"),
|
|
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
|
|
|
r = _charge(2000, 2500)
|
|
check("arbiter: the refused pair really does emit 0 W",
|
|
r.target_w == 0.0 and r.contradiction)
|
|
r = _charge(2000, 2000)
|
|
check("arbiter: the permitted equality really does charge",
|
|
r.target_w == -2000.0 and not r.contradiction)
|
|
r = _charge(3000, 2500)
|
|
check("arbiter: raising max_w above the rate charges at the rate",
|
|
r.target_w == -2500.0 and not r.contradiction)
|
|
r = _drain(2000, 2500)
|
|
check("arbiter: the drain SET is clamped, not contradicted",
|
|
r.target_w == 2000.0 and not r.contradiction)
|
|
check("...and the clamp names itself, which is why it is not refused",
|
|
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("10. a slew rate the plant can actually follow (SAFETY-39)")
|
|
# ⚠️ THIS RULE GRADES A RELATIONSHIP, WHICH IS THE POINT OF IT. Every other rule
|
|
# in this file compares a configured number against another configured number
|
|
# and refuses one ordering. This one refuses a RATIO: `slew_w` may not exceed
|
|
# `SLEW_RAIL_FRAC * rail`, so the same `slew_w` is fine at one rating and
|
|
# refused at another, and raising the rating moves the bound with it instead of
|
|
# leaving the constant behind. That is the failure the ticket is about -
|
|
# `slew_w: 1000` was derived at a 1500 W clamp, the live install went to 5000,
|
|
# and nothing anywhere said the derivation no longer held.
|
|
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = "
|
|
f"{LOOP_TICK_S:.0f} s / {PLANT_SETTLE_S:.0f} s = {SLEW_RAIL_FRAC:.4f}")
|
|
|
|
_R5000 = base(max_w=5000, maintenance_charge_w=2000, maintenance_discharge_w=2000)
|
|
check("the rail this rule grades is the SITE rail, and at the shipped roster of "
|
|
"one it is `max_w` exactly - byte for byte the number the single-inverter "
|
|
"shape has always meant",
|
|
site_rail_w(_R5000) == 5000.0)
|
|
|
|
# ⭐ VERIFIED BY BREAKING IT. This is the acceptance criterion stated as a test:
|
|
# put the 2026-09-05 pair back and the gate must go red. A rule that could not
|
|
# be made to fire on the configuration it was written for is a rule nobody has
|
|
# checked.
|
|
_broken = validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000)
|
|
print(f" restoring slew_w 1000 at max_w 5000 -> {len(_broken)} problem(s)")
|
|
if _broken:
|
|
print(f" {_broken[0][:160]}...")
|
|
check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSED. "
|
|
"It crosses the whole output range in 10 s against a plant measured at "
|
|
"18 s, which is not a fast loop, it is a loop that commands its entire "
|
|
"range before it can observe its answer to the first step",
|
|
len(_broken) == 1 and why(_broken, "(slew_w) is 1000 W"))
|
|
check("...and the refusal says WHICH RAIL it graded against, because 'slew_w "
|
|
"is too high' sends an operator to lower a number when raising max_w or "
|
|
"setting 0 are both correct answers too",
|
|
why(_broken, "for a 5000 W site"))
|
|
# UX-08 2c: the plant measurement the rule is graded against (the ~18 s step
|
|
# response) is in DOCS.md "For installers", beside slew_w, not in the refusal.
|
|
check("...and it says what to DO, including the setting that cannot go stale",
|
|
why(_broken, "Set it to 0") and why(_broken, "556 W"))
|
|
|
|
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
|
|
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
|
|
# cross its range, which is the constraint met. Rule 6 permits equality for the
|
|
# same shape of reason and rule 5 refuses it for the opposite one; this is
|
|
# rule 6's case, and getting it backwards would refuse the value this rule
|
|
# exists to recommend.
|
|
_bound = SLEW_RAIL_FRAC * 5000.0
|
|
check(f"at exactly the derived value ({_bound:.0f} W) the config STARTS - "
|
|
"equality is the constraint satisfied, not violated",
|
|
validate_startup_config(dict(_R5000, slew_w=int(_bound)), -5000, 5000) == [])
|
|
check("...and one watt of slack above it starts too, because a boundary that "
|
|
"is not meaningful to one watt must not refuse on one watt - an operator "
|
|
"reading 556 off a status page that rounded it is not misconfigured",
|
|
validate_startup_config(dict(_R5000, slew_w=556), -5000, 5000) == [])
|
|
check("...and well above it does not",
|
|
validate_startup_config(dict(_R5000, slew_w=600), -5000, 5000) != [])
|
|
|
|
# SLOWER IS ALWAYS ACCEPTED, and the live install is the reason it has to be.
|
|
check("the interim that is holding on the live system - slew_w 400 against "
|
|
"max_w 5000, applied 2026-09-05 12:00 - STARTS. It is 12.5 ticks to cross "
|
|
"the rail against the 9 this rule requires, and refusing 'too slow' would "
|
|
"refuse the mitigation",
|
|
validate_startup_config(dict(_R5000, slew_w=400), -5000, 5000) == [])
|
|
check("...and so does the schema floor at the biggest rail, because there is no "
|
|
"such thing as too slow here",
|
|
validate_startup_config(dict(_R5000, slew_w=50), -5000, 5000) == [])
|
|
|
|
# ⚠️ 0 IS NOT GRADED AND MUST NOT BE. It means "follow the rail" and satisfies
|
|
# this rule by construction at every rail there is; a rule that refused it would
|
|
# refuse the fix. Checked at three ratings so that "it passes" cannot be an
|
|
# accident of the default one.
|
|
# ⚠️ NO SLEW PROBLEM AT ANY RATING, rather than NO PROBLEMS AT ALL. At the
|
|
# schema floor of 100 W rules 5 and 5b fire on the shipped sign thresholds,
|
|
# which is correct and is not this rule's business - asserting an empty list
|
|
# would make this check fail for a reason it is not about, and somebody would
|
|
# then "fix" it by moving a sign threshold.
|
|
_zero_problems = {m: [p for p in validate_startup_config(
|
|
base(max_w=m, slew_w=0,
|
|
maintenance_charge_w=min(2000, m),
|
|
maintenance_discharge_w=min(2000, m)),
|
|
-abs(m), abs(m)) if "slew_w" in p]
|
|
for m in (100, 500, 2000, 5000, 15000)}
|
|
check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
|
|
"must not be, because it is the shipped value and it satisfies this rule "
|
|
f"by construction at every rail there is: {_zero_problems}",
|
|
not any(_zero_problems.values()))
|
|
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
|
|
"is a magnitude, and 0 already has a meaning here",
|
|
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
|
|
"cannot be negative"))
|
|
|
|
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
|
|
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
|
|
# `compute()` emits the SITE target. So the rail is the roster sum, and adding
|
|
# an inverter moves this bound with no operator action and no second derivation.
|
|
#
|
|
# ⚠️ INVERTER B'S 4600 W IS ITS DATASHEET AND SERIAL, NOT A MEASUREMENT, AND
|
|
# INVERTER B IS NOT ON SITE (SPIKE-04, ~2026-09-12; goodwe/NOTES.md §1.1b). It
|
|
# is used here because what is graded is the ARITHMETIC OF THE RULE, which does
|
|
# not depend on the number being measured - not because 9600 W is a fact about
|
|
# this house yet.
|
|
def _roster(a=5000, b=4600, **over):
|
|
o = base(setpoint_entity="", inverters=[
|
|
{"name": "inverter-a", "setpoint_entity": "number.a", "max_w": a,
|
|
"bms_soc_floor": 10, "soc_entity": "sensor.sa", "batt_entity": "sensor.ba"},
|
|
{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": b,
|
|
"bms_soc_floor": 10, "soc_entity": "sensor.sb", "batt_entity": "sensor.bb"}])
|
|
o.pop("max_w"); o.pop("soc_entity"); o.pop("batt_entity")
|
|
o.update(over)
|
|
return o
|
|
|
|
|
|
_site = site_rail_w(_roster())
|
|
print(f" roster 5000 + 4600 (B FROM DATASHEET, NOT MEASURED) -> site rail "
|
|
f"{_site:.0f} W, bound {SLEW_RAIL_FRAC * _site:.0f} W")
|
|
check("the rail is the roster SUM and not the weakest unit, which is what "
|
|
"separates this rule from 5, 6 and 8 - those grade one watchdog, one "
|
|
"shaping claim and one pack, and this grades the range one SITE COMMAND "
|
|
"is a rate across",
|
|
_site == 9600.0)
|
|
check("⭐ AND THE BOUND MOVES ON ITS OWN WHEN THE ROSTER DOES: the derived value "
|
|
"at a 9600 W site is 1067 W with nobody re-deriving anything, which is "
|
|
"the property that makes this a fix rather than a re-tune",
|
|
abs(SLEW_RAIL_FRAC * _site - 9600.0 / 9.0) < 1e-9)
|
|
check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
|
|
"ACCEPTED at 9600 - the bound is on how long the crossing TAKES, and a "
|
|
"bigger rail at the same step size takes longer to cross. ⚠️ This is the "
|
|
"direction of the rule stated as a test, because it is not the direction "
|
|
"the ticket assumed: a growing rail LOOSENS this bound. What the rule "
|
|
"protects against is the constant being left behind when the rail moves; "
|
|
"it is not a bound on how much the site may command, and it does not "
|
|
"bound the over-absorption, which is a `gain` term SAFETY-39 measured "
|
|
"and deliberately did not change",
|
|
validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000) != []
|
|
and validate_startup_config(_roster(slew_w=1000)) == [])
|
|
check("...and a slew that outruns even the doubled rail is still refused there, "
|
|
"so the roster path is not simply un-graded",
|
|
why(validate_startup_config(_roster(slew_w=2000)),
|
|
"for a 9600 W site"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
|
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
|
# refuses. Read config.yaml itself rather than a transcription of it - a
|
|
# transcription is what lets the two drift apart silently. Regex, not PyYAML:
|
|
# the add-on image has no PyYAML (Dockerfile installs py3-aiohttp/py3-paho-mqtt
|
|
# only) and a check that skips on ImportError is not a check.
|
|
import os # noqa: E402
|
|
import re # noqa: E402
|
|
|
|
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
|
with open(_CFG, encoding="utf-8") as _fh:
|
|
_txt = _fh.read()
|
|
# ⚠️ `slew_w` JOINED THIS SET AT SAFETY-39 AND IT IS THE INTERESTING MEMBER.
|
|
# The others are refused when they are too big for `max_w`; this one is refused
|
|
# when it is too big for `max_w` AS A RATIO, so shipping the wrong number here
|
|
# would be shipping the exact defect the card is about with the gate that
|
|
# catches it standing right beside it. Reading it out of config.yaml is what
|
|
# makes "the shipped value is 0" a fact rather than a claim.
|
|
_shipped = {k: float(v) for k, v in re.findall(
|
|
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w"
|
|
r"|slew_w): (-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
|
check("config.yaml really was read (all five defaults found)",
|
|
set(_shipped) == {"max_w", "maintenance_charge_w",
|
|
"maintenance_discharge_w", "sign_disagree_w", "slew_w"})
|
|
check(f"...and the shipped slew_w is 0, i.e. FOLLOW THE RAIL - the one value "
|
|
f"that cannot be left behind when max_w moves (got {_shipped.get('slew_w')})",
|
|
_shipped.get("slew_w") == 0.0)
|
|
check(f"the shipped defaults are accepted {_shipped}",
|
|
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
|
check("...and the shipped charge rate is at or under the shipped ceiling",
|
|
_shipped["maintenance_charge_w"] <= _shipped["max_w"])
|
|
check("...and the shipped discharge rate too, so it warns about nothing",
|
|
_shipped["maintenance_discharge_w"] <= _shipped["max_w"])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the shipped meter_freeze_s, pinned (TEST-06 / AC3)")
|
|
# ⚠️ WHY A NUMBER GETS ITS OWN CHECK. `meter_freeze_s: 300` is the threshold
|
|
# SAFETY-18's entire argument is about, and before this it was pinned NOWHERE:
|
|
# 300 -> 3600 left test_freeze.py AND this file green, because the schema
|
|
# accepts int(60,3600) and no check ever read the shipped value. The schema
|
|
# bounds what is CONFIGURABLE; this pins what is SHIPPED, which is a different
|
|
# question and the one the docs answer.
|
|
#
|
|
# Read by regex like _shipped above, for the same reason: no PyYAML in the
|
|
# image, and a check that skips on ImportError is not a check.
|
|
_freeze_s = [float(v) for v in re.findall(
|
|
r"^ meter_freeze_s: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
|
_freeze_schema = re.findall(r"^ meter_freeze_s: int\((\d+),(\d+)\)\s*$",
|
|
_txt, re.M)
|
|
check(f"config.yaml carries exactly one meter_freeze_s default {_freeze_s}",
|
|
len(_freeze_s) == 1)
|
|
check("...and exactly one schema range for it", len(_freeze_schema) == 1)
|
|
_hold_s = _freeze_s[0] if _freeze_s else None
|
|
_lo, _hi = ((float(_freeze_schema[0][0]), float(_freeze_schema[0][1]))
|
|
if _freeze_schema else (None, None))
|
|
check("the shipped meter_freeze_s is still the measured 300 s that freeze.py, "
|
|
"config.yaml's own comment and test_freeze.py are all written at",
|
|
_hold_s == 300.0)
|
|
check(f"...and it is inside its own schema range ({_lo}-{_hi} s)",
|
|
None not in (_lo, _hi, _hold_s) and _lo <= _hold_s <= _hi)
|
|
|
|
# The two properties the number has to have, DERIVED rather than restated - so
|
|
# they go red on a value that is merely schema-legal. Both come straight from
|
|
# config.yaml's own comment on the key.
|
|
#
|
|
# upper: the alert must land inside the 900 s quarter that bills the capacity
|
|
# tariff, or the notification arrives after the money is spent;
|
|
# lower: the slowest a HEALTHY meter can tick a 1 Wh register and still look
|
|
# unchanged for hold_s is 3600/hold_s W, and that must stay inside the
|
|
# deadband or the conjunction's two terms stop being mutually
|
|
# exclusive (freeze.py, "WHERE hold_s COMES FROM").
|
|
_QUARTER_S = 900.0
|
|
_shipped_deadband = [float(v) for v in re.findall(
|
|
r"^ deadband_w: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
|
check(f"config.yaml carries exactly one deadband_w default {_shipped_deadband}",
|
|
len(_shipped_deadband) == 1)
|
|
_db = _shipped_deadband[0] if _shipped_deadband else None
|
|
_slowest_healthy_w = 3600.0 / _hold_s if _hold_s else None
|
|
print(f" shipped meter_freeze_s {_hold_s} s: alert inside the "
|
|
f"{_QUARTER_S:.0f} s billing quarter, and a healthy meter slow enough to "
|
|
f"look unchanged for it is {_slowest_healthy_w} W against a "
|
|
f"{_db} W deadband")
|
|
check(f"...the alarm still lands inside the {_QUARTER_S:.0f} s billing quarter",
|
|
_hold_s is not None and _hold_s < _QUARTER_S)
|
|
check("...and a healthy meter that looks unchanged for it is still inside the "
|
|
"deadband, so the two terms stay mutually exclusive",
|
|
_slowest_healthy_w is not None and _db is not None
|
|
and _slowest_healthy_w < _db)
|
|
|
|
# ⚠️ AND THE RANGE'S OWN BOUNDS. "300 sits inside the range" is worth nothing
|
|
# while the range itself is unpinned: widened to `int(1,86400)` an operator can
|
|
# configure `meter_freeze_s: 86400` and the guard is dead for a day at a time,
|
|
# with every check in this repo still green. That mutation survived the first
|
|
# cut of this PR and is what this block closes. Both numbers are argued for in
|
|
# config.yaml's own comment and in freeze.py, and each is re-derived here:
|
|
#
|
|
# 60 s the floor freeze.py calls NOT A RECOMMENDATION - there a healthy
|
|
# meter can look unchanged at 60 W, four times the shipped deadband, so
|
|
# the conjunction's two terms stop being mutually exclusive and only
|
|
# the margin is left;
|
|
# 3600 s the ceiling - already four times the billing quarter, so the top of
|
|
# the range has given up detecting a freeze inside the quarter that
|
|
# bills it. It is the last bound between a supported configuration and
|
|
# a disabled guard, and moving it is a decision rather than a tidy-up.
|
|
print(f" schema range {_lo}-{_hi} s: at the floor a healthy meter that looks "
|
|
f"unchanged is "
|
|
+ (f"{3600.0 / _lo:.0f} W" if _lo else "?")
|
|
+ f" against a {_db} W deadband; the ceiling is "
|
|
+ (f"{_hi / _QUARTER_S:.1f}x" if _hi else "?")
|
|
+ " the billing quarter")
|
|
check(f"the schema range itself is still the argued 60-3600 s (found "
|
|
f"{_lo}-{_hi})", (_lo, _hi) == (60.0, 3600.0))
|
|
check("...a floor that gives up the mutual exclusion and keeps only the "
|
|
"margin, which is why config.yaml calls it not a recommendation",
|
|
None not in (_lo, _db) and 3600.0 / _lo > _db)
|
|
check("...and a ceiling already past the billing quarter, so no value the "
|
|
"schema still accepts can alarm inside the quarter that bills it",
|
|
_hi is not None and _hi > _QUARTER_S)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("max_w must be a positive magnitude")
|
|
# Schema-blocked on a real install, but validate_startup_config() is also
|
|
# sim/inject.py's entry point and can be handed any dict at all.
|
|
for bad in (0, -1, -2000):
|
|
p = validate_startup_config(base(max_w=bad), -5000, 5000)
|
|
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "Set it above 0 W"))
|
|
check("...and the message names the setting by its label and its key",
|
|
why(validate_startup_config(base(max_w=0), -5000, 5000),
|
|
"“Inverter rating” (max_w) is 0 W"))
|
|
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
|
|
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
|
|
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
|
|
# would report itself armed while being incapable of firing. What this check is
|
|
# FOR is "a positive max_w is not itself a refusal", so it now names a sign
|
|
# threshold the ceiling can actually reach, and the interaction gets its own
|
|
# check below.
|
|
check("the smallest sane max_w still starts",
|
|
validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
|
unit_sign_disagree_w=0.5,
|
|
maintenance_charge_w=1), -5000, 5000) == [])
|
|
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
|
|
why(validate_startup_config(base(max_w=1), -5000, 5000), "can never trip"))
|
|
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
|
|
# and the fixture above now has to name BOTH thresholds - which is the check
|
|
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
|
|
# unit's own SHARE, which the same `supervised` claim holds at +-max_w, so at or
|
|
# above the ceiling it is unsatisfiable by construction exactly as
|
|
# `sign_disagree_w` is. Refused even at the shipped roster of one, where this
|
|
# watchdog is not armed: a config that cannot work is refused when it is
|
|
# written, not on the day somebody adds a second inverter.
|
|
check("...and the per-inverter threshold has the same rule, named separately "
|
|
"so an operator is told WHICH watchdog they disarmed",
|
|
why(validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
|
maintenance_charge_w=1), -5000, 5000),
|
|
"unit_sign_disagree_w"))
|
|
check("...and one watt under that ceiling is reachable, so accepted",
|
|
validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
|
|
-5000, 5000) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("what must NOT be refused - a validator that fires on trivia gets deleted")
|
|
# DOCS.md: the arbiter's `supervised` limit still binds the wire to +-max_w, so
|
|
# the only cost of the extra headroom is unwind latency after saturation.
|
|
check("integrator_max_w above max_w warns but starts",
|
|
validate_startup_config(base(max_w=2000, integrator_max_w=9000),
|
|
-5000, 5000) == [])
|
|
check("integrator_max_w 0 (follow max_w) starts",
|
|
validate_startup_config(base(integrator_max_w=0), -5000, 5000) == [])
|
|
# Fail-safe AND already a red NOT READY banner naming the entity: a refusal on
|
|
# top of that adds nothing an operator can act on.
|
|
check("a missing soc_entity is left to the readiness banner",
|
|
validate_startup_config(base(soc_entity=""), -5000, 5000) == [])
|
|
check("a missing batt_entity likewise",
|
|
validate_startup_config(base(batt_entity=""), -5000, 5000) == [])
|
|
# The transient case. ESPHome is routinely not up when the add-on boots, and
|
|
# refusing here would turn a device that arrives late into a restart loop.
|
|
check("a setpoint entity that is configured but not yet found starts",
|
|
validate_startup_config(base(), None, None) == [])
|
|
check("meter_poll_s with thin but non-zero headroom starts",
|
|
validate_startup_config(base(meter_source="homewizard_local", p1_host="m",
|
|
meter_poll_s=20, meter_max_age_s=30),
|
|
-5000, 5000) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the refusal must be legible as a refusal, not as a crash")
|
|
p = validate_startup_config(base(max_w=8000, setpoint_entity=""), -5000, 5000)
|
|
check("several problems are all reported, not just the first", len(p) >= 1)
|
|
refuse(p) # prints the banner; the eye is the assertion for its wording
|
|
err = ConfigRefused(p)
|
|
check("ConfigRefused carries every problem", err.problems == p)
|
|
check("...and str() names one of them", str(err) == p[0])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the LATE refusal, when the device only shows up after the gate has run")
|
|
# ⚠️ This path had no test at all and no live demonstration - the review's
|
|
# catch. ESPHome is routinely not up when the add-on boots, so hass.limits()
|
|
# returns (None, None), the range checks are skipped, and run_control() is the
|
|
# first place the device's real rating is ever known. Everything below is about
|
|
# what happens when it turns out to conflict.
|
|
from app.main import Controller # noqa: E402
|
|
|
|
|
|
class _LateHass:
|
|
"""Reports the range only run_control() gets to see."""
|
|
|
|
def __init__(self, lo, hi):
|
|
self.lo, self.hi = lo, hi
|
|
self.writes = []
|
|
|
|
async def limits(self, entity):
|
|
return (self.lo, self.hi, 1.0)
|
|
|
|
async def number(self, *a, **k):
|
|
return 0.0
|
|
|
|
async def set_number(self, entity, value):
|
|
self.writes.append(value)
|
|
return True
|
|
|
|
|
|
class _Store2:
|
|
data = {}
|
|
|
|
def set(self, *a):
|
|
pass
|
|
|
|
|
|
class _Pub2:
|
|
enabled = False
|
|
|
|
def publish(self, *a, **k):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
def late(opts_over, lo, hi):
|
|
"""Drive run_control() against a device that only reports (lo, hi) now.
|
|
|
|
⚠️ Returns whether run_control() EXITED ON ITS OWN. That is the assertion
|
|
that matters: on a refusal it must return without entering the while loop,
|
|
and on a clean config it must not return at all. So the clean case is stopped
|
|
from outside after it has had time to write, rather than waited on - a
|
|
wait_for() here would just time out, which is the correct behaviour dressed
|
|
up as a failure.
|
|
"""
|
|
async def go():
|
|
stop = asyncio.Event()
|
|
hass = _LateHass(lo, hi)
|
|
# A device narrower than the shipped 5000 W schema ceiling is exactly
|
|
# the case that cannot be reached through the Supervisor on the live
|
|
# install, so it is reached here instead.
|
|
c = Controller(base(**opts_over), hass, _Store2(), _Pub2(), stop=stop)
|
|
c.auto = True
|
|
task = asyncio.create_task(c.run_control())
|
|
for _ in range(30): # up to ~3 s
|
|
if task.done():
|
|
break
|
|
await asyncio.sleep(0.1)
|
|
exited = task.done()
|
|
if not exited:
|
|
c.stopping = True
|
|
await asyncio.wait_for(task, timeout=5)
|
|
return c, stop, hass, exited
|
|
return asyncio.run(go())
|
|
|
|
c, stop, hass, exited = late({"max_w": 5000}, -3600.0, 3600.0)
|
|
check("a device narrower than max_w, discovered late, is refused",
|
|
c.refused is not None and len(c.refused) == 1)
|
|
check("...naming both values", why(c.refused, "5000") and why(c.refused, "3600"))
|
|
check("...signalling amain to wind down", stop.is_set())
|
|
check("...and returning instead of looping forever", exited)
|
|
# The whole point: run_control() must not fall through into the while loop. A
|
|
# single write here would mean it commanded the inverter under a config it had
|
|
# just declared unusable.
|
|
check("...having never entered the control loop, so it wrote nothing",
|
|
hass.writes == [])
|
|
check("...and it is recorded in the event log an operator reads",
|
|
any("REFUSED" in e for e in c.events))
|
|
|
|
# The same path when there is no conflict: it must NOT refuse, and it must
|
|
# actually regulate - asserted by a write reaching the device, not by absence.
|
|
c2, stop2, hass2, exited2 = late({"max_w": 2000}, -5000.0, 5000.0)
|
|
check("a late device that agrees does not trip the re-check", c2.refused is None)
|
|
check("...and the control loop runs, writing to the device", hass2.writes != [])
|
|
check("...and does not signal a shutdown", not stop2.is_set() and not exited2)
|
|
|
|
# ⚠️ THE FOOTGUN THE REVIEW FOUND. `stop` used to default to None, so this
|
|
# construction was legal and the refusal path then returned into a live process
|
|
# with no control loop, no exit and no further output.
|
|
try:
|
|
Controller(base(), _LateHass(-5000.0, 5000.0), _Store2(), _Pub2())
|
|
check("a Controller with no stop Event is rejected at construction", False)
|
|
except TypeError:
|
|
check("a Controller with no stop Event is rejected at construction", True)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("CFG-03: the key ABSENT is the SAME NUMBER everywhere, through real code")
|
|
# ⚠️ WHAT THIS REPLACES, AND WHY IT IS THE OPPOSITE ASSERTION. MAINT-04 left a
|
|
# TRIPWIRE here whose PASSING CONDITION WAS THE BUG: it asserted that
|
|
# config.yaml said 2000 while main.py's two `opts.get(..., 2500)` fallbacks and
|
|
# MaintConfig's dataclass default said 2500, and it went red if EITHER side
|
|
# moved - including when somebody closed the divergence. That check has been
|
|
# DELETED, as it and validate.py's ⚠️ block both instructed, and this is what
|
|
# stands in its place: the same four consumers, exercised the same way, now
|
|
# agreeing.
|
|
#
|
|
# The four are the four the defect ran through. The validator ACCEPTS a dict
|
|
# with the key missing; main.py BUILDS a MaintConfig from that same dict; the
|
|
# dataclass answers on its own with no dict at all; and the arbiter is what
|
|
# turns their disagreement into a number an operator can see. Any three of them
|
|
# agreeing was never the property that mattered.
|
|
_absent = {k: v for k, v in base().items()
|
|
if not k.startswith("maintenance_")}
|
|
_ship_charge = _shipped["maintenance_charge_w"]
|
|
_ship_drain = _shipped["maintenance_discharge_w"]
|
|
|
|
check("the validator still ACCEPTS a dict with every maintenance key missing - "
|
|
"the fixture is the reachable one, not a refusal in disguise",
|
|
validate_startup_config(_absent, -5000, 5000) == [])
|
|
|
|
# 1. the mirror against the shipped file. Read, not transcribed: a transcription
|
|
# is the thing that drifted.
|
|
check(f"the ONE in-code mirror equals what config.yaml ships "
|
|
f"(charge {MAINT_OPTION_DEFAULTS['maintenance_charge_w']}, "
|
|
f"shipped {_ship_charge:.0f})",
|
|
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]) == _ship_charge
|
|
and float(MAINT_OPTION_DEFAULTS["maintenance_discharge_w"]) == _ship_drain)
|
|
|
|
# 2. MaintConfig on its own - the one that bites when main.py is bypassed
|
|
# entirely, which is sim/inject.py and most of this suite.
|
|
check(f"MaintConfig() answers the shipped number, not a third one "
|
|
f"({MaintConfig().charge_w:.0f} W)",
|
|
MaintConfig().charge_w == _ship_charge
|
|
and MaintConfig().discharge_w == _ship_drain)
|
|
|
|
# 3. main.py's own path, through a real Controller rather than through a regex
|
|
# over its source. A source scan can only say the literal is gone; this says
|
|
# the object an operator's install actually runs on holds the right number.
|
|
_ctl_absent = Controller(_absent, None, _Store2(), _Pub2(), stop=asyncio.Event())
|
|
print(f" a Controller built from a dict with NO maintenance key: "
|
|
f"charge_w={_ctl_absent.maint.cfg.charge_w:.0f} W, "
|
|
f"discharge_w={_ctl_absent.maint.cfg.discharge_w:.0f} W")
|
|
check("main.py builds the shipped number from a dict that does not contain it",
|
|
_ctl_absent.maint.cfg.charge_w == _ship_charge
|
|
and _ctl_absent.maint.cfg.discharge_w == _ship_drain)
|
|
# ...and it is the SAME number when the operator supplies it explicitly, which
|
|
# is what "the default is not a special case" means.
|
|
_ctl_named = Controller(base(), None, _Store2(), _Pub2(), stop=asyncio.Event())
|
|
check("...and an explicit key builds the identical config, so absent and "
|
|
"present are not two code paths",
|
|
_ctl_named.maint.cfg == _ctl_absent.maint.cfg)
|
|
|
|
# 4. the arbiter - the consequence itself. Under MAINT-04 this pair was
|
|
# (2000, 2500) and resolved to 0 W with contradiction=True; that was the
|
|
# ACCEPTED-then-contradicted hole. The rates come out of the built config,
|
|
# not out of a literal, so if the single source moves this moves with it.
|
|
_r_ok = _charge(float(_absent["max_w"]), _ctl_absent.maint.cfg.charge_w)
|
|
print(f" arbiter with that config: {_r_ok.explain()}")
|
|
check("the arbiter CHARGES instead of contradicting itself - the hole MAINT-04 "
|
|
"pinned open is closed at the only place it was ever reachable",
|
|
_r_ok.target_w == -_ship_charge and not _r_ok.contradiction)
|
|
_r_drain = _drain(float(_absent["max_w"]), _ctl_absent.maint.cfg.discharge_w)
|
|
check("...and the drain SET is not clamped either, so the twin key the card "
|
|
"did not name is closed too",
|
|
_r_drain.target_w == _ship_drain and not _r_drain.contradiction)
|
|
|
|
# 5. nothing typed a number behind the mirror's back. Rule 6's fallback and
|
|
# main.py's are the two sites the divergence lived at, so they are the two
|
|
# sites a scan is worth running over.
|
|
_srcs = {name: open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"app", name), encoding="utf-8").read()
|
|
for name in ("main.py", "validate.py", "maintenance.py")}
|
|
_literals = {name: re.findall(
|
|
r'"(maintenance_[a-z_]+)"\s*,\s*(-?\d+(?:\.\d+)?)\s*[,)]', src)
|
|
for name, src in _srcs.items()}
|
|
check(f"no numeric fallback for any maintenance_* key survives in main.py, "
|
|
f"validate.py or maintenance.py {_literals}",
|
|
not any(_literals.values()))
|
|
check("...and MaintConfig's dataclass defaults carry no numeric literal either "
|
|
"- they are indexed out of the mirror",
|
|
not re.search(r"^\s*(charge_w|discharge_w):\s*float\s*=\s*-?\d",
|
|
_srcs["maintenance.py"], re.M))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("SPIKE-04a: inverter B's documented rating, and the provenance beside it")
|
|
# ⚠️ WHY A DOCUMENTATION CARD LEFT A CHECK BEHIND. Inverter B's rating was
|
|
# written down in THREE places - config.yaml's commented roster example,
|
|
# DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator would
|
|
# POST" - as 3000 W, a figure nobody had measured, read off a datasheet, or
|
|
# sourced at all. It was wrong: SPIKE-04a (2026-09-04) identified inverter B
|
|
# from the owner's model number and serial as a GoodWe GW5048D-ES rated 4600 W
|
|
# on the battery path. Three transcriptions of one number drifting away from
|
|
# the truth together is this repo's recurring defect in its plainest form, and
|
|
# correcting them once WITHOUT PINNING THEM buys nothing: the next editor
|
|
# touches one of the three and the other two stay wrong, silently.
|
|
#
|
|
# ⚠️ AND THE PROVENANCE IS GRADED, NOT ONLY THE VALUE, WHICH IS THE HALF THAT
|
|
# MATTERS. 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT. The datasheet
|
|
# prints it under BATTERY INPUT DATA, so it is probably DC, while `max_w` is
|
|
# graded against 0x0518 - the inverter's own AC power - which would put the AC
|
|
# ceiling nearer 4324 W. An editor who tidies that caveat away leaves a paper
|
|
# number reading as an observation of hardware that does not exist yet, and
|
|
# THAT is the failure this card exists to prevent. So the caveat is what these
|
|
# checks reach for; a check that graded 4600 alone would watch the value and
|
|
# miss the lie.
|
|
#
|
|
# ⚠️ WHAT THIS DELIBERATELY DOES NOT GRADE: the test fixtures. Several files
|
|
# build a second inverter at 3000 W on purpose, because the property those
|
|
# checks consume is that B's rating is UNMISTAKABLY not A's, and the real pair
|
|
# (5000/4600) is a 400 W gap - narrower than `slew_w`. Those are illustrative,
|
|
# they say so at the line, and they are not statements of B's rating. The three
|
|
# sites below are the ones that ARE such statements.
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_docs_txt = open(os.path.join(_HERE, "DOCS.md"), encoding="utf-8").read()
|
|
_probe_txt = open(os.path.join(_HERE, "tools", "supervisor_shape_probe.py"),
|
|
encoding="utf-8").read()
|
|
|
|
# One regex per site, each anchored on something only THAT site has, so a match
|
|
# cannot silently wander into a neighbouring blob. The probe's anchor is the
|
|
# operator blob's real entity name: its section D refusal entries use
|
|
# `number.b` and one of them carries a deliberate 9999, so an anchor loose
|
|
# enough to reach them would grade the wrong dict and still look green.
|
|
_b_rating = {
|
|
"config.yaml": re.search(
|
|
r"#\s+- name: inverter-b\b(?:[^\n]*\n)*?\s*#\s+max_w:\s*(\d+)", _txt),
|
|
"DOCS.md": re.search(
|
|
r"^ - name: inverter-b\b(?:[^\n]*\n)*?^ max_w:\s*(\d+)",
|
|
_docs_txt, re.M),
|
|
"supervisor_shape_probe.py": re.search(
|
|
r'"name": "inverter-b",\s*"setpoint_entity": "number\.goodwe_b_setpoint_w"'
|
|
r'(?:[^}]*?)"max_w":\s*(\d+)', _probe_txt),
|
|
}
|
|
check(f"all three documented rosters were FOUND "
|
|
f"{ {k: bool(v) for k, v in _b_rating.items()} } - a regex that matches "
|
|
"nothing is a green check that read nothing",
|
|
all(_b_rating.values()))
|
|
# ⚠️ THE `len(...) == 3` IS NOT DECORATION. Without it a regex that stopped
|
|
# matching would shrink this dict, and "all the values agree" would go GREEN on
|
|
# the two sites that were still read - a check whose data no longer reaches the
|
|
# failure it names, which is the exact fault this whole section is about.
|
|
_b_vals = {k: int(m.group(1)) for k, m in _b_rating.items() if m}
|
|
check(f"...and all three state the SAME rating for inverter B {_b_vals}",
|
|
len(_b_vals) == 3 and len(set(_b_vals.values())) == 1)
|
|
check(f"...and it is 4600 W - the GW5048D-ES datasheet figure, not the 3000 W "
|
|
f"hypothesis that predated the unit being identified {_b_vals}",
|
|
len(_b_vals) == 3 and set(_b_vals.values()) == {4600})
|
|
|
|
# The provenance triple. Each site must still say WHERE 4600 came from, that it
|
|
# is graded against an AC register, and what the DC-vs-AC ambiguity does to it -
|
|
# the last carried by the 4324 W figure, which is the one marker that cannot
|
|
# survive a careless "it's 4600 now, done" rewrite.
|
|
#
|
|
# ⚠️ SEARCHED IN A WINDOW AROUND THE ROSTER, NOT OVER THE WHOLE FILE, AND THE
|
|
# FIRST VERSION OF THIS CHECK DID THE LATTER. `0x0518` already appeared once in
|
|
# config.yaml and once in DOCS.md before this card, for unrelated reasons - so a
|
|
# whole-file `"0x0518" in src` was GREEN on those two files no matter what
|
|
# happened next to inverter B's rating. That is a check whose data cannot reach
|
|
# the failure it names, in the same file that exists to complain about them.
|
|
# The window is the text either side of the roster match, so deleting the caveat
|
|
# beside the number goes red even where the same word survives elsewhere.
|
|
_sites = {"config.yaml": _txt, "DOCS.md": _docs_txt,
|
|
"supervisor_shape_probe.py": _probe_txt}
|
|
_MARKERS = ("datasheet", "0x0518", "4324")
|
|
_prov = {}
|
|
for _name, _src in _sites.items():
|
|
_m = _b_rating[_name]
|
|
_win = _src[max(0, _m.start() - 2500):_m.end() + 2500].lower() if _m else ""
|
|
_prov[_name] = {k: k in _win for k in _MARKERS}
|
|
for _name, _got in sorted(_prov.items()):
|
|
check(f"{_name} still carries B's provenance WITHIN 2500 chars of the "
|
|
f"roster - the datasheet it came from, the 0x0518 it is graded "
|
|
f"against, and the 4324 W the DC reading would make it {_got}",
|
|
all(_got.values()))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("rules 5, 5b and 6 on a ROSTER SET - the smallest cap at EVERY position")
|
|
# ⚠️ SAFETY-32 ROUND 5, AND THE REASON THIS SECTION EXISTS AT ALL. PR #48 was
|
|
# rejected on FOUR MUTATIONS THAT SURVIVED A 19/19 GREEN SUITE, all of them in
|
|
# the three lines above:
|
|
#
|
|
# validate.py:779 min(caps) -> min(caps[:1]) +4 B GREEN
|
|
# validate.py:779 min(caps) -> min(caps[-1:]) +5 B GREEN
|
|
# validate.py:778 caps = [cap for _, cap in caps_ix[:1]] +4 B GREEN
|
|
# validate.py:893 min(caps_ix[-1:], key=lambda p: p[1]) +5 B GREEN
|
|
#
|
|
# NONE of them is an equivalent mutant: each one deletes a safety refusal.
|
|
# `max_w: 5000` on inverter-a beside `2000` on inverter-b with
|
|
# `sign_disagree_w: 3000` is refused by the shipped validator with "the
|
|
# SAFETY-02 sign-disagreement watchdog can never fire" and ACCEPTED by every one
|
|
# of the four - the site starts, with a watchdog that reports `armed` on the
|
|
# status page and cannot trip on pack B. That is rule 5's own failure mode
|
|
# reached THROUGH rule 5.
|
|
#
|
|
# WHY THE OLD FIXTURE COULD NOT SEE IT, which is the whole lesson and is now
|
|
# MUTATION.md habit 10: the fixture was ONE roster, `[5000, 2000]`. On that
|
|
# roster `min` and `last` and `[-1:]` and `[:2]` all answer 2000. A fixture of N
|
|
# members cannot separate two reductions that agree on N members, and adding a
|
|
# member moves the hole instead of closing it.
|
|
#
|
|
# SO THE FIXTURE IS A SET, NOT A ROSTER: the smallest cap at EVERY POSITION of
|
|
# rosters of width 2, 3, 4 and 5. `[:1]` needs width 2 to be caught, `[:2]`
|
|
# needs 3, `[:3]` needs 4 - the sweep is what terminates the regress that a
|
|
# single fixture cannot. And the FAMILY TABLE below is COMPUTED AND PRINTED, so
|
|
# a member no fixture kills shows up as a NAMED SURVIVOR rather than as an
|
|
# absence from a list nobody re-derived.
|
|
#
|
|
# ⚠️ THE DIFFERING CAPS ARE A FIXTURE PROPERTY AND MUST STAY ONE. In production
|
|
# both packs are expected to floor at 10 % and SPIKE-04a puts inverter B at
|
|
# 4600 W against A's 5000 W - a 400 W gap, narrower than slew_w. A fixture built
|
|
# on the real pair would be degenerate on exactly the axis these checks measure.
|
|
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (SPIKE-04, ~2026-09-12). Nothing
|
|
# in this section is measured off a second unit; the rosters are simulated and
|
|
# are labelled so.
|
|
_BIG, _SMALL = 5000.0, 2000.0
|
|
_RATE = 3000.0 # strictly between them, which is the point
|
|
|
|
|
|
def _roster_n(caps, **over):
|
|
"""A roster of len(caps) units, unit-0..unit-N, each with its own cap."""
|
|
o = base(**over)
|
|
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
|
|
o.pop(k, None)
|
|
o["inverters"] = [
|
|
dict(name=f"unit-{i}", setpoint_entity=f"number.sp{i}",
|
|
soc_entity=f"sensor.soc{i}", batt_entity=f"sensor.batt{i}",
|
|
max_w=c, bms_soc_floor=10)
|
|
for i, c in enumerate(caps)]
|
|
o.update(over)
|
|
return o
|
|
|
|
|
|
# The three rules that reduce over `unit_caps_indexed()`, each as
|
|
# (option name, the token its refusal is recognised by).
|
|
_CAP_RULES = (
|
|
("sign_disagree_w", "(sign_disagree_w) is"),
|
|
("unit_sign_disagree_w", "(unit_sign_disagree_w) is"),
|
|
("maintenance_charge_w", "monthly recharge cannot run"),
|
|
)
|
|
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
|
|
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
|
|
|
|
# THE SET: width x position of the one small cap.
|
|
_SET = []
|
|
for _n in (2, 3, 4, 5):
|
|
for _pos in range(_n):
|
|
_caps = [_BIG] * _n
|
|
_caps[_pos] = _SMALL
|
|
_SET.append((_n, _pos, tuple(_caps)))
|
|
|
|
# habit 1: the PRECONDITION as its own check. If a later edit makes the caps
|
|
# equal, this goes red rather than the claims below going quietly vacuous.
|
|
check("PRECONDITION: every fixture in the set has a UNIQUE smallest cap, that "
|
|
"cap is at the stated position, and the rate under test sits STRICTLY "
|
|
"between it and the others - two equal caps could not tell `min` from "
|
|
"`first` from `last` and the whole set would be measuring nothing",
|
|
len(_SET) == 14
|
|
and all(c[p] == _SMALL and c.count(_SMALL) == 1 and _SMALL < _RATE < _BIG
|
|
for _n2, p, c in _SET)
|
|
and {p for _n2, p, _c in _SET} == {0, 1, 2, 3, 4})
|
|
|
|
|
|
def _verdict(opts, token):
|
|
"""(refused?, the unit the message NAMES, the cap it QUOTES)."""
|
|
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
|
|
if not ps:
|
|
return (False, None, None)
|
|
m = re.search(r"for (\S+) \(inverters\.max_w\),? (?:is|at) (\d+) W",
|
|
ps[0])
|
|
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
|
|
|
|
|
|
# --- the shipped answer, on every member of the set -------------------------
|
|
_rows = []
|
|
for _opt, _token in _CAP_RULES:
|
|
for _n, _pos, _caps in _SET:
|
|
_o = _roster_n(list(_caps), **dict(_QUIET, **{_opt: _RATE}))
|
|
_rows.append((_opt, _n, _pos, _caps, _verdict(_o, _token)))
|
|
print(f" {len(_CAP_RULES)} rules x {len(_SET)} rosters = {len(_rows)} runs "
|
|
f"of the REAL validator (rate {_RATE:.0f} W, caps {_SMALL:.0f}/"
|
|
f"{_BIG:.0f} W, simulated rosters - inverter B does not exist):")
|
|
for _opt in sorted({r[0] for r in _rows}):
|
|
_got = [(r[1], r[2], r[4]) for r in _rows if r[0] == _opt]
|
|
print(f" {_opt:<22} " + " ".join(
|
|
f"n{n}p{p}:{'REFUSE ' + str(u) if ok else 'starts'}"
|
|
for n, p, (ok, u, _c) in _got))
|
|
|
|
check("⚠️ EVERY ONE OF THE THREE CAP RULES REFUSES ON EVERY MEMBER OF THE SET, "
|
|
"and NAMES THE UNIT THE NUMBER CAME OFF - at width 2, 3, 4 and 5, with "
|
|
"the small cap at every position in turn. A reduction that answered "
|
|
"`first`, `last`, `[:1]`, `[-1:]`, `[:2]` or `[:-1]` instead of `min` "
|
|
"misses at least one member of this set, which is what four hand-run "
|
|
"mutations against a 19/19 green suite proved the single `[5000, 2000]` "
|
|
"fixture could not do",
|
|
all(ok and unit == f"unit-{p}" and cap == _SMALL
|
|
for _o2, _n2, p, _c2, (ok, unit, cap) in _rows))
|
|
|
|
check("...and it is a COMPARISON, not a blanket refusal of rosters: the same "
|
|
"three rates against a roster whose caps can ALL take them start "
|
|
"cleanly, at every width. Without this the line above is satisfied by a "
|
|
"validator that refuses everything (habit 2 in the negative direction)",
|
|
all(_verdict(_roster_n([_BIG] * n, **dict(_QUIET, **{opt: _RATE})),
|
|
tok) == (False, None, None)
|
|
for opt, tok in _CAP_RULES for n in (1, 2, 3, 4, 5)))
|
|
|
|
# --- the POSITIONAL-SLICE FAMILY TABLE, computed and printed ----------------
|
|
# ⚠️ COMPUTED, NOT LISTED. Each candidate is applied to the SAME caps the
|
|
# validator saw, and its (unit, cap) answer is compared against what the shipped
|
|
# validator actually said on that roster. A candidate that agrees everywhere is
|
|
# a reduction this fixture set CANNOT distinguish from the shipped one, and it
|
|
# is printed as a SURVIVOR rather than left out of a table nobody re-derived.
|
|
def _pick(caps, fn):
|
|
ix = list(enumerate(caps))
|
|
try:
|
|
i, c = fn(ix)
|
|
except (ValueError, IndexError):
|
|
return None
|
|
return (f"unit-{i}", c)
|
|
|
|
|
|
_FAMILY = {
|
|
"min (SHIPPED)": lambda ix: min(ix, key=lambda p: p[1]),
|
|
"max": lambda ix: max(ix, key=lambda p: p[1]),
|
|
"first": lambda ix: ix[0],
|
|
"last": lambda ix: ix[-1],
|
|
"min(caps[:1])": lambda ix: min(ix[:1], key=lambda p: p[1]),
|
|
"min(caps[-1:])": lambda ix: min(ix[-1:], key=lambda p: p[1]),
|
|
"min(caps[:2])": lambda ix: min(ix[:2], key=lambda p: p[1]),
|
|
"min(caps[:3])": lambda ix: min(ix[:3], key=lambda p: p[1]),
|
|
"min(caps[1:])": lambda ix: min(ix[1:], key=lambda p: p[1]),
|
|
"min(caps[:-1])": lambda ix: min(ix[:-1], key=lambda p: p[1]),
|
|
"min(caps[::2])": lambda ix: min(ix[::2], key=lambda p: p[1]),
|
|
"mean": lambda ix: (0, sum(c for _i, c in ix) / len(ix)),
|
|
}
|
|
_survivors = []
|
|
print(" the family table - each candidate against what the SHIPPED "
|
|
"validator answered on the same roster:")
|
|
for _name, _fn in _FAMILY.items():
|
|
_misses = []
|
|
for _opt, _n, _pos, _caps, (_ok, _unit, _cap) in _rows:
|
|
_pred = _pick(list(_caps), _fn)
|
|
_pred_refuses = _pred is not None and _pred[1] > 0 and _RATE >= _pred[1]
|
|
if _opt == "maintenance_charge_w":
|
|
_pred_refuses = (_pred is not None and _pred[1] > 0
|
|
and _RATE > _pred[1])
|
|
_said = ((True, _pred[0], _pred[1]) if _pred_refuses
|
|
else (False, None, None))
|
|
if _said != (_ok, _unit, _cap):
|
|
_misses.append(f"{_opt[:4]}/n{_n}p{_pos}")
|
|
if not _misses and _name != "min (SHIPPED)":
|
|
_survivors.append(_name)
|
|
print(f" {_name:<16} {'AGREES EVERYWHERE (survivor)' if not _misses else 'differs on ' + str(len(_misses)) + ' run(s), first: ' + _misses[0]}")
|
|
check("⚠️ AND THE FAMILY TABLE HAS NO SURVIVOR BUT THE SHIPPED REDUCTION. "
|
|
"Eleven candidates - max, first, last, the mean, and every positional "
|
|
"slice this card has actually been defeated by - are each computed "
|
|
"against what the real validator answered on the same roster, and every "
|
|
"one of them differs somewhere in the set. This is the check that would "
|
|
"have taken PR #48 red four rounds ago: a member no fixture kills is "
|
|
"PRINTED AS A NAMED SURVIVOR here instead of being absent from a list "
|
|
f"nobody re-derived. survivors: {_survivors}",
|
|
_survivors == [])
|
|
|
|
# --- the ROSTER OF ONE, where every one of them is the same thing -----------
|
|
_at_one = {n: _pick([_SMALL], f) for n, f in _FAMILY.items()}
|
|
print(f" at a ROSTER OF ONE the twelve candidates answer: "
|
|
f"{sorted({str(v) for v in _at_one.values()})}")
|
|
check("...and at a ROSTER OF ONE every candidate that still has a member to "
|
|
"reduce answers THE SAME THING - which is exactly why no "
|
|
"single-inverter fixture in this repository could ever have caught any "
|
|
"of this, and why the shipped single-inverter refusals are what they "
|
|
"always were. The only two that do NOT coincide are `caps[1:]` and "
|
|
"`caps[:-1]`, which EMPTY the list and would raise: at N=1 they are "
|
|
"crashes rather than silent wrong answers, so they are not survivors "
|
|
"there either",
|
|
{v for v in _at_one.values() if v is not None} == {("unit-0", _SMALL)}
|
|
and {n for n, v in _at_one.items() if v is None}
|
|
== {"min(caps[1:])", "min(caps[:-1])"}
|
|
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
|
|
"(sign_disagree_w) is")[0])
|
|
|
|
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
|
|
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
|
|
# PIN - and said that way on purpose, because it is the shape of what the pin
|
|
# cannot see. `unit_sign_disagree_w` is SAFETY-30's, not this card's. It was
|
|
# graded against `_number(opts, "max_w", 2000.0)`, the FLAT option, three lines
|
|
# under a branch that had already stopped doing that. Both directions were
|
|
# measured before the fix and both are asserted here.
|
|
_leftover = _roster_n([_SMALL, _SMALL],
|
|
**dict(_QUIET, unit_sign_disagree_w=_RATE))
|
|
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
|
|
_lv = _verdict(_leftover, "(unit_sign_disagree_w) is")
|
|
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
|
|
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
|
|
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
|
|
"WATCHDOG SILENTLY. Two 2000 W units beside a `max_w: 5000` left over "
|
|
"from a single-inverter install and `unit_sign_disagree_w: 3000` used to "
|
|
"produce ZERO problems - the site started, the status page said `armed`, "
|
|
"and the watchdog could not trip on EITHER unit. It is refused now, "
|
|
"against the weakest unit's own cap, and the message names that unit",
|
|
_lv == (True, "unit-0", _SMALL))
|
|
check("...and the over-refusal in the OTHER direction is gone too: a roster of "
|
|
"two 5000 W units with NO flat `max_w` was refused at 3000 W against a "
|
|
"2000 W DEFAULT the operator never wrote, for a threshold both units can "
|
|
"reach. It starts",
|
|
_verdict(_roster_n([_BIG, _BIG],
|
|
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
|
|
"(unit_sign_disagree_w) is")
|
|
== (False, None, None))
|
|
check("...and the flat single-inverter config it always refused is refused "
|
|
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
|
|
"the same comparison, the same boundary and the same sentence",
|
|
why(validate_startup_config(base(max_w=2000, unit_sign_disagree_w=2000),
|
|
-5000, 5000), "unit_sign_disagree_w")
|
|
and validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
|
|
-5000, 5000) == [])
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("TARIFF-17 - the taxes period table is refused HERE, at startup, and not "
|
|
"at 03:00")
|
|
# ⚠️ WHY IT IS REFUSED IN THIS MODULE AND NOT AT FETCH TIME. `price.assemble()`
|
|
# reads this table on the ~30 s planning path. A table that only fails when a
|
|
# fetch happens is a site that stops pricing mid-afternoon, and §11.4 turns
|
|
# that into a quiet fallback indistinguishable on the status page from a
|
|
# healthy fixed-tariff day. That is `static_profile`'s argument, one option
|
|
# over, and this is the same rule.
|
|
_T17_DAG, _T17_NACHT = 0.1327, 0.0739
|
|
_T18_SHIPPED_TAXES = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
|
_T18_SUPERSEDED = tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
|
_T17_OK = ('{"periods":['
|
|
'{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",'
|
|
f'"eur_per_kwh":{_T17_DAG}}},'
|
|
'{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri",'
|
|
f'"eur_per_kwh":{_T17_NACHT}}},'
|
|
'{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun",'
|
|
f'"eur_per_kwh":{_T17_NACHT}}}]}}')
|
|
|
|
# ⚠️ THE ACCEPTED BASELINE COMES FIRST (MUTATION.md habit 7). Every refusal
|
|
# below varies ONE thing from this line, so a refusal on the next line has
|
|
# nowhere else to have come from.
|
|
check("ORES's real dag/nacht table STARTS - it is a valid configuration, and "
|
|
"the refusals below are one edit away from this one",
|
|
validate_startup_config(base(price_taxes_profile=_T17_OK),
|
|
-5000, 5000) == [])
|
|
check("...and so does the shipped \"\", which is every install that exists: "
|
|
"the option is an addition, not a requirement",
|
|
validate_startup_config(base(price_taxes_profile=""), -5000, 5000) == []
|
|
and validate_startup_config(base(), -5000, 5000) == [])
|
|
|
|
_T17_GAP = ('{"periods":[{"name":"dag","from":"07:00","to":"22:00",'
|
|
f'"days":"all","eur_per_kwh":{_T17_DAG}}}]}}')
|
|
_T17_P_GAP = validate_startup_config(base(price_taxes_profile=_T17_GAP),
|
|
-5000, 5000)
|
|
print(f" {(_T17_P_GAP or [''])[0][:120]}")
|
|
check("a table that leaves 00:00-07:00 unpriced is REFUSED, by the same tiling "
|
|
"rule static_profile has - and the refusal names the KEY and the HOURS, "
|
|
"not merely that something was wrong",
|
|
why(_T17_P_GAP, "“Day/night levies and VAT” cannot be used.")
|
|
and why(_T17_P_GAP, "leaves mon 00:00-07:00 with no period"))
|
|
check("...and it is refused for THAT reason and not as a side effect of "
|
|
"something else this fixture happens to break: exactly one problem",
|
|
len(_T17_P_GAP) == 1)
|
|
|
|
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY WINS. The table decides
|
|
# when it is set, so a scalar left at a NON-DEFAULT value beside it is a number
|
|
# the operator typed that changes nothing - on the quantity §11.4 makes hardest
|
|
# to notice.
|
|
_T17_P_BOTH = validate_startup_config(
|
|
base(price_taxes_profile=_T17_OK, price_taxes_eur_kwh=0.1198), -5000, 5000)
|
|
print(f" {(_T17_P_BOTH or [''])[0][:120]}")
|
|
check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
|
|
"refusal says WHICH ONE WINS and what to do - not merely that the two "
|
|
"are incompatible",
|
|
why(_T17_P_BOTH, "(price_taxes_profile) is set, so")
|
|
and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used. Set it "
|
|
"back to"))
|
|
check("...and the SHIPPED scalar beside the table is NOT refused, or every "
|
|
"Walloon site would have to clear a key it never touched. The refusal is "
|
|
"about a value the operator chose, and the shipped default is the one "
|
|
"they did not",
|
|
validate_startup_config(
|
|
base(price_taxes_profile=_T17_OK,
|
|
price_taxes_eur_kwh=_T18_SHIPPED_TAXES),
|
|
-5000, 5000) == []
|
|
and len(_T17_P_BOTH) == 1)
|
|
|
|
# ⚠️ TARIFF-18 - "UNTOUCHED" IS A SET, AND THIS IS THE HALF THAT DECIDES
|
|
# WHETHER AN UPGRADE STOPS A RUNNING SITE. The Supervisor sets `App.options =
|
|
# deepmerge(config.yaml defaults, stored options)`, so a saved blob carries
|
|
# every schema key whether the operator typed it or not - the reference
|
|
# install's export is 70 of 71 (CHANGELOG 0.9.0). When TARIFF-18 moved this
|
|
# default 0.06 -> 0.11736, every such blob went from "equal to the shipped
|
|
# default" to "not equal to it" WITHOUT ANYBODY EDITING ANYTHING. A site that
|
|
# also carries a taxes table would have met `SystemExit(78)` on the upgrade,
|
|
# over a number it never typed and that decides nothing. So a SUPERSEDED
|
|
# default still reads as untouched.
|
|
#
|
|
# ⚠️ AND THE PRECONDITION FIRST (MUTATION.md habit 1): if the shipped default
|
|
# and the superseded one were ever the same number, the two checks below would
|
|
# be one check written twice, and dropping the superseded set entirely would
|
|
# leave both green.
|
|
check(f"the shipped taxes default {_T18_SHIPPED_TAXES} and the superseded "
|
|
f"{_T18_SUPERSEDED} are DIFFERENT numbers, so 'the shipped one is "
|
|
f"accepted' and 'the superseded one is accepted' are two claims",
|
|
_T18_SHIPPED_TAXES not in _T18_SUPERSEDED and len(_T18_SUPERSEDED) >= 1)
|
|
check(f"...and the superseded set is non-empty ({_T18_SUPERSEDED}), or the "
|
|
f"loop below quantifies over nothing and passes on a tree that dropped "
|
|
f"the set entirely - UX-01 AC 4's vacuous clause, one file over",
|
|
len(_T18_SUPERSEDED) >= 1)
|
|
for _old in _T18_SUPERSEDED:
|
|
check(f"...and a blob that still carries the SUPERSEDED default {_old} "
|
|
f"beside the table STARTS. That value was written into the options "
|
|
f"file by this add-on, not typed by the operator, so refusing it "
|
|
f"would stop a working site on an upgrade over an inert number",
|
|
validate_startup_config(base(price_taxes_profile=_T17_OK,
|
|
price_taxes_eur_kwh=_old),
|
|
-5000, 5000) == [])
|
|
# ⚠️ AND THE RUNG IS STILL A RUNG. A superseded default is not a licence for
|
|
# any value: 0.1198 is ORES's enkelvoudig rate, a number an operator plausibly
|
|
# types, and it is still refused beside the table. Without this line the set
|
|
# above could be widened to "anything" and nothing would report.
|
|
check("...while a value that is NEITHER the shipped default NOR a superseded "
|
|
"one is still REFUSED beside the table, naming it - the set of "
|
|
"'untouched' figures widened by exactly the old defaults and by nothing "
|
|
"else",
|
|
why(_T17_P_BOTH, "from 0.1198, or clear")
|
|
and 0.1198 not in _T18_SUPERSEDED
|
|
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
|
|
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
|
|
# both which figure to return to and that the old default is not the problem.
|
|
# UX-08 2c: that a superseded default is accepted is said in DOCS.md "For
|
|
# installers" now. The refusal keeps the one figure the operator acts on.
|
|
check("...and the refusal names the SHIPPED default to return to",
|
|
bool(_T18_SUPERSEDED)
|
|
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}"))
|
|
check("...and the same non-default scalar WITHOUT the table still starts, "
|
|
"which is what makes the line above about the PAIR rather than about "
|
|
"0.1198 being disliked",
|
|
validate_startup_config(base(price_taxes_eur_kwh=0.1198),
|
|
-5000, 5000) == [])
|
|
|
|
# ⚠️ THE UNITS RUNG, A SECOND QUANTITY RATHER THAN A RESTATEMENT. Every Belgian
|
|
# DSO prints these bands in c EUR/kWh, and the whole table is copied off ONE
|
|
# card - so a factor-100 error lands on every row at once and nothing inside
|
|
# the table disagrees with itself.
|
|
#
|
|
# ⚠️ AND THE TWO RUNGS ARE GRADED SEPARATELY, BY THE REASON EACH GIVES, because
|
|
# the parser's own +-10 EUR/kWh bound catches SOME of these cards and not
|
|
# others - and a check that only ever exercised the loud ones would pass with
|
|
# this rung deleted. ORES's 13,27 is refused by the parser; SIBELGA's whole
|
|
# card - 9,39 dag against 7,10 nacht, the Brussels DSO - is under 10 in both
|
|
# bands and reaches this rung alone.
|
|
_T17_ORES_C = ('{"periods":['
|
|
'{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",'
|
|
'"eur_per_kwh":13.27},'
|
|
'{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri",'
|
|
'"eur_per_kwh":7.39},'
|
|
'{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun",'
|
|
'"eur_per_kwh":7.39}]}')
|
|
_T17_SIB_C = ('{"periods":['
|
|
'{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",'
|
|
'"eur_per_kwh":9.39},'
|
|
'{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri",'
|
|
'"eur_per_kwh":7.10},'
|
|
'{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun",'
|
|
'"eur_per_kwh":7.10}]}')
|
|
_T17_P_ORES = validate_startup_config(base(price_taxes_profile=_T17_ORES_C),
|
|
-5000, 5000)
|
|
_T17_P_SIB = validate_startup_config(base(price_taxes_profile=_T17_SIB_C),
|
|
-5000, 5000)
|
|
print(f" ORES : {(_T17_P_ORES or [''])[0][:110]}")
|
|
print(f" Sibelga : {(_T17_P_SIB or [''])[0][:110]}")
|
|
check("ORES's card typed in CENTS is refused by the PARSER's bound, and the "
|
|
"sentence is the parser's - `beyond +-10.0`, naming the row",
|
|
why(_T17_P_ORES, "“Day/night levies and VAT” cannot be used.")
|
|
and why(_T17_P_ORES, "beyond +-10.0"))
|
|
check("...and SIBELGA's whole card - 9,39 and 7,10, BOTH under that bound - is "
|
|
"refused by THIS rung instead, naming both bands and the factor. Without "
|
|
"it a Brussels site would price a 9.39 EUR/kWh levy and start",
|
|
why(_T17_P_SIB, "(price_taxes_profile) has [7.1, 9.39] EUR/kWh")
|
|
and why(_T17_P_SIB, "Divide the figures by 100"))
|
|
check("...and the two refusals are DIFFERENT sentences, so the rungs cannot "
|
|
"mask each other: deleting the units rung leaves the Sibelga line red "
|
|
"while the ORES line goes on passing for the parser's reason",
|
|
_T17_P_ORES != _T17_P_SIB
|
|
and not why(_T17_P_ORES, "Divide the figures by 100")
|
|
and not why(_T17_P_SIB, "beyond +-10.0"))
|
|
check("...and BOTH cards divided by 100 start, which is habit 7: the refusals "
|
|
"above are about the units and not about Brussels or Wallonia",
|
|
validate_startup_config(
|
|
base(price_taxes_profile=_T17_ORES_C.replace("13.27", "0.1327")
|
|
.replace("7.39", "0.0739")), -5000, 5000) == []
|
|
and validate_startup_config(
|
|
base(price_taxes_profile=_T17_SIB_C.replace("9.39", "0.0939")
|
|
.replace("7.10", "0.0710")), -5000, 5000) == [])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("TARIFF-16 - the INJECTION leg is refused HERE, at startup, and the leg "
|
|
"the card cannot grade is SAID to be ungraded")
|
|
# ⚠️ WHY IT IS REFUSED IN THIS MODULE. `export_price_at()` is the seam a future
|
|
# export-aware strategy reads, and a formula that first fails when something
|
|
# reads it is a defect discovered at the worst possible moment - which is
|
|
# `static_profile`'s and `price_taxes_profile`'s argument, one option over.
|
|
#
|
|
# ⚠️ AND WHAT THIS OPTION DOES **NOT** DO, said before what it does: no
|
|
# strategy in this add-on reads an injection price. `arbitrage.py` prices the
|
|
# sell side as displaced IMPORT and compares two all-in CONSUMPTION prices.
|
|
# test_price.py 17h grades that by parsing every module under app/; what is
|
|
# graded HERE is that the operator is TOLD it, at startup, in the same breath
|
|
# as the formula being accepted.
|
|
import logging as _t16_logging # noqa: E402
|
|
|
|
|
|
class _T16Log(_t16_logging.Handler):
|
|
"""Every record app.validate emits during one call, rendered."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.lines = []
|
|
|
|
def emit(self, record):
|
|
self.lines.append((record.levelname, record.getMessage()))
|
|
|
|
|
|
def _t16_start(**over):
|
|
"""`validate_startup_config()` -> (problems, rendered log lines).
|
|
|
|
⚠️ THE LEVEL IS FORCED TO INFO FOR THE DURATION AND RESTORED. Without it
|
|
the capture returns an EMPTY list on a tree that logs correctly, which
|
|
reads exactly like "the line is not there" - a check whose data cannot
|
|
reach the failure it names, in the instrument rather than in the check.
|
|
"""
|
|
log = _t16_logging.getLogger("goodwe.config")
|
|
h = _T16Log()
|
|
was, log.level = log.level, _t16_logging.INFO
|
|
log.addHandler(h)
|
|
try:
|
|
return validate_startup_config(base(**over), -5000, 5000), h.lines
|
|
finally:
|
|
log.removeHandler(h)
|
|
log.level = was
|
|
|
|
|
|
# The two published injection lines, transcribed from the cards. ⚠️ NEITHER
|
|
# CARRIES A `card` BLOCK, because neither card prints a price beside its
|
|
# injection formula - that is the state this section exists to grade.
|
|
_T16_ENECO = ('{"index":"epex_be","resolution":"hourly","factor":0.1,'
|
|
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}')
|
|
_T16_CONS = ('{"index":"epex_be","resolution":"hourly","factor":0.102,'
|
|
'"offset":1.0,"units":"c_eur_per_kwh","vat_pct":6,'
|
|
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
|
'"expect_incl_vat":15.55}}')
|
|
|
|
# ⚠️ THE ACCEPTED BASELINE COMES FIRST (habit 7). Every refusal below varies
|
|
# ONE field from this line, so a refusal has nowhere else to have come from.
|
|
_T16_OK, _T16_OK_LOG = _t16_start(price_export_formula=_T16_ENECO)
|
|
check("Eneco's real published injection line `0,1 x BELPEX-H - 1,188` STARTS - "
|
|
"including the part that makes its price NEGATIVE below 11,88 EUR/MWh, "
|
|
"which is the market and not a misconfiguration",
|
|
_T16_OK == [])
|
|
check("...and so does the shipped \"\", which is every install that exists: "
|
|
"the option is an addition and the scalar still answers without it",
|
|
_t16_start(price_export_formula="")[0] == []
|
|
and _t16_start()[0] == [])
|
|
|
|
_T16_BAD_PT = _T16_ENECO.replace('"factor":0.1,', '"factor":10.0,')
|
|
_T16_BAD_OFF = _T16_ENECO.replace('"offset":-1.188', '"offset":-118.8')
|
|
_T16_P_PT = _t16_start(price_export_formula=_T16_BAD_PT)[0]
|
|
_T16_P_OFF = _t16_start(price_export_formula=_T16_BAD_OFF)[0]
|
|
print(f" {(_T16_P_PT or [''])[0][:118]}")
|
|
print(f" {(_T16_P_OFF or [''])[0][:118]}")
|
|
check("PRECONDITION: each of those two blobs differs from the accepted "
|
|
"baseline in exactly one coefficient, so the refusals below are about "
|
|
"the field each names",
|
|
_T16_BAD_PT != _T16_ENECO and _T16_BAD_OFF != _T16_ENECO
|
|
and _T16_BAD_PT.replace('"factor":10.0,', '"factor":0.1,') == _T16_ENECO
|
|
and _T16_BAD_OFF.replace('"offset":-118.8', '"offset":-1.188')
|
|
== _T16_ENECO)
|
|
check("a factor 100x out is REFUSED at startup, by the pass-through rung, "
|
|
"naming the option the operator typed",
|
|
why(_T16_P_PT, "“Your own injection formula” cannot be used.")
|
|
and why(_T16_P_PT, "passes through")
|
|
and not why(_T16_P_PT, "larger than"))
|
|
check("...and an offset 100x out is refused by the OFFSET rung, so the two "
|
|
"rungs are graded on their reasons and cannot mask each other",
|
|
why(_T16_P_OFF, "“Your own injection formula” cannot be used.")
|
|
and why(_T16_P_OFF, "larger than")
|
|
and not why(_T16_P_OFF, "passes through"))
|
|
check("...and a malformed blob is refused as a malformed blob, naming the "
|
|
"field rather than 'invalid configuration'",
|
|
why(_t16_start(price_export_formula='{"index":"belpex_h"}')[0],
|
|
"price_export_formula.index is 'belpex_h'"))
|
|
|
|
# ⚠️ THE HONEST HALF, AND IT IS A WARNING RATHER THAN A REFUSAL. Eneco prints
|
|
# its injection formula with NO price beside it, so on that card there is
|
|
# nothing for the self-check to reproduce. Requiring a `card` block would make
|
|
# the published line inexpressible; inventing the estimate would grade the
|
|
# coefficients against arithmetic done with the same coefficients. So the leg
|
|
# starts, and the operator is told WHICH check did not run.
|
|
_T16_UNGRADED = [m for lvl, m in _T16_OK_LOG
|
|
if lvl == "WARNING" and "not checked against a printed price" in m]
|
|
print(f" {(_T16_UNGRADED or [''])[0][:118]}")
|
|
check("a leg with no card block starts, and says at startup that its "
|
|
"coefficients are UNGRADED - naming what was checked instead, so the "
|
|
"gap is stated rather than worked around",
|
|
len(_T16_UNGRADED) == 1
|
|
and "no \"card\" block" in _T16_UNGRADED[0]
|
|
and "pass-through" in _T16_UNGRADED[0])
|
|
_T16_WITH_CARD, _T16_CARD_LOG = _t16_start(
|
|
price_export_formula=_T16_CONS, price_formula=_T16_CONS,
|
|
price_provider="custom_formula")
|
|
# ...and a SECOND run with the two legs genuinely different, for the ordered
|
|
# pair below. ⚠️ THE FIRST ONE PUTS THE CONSUMPTION CARD ON BOTH LEGS, so
|
|
# 0.102/6 appears twice and an assertion about arrangement would pass on a
|
|
# TRANSPOSED rendering - MUTATION.md habit 4, in the fixture of a habit-14
|
|
# check. The precondition is asserted below rather than trusted.
|
|
_T16_BOTH_LEGS, _T16_BOTH_LOG = _t16_start(
|
|
price_export_formula=_T16_ENECO, price_formula=_T16_CONS,
|
|
price_provider="custom_formula")
|
|
check("...and a leg that DOES carry a card does NOT print it, or the sentence "
|
|
"would be noise an operator learns to ignore rather than a report of "
|
|
"which check ran",
|
|
_T16_WITH_CARD == []
|
|
and not [m for lvl, m in _T16_CARD_LOG if "not checked against a printed price" in m])
|
|
# ⚠️ AND A CARD ON THIS LEG IS STILL GRADED, at startup, by the same rung.
|
|
check("...and a card ON THIS LEG that the coefficients do not reproduce is "
|
|
"REFUSED at startup, so the optional block is optional and not inert",
|
|
why(_t16_start(price_export_formula=_T16_CONS.replace(
|
|
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
|
|
"price_export_formula gives"))
|
|
|
|
# ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT.
|
|
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
|
|
and "No part of the add-on reads it yet" in m]
|
|
check("the startup line says, in so many words, that NO STRATEGY IN THIS "
|
|
"ADD-ON READS IT - the operator is told what the option does not do in "
|
|
"the same breath as it being accepted",
|
|
len(_T16_INFO) == 1 and "displaced import" in _T16_INFO[0])
|
|
# ⚠️ THE ASYMMETRY IS ASSERTED AS AN ORDERED PAIR, NOT AS TWO NUMBERS IN ONE
|
|
# SENTENCE (MUTATION.md habit 14). "6 and 0 appear somewhere" is satisfied by
|
|
# the two legs TRANSPOSED - which is the sentence telling an operator that the
|
|
# add-on has their VAT treatment exactly backwards, on the one line where they
|
|
# could have seen it.
|
|
_T16_PAIR = [m for _l, m in _T16_BOTH_LOG if "priced independently" in m]
|
|
print(f" {(_T16_PAIR or [''])[0][:118]}")
|
|
check("PRECONDITION: the two legs of that run carry DIFFERENT factors and "
|
|
"DIFFERENT VAT rates (0.102 at 6 against 0.1 at 0), or the ordered-pair "
|
|
"assertion below would be satisfied by the transposed rendering it "
|
|
"exists to refuse",
|
|
_T16_BOTH_LEGS == [] and '"factor":0.102' in _T16_CONS
|
|
and '"vat_pct":6' in _T16_CONS and '"factor":0.1,' in _T16_ENECO
|
|
and '"vat_pct":0' in _T16_ENECO)
|
|
check("and the 6% asymmetry is reported as an ORDERED PAIR: the transposed "
|
|
"rendering would read `consumption factor 0.1 at vat_pct 0, injection "
|
|
"factor 0.102 at vat_pct 6`, which is a DIFFERENT string - so an "
|
|
"argument swap at the call site cannot hide behind `0.102 is in there "
|
|
"somewhere`",
|
|
len(_T16_PAIR) == 1
|
|
and "consumption factor 0.102 at vat_pct 6, injection factor 0.1 at "
|
|
"vat_pct 0" in _T16_PAIR[0]
|
|
and "consumption factor 0.1 at vat_pct 0, injection factor 0.102 at "
|
|
"vat_pct 6" not in _T16_PAIR[0])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\nSAFETY-10 - shadow_mode is validated like every other option")
|
|
# ⚠️ WHY THIS IS A REFUSAL AND NOT A COERCION. `shadow.mode()` resolves an
|
|
# unrecognised value to SHADOW - the direction that does not move energy - and
|
|
# an install silently reinterpreted that way COMMANDS NOTHING, indefinitely,
|
|
# while its status page calmly explains that this is intentional. A typo is not
|
|
# a decision, and UX-02 made this validator the one rule surface both editors
|
|
# read, so the rule has to be here rather than in a quiet fallback.
|
|
_S10_BAD = ["true", "yes", "1", "shadow", "", "autoo", None, 1]
|
|
for _v in _S10_BAD:
|
|
_p = validate_startup_config(base(shadow_mode=_v), -5000, 5000)
|
|
check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so "
|
|
"configui.problems_by_field() can file it under the right control",
|
|
why(_p, "(shadow_mode) is") and why(_p, "'auto'")
|
|
and why(_p, "'on'") and why(_p, "'off'"))
|
|
for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "):
|
|
check(f"shadow_mode={_v!r} starts - the three values and the tolerant "
|
|
"spellings of them are accepted, so the rule above refuses garbage "
|
|
"and not case",
|
|
validate_startup_config(base(shadow_mode=_v), -5000, 5000) == [])
|
|
check("PRECONDITION: and the key being ABSENT starts too, which is what every "
|
|
"install that upgrades into this version has. A rule that refused the "
|
|
"absent key would refuse every existing install at once",
|
|
validate_startup_config(base(), -5000, 5000) == []
|
|
and "shadow_mode" not in base())
|
|
# The two arrangements have to DISAGREE somewhere, or `why()` is grading a
|
|
# sentence that is in the list for another reason entirely.
|
|
check("...and a refused shadow_mode is the ONLY thing wrong with that config, "
|
|
"so the refusal above is this rule's and not some other rule's",
|
|
len(validate_startup_config(base(shadow_mode="maybe"), -5000, 5000)) == 1)
|
|
|
|
|
|
# --- THE SIX STATES, AND THE ONE THAT PROTECTS THE EXISTING INSTALL ---------
|
|
# ⚠️ GRADED HERE RATHER THAN IN test_clamp.py, WHERE IT STARTED, AND THE REASON
|
|
# IS MEASURED. `shadow.engaged()` is a pure function of the option and the
|
|
# start button; a mutation that defeats the DEFAULT stops test_clamp.py's
|
|
# part 3 reaching the wire at all, which aborts that file with an IndexError
|
|
# before part 8 runs - so the mutation's red count there is a lower bound and
|
|
# the table itself is never graded (MUTATION.md, FCST-06's shape). Nothing in
|
|
# this file drives a Controller, so nothing here can be taken down that way.
|
|
# Six states. The one that matters most is the LAST row: an install that is
|
|
# controlling today must not be changed by this option arriving with a default.
|
|
_TABLE = [
|
|
("off", True, False), ("off", False, False),
|
|
("on", True, True), ("on", False, True),
|
|
("auto", False, True), ("auto", True, False),
|
|
]
|
|
for _m, _started, _want in _TABLE:
|
|
check(f"shadow_mode {_m!r} with control "
|
|
f"{'started' if _started else 'stopped'} -> "
|
|
f"{'SHADOW' if _want else 'LIVE'}",
|
|
shadow.engaged({"shadow_mode": _m}, control_started=_started)
|
|
is _want)
|
|
check("THE EXISTING INSTALL: the SHIPPED DEFAULT, on an install that is "
|
|
"controlling, is LIVE. This is the whole reason the option is not a "
|
|
"boolean defaulting to true - the Supervisor merges a new default into "
|
|
"every existing install, and a boolean would have stopped a battery that "
|
|
"is regulating a real house today",
|
|
shadow.engaged({}, control_started=True) is False)
|
|
check("...and the SAME shipped default on a FRESH install - `auto_start` is "
|
|
"false, so nothing has started - is SHADOW. That is what 'default-on' "
|
|
"means here, delivered where it is safe",
|
|
shadow.engaged({}, control_started=False) is True)
|
|
# ⚠️ THE REASON, NOT THE VERDICT (MUTATION.md's masking rule). `on` and
|
|
# `auto`-while-stopped are the same boolean and want DIFFERENT operator
|
|
# actions: one is an option, the other is a button. A surface showing only the
|
|
# verdict would be blind to the two rungs being swapped.
|
|
check("the two ways of being in shadow mode are told apart in words, so a "
|
|
"build that swapped the rungs could not render identically",
|
|
shadow.why({"shadow_mode": "on"}, control_started=False)
|
|
!= shadow.why({"shadow_mode": "auto"}, control_started=False))
|
|
|
|
# ⚠️ AND THE FAIL-SAFE DIRECTION OF AN UNREADABLE VALUE IS GRADED, THOUGH THE
|
|
# rule above means a running install can never hold one. It survived the first
|
|
# mutation battery as M9 for exactly that reason: everything else refused the
|
|
# value before `mode()` ever saw it, so the direction it fails in was ungraded
|
|
# and could have been flipped to "live" with the whole suite green.
|
|
check("an UNREADABLE shadow_mode resolves to SHADOW, not to live - the "
|
|
"direction that does not move energy. It is reachable only if this "
|
|
"file's rule is ever weakened, which is the case it exists for",
|
|
shadow.engaged({"shadow_mode": "garbage"}, control_started=True) is True
|
|
and shadow.engaged({"shadow_mode": ""}, control_started=True) is True
|
|
and shadow.mode({"shadow_mode": "garbage"}) == "on")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\nUX-13 - the warning tier: notes for the settings page, never refusals")
|
|
from app.validate import Note, WATT_ENTITY_KEYS, config_notes # noqa: E402
|
|
|
|
_warned = base(integrator_max_w=3000, maintenance_discharge_w=2500,
|
|
price_provider="generic_dayahead", static_profile="")
|
|
_notes = []
|
|
_with = validate_startup_config(_warned, -5000, 5000, notes=_notes)
|
|
check("a config with three logged warnings starts, and gives three notes, "
|
|
"each filed under the setting it names",
|
|
_with == [] and validate_startup_config(_warned, -5000, 5000) == []
|
|
and sorted(n.keys for n in _notes) == [
|
|
("integrator_max_w",), ("maintenance_discharge_w",),
|
|
("static_profile",)]
|
|
and all(n.tier == "warn" and f"({n.keys[0]})" in n.text for n in _notes))
|
|
_refused = base(setpoint_entity="", integrator_max_w=3000)
|
|
_n2 = []
|
|
check("collecting notes changes no refusal",
|
|
validate_startup_config(_refused, notes=_n2)
|
|
== validate_startup_config(_refused) != [] and len(_n2) == 1)
|
|
_rows = {"number.goodwe_setpoint": {"unit": None},
|
|
"sensor.soc": {"unit": "%"}, "sensor.batt": {"unit": "kW"}}
|
|
_cn = config_notes(base(), entities=_rows)
|
|
check("a battery power sensor in kW is warned about: the add-on takes it as "
|
|
"watts", [n.keys for n in _cn] == [("batt_entity",)]
|
|
and "kW" in _cn[0].text)
|
|
_cn = config_notes(base(meter_entity="sensor.grid"), entities={})
|
|
check("an entity Home Assistant does not have is named, and only the ones "
|
|
"this meter source and roster shape read",
|
|
sorted(n.keys[0] for n in _cn) == ["batt_entity", "meter_entity",
|
|
"setpoint_entity", "soc_entity"])
|
|
check("with no entity list (Home Assistant down) those checks are skipped",
|
|
config_notes(base(), entities=None) == [])
|
|
check("a peak limit below this month's peak is warned about",
|
|
[n.keys for n in config_notes(base(peak_cap_w=2500),
|
|
month_peak_w=2900)] == [("peak_cap_w",)]
|
|
and config_notes(base(peak_cap_w=3500), month_peak_w=2900) == [])
|
|
check("pv.py converts solar units itself, so no solar key is read as watts",
|
|
not [k for k in WATT_ENTITY_KEYS if k.startswith("pv_")]
|
|
and isinstance(_cn[0], Note))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
|
sys.exit(1)
|
|
print("all checks passed")
|