Make grain_mass/water_mass per-step and re-derive plant params on change

grain_mass and water_mass now live on each step (defaulted from
default.step) instead of being fixed for the whole brew, since both
change over a mash (malt going in, water boiling off).
Sud.derive_plant_params() takes them as arguments so it can be
recomputed per step; the demo re-applies the resulting M/C to both the
real plant and the controller's Smith-predictor model on every step
change via the new Pot.set_thermal_params()/
TempController.set_model_params().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD
This commit is contained in:
2026-06-20 07:45:42 +02:00
co-authored by Claude Sonnet 4.6
parent ca0a014ffb
commit 241a796ffa
6 changed files with 41 additions and 16 deletions
+11 -4
View File
@@ -129,10 +129,7 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment.
## Mash schedules ## Mash schedules
Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`, Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`,
`pot_material`, `grain_mass`, `water_mass` (used by `Sud.derive_plant_params()` `pot_material` (fixed for the whole brew), and a `steps` list, each either:
to compute a lumped thermal mass/specific-heat pair for `Pot`'s `M`/`C`, e.g.
in `scripts/demos/sud/demo_sud.py`, instead of hand-tuning them), and a
`steps` list, each either:
- a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate` - a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate`
(°C/min), or (°C/min), or
@@ -147,6 +144,16 @@ Both `ramp` and `hold` carry a `stirrer` block (`speed`, `interval_time`,
the user with) to pause for confirmation once the step completes, e.g. to the user with) to pause for confirmation once the step completes, e.g. to
add malt or check gravity, instead of advancing immediately. add malt or check gravity, instead of advancing immediately.
Each step also has its own `grain_mass`/`water_mass` (defaulted like
everything else from `default.step`), since both change over the course of
a brew — e.g. malt going in partway through, or water boiling off. Pass a
step's `grain_mass`/`water_mass` to `Sud.derive_plant_params()` (together
with the brew-wide `pot_mass`/`pot_material`) to get that step's lumped
`Pot` `M`/`C`; `scripts/demos/sud/demo_sud.py` recomputes and re-applies
these (via `Pot.set_thermal_params()`/`TempController.set_model_params()`,
on both the real plant and the controller's Smith-predictor model) every
time the current step changes, instead of deriving them once at startup.
The top-level `default.step` object gives every field above (including the The top-level `default.step` object gives every field above (including the
nested `ramp`/`hold`/`stirrer` blocks) a default value; a step in `steps` nested `ramp`/`hold`/`stirrer` blocks) a default value; a step in `steps`
only needs to specify the fields it overrides — anything it omits is filled only needs to specify the fields it overrides — anything it omits is filled
+4
View File
@@ -24,6 +24,10 @@ class TempController(TempControllerBase):
self.model.set_power(power) self.model.set_power(power)
self.model_delay.set_power(power) self.model_delay.set_power(power)
def set_model_params(self, M, C):
self.model.set_thermal_params(M, C)
self.model_delay.set_thermal_params(M, C)
def on_state_entered(self, state): def on_state_entered(self, state):
if state == States.HEAT: if state == States.HEAT:
self.model.initial(self.theta_ist) self.model.initial(self.theta_ist)
+4
View File
@@ -56,6 +56,10 @@ class Pot(APlant):
def set_ambient_temperature(self, theta_amb): def set_ambient_temperature(self, theta_amb):
self.theta_amb = theta_amb self.theta_amb = theta_amb
def set_thermal_params(self, M, C):
self.M = M
self.C = C
def set_power(self, power): def set_power(self, power):
self.p_in = power self.p_in = power
+10 -9
View File
@@ -37,7 +37,7 @@ def _build_step(number, defaults, raw_step):
ramp or a hold depending on which of those keys it specifies; the other ramp or a hold depending on which of those keys it specifies; the other
is left out rather than synthesized from defaults.""" is left out rather than synthesized from defaults."""
step = {'number': number} step = {'number': number}
for key in ('descr', 'user_message', 'user_wait_for_continue'): for key in ('descr', 'user_message', 'user_wait_for_continue', 'grain_mass', 'water_mass'):
step[key] = raw_step.get(key, defaults.get(key)) step[key] = raw_step.get(key, defaults.get(key))
for key in ('ramp', 'hold'): for key in ('ramp', 'hold'):
if key in raw_step: if key in raw_step:
@@ -59,8 +59,6 @@ class Sud(AttributeChange):
self.pot_mass = data.get('pot_mass', 0) self.pot_mass = data.get('pot_mass', 0)
self.pot_material = data.get('pot_material') self.pot_material = data.get('pot_material')
self.grain_mass = data.get('grain_mass', 0)
self.water_mass = data.get('water_mass', 0)
self.index = -1 self.index = -1
self.hold_remaining = 0.0 self.hold_remaining = 0.0
@@ -68,13 +66,16 @@ class Sud(AttributeChange):
self.step = None self.step = None
self.user_message = None self.user_message = None
def derive_plant_params(self): def derive_plant_params(self, grain_mass, water_mass):
"""Lumped (mass, specific heat) lifted from pot_mass/pot_material/ """Lumped (mass, specific heat) lifted from pot_mass/pot_material and
grain_mass/water_mass, for use as Pot's "M"/"C" params.""" 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) c_pot = SPECIFIC_HEAT_BY_MATERIAL.get(self.pot_material, SPECIFIC_HEAT_WATER)
mass = self.water_mass + self.grain_mass + self.pot_mass mass = water_mass + grain_mass + self.pot_mass
capacitance = (self.water_mass * SPECIFIC_HEAT_WATER capacitance = (water_mass * SPECIFIC_HEAT_WATER
+ self.grain_mass * SPECIFIC_HEAT_GRAIN + grain_mass * SPECIFIC_HEAT_GRAIN
+ self.pot_mass * c_pot) + self.pot_mass * c_pot)
return { return {
+8 -1
View File
@@ -35,8 +35,9 @@ if __name__ == '__main__':
sud = Sud("sude/sud_0010.json") sud = Sud("sude/sud_0010.json")
first_step = sud.schedule[0]
plant_params = { plant_params = {
**sud.derive_plant_params(), **sud.derive_plant_params(first_step['grain_mass'], first_step['water_mass']),
"L" : 0.2, "L" : 0.2,
"Td" : 60 "Td" : 60
} }
@@ -65,9 +66,15 @@ if __name__ == '__main__':
stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0) stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
stirrer.set_speed(speed) stirrer.set_speed(speed)
def apply_plant_params(step):
params = sud.derive_plant_params(step['grain_mass'], step['water_mass'])
plant.set_thermal_params(params['M'], params['C'])
ctrl.set_model_params(params['M'], params['C'])
def on_step_changed(step): def on_step_changed(step):
if step is not None: if step is not None:
print(f"Step {step['number']}: {step['descr']}") print(f"Step {step['number']}: {step['descr']}")
apply_plant_params(step)
if 'ramp' in step: if 'ramp' in step:
ctrl.set_theta_soll(step['ramp']['temp']) ctrl.set_theta_soll(step['ramp']['temp'])
ctrl.set_heatrate_soll(step['ramp']['rate']) ctrl.set_heatrate_soll(step['ramp']['rate'])
+4 -2
View File
@@ -3,14 +3,14 @@
"Description" : "Rotfraenkisch, Dunkles Lager", "Description" : "Rotfraenkisch, Dunkles Lager",
"pot_mass" : 5.960, "pot_mass" : 5.960,
"pot_material" : "Edelstahl 18/10", "pot_material" : "Edelstahl 18/10",
"grain_mass" : 5.21,
"water_mass" : 22,
"default": "default":
{ {
"step" : { "step" : {
"descr": "Put description here", "descr": "Put description here",
"user_message": "Put user message here", "user_message": "Put user message here",
"user_wait_for_continue": false, "user_wait_for_continue": false,
"grain_mass" : 5.21,
"water_mass" : 22,
"ramp": { "ramp": {
"rate": 1.0, "rate": 1.0,
"temp": 0, "temp": 0,
@@ -33,6 +33,7 @@
"steps": "steps":
[ [
{ {
"grain_mass" : 0,
"descr": "Heizen für Einmaischen", "descr": "Heizen für Einmaischen",
"ramp": { "ramp": {
"rate": 1.8, "rate": 1.8,
@@ -101,6 +102,7 @@
} }
}, },
{ {
"water_mass" : 21,
"descr": "Halten Abmaischen", "descr": "Halten Abmaischen",
"user_message": "Quittieren zum Abmaischen", "user_message": "Quittieren zum Abmaischen",
"user_wait_for_continue": true, "user_wait_for_continue": true,