From 0b2a2be035fb82f8fdfd431e91177926a28ee6dc Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Wed, 1 Jul 2026 09:06:39 +0200 Subject: [PATCH] feat: re-add per-run sud log alongside the continuous server log SudLogTask reuses ServerLogTask's sample format but only records while a Sud run is active: starts on Play (fresh start only, not a Pause resume) and writes logs/log_{date_time}_{sud_name}.log on Stop or natural completion. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS --- server/brewpi.py | 22 ++++++++++++++++------ tasks/__init__.py | 1 + tasks/server_log.py | 34 ++++++++++++++++++++++------------ tasks/sud_log.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 tasks/sud_log.py diff --git a/server/brewpi.py b/server/brewpi.py index e5d4685..38afdd8 100755 --- a/server/brewpi.py +++ b/server/brewpi.py @@ -15,7 +15,7 @@ from components.plant import PlantFactory from components.actor import StirrerFactory from components.sud import Sud from components.sud_forecast import SudForecastEstimator -from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, SudTask, ServerLogTask +from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, SudTask, ServerLogTask, SudLogTask import argparse as ap @@ -133,21 +133,31 @@ if __name__ == '__main__': # Continuous server-session log - records from startup to shutdown # regardless of Sud state; written to logs/log_{date_time}.json on exit. server_log_task = ServerLogTask(tc, heater, heater_task, DT_TASK, args.logdir, config=config, dt=DT) - sud_task.set_on_plant_params(server_log_task.log_plant_params) if startup_params is not None: server_log_task.log_plant_params(0, startup_params) taskmgr.add(server_log_task) + # Per-run log - only records while a Sud is actually playing; written to + # logs/log_{date_time}_{sud_name}.log on Stop (or natural completion). + sud_log_task = SudLogTask(tc, heater, heater_task, sud, DT_TASK, args.logdir, config=config, dt=DT) + taskmgr.add(sud_log_task) + + sud_task.set_on_plant_params(lambda elapsed, params: ( + server_log_task.log_plant_params(elapsed, params), + sud_log_task.log_plant_params(elapsed, params))) + # Assign data flow # Assign tc control value to heater tc.set_on_changed("y", heater_task.actor) # HeaterTask owns TC enable: enabled in closed-loop mode, disabled in open-loop. heater_task.set_on_closed_loop_changed(tc.set_enabled) # Brew start: switch heater to closed-loop so the TC drives it regardless - # of which client (browser, PyQt GUI, …) started the brew. - sud_task.set_on_start(heater_task.start_closed_loop) - # Brew end (DONE/IDLE): shut heater off and broadcast open-loop/power-0 to clients. - sud_task.set_on_end(heater_task.shutdown) + # of which client (browser, PyQt GUI, …) started the brew; also (re)starts + # the per-run sud log (a Pause->resume Start is a no-op for the log). + sud_task.set_on_start(lambda: (heater_task.start_closed_loop(), sud_log_task.start_run())) + # Brew end (DONE/IDLE): shut heater off, broadcast open-loop/power-0 to + # clients, and write out the per-run sud log. + sud_task.set_on_end(lambda: (heater_task.shutdown(), sud_log_task.stop_run())) # Assign temp. sensor readings to tc sensor_task.set_on_changed("temp", ChangedFloat(tc.set_theta_ist, prec=2).set) diff --git a/tasks/__init__.py b/tasks/__init__.py index 25541ad..00988a7 100644 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -6,3 +6,4 @@ from tasks.pot import PotTask from tasks.tempctrl import TcTask from tasks.sud import SudTask from tasks.server_log import ServerLogTask +from tasks.sud_log import SudLogTask diff --git a/tasks/server_log.py b/tasks/server_log.py index 0bcb2df..e13ee90 100644 --- a/tasks/server_log.py +++ b/tasks/server_log.py @@ -34,24 +34,34 @@ class ServerLogTask(ATask): async def on_process(self): while True: - power_set = max(self.heater_task.power_soll, self.heater_task.power_actor) - self._samples.append({ - 't': time.monotonic() - self._t0, - 'timestamp': time.time(), - 'temp_ist': self.tc.theta_ist, - 'temp_soll': self.tc.theta_soll_set, - 'rate_ist': self.tc.heatrate_ist, - 'rate_soll': self.tc.heatrate_soll, - 'power_set': power_set, - 'power_eff': self.heater.power_eff, - }) + if self._should_sample(): + self._samples.append(self._take_sample()) await asyncio.sleep(self.interval) + def _should_sample(self): + return True + + def _take_sample(self): + power_set = max(self.heater_task.power_soll, self.heater_task.power_actor) + return { + 't': time.monotonic() - self._t0, + 'timestamp': time.time(), + 'temp_ist': self.tc.theta_ist, + 'temp_soll': self.tc.theta_soll_set, + 'rate_ist': self.tc.heatrate_ist, + 'rate_soll': self.tc.heatrate_soll, + 'power_set': power_set, + 'power_eff': self.heater.power_eff, + } + + def _filename(self): + return 'log_{}.json'.format(self._run_id) + def write(self): if not self._samples: return os.makedirs(self.path, exist_ok=True) - filename = 'log_{}.json'.format(self._run_id) + filename = self._filename() path = os.path.join(self.path, filename) log_data = {'Name': ''} if self.dt is not None: diff --git a/tasks/sud_log.py b/tasks/sud_log.py new file mode 100644 index 0000000..bf65740 --- /dev/null +++ b/tasks/sud_log.py @@ -0,0 +1,42 @@ +import time +from tasks.server_log import ServerLogTask +from components import APid, AHeater + + +def _safe_filename_part(text): + return "".join(c if c.isalnum() or c in "-_" else "_" for c in text) or "Sud" + + +class SudLogTask(ServerLogTask): + """Records temp/power samples for a single Sud run - same sample format + as ServerLogTask, but only while a run is active: recording starts on + Play and is written out on Stop (or natural completion), to + logs/log_{date_time}_{sud_name}.log. A Pause/resume doesn't start a new + file - only a fresh Play (from IDLE/DONE) does.""" + + def __init__(self, tc: APid, heater: AHeater, heater_task, sud, interval, path='./logs', config=None, dt=None): + ServerLogTask.__init__(self, tc, heater, heater_task, interval, path, config, dt) + self.sud = sud + self._active = False + + def _should_sample(self): + return self._active + + def _filename(self): + name_part = _safe_filename_part(self.sud.name or 'Sud') + return 'log_{}_{}.log'.format(self._run_id, name_part) + + def start_run(self): + if self._active: + return + self._samples = [] + self._t0 = time.monotonic() + self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime()) + self._active = True + + def stop_run(self): + if not self._active: + return + self._active = False + self.write() + self._param_events = []