Precedence arbiter: one rule instead of an if/else ladder
Two controllers writing one actuator is the failure this system exists to
avoid. "Exactly one writer" was true, but only as a convention held up by
careful reading - which does not survive an EV charger and a heat pump wanting
the same battery.
Strategies now return claims and arbiter.py resolves them:
highest-priority `set` wins (none at all means 0 W), then every `limit` whose
priority is >= that set's applies, most restrictive first; contradictory
limits command 0 W and are flagged as the bug they are.
The second clause is the whole point. "Money outranks maintenance" used to be a
hand-written exception inside a Jinja template; it is now a consequence of the
priorities - the charge-only limit binds the loop but cannot bind a
higher-priority peak claim.
Also: maintenance shaping moved out of control.py, which is a controller again
and not a policy engine; the loop now tracks the arbiter's actual output rather
than its own last wish, so it does not jump when it regains control; and every
decision explains itself ("loop -> 0 W, limited by maintenance(charge-only)")
in the UI and the log.
19 new assertions in test_arbiter.py, each one a precedence question someone
will eventually ask in the field. Deployed to the reference site as 0.2.0 and
holding grid within a few watts of zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
This commit is contained in:
@@ -44,9 +44,6 @@ def compute(
|
||||
actual_w: float,
|
||||
tuning: Tuning,
|
||||
sat_count: int = 0,
|
||||
*,
|
||||
charge_only: bool = False,
|
||||
charge_floor_w: float = 0.0,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -83,15 +80,10 @@ def compute(
|
||||
else:
|
||||
want = prev_w + tuning.gain * grid_w
|
||||
|
||||
# --- maintenance charge shaping ---------------------------------------
|
||||
# Applied BEFORE clamp and slew so a forced charge is still rate-limited
|
||||
# like any other demand.
|
||||
if charge_only:
|
||||
want = min(want, 0.0)
|
||||
reason = "charge-only"
|
||||
if charge_floor_w > 0:
|
||||
want = min(want, -charge_floor_w)
|
||||
reason = "charge-floor"
|
||||
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
||||
# here. It now belongs to arbiter.py as limit claims, so that precedence
|
||||
# between strategies is decided in ONE place. This function is again what it
|
||||
# should be: a controller that knows only about tracking the meter.
|
||||
|
||||
# --- ORDER MATTERS: clamp -> slew -> freeze ----------------------------
|
||||
# An early draft applied a floor after the clamp and let demand escape it.
|
||||
|
||||
Reference in New Issue
Block a user