From 5be1d4e862014244f0985c2bafce6a2529a84eb9 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Tue, 30 Jun 2026 17:02:14 +0200 Subject: [PATCH] pid: option A heat-rate pre-filter (beta IIR before differentiation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a configurable IIR pre-filter (beta, default 0.05, τ≈19 s at dt=1 s) applied to theta_ist before the derivative, so stirrer-induced temperature noise is attenuated before being amplified by differentiation rather than after. The shared filter/derivative/post-filter logic is factored into TempControllerBase._compute_heatrate(); both Normal and Smith subclasses call it, replacing their duplicated inline rate blocks. beta is read from TempCtrl.beta in the config (default 0.05 if absent) and added to config-sim.json.tpl and config-real.json.tpl. Co-Authored-By: Claude Sonnet 4.6 --- components/pid/temp_controller.py | 20 +++----------------- components/pid/temp_controller_base.py | 23 +++++++++++++++++++++++ components/pid/temp_controller_smith.py | 19 +++---------------- config-real.json.tpl | 1 + config-sim.json.tpl | 1 + 5 files changed, 31 insertions(+), 33 deletions(-) diff --git a/components/pid/temp_controller.py b/components/pid/temp_controller.py index a23e95a..6474359 100644 --- a/components/pid/temp_controller.py +++ b/components/pid/temp_controller.py @@ -4,28 +4,14 @@ from components.pid.temp_controller_base import TempControllerBase class TempController(TempControllerBase): def __init__(self, dt): TempControllerBase.__init__(self, dt) - self.dt = dt - # 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 + # last_theta_ist / theta_ist_filtered / beta / dt all live in the base - + # see TempControllerBase.__init__ and _compute_heatrate(). def process(self): self._require_params() - # Process Kalman self.theta_ist = self.theta_ist_set - 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 - self.last_theta_ist = self.theta_ist + self._compute_heatrate(self.theta_ist) # Compensate for max heat rate to reduce overshoot hold_scale = 1.0/self.heatrate_soll_set if self.heatrate_soll_set > 0 else 1.0 diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py index 9ea43cf..20bbf20 100644 --- a/components/pid/temp_controller_base.py +++ b/components/pid/temp_controller_base.py @@ -7,6 +7,7 @@ class TempControllerBase(TempControllerFsm, APid): def __init__(self, dt): APid.__init__(self) TempControllerFsm.__init__(self, dt) + self.dt = dt self.theta_ist_set = 0 self.theta_soll_set = 0 self.heatrate_ist_set = 0 @@ -19,14 +20,36 @@ class TempControllerBase(TempControllerFsm, APid): # already relies on the same "None means not configured yet". self.params = None self.y = -1 + # Heat-rate pre-filter state (option A) - None until first process() tick + self.last_theta_ist = None + self.theta_ist_filtered = None + self.beta = 0.05 def set_params(self, params): self.params = params self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})} + self.beta = params.get('beta', 0.05) self.pid_hold.set_params(params['Hold']) self.pid_heat.set_params(params['Heat']) self.pid_cool.set_params(params['Cool']) + def _compute_heatrate(self, theta_ist): + """Pre-filter theta_ist, then differentiate and low-pass to get heatrate_ist. + + Filtering before differentiating (option A) keeps noise that comes in on + theta_ist from being amplified by the derivative — stirrer-induced + temperature fluctuations would otherwise produce K/min rate noise of the + same order as a typical rate_soll, directly corrupting the heat PID error.""" + if self.theta_ist_filtered is None: + self.theta_ist_filtered = theta_ist + else: + self.theta_ist_filtered = (1 - self.beta) * self.theta_ist_filtered + self.beta * theta_ist + heatrate = 0.0 if self.last_theta_ist is None else \ + (self.theta_ist_filtered - self.last_theta_ist) / self.dt * 60 + alpha = 0.1 + self.heatrate_ist = (1 - alpha) * self.heatrate_ist + alpha * heatrate + self.last_theta_ist = self.theta_ist_filtered + def _require_params(self): """Called by each subclass's process() before doing anything else - without it, a missing set_params() call would otherwise only diff --git a/components/pid/temp_controller_smith.py b/components/pid/temp_controller_smith.py index 18bb534..bae6e97 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -5,17 +5,8 @@ from components.pid.temp_controller_base import TempControllerBase, States class TempController(TempControllerBase): def __init__(self, dt): TempControllerBase.__init__(self, dt) - self.dt = dt - # 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 + # last_theta_ist / theta_ist_filtered / beta / dt all live in the base - + # see TempControllerBase.__init__ and _compute_heatrate(). # Fast model: same plant model but with zero transport delay, used # to predict the current temperature without the dead time. @@ -68,11 +59,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 = 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 - self.last_theta_ist = self.theta_ist + self._compute_heatrate(self.theta_ist) # Compensate for max heat rate to reduce overshoot hold_scale = 1.0/self.heatrate_soll_set if self.heatrate_soll_set > 0 else 1.0 diff --git a/config-real.json.tpl b/config-real.json.tpl index e5f1395..da53ef5 100644 --- a/config-real.json.tpl +++ b/config-real.json.tpl @@ -6,6 +6,7 @@ "pid_type": "Smith" }, "TempCtrl": { + "beta": 0.05, "Hold": { "kp": 0.4, "ki": 0.0, diff --git a/config-sim.json.tpl b/config-sim.json.tpl index d975d5b..95b636d 100644 --- a/config-sim.json.tpl +++ b/config-sim.json.tpl @@ -6,6 +6,7 @@ "pid_type": "Smith" }, "TempCtrl": { + "beta": 0.05, "Hold": { "kp": 0.4, "ki": 0.0,