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:
+289
-1
@@ -35,6 +35,17 @@ SUD_PAUSED_STATE = "SudState.PAUSED"
|
||||
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):
|
||||
ax.set_facecolor('white')
|
||||
ax.spines['top'].set_visible(False)
|
||||
@@ -246,6 +257,129 @@ class SudForecastPlot(FigureCanvasQTAgg):
|
||||
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):
|
||||
# on_*_changed()/on_ws_connect_changed() are invoked straight from the
|
||||
# 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
|
||||
# comparison (SudForecastPlot.show_dynamic()).
|
||||
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_sensor = self.msg_dispatch.msgio_get('Sensor')
|
||||
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.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):
|
||||
"""recv_signal's slot - runs on the GUI thread regardless of which
|
||||
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):
|
||||
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):
|
||||
print("on_slider_temp_soll_changed {}".format(value))
|
||||
self.msg_tempctrl.send({'Soll': {'Temp': value}})
|
||||
@@ -734,6 +928,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
if 'Temp' in submsg:
|
||||
self.plot_temp_ist = submsg['Temp']
|
||||
self.lcdNumber_temp_ist.display(str(submsg['Temp']))
|
||||
self._update_step_plates()
|
||||
if 'Rate' in submsg:
|
||||
self.plot_rate_ist = 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_initial_update = False
|
||||
elif "Speed" in key:
|
||||
self.stirrer_speed_ist = msg['Speed']
|
||||
self._update_step_plates()
|
||||
if self.slider_speed_initial_update:
|
||||
self.Slider_speed_soll.blockSignals(True)
|
||||
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:
|
||||
self.show_user_message(self.sud_user_message)
|
||||
self._update_step_plates()
|
||||
elif "UserMessage" in key:
|
||||
self.sud_user_message = msg['UserMessage']
|
||||
elif "Step" in key:
|
||||
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_type = step.get('Type')
|
||||
self.update_status_step_label()
|
||||
self._update_step_plates()
|
||||
elif "HoldRemaining" in key:
|
||||
self.sud_hold_remaining = msg['HoldRemaining']
|
||||
self.update_status_step_label()
|
||||
@@ -878,6 +1088,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
# at x=0.
|
||||
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._update_step_plates()
|
||||
elif "Forecast" in key:
|
||||
self.on_sud_forecast_received(msg['Forecast'])
|
||||
elif "Error" in key:
|
||||
@@ -902,6 +1113,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
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.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):
|
||||
if 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_hold_remaining = 0.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_status_step_label()
|
||||
if self.sud_empty:
|
||||
@@ -969,6 +1192,71 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
# this over to the dynamic view instead.
|
||||
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):
|
||||
print("Exitting gracefully!")
|
||||
self.user_config.set('ambient_temperature', self.doubleSpinBox_ambient.value())
|
||||
|
||||
@@ -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
|
||||
|
||||
+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