Files
goodwe-addon/goodwe_controller/test_control.py
T
adminandClaude Opus 5 017b798fe6 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
2026-08-23 02:45:32 +02:00

105 lines
4.2 KiB
Python

"""Runnable check for the control law. `python3 test_control.py`
No framework, no fixtures - it needs to run on a tech's laptop and in CI with
nothing installed. Every assert here corresponds to a rule that exists because
its absence caused an observed failure on real hardware.
If you change control.py, run this. If it fails, the inverter would have done
something you did not intend.
"""
import sys
from app.control import Tuning, compute, maintenance_charge_floor, peak_at_risk
T = Tuning()
fails = []
def check(name, cond):
if cond:
print(f" ok {name}")
else:
print(f" FAIL {name}")
fails.append(name)
print("control law")
# Deadband: inside meter noise, hold exactly - do not drift.
d = compute(prev_w=900, grid_w=10, actual_w=900, tuning=T)
check("deadband holds the command", d.target_w == 900 and d.reason == "deadband")
d = compute(prev_w=900, grid_w=20, actual_w=900, tuning=T)
check("outside deadband it acts", d.target_w != 900)
# Proportional: 0 + 0.6*500 = 300
d = compute(prev_w=0, grid_w=500, actual_w=0, tuning=T)
check("proportional step (gain 0.6)", d.target_w == 300)
# Sign: exporting (negative grid) must CHARGE (negative target).
d = compute(prev_w=0, grid_w=-500, actual_w=0, tuning=T)
check("export drives charging", d.target_w == -300)
# Clamp
d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=Tuning(max_w=2000, slew_w=5000))
check("clamped to max_w", d.target_w == 2000)
# Slew: from 0 with a huge error, no more than slew_w in one cycle.
d = compute(prev_w=0, grid_w=5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
check("slew limits one cycle", d.target_w == 1000)
# Saturation needs DURATION: one diverging cycle must NOT freeze.
t = Tuning(saturation_w=500, saturation_cycles=3)
d1 = compute(prev_w=2000, grid_w=500, actual_w=1000, tuning=t, sat_count=0)
check("one saturated cycle does not freeze", not d1.frozen and d1.sat_count == 1)
d2 = compute(prev_w=2000, grid_w=500, actual_w=1000, tuning=t, sat_count=d1.sat_count)
d3 = compute(prev_w=2000, grid_w=500, actual_w=1000, tuning=t, sat_count=d2.sat_count)
check("three consecutive saturated cycles freeze", d3.frozen)
check("freeze forbids raising magnitude", d3.target_w <= 2000)
# ...and one good cycle clears the counter immediately.
d4 = compute(prev_w=2000, grid_w=500, actual_w=1990, tuning=t, sat_count=3)
check("counter resets when tracking resumes", d4.sat_count == 0 and not d4.frozen)
# Freeze must still allow the magnitude to FALL (that is the escape route).
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)
# 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))
check("quantised to step_w", d.target_w % 10 == 0)
print("capacity tariff")
check("no forecast means no cap", maintenance_charge_floor(2500, None, 3500) == 2500)
check("headroom caps the charge", maintenance_charge_floor(2500, 2000, 3500) == 1500)
check("no headroom means no charge", maintenance_charge_floor(2500, 4000, 3500) == 0)
check("peak risk detected", peak_at_risk(4000, 3500) is True)
check("peak risk off without forecast", peak_at_risk(None, 3500) is False)
print("behaviour: 2 kW load step converges")
# Closed-loop sim. The plant is modelled as first-order-ish: it moves most of
# the way to the command each cycle (measured: 94 % by 3.3 s against a ~5 s
# cycle). House load steps by 2000 W at t=0.
prev, actual, sat, load = 0.0, 0.0, 0, 2000.0
cycles = 0
for i in range(12):
grid = load - actual # what the meter sees
d = compute(prev, grid, actual, T, sat)
prev, sat = d.target_w, d.sat_count
actual = actual + 0.94 * (prev - actual) # plant follows
cycles += 1
if abs(load - actual) < T.deadband_w:
break
check(f"converges within deadband in {cycles} cycles (<=6)", cycles <= 6)
check("no overshoot past the load", actual <= load + T.deadband_w)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")