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:
@@ -4,28 +4,14 @@ from components.pid.temp_controller_base import TempControllerBase
|
|||||||
class TempController(TempControllerBase):
|
class TempController(TempControllerBase):
|
||||||
def __init__(self, dt):
|
def __init__(self, dt):
|
||||||
TempControllerBase.__init__(self, dt)
|
TempControllerBase.__init__(self, dt)
|
||||||
self.dt = dt
|
# last_theta_ist / theta_ist_filtered / beta / dt all live in the base -
|
||||||
# None until the first process() tick - rather than hardcoding a
|
# see TempControllerBase.__init__ and _compute_heatrate().
|
||||||
# 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):
|
def process(self):
|
||||||
self._require_params()
|
self._require_params()
|
||||||
|
|
||||||
# Process Kalman
|
|
||||||
self.theta_ist = self.theta_ist_set
|
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
|
self._compute_heatrate(self.theta_ist)
|
||||||
|
|
||||||
alpha = 0.1
|
|
||||||
self.heatrate_ist = (1-alpha) * self.heatrate_ist + alpha*heatrate
|
|
||||||
self.last_theta_ist = self.theta_ist
|
|
||||||
|
|
||||||
# Compensate for max heat rate to reduce overshoot
|
# 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
|
hold_scale = 1.0/self.heatrate_soll_set if self.heatrate_soll_set > 0 else 1.0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class TempControllerBase(TempControllerFsm, APid):
|
|||||||
def __init__(self, dt):
|
def __init__(self, dt):
|
||||||
APid.__init__(self)
|
APid.__init__(self)
|
||||||
TempControllerFsm.__init__(self, dt)
|
TempControllerFsm.__init__(self, dt)
|
||||||
|
self.dt = dt
|
||||||
self.theta_ist_set = 0
|
self.theta_ist_set = 0
|
||||||
self.theta_soll_set = 0
|
self.theta_soll_set = 0
|
||||||
self.heatrate_ist_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".
|
# already relies on the same "None means not configured yet".
|
||||||
self.params = None
|
self.params = None
|
||||||
self.y = -1
|
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):
|
def set_params(self, params):
|
||||||
self.params = params
|
self.params = params
|
||||||
self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})}
|
self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})}
|
||||||
|
self.beta = params.get('beta', 0.05)
|
||||||
self.pid_hold.set_params(params['Hold'])
|
self.pid_hold.set_params(params['Hold'])
|
||||||
self.pid_heat.set_params(params['Heat'])
|
self.pid_heat.set_params(params['Heat'])
|
||||||
self.pid_cool.set_params(params['Cool'])
|
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):
|
def _require_params(self):
|
||||||
"""Called by each subclass's process() before doing anything else -
|
"""Called by each subclass's process() before doing anything else -
|
||||||
without it, a missing set_params() call would otherwise only
|
without it, a missing set_params() call would otherwise only
|
||||||
|
|||||||
@@ -5,17 +5,8 @@ from components.pid.temp_controller_base import TempControllerBase, States
|
|||||||
class TempController(TempControllerBase):
|
class TempController(TempControllerBase):
|
||||||
def __init__(self, dt):
|
def __init__(self, dt):
|
||||||
TempControllerBase.__init__(self, dt)
|
TempControllerBase.__init__(self, dt)
|
||||||
self.dt = dt
|
# last_theta_ist / theta_ist_filtered / beta / dt all live in the base -
|
||||||
# None until the first process() tick - rather than hardcoding a
|
# see TempControllerBase.__init__ and _compute_heatrate().
|
||||||
# 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
|
# Fast model: same plant model but with zero transport delay, used
|
||||||
# to predict the current temperature without the dead time.
|
# 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.
|
# 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)
|
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
|
self._compute_heatrate(self.theta_ist)
|
||||||
|
|
||||||
alpha = 0.1
|
|
||||||
self.heatrate_ist = (1-alpha) * self.heatrate_ist + alpha*heatrate
|
|
||||||
self.last_theta_ist = self.theta_ist
|
|
||||||
|
|
||||||
# Compensate for max heat rate to reduce overshoot
|
# 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
|
hold_scale = 1.0/self.heatrate_soll_set if self.heatrate_soll_set > 0 else 1.0
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"pid_type": "Smith"
|
"pid_type": "Smith"
|
||||||
},
|
},
|
||||||
"TempCtrl": {
|
"TempCtrl": {
|
||||||
|
"beta": 0.05,
|
||||||
"Hold": {
|
"Hold": {
|
||||||
"kp": 0.4,
|
"kp": 0.4,
|
||||||
"ki": 0.0,
|
"ki": 0.0,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"pid_type": "Smith"
|
"pid_type": "Smith"
|
||||||
},
|
},
|
||||||
"TempCtrl": {
|
"TempCtrl": {
|
||||||
|
"beta": 0.05,
|
||||||
"Hold": {
|
"Hold": {
|
||||||
"kp": 0.4,
|
"kp": 0.4,
|
||||||
"ki": 0.0,
|
"ki": 0.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user