Files
brewpi/components/sud.py
T
jens ce5eb3bc23 log: Sud lifecycle transitions and stirrer/heater connect state
Logs load/start/pause/continue/stop/entering-step/wait-user/finished
on Sud (components/sud.py) and connected/disconnected on
StirrerTask/HeaterTask, using the self.log added earlier.

SudForecastEstimator.estimate() (components/sud_forecast.py) drives its
own throwaway Sud through an entire schedule in a tight loop (no real
waiting) to predict its duration, and re-runs that on every real step
transition too - since it's still a Sud, it logged through the exact
same "Sud" logger as the real, server-driven one, making an instant
internal forecast run indistinguishable in the log from an actual
brew. Gave it its own "SudForecastEstimator" logger, silenced to
WARNING by default, so only the real Sud's transitions show up.
2026-07-10 22:28:01 +02:00

271 lines
9.7 KiB
Python

from enum import Enum
from utils.value import AttributeChange
# Specific heat capacities [J/(kg*K)] used to derive a single lumped
# (mass, specific heat) pair for the pot's contents from pot_mass/
# grain_mass/water_mass. Approximate, typical values.
SPECIFIC_HEAT_WATER = 4190
SPECIFIC_HEAT_GRAIN = 1800
SPECIFIC_HEAT_BY_MATERIAL = {
"Edelstahl 18/10": 500,
}
class SudState(Enum):
IDLE = 0
RAMPING = 1
HOLDING = 2
WAIT_USER = 3
DONE = 4
PAUSED = 5
def _merge_defaults(default, override):
"""Deep-merges override onto default, recursing into nested dicts so
values missing from override fall back to default's at every level."""
merged = dict(default)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = _merge_defaults(merged[key], value)
else:
merged[key] = value
return merged
def _build_step(number, defaults, raw_step):
"""Fills in a schedule step with default.step's values. 'ramp' is
always synthesized from defaults, even if raw_step doesn't specify
one of its own - every step ramps toward 'temperature' first, for as
long as the controller's own gap-tracking FSM says it needs to (see
Sud._advance()/temp_reached()), so its settings (e.g. 'rate') always
have to be available. 'hold' stays opt-in: only a step that
specifies it gets a hold-duration phase once the ramp is done.
'temperature' is deliberately *not* defaulted from default.step (every
sude/*.json's default.step.temperature is just inert template filler,
never a real shared target) - a step omitting it has no new target of
its own, so it's left None, telling SudTask not to push a new
theta_soll and just keep whatever the previous step left running."""
step = {'number': number}
for key in ('descr', 'user_message', 'user_wait_for_continue'):
step[key] = raw_step.get(key, defaults.get(key))
step['pot'] = _merge_defaults(defaults.get('pot', {}), raw_step.get('pot', {}))
step['temperature'] = raw_step.get('temperature')
step['ramp'] = _merge_defaults(defaults.get('ramp', {}), raw_step.get('ramp', {}))
if 'hold' in raw_step:
step['hold'] = _merge_defaults(defaults.get('hold', {}), raw_step['hold'])
return step
EMPTY_SUD = {
'Name': '',
'Description': '',
'pot': {
'grain_mass': 0,
'water_mass': 0,
},
'steps': [],
}
class Sud(AttributeChange):
def __init__(self, pot_config=None):
"""Starts out with no schedule loaded - one of potentially several
sude/*.json files on disk is brought in later via load(), kept
purely in memory (see load()) until replaced or the server
restarts.
pot_config provides hardware baseline values (mass, material, L, Td)
from config.json's Pot section - a loaded sud.json may override any
of them per-brew, but config is the source of truth for the physical
kettle."""
AttributeChange.__init__(self)
self._pot_config = pot_config or {}
self._data = EMPTY_SUD
(self.name, self.description, self.schedule, self.pot_mass,
self.pot_material, self.L, self.Td, self.grain_mass,
self.water_mass) = self._parse_data(self._data, self._pot_config)
self._paused_from = None
self._reset_run_state()
@staticmethod
def _parse_data(data, pot_config=None):
"""Parses a sud.json document into the (name, description, schedule,
pot_mass, pot_material, L, Td, grain_mass, water_mass) tuple Sud
needs. Computed up front rather than assigned straight onto self,
so a malformed load() can't leave a half-applied schedule in
place.
pot_config (from config.json's Pot section) provides hardware baseline
values; a sud.json's own pot section overrides any of them per-brew."""
pot_config = pot_config or {}
name = data.get('Name', '')
description = data.get('Description', '')
step_defaults = data.get('default', {}).get('step', {})
schedule = [_build_step(i + 1, step_defaults, raw) for i, raw in enumerate(data['steps'])]
pot_data = data.get('pot', {})
pot_mass = pot_data.get('mass', pot_config.get('mass', 0))
pot_material = pot_data.get('material', pot_config.get('material'))
L = pot_data.get('L', pot_config.get('L', 0.2))
Td = pot_data.get('Td', pot_config.get('Td', 30))
grain_mass = pot_data.get('grain_mass', 0)
water_mass = pot_data.get('water_mass', 0)
return name, description, schedule, pot_mass, pot_material, L, Td, grain_mass, water_mass
def _reset_run_state(self):
"""Resets run-time progress back to a freshly-loaded, not-yet-started
IDLE state. Shared by __init__, load(), and stop()."""
self.index = -1
self.hold_remaining = 0.0
self.state = SudState.IDLE
self.step = None
self.user_message = None
self._paused_from = None
self.elapsed = 0.0
def save(self):
"""Returns the currently running sud.json document (EMPTY_SUD if
none has been loaded yet), for a client to edit and hand back to
load(). Purely in-memory - never read from or written to disk."""
return self._data
def load(self, data):
"""Replaces the running schedule with a new sud.json document, kept
in memory only (never written to disk - a client wanting to persist
a schedule does so explicitly itself, e.g. by writing save()'s
result to one of the sude/*.json files), resetting to IDLE as if
freshly loaded. Refused while a brew is in progress, or if data is
malformed - in either case the current schedule is left untouched."""
if self.state not in (SudState.IDLE, SudState.DONE):
return False
try:
parsed = self._parse_data(data, self._pot_config)
except (KeyError, TypeError):
return False
(self.name, self.description, self.schedule, self.pot_mass,
self.pot_material, self.L, self.Td, self.grain_mass,
self.water_mass) = parsed
self._data = data
self._reset_run_state()
self.log.info("Loaded '{}'".format(self.name))
return True
def derive_plant_params(self, grain_mass, water_mass):
"""Full Pot plant params - "M"/"C" lumped (mass, specific heat)
from pot_mass/pot_material and the current step's grain_mass/
water_mass (these vary per step, e.g. malt going in, water
boiling off, so this is recomputed on every step change rather
than once at startup), plus "L"/"Td" straight from this Sud's
own doc (constant for the whole brew - see Sud.load())."""
c_pot = SPECIFIC_HEAT_BY_MATERIAL.get(self.pot_material, SPECIFIC_HEAT_WATER)
mass = water_mass + grain_mass + self.pot_mass
capacitance = (water_mass * SPECIFIC_HEAT_WATER
+ grain_mass * SPECIFIC_HEAT_GRAIN
+ self.pot_mass * c_pot)
return {
'M': mass,
'C': capacitance / mass if mass > 0 else SPECIFIC_HEAT_WATER,
'L': self.L,
'Td': self.Td,
}
def start(self):
"""Starts a fresh run from IDLE or DONE (i.e. restarts from the
beginning once a previous run has finished), or resumes a paused
one - whichever applies. No-op otherwise."""
if self.state == SudState.PAUSED:
self.state = self._paused_from
self._paused_from = None
self.log.info("Continued")
return
if self.state not in (SudState.IDLE, SudState.DONE):
return
self.index = -1
self.elapsed = 0.0
self.log.info("Started")
self._advance()
def pause(self):
"""Freezes progress (the hold countdown and ramp-reached checks both
no-op while PAUSED) without losing where we are; start() resumes."""
if self.state in (SudState.RAMPING, SudState.HOLDING):
self._paused_from = self.state
self.state = SudState.PAUSED
self.log.info("Paused")
def stop(self):
"""Aborts the run, discarding progress back to IDLE."""
if self.state not in (SudState.IDLE, SudState.DONE):
self._reset_run_state()
self.log.info("Stopped")
def confirm(self):
if self.state == SudState.WAIT_USER:
self._advance()
def temp_reached(self):
if self.state != SudState.RAMPING:
return
if 'hold' in self.step:
# Same step, ramp phase done - move into its hold phase rather
# than finishing the step. Re-assign self.step (AttributeChange
# fires on every assignment, not just on change) to re-trigger
# the step-changed callback so e.g. the hold's stirrer settings
# get (re-)applied.
self.hold_remaining = self.step['hold'].get('duration', 0) * 60.0
self.state = SudState.HOLDING
self.step = self.step
else:
self._finish_step()
def tick(self, dt):
"""Advances the run by dt simulated seconds. 'elapsed' is the tick-
counted ground truth for how far the run has actually progressed -
clients used to reconstruct this themselves from wall-clock time
times the configured warp factor, but the warp factor is only
nominal (real asyncio/Python scheduling overhead means the actual
achieved speedup runs measurably below it, especially under heavy
message/print load), making that reconstruction drift from the
truth. Frozen while PAUSED/IDLE/DONE, same as hold_remaining."""
if self.state not in (SudState.IDLE, SudState.DONE, SudState.PAUSED):
self.elapsed += dt
if self.state == SudState.HOLDING:
self.hold_remaining -= dt
if self.hold_remaining <= 0:
self._finish_step()
def _advance(self):
self.index += 1
if self.index >= len(self.schedule):
self.state = SudState.DONE
self.user_message = None
self.step = None
self.log.info("Finished")
return
next_step = self.schedule[self.index]
# Every step ramps toward 'temperature' first - whether that takes
# any real time depends on the actual gap, which only the
# temperature controller's own FSM can tell (see temp_reached()'s
# caller, SudTask.on_process()). 'hold' only starts counting down
# once the controller reports the gap closed.
self.state = SudState.RAMPING
self.user_message = next_step.get('user_message')
self.step = next_step
self.log.info("Entering step {} ({})".format(next_step['number'], next_step.get('descr')))
def _finish_step(self):
if self.step.get('user_wait_for_continue', False):
self.state = SudState.WAIT_USER
self.log.info("Waiting for user interaction")
else:
self._advance()