Add a Progress tab: one step plate per schedule step, stacked

A glance at the Automatic tab's forecast plot couldn't show where the
brew actually stands within its own schedule - just a curve. The new
Progress tab stacks one StepPlate per step instead: an LED for its
status (off/done/active-ramping/active-holding), its resolved target
temp, masses and ramp rate, plus, for whichever step is currently
active, live actual temp and stirrer state (frozen at their last
value once that step finishes) and a forecast-based remaining-time
countdown.

The countdown needs to know where each step actually begins in the
forecast's timeline, which the server didn't track before -
SudForecastEstimator.estimate() now returns per-step start times
alongside t/theta, and SudTask threads them through send_forecast()/
_reanchor_forecast() the same way (including the same generation-guard
against the concurrent-call race) as a new 'StepStarts' field on the
'Forecast' message.

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 17:15:14 +02:00
co-authored by Claude Sonnet 4.6
parent 0be8ffcbe0
commit e49af8a347
3 changed files with 338 additions and 12 deletions
+29 -3
View File
@@ -73,6 +73,13 @@ class SudTask(ATask):
# of a step whose target can never be reached (see
# components/sud_forecast.py's MAX_TICKS).
self.forecast_finished = True
# Where each schedule step begins, in the same absolute timeline as
# forecast_t - keyed by the schedule's own (absolute) step index,
# rebased the same way as forecast_t/forecast_theta themselves on
# every send_forecast()/_reanchor_forecast() call (see either's own
# comment). Lets a client (the GUI's Progress tab) show each step's
# predicted total/remaining duration without re-deriving it itself.
self.forecast_step_starts = {}
# Bumped by every call to send_forecast()/_reanchor_forecast() -
# see either's own comment for why: it lets a call that's still
# awaiting its worker-thread simulation tell, once it resumes,
@@ -224,13 +231,14 @@ class SudTask(ATask):
# whole window.
loop = asyncio.get_event_loop()
start_theta = self.tc.get_theta_ist_set()
t, theta, final_state = await loop.run_in_executor(
t, theta, final_state, step_starts = await loop.run_in_executor(
None, self.forecast_estimator.estimate, doc, start_theta)
if generation != self._forecast_generation:
return
self.forecast_t = t
self.forecast_theta = theta
self.forecast_finished = (final_state == SudState.DONE)
self.forecast_step_starts = step_starts
await self._send_forecast()
async def _send_forecast(self):
@@ -239,6 +247,12 @@ class SudTask(ATask):
'T': t,
'Theta': theta,
'Finished': self.forecast_finished,
# Sorted [index, t] pairs rather than a {index: t} object - JSON
# object keys are always strings, which would force every
# consumer to int() them back; a plain sorted list sidesteps
# that and is just as easy to look up from (the GUI's Progress
# tab only ever needs it index-aligned with its own step list).
'StepStarts': sorted(self.forecast_step_starts.items()),
}})
async def _reanchor_forecast(self):
@@ -276,15 +290,20 @@ class SudTask(ATask):
cut = bisect.bisect_right(self.forecast_t, real_elapsed)
forecast_t = self.forecast_t[:cut]
forecast_theta = self.forecast_theta[:cut]
# Steps already passed (< index) have their real, now-immutable
# start time; anything from index on is about to be resimulated
# fresh below and must not keep a stale prediction around.
schedule = self.sud.schedule
index = self.sud.index
forecast_step_starts = {i: tt for i, tt in self.forecast_step_starts.items() if i < index}
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_step_starts = forecast_step_starts
await self._send_forecast()
return
doc = {
@@ -300,7 +319,7 @@ class SudTask(ATask):
}
start_theta = self.tc.get_theta_ist()
loop = asyncio.get_event_loop()
t, theta, final_state = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
t, theta, final_state, step_starts = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
if generation != self._forecast_generation:
return
# Bridge any gap between the last surviving old point and right
@@ -313,9 +332,16 @@ class SudTask(ATask):
forecast_theta.append(start_theta)
forecast_t.extend(real_elapsed + seconds for seconds in t)
forecast_theta.extend(theta)
# step_starts' indices/times are relative to this sub-schedule
# (starting fresh at doc['steps'][0]) - rebase both onto the real
# schedule's absolute indices and the master forecast timeline,
# same as t/theta above.
forecast_step_starts.update(
(index + local_index, real_elapsed + local_t) for local_index, local_t in step_starts.items())
self.forecast_t = forecast_t
self.forecast_theta = forecast_theta
self.forecast_finished = (final_state == SudState.DONE)
self.forecast_step_starts = forecast_step_starts
await self._send_forecast()
async def recv(self, data):