Make the Sud forecast piecewise, computed once per segment
Previously, the dynamic forecast's projected remainder was fully
recomputed on every step change, and a step requiring user
confirmation was simulated with zero added delay ("count it as
instant for estimation purposes"). Both undercut the actual goal of
the forecast: an honest comparison between real control behavior and
a simulation-based prediction. A forecast that keeps re-anchoring
itself to match reality isn't a useful baseline to compare reality
against, and assuming zero confirmation delay quietly understates the
schedule.
components/sud_forecast.py: SudForecastEstimator.estimate() now stops
simulating at the first step requiring user confirmation instead of
auto-confirming, returning the final SudState alongside the data so
the caller knows whether it stopped there or actually finished.
tasks/sud.py: SudTask now accumulates the forecast across piecewise
segments (forecast_t/forecast_theta). send_forecast() computes only
the first segment, at Load/Save. The real Confirm handler triggers
_continue_forecast_after_confirm(), which computes the next segment
anchored at the real elapsed time and real current temperature -
bridging the unforecastable wait with a flat segment rather than
guessing at its length - and sends the updated, stitched Forecast
(now carrying a Finished flag). The old per-step-change
RemainingForecast recompute is gone entirely, along with
remaining_schedule()/send_remaining_forecast().
client/brewpi_gui.py: SudForecastPlot redesigned around this - the
dashed line is the fixed, piecewise forecast (locked axes, untouched
except when a genuinely new segment arrives via show_forecast());
the solid line is purely the actual measured trace
(show_dynamic(), now only touching that). extend_forecast_while_
waiting() repeats the forecast's last value while the real Sud sits
in WAIT_USER, so the dashed line doesn't just stop, then snaps to the
new segment the moment the real confirmation appends one.
Verified via a raw-protocol test (segment boundaries, the Finished
flag, and real-time stitching across a confirm delay) and visually in
the GUI (locked dashed forecast, "X min so far total" title while
incomplete, solid/dashed comparison rendering with realistic
convergence/divergence).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
This commit is contained in:
+58
-50
@@ -29,6 +29,18 @@ class SudTask(ATask):
|
||||
# build a SudTask without one still work; the server always passes
|
||||
# one.
|
||||
self.forecast_estimator = forecast_estimator
|
||||
# Accumulated forecast across all piecewise segments computed so
|
||||
# far (see send_forecast()/_continue_forecast_after_confirm()) -
|
||||
# T in simulated seconds, Theta in degrees, parallel lists, both
|
||||
# growing monotonically as segments get appended; never re-walked
|
||||
# from scratch once a run is in progress, except for the one
|
||||
# genuinely unforecastable case (a real user confirmation).
|
||||
self.forecast_t = []
|
||||
self.forecast_theta = []
|
||||
# Whether the most recently computed segment stopped at a step
|
||||
# requiring user confirmation rather than reaching the
|
||||
# schedule's actual end - see _continue_forecast_after_confirm().
|
||||
self.forecast_waiting_for_confirm = False
|
||||
msg_handler.set_recv_handler(self.recv)
|
||||
|
||||
def apply_plant_params(self, step):
|
||||
@@ -81,7 +93,6 @@ class SudTask(ATask):
|
||||
'Duration': hold.get('duration') if (hold is not None and not ramping) else None,
|
||||
'WaitForUser': step.get('user_wait_for_continue', False) if step else None,
|
||||
}}))
|
||||
asyncio.create_task(self.send_remaining_forecast())
|
||||
|
||||
def on_state_changed(self, value):
|
||||
asyncio.create_task(self.send({'State': str(value)}))
|
||||
@@ -108,6 +119,12 @@ class SudTask(ATask):
|
||||
asyncio.create_task(self.send({'Elapsed': value}))
|
||||
|
||||
async def send_forecast(self, doc):
|
||||
"""Computes and sends the first piecewise segment of doc's
|
||||
forecast - from the very start, up to either the schedule's end
|
||||
or its first step requiring user confirmation (see
|
||||
components/sud_forecast.py's SudForecastEstimator.estimate()).
|
||||
Always a fresh start: resets any segments accumulated for
|
||||
whatever was loaded before."""
|
||||
if self.forecast_estimator is None:
|
||||
return
|
||||
# Runs the simulation in a worker thread - it's CPU-bound and can
|
||||
@@ -115,67 +132,57 @@ class SudTask(ATask):
|
||||
# otherwise stall every other task (heater, sensor, ...) for that
|
||||
# whole window.
|
||||
loop = asyncio.get_event_loop()
|
||||
t, theta = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc)
|
||||
await self.send({'Forecast': {'T': t, 'Theta': theta}})
|
||||
t, theta, final_state = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc)
|
||||
self.forecast_t = t
|
||||
self.forecast_theta = theta
|
||||
self.forecast_waiting_for_confirm = (final_state == SudState.WAIT_USER)
|
||||
await self._send_forecast()
|
||||
|
||||
def remaining_schedule(self):
|
||||
"""The not-yet-done part of the running schedule - the current
|
||||
step's already-finished phase is dropped, and its still-to-run
|
||||
phase is left for SudForecastEstimator to size from the live
|
||||
start_theta/hold_remaining it's seeded with, rather than the
|
||||
step's nominal start. Mirrors client/brewpi_gui.py's
|
||||
Window._remaining_schedule(), which does the same thing
|
||||
client-side for its own (less accurate) naive fallback."""
|
||||
async def _send_forecast(self):
|
||||
await self.send({'Forecast': {
|
||||
'T': self.forecast_t,
|
||||
'Theta': self.forecast_theta,
|
||||
'Finished': not self.forecast_waiting_for_confirm,
|
||||
}})
|
||||
|
||||
async def _continue_forecast_after_confirm(self):
|
||||
"""Computes and appends the next piecewise segment once a real
|
||||
user confirmation has actually happened, rather than guessing at
|
||||
the delay - see SudForecastEstimator.estimate()'s docstring for
|
||||
why. Anchored at the real elapsed time (self.sud.elapsed), so the
|
||||
unforecastable wait shows up honestly as a gap in the forecast
|
||||
rather than as zero delay, and at the real current temperature
|
||||
(more trustworthy than whatever the now-superseded previous
|
||||
segment's simulation predicted it would be by now)."""
|
||||
if self.forecast_estimator is None or not self.forecast_waiting_for_confirm:
|
||||
return
|
||||
schedule = self.sud.schedule
|
||||
index = self.sud.index
|
||||
if not schedule or not (0 <= index < len(schedule)):
|
||||
return []
|
||||
current = schedule[index]
|
||||
rest = schedule[index + 1:]
|
||||
if self.sud.state == SudState.WAIT_USER:
|
||||
return rest
|
||||
if self.sud.state == SudState.HOLDING:
|
||||
# Keep the rest of the current (already fully-resolved) step -
|
||||
# grain_mass/water_mass/etc. - SudForecastEstimator re-resolves
|
||||
# each step against an empty default.step, so a bare
|
||||
# {'hold': {...}} here would leave those None instead of
|
||||
# inherited, breaking derive_plant_params(). The ramp phase is
|
||||
# already done, so drop 'temperature' (not 'ramp' - every step
|
||||
# always carries a 'ramp' dict now, dropping it would leave
|
||||
# 'rate' missing the moment this synthetic step is re-resolved):
|
||||
# without a 'temperature' of its own, the rebuilt step pushes
|
||||
# no new target, start_theta already seeds the simulation at
|
||||
# the target anyway, so it resolves out of its (now synthetic)
|
||||
# ramp phase in a tick or two regardless. Override the hold's
|
||||
# duration to what's actually left.
|
||||
remaining_minutes = max(self.sud.hold_remaining, 0.0) / 60.0
|
||||
synthetic = {k: v for k, v in current.items() if k != 'temperature'}
|
||||
synthetic['hold'] = {**current.get('hold', {}), 'duration': remaining_minutes}
|
||||
return [synthetic] + rest
|
||||
return [current] + rest
|
||||
|
||||
async def send_remaining_forecast(self):
|
||||
"""Like send_forecast(), but for the remaining steps only, seeded
|
||||
from the live current temperature - the dynamic forecast's
|
||||
projected-remainder line (GUI's SudForecastPlot.line_projected)
|
||||
uses this instead of its own naive abs(delta)/rate estimate
|
||||
whenever it's available."""
|
||||
if self.forecast_estimator is None:
|
||||
return
|
||||
remaining = self.remaining_schedule()
|
||||
if not remaining:
|
||||
if not (0 <= index < len(schedule)):
|
||||
self.forecast_waiting_for_confirm = False
|
||||
return
|
||||
doc = {
|
||||
'Name': self.sud.name,
|
||||
'Description': self.sud.description,
|
||||
'pot_mass': self.sud.pot_mass,
|
||||
'pot_material': self.sud.pot_material,
|
||||
'steps': remaining,
|
||||
'steps': schedule[index:],
|
||||
}
|
||||
start_theta = self.tc.get_theta_ist()
|
||||
real_elapsed = self.sud.elapsed
|
||||
loop = asyncio.get_event_loop()
|
||||
t, theta = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
|
||||
await self.send({'RemainingForecast': {'T': t, 'Theta': theta}})
|
||||
t, theta, final_state = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
|
||||
# Bridge the gap between where the previous segment's own
|
||||
# simulated time left off and the real elapsed time the confirm
|
||||
# actually happened at, holding flat at its last temperature -
|
||||
# then append the new segment, offset to start exactly there.
|
||||
if self.forecast_t and self.forecast_t[-1] < real_elapsed:
|
||||
self.forecast_t.append(real_elapsed)
|
||||
self.forecast_theta.append(self.forecast_theta[-1])
|
||||
self.forecast_t.extend(real_elapsed + seconds for seconds in t)
|
||||
self.forecast_theta.extend(theta)
|
||||
self.forecast_waiting_for_confirm = (final_state == SudState.WAIT_USER)
|
||||
await self._send_forecast()
|
||||
|
||||
async def recv(self, data):
|
||||
for pair in data.items():
|
||||
@@ -183,6 +190,7 @@ class SudTask(ATask):
|
||||
self.sud.start()
|
||||
elif 'Confirm' in pair[0]:
|
||||
self.sud.confirm()
|
||||
asyncio.create_task(self._continue_forecast_after_confirm())
|
||||
elif 'Pause' in pair[0]:
|
||||
self.sud.pause()
|
||||
elif 'Stop' in pair[0]:
|
||||
|
||||
Reference in New Issue
Block a user