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
+69
View File
@@ -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)