From 241a796ffa7fa81b393ecd373913dc7332fd06d1 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sat, 20 Jun 2026 07:45:42 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD --- README.md | 15 +++++++++++---- components/pid/temp_controller_smith.py | 4 ++++ components/plant/pot.py | 4 ++++ components/sud.py | 19 ++++++++++--------- scripts/demos/sud/demo_sud.py | 9 ++++++++- sude/sud_0010.json | 6 ++++-- 6 files changed, 41 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4c0e659..4c3ba55 100644 --- a/README.md +++ b/README.md @@ -129,10 +129,7 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment. ## Mash schedules Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`, -`pot_material`, `grain_mass`, `water_mass` (used by `Sud.derive_plant_params()` -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: +`pot_material` (fixed for the whole brew), and a `steps` list, each either: - a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate` (°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 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 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 diff --git a/components/pid/temp_controller_smith.py b/components/pid/temp_controller_smith.py index f4b7cc9..066fdfb 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -24,6 +24,10 @@ class TempController(TempControllerBase): self.model.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): if state == States.HEAT: self.model.initial(self.theta_ist) diff --git a/components/plant/pot.py b/components/plant/pot.py index 39560a3..f0ea0d8 100644 --- a/components/plant/pot.py +++ b/components/plant/pot.py @@ -56,6 +56,10 @@ class Pot(APlant): def set_ambient_temperature(self, 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): self.p_in = power diff --git a/components/sud.py b/components/sud.py index 03991d2..5b657f6 100644 --- a/components/sud.py +++ b/components/sud.py @@ -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 is left out rather than synthesized from defaults.""" 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)) for key in ('ramp', 'hold'): if key in raw_step: @@ -59,8 +59,6 @@ class Sud(AttributeChange): self.pot_mass = data.get('pot_mass', 0) 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.hold_remaining = 0.0 @@ -68,13 +66,16 @@ class Sud(AttributeChange): self.step = None self.user_message = None - def derive_plant_params(self): - """Lumped (mass, specific heat) lifted from pot_mass/pot_material/ - grain_mass/water_mass, for use as Pot's "M"/"C" params.""" + 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 = self.water_mass + self.grain_mass + self.pot_mass - capacitance = (self.water_mass * SPECIFIC_HEAT_WATER - + self.grain_mass * SPECIFIC_HEAT_GRAIN + 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 { diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index 7f0ca52..28f9c25 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -35,8 +35,9 @@ if __name__ == '__main__': sud = Sud("sude/sud_0010.json") + first_step = sud.schedule[0] plant_params = { - **sud.derive_plant_params(), + **sud.derive_plant_params(first_step['grain_mass'], first_step['water_mass']), "L" : 0.2, "Td" : 60 } @@ -65,9 +66,15 @@ if __name__ == '__main__': stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0) 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): if step is not None: print(f"Step {step['number']}: {step['descr']}") + apply_plant_params(step) if 'ramp' in step: ctrl.set_theta_soll(step['ramp']['temp']) ctrl.set_heatrate_soll(step['ramp']['rate']) diff --git a/sude/sud_0010.json b/sude/sud_0010.json index 2d88d9b..40408f2 100644 --- a/sude/sud_0010.json +++ b/sude/sud_0010.json @@ -3,14 +3,14 @@ "Description" : "Rotfraenkisch, Dunkles Lager", "pot_mass" : 5.960, "pot_material" : "Edelstahl 18/10", - "grain_mass" : 5.21, - "water_mass" : 22, "default": { "step" : { "descr": "Put description here", "user_message": "Put user message here", "user_wait_for_continue": false, + "grain_mass" : 5.21, + "water_mass" : 22, "ramp": { "rate": 1.0, "temp": 0, @@ -33,6 +33,7 @@ "steps": [ { + "grain_mass" : 0, "descr": "Heizen für Einmaischen", "ramp": { "rate": 1.8, @@ -101,6 +102,7 @@ } }, { + "water_mass" : 21, "descr": "Halten Abmaischen", "user_message": "Quittieren zum Abmaischen", "user_wait_for_continue": true,