pid: option A heat-rate pre-filter (beta IIR before differentiation)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 17:02:14 +02:00
co-authored by Claude Sonnet 4.6
parent 8ca4e4d803
commit 5be1d4e862
5 changed files with 31 additions and 33 deletions
+23
View File
@@ -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