From eac457344aba131eb146fd998f009f1e086ed2c4 Mon Sep 17 00:00:00 2001 From: Fernando Gonzalez Date: Wed, 14 Oct 2026 19:55:05 -0400 Subject: [PATCH] fix: settle controller judges a step-up on the same reading as a step-down With the ambient prior well under the cable's real temperature, the settle controller stepped up on the fused estimate (which the cool prior pulls down) and the step-down rule, on the live stretch's own reading, took the amp back on the next tick: a 1 A step does not start a new stretch. In a closed-loop fixture with the prior 8 C low: 48 -> 42 -> 43 -> 42 A, the last two 30 s apart. I* up is now worked from the same higher-of-fused-and-live reading as I* down. With k_up >= k_down it can never exceed I* down, so a step-up is never to a current the down rule would refuse. Whole-history closed-loop backtest (production snapshot 2026-10-14, 93 sessions with a usable plant), settle at a 1.5 C soak margin, before / after, fitted plant: trips 0 / 0, worst peak 63.7 / 63.7 C, amp changes 315 / 250, reversals 61 / 0, flaps undone within a minute 23 / 0, 3981.2 / 3982.1 Ah. On the four hotter plants: no trips either way, reversals 88-97 -> 3-5. No session got worse; 09-06 goes from 12 changes and 6 reversals to 6 and none. Step-ups nearly vanish (32 -> 0 on the fitted plant, 40-50 -> 2-3 on the hotter ones) with no loss of amp-hours: on this history nearly every step-up was later undone. The plant holds ambient constant through a session, so the backtest cannot reward a step-up that a cooling evening would justify; that needs live sessions. Co-Authored-By: Claude Opus 5.5 --- contrib/settle_amp_control.py | 18 ++++++++++++------ tests/test_settle_amp_control.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/contrib/settle_amp_control.py b/contrib/settle_amp_control.py index 692c532..2d5e24a 100644 --- a/contrib/settle_amp_control.py +++ b/contrib/settle_amp_control.py @@ -46,11 +46,12 @@ current. Not rate-limited, and judged on the higher of the fused theta and the present stretch's own reading: the cable soaks through a hot session, so earlier readings are of a cooler cable. -- **Up** only when I* (at ``k_up``) is at least 1 A over, the present - stretch is at least a time constant old, the handle has margin, and a - time constant has passed since the last move. A step-up undone within a - time constant widens ``k_up`` for the rest of the session — it never - gives up and pins the session low. +- **Up** only when I* (at ``k_up``, on that same higher reading, so a + step-up is never to a current the down rule would refuse) is at least + 1 A over, the present stretch is at least a time constant old, the handle + has margin, and a time constant has passed since the last move. A step-up + undone within a time constant widens ``k_up`` for the rest of the session + — it never gives up and pins the session low. - **Emergency:** handle at ``emergency_c`` and still rising (against itself two minutes ago, not the last tick) — cut 2 A below the present current, or below I* if that is lower, whatever the estimate says. @@ -187,6 +188,11 @@ def decide(thermal: dict, state: State, cfg: Config) -> tuple[Action, State, str # present stretch's own reading once it is half a time constant old. # (Whole-history backtest: without this, hot days landed 3-4 A high and # walked down 1 A at a time into the trip — seven sessions.) + # Stepping *up* is judged on the same number. On the fused estimate + # alone, a prior well under the cable's real temperature offered an amp + # that the live reading took back on the next tick (a 1 A step does not + # start a new stretch): with k_up >= k_down, I* up can now never exceed + # I* down. theta_down = theta if st.live is not None and age_s >= cfg.live_down_tau * tau_s: theta_down = max(theta, st.live[0]) @@ -194,7 +200,7 @@ def decide(thermal: dict, state: State, cfg: Config) -> tuple[Action, State, str cur = st.cap if st.cap is not None else cfg.normal_amps k_up = cfg.k_up + st.k_extra i_down = _amps_for(theta_down, cfg.k_down * sd, model, cfg) - i_up = _amps_for(theta, k_up * sd, model, cfg) + i_up = _amps_for(theta_down, k_up * sd, model, cfg) rising = False if isinstance(handle, (int, float)): log = [(ts, value) for ts, value in st.handle_log if now - ts <= cfg.rising_window_s + 45.0] + [(now, handle)] diff --git a/tests/test_settle_amp_control.py b/tests/test_settle_amp_control.py index 02e0b76..2787fc4 100644 --- a/tests/test_settle_amp_control.py +++ b/tests/test_settle_amp_control.py @@ -102,6 +102,16 @@ def test_front_load_drops_once_to_the_target_when_the_handle_nears_the_plateau() assert peak < MODEL["trip_c"] - sac.Config().soak_margin_c +def test_a_cool_prior_does_not_buy_an_amp_the_live_reading_takes_back(): + # The same cable, but the ambient prior reads 8 C under it. The fused + # estimate sits between the two; judged on it alone, the step-up rule + # offered 43 A at minute 24 and the step-down rule, on the stretch's own + # reading, took it back 30 s later. + moves, _peak, state = _closed_loop(theta=33.0, prior=25.0, minutes=120) + assert [(kind, amps) for _minute, kind, amps in moves] == [("cap", 42.0)] + assert state.k_extra == 0.0 # no reversal to back off from + + def test_a_long_stretch_is_one_reading_not_one_per_tick(): # 40 minutes at one current: the stretch must never be banked as finished. ticks = [(30.0 * i, 50.0, 48.7, 60.0) for i in range(1, 81)]