Files
brewpi/components/pid/temp_controller.py
T
jensandClaude Sonnet 4.6 d981c36194 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 22:41:14 +02:00

40 lines
1.5 KiB
Python

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
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
# 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)