From 389d9ecd6dfaf31d0cf458d45b9ce39b52d21f5b Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 24 Aug 2026 22:59:29 +0200 Subject: [PATCH] SAFETY-04: stop the clamps standing in for the mechanisms under test AC 3 - "integration freezes while saturated" - had no non-vacuous test. Deleting the integrator freeze outright failed 0 of 55 checks: the two checks that name it used a fixture at max_w 2000 with the integrator bound following it, so a wound value was truncated back to exactly 2000 and the assertion passed on the clamp instead. Fixture lifted to max_w 5000, clear of every rail. Deleting the freeze now fails 2. Then swept the whole function for the same pattern, one mechanism at a time: delete it, count which checks notice. It found a second instance - the OUTPUT clamp. `clamped to max_w` and `clamped to -max_w` were both satisfied by the integrator bound truncating first, so removing the output clamp failed only the reason-string check. Those two fixtures now set integrator_max_w above max_w so the mechanism they name is the binding one; the output clamp goes from 1 failure to 3. Every mechanism in compute() is now caught by a check that names it: freeze 2, integrator clamp 6, bound-follows-max_w 4, output clamp 3, slew 4, output freeze 2, deadband 5, quantise 2, detector 10, duration 2, counter reset 6, grid bias 3, None-seeding 6. No mechanism at zero. Method note: the audit disables bytecode caching. Rewriting control.py inside one second leaves a stale app/__pycache__ entry and silently under-reports - it under-reported one mutation as 2 failures where the true figure is 6. test_control.py stays at 55 checks, all passing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa --- goodwe_controller/test_control.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/goodwe_controller/test_control.py b/goodwe_controller/test_control.py index 3436d8f..ec1943b 100644 --- a/goodwe_controller/test_control.py +++ b/goodwe_controller/test_control.py @@ -41,15 +41,19 @@ check("proportional step (gain 0.6)", d.target_w == 300) 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)) +# Clamp. +# ⚠️ integrator_max_w is lifted clear of max_w so that the OUTPUT clamp is the +# mechanism under test. Left at the default the integrator bound truncates +# first, these two assertions pass on that alone, and deleting the output clamp +# fails nothing - the same masking that hid the integrator freeze. +TCLAMP = Tuning(max_w=2000, slew_w=5000, integrator_max_w=5000) +d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=TCLAMP) 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) -d = compute(prev_w=-1900, grid_w=-1000, actual_w=-1900, - tuning=Tuning(max_w=2000, slew_w=5000)) +d = compute(prev_w=-1900, grid_w=-1000, actual_w=-1900, tuning=TCLAMP) check("clamped to -max_w", d.target_w == -2000) d = compute(prev_w=0, grid_w=-5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000)) check("slew limits one cycle, charging", d.target_w == -1000) @@ -132,7 +136,12 @@ check("integrator bound binds independently of the output clamp", d.i_w == 1000 and d.target_w == 1000) # Freeze = may not wind further in the direction it is already pushing. -TF = Tuning(saturation_w=500, saturation_cycles=3) +# ⚠️ max_w is raised WELL above the fixtures on purpose. At the default 2000 +# the integrator bound truncates a wound value back to exactly 2000 and +# satisfies these assertions on its own, so deleting the freeze outright +# failed nothing - the clamp was standing in for the mechanism under test. +# Any fixture here must sit clear of every rail, or it tests the rail. +TF = Tuning(saturation_w=500, saturation_cycles=3, max_w=5000) f1 = compute(prev_w=2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0) check("frozen: integration does not wind further", f1.i_w == 2000.0 and f1.frozen) f2 = compute(prev_w=2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)