diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index dfba0ff..d312405 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,86 @@ # Changelog +## Unreleased + +**Cheap-window maintenance charging was unreachable at the shipped defaults, and +now the config that makes it unreachable is refused (MAINT-04).** `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 an operator set. +Measured against the real arbiter, loop asking +500 W: + +``` +max_w=2000 hi=-2500 -> +0 W contradiction=True +max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w) +max_w=3000 hi=-2500 -> -2500 W limited by maintenance(cheap-window charge) +``` + +`validate_startup_config()` now refuses `maintenance_charge_w > max_w` (rule 6), +the same shape as CFG-01's `sign_disagree_w >= max_w`: a number the operator set, +overridden by a layer they cannot see. Equality is reachable (`-max_w` is a +single valid point) and deliberately not refused. Verified by starting the +add-on on the pre-fix pair: exit 78, the refusal banner, no traceback. + +**`maintenance_discharge_w` was checked, not assumed, and it is NOT the same +case.** The shaping is not symmetric. The drain phase does not shape the loop, +it OWNS the actuator — `Claim.set(P_MAINTENANCE, +discharge_w)` — and a SET is +*clamped* by the limits above it rather than contradicted by them. 2500 against +a 2000 W ceiling emits **+2000 W**, and the arbiter's own `explain()` prints +`maintenance -> 2000 W, limited by supervised(max_w)` every cycle. Nothing is +silent and energy still moves; the cycle completes slower (13.8 kWh usable at +2000 W is ~6.9 h against the 720 min drain timeout). **Warned about, not +refused.** + +**The shipped defaults changed so the validator does not reject them.** +`maintenance_charge_w` and `maintenance_discharge_w` are now **2000**, at the +shipped `max_w`. `max_w` was deliberately NOT raised instead: it is the +supervised safety ceiling and raising a default upward ships more power to every +new install. Raising `max_w` (the reference install runs 5000) is what unlocks a +faster maintenance cycle — raise it first, then these. `test_validate.py` now +reads `config.yaml` itself and asserts the shipped numbers pass the rule, so a +default that the validator refuses cannot ship again. + +### ⚠️ Upgrading an existing default install: the add-on may refuse to start + +An install still carrying `maintenance_charge_w: 2500` against `max_w: 2000` +now meets rule 6 and **exits 78 with the refusal banner** instead of starting. +That is the intended behaviour — the configuration was already broken, it was +just silent about it — but an add-on that will not come back after an update +needs the fix stated here rather than only in the log. + +**The fix, either way: set `maintenance_charge_w` to `2000` or lower, OR raise +`max_w` to `2500` or higher** (Settings → Add-ons → GoodWe RS485 Controller → +Configuration), then start it. The banner in the log says the same thing and +names both numbers. + +Whether you hit this at all depends on whether the Supervisor kept the old +value, and **both cases are covered above because we did not verify which one +applies here.** The Supervisor merges an add-on's *stored* options over the +defaults in `config.yaml`, so: + +- **A key the operator never edited** re-derives from the new `config.yaml` and + becomes 2000 on upgrade. Non-event, nothing to do. +- **A key the operator did set** persists at 2500 and the add-on refuses to + start until it is changed. Note this includes anyone who used the + Configuration page's YAML editor, which writes the whole options block back — + so "I never touched that field" is not a guarantee the old value is gone. + +⚠️ **This is reasoned from the Supervisor's documented merge behaviour, not +measured on the live install.** The reference install's stored options could not +be read (`/api/hassio/` refuses a long-lived token) and probing it was out of +scope — it regulates a real battery. It runs `max_w: 5000` and is unaffected +either way. + +⚠️ **Known, deliberately left, and worth a follow-up:** `main.py` and +`maintenance.py` still fall back to **2500** when the key is absent from the +options dict, while `config.yaml` and `validate.py` now say 2000. Unreachable on +a Supervisor install (every key is always supplied) and reachable only from +tests/`sim`. The real fix is one source of truth for option defaults, which is +bigger than this ticket and touches files another ticket is in. + ## 0.5.1 **SAFETY-02's latch now survives an add-on restart, which is the difference diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index f6b53db..39d2951 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -414,8 +414,8 @@ help - see "Why the tuning is what it is". | `maintenance_enabled` | false | Enable the monthly cycle | | `maintenance_interval_days` | 28 | Minimum gap between cycles | | `maintenance_start_hour` | 10 | Hour of day a due cycle begins | -| `maintenance_discharge_w` | 2500 | Drain rate (exports the surplus) | -| `maintenance_charge_w` | 2500 | Charge ceiling, capped again by peak headroom | +| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower | +| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** | | `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor | | `maintenance_soc_target` | 99 | Charge target | | `maintenance_hold_min` | 120 | Hold at full so the BMS can balance | diff --git a/goodwe_controller/app/maintenance.py b/goodwe_controller/app/maintenance.py index b12669d..5119b0e 100644 --- a/goodwe_controller/app/maintenance.py +++ b/goodwe_controller/app/maintenance.py @@ -28,9 +28,10 @@ _LOG = logging.getLogger("goodwe.maint") IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold" -# A drain of a 13.5 kWh usable pack at 2500 W is ~5 h even with no house load -# helping. 12 h means the battery is not following commands - a fault, not a -# slow day. The charge phase deliberately waits for sun and for cheap hours, so +# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped +# 2000 W is ~6.9 h even with no house load helping - and less in practice, since +# house load drains alongside the export and soc_floor stops short of empty. +# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so # it is expected to span a night; 36 h still bounds it. DRAIN_TIMEOUT_MIN = 720 CHARGE_TIMEOUT_MIN = 2160 diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index d5be62c..b17b449 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -85,6 +85,37 @@ NOT READY banner already names the problem, (b) does not apply. watchdog: armed". A disarmed guard that advertises itself as armed is the one thing this project has decided repeatedly it will not ship. + 6. `maintenance_charge_w` above `max_w`. [(a)] + The same shape as 5, and found the same way - by checking a worked example + against what actually ships. maintenance's cheap-window shaping files + `Claim.limit(hi=-maintenance_charge_w)` at P_MAINT_SHAPE, against the + `supervised` SAFETY limit's `lo=-max_w`. Both bind (P_SAFETY and + P_MAINT_SHAPE both outrank the loop that wins the SET), so a charge rate + above the ceiling gives `lo > hi` - the arbiter's clause 3, which it + correctly resolves to 0 W and logs as a CONTRADICTION. Measured against + the arbiter, loop asking +500 W: + + max_w=2000 hi=-2500 -> +0 W contradiction=True + max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w) + max_w=3000 hi=-2500 -> -2500 W limited by maintenance(...) + + So MAINTENANCE SILENTLY NEVER CHARGES IN A CHEAP WINDOW until max_w is at + or above the charge rate, and the only evidence is a contradiction line + that reads like a bug in the arbiter rather than like the two numbers the + operator set. Equality is fine and deliberately not refused - at + max_w == charge_w the window is a single reachable point, -max_w. + + ⚠️ `maintenance_discharge_w` IS NOT THE SAME CASE, and this was checked + rather than assumed. THE SHAPING IS NOT SYMMETRIC: the drain phase does + not shape the loop, it OWNS the actuator - `Claim.set(P_MAINTENANCE, + +discharge_w)`. A SET is clamped by the limits above it, not contradicted + by them, so 2500 against a 2000 W ceiling emits +2000 W and the arbiter's + own explain() says `maintenance -> 2000 W, limited by supervised(max_w)` + on the status page every cycle. That is neither (a) - nothing is silent - + nor (b) - it moves energy and the cycle still completes, just slower + (13.8 kWh usable at 2000 W is ~6.9 h against the 720 min DRAIN_TIMEOUT). + It is warned about below, not refused. + WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe: - `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the @@ -102,6 +133,9 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe: NOT READY banner. - `meter_poll_s` above half `meter_max_age_s`. One missed poll costs a stale reading and a 0 W cycle. Warned about in p1.py, not refused. + - `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the + clamp names itself in the arbiter's explain() - see rule 6. Warned about, + not refused. """ import logging @@ -269,6 +303,52 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str] f"sign_disagree_w below {max_w:.0f} W (300 W is the measured " "default), or raise max_w.") + # --- 6. a maintenance charge the ceiling can actually accommodate -------- + # ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is + # `Claim.limit(hi=-charge_w)` against `supervised`'s `lo=-max_w`, so + # charge_w == max_w leaves the single reachable point -max_w and works; + # only ABOVE is `lo > hi`, arbiter clause 3, 0 W and a contradiction. + # + # Not scoped to maintenance_enabled, deliberately. The pair is + # contradictory whether or not the schedule is on, and scoping it would + # leave the trap armed for the one operator action - ticking + # maintenance_enabled - that cannot possibly be expected to change what a + # charge rate means. + # ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, and it has to be kept + # equal to it - that is the whole point of AC3. It is reachable only when a + # caller hands over a dict with the key missing (sim/inject.py, a test); the + # Supervisor always supplies every key from config.yaml, so on a real + # install the argument is always present. + # + # ⚠️ KNOWN MISMATCH, LEFT DELIBERATELY AND FLAGGED RATHER THAN FIXED HERE: + # `main.py` still falls back to 2500 in two places + # (`opts.get("maintenance_charge_w", 2500)`), as does maintenance.py's + # MaintConfig dataclass. So a dict with the key ABSENT is accepted by this + # rule at 2000 and then built by main.py at 2500 - the contradiction this + # rule exists to catch, in the one case it cannot see. It is unreachable on + # a Supervisor install and those files are another ticket's right now. + # THE REAL FIX is one source of truth for option defaults; until then the + # divergence is PINNED BY A CHECK rather than by this comment - see + # test_validate.py, "the KNOWN cross-file default divergence". It asserts + # the mismatch as it stands and goes red if EITHER side moves, including + # when somebody closes it in main.py, at which point this block and that + # check both get deleted. + charge_w = _number(opts, "maintenance_charge_w", 2000.0) + if charge_w is None: + problems.append( + f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number") + elif max_w is not None and max_w > 0 and charge_w > max_w: + problems.append( + f"maintenance_charge_w is {charge_w:.0f} W but max_w is " + f"{max_w:.0f} W, so a maintenance charge in a cheap window can " + "never happen: the shaping asks the arbiter for at most " + f"{-charge_w:.0f} W while the supervised limit allows no less than " + f"{-max_w:.0f} W, which is a contradiction it resolves to 0 W - " + "THE CEILING WINS, and what you would see is an ARBITER " + "CONTRADICTION line that reads like a bug rather than like these " + f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, " + f"or raise max_w to at least {charge_w:.0f} W.") + # --- warnings: real, but not worth refusing over ------------------------- integ = opts.get("integrator_max_w") integ_w = None if not integ else _number(opts, "integrator_max_w", None) @@ -280,6 +360,25 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str] "saturation. 0 (follow max_w) is the recommended value.", integ_w, max_w) + # ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The + # drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is + # clamped by the limits above it rather than contradicted by them. So this + # drains at max_w instead of the configured rate, the arbiter says so in + # words on the status page every cycle, and the cycle still completes - + # slower, and eating into maintenance.DRAIN_TIMEOUT_MIN. Loud and it moves + # energy, so it is neither (a) nor (b): warned, not refused. + disch_w = _number(opts, "maintenance_discharge_w", 2000.0) + if disch_w is not None and max_w is not None and max_w > 0 and disch_w > max_w: + _LOG.warning( + "maintenance_discharge_w %.0f W is above max_w %.0f W. Not refused - " + "the drain phase SETS the setpoint and the arbiter clamps it to " + "%.0f W and names the clamp ('limited by supervised(max_w)'), so " + "nothing is silent and the cycle still completes. But it drains at " + "%.0f W, not the %.0f W you configured, and the drain phase has a " + "720 min timeout. Lower it to %.0f W or raise max_w to remove the " + "surprise.", + disch_w, max_w, max_w, max_w, disch_w, max_w) + return problems diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index d9fc3d1..3f7918c 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -118,8 +118,23 @@ options: maintenance_enabled: false maintenance_interval_days: 28 maintenance_start_hour: 10 - maintenance_discharge_w: 2500 - maintenance_charge_w: 2500 + # ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather + # than the 2500 they shipped as. The cheap-window charge shaping asks the + # arbiter for `hi = -maintenance_charge_w` against the supervised `lo = + # -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to + # 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a + # default install. validate.py refuses that config (rule 6), so these + # defaults had to become self-consistent with the max_w above rather than + # ship a configuration the validator rejects. + # + # The discharge side is clamped rather than contradicted - it drains at max_w + # and the arbiter says so - so it is only warned about; it is 2000 here for + # symmetry and to keep the shipped config warning-free. + # + # Raising max_w (the live reference install runs 5000) is what unlocks a + # faster maintenance cycle. Raise max_w FIRST, then these. + maintenance_discharge_w: 2000 + maintenance_charge_w: 2000 maintenance_soc_floor: 11 maintenance_soc_target: 99 maintenance_hold_min: 120 diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index a46de58..23a029e 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -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,170 @@ 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("the KNOWN cross-file default divergence, pinned rather than commented") +# ⚠️ WHAT THIS GUARDS, AND WHY IT ASSERTS THE BUG RATHER THAN THE FIX. +# config.yaml and validate.py now default maintenance_charge_w to 2000, while +# main.py's two `opts.get(..., 2500)` fallbacks and MaintConfig's dataclass +# default still say 2500. So an options dict with the key ABSENT is ACCEPTED by +# rule 6 at 2000 and then built by main.py at 2500 - which is a contradiction: +# +# validate_startup_config -> [] (accepted) +# MaintConfig().charge_w -> 2500.0 +# arbiter -> 0.0 W, contradiction=True +# +# That is the bug rule 6 exists to catch, passing rule 6. It is unreachable on a +# Supervisor install (every key is always supplied from config.yaml) but fully +# reachable from sim/inject.py and from tests - which is exactly where anyone +# debugging maintenance works. +# +# The fix is ONE SOURCE OF TRUTH for option defaults. That is a bigger change +# than this ticket and it lands in main.py, which another ticket is live in - so +# the divergence is RECORDED here instead of closed. The check therefore passes +# on the mismatch and FAILS when either side moves, in EITHER direction: +# +# - config.yaml moves -> FAIL: re-decide what the fallback should be +# - main.py is fixed to match -> FAIL: the divergence is gone, DELETE this +# check and the ⚠️ block in validate.py +# +# A comment cannot do that, and "a comment where a check belonged" is this +# repo's most repeated defect. +_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app") + +def _read(*parts): + with open(os.path.join(*parts), encoding="utf-8") as fh: + return fh.read() + +# Every literal fallback for this key in main.py, whatever the call shape. +_main_falls = [float(m) for m in re.findall( + r'\.get\(\s*"maintenance_charge_w"\s*,\s*(-?\d+(?:\.\d+)?)\s*\)', + _read(_APP, "main.py"))] +# ...and MaintConfig's own dataclass default, which is the one that bites when +# main.py is bypassed entirely (a Maintenance built straight from defaults). +_dc_falls = [float(m) for m in re.findall( + r"^\s*charge_w:\s*float\s*=\s*(-?\d+(?:\.\d+)?)\s*$", + _read(_APP, "maintenance.py"), re.M)] + +check(f"main.py's fallbacks are still found and still 2500 {_main_falls}", + len(_main_falls) == 2 and set(_main_falls) == {2500.0}) +check(f"MaintConfig's default is still found and still 2500 {_dc_falls}", + len(_dc_falls) == 1 and set(_dc_falls) == {2500.0}) +check(f"...and config.yaml still ships 2000, so the divergence is UNCHANGED " + f"(shipped {_shipped['maintenance_charge_w']:.0f}, code 2500)", + _shipped["maintenance_charge_w"] == 2000.0) + +# ⚠️ And the consequence itself, through the REAL code rather than through the +# numbers above - so that if someone "fixes" this by changing a literal without +# understanding it, the thing that goes red is the behaviour, not the transcript. +from app.maintenance import MaintConfig # noqa: E402 + +_absent = {k: v for k, v in base().items() if k != "maintenance_charge_w"} +_built = MaintConfig().charge_w +check("with the key ABSENT the validator accepts...", + validate_startup_config(_absent, -5000, 5000) == []) +check(f"...while the code would build {_built:.0f} W against a " + f"{_absent['max_w']:.0f} W ceiling...", _built > _absent["max_w"]) +_r = _charge(_absent["max_w"], _built) +check("...which the arbiter resolves to 0 W as a contradiction - the hole, " + "asserted so it cannot widen unnoticed", + _r.target_w == 0.0 and _r.contradiction) + # --------------------------------------------------------------------------- # print("max_w must be a positive magnitude") # Schema-blocked on a real install, but validate_startup_config() is also @@ -168,7 +338,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")) diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 5cb701a..d1657f0 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -145,8 +145,13 @@ check("300 W under a 2000 W ceiling is accepted", p == []) p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000}, -5000, 5000) check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0]) -p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000}, - -5000, 5000) +# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN +# rule. MAINT-04 added a second rule against the same ceiling, and a 500 W +# max_w is also below the 2000 W shipped maintenance charge - so without this +# the dict is refused twice, for two correct but different reasons, and the +# `len(p) == 1` below would be measuring the interaction rather than this rule. +p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000, + "maintenance_charge_w": 500}, -5000, 5000) check("a threshold above max_w is refused", len(p) == 1 and "never fire" in p[0]) p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999}, -5000, 5000)