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
-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'])