Derive Pot's L/Td from the Sud doc instead of a fixed server default

Sud now parses top-level L/Td fields from sude/*.json (defaulting to 0.2/30,
matching the old hardcoded server constants), and derive_plant_params()
returns them alongside M/C - constant for the whole brew, unlike M/C which
vary per step with grain/water mass. All sude/*.json docs gain explicit
L/Td fields.

Callers (tasks/sud.py, SudForecastEstimator, demo_sud.py) switch from the
narrow set_thermal_params(M, C)/set_model_params(M, C) to the full
set_plant_params(params)/set_model_plant_params(params), since
derive_plant_params() now always returns all four keys; both narrow setters
are removed as dead code.

Caught along the way: Pot.set_plant_params() unconditionally rebuilt the
Delay ring buffer, which was harmless when only ever called once at
construction - but now running on every Sud step change, it was wiping
in-flight delayed power at each step boundary. Fixed to only rebuild when
Td actually changes; verified this restores the exact original forecast
result for sud_0010.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
This commit is contained in:
2026-06-22 18:45:07 +02:00
co-authored by Claude Sonnet 4.6
parent 96fe7ce90c
commit f1688d3c71
12 changed files with 59 additions and 41 deletions
+1 -1
View File
@@ -856,7 +856,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
def update_sud_forecast(self, doc): def update_sud_forecast(self, doc):
try: try:
name, _, schedule, _, _ = Sud._parse_data(doc) name, _, schedule, _, _, _, _ = Sud._parse_data(doc)
except (KeyError, TypeError): except (KeyError, TypeError):
return return
self.sud_name = name self.sud_name = name
-4
View File
@@ -28,10 +28,6 @@ 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 set_ambient_temperature(self, theta_amb): def set_ambient_temperature(self, theta_amb):
self.model.set_ambient_temperature(theta_amb) self.model.set_ambient_temperature(theta_amb)
self.model_delay.set_ambient_temperature(theta_amb) self.model_delay.set_ambient_temperature(theta_amb)
+9 -6
View File
@@ -42,8 +42,15 @@ class Pot(APlant):
# P_loss = L * Mass * (T_plant - T_ambient) # P_loss = L * Mass * (T_plant - T_ambient)
self.L = params['L'] self.L = params['L']
self.Td = params['Td'] # Only rebuilds the delay line if Td actually changed - this now
self.delay = delay.Delay(self.dt, self.Td, 0) # gets called on every Sud step change (M/C vary with grain/water
# mass), not just once at construction, and rebuilding unconditionally
# would zero out whatever power was already in flight through the
# transport delay at every single step transition.
Td = params['Td']
if Td != self.Td:
self.Td = Td
self.delay = delay.Delay(self.dt, self.Td, 0)
def set_ambient_temperature(self, theta_amb): def set_ambient_temperature(self, theta_amb):
self.theta_amb = theta_amb self.theta_amb = theta_amb
@@ -84,10 +91,6 @@ class Pot(APlant):
def is_activated(self): def is_activated(self):
return True return True
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
+23 -12
View File
@@ -61,6 +61,11 @@ EMPTY_SUD = {
'Description': '', 'Description': '',
'pot_mass': 0, 'pot_mass': 0,
'pot_material': None, 'pot_material': None,
# Pot energy loss coefficient [W/(kg*K)] and transport propagation
# delay [s] - see derive_plant_params(). Defaults match the generic
# values brewpi.py used to hardcode for every brew alike.
'L': 0.2,
'Td': 30,
'steps': [], 'steps': [],
} }
@@ -74,8 +79,8 @@ class Sud(AttributeChange):
AttributeChange.__init__(self) AttributeChange.__init__(self)
self._data = EMPTY_SUD self._data = EMPTY_SUD
(self.name, self.description, self.schedule, (self.name, self.description, self.schedule, self.pot_mass,
self.pot_mass, self.pot_material) = self._parse_data(self._data) self.pot_material, self.L, self.Td) = self._parse_data(self._data)
self._paused_from = None self._paused_from = None
self._reset_run_state() self._reset_run_state()
@@ -83,9 +88,9 @@ class Sud(AttributeChange):
@staticmethod @staticmethod
def _parse_data(data): def _parse_data(data):
"""Parses a sud.json document into the (name, description, schedule, """Parses a sud.json document into the (name, description, schedule,
pot_mass, pot_material) tuple Sud needs. Computed up front rather pot_mass, pot_material, L, Td) tuple Sud needs. Computed up front
than assigned straight onto self, so a malformed load() can't rather than assigned straight onto self, so a malformed load()
leave a half-applied schedule in place.""" can't leave a half-applied schedule in place."""
name = data.get('Name', '') name = data.get('Name', '')
description = data.get('Description', '') description = data.get('Description', '')
@@ -94,8 +99,10 @@ class Sud(AttributeChange):
pot_mass = data.get('pot_mass', 0) pot_mass = data.get('pot_mass', 0)
pot_material = data.get('pot_material') pot_material = data.get('pot_material')
L = data.get('L', EMPTY_SUD['L'])
Td = data.get('Td', EMPTY_SUD['Td'])
return name, description, schedule, pot_mass, pot_material return name, description, schedule, pot_mass, pot_material, L, Td
def _reset_run_state(self): def _reset_run_state(self):
"""Resets run-time progress back to a freshly-loaded, not-yet-started """Resets run-time progress back to a freshly-loaded, not-yet-started
@@ -128,18 +135,20 @@ class Sud(AttributeChange):
except (KeyError, TypeError): except (KeyError, TypeError):
return False return False
self.name, self.description, self.schedule, self.pot_mass, self.pot_material = parsed (self.name, self.description, self.schedule, self.pot_mass,
self.pot_material, self.L, self.Td) = parsed
self._data = data self._data = data
self._reset_run_state() self._reset_run_state()
return True return True
def derive_plant_params(self, grain_mass, water_mass): def derive_plant_params(self, grain_mass, water_mass):
"""Lumped (mass, specific heat) lifted from pot_mass/pot_material and """Full Pot plant params - "M"/"C" lumped (mass, specific heat)
the current step's grain_mass/water_mass, for use as Pot's "M"/"C" from pot_mass/pot_material and the current step's grain_mass/
params. grain_mass/water_mass vary per step (e.g. malt going in, water_mass (these vary per step, e.g. malt going in, water
water boiling off), so this is recomputed on every step change boiling off, so this is recomputed on every step change rather
rather than once at startup.""" 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) c_pot = SPECIFIC_HEAT_BY_MATERIAL.get(self.pot_material, SPECIFIC_HEAT_WATER)
mass = water_mass + grain_mass + self.pot_mass mass = water_mass + grain_mass + self.pot_mass
capacitance = (water_mass * SPECIFIC_HEAT_WATER capacitance = (water_mass * SPECIFIC_HEAT_WATER
@@ -149,6 +158,8 @@ class Sud(AttributeChange):
return { return {
'M': mass, 'M': mass,
'C': capacitance / mass if mass > 0 else SPECIFIC_HEAT_WATER, 'C': capacitance / mass if mass > 0 else SPECIFIC_HEAT_WATER,
'L': self.L,
'Td': self.Td,
} }
def start(self): def start(self):
+3 -3
View File
@@ -89,9 +89,9 @@ class SudForecastEstimator:
if step is None: if step is None:
return return
params = sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0)) params = sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0))
pot.set_thermal_params(params['M'], params['C']) pot.set_plant_params(params)
if hasattr(tc, 'set_model_params'): if hasattr(tc, 'set_model_plant_params'):
tc.set_model_params(params['M'], params['C']) tc.set_model_plant_params(params)
if sud.state == SudState.RAMPING and step['temperature'] is not None: if sud.state == SudState.RAMPING and step['temperature'] is not None:
tc.set_theta_soll(step['temperature']) tc.set_theta_soll(step['temperature'])
tc.set_heatrate_soll(step['ramp']['rate']) tc.set_heatrate_soll(step['ramp']['rate'])
+3 -7
View File
@@ -42,11 +42,7 @@ if __name__ == '__main__':
sud.load(json.load(f)) sud.load(json.load(f))
first_step = sud.schedule[0] first_step = sud.schedule[0]
plant_params = { plant_params = sud.derive_plant_params(first_step['grain_mass'], first_step['water_mass'])
**sud.derive_plant_params(first_step['grain_mass'], first_step['water_mass']),
"L" : 0.2,
"Td" : 60
}
ctrl = TempController(dt) ctrl = TempController(dt)
ctrl.set_params(ctrl_params) ctrl.set_params(ctrl_params)
@@ -79,8 +75,8 @@ if __name__ == '__main__':
def apply_plant_params(step): def apply_plant_params(step):
params = sud.derive_plant_params(step['grain_mass'], step['water_mass']) params = sud.derive_plant_params(step['grain_mass'], step['water_mass'])
plant.set_thermal_params(params['M'], params['C']) plant.set_plant_params(params)
ctrl.set_model_params(params['M'], params['C']) ctrl.set_model_plant_params(params)
def on_step_changed(step): def on_step_changed(step):
# A step may carry both 'ramp' and 'hold' (ramp to temp, then hold); # A step may carry both 'ramp' and 'hold' (ramp to temp, then hold);
+2
View File
@@ -3,6 +3,8 @@
"Description": "Small, fast schedule for exercising the GUI (load/start/pause/stop/restart) without waiting through a real brew", "Description": "Small, fast schedule for exercising the GUI (load/start/pause/stop/restart) without waiting through a real brew",
"pot_mass": 5.96, "pot_mass": 5.96,
"pot_material": "Edelstahl 18/10", "pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"default": { "default": {
"step": { "step": {
"descr": "Put description here", "descr": "Put description here",
+2
View File
@@ -3,6 +3,8 @@
"Description": "Small, fast schedule for exercising the GUI (load/start/pause/stop/restart) without waiting through a real brew", "Description": "Small, fast schedule for exercising the GUI (load/start/pause/stop/restart) without waiting through a real brew",
"pot_mass": 5.96, "pot_mass": 5.96,
"pot_material": "Edelstahl 18/10", "pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"default": { "default": {
"step": { "step": {
"descr": "Put description here", "descr": "Put description here",
+2
View File
@@ -3,6 +3,8 @@
"Description": "Rotfraenkisch, Dunkles Lager", "Description": "Rotfraenkisch, Dunkles Lager",
"pot_mass": 5.96, "pot_mass": 5.96,
"pot_material": "Edelstahl 18/10", "pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"default": { "default": {
"step": { "step": {
"descr": "Put description here", "descr": "Put description here",
+2
View File
@@ -3,6 +3,8 @@
"Description": "Rotfraenkisch, Dunkles Lager", "Description": "Rotfraenkisch, Dunkles Lager",
"pot_mass": 5.96, "pot_mass": 5.96,
"pot_material": "Edelstahl 18/10", "pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"default": { "default": {
"step": { "step": {
"descr": "Put description here", "descr": "Put description here",
+4 -2
View File
@@ -3,13 +3,15 @@
"Description": "Bavarian, Dunkles", "Description": "Bavarian, Dunkles",
"pot_mass": 5.96, "pot_mass": 5.96,
"pot_material": "Edelstahl 18/10", "pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"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, "grain_mass": 2.51,
"water_mass": 22, "water_mass": 10,
"temperature": 0, "temperature": 0,
"ramp": { "ramp": {
"rate": 1.0, "rate": 1.0,
+8 -6
View File
@@ -57,13 +57,15 @@ class SudTask(ATask):
def apply_plant_params(self, step): def apply_plant_params(self, step):
"""Keeps the real plant's and the controller's internal model's """Keeps the real plant's and the controller's internal model's
lumped (M, C) in sync with the step's grain_mass/water_mass, since plant params in sync with this Sud's own doc - L/Td come straight
those vary over the course of a brew (malt going in, water boiling from it (constant for the whole brew), while M/C also fold in the
off) - mirrors demo_sud.py's apply_plant_params().""" step's grain_mass/water_mass, which vary over the brew's course
(malt going in, water boiling off) - mirrors demo_sud.py's
apply_plant_params()."""
params = self.sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0)) params = self.sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0))
self.pot.set_thermal_params(params['M'], params['C']) self.pot.set_plant_params(params)
if hasattr(self.tc, 'set_model_params'): if hasattr(self.tc, 'set_model_plant_params'):
self.tc.set_model_params(params['M'], params['C']) self.tc.set_model_plant_params(params)
def apply_stirrer(self, phase): def apply_stirrer(self, phase):
stirrer_cfg = phase.get('stirrer', {}) stirrer_cfg = phase.get('stirrer', {})