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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
This commit is contained in:
2026-07-01 09:06:39 +02:00
co-authored by Claude Sonnet 5
parent 0736f76ca3
commit 0b2a2be035
4 changed files with 81 additions and 18 deletions
+16 -6
View File
@@ -15,7 +15,7 @@ from components.plant import PlantFactory
from components.actor import StirrerFactory from components.actor import StirrerFactory
from components.sud import Sud from components.sud import Sud
from components.sud_forecast import SudForecastEstimator 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 import argparse as ap
@@ -133,21 +133,31 @@ if __name__ == '__main__':
# Continuous server-session log - records from startup to shutdown # Continuous server-session log - records from startup to shutdown
# regardless of Sud state; written to logs/log_{date_time}.json on exit. # 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) 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: if startup_params is not None:
server_log_task.log_plant_params(0, startup_params) server_log_task.log_plant_params(0, startup_params)
taskmgr.add(server_log_task) 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 data flow
# Assign tc control value to heater # Assign tc control value to heater
tc.set_on_changed("y", heater_task.actor) tc.set_on_changed("y", heater_task.actor)
# HeaterTask owns TC enable: enabled in closed-loop mode, disabled in open-loop. # HeaterTask owns TC enable: enabled in closed-loop mode, disabled in open-loop.
heater_task.set_on_closed_loop_changed(tc.set_enabled) heater_task.set_on_closed_loop_changed(tc.set_enabled)
# Brew start: switch heater to closed-loop so the TC drives it regardless # Brew start: switch heater to closed-loop so the TC drives it regardless
# of which client (browser, PyQt GUI, …) started the brew. # of which client (browser, PyQt GUI, …) started the brew; also (re)starts
sud_task.set_on_start(heater_task.start_closed_loop) # the per-run sud log (a Pause->resume Start is a no-op for the log).
# Brew end (DONE/IDLE): shut heater off and broadcast open-loop/power-0 to clients. sud_task.set_on_start(lambda: (heater_task.start_closed_loop(), sud_log_task.start_run()))
sud_task.set_on_end(heater_task.shutdown) # 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 # Assign temp. sensor readings to tc
sensor_task.set_on_changed("temp", ChangedFloat(tc.set_theta_ist, prec=2).set) sensor_task.set_on_changed("temp", ChangedFloat(tc.set_theta_ist, prec=2).set)
+1
View File
@@ -6,3 +6,4 @@ from tasks.pot import PotTask
from tasks.tempctrl import TcTask from tasks.tempctrl import TcTask
from tasks.sud import SudTask from tasks.sud import SudTask
from tasks.server_log import ServerLogTask from tasks.server_log import ServerLogTask
from tasks.sud_log import SudLogTask
+22 -12
View File
@@ -34,24 +34,34 @@ class ServerLogTask(ATask):
async def on_process(self): async def on_process(self):
while True: while True:
power_set = max(self.heater_task.power_soll, self.heater_task.power_actor) if self._should_sample():
self._samples.append({ self._samples.append(self._take_sample())
'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,
})
await asyncio.sleep(self.interval) 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): def write(self):
if not self._samples: if not self._samples:
return return
os.makedirs(self.path, exist_ok=True) os.makedirs(self.path, exist_ok=True)
filename = 'log_{}.json'.format(self._run_id) filename = self._filename()
path = os.path.join(self.path, filename) path = os.path.join(self.path, filename)
log_data = {'Name': ''} log_data = {'Name': ''}
if self.dt is not None: if self.dt is not None:
+42
View File
@@ -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 = []