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