Track and display energy consumption per Sud step

Each StepPlate on the Progress tab now shows a step's energy use in
Wh - live and growing while it's the active step, frozen at its final
total once finished, blank before it's ever been reached.

Integrated server-side from the heater's own live effective power
(pot.get_power(), already fed from heater.power_eff - see server/
brewpi.py's wiring), since actual energy used can't be predicted from
the forecast like the timing/temperature fields are, only measured as
it happens. Banked into energy_by_step on every genuine step
transition (on_step_changed(), keyed off the same index-change check
used for the ramp->hold phase switch), including the final transition
to DONE; reset on every fresh Load.

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 19:09:10 +02:00
co-authored by Claude Sonnet 4.6
parent 8896f216d1
commit 6e19162c16
2 changed files with 105 additions and 2 deletions
+36 -2
View File
@@ -301,7 +301,9 @@ class StepPlate(QtWidgets.QFrame):
self.label_masses = QtWidgets.QLabel() self.label_masses = QtWidgets.QLabel()
self.label_rate = QtWidgets.QLabel() self.label_rate = QtWidgets.QLabel()
self.label_stirrer = 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;") label.setStyleSheet("color: #555;")
# A grid, not a single row - the Progress tab shares its pane's # 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_remaining, 0, 2)
layout.addWidget(self.label_target, 1, 0, 1, 2) layout.addWidget(self.label_target, 1, 0, 1, 2)
layout.addWidget(self.label_actual, 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_rate, 3, 0, 1, 2)
layout.addWidget(self.label_stirrer, 3, 2) layout.addWidget(self.label_stirrer, 3, 2)
self.set_step(step, resolved_temp) self.set_step(step, resolved_temp)
self.set_actual_temp(None) self.set_actual_temp(None)
self.set_live_stirrer(None) self.set_live_stirrer(None)
self.set_energy(None)
def set_step(self, step, resolved_temp): def set_step(self, step, resolved_temp):
"""Applies this step's static, schedule-config fields - called once, """Applies this step's static, schedule-config fields - called once,
@@ -345,6 +349,13 @@ class StepPlate(QtWidgets.QFrame):
def set_actual_temp(self, temp): def set_actual_temp(self, temp):
self.label_actual.setText("Actual {}".format("{:.1f} °C".format(temp) if temp is not None else "")) 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): def set_live_stirrer(self, speed):
"""speed is the live rpm while this step is the active one, or None """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 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 # on_stirrer_changed()) so the Progress tab's active-step plate can
# show it. # show it.
self.stirrer_speed_ist = 0.0 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_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')
@@ -1097,6 +1118,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self._update_step_plates() 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 "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: elif "Error" in key:
# The server clears this back to None right after sending # The server clears this back to None right after sending
# it (see tasks/sud.py's recv()) so it doesn't linger in # 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_finished = False
self.sud_forecast_final_t = None self.sud_forecast_final_t = None
self.step_actual_frozen = {} self.step_actual_frozen = {}
self.sud_energy_by_step = {}
self.sud_energy_current = 0.0
self._rebuild_step_plates() self._rebuild_step_plates()
self.update_sud_actions() self.update_sud_actions()
self.update_status_step_label() self.update_status_step_label()
@@ -1280,6 +1307,13 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
plate.set_actual_temp(None) plate.set_actual_temp(None)
plate.set_live_stirrer(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): 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())
+69
View File
@@ -87,6 +87,26 @@ class SudTask(ATask):
# fresher result - if so, it discards its own rather than # fresher result - if so, it discards its own rather than
# corrupting forecast_t/forecast_theta with stale data. # corrupting forecast_t/forecast_theta with stale data.
self._forecast_generation = 0 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) msg_handler.set_recv_handler(self.recv)
def apply_plant_params(self, grain_mass, water_mass): def apply_plant_params(self, grain_mass, water_mass):
@@ -121,6 +141,19 @@ class SudTask(ATask):
self.stirrer.set_speed(speed) self.stirrer.set_speed(speed)
def on_step_changed(self, step): 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 ramp = step.get('ramp') if step else None
hold = step.get('hold') if step else None hold = step.get('hold') if step else None
# Every step ramps to 'temperature' first, then optionally holds - # Every step ramps to 'temperature' first, then optionally holds -
@@ -205,6 +238,23 @@ class SudTask(ATask):
def on_elapsed_changed(self, value): def on_elapsed_changed(self, value):
asyncio.create_task(self.send({'Elapsed': 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): async def send_forecast(self, doc):
"""Computes and sends the full forecast for doc, start to finish - """Computes and sends the full forecast for doc, start to finish -
including through every step requiring user confirmation, which including through every step requiring user confirmation, which
@@ -415,6 +465,12 @@ class SudTask(ATask):
await self._send_forecast() await self._send_forecast()
elif 'Load' in pair[0]: elif 'Load' in pair[0]:
if self.sud.load(pair[1]): 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({'Name': self.sud.name, 'Description': self.sud.description})
await self.send({'Json': pair[1]}) await self.send({'Json': pair[1]})
# on_step_changed() only re-applies plant params once a # on_step_changed() only re-applies plant params once a
@@ -462,5 +518,18 @@ class SudTask(ATask):
while True: while True:
if self.sud.state == SudState.RAMPING and self.tc.is_holding(): if self.sud.state == SudState.RAMPING and self.tc.is_holding():
self.sud.temp_reached() 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) self.sud.tick(self.dt)
await asyncio.sleep(self.interval) await asyncio.sleep(self.interval)