Merge release/1.0 into SAFETY-18 - CHANGELOG only
MAINT-04 (#10) landed on release/1.0 mid-review, so both branches had prepended a stanza. config.yaml and DOCS.md auto-merged clean; only the CHANGELOG needed a hand. Both stanzas are kept verbatim, MAINT-04's Unreleased above the new 0.5.2 - no text from either author edited. MAINT-04 is unreleased and 0.5.2 is being cut here, so a release manager may want to fold the two; that is an editorial call, not a reviewer's. Full suite green on the merged tree: arbiter, control, loop, maintenance, p1, validate, watchdog, freeze. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
@@ -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.2
|
||||
|
||||
**A meter frozen at a wrong-but-fresh value now raises an alarm. It still is not
|
||||
|
||||
@@ -418,8 +418,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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -138,8 +138,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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user