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
+20 -8
View File
@@ -66,12 +66,17 @@ class SudForecastEstimator:
self.theta_amb = theta_amb
def estimate(self, doc, start_theta=None):
"""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.
"""Returns (t, theta, final_state, step_starts): 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. step_starts maps each step's
index in doc['steps'] (0-based, local to this doc - the caller
rebases onto its own absolute schedule indices/timeline, same as
it does for t/theta themselves) to the simulated second it began
at - consulted by tasks/sud.py's SudTask to show each step's
predicted total/remaining duration (the GUI's Progress tab).
A step requiring user confirmation doesn't stop the simulation
either - a human's response time genuinely can't be forecast,
@@ -90,7 +95,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
@@ -142,9 +147,16 @@ class SudForecastEstimator:
pulse_counter = 0
return power_low if (power == 0 or pulse_counter >= on_count) else power_high
step_starts = {}
def on_step_changed(step):
if step is None:
return
# setdefault: this callback also re-fires for the same step's
# ramp->hold phase switch (components/sud.py's temp_reached()
# re-assigns self.step to retrigger it) - only the *first* call
# for a given index is its actual start.
step_starts.setdefault(sud.index, t[-1])
params = sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0))
pot.set_plant_params(params)
if hasattr(tc, 'set_model_plant_params'):
@@ -181,4 +193,4 @@ class SudForecastEstimator:
theta.append(pot.get_temperature())
ticks += 1
return t, theta, sud.state
return t, theta, sud.state, step_starts