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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user