MAINT-04: refuse a maintenance charge the ceiling cannot deliver
config.yaml shipped max_w 2000 against maintenance_charge_w 2500. The cheap-window shaping files Claim.limit(hi = -maintenance_charge_w) against the supervised SAFETY limit's lo = -max_w, so lo > hi - the arbiter's clause 3, correctly resolved to 0 W and logged as a CONTRADICTION. Maintenance silently never charged in a cheap window on a default install, and the only evidence read like a bug in the arbiter rather than like the two numbers the operator set. validate_startup_config() now refuses maintenance_charge_w > max_w, the same shape as CFG-01's sign_disagree_w >= max_w. Equality is reachable (-max_w is a single valid point) and is not refused. Demonstrated by starting the add-on on the pre-fix pair: exit 78 and the refusal banner. maintenance_discharge_w was checked rather than assumed and is NOT the same case: the drain phase OWNS the actuator (Claim.set at P_MAINTENANCE), so the ceiling clamps it to +2000 W and the arbiter's explain() names the clamp every cycle. Loud, and it still moves energy - warned, not refused. The shipped maintenance rates drop to 2000 so the defaults pass the rule; max_w was deliberately not raised, because it is the supervised safety ceiling. test_validate.py now reads config.yaml itself, so a default the validator refuses cannot ship again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
co-authored by
Claude Opus 5
parent
bb64f8fdd7
commit
878a6ce452
@@ -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,48 @@ 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, if
|
||||
# config.yaml's value moves, move this and main.py's together.
|
||||
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 +356,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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user