refactor: move pot hardware params and pid_type out of per-brew docs

Pot hardware constants (mass, material, L, Td) moved from sude/*.json
to config.json's Pot section; Sud/SudForecastEstimator accept pot_config
as baseline, per-brew pot sections may still override. Controller key
removed; pid_type moved into TempCtrl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 19:41:04 +02:00
co-authored by Claude Sonnet 4.6
parent 0495e177c4
commit 5142fcbf3d
6 changed files with 41 additions and 26 deletions
+20 -10
View File
@@ -82,28 +82,38 @@ EMPTY_SUD = {
class Sud(AttributeChange): class Sud(AttributeChange):
def __init__(self): def __init__(self, pot_config=None):
"""Starts out with no schedule loaded - one of potentially several """Starts out with no schedule loaded - one of potentially several
sude/*.json files on disk is brought in later via load(), kept sude/*.json files on disk is brought in later via load(), kept
purely in memory (see load()) until replaced or the server purely in memory (see load()) until replaced or the server
restarts.""" restarts.
pot_config provides hardware baseline values (mass, material, L, Td)
from config.json's Pot section - a loaded sud.json may override any
of them per-brew, but config is the source of truth for the physical
kettle."""
AttributeChange.__init__(self) AttributeChange.__init__(self)
self._pot_config = pot_config or {}
self._data = EMPTY_SUD self._data = EMPTY_SUD
(self.name, self.description, self.schedule, self.pot_mass, (self.name, self.description, self.schedule, self.pot_mass,
self.pot_material, self.L, self.Td, self.grain_mass, self.pot_material, self.L, self.Td, self.grain_mass,
self.water_mass) = self._parse_data(self._data) self.water_mass) = self._parse_data(self._data, self._pot_config)
self._paused_from = None self._paused_from = None
self._reset_run_state() self._reset_run_state()
@staticmethod @staticmethod
def _parse_data(data): def _parse_data(data, pot_config=None):
"""Parses a sud.json document into the (name, description, schedule, """Parses a sud.json document into the (name, description, schedule,
pot_mass, pot_material, L, Td, grain_mass, water_mass) tuple Sud pot_mass, pot_material, L, Td, grain_mass, water_mass) tuple Sud
needs. Computed up front rather than assigned straight onto self, needs. Computed up front rather than assigned straight onto self,
so a malformed load() can't leave a half-applied schedule in so a malformed load() can't leave a half-applied schedule in
place.""" place.
pot_config (from config.json's Pot section) provides hardware baseline
values; a sud.json's own pot section overrides any of them per-brew."""
pot_config = pot_config or {}
name = data.get('Name', '') name = data.get('Name', '')
description = data.get('Description', '') description = data.get('Description', '')
@@ -111,10 +121,10 @@ class Sud(AttributeChange):
schedule = [_build_step(i + 1, step_defaults, raw) for i, raw in enumerate(data['steps'])] schedule = [_build_step(i + 1, step_defaults, raw) for i, raw in enumerate(data['steps'])]
pot_data = data.get('pot', {}) pot_data = data.get('pot', {})
pot_mass = pot_data.get('mass', 0) pot_mass = pot_data.get('mass', pot_config.get('mass', 0))
pot_material = pot_data.get('material') pot_material = pot_data.get('material', pot_config.get('material'))
L = pot_data.get('L', EMPTY_SUD['pot']['L']) L = pot_data.get('L', pot_config.get('L', EMPTY_SUD['pot']['L']))
Td = pot_data.get('Td', EMPTY_SUD['pot']['Td']) Td = pot_data.get('Td', pot_config.get('Td', EMPTY_SUD['pot']['Td']))
grain_mass = pot_data.get('grain_mass', EMPTY_SUD['pot']['grain_mass']) grain_mass = pot_data.get('grain_mass', EMPTY_SUD['pot']['grain_mass'])
water_mass = pot_data.get('water_mass', EMPTY_SUD['pot']['water_mass']) water_mass = pot_data.get('water_mass', EMPTY_SUD['pot']['water_mass'])
@@ -147,7 +157,7 @@ class Sud(AttributeChange):
if self.state not in (SudState.IDLE, SudState.DONE): if self.state not in (SudState.IDLE, SudState.DONE):
return False return False
try: try:
parsed = self._parse_data(data) parsed = self._parse_data(data, self._pot_config)
except (KeyError, TypeError): except (KeyError, TypeError):
return False return False
+3 -2
View File
@@ -51,7 +51,7 @@ class SudForecastEstimator:
real run actually is (the discrete steps end up duty-cycling it back real run actually is (the discrete steps end up duty-cycling it back
down to something close to the commanded average).""" down to something close to the commanded average)."""
def __init__(self, dt, theta_amb, pid_type, tempctrl_params, heater_powers, sim_warp_factor): def __init__(self, dt, theta_amb, pid_type, tempctrl_params, heater_powers, sim_warp_factor, pot_config=None):
self.dt = dt self.dt = dt
self.theta_amb = theta_amb self.theta_amb = theta_amb
self.pid_type = pid_type self.pid_type = pid_type
@@ -61,6 +61,7 @@ class SudForecastEstimator:
# pair straddling the commanded power. # pair straddling the commanded power.
self.heater_powers = heater_powers self.heater_powers = heater_powers
self.sim_warp_factor = sim_warp_factor self.sim_warp_factor = sim_warp_factor
self.pot_config = pot_config or {}
def set_ambient_temperature(self, theta_amb): def set_ambient_temperature(self, theta_amb):
self.theta_amb = theta_amb self.theta_amb = theta_amb
@@ -93,7 +94,7 @@ class SudForecastEstimator:
if start_theta is None: if start_theta is None:
start_theta = self.theta_amb start_theta = self.theta_amb
sud = Sud() sud = Sud(self.pot_config)
if not sud.load(doc) or not sud.schedule: if not sud.load(doc) or not sud.schedule:
return [0.0], [start_theta], SudState.DONE, {} return [0.0], [start_theta], SudState.DONE, {}
+7 -3
View File
@@ -1,9 +1,7 @@
{ {
"ambient_temperature": 20, "ambient_temperature": 20,
"Controller" : {
"pid_type": "Smith"
},
"TempCtrl": { "TempCtrl": {
"pid_type": "Smith",
"beta": 0.05, "beta": 0.05,
"Hold": { "Hold": {
"kp": 0.4, "kp": 0.4,
@@ -45,5 +43,11 @@
"TempSensor": { "TempSensor": {
"type": "max31865", "type": "max31865",
"temp_offset": -0.15 "temp_offset": -0.15
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17
} }
} }
+7 -3
View File
@@ -1,9 +1,7 @@
{ {
"ambient_temperature": 20, "ambient_temperature": 20,
"Controller" : {
"pid_type": "Smith"
},
"TempCtrl": { "TempCtrl": {
"pid_type": "Smith",
"beta": 0.05, "beta": 0.05,
"Hold": { "Hold": {
"kp": 0.4, "kp": 0.4,
@@ -44,5 +42,11 @@
"sigma": 0.05, "sigma": 0.05,
"stirrer_sigma": 0.0, "stirrer_sigma": 0.0,
"stirrer_tau": 20.0 "stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17
} }
} }
+4 -4
View File
@@ -81,7 +81,7 @@ if __name__ == '__main__':
# Mash schedule - starts out empty; a client loads one of sude/*.json's # Mash schedule - starts out empty; a client loads one of sude/*.json's
# several schedules onto it later (see components/sud.py's Sud). # several schedules onto it later (see components/sud.py's Sud).
sud = Sud() sud = Sud(config.get('Pot', {}))
# Heater/Pot/Sensor - built together as one consistent rig; each # Heater/Pot/Sensor - built together as one consistent rig; each
# component's type comes from its own config section. Heater.type==sim # component's type comes from its own config section. Heater.type==sim
@@ -113,7 +113,7 @@ if __name__ == '__main__':
# temp_controller_smith.py.), and Smith's model plant params are - # temp_controller_smith.py.), and Smith's model plant params are -
# like the real Pot's above - deliberately left unset here, only # like the real Pot's above - deliberately left unset here, only
# ever coming from a Sud's own doc. # ever coming from a Sud's own doc.
tc = PidFactory.create(config['Controller']['pid_type'], DT) tc = PidFactory.create(config['TempCtrl']['pid_type'], DT)
tc.set_params(config['TempCtrl']) tc.set_params(config['TempCtrl'])
tc.set_ambient_temperature(theta_amb) tc.set_ambient_temperature(theta_amb)
tc_task = TcTask(tc, DT_TASK, dispatcher.msgio_get("TempCtrl")) tc_task = TcTask(tc, DT_TASK, dispatcher.msgio_get("TempCtrl"))
@@ -128,8 +128,8 @@ if __name__ == '__main__':
# it with the same kind of plant/controller (and params) as above - # it with the same kind of plant/controller (and params) as above -
# see components/sud_forecast.py. # see components/sud_forecast.py.
forecast_estimator = SudForecastEstimator( forecast_estimator = SudForecastEstimator(
DT, theta_amb, config['Controller']['pid_type'], config['TempCtrl'], DT, theta_amb, config['TempCtrl']['pid_type'], config['TempCtrl'],
heater.get_powers(), args.sim_warp_factor) heater.get_powers(), args.sim_warp_factor, config.get('Pot', {}))
sud_task = SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"), forecast_estimator) sud_task = SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"), forecast_estimator)
taskmgr.add(sud_task) taskmgr.add(sud_task)
# Records every Sud run's measured data and forecast to their own # Records every Sud run's measured data and forecast to their own
-4
View File
@@ -2,10 +2,6 @@
"Name": "Sud-0010", "Name": "Sud-0010",
"Description": "Rotfraenkisch, Dunkles Lager", "Description": "Rotfraenkisch, Dunkles Lager",
"pot": { "pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"grain_mass": 0, "grain_mass": 0,
"water_mass": 22, "water_mass": 22,
"volumen": 30 "volumen": 30