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
+289 -1
View File
@@ -35,6 +35,17 @@ SUD_PAUSED_STATE = "SudState.PAUSED"
FRESH_RUN_ELAPSED_THRESHOLD_S = 5.0 FRESH_RUN_ELAPSED_THRESHOLD_S = 5.0
def _format_duration(seconds):
"""mm:ss, or h:mm:ss past an hour - used by StepPlate's remaining-time
label, where a brew step can run from a few minutes to a few hours."""
seconds = max(0, int(seconds))
hours, rem = divmod(seconds, 3600)
minutes, secs = divmod(rem, 60)
if hours:
return "{}:{:02d}:{:02d}".format(hours, minutes, secs)
return "{}:{:02d}".format(minutes, secs)
def _style_axis(ax): def _style_axis(ax):
ax.set_facecolor('white') ax.set_facecolor('white')
ax.spines['top'].set_visible(False) ax.spines['top'].set_visible(False)
@@ -246,6 +257,129 @@ class SudForecastPlot(FigureCanvasQTAgg):
self.draw_idle() self.draw_idle()
class StepPlate(QtWidgets.QFrame):
"""One rectangle per schedule step, stacked top-to-bottom on the
Progress tab to represent the whole Sud - see Window.
_rebuild_step_plates()/_update_step_plates(). The LED at a glance
communicates this step's status (not yet entered/finished/active);
the row below it mirrors what's actually driving the brew at that
step: target temp (resolved by the caller - a step without its own
'temperature' inherits whatever the previous one set, see
components/sud.py's _build_step()), masses and ramp rate as
configured in the schedule, plus, for whichever step is currently
active, the same live actual-temp/stirrer readings the Manual tab
shows (frozen at their last value once that step finishes, blank
before it's ever been reached)."""
LED_OFF = "#555555"
LED_GREEN = "#2ecc71"
LED_GREEN_DIM = "#1b5e36"
LED_ORANGE = "#e67e22"
LED_ORANGE_DIM = "#7a4111"
def __init__(self, index, step, resolved_temp):
QtWidgets.QFrame.__init__(self)
self.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.setFrameShadow(QtWidgets.QFrame.Raised)
# 'off' (not yet entered) | 'done' (finished, solid green) |
# 'ramp'/'hold' (active, flashing orange/green - see apply_blink()).
self.led_mode = 'off'
self.led = QtWidgets.QLabel()
self.led.setFixedSize(16, 16)
self.label_title = QtWidgets.QLabel("Step {}: {}".format(index + 1, step.get('descr') or ''))
self.label_title.setStyleSheet("font-weight: bold;")
self.label_title.setWordWrap(True)
self.label_remaining = QtWidgets.QLabel("--:--")
self.label_remaining.setStyleSheet("font-weight: bold;")
self.label_remaining.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
self.label_target = QtWidgets.QLabel()
self.label_actual = QtWidgets.QLabel()
self.label_masses = QtWidgets.QLabel()
self.label_rate = QtWidgets.QLabel()
self.label_stirrer = QtWidgets.QLabel()
for label in (self.label_target, self.label_actual, self.label_masses, self.label_rate, self.label_stirrer):
label.setStyleSheet("color: #555;")
# A grid, not a single row - the Progress tab shares its pane's
# width with the LCD panel on the Manual tab's side of the same
# splitter (see Window.__init__), which only leaves room for two
# short columns before needing a horizontal scrollbar.
layout = QtWidgets.QGridLayout(self)
layout.setColumnStretch(1, 1)
layout.addWidget(self.led, 0, 0)
layout.addWidget(self.label_title, 0, 1)
layout.addWidget(self.label_remaining, 0, 2)
layout.addWidget(self.label_target, 1, 0, 1, 2)
layout.addWidget(self.label_actual, 1, 2)
layout.addWidget(self.label_masses, 2, 0, 1, 3)
layout.addWidget(self.label_rate, 3, 0, 1, 2)
layout.addWidget(self.label_stirrer, 3, 2)
self.set_step(step, resolved_temp)
self.set_actual_temp(None)
self.set_live_stirrer(None)
def set_step(self, step, resolved_temp):
"""Applies this step's static, schedule-config fields - called once,
right after construction (see __init__)."""
grain = step.get('grain_mass', 0)
water = step.get('water_mass', 0)
self.label_masses.setText("Grain {:.2f} kg / Water {:.2f} kg".format(grain, water))
ramp = step.get('ramp') or {}
self.label_rate.setText("Rate {:.2f} °/min".format(ramp.get('rate', 0)))
hold = step.get('hold')
# The hold phase's speed, not the ramp phase's, as the "configured"
# fallback shown for an inactive step - hold is the long-running
# phase a glance at the plate cares about; ramp's own speed still
# shows live (see set_live_stirrer()) while this step is actually
# ramping.
hold_stirrer = hold.get('stirrer', {}) if hold else {}
self._configured_speed = hold_stirrer.get('speed', ramp.get('stirrer', {}).get('speed', 0))
self.label_target.setText(
"Target {}".format("{:.1f} °C".format(resolved_temp) if resolved_temp is not None else ""))
def set_actual_temp(self, temp):
self.label_actual.setText("Actual {}".format("{:.1f} °C".format(temp) if temp is not None else ""))
def set_live_stirrer(self, speed):
"""speed is the live rpm while this step is the active one, or None
to fall back to its configured (hold-phase) speed for a step
that's inactive (not yet reached, or already finished)."""
if speed is not None:
self.label_stirrer.setText("Stirrer {:.0f} rpm".format(speed))
else:
self.label_stirrer.setText("Stirrer {:.0f} rpm (cfg)".format(self._configured_speed))
def set_remaining(self, seconds):
self.label_remaining.setText(_format_duration(seconds) if seconds is not None else "--:--")
def set_led(self, mode):
self.led_mode = mode
if mode == 'done':
self._led_color(self.LED_GREEN)
elif mode == 'ramp':
self._led_color(self.LED_ORANGE)
elif mode == 'hold':
self._led_color(self.LED_GREEN)
else:
self._led_color(self.LED_OFF)
def apply_blink(self, blink_on):
"""Ticked by Window's shared blink timer - only an active step's LED
(mode 'ramp'/'hold') actually alternates; 'off'/'done' ignore it."""
if self.led_mode == 'ramp':
self._led_color(self.LED_ORANGE if blink_on else self.LED_ORANGE_DIM)
elif self.led_mode == 'hold':
self._led_color(self.LED_GREEN if blink_on else self.LED_GREEN_DIM)
def _led_color(self, color):
self.led.setStyleSheet("background-color: {}; border-radius: 8px; border: 1px solid #222;".format(color))
class Window(QtWidgets.QMainWindow, Ui_MainWindow): class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# on_*_changed()/on_ws_connect_changed() are invoked straight from the # on_*_changed()/on_ws_connect_changed() are invoked straight from the
# websocket client's background thread (WsClient.bg_thread), but Qt # websocket client's background thread (WsClient.bg_thread), but Qt
@@ -306,6 +440,30 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# started - the "actual" half of the forecast-vs-actual # started - the "actual" half of the forecast-vs-actual
# comparison (SudForecastPlot.show_dynamic()). # comparison (SudForecastPlot.show_dynamic()).
self.forecast_history = [] self.forecast_history = []
# Per-step boundary times (absolute schedule index -> simulated
# second), straight from the latest 'Forecast' message's
# 'StepStarts' (see tasks/sud.py's SudTask.forecast_step_starts) -
# drives the Progress tab's remaining-time labels (see
# _update_step_plates()). sud_forecast_final_t is the forecast's
# own last t, used as the implicit "end" of the last step once
# sud_forecast_finished says the simulation actually reached it.
self.sud_forecast_step_starts = {}
self.sud_forecast_finished = False
self.sud_forecast_final_t = None
# One StepPlate per self.sud_schedule entry, same order/index - see
# _rebuild_step_plates()/_update_step_plates().
self.step_plates = []
# Index -> the live actual temperature last seen while that step
# was active, captured the moment a later 'Step' message reports
# the schedule has moved past it (see on_sud_changed()) - lets a
# finished step's plate keep showing where it actually ended up
# rather than going blank.
self.step_actual_frozen = {}
# Live stirrer speed (rpm), tracked unconditionally (unlike
# Slider_speed_soll, which only syncs once - see
# on_stirrer_changed()) so the Progress tab's active-step plate can
# show it.
self.stirrer_speed_ist = 0.0
self.msg_pot = self.msg_dispatch.msgio_get('Pot') self.msg_pot = self.msg_dispatch.msgio_get('Pot')
self.msg_sensor = self.msg_dispatch.msgio_get('Sensor') self.msg_sensor = self.msg_dispatch.msgio_get('Sensor')
self.msg_heater = self.msg_dispatch.msgio_get('Heater') self.msg_heater = self.msg_dispatch.msgio_get('Heater')
@@ -422,6 +580,37 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
forecast_layout.setContentsMargins(0, 0, 0, 0) forecast_layout.setContentsMargins(0, 0, 0, 0)
forecast_layout.addWidget(self.forecast_plot) forecast_layout.addWidget(self.forecast_plot)
# Progress tab - one StepPlate per schedule step, stacked inside a
# scroll area (a long mash schedule can easily run past the visible
# height). Built entirely in code rather than via brewpi.ui/
# main_window.py, since its content (the plates themselves) is
# already fully dynamic - there's nothing for Designer to usefully
# own here, unlike widget_plot/widget_plot_forecast above, which at
# least have a fixed placeholder to hand a single child widget to.
progress_tab = QtWidgets.QWidget()
progress_layout = QtWidgets.QVBoxLayout(progress_tab)
progress_layout.setContentsMargins(0, 0, 0, 0)
progress_scroll = QtWidgets.QScrollArea()
progress_scroll.setWidgetResizable(True)
progress_container = QtWidgets.QWidget()
self.progress_container_layout = QtWidgets.QVBoxLayout(progress_container)
self.progress_container_layout.setSpacing(6)
# Kept as the layout's last item by _rebuild_step_plates() (which
# only ever inserts plates before it) - without it, fewer plates
# than the scroll area's height would stretch to fill the gap
# instead of stacking at the top.
self.progress_container_layout.addStretch(1)
progress_scroll.setWidget(progress_container)
progress_layout.addWidget(progress_scroll)
self.horizontalTabWidget.addTab(progress_tab, "Progress")
# Single shared timer for every active plate's flashing LED -
# mirrors status_label_cooling/cooling_blink_timer's pattern above.
self.progress_blink_on = True
self.progress_blink_timer = QtCore.QTimer(self)
self.progress_blink_timer.timeout.connect(self.on_progress_blink)
self.progress_blink_timer.start(500)
def _dispatch_recv(self, handler, msg): def _dispatch_recv(self, handler, msg):
"""recv_signal's slot - runs on the GUI thread regardless of which """recv_signal's slot - runs on the GUI thread regardless of which
thread emitted the signal, so handler(msg) can touch Qt widgets thread emitted the signal, so handler(msg) can touch Qt widgets
@@ -605,6 +794,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
def on_cooling_blink(self): def on_cooling_blink(self):
self.status_label_cooling.setVisible(not self.status_label_cooling.isVisible()) self.status_label_cooling.setVisible(not self.status_label_cooling.isVisible())
def on_progress_blink(self):
self.progress_blink_on = not self.progress_blink_on
for plate in self.step_plates:
plate.apply_blink(self.progress_blink_on)
def on_slider_temp_soll_changed(self, value): def on_slider_temp_soll_changed(self, value):
print("on_slider_temp_soll_changed {}".format(value)) print("on_slider_temp_soll_changed {}".format(value))
self.msg_tempctrl.send({'Soll': {'Temp': value}}) self.msg_tempctrl.send({'Soll': {'Temp': value}})
@@ -734,6 +928,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
if 'Temp' in submsg: if 'Temp' in submsg:
self.plot_temp_ist = submsg['Temp'] self.plot_temp_ist = submsg['Temp']
self.lcdNumber_temp_ist.display(str(submsg['Temp'])) self.lcdNumber_temp_ist.display(str(submsg['Temp']))
self._update_step_plates()
if 'Rate' in submsg: if 'Rate' in submsg:
self.plot_rate_ist = submsg['Rate'] self.plot_rate_ist = submsg['Rate']
self.lcdNumber_heatrate_ist.display(str(submsg['Rate'])) self.lcdNumber_heatrate_ist.display(str(submsg['Rate']))
@@ -781,6 +976,8 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.checkbox_stirrer_activate.blockSignals(False) self.checkbox_stirrer_activate.blockSignals(False)
self.checkbox_stirrer_activate_initial_update = False self.checkbox_stirrer_activate_initial_update = False
elif "Speed" in key: elif "Speed" in key:
self.stirrer_speed_ist = msg['Speed']
self._update_step_plates()
if self.slider_speed_initial_update: if self.slider_speed_initial_update:
self.Slider_speed_soll.blockSignals(True) self.Slider_speed_soll.blockSignals(True)
self.Slider_speed_soll.setValue(int(round(msg['Speed']))) self.Slider_speed_soll.setValue(int(round(msg['Speed'])))
@@ -844,14 +1041,27 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message: if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message:
self.show_user_message(self.sud_user_message) self.show_user_message(self.sud_user_message)
self._update_step_plates()
elif "UserMessage" in key: elif "UserMessage" in key:
self.sud_user_message = msg['UserMessage'] self.sud_user_message = msg['UserMessage']
elif "Step" in key: elif "Step" in key:
step = msg['Step'] step = msg['Step']
self.sud_step_index = step.get('Index') prev_index = self.sud_step_index
new_index = step.get('Index')
if prev_index is not None and new_index is not None and new_index > prev_index:
# The step(s) just left (usually exactly one, but a hold
# whose duration is already 0 can advance straight
# through without this client ever seeing it as active -
# see tasks/sud.py's _reanchor_forecast() comment on the
# same race) freeze their last live actual temperature
# for the Progress tab - see StepPlate.set_actual_temp().
for passed_index in range(prev_index, new_index):
self.step_actual_frozen[passed_index] = self.plot_temp_ist
self.sud_step_index = new_index
self.sud_step_descr = step.get('Descr') self.sud_step_descr = step.get('Descr')
self.sud_step_type = step.get('Type') self.sud_step_type = step.get('Type')
self.update_status_step_label() self.update_status_step_label()
self._update_step_plates()
elif "HoldRemaining" in key: elif "HoldRemaining" in key:
self.sud_hold_remaining = msg['HoldRemaining'] self.sud_hold_remaining = msg['HoldRemaining']
self.update_status_step_label() self.update_status_step_label()
@@ -878,6 +1088,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# at x=0. # at x=0.
if not self.forecast_history and self.plot_temp_ist and msg['Elapsed'] < FRESH_RUN_ELAPSED_THRESHOLD_S: if not self.forecast_history and self.plot_temp_ist and msg['Elapsed'] < FRESH_RUN_ELAPSED_THRESHOLD_S:
self.forecast_history.append((msg['Elapsed'] / 60.0, self.plot_temp_ist)) self.forecast_history.append((msg['Elapsed'] / 60.0, self.plot_temp_ist))
self._update_step_plates()
elif "Forecast" in key: elif "Forecast" in key:
self.on_sud_forecast_received(msg['Forecast']) self.on_sud_forecast_received(msg['Forecast'])
elif "Error" in key: elif "Error" in key:
@@ -902,6 +1113,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
t_min = [seconds / 60.0 for seconds in forecast['T']] t_min = [seconds / 60.0 for seconds in forecast['T']]
self.forecast_plot.show_forecast(t_min, forecast['Theta'], self.sud_name, forecast['Finished']) self.forecast_plot.show_forecast(t_min, forecast['Theta'], self.sud_name, forecast['Finished'])
self.sud_forecast_step_starts = dict(forecast['StepStarts'])
self.sud_forecast_finished = forecast['Finished']
self.sud_forecast_final_t = forecast['T'][-1] if forecast['T'] else None
self._update_step_plates()
def update_status_step_label(self): def update_status_step_label(self):
if self.sud_step_descr: if self.sud_step_descr:
text = "Step {}: {}".format(self.sud_step_index, self.sud_step_descr) text = "Step {}: {}".format(self.sud_step_index, self.sud_step_descr)
@@ -958,6 +1174,13 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_step_type = None self.sud_step_type = None
self.sud_hold_remaining = 0.0 self.sud_hold_remaining = 0.0
self.sud_empty = len(schedule) == 0 self.sud_empty = len(schedule) == 0
# Belongs to whatever schedule was loaded before - on_sud_forecast_
# received() re-fills these once a forecast for *this* one arrives.
self.sud_forecast_step_starts = {}
self.sud_forecast_finished = False
self.sud_forecast_final_t = None
self.step_actual_frozen = {}
self._rebuild_step_plates()
self.update_sud_actions() self.update_sud_actions()
self.update_status_step_label() self.update_status_step_label()
if self.sud_empty: if self.sud_empty:
@@ -969,6 +1192,71 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# this over to the dynamic view instead. # this over to the dynamic view instead.
self.forecast_plot.show_computing(name) self.forecast_plot.show_computing(name)
def _rebuild_step_plates(self):
"""(Re)builds the Progress tab's stack of StepPlates from
self.sud_schedule - called whenever a (possibly different) Sud is
loaded (see update_sud_forecast()). Keeps the trailing stretch
progress_container_layout was seeded with at construction (see
Window.__init__) as its last item, so plates always stack at the
top regardless of how few there are."""
while self.progress_container_layout.count() > 1:
item = self.progress_container_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
self.step_plates = []
resolved_temp = None
for step in self.sud_schedule:
if step.get('temperature') is not None:
resolved_temp = step['temperature']
plate = StepPlate(len(self.step_plates), step, resolved_temp)
self.step_plates.append(plate)
self.progress_container_layout.insertWidget(self.progress_container_layout.count() - 1, plate)
self._update_step_plates()
def _update_step_plates(self):
"""Refreshes every StepPlate's LED/remaining-time/live fields from
current state - called on every relevant push from the server
(Step/State/Elapsed/Forecast on the Sud channel, Ist on TempCtrl,
Speed on Stirrer). Cheap enough to just redo in full each time: a
mash schedule is at most a handful of steps."""
if not self.step_plates:
return
current_elapsed = self.sud_elapsed_seconds if self.sud_elapsed_seconds is not None else 0.0
starts = self.sud_forecast_step_starts
n = len(self.step_plates)
for i, plate in enumerate(self.step_plates):
start_t = starts.get(i)
end_t = starts.get(i + 1)
if end_t is None and i == n - 1 and self.sud_forecast_finished:
end_t = self.sud_forecast_final_t
if start_t is not None and end_t is not None:
total = max(0.0, end_t - start_t)
plate.set_remaining(max(0.0, min(total, end_t - current_elapsed)))
else:
plate.set_remaining(None)
if self.sud_step_index is None or i > self.sud_step_index:
plate.set_led('off')
elif i < self.sud_step_index:
plate.set_led('done')
else:
# WAIT_USER/PAUSED don't change sud_step_type (see
# components/sud.py's Sud._finish_step()/pause()) - it
# stays whatever phase was actually last active, which is
# exactly the color this step's LED should keep flashing.
plate.set_led(self.sud_step_type if self.sud_step_type in ('ramp', 'hold') else 'hold')
if self.sud_step_index is not None and i == self.sud_step_index:
plate.set_actual_temp(self.plot_temp_ist)
plate.set_live_stirrer(self.stirrer_speed_ist)
elif i in self.step_actual_frozen:
plate.set_actual_temp(self.step_actual_frozen[i])
plate.set_live_stirrer(None)
else:
plate.set_actual_temp(None)
plate.set_live_stirrer(None)
def closeEvent(self, event): def closeEvent(self, event):
print("Exitting gracefully!") print("Exitting gracefully!")
self.user_config.set('ambient_temperature', self.doubleSpinBox_ambient.value()) self.user_config.set('ambient_temperature', self.doubleSpinBox_ambient.value())
+20 -8
View File
@@ -66,12 +66,17 @@ class SudForecastEstimator:
self.theta_amb = theta_amb self.theta_amb = theta_amb
def estimate(self, doc, start_theta=None): def estimate(self, doc, start_theta=None):
"""Returns (t, theta, final_state): t/theta are parallel lists of """Returns (t, theta, final_state, step_starts): t/theta are
elapsed simulated seconds and temperature, covering doc['steps'] parallel lists of elapsed simulated seconds and temperature,
from the start all the way to the end (final_state is covering doc['steps'] from the start all the way to the end
SudState.DONE), or, in the pathological case of a step whose (final_state is SudState.DONE), or, in the pathological case of
target can never actually be reached, wherever MAX_TICKS cut the a step whose target can never actually be reached, wherever
simulation off. 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 A step requiring user confirmation doesn't stop the simulation
either - a human's response time genuinely can't be forecast, either - a human's response time genuinely can't be forecast,
@@ -90,7 +95,7 @@ class SudForecastEstimator:
sud = Sud() sud = Sud()
if not sud.load(doc) or not sud.schedule: 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 # Plant params (M/C/L/Td) are deliberately *not* seeded here from
# any default - sud.start() below synchronously fires the first # any default - sud.start() below synchronously fires the first
@@ -142,9 +147,16 @@ class SudForecastEstimator:
pulse_counter = 0 pulse_counter = 0
return power_low if (power == 0 or pulse_counter >= on_count) else power_high return power_low if (power == 0 or pulse_counter >= on_count) else power_high
step_starts = {}
def on_step_changed(step): def on_step_changed(step):
if step is None: if step is None:
return 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)) params = sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0))
pot.set_plant_params(params) pot.set_plant_params(params)
if hasattr(tc, 'set_model_plant_params'): if hasattr(tc, 'set_model_plant_params'):
@@ -181,4 +193,4 @@ class SudForecastEstimator:
theta.append(pot.get_temperature()) theta.append(pot.get_temperature())
ticks += 1 ticks += 1
return t, theta, sud.state return t, theta, sud.state, step_starts
+29 -3
View File
@@ -73,6 +73,13 @@ class SudTask(ATask):
# of a step whose target can never be reached (see # of a step whose target can never be reached (see
# components/sud_forecast.py's MAX_TICKS). # components/sud_forecast.py's MAX_TICKS).
self.forecast_finished = True 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() - # Bumped by every call to send_forecast()/_reanchor_forecast() -
# see either's own comment for why: it lets a call that's still # see either's own comment for why: it lets a call that's still
# awaiting its worker-thread simulation tell, once it resumes, # awaiting its worker-thread simulation tell, once it resumes,
@@ -224,13 +231,14 @@ class SudTask(ATask):
# whole window. # whole window.
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
start_theta = self.tc.get_theta_ist_set() 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) None, self.forecast_estimator.estimate, doc, start_theta)
if generation != self._forecast_generation: if generation != self._forecast_generation:
return return
self.forecast_t = t self.forecast_t = t
self.forecast_theta = theta self.forecast_theta = theta
self.forecast_finished = (final_state == SudState.DONE) self.forecast_finished = (final_state == SudState.DONE)
self.forecast_step_starts = step_starts
await self._send_forecast() await self._send_forecast()
async def _send_forecast(self): async def _send_forecast(self):
@@ -239,6 +247,12 @@ class SudTask(ATask):
'T': t, 'T': t,
'Theta': theta, 'Theta': theta,
'Finished': self.forecast_finished, '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): async def _reanchor_forecast(self):
@@ -276,15 +290,20 @@ class SudTask(ATask):
cut = bisect.bisect_right(self.forecast_t, real_elapsed) cut = bisect.bisect_right(self.forecast_t, real_elapsed)
forecast_t = self.forecast_t[:cut] forecast_t = self.forecast_t[:cut]
forecast_theta = self.forecast_theta[: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 schedule = self.sud.schedule
index = self.sud.index 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 not (0 <= index < len(schedule)):
if generation != self._forecast_generation: if generation != self._forecast_generation:
return return
self.forecast_t = forecast_t self.forecast_t = forecast_t
self.forecast_theta = forecast_theta self.forecast_theta = forecast_theta
self.forecast_finished = True self.forecast_finished = True
self.forecast_step_starts = forecast_step_starts
await self._send_forecast() await self._send_forecast()
return return
doc = { doc = {
@@ -300,7 +319,7 @@ class SudTask(ATask):
} }
start_theta = self.tc.get_theta_ist() start_theta = self.tc.get_theta_ist()
loop = asyncio.get_event_loop() 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: if generation != self._forecast_generation:
return return
# Bridge any gap between the last surviving old point and right # Bridge any gap between the last surviving old point and right
@@ -313,9 +332,16 @@ class SudTask(ATask):
forecast_theta.append(start_theta) forecast_theta.append(start_theta)
forecast_t.extend(real_elapsed + seconds for seconds in t) forecast_t.extend(real_elapsed + seconds for seconds in t)
forecast_theta.extend(theta) 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_t = forecast_t
self.forecast_theta = forecast_theta self.forecast_theta = forecast_theta
self.forecast_finished = (final_state == SudState.DONE) self.forecast_finished = (final_state == SudState.DONE)
self.forecast_step_starts = forecast_step_starts
await self._send_forecast() await self._send_forecast()
async def recv(self, data): async def recv(self, data):