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:
@@ -32,6 +32,9 @@ from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .arbiter import (
|
||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve,
|
||||
)
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .hass import HomeAssistant
|
||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||
@@ -158,17 +161,17 @@ class Controller:
|
||||
|
||||
# -- the cycle -----------------------------------------------------------
|
||||
async def cycle(self) -> None:
|
||||
"""Gather claims, resolve precedence, write the winner.
|
||||
|
||||
Nothing here decides who wins - arbiter.py does, by one rule. This
|
||||
method's only job is to state honestly what each strategy wants.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
result = self.maint.tick(now, self.soc)
|
||||
for msg in result.events:
|
||||
self.log_event(msg)
|
||||
|
||||
# ⚠️ Fail toward inaction, and do it ACTIVELY. If the inputs are missing
|
||||
# we command 0 rather than replaying the last value. The YAML
|
||||
# implementation this replaces kept replaying its last setpoint when the
|
||||
# meter died - which the hardware watchdog cannot catch, because from the
|
||||
# ESP32's point of view Home Assistant is still talking to it.
|
||||
stale_after = float(self.o.get("stale_input_s", 15))
|
||||
if not self.inputs_ok:
|
||||
bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0
|
||||
@@ -177,52 +180,73 @@ class Controller:
|
||||
# going, and give it a few seconds to come back.
|
||||
self.reason = f"inputs missing {bad_for:.0f}s"
|
||||
return
|
||||
if self.target != 0.0:
|
||||
self.log_event(f"inputs missing for {bad_for:.0f}s - commanding 0 W")
|
||||
self.target, self.reason = 0.0, "inputs-missing"
|
||||
await self.write_setpoint(0.0)
|
||||
return
|
||||
|
||||
if result.owns_setpoint:
|
||||
# drain / hold / abort: maintenance drives directly.
|
||||
self.target = float(result.setpoint_w or 0.0)
|
||||
self.reason = f"maintenance:{result.phase}"
|
||||
self.sat_count = 0
|
||||
await self.write_setpoint(self.target)
|
||||
return
|
||||
claims = []
|
||||
|
||||
# --- safety limits: these bind EVERY strategy, always ----------------
|
||||
# Defence in depth: write_setpoint() clamps to the device range too. The
|
||||
# duplication is deliberate - one of them is the policy, the other is the
|
||||
# last thing between a bug and the hardware.
|
||||
if self.dev_min is not None and self.dev_max is not None:
|
||||
claims.append(Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, "rating"))
|
||||
max_w = float(self.o.get("max_w", 2000))
|
||||
claims.append(Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"))
|
||||
|
||||
# --- safety stops ----------------------------------------------------
|
||||
if not self.auto:
|
||||
self.target, self.reason = 0.0, "stopped"
|
||||
await self.write_setpoint(0.0)
|
||||
return
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped"))
|
||||
elif not self.inputs_ok:
|
||||
if self.target != 0.0:
|
||||
self.log_event("inputs missing - commanding 0 W")
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing"))
|
||||
|
||||
charge_floor = 0.0
|
||||
charge_only = result.charge_only
|
||||
if charge_only:
|
||||
# Money outranks the maintenance schedule (see control.peak_at_risk).
|
||||
# --- maintenance -----------------------------------------------------
|
||||
if result.owns_setpoint:
|
||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||
float(result.setpoint_w or 0.0), result.phase))
|
||||
self.sat_count = 0
|
||||
elif result.charge_only:
|
||||
# Money outranks the maintenance schedule: while the quarter-hour
|
||||
# projection is over the cap, the charge-only shaping is simply not
|
||||
# claimed, so the loop can discharge and shave the peak. When a
|
||||
# dedicated peak-shaving strategy arrives it will claim SET at
|
||||
# P_PEAK and outrank this limit without any code here changing.
|
||||
if peak_at_risk(self.peak_fc, float(self.o.get("peak_cap_w", 3500))):
|
||||
charge_only = False
|
||||
self.log_event("peak at risk - suspending charge-only clamp")
|
||||
elif self.cheap:
|
||||
charge_floor = maintenance_charge_floor(
|
||||
float(self.o.get("maintenance_charge_w", 2500)),
|
||||
self.peak_fc,
|
||||
float(self.o.get("peak_cap_w", 3500)),
|
||||
)
|
||||
self.log_event("peak at risk - maintenance charge shaping suspended")
|
||||
else:
|
||||
hi = 0.0
|
||||
reason = "charge-only"
|
||||
if self.cheap:
|
||||
floor = maintenance_charge_floor(
|
||||
float(self.o.get("maintenance_charge_w", 2500)),
|
||||
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason))
|
||||
|
||||
decision = compute(
|
||||
prev_w=self.target,
|
||||
grid_w=self.grid,
|
||||
actual_w=self.batt,
|
||||
tuning=self.tuning,
|
||||
sat_count=self.sat_count,
|
||||
charge_only=charge_only,
|
||||
charge_floor_w=charge_floor,
|
||||
)
|
||||
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs {self.batt:.0f} W)")
|
||||
self.target, self.sat_count, self.reason = (
|
||||
decision.target_w, decision.sat_count, decision.reason)
|
||||
# --- the grid-following loop -----------------------------------------
|
||||
if self.auto and self.inputs_ok:
|
||||
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
||||
# wish. If something outranked the loop, that is what the hardware
|
||||
# actually did, and the controller must track reality or it jumps
|
||||
# the moment it regains control.
|
||||
decision = compute(
|
||||
prev_w=self.target,
|
||||
grid_w=self.grid,
|
||||
actual_w=self.batt,
|
||||
tuning=self.tuning,
|
||||
sat_count=self.sat_count,
|
||||
)
|
||||
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs {self.batt:.0f} W)")
|
||||
self.sat_count = decision.sat_count
|
||||
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason))
|
||||
|
||||
resolution = resolve(claims)
|
||||
if resolution.contradiction:
|
||||
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log")
|
||||
self.target = resolution.target_w
|
||||
self.reason = resolution.explain()
|
||||
await self.write_setpoint(self.target)
|
||||
|
||||
# -- tasks ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user