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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user