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:
+29
-3
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user