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:
2026-08-23 02:45:32 +02:00
co-authored by Claude Opus 5
parent 36cc837446
commit 017b798fe6
7 changed files with 320 additions and 69 deletions
+2 -11
View File
@@ -66,17 +66,8 @@ check("counter resets when tracking resumes", d4.sat_count == 0 and not d4.froze
d = compute(prev_w=2000, grid_w=-800, actual_w=1000, tuning=t, sat_count=3)
check("freeze still allows magnitude to fall", d.target_w < 2000)
# Charge-only (maintenance charge phase)
d = compute(prev_w=500, grid_w=500, actual_w=500, tuning=T, charge_only=True)
check("charge-only never discharges", d.target_w <= 0)
d = compute(prev_w=0, grid_w=0, actual_w=0, tuning=T, charge_only=True, charge_floor_w=800)
check("charge floor pulls at least the floor", d.target_w == -800)
# Charge floor must still respect slew (floor applied BEFORE slew).
d = compute(prev_w=0, grid_w=0, actual_w=0, tuning=Tuning(slew_w=200),
charge_only=True, charge_floor_w=2000)
check("charge floor is still slew-limited", d.target_w == -200)
# Maintenance shaping (charge-only, cheap-window floor) is no longer this
# function's business - it is expressed as limit claims. See test_arbiter.py.
# Quantisation
d = compute(prev_w=0, grid_w=7, actual_w=0, tuning=Tuning(deadband_w=1, step_w=10))