Generalize Sud forecast reanchoring to every step boundary

Reanchoring used to only happen when a user confirmed a WAIT_USER
step, so anything the schedule advanced through on its own (a ramp
reaching target, a hold timing out) left the forecast showing a
stale, increasingly wrong prediction once reality diverged from it.
_reanchor_forecast() now fires from on_step_changed() on every real
transition, splicing in a fresh simulation anchored at the real
current temperature/elapsed time instead.

Also fixes two bugs surfaced while testing that change:
- estimate()'s early-return for an empty/not-yet-loaded schedule still
  returned the old 4-tuple shape, crashing every connection before a
  Sud was ever Loaded.
- send_forecast() and _reanchor_forecast() can run concurrently (e.g.
  a fresh Start triggers both at once), and whichever resumed second
  after its own worker-thread simulation would blindly splice its tail
  onto whatever the other had already written, producing a spurious
  connecting line across the plot. Both now carry a generation counter
  and discard their result if a newer call has since committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
This commit is contained in:
2026-06-24 10:50:17 +02:00
co-authored by Claude Sonnet 4.6
parent d8e9c39fda
commit 17e8386002
4 changed files with 124 additions and 103 deletions
+7 -7
View File
@@ -129,10 +129,10 @@ class SudForecastPlot(FigureCanvasQTAgg):
requiring user confirmation - a human's response time genuinely requiring user confirmation - a human's response time genuinely
can't be forecast, so it's modeled as a zero-delay auto-confirm can't be forecast, so it's modeled as a zero-delay auto-confirm
there instead of stopping. That assumption gets corrected once the there instead of stopping. That assumption gets corrected once the
real confirmation actually happens (anchored at the real elapsed schedule actually reaches the next real step boundary (anchored at
time and temperature) - see tasks/sud.py's SudTask. the real elapsed time and temperature) - see tasks/sud.py's
_continue_forecast_after_confirm() - at which point show_forecast() SudTask._reanchor_forecast() - at which point show_forecast() is
is called again with the corrected curve, replacing the optimistic called again with the corrected curve, replacing the optimistic
guess outright rather than the GUI patching it up itself.""" guess outright rather than the GUI patching it up itself."""
def __init__(self): def __init__(self):
@@ -894,9 +894,9 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# Computed asynchronously server-side and can arrive slightly # Computed asynchronously server-side and can arrive slightly
# after the schedule itself - applies regardless of whether a # after the schedule itself - applies regardless of whether a
# run is in progress, since this can also be a later piecewise # run is in progress, since this can also be a later piecewise
# segment appended after a real user confirmation (see # segment appended after a real step transition (see tasks/
# tasks/sud.py's SudTask._continue_forecast_after_confirm()), not # sud.py's SudTask._reanchor_forecast()), not just the initial
# just the initial one computed at Load. # one computed at Load.
if self.sud_empty: if self.sud_empty:
return return
t_min = [seconds / 60.0 for seconds in forecast['T']] t_min = [seconds / 60.0 for seconds in forecast['T']]
+13 -16
View File
@@ -66,23 +66,22 @@ class SudForecastEstimator:
self.theta_amb = theta_amb self.theta_amb = theta_amb
def estimate(self, doc, start_theta=None): def estimate(self, doc, start_theta=None):
"""Returns (t, theta, final_state, confirm_points): t/theta are """Returns (t, theta, final_state): t/theta are parallel lists of
parallel lists of elapsed simulated seconds and temperature, elapsed simulated seconds and temperature, covering doc['steps']
covering doc['steps'] from the start all the way to the end from the start all the way to the end (final_state is
(final_state is SudState.DONE), or, in the pathological case of SudState.DONE), or, in the pathological case of a step whose
a step whose target can never actually be reached, wherever target can never actually be reached, wherever MAX_TICKS cut the
MAX_TICKS cut the simulation off. simulation off.
A step requiring user confirmation doesn't stop the simulation A step requiring user confirmation doesn't stop the simulation
either - a human's response time genuinely can't be forecast, either - a human's response time genuinely can't be forecast,
so it's modeled as zero delay (auto-confirmed the instant that so it's modeled as zero delay (auto-confirmed the instant that
step's hold completes) rather than leaving the estimate stuck step's hold completes) rather than leaving the estimate stuck
there forever. confirm_points records every place that there forever. That optimistic guess gets corrected for real once
assumption was made, as (step_index, t) pairs, so the caller the caller (tasks/sud.py's SudTask) sees the schedule actually
(tasks/sud.py's SudTask) can correct it once a real reach the next step boundary - see SudTask._reanchor_forecast(),
confirmation actually happens: truncate the forecast at that called from on_step_changed() on every real transition, not just
point and splice in a freshly anchored simulation of the confirmations.
remaining steps in place of the optimistic guess.
start_theta defaults to the configured ambient temperature - i.e. start_theta defaults to the configured ambient temperature - i.e.
a cold start, same as the GUI's static estimate.""" a cold start, same as the GUI's static estimate."""
@@ -91,7 +90,7 @@ class SudForecastEstimator:
sud = Sud() sud = Sud()
if not sud.load(doc) or not sud.schedule: if not sud.load(doc) or not sud.schedule:
return [0.0], [start_theta], SudState.DONE, [] return [0.0], [start_theta], SudState.DONE
# Plant params (M/C/L/Td) are deliberately *not* seeded here from # Plant params (M/C/L/Td) are deliberately *not* seeded here from
# any default - sud.start() below synchronously fires the first # any default - sud.start() below synchronously fires the first
@@ -157,13 +156,11 @@ class SudForecastEstimator:
t = [0.0] t = [0.0]
theta = [pot.get_temperature()] theta = [pot.get_temperature()]
confirm_points = []
sud.start() sud.start()
ticks = 0 ticks = 0
while sud.state != SudState.DONE and ticks < MAX_TICKS: while sud.state != SudState.DONE and ticks < MAX_TICKS:
if sud.state == SudState.WAIT_USER: if sud.state == SudState.WAIT_USER:
confirm_points.append((sud.index, t[-1]))
sud.confirm() sud.confirm()
continue continue
@@ -184,4 +181,4 @@ class SudForecastEstimator:
theta.append(pot.get_temperature()) theta.append(pot.get_temperature())
ticks += 1 ticks += 1
return t, theta, sud.state, confirm_points return t, theta, sud.state
+102 -78
View File
@@ -59,13 +59,13 @@ class SudTask(ATask):
# one. # one.
self.forecast_estimator = forecast_estimator self.forecast_estimator = forecast_estimator
# The forecast as last computed/corrected (see send_forecast()/ # The forecast as last computed/corrected (see send_forecast()/
# _continue_forecast_after_confirm()) - T in simulated seconds, # _reanchor_forecast()) - T in simulated seconds, Theta in degrees,
# Theta in degrees, parallel lists, both monotonically growing as # parallel lists, both monotonically growing as corrections get
# corrections get spliced in. Covers the whole schedule from the # spliced in. Covers the whole schedule from the very first Load,
# very first Load, including through steps requiring user # including through steps requiring user confirmation - those are
# confirmation - those are simulated as a zero-delay auto-confirm # simulated as a zero-delay auto-confirm (see components/
# (see components/sud_forecast.py's SudForecastEstimator. # sud_forecast.py's SudForecastEstimator.estimate()) rather than
# estimate()) rather than left unforecast. # left unforecast, until _reanchor_forecast() corrects it for real.
self.forecast_t = [] self.forecast_t = []
self.forecast_theta = [] self.forecast_theta = []
# Whether the forecast above actually reaches the schedule's real # Whether the forecast above actually reaches the schedule's real
@@ -73,14 +73,13 @@ class SudTask(ATask):
# of a step whose target can never be reached (see # of a step whose target can never be reached (see
# components/sud_forecast.py's MAX_TICKS). # components/sud_forecast.py's MAX_TICKS).
self.forecast_finished = True self.forecast_finished = True
# Where each user-confirmation step's zero-delay assumption sits # Bumped by every call to send_forecast()/_reanchor_forecast() -
# in the forecast timeline above, keyed by the schedule's # see either's own comment for why: it lets a call that's still
# (absolute) step index - see SudForecastEstimator.estimate()'s # awaiting its worker-thread simulation tell, once it resumes,
# confirm_points. Consulted by _continue_forecast_after_confirm() # whether a newer call has since started and already committed a
# to know where to cut the optimistic guess loose and splice in a # fresher result - if so, it discards its own rather than
# freshly anchored simulation once that confirmation actually # corrupting forecast_t/forecast_theta with stale data.
# happens for real. self._forecast_generation = 0
self.forecast_confirm_marks = {}
msg_handler.set_recv_handler(self.recv) msg_handler.set_recv_handler(self.recv)
def apply_plant_params(self, grain_mass, water_mass): def apply_plant_params(self, grain_mass, water_mass):
@@ -131,6 +130,14 @@ class SudTask(ATask):
self.tc.set_theta_soll(step['temperature']) self.tc.set_theta_soll(step['temperature'])
self.tc.set_heatrate_soll(ramp['rate']) self.tc.set_heatrate_soll(ramp['rate'])
self.apply_stirrer(phase) self.apply_stirrer(phase)
# Every real step boundary (full step change or ramp->hold
# within one) is a trustworthy checkpoint to correct the
# forecast against - see _reanchor_forecast(). Catches drift
# from anything the original simulation couldn't have known
# (a malt fill-in's actual cooldown, a longer/shorter ramp than
# modeled, ...) at the next opportunity, not just at the next
# user confirmation.
asyncio.create_task(self._reanchor_forecast())
asyncio.create_task(self.send({'Step': { asyncio.create_task(self.send({'Step': {
'Index': self.sud.index, 'Index': self.sud.index,
@@ -184,31 +191,46 @@ class SudTask(ATask):
actually will, never lining up with the actual trace even at actually will, never lining up with the actual trace even at
t=0. Deliberately the raw sensor reading (theta_ist_set), not t=0. Deliberately the raw sensor reading (theta_ist_set), not
the controller's own get_theta_ist() (theta_ist) - the controller's own get_theta_ist() (theta_ist) -
_continue_forecast_after_confirm() can trust that one because a _reanchor_forecast() can trust that one because a real run has
real run has been actively ticking for a while by the time it been actively ticking for a while by the time it runs, but this
runs, but this is called right after Load, before this is called right after Load, before this controller may have
controller may have processed even a single tick yet (e.g. its processed even a single tick yet (e.g. its plant params/model
plant params/model only just got configured - see SudTask. only just got configured - see SudTask.recv()), so theta_ist
recv()), so theta_ist itself could still be sitting at its itself could still be sitting at its never-updated __init__
never-updated __init__ default. default.
Those zero-delay assumptions get corrected piecewise as real Those zero-delay assumptions get corrected piecewise as the
confirmations actually happen - see schedule actually reaches each step boundary - see
_continue_forecast_after_confirm().""" _reanchor_forecast()."""
if self.forecast_estimator is None: if self.forecast_estimator is None:
return return
# Both this and _reanchor_forecast() can end up running
# concurrently - e.g. a fresh Start triggers this explicitly *and*
# (via Sud.start() synchronously firing on_step_changed() for the
# first step) a _reanchor_forecast() of its own; a step whose hold
# duration is already 0 can likewise advance twice within a single
# tick, firing on_step_changed() twice back to back. Each such call
# awaits a worker-thread simulation, so without this guard whichever
# one resumes second would blindly splice its own tail onto
# whatever the other already finished writing, producing a
# spurious connecting line across the plot. Bumping/checking this
# generation counter across the await ensures only the very latest
# call's result is ever committed - any older one discards itself.
self._forecast_generation += 1
generation = self._forecast_generation
# Runs the simulation in a worker thread - it's CPU-bound and can # Runs the simulation in a worker thread - it's CPU-bound and can
# take a couple hundred ms for a long schedule, which would # take a couple hundred ms for a long schedule, which would
# 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()
start_theta = self.tc.get_theta_ist_set() start_theta = self.tc.get_theta_ist_set()
t, theta, final_state, confirm_points = await loop.run_in_executor( t, theta, final_state = await loop.run_in_executor(
None, self.forecast_estimator.estimate, doc, start_theta) None, self.forecast_estimator.estimate, doc, start_theta)
if generation != self._forecast_generation:
return
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)
self.forecast_confirm_marks = dict(confirm_points)
await self._send_forecast() await self._send_forecast()
async def _send_forecast(self): async def _send_forecast(self):
@@ -219,43 +241,49 @@ class SudTask(ATask):
'Finished': self.forecast_finished, 'Finished': self.forecast_finished,
}}) }})
async def _continue_forecast_after_confirm(self, confirmed_index, confirmed_target): async def _reanchor_forecast(self):
"""Corrects the optimistic, zero-delay guess send_forecast() (or a """Corrects the optimistic, zero-delay guesses baked into the
previous call to this method) made at confirmed_index's forecast (see SudForecastEstimator.estimate()'s docstring) now
user-confirmation step, now that the confirmation has actually that the schedule has actually reached a real step boundary -
happened for real - see SudForecastEstimator.estimate()'s called from on_step_changed() on every transition, whether it's
docstring for why that assumption can't just be trusted as-is. a user confirmation or fully automatic (e.g. a ramp reaching its
Truncates the forecast right back to that point and splices in a target, or a hold's duration running out). Truncates the forecast
freshly anchored simulation of the rest of the schedule, anchored back to right now and splices in a freshly anchored simulation of
at the real elapsed time (self.sud.elapsed) - so an actual delay the rest of the schedule, anchored at the real elapsed time
shows up honestly as a gap rather than as the assumed zero - and (self.sud.elapsed) and the real current temperature
the real current temperature (more trustworthy than whatever the (self.tc.get_theta_ist()) - so any divergence the original
now-superseded guess predicted it would be by now). simulation couldn't have predicted (a malt fill-in's actual
cooldown, a longer/shorter ramp than modeled, ...) gets corrected
at the next opportunity instead of leaving the forecast stuck
showing what was once guessed.
confirmed_target is the real controller's theta_soll_set at the No-op if the estimator isn't configured.
moment of confirmation (captured by recv() *before* calling
Sud.confirm(), since that synchronously pushes the next step's
own target onto it) - used to fill the real-world wait itself
(see below).
No-op if confirmed_index was never part of a computed forecast in Guards against the same concurrent-call race send_forecast() does
the first place (e.g. the estimator isn't configured).""" (see its own comment) by working off local copies of the forecast
lists throughout, only ever committing them to self.forecast_t/
forecast_theta right at the end, and only if this is still the
latest call by then - an older call resuming after a newer one
has already committed must discard its own (now-stale) result
rather than splice it onto what the newer call already wrote."""
if self.forecast_estimator is None: if self.forecast_estimator is None:
return return
mark_t = self.forecast_confirm_marks.pop(confirmed_index, None) self._forecast_generation += 1
if mark_t is None: generation = self._forecast_generation
return real_elapsed = self.sud.elapsed
# Drop the now-stale tail (everything beyond the confirmation # Drop the now-stale tail (everything beyond right now) - it's
# point) - both the curve itself and any confirm marks that fell # about to be replaced by a freshly anchored simulation.
# within it, since they're about to be replaced by fresh ones. cut = bisect.bisect_right(self.forecast_t, real_elapsed)
cut = bisect.bisect_right(self.forecast_t, mark_t) forecast_t = self.forecast_t[:cut]
self.forecast_t = self.forecast_t[:cut] forecast_theta = self.forecast_theta[:cut]
self.forecast_theta = self.forecast_theta[:cut]
self.forecast_confirm_marks = {idx: t for idx, t in self.forecast_confirm_marks.items() if t <= mark_t}
schedule = self.sud.schedule schedule = self.sud.schedule
index = self.sud.index index = self.sud.index
if not (0 <= index < len(schedule)): if not (0 <= index < len(schedule)):
if generation != self._forecast_generation:
return
self.forecast_t = forecast_t
self.forecast_theta = forecast_theta
self.forecast_finished = True self.forecast_finished = True
await self._send_forecast() await self._send_forecast()
return return
@@ -271,26 +299,22 @@ class SudTask(ATask):
'steps': schedule[index:], 'steps': schedule[index:],
} }
start_theta = self.tc.get_theta_ist() start_theta = self.tc.get_theta_ist()
real_elapsed = self.sud.elapsed
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) t, theta, final_state = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
# Bridge the gap between the confirmation point and the real if generation != self._forecast_generation:
# elapsed time it actually happened at - the real controller return
# stays enabled and actively holding through WAIT_USER, so # Bridge any gap between the last surviving old point and right
# confirmed_target (its setpoint at the time) is what it was # now (e.g. the old forecast's timeline had already drifted
# actually converging toward during the wait, not whatever # behind real_elapsed) with the same real measurement the fresh
# (possibly still mid-ramp) value the forecast happened to log # simulation below starts from, so the spliced curve doesn't
# the instant WAIT_USER tripped. # visibly jump.
if self.forecast_t and self.forecast_t[-1] < real_elapsed: if forecast_t and forecast_t[-1] < real_elapsed:
self.forecast_t.append(real_elapsed) forecast_t.append(real_elapsed)
self.forecast_theta.append(confirmed_target) forecast_theta.append(start_theta)
self.forecast_t.extend(real_elapsed + seconds for seconds in t) forecast_t.extend(real_elapsed + seconds for seconds in t)
self.forecast_theta.extend(theta) forecast_theta.extend(theta)
# confirm_points' indices/times are relative to this sub-schedule self.forecast_t = forecast_t
# (starting fresh at doc['steps'][0]) - rebase both onto the real self.forecast_theta = forecast_theta
# schedule's absolute indices and the master forecast timeline.
self.forecast_confirm_marks.update(
(index + local_index, real_elapsed + local_t) for local_index, local_t in confirm_points)
self.forecast_finished = (final_state == SudState.DONE) self.forecast_finished = (final_state == SudState.DONE)
await self._send_forecast() await self._send_forecast()
@@ -309,10 +333,10 @@ class SudTask(ATask):
if fresh_start: if fresh_start:
asyncio.create_task(self.send_forecast(self.sud.save())) asyncio.create_task(self.send_forecast(self.sud.save()))
elif 'Confirm' in pair[0]: elif 'Confirm' in pair[0]:
confirmed_index = self.sud.index # Sud.confirm() synchronously fires on_step_changed() for
confirmed_target = self.tc.get_theta_soll_set() # the now-current step, which schedules the forecast
# reanchor itself - see _reanchor_forecast().
self.sud.confirm() self.sud.confirm()
asyncio.create_task(self._continue_forecast_after_confirm(confirmed_index, confirmed_target))
elif 'Pause' in pair[0]: elif 'Pause' in pair[0]:
self.sud.pause() self.sud.pause()
elif 'Stop' in pair[0]: elif 'Stop' in pair[0]:
+2 -2
View File
@@ -33,8 +33,8 @@ class SudLogTask(ATask):
is of no use before the run is over anyway. The forecast file is is of no use before the run is over anyway. The forecast file is
written from SudTask.forecast_t/forecast_theta as they stand at written from SudTask.forecast_t/forecast_theta as they stand at
that point - the *final*, most-corrected version (see tasks/sud.py's that point - the *final*, most-corrected version (see tasks/sud.py's
SudTask._continue_forecast_after_confirm()), not the optimistic SudTask._reanchor_forecast()), not the optimistic guess computed
guess computed back at Load. back at Load.
Samples are taken once per interval regardless of Sud.state Samples are taken once per interval regardless of Sud.state
(RAMPING/HOLDING/WAIT_USER/PAUSED all keep recording, mirroring (RAMPING/HOLDING/WAIT_USER/PAUSED all keep recording, mirroring