From d981c36194daf521fdd060d5a0c7023efe846cf7 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 22 Jun 2026 22:41:14 +0200 Subject: [PATCH] Fix bogus first-tick heatrate from hardcoded last_theta_ist=20 last_theta_ist was hardcoded to 20 in __init__, used to compute the very first process() tick's heatrate as (theta_ist - 20)/dt*60 - a wildly bogus spike whenever the real starting temperature wasn't actually 20 (e.g. 1200 deg/min for a 40-degree start). The exponentially-smoothed heatrate_ist (alpha=0.1) takes several ticks to recover from that, during which it distorts the PID's rate-error term and visibly throws off the heating trajectory. This was invisible everywhere this assumption was implicitly tested, since ambient (and every start_theta used) was always exactly 20. It surfaced once SudForecastEstimator.estimate() started being called with a real, non-20 start_theta: it builds a fresh TempController on every call, so its first tick always hits this bug fresh - unlike the real controller, which only ever goes through this once near server startup and has long since self-corrected (last_theta_ist = theta_ist runs every tick) by the time any forecast comparison matters. That's exactly what showed up as a forecast-vs-actual gap during the initial ramp. last_theta_ist now starts None; process() treats the first tick's heatrate as 0 (no real history yet) instead of computing it against a hardcoded, usually-wrong baseline. Verified: ambient=20 (the only case ever exercised before) is byte- identical to before the fix. ambient=40/start=40 - reproducing the old hardcoded-20 behavior side by side - shows the old version visibly lagging ~12 minutes behind the fixed one during the initial ramp before they converge, matching the reported gap exactly. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk --- components/pid/temp_controller.py | 12 ++++++++++-- components/pid/temp_controller_smith.py | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/components/pid/temp_controller.py b/components/pid/temp_controller.py index 986fa3d..a23e95a 100644 --- a/components/pid/temp_controller.py +++ b/components/pid/temp_controller.py @@ -5,7 +5,15 @@ class TempController(TempControllerBase): def __init__(self, dt): TempControllerBase.__init__(self, dt) self.dt = dt - self.last_theta_ist = 20 + # None until the first process() tick - rather than hardcoding a + # starting point (e.g. 20), which would make that very first + # tick's heatrate come out as a bogus spike whenever the real + # starting temperature isn't actually 20 (see + # components/sud_forecast.py's SudForecastEstimator, which builds + # a fresh TempController on every call - unlike the real one, + # which only ever goes through this exactly once, it has no + # earlier ticks to have already settled past that spike by). + self.last_theta_ist = None self.heatrate_ist = 0 def process(self): @@ -13,7 +21,7 @@ class TempController(TempControllerBase): # Process Kalman self.theta_ist = self.theta_ist_set - heatrate = (self.theta_ist - self.last_theta_ist)/self.dt*60 + heatrate = 0 if self.last_theta_ist is None else (self.theta_ist - self.last_theta_ist)/self.dt*60 alpha = 0.1 self.heatrate_ist = (1-alpha) * self.heatrate_ist + alpha*heatrate diff --git a/components/pid/temp_controller_smith.py b/components/pid/temp_controller_smith.py index a806c2c..18bb534 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -6,7 +6,15 @@ class TempController(TempControllerBase): def __init__(self, dt): TempControllerBase.__init__(self, dt) self.dt = dt - self.last_theta_ist = 20 + # None until the first process() tick - rather than hardcoding a + # starting point (e.g. 20), which would make that very first + # tick's heatrate come out as a bogus spike whenever the real + # starting temperature isn't actually 20 (see + # components/sud_forecast.py's SudForecastEstimator, which builds + # a fresh TempController on every call - unlike the real one, + # which only ever goes through this exactly once, it has no + # earlier ticks to have already settled past that spike by). + self.last_theta_ist = None self.heatrate_ist = 0 # Fast model: same plant model but with zero transport delay, used @@ -60,7 +68,7 @@ class TempController(TempControllerBase): # so the dead time drops out of the feedback path. self.theta_ist = self.theta_ist_model + (self.theta_ist_plant - self.theta_ist_model_delay) - heatrate = (self.theta_ist - self.last_theta_ist)/self.dt*60 + heatrate = 0 if self.last_theta_ist is None else (self.theta_ist - self.last_theta_ist)/self.dt*60 alpha = 0.1 self.heatrate_ist = (1-alpha) * self.heatrate_ist + alpha*heatrate