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.
271 lines
9.7 KiB
Python
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()
|