diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index bbf4e0e..1cc7c5f 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -301,7 +301,9 @@ class StepPlate(QtWidgets.QFrame): 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): + self.label_energy = QtWidgets.QLabel() + for label in (self.label_target, self.label_actual, self.label_masses, self.label_rate, + self.label_stirrer, self.label_energy): label.setStyleSheet("color: #555;") # A grid, not a single row - the Progress tab shares its pane's @@ -315,13 +317,15 @@ class StepPlate(QtWidgets.QFrame): 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_masses, 2, 0, 1, 2) + layout.addWidget(self.label_energy, 2, 2) 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) + self.set_energy(None) def set_step(self, step, resolved_temp): """Applies this step's static, schedule-config fields - called once, @@ -345,6 +349,13 @@ class StepPlate(QtWidgets.QFrame): def set_actual_temp(self, temp): self.label_actual.setText("Actual {}".format("{:.1f} °C".format(temp) if temp is not None else "—")) + def set_energy(self, wh): + """wh is this step's energy consumption so far (Wh) - live and + still growing while this is the active step, frozen at its final + total once finished, or None before this step has ever been + reached (see Window._update_step_plates()).""" + self.label_energy.setText("Energy {}".format("{:.0f} Wh".format(wh) if wh 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 @@ -470,6 +481,16 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): # on_stirrer_changed()) so the Progress tab's active-step plate can # show it. self.stirrer_speed_ist = 0.0 + # Energy consumption (Wh), straight from the latest 'Energy' + # message (see tasks/sud.py's SudTask._on_energy_changed()) - + # sud_energy_by_step covers every *finished* step (absolute index + # -> Wh), sud_energy_current is the running total for whichever + # step is presently active. Unlike the timing/temperature fields + # above, there's no forecast equivalent to preview before a run + # starts - energy actually used can only be measured, not + # predicted - so both stay empty/zero until one actually does. + self.sud_energy_by_step = {} + self.sud_energy_current = 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') @@ -1097,6 +1118,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self._update_step_plates() elif "Forecast" in key: self.on_sud_forecast_received(msg['Forecast']) + elif "Energy" in key: + self.sud_energy_by_step = dict(msg['Energy']['StepEnergy']) + self.sud_energy_current = msg['Energy']['Current'] + self._update_step_plates() elif "Error" in key: # The server clears this back to None right after sending # it (see tasks/sud.py's recv()) so it doesn't linger in @@ -1203,6 +1228,8 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_forecast_finished = False self.sud_forecast_final_t = None self.step_actual_frozen = {} + self.sud_energy_by_step = {} + self.sud_energy_current = 0.0 self._rebuild_step_plates() self.update_sud_actions() self.update_status_step_label() @@ -1280,6 +1307,13 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): plate.set_actual_temp(None) plate.set_live_stirrer(None) + if self.sud_step_index is not None and i == self.sud_step_index: + plate.set_energy(self.sud_energy_current) + elif i in self.sud_energy_by_step: + plate.set_energy(self.sud_energy_by_step[i]) + else: + plate.set_energy(None) + def closeEvent(self, event): print("Exitting gracefully!") self.user_config.set('ambient_temperature', self.doubleSpinBox_ambient.value()) diff --git a/tasks/sud.py b/tasks/sud.py index 14b5d24..a996787 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -87,6 +87,26 @@ class SudTask(ATask): # fresher result - if so, it discards its own rather than # corrupting forecast_t/forecast_theta with stale data. self._forecast_generation = 0 + # Energy consumption per step (Wh), integrated from the heater's + # own live effective power - see pot.get_power() (set from + # heater.power_eff - server/brewpi.py wires that up) - rather + # than recomputed from the forecast like the timing fields above, + # since energy actually used can't be predicted in advance, only + # measured as it happens. energy_step_accum_j is the *current* + # step's running total, in Joules (integrated every tick in + # on_process() - converted to Wh only at the message boundary, + # see _on_energy_changed()); energy_by_step holds each *finished* + # step's final Wh total, keyed by its (absolute) schedule index - + # both reset on every Load (see recv()) since neither make sense + # carried over to a different schedule. _energy_index is simply + # which index the running accumulator currently belongs to, so + # on_step_changed() can tell a genuine new step (a different + # index) from its own ramp->hold phase switch (same index, must + # not reset the accumulator mid-step). + self.energy_step_accum_j = 0.0 + self.energy_by_step = {} + self._energy_index = None + self._energy_changed = ChangedFloat(self._on_energy_changed, prec=2) msg_handler.set_recv_handler(self.recv) def apply_plant_params(self, grain_mass, water_mass): @@ -121,6 +141,19 @@ class SudTask(ATask): self.stirrer.set_speed(speed) def on_step_changed(self, step): + # A genuinely new step (this index differs from whichever one the + # running accumulator currently belongs to - including the + # transition to DONE, step=None, self.sud.index past the last + # real one) banks the just-finished step's total and starts a + # fresh one; the ramp->hold phase switch within the *same* step + # re-fires this callback too (see below) but must not reset + # mid-step, hence comparing the index itself rather than reacting + # to every call. + if self.sud.index != self._energy_index: + if self._energy_index is not None: + self.energy_by_step[self._energy_index] = self.energy_step_accum_j / 3600.0 + self.energy_step_accum_j = 0.0 + self._energy_index = self.sud.index ramp = step.get('ramp') if step else None hold = step.get('hold') if step else None # Every step ramps to 'temperature' first, then optionally holds - @@ -205,6 +238,23 @@ class SudTask(ATask): def on_elapsed_changed(self, value): asyncio.create_task(self.send({'Elapsed': value})) + def _on_energy_changed(self, value): + """value is the currently active step's running total (Wh) - + throttled to once per self._energy_changed's rounding step (see + __init__), same pattern as on_hold_remaining_changed()/ + on_elapsed_changed() above, just driven manually from on_process() + each tick rather than via Sud's own AttributeChange (energy isn't + one of Sud's own attributes). energy_by_step (every *finished* + step's own final Wh total) rides along on every such push rather + than only on change - it's a handful of entries at most, and + piggybacking means the client never has to reconcile two + differently-timed messages to know "the rest of the totals plus + what's happening right now".""" + asyncio.create_task(self.send({'Energy': { + 'StepEnergy': sorted(self.energy_by_step.items()), + 'Current': value, + }})) + async def send_forecast(self, doc): """Computes and sends the full forecast for doc, start to finish - including through every step requiring user confirmation, which @@ -415,6 +465,12 @@ class SudTask(ATask): await self._send_forecast() elif 'Load' in pair[0]: if self.sud.load(pair[1]): + # Energy consumption belongs to a specific schedule's + # run, same as forecast_step_starts' timings - neither + # means anything carried over to a different one. + self.energy_step_accum_j = 0.0 + self.energy_by_step = {} + self._energy_index = None await self.send({'Name': self.sud.name, 'Description': self.sud.description}) await self.send({'Json': pair[1]}) # on_step_changed() only re-applies plant params once a @@ -462,5 +518,18 @@ class SudTask(ATask): while True: if self.sud.state == SudState.RAMPING and self.tc.is_holding(): self.sud.temp_reached() + # Energy actually drawn this tick - pot.get_power() reflects + # the heater's own live effective power (see server/brewpi. + # py's heater.set_on_changed("power_eff", ...pot.set_power) + # wiring), in Watts; dt is this tick's simulated seconds, so + # the product is Joules, accumulated for whichever step is + # current (see on_step_changed()). Counted through every + # non-idle state, WAIT_USER/PAUSED included - the controller + # stays enabled (see on_state_changed()) and may well still + # be actively holding, drawing real power, even though the + # schedule itself isn't progressing. + if self.sud.state not in (SudState.IDLE, SudState.DONE): + self.energy_step_accum_j += self.pot.get_power() * self.dt + self._energy_changed.set(self.energy_step_accum_j / 3600.0) self.sud.tick(self.dt) await asyncio.sleep(self.interval)