Anchor the forecast to the real current temperature, not a cold start

send_forecast() was defaulting to a cold start at ambient (SudForecastEstimator.
estimate()'s default when start_theta isn't passed), so the forecast's t=0
never matched whatever temperature the pot actually was at - understating
how long the first ramp would really take whenever it wasn't already cold.

Anchoring at tc.get_theta_ist() seemed like the obvious fix but isn't
reliable right after Load: that's only ever updated inside process(), which
a model-based controller (Smith) doesn't run until its plant params are
configured - which now only happens once a Sud is loaded (see the
"inert until loaded" change) - so theta_ist can still be sitting at its
never-updated __init__ default of 0 the instant send_forecast() reads it.
Added TempControllerBase.get_theta_ist_set() (mirroring the existing
get_theta_soll_set()) - the raw sensor reading, updated the instant a
reading comes in regardless of whether process() has ever run - and used
that instead.

That still leaves a gap between Load and Start: the real temperature can
drift (time passing, manual heating via the Manual tab) between loading a
schedule and actually starting it, leaving the forecast anchored to a
now-stale temperature. recv()'s 'Start' handler now re-sends the forecast,
freshly anchored to the real temperature at that moment, on every fresh
start (not a Pause->resume, which keeps the already-established forecast
rather than discarding it mid-run).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
This commit is contained in:
2026-06-22 22:03:35 +02:00
co-authored by Claude Sonnet 4.6
parent 442b032117
commit d5a8c2422b
2 changed files with 40 additions and 1 deletions
+11
View File
@@ -97,6 +97,17 @@ class TempControllerBase(APid):
def get_theta_ist(self): def get_theta_ist(self):
return self.theta_ist return self.theta_ist
def get_theta_ist_set(self):
"""The raw sensor reading, as last pushed via set_theta_ist() -
unlike get_theta_ist() (self.theta_ist), this is updated the
instant a reading comes in, regardless of whether process() has
ever actually run (e.g. before set_params()/a model-based
controller's plant params have been configured - see SudTask.
send_forecast(), which needs a real "current temperature" at the
moment a Sud is loaded, before this controller may have ticked
even once)."""
return self.theta_ist_set
def set_heatrate_ist(self, value): def set_heatrate_ist(self, value):
self.heatrate_ist_set = value self.heatrate_ist_set = value
+29 -1
View File
@@ -176,6 +176,22 @@ class SudTask(ATask):
first one. Always a fresh start: discards whatever forecast was first one. Always a fresh start: discards whatever forecast was
accumulated for the previously loaded schedule. accumulated for the previously loaded schedule.
Anchored at the real current temperature
(self.tc.get_theta_ist_set()), not a cold start at ambient - the
pot may already be warm (a previous run, or manual heating) at
the moment this Sud is loaded, and a forecast that assumes
ambient regardless would reach every target later than it
actually will, never lining up with the actual trace even at
t=0. Deliberately the raw sensor reading (theta_ist_set), not
the controller's own get_theta_ist() (theta_ist) -
_continue_forecast_after_confirm() can trust that one because a
real run has been actively ticking for a while by the time it
runs, but this is called right after Load, before this
controller may have processed even a single tick yet (e.g. its
plant params/model only just got configured - see SudTask.
recv()), so theta_ist itself could still be sitting at its
never-updated __init__ default.
Those zero-delay assumptions get corrected piecewise as real Those zero-delay assumptions get corrected piecewise as real
confirmations actually happen - see confirmations actually happen - see
_continue_forecast_after_confirm().""" _continue_forecast_after_confirm()."""
@@ -186,7 +202,9 @@ class SudTask(ATask):
# otherwise stall every other task (heater, sensor, ...) for that # otherwise stall every other task (heater, sensor, ...) for that
# whole window. # whole window.
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
t, theta, final_state, confirm_points = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc) start_theta = self.tc.get_theta_ist_set()
t, theta, final_state, confirm_points = await loop.run_in_executor(
None, self.forecast_estimator.estimate, doc, start_theta)
self.forecast_t = t self.forecast_t = t
self.forecast_theta = theta self.forecast_theta = theta
self.forecast_finished = (final_state == SudState.DONE) self.forecast_finished = (final_state == SudState.DONE)
@@ -279,7 +297,17 @@ class SudTask(ATask):
async def recv(self, data): async def recv(self, data):
for pair in data.items(): for pair in data.items():
if 'Start' in pair[0]: if 'Start' in pair[0]:
# A fresh start (not a resume from Pause, which keeps
# whatever forecast the run already established) re-
# anchors the forecast to the real temperature right now
# - that's the actual "t=0" the about-to-start actual
# trace will be plotted from, which may no longer match
# whatever temperature existed back at Load (time passed,
# possibly manual heating in between).
fresh_start = self.sud.state in (SudState.IDLE, SudState.DONE)
self.sud.start() self.sud.start()
if fresh_start:
asyncio.create_task(self.send_forecast(self.sud.save()))
elif 'Confirm' in pair[0]: elif 'Confirm' in pair[0]:
confirmed_index = self.sud.index confirmed_index = self.sud.index
confirmed_target = self.tc.get_theta_soll_set() confirmed_target = self.tc.get_theta_soll_set()