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
|
# 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
|
## 0.5.2
|
||||||
|
|
||||||
**A meter frozen at a wrong-but-fresh value now raises an alarm. It still is not
|
**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_enabled` | false | Enable the monthly cycle |
|
||||||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||||
| `maintenance_discharge_w` | 2500 | Drain rate (exports the surplus) |
|
| `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` | 2500 | Charge ceiling, capped again by peak headroom |
|
| `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_floor` | 11 | Drain target — stay just above the inverter's own floor |
|
||||||
| `maintenance_soc_target` | 99 | Charge target |
|
| `maintenance_soc_target` | 99 | Charge target |
|
||||||
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
| `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"
|
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
|
# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped
|
||||||
# helping. 12 h means the battery is not following commands - a fault, not a
|
# 2000 W is ~6.9 h even with no house load helping - and less in practice, since
|
||||||
# slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
# 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.
|
# it is expected to span a night; 36 h still bounds it.
|
||||||
DRAIN_TIMEOUT_MIN = 720
|
DRAIN_TIMEOUT_MIN = 720
|
||||||
CHARGE_TIMEOUT_MIN = 2160
|
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
|
watchdog: armed". A disarmed guard that advertises itself as armed is the
|
||||||
one thing this project has decided repeatedly it will not ship.
|
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:
|
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
|
- `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.
|
NOT READY banner.
|
||||||
- `meter_poll_s` above half `meter_max_age_s`. One missed poll costs a stale
|
- `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.
|
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
|
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 "
|
f"sign_disagree_w below {max_w:.0f} W (300 W is the measured "
|
||||||
"default), or raise max_w.")
|
"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 -------------------------
|
# --- warnings: real, but not worth refusing over -------------------------
|
||||||
integ = opts.get("integrator_max_w")
|
integ = opts.get("integrator_max_w")
|
||||||
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
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.",
|
"saturation. 0 (follow max_w) is the recommended value.",
|
||||||
integ_w, max_w)
|
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
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,8 +138,23 @@ options:
|
|||||||
maintenance_enabled: false
|
maintenance_enabled: false
|
||||||
maintenance_interval_days: 28
|
maintenance_interval_days: 28
|
||||||
maintenance_start_hour: 10
|
maintenance_start_hour: 10
|
||||||
maintenance_discharge_w: 2500
|
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||||
maintenance_charge_w: 2500
|
# 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_floor: 11
|
||||||
maintenance_soc_target: 99
|
maintenance_soc_target: 99
|
||||||
maintenance_hold_min: 120
|
maintenance_hold_min: 120
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ def check(name, cond):
|
|||||||
|
|
||||||
def base(**over):
|
def base(**over):
|
||||||
"""A configuration that must start. Everything else is this, spoilt."""
|
"""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,
|
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
"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)
|
o.update(over)
|
||||||
return o
|
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",
|
check("the constant is the firmware's, not a rounded guess",
|
||||||
FIRMWARE_INGEST_VETO_S == 30.0)
|
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")
|
print("max_w must be a positive magnitude")
|
||||||
# Schema-blocked on a real install, but validate_startup_config() is also
|
# 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
|
# threshold the ceiling can actually reach, and the interaction gets its own
|
||||||
# check below.
|
# check below.
|
||||||
check("the smallest sane max_w still starts",
|
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",
|
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"))
|
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},
|
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000},
|
||||||
-5000, 5000)
|
-5000, 5000)
|
||||||
check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
|
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},
|
# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN
|
||||||
-5000, 5000)
|
# 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])
|
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},
|
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999},
|
||||||
-5000, 5000)
|
-5000, 5000)
|
||||||
|
|||||||
Reference in New Issue
Block a user