diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index acfe3e8..b4aa2e8 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -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()) diff --git a/components/sud_forecast.py b/components/sud_forecast.py index 303c3ae..c988f1a 100644 --- a/components/sud_forecast.py +++ b/components/sud_forecast.py @@ -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 diff --git a/tasks/sud.py b/tasks/sud.py index 17bbc45..9f7ee73 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -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):