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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
87 lines
3.3 KiB
Python
Executable File
87 lines
3.3 KiB
Python
Executable File
from components.plant.pot import Pot
|
|
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
|
|
|
|
# Fast model: same plant model but with zero transport delay, used
|
|
# to predict the current temperature without the dead time.
|
|
self.model = Pot(dt)
|
|
# Delayed model: keeps the plant's assumed transport delay, so it
|
|
# can be compared like-for-like against the real (delayed) measurement.
|
|
self.model_delay = Pot(dt)
|
|
|
|
self.theta_ist_plant = 0
|
|
self.theta_ist_model = 0
|
|
self.theta_ist_model_delay = 0
|
|
|
|
def set_model_plant_params(self, model_params):
|
|
self.model.set_plant_params({**model_params, 'Td': 0})
|
|
self.model_delay.set_plant_params(model_params)
|
|
|
|
def set_model_power(self, power):
|
|
self.model.set_power(power)
|
|
self.model_delay.set_power(power)
|
|
|
|
def set_ambient_temperature(self, theta_amb):
|
|
self.model.set_ambient_temperature(theta_amb)
|
|
self.model_delay.set_ambient_temperature(theta_amb)
|
|
|
|
def is_configured(self):
|
|
return super().is_configured() and self.model.is_configured() and self.model_delay.is_configured()
|
|
|
|
def on_state_entered(self, state):
|
|
if state in (States.HEAT, States.COOL):
|
|
self.model.initial(self.theta_ist)
|
|
self.model_delay.initial(self.theta_ist)
|
|
|
|
def post_pid(self):
|
|
self.model.process()
|
|
self.model_delay.process()
|
|
|
|
def process(self):
|
|
self._require_params()
|
|
if not (self.model.is_configured() and self.model_delay.is_configured()):
|
|
raise RuntimeError(
|
|
"{}.process(): model plant params and/or ambient temperature not set - "
|
|
"call set_model_plant_params() and set_ambient_temperature() first".format(type(self).__name__))
|
|
|
|
self.theta_ist_plant = self.theta_ist_set
|
|
self.theta_ist_model = self.model.get_temperature()
|
|
self.theta_ist_model_delay = self.model_delay.get_temperature()
|
|
|
|
# Smith predictor: use the fast model's prediction, corrected by the
|
|
# mismatch between the real (delayed) plant and the delayed model,
|
|
# 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
|
|
|
|
# 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
|
|
|
|
self.heatrate_soll = self.heatrate_soll_set * self.pid_hold.get_y()
|
|
theta_err = self.theta_soll_set - self.theta_ist
|
|
heatrate_err = self.heatrate_soll - self.heatrate_ist
|
|
|
|
diff = self.theta_soll_set - self.theta_ist
|
|
self.process_fsm(diff)
|
|
self.process_pid(theta_err, heatrate_err, hold_scale)
|