MAINT-04: refuse a maintenance charge the ceiling cannot deliver
config.yaml shipped max_w 2000 against maintenance_charge_w 2500. The cheap-window shaping files Claim.limit(hi = -maintenance_charge_w) against the supervised SAFETY limit's lo = -max_w, so lo > hi - the arbiter's clause 3, correctly resolved to 0 W and logged as a CONTRADICTION. Maintenance silently never charged in a cheap window on a default install, and the only evidence read like a bug in the arbiter rather than like the two numbers the operator set. validate_startup_config() now refuses maintenance_charge_w > max_w, the same shape as CFG-01's sign_disagree_w >= max_w. Equality is reachable (-max_w is a single valid point) and is not refused. Demonstrated by starting the add-on on the pre-fix pair: exit 78 and the refusal banner. maintenance_discharge_w was checked rather than assumed and is NOT the same case: the drain phase OWNS the actuator (Claim.set at P_MAINTENANCE), so the ceiling clamps it to +2000 W and the arbiter's explain() names the clamp every cycle. Loud, and it still moves energy - warned, not refused. The shipped maintenance rates drop to 2000 so the defaults pass the rule; max_w was deliberately not raised, because it is the supervised safety ceiling. test_validate.py now reads config.yaml itself, so a default the validator refuses cannot ship again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
co-authored by
Claude Opus 5
parent
bb64f8fdd7
commit
878a6ce452
@@ -26,9 +26,15 @@ def check(name, cond):
|
||||
|
||||
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). They are not decoration: with the key absent, rule 6
|
||||
# falls back to main.py's 2500 W, which against this 2000 W ceiling is a
|
||||
# genuine refusal - so a base() without them would be a config that does
|
||||
# not start, and every check below would be measuring the wrong thing.
|
||||
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off"}
|
||||
"meter_source": "off",
|
||||
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
@@ -151,6 +157,102 @@ check("with meter_source off the number governs nothing and is not refused",
|
||||
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 which one wins", why(p, "THE CEILING WINS"))
|
||||
# ⚠️ 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), "THE CEILING WINS"))
|
||||
|
||||
# ⚠️ 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("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()
|
||||
_shipped = {k: float(v) for k, v in re.findall(
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
||||
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all four defaults found)",
|
||||
set(_shipped) == {"max_w", "maintenance_charge_w",
|
||||
"maintenance_discharge_w", "sign_disagree_w"})
|
||||
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("max_w must be a positive magnitude")
|
||||
# Schema-blocked on a real install, but validate_startup_config() is also
|
||||
@@ -168,7 +270,8 @@ check("...and the message explains it would read as a trip, not a mistake",
|
||||
# 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), -5000, 5000) == [])
|
||||
validate_startup_config(base(max_w=1, 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), "never fire"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user