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:
+36
-2
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user