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):
try:
name, _, schedule, _, _ = Sud._parse_data(doc)
name, _, schedule, _, _, _, _ = Sud._parse_data(doc)
except (KeyError, TypeError):
return
self.sud_name = name
-4
View File
@@ -28,10 +28,6 @@ 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 set_ambient_temperature(self, theta_amb):
self.model.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)
self.L = params['L']
self.Td = params['Td']
self.delay = delay.Delay(self.dt, self.Td, 0)
# Only rebuilds the delay line if Td actually changed - this now
# 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):
self.theta_amb = theta_amb
@@ -84,10 +91,6 @@ class Pot(APlant):
def is_activated(self):
return True
def set_thermal_params(self, M, C):
self.M = M
self.C = C
def set_power(self, power):
self.p_in = power
+23 -12
View File
@@ -61,6 +61,11 @@ EMPTY_SUD = {
'Description': '',
'pot_mass': 0,
'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': [],
}
@@ -74,8 +79,8 @@ class Sud(AttributeChange):
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.name, self.description, self.schedule, self.pot_mass,
self.pot_material, self.L, self.Td) = self._parse_data(self._data)
self._paused_from = None
self._reset_run_state()
@@ -83,9 +88,9 @@ class Sud(AttributeChange):
@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."""
pot_mass, pot_material, L, Td) 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', '')
@@ -94,8 +99,10 @@ class Sud(AttributeChange):
pot_mass = data.get('pot_mass', 0)
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):
"""Resets run-time progress back to a freshly-loaded, not-yet-started
@@ -128,18 +135,20 @@ class Sud(AttributeChange):
except (KeyError, TypeError):
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._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."""
"""Full Pot plant params - "M"/"C" lumped (mass, specific heat)
from pot_mass/pot_material and the current step's grain_mass/
water_mass (these vary per step, e.g. malt going in, water
boiling off, so this is recomputed on every step change rather
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)
mass = water_mass + grain_mass + self.pot_mass
capacitance = (water_mass * SPECIFIC_HEAT_WATER
@@ -149,6 +158,8 @@ class Sud(AttributeChange):
return {
'M': mass,
'C': capacitance / mass if mass > 0 else SPECIFIC_HEAT_WATER,
'L': self.L,
'Td': self.Td,
}
def start(self):
+3 -3
View File
@@ -89,9 +89,9 @@ class SudForecastEstimator:
if step is None:
return
params = sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0))
pot.set_thermal_params(params['M'], params['C'])
if hasattr(tc, 'set_model_params'):
tc.set_model_params(params['M'], params['C'])
pot.set_plant_params(params)
if hasattr(tc, 'set_model_plant_params'):
tc.set_model_plant_params(params)
if sud.state == SudState.RAMPING and step['temperature'] is not None:
tc.set_theta_soll(step['temperature'])
tc.set_heatrate_soll(step['ramp']['rate'])
+3 -7
View File
@@ -42,11 +42,7 @@ if __name__ == '__main__':
sud.load(json.load(f))
first_step = sud.schedule[0]
plant_params = {
**sud.derive_plant_params(first_step['grain_mass'], first_step['water_mass']),
"L" : 0.2,
"Td" : 60
}
plant_params = sud.derive_plant_params(first_step['grain_mass'], first_step['water_mass'])
ctrl = TempController(dt)
ctrl.set_params(ctrl_params)
@@ -79,8 +75,8 @@ if __name__ == '__main__':
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'])
plant.set_plant_params(params)
ctrl.set_model_plant_params(params)
def on_step_changed(step):
# 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",
"pot_mass": 5.96,
"pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"default": {
"step": {
"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",
"pot_mass": 5.96,
"pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"default": {
"step": {
"descr": "Put description here",
+2
View File
@@ -3,6 +3,8 @@
"Description": "Rotfraenkisch, Dunkles Lager",
"pot_mass": 5.96,
"pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"default": {
"step": {
"descr": "Put description here",
+2
View File
@@ -3,6 +3,8 @@
"Description": "Rotfraenkisch, Dunkles Lager",
"pot_mass": 5.96,
"pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"default": {
"step": {
"descr": "Put description here",
+4 -2
View File
@@ -3,13 +3,15 @@
"Description": "Bavarian, Dunkles",
"pot_mass": 5.96,
"pot_material": "Edelstahl 18/10",
"L": 0.2,
"Td": 30,
"default": {
"step": {
"descr": "Put description here",
"user_message": "Put user message here",
"user_wait_for_continue": false,
"grain_mass": 5.21,
"water_mass": 22,
"grain_mass": 2.51,
"water_mass": 10,
"temperature": 0,
"ramp": {
"rate": 1.0,
+8 -6
View File
@@ -57,13 +57,15 @@ class SudTask(ATask):
def apply_plant_params(self, step):
"""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
those vary over the course of a brew (malt going in, water boiling
off) - mirrors demo_sud.py's apply_plant_params()."""
plant params in sync with this Sud's own doc - L/Td come straight
from it (constant for the whole brew), while M/C also fold in the
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))
self.pot.set_thermal_params(params['M'], params['C'])
if hasattr(self.tc, 'set_model_params'):
self.tc.set_model_params(params['M'], params['C'])
self.pot.set_plant_params(params)
if hasattr(self.tc, 'set_model_plant_params'):
self.tc.set_model_plant_params(params)
def apply_stirrer(self, phase):
stirrer_cfg = phase.get('stirrer', {})