Files
brewpi/components/sud.py
T
jensandClaude Sonnet 4.6 2bdd3203cf Make Sud steps ramp based on the actual temperature gap, not a 'ramp' key
Previously, whether a schedule step ramped at all was decided purely by
the presence of a 'ramp' key (components/sud.py's _advance()) - a
hold-only step jumped straight into its hold countdown, assuming the
plant was already at temperature. Now every step ramps toward
'temperature' first, for as long as the controller's own gap-tracking
FSM (TempControllerBase, already distinguishing HEAT/COOL/HOLD) says
the gap actually warrants it - via the new is_holding() (on APid and
TempControllerBase), which replaces a separate, redundant
TEMP_REACHED_TOLERANCE constant duplicated across tasks/sud.py,
components/sud_forecast.py, and scripts/demos/sud/demo_sud.py.

set_theta_soll() now recomputes the FSM eagerly so is_holding() can't
read stale HOLD for a tick after a much-further-away target is pushed.

components/sud.py's _build_step() always synthesizes a 'ramp' block
from default.step.ramp (so 'rate' is always available), but leaves
'temperature' undefaulted - every real sude/*.json's
default.step.temperature is inert template filler, and defaulting it
would send hold-only steps chasing 0 degrees. SudTask/
SudForecastEstimator/the demo only push a new theta_soll/heatrate_soll
when a step actually specifies its own temperature; otherwise the
controller keeps whatever the previous step left running.

Also fixes a related crash this surfaced: SudTask.remaining_schedule()'s
synthetic mid-hold step dropped 'ramp' to signal "don't re-ramp" - now
that every step always carries a 'ramp' dict, dropping it left 'rate'
missing the moment the synthetic step was re-resolved by
SudForecastEstimator. Drops 'temperature' instead, which is what
actually signals "no new target" under the new model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 09:35:53 +02:00

226 lines
7.6 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', 'grain_mass', 'water_mass'):
step[key] = raw_step.get(key, defaults.get(key))
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_mass': 0,
'pot_material': None,
'steps': [],
}
class Sud(AttributeChange):
def __init__(self):
"""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."""
AttributeChange.__init__(self)
self._data = EMPTY_SUD
(self.name, self.description, self.schedule,
self.pot_mass, self.pot_material) = self._parse_data(self._data)
self._paused_from = None
self._reset_run_state()
@staticmethod
def _parse_data(data):
"""Parses a sud.json document into the (name, description, schedule,
pot_mass, pot_material) 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."""
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_mass = data.get('pot_mass', 0)
pot_material = data.get('pot_material')
return name, description, schedule, pot_mass, pot_material
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
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)
except (KeyError, TypeError):
return False
self.name, self.description, self.schedule, self.pot_mass, self.pot_material = parsed
self._data = data
self._reset_run_state()
return True
def derive_plant_params(self, grain_mass, water_mass):
"""Lumped (mass, specific heat) lifted from pot_mass/pot_material and
the current step's grain_mass/water_mass, for use as Pot's "M"/"C"
params. grain_mass/water_mass vary per step (e.g. malt going in,
water boiling off), so this is recomputed on every step change
rather than once at startup."""
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,
}
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
return
if self.state not in (SudState.IDLE, SudState.DONE):
return
self.index = -1
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
def stop(self):
"""Aborts the run, discarding progress back to IDLE."""
if self.state not in (SudState.IDLE, SudState.DONE):
self._reset_run_state()
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):
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
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
def _finish_step(self):
if self.step.get('user_wait_for_continue', False):
self.state = SudState.WAIT_USER
else:
self._advance()