Fill the post-confirm forecast gap with the real setpoint, not a mid-ramp snapshot

_continue_forecast_after_confirm()'s bridge was repeating forecast_theta[-1]
to cover the real-world wait, but that's whatever value the simulation
happened to log the instant WAIT_USER tripped - often still mid-ramp, not the
step's actual hold target. The real controller stays enabled and actively
holding throughout WAIT_USER, so capture its setpoint (tc.get_theta_soll_set())
in recv() before calling Sud.confirm() - confirming synchronously pushes the
next step's own target onto it - and use that to fill the gap instead.

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 17:24:02 +02:00
co-authored by Claude Sonnet 4.6
parent 84b1607c78
commit 031b13ca2c
2 changed files with 26 additions and 8 deletions
+10 -2
View File
@@ -309,8 +309,16 @@ looks up the relevant `confirm_points` entry, truncates the forecast right
back to that point, and splices in a freshly anchored simulation of the back to that point, and splices in a freshly anchored simulation of the
remaining steps - anchored at the real elapsed time and real current remaining steps - anchored at the real elapsed time and real current
temperature, so an actual delay shows up honestly as a gap in the timeline temperature, so an actual delay shows up honestly as a gap in the timeline
rather than as the assumed zero. The corrected forecast is sent in full rather than as the assumed zero. That gap is filled at the real
each time, so the GUI's `SudForecastPlot.show_forecast()` simply redraws controller's setpoint at the moment of confirmation (`recv()` captures
`tc.get_theta_soll_set()` *before* calling `Sud.confirm()`, since confirming
synchronously pushes the next step's own target onto it) rather than
whatever value the forecast happened to log the instant `WAIT_USER`
tripped - the controller stays enabled and actively holding throughout
`WAIT_USER`, so the setpoint is what it was actually converging toward
during the wait, not a possibly-still-mid-ramp snapshot. The corrected
forecast is sent in full each time, so the GUI's `SudForecastPlot.
show_forecast()` simply redraws
the dashed line outright rather than patching it up itself. the dashed line outright rather than patching it up itself.
Comparing the two lines: real-world divergence from the forecast - more Comparing the two lines: real-world divergence from the forecast - more
+16 -6
View File
@@ -164,7 +164,7 @@ class SudTask(ATask):
'Finished': self.forecast_finished, 'Finished': self.forecast_finished,
}}) }})
async def _continue_forecast_after_confirm(self, confirmed_index): async def _continue_forecast_after_confirm(self, confirmed_index, confirmed_target):
"""Corrects the optimistic, zero-delay guess send_forecast() (or a """Corrects the optimistic, zero-delay guess send_forecast() (or a
previous call to this method) made at confirmed_index's previous call to this method) made at confirmed_index's
user-confirmation step, now that the confirmation has actually user-confirmation step, now that the confirmation has actually
@@ -177,6 +177,12 @@ class SudTask(ATask):
the real current temperature (more trustworthy than whatever the the real current temperature (more trustworthy than whatever the
now-superseded guess predicted it would be by now). now-superseded guess predicted it would be by now).
confirmed_target is the real controller's theta_soll_set at the
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 No-op if confirmed_index was never part of a computed forecast in
the first place (e.g. the estimator isn't configured).""" the first place (e.g. the estimator isn't configured)."""
if self.forecast_estimator is None: if self.forecast_estimator is None:
@@ -210,12 +216,15 @@ class SudTask(ATask):
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, confirm_points = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
# Bridge the gap between the confirmation point and the real # Bridge the gap between the confirmation point and the real
# elapsed time it actually happened at, holding flat at its last # elapsed time it actually happened at - the real controller
# temperature - then append the new segment, offset to start # stays enabled and actively holding through WAIT_USER, so
# exactly there. # confirmed_target (its setpoint at the time) is what it was
# actually converging toward during the wait, not whatever
# (possibly still mid-ramp) value the forecast happened to log
# the instant WAIT_USER tripped.
if self.forecast_t and self.forecast_t[-1] < real_elapsed: if self.forecast_t and self.forecast_t[-1] < real_elapsed:
self.forecast_t.append(real_elapsed) self.forecast_t.append(real_elapsed)
self.forecast_theta.append(self.forecast_theta[-1]) self.forecast_theta.append(confirmed_target)
self.forecast_t.extend(real_elapsed + seconds for seconds in t) self.forecast_t.extend(real_elapsed + seconds for seconds in t)
self.forecast_theta.extend(theta) self.forecast_theta.extend(theta)
# confirm_points' indices/times are relative to this sub-schedule # confirm_points' indices/times are relative to this sub-schedule
@@ -232,8 +241,9 @@ class SudTask(ATask):
self.sud.start() self.sud.start()
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()
self.sud.confirm() self.sud.confirm()
asyncio.create_task(self._continue_forecast_after_confirm(confirmed_index)) 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]: