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_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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user