From 5ccfcef6795e08325a4d3206b433728a4bae35ef Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 22 Jun 2026 20:19:25 +0200 Subject: [PATCH] Add explicit initial grain_mass/water_mass to the Sud doc Sud now parses top-level grain_mass/water_mass (sibling to pot_mass/ pot_material/L/Td, defaulting to 0/0) - what's actually in the pot before the brew starts, as opposed to default.step's grain_mass/water_mass, which is just inert template filler. All sude/*.json docs gain explicit values matching what their first step already resolved to. tasks/sud.py's apply_plant_params() now takes grain_mass/water_mass directly instead of a step dict: on_step_changed() still passes the active step's own (these vary as malt goes in/water boils off), but the Load handler now reads Sud.grain_mass/water_mass directly instead of parsing schedule[0]. The GUI's status-line mass preview (shown immediately on Load, before Start) does the same. Also fixes a latent bug found along the way: _continue_forecast_after_confirm()'s reconstructed sub-doc was missing L/Td/grain_mass/water_mass entirely, silently falling back to generic defaults instead of the real Sud's own values - invisible only because every current sude/*.json happens to use those same defaults. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk --- client/brewpi_gui.py | 66 +++++++++++++++++++++++++---------- components/sud.py | 25 +++++++++---- sude/GuiTest.json | 2 ++ sude/GuiTest_no_user.json | 2 ++ sude/sud_0010.json | 2 ++ sude/sud_0010_downloaded.json | 2 ++ sude/sud_0020.json | 2 ++ tasks/sud.py | 31 +++++++++------- 8 files changed, 96 insertions(+), 36 deletions(-) diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index 7f2c1d7..ecdca4d 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -285,6 +285,8 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_name = '' self.sud_schedule = [] self.sud_pot_mass = 0 + self.sud_grain_mass = 0 + self.sud_water_mass = 0 self.sud_step_index = None self.sud_step_descr = None self.sud_step_type = None @@ -488,6 +490,8 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_name = '' self.sud_schedule = [] self.sud_pot_mass = 0 + self.sud_grain_mass = 0 + self.sud_water_mass = 0 self.sud_step_index = None self.sud_step_descr = None self.sud_step_type = None @@ -847,37 +851,63 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.forecast_plot.show_forecast(t_min, forecast['Theta'], self.sud_name, forecast['Finished']) def update_status_step_label(self): - if not self.sud_step_descr: + if self.sud_step_descr: + text = "Step {}: {}".format(self.sud_step_index, self.sud_step_descr) + if self.sud_step_type == 'hold': + remaining = max(self.sud_hold_remaining, 0.0) + text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60) + # sud_step_index is the same 0-based index Sud.index uses into + # its own schedule list (see components/sud.py) - self. + # sud_schedule is built from the very same doc, so it lines up + # directly; grain_mass/water_mass are already resolved against + # default.step there (see components/sud.py's _build_step()). + if 0 <= self.sud_step_index < len(self.sud_schedule): + step = self.sud_schedule[self.sud_step_index] + grain, water = step.get('grain_mass', 0), step.get('water_mass', 0) + else: + grain, water = self.sud_grain_mass, self.sud_water_mass + elif self.sud_schedule: + # Loaded but not started yet (no real Step message has + # arrived) - preview the doc's own initial grain_mass/ + # water_mass right away instead of leaving the status line + # blank until Start, mirroring tasks/sud.py's SudTask. + # apply_plant_params() applying it to the real plant/ + # controller immediately on Load too. + text = "" + grain, water = self.sud_grain_mass, self.sud_water_mass + else: self.statusBar().clearMessage() return - text = "Step {}: {}".format(self.sud_step_index, self.sud_step_descr) - if self.sud_step_type == 'hold': - remaining = max(self.sud_hold_remaining, 0.0) - text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60) - # sud_step_index is the same 0-based index Sud.index uses into its - # own schedule list (see components/sud.py) - self.sud_schedule is - # built from the very same doc, so it lines up directly; grain_mass/ - # water_mass are already resolved against default.step there (see - # components/sud.py's _build_step()). - if self.sud_step_index is not None and 0 <= self.sud_step_index < len(self.sud_schedule): - step = self.sud_schedule[self.sud_step_index] - pot = self.sud_pot_mass - water = step.get('water_mass', 0) - grain = step.get('grain_mass', 0) - text += " Pot: {:.2f} kg, Water: {:.2f} kg, Grain {:.2f} kg, total {:.2f} kg".format( - pot, water, grain, pot + water + grain) + + pot = self.sud_pot_mass + mass_text = "Pot: {:.2f} kg, Water: {:.2f} kg, Grain {:.2f} kg, total {:.2f} kg".format( + pot, water, grain, pot + water + grain) + text = text + " " + mass_text if text else mass_text self.statusBar().showMessage(text) def update_sud_forecast(self, doc): try: - name, _, schedule, pot_mass, _, _, _ = Sud._parse_data(doc) + name, _, schedule, pot_mass, _, _, _, grain_mass, water_mass = Sud._parse_data(doc) except (KeyError, TypeError): return self.sud_name = name self.sud_schedule = schedule self.sud_pot_mass = pot_mass + self.sud_grain_mass = grain_mass + self.sud_water_mass = water_mass + # A fresh Load means no step has actually started yet, regardless + # of whatever the previously loaded Sud last reported - don't wait + # for the server's own 'Step' (None) reset (asyncio.create_task() + # there means its arrival order relative to this doc isn't + # guaranteed) to clear a stale step from the *previous* schedule + # before showing this one's preview below. + self.sud_step_descr = None + self.sud_step_index = None + self.sud_step_type = None + self.sud_hold_remaining = 0.0 self.sud_empty = len(schedule) == 0 self.update_sud_actions() + self.update_status_step_label() if self.sud_empty: self.forecast_plot.show_no_schedule() return diff --git a/components/sud.py b/components/sud.py index efe1634..968036a 100644 --- a/components/sud.py +++ b/components/sud.py @@ -66,6 +66,14 @@ EMPTY_SUD = { # values brewpi.py used to hardcode for every brew alike. 'L': 0.2, 'Td': 30, + # Initial grain_mass/water_mass - what's actually in the pot before + # the brew starts (e.g. water added but no malt yet), as opposed to + # default.step's grain_mass/water_mass, which is just inert template + # filler for steps that don't override it. Lets a caller derive + # starting plant params directly (see SudTask.recv()'s Load handler) + # without having to parse the first step out of the schedule. + 'grain_mass': 0, + 'water_mass': 0, 'steps': [], } @@ -80,7 +88,8 @@ class Sud(AttributeChange): self._data = EMPTY_SUD (self.name, self.description, self.schedule, self.pot_mass, - self.pot_material, self.L, self.Td) = self._parse_data(self._data) + self.pot_material, self.L, self.Td, self.grain_mass, + self.water_mass) = self._parse_data(self._data) self._paused_from = None self._reset_run_state() @@ -88,9 +97,10 @@ class Sud(AttributeChange): @staticmethod def _parse_data(data): """Parses a sud.json document into the (name, description, schedule, - 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.""" + pot_mass, pot_material, L, Td, grain_mass, water_mass) 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', '') @@ -101,8 +111,10 @@ class Sud(AttributeChange): pot_material = data.get('pot_material') L = data.get('L', EMPTY_SUD['L']) Td = data.get('Td', EMPTY_SUD['Td']) + grain_mass = data.get('grain_mass', EMPTY_SUD['grain_mass']) + water_mass = data.get('water_mass', EMPTY_SUD['water_mass']) - return name, description, schedule, pot_mass, pot_material, L, Td + return name, description, schedule, pot_mass, pot_material, L, Td, grain_mass, water_mass def _reset_run_state(self): """Resets run-time progress back to a freshly-loaded, not-yet-started @@ -136,7 +148,8 @@ class Sud(AttributeChange): return False (self.name, self.description, self.schedule, self.pot_mass, - self.pot_material, self.L, self.Td) = parsed + self.pot_material, self.L, self.Td, self.grain_mass, + self.water_mass) = parsed self._data = data self._reset_run_state() diff --git a/sude/GuiTest.json b/sude/GuiTest.json index c088998..bb5753c 100644 --- a/sude/GuiTest.json +++ b/sude/GuiTest.json @@ -5,6 +5,8 @@ "pot_material": "Edelstahl 18/10", "L": 0.2, "Td": 30, + "grain_mass": 5.21, + "water_mass": 22, "default": { "step": { "descr": "Put description here", diff --git a/sude/GuiTest_no_user.json b/sude/GuiTest_no_user.json index d605e00..89be929 100644 --- a/sude/GuiTest_no_user.json +++ b/sude/GuiTest_no_user.json @@ -5,6 +5,8 @@ "pot_material": "Edelstahl 18/10", "L": 0.2, "Td": 30, + "grain_mass": 5.21, + "water_mass": 22, "default": { "step": { "descr": "Put description here", diff --git a/sude/sud_0010.json b/sude/sud_0010.json index 0dc67a2..9cca489 100644 --- a/sude/sud_0010.json +++ b/sude/sud_0010.json @@ -5,6 +5,8 @@ "pot_material": "Edelstahl 18/10", "L": 0.2, "Td": 30, + "grain_mass": 0, + "water_mass": 22, "default": { "step": { "descr": "Put description here", diff --git a/sude/sud_0010_downloaded.json b/sude/sud_0010_downloaded.json index f8a6749..8dd7a7b 100644 --- a/sude/sud_0010_downloaded.json +++ b/sude/sud_0010_downloaded.json @@ -5,6 +5,8 @@ "pot_material": "Edelstahl 18/10", "L": 0.2, "Td": 30, + "grain_mass": 0, + "water_mass": 22, "default": { "step": { "descr": "Put description here", diff --git a/sude/sud_0020.json b/sude/sud_0020.json index 4873a60..3b33146 100644 --- a/sude/sud_0020.json +++ b/sude/sud_0020.json @@ -5,6 +5,8 @@ "pot_material": "Edelstahl 18/10", "L": 0.2, "Td": 30, + "grain_mass": 0, + "water_mass": 10, "default": { "step": { "descr": "Put description here", diff --git a/tasks/sud.py b/tasks/sud.py index a9331b2..3cb3502 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -55,17 +55,20 @@ class SudTask(ATask): self.forecast_confirm_marks = {} msg_handler.set_recv_handler(self.recv) - def apply_plant_params(self, step): + def apply_plant_params(self, grain_mass, water_mass): """Keeps the real plant's and the controller's internal model's 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 + given 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(). Called both on every real step transition - (via on_step_changed()) and once immediately on Load (see recv()), - so the controller's behavior already matches the expected plant - as soon as a Sud is loaded, not just once a run actually starts.""" - params = self.sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0)) + (via on_step_changed(), with that step's own grain_mass/water_mass) + and once immediately on Load (see recv(), with the doc's own + initial Sud.grain_mass/water_mass - no need to parse the first + step out of the schedule for that), so the controller's behavior + already matches the expected plant as soon as a Sud is loaded, + not just once a run actually starts.""" + params = self.sud.derive_plant_params(grain_mass, water_mass) self.pot.set_plant_params(params) if hasattr(self.tc, 'set_model_plant_params'): self.tc.set_model_plant_params(params) @@ -95,7 +98,7 @@ class SudTask(ATask): phase = ramp if ramping else hold if step is not None: - self.apply_plant_params(step) + self.apply_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0)) if ramping and step['temperature'] is not None: self.tc.set_theta_soll(step['temperature']) self.tc.set_heatrate_soll(ramp['rate']) @@ -214,6 +217,10 @@ class SudTask(ATask): 'Description': self.sud.description, 'pot_mass': self.sud.pot_mass, 'pot_material': self.sud.pot_material, + 'L': self.sud.L, + 'Td': self.sud.Td, + 'grain_mass': self.sud.grain_mass, + 'water_mass': self.sud.water_mass, 'steps': schedule[index:], } start_theta = self.tc.get_theta_ist() @@ -264,12 +271,12 @@ class SudTask(ATask): # on_step_changed() only re-applies plant params once a # real step starts (Start) - apply them right away too, # so the controller already matches this Sud's own pot/ - # L/Td (and its first step's expected grain/water mass) - # from the moment it's loaded, rather than whatever the - # previously loaded Sud (or the generic startup - # baseline - see server/brewpi.py) left behind. + # L/Td/initial grain_mass/water_mass from the moment + # it's loaded, rather than whatever the previously + # loaded Sud (or the generic startup baseline - see + # server/brewpi.py) left behind. if self.sud.schedule: - self.apply_plant_params(self.sud.schedule[0]) + self.apply_plant_params(self.sud.grain_mass, self.sud.water_mass) await self.send_forecast(pair[1]) else: # Sud.load() refuses while a run is in progress (state