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
+13 -16
View File
@@ -66,23 +66,22 @@ class SudForecastEstimator:
self.theta_amb = theta_amb
def estimate(self, doc, start_theta=None):
"""Returns (t, theta, final_state, confirm_points): t/theta are
parallel lists of elapsed simulated seconds and temperature,
covering doc['steps'] from the start all the way to the end
(final_state is SudState.DONE), or, in the pathological case of
a step whose target can never actually be reached, wherever
MAX_TICKS cut the simulation off.
"""Returns (t, theta, final_state): t/theta are parallel lists of
elapsed simulated seconds and temperature, covering doc['steps']
from the start all the way to the end (final_state is
SudState.DONE), or, in the pathological case of a step whose
target can never actually be reached, wherever MAX_TICKS cut the
simulation off.
A step requiring user confirmation doesn't stop the simulation
either - a human's response time genuinely can't be forecast,
so it's modeled as zero delay (auto-confirmed the instant that
step's hold completes) rather than leaving the estimate stuck
there forever. confirm_points records every place that
assumption was made, as (step_index, t) pairs, so the caller
(tasks/sud.py's SudTask) can correct it once a real
confirmation actually happens: truncate the forecast at that
point and splice in a freshly anchored simulation of the
remaining steps in place of the optimistic guess.
there forever. That optimistic guess gets corrected for real once
the caller (tasks/sud.py's SudTask) sees the schedule actually
reach the next step boundary - see SudTask._reanchor_forecast(),
called from on_step_changed() on every real transition, not just
confirmations.
start_theta defaults to the configured ambient temperature - i.e.
a cold start, same as the GUI's static estimate."""
@@ -91,7 +90,7 @@ class SudForecastEstimator:
sud = Sud()
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
# any default - sud.start() below synchronously fires the first
@@ -157,13 +156,11 @@ class SudForecastEstimator:
t = [0.0]
theta = [pot.get_temperature()]
confirm_points = []
sud.start()
ticks = 0
while sud.state != SudState.DONE and ticks < MAX_TICKS:
if sud.state == SudState.WAIT_USER:
confirm_points.append((sud.index, t[-1]))
sud.confirm()
continue
@@ -184,4 +181,4 @@ class SudForecastEstimator:
theta.append(pot.get_temperature())
ticks += 1
return t, theta, sud.state, confirm_points
return t, theta, sud.state