diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index 1fd4070..e68ffc8 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -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 diff --git a/components/pid/temp_controller_smith.py b/components/pid/temp_controller_smith.py index 8996efe..44e5ee5 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -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) diff --git a/components/plant/pot.py b/components/plant/pot.py index 3500eba..70f2ba2 100644 --- a/components/plant/pot.py +++ b/components/plant/pot.py @@ -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 diff --git a/components/sud.py b/components/sud.py index bf20191..efe1634 100644 --- a/components/sud.py +++ b/components/sud.py @@ -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): diff --git a/components/sud_forecast.py b/components/sud_forecast.py index e6672c1..0798123 100644 --- a/components/sud_forecast.py +++ b/components/sud_forecast.py @@ -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']) diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index fc6da2a..db192ee 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -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); diff --git a/sude/GuiTest.json b/sude/GuiTest.json index e23fcc1..c088998 100644 --- a/sude/GuiTest.json +++ b/sude/GuiTest.json @@ -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", diff --git a/sude/GuiTest_no_user.json b/sude/GuiTest_no_user.json index 0f9ad71..d605e00 100644 --- a/sude/GuiTest_no_user.json +++ b/sude/GuiTest_no_user.json @@ -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", diff --git a/sude/sud_0010.json b/sude/sud_0010.json index 0805317..0dc67a2 100644 --- a/sude/sud_0010.json +++ b/sude/sud_0010.json @@ -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", diff --git a/sude/sud_0010_downloaded.json b/sude/sud_0010_downloaded.json index 345569a..f8a6749 100644 --- a/sude/sud_0010_downloaded.json +++ b/sude/sud_0010_downloaded.json @@ -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", diff --git a/sude/sud_0020.json b/sude/sud_0020.json index 2a2e21e..4873a60 100644 --- a/sude/sud_0020.json +++ b/sude/sud_0020.json @@ -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, diff --git a/tasks/sud.py b/tasks/sud.py index 5a7d010..ef24744 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -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', {})