refactor: replace SudLogTask with ServerLogTask for all logging

SudLogTask is removed; ServerLogTask now covers the full server session
and gains PlantParams tracking (log_plant_params callback wired from
SudTask.set_on_plant_params) so step transitions are still captured in
the log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 23:22:11 +02:00
co-authored by Claude Sonnet 4.6
parent dc687c3559
commit 4a1c969ad0
4 changed files with 8 additions and 148 deletions
+2 -7
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, SudLogTask, ServerLogTask from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, SudTask, ServerLogTask
import argparse as ap import argparse as ap
@@ -128,16 +128,11 @@ if __name__ == '__main__':
heater.get_powers(), args.sim_warp_factor, config.get('Pot', {})) 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
# JSON files under logs/, for later offline analysis - see
# tasks/sud_log.py.
sud_log_task = SudLogTask(sud, tc, heater, heater_task, sud_task, DT_TASK, config=config)
sud_task.set_on_plant_params(sud_log_task.log_plant_params)
taskmgr.add(sud_log_task)
# 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) server_log_task = ServerLogTask(tc, heater, heater_task, DT_TASK, args.logdir, config=config)
sud_task.set_on_plant_params(server_log_task.log_plant_params)
taskmgr.add(server_log_task) taskmgr.add(server_log_task)
# Assign data flow # Assign data flow
-1
View File
@@ -5,5 +5,4 @@ from tasks.stirrer import StirrerTask
from tasks.pot import PotTask 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.sud_log import SudLogTask
from tasks.server_log import ServerLogTask from tasks.server_log import ServerLogTask
+6
View File
@@ -24,9 +24,13 @@ class ServerLogTask(ATask):
self.path = path self.path = path
self.config = config self.config = config
self._samples = [] self._samples = []
self._param_events = []
self._t0 = time.monotonic() self._t0 = time.monotonic()
self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime()) self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime())
def log_plant_params(self, elapsed, params):
self._param_events.append({'t': elapsed, 'params': params})
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) power_set = max(self.heater_task.power_soll, self.heater_task.power_actor)
@@ -51,6 +55,8 @@ class ServerLogTask(ATask):
log_data = {'Name': ''} log_data = {'Name': ''}
if self.config is not None: if self.config is not None:
log_data['Config'] = self.config log_data['Config'] = self.config
if self._param_events:
log_data['PlantParams'] = self._param_events
log_data['Samples'] = self._samples log_data['Samples'] = self._samples
with open(path, 'w') as f: with open(path, 'w') as f:
json.dump(log_data, f, indent='\t') json.dump(log_data, f, indent='\t')
-140
View File
@@ -1,140 +0,0 @@
import asyncio
import json
import os
import time
from tasks import ATask
from components import APid, AHeater
from components.sud import Sud, SudState
def _safe_filename_part(text):
"""Sanitizes text for use in a log filename - sude/*.json names are
short codes (e.g. "Sud-0010") in practice, but nothing stops a
client from loading a schedule with spaces, slashes, etc. in its
Name."""
return "".join(c if c.isalnum() or c in "-_" else "_" for c in text) or "Sud"
class SudLogTask(ATask):
"""Records every sample of the same data the GUI's Automatic tab
plots live (temp_ist/temp_soll, rate_ist/rate_soll, power_set/
power_eff - see client/brewpi_gui.py's PlotCanvas.sample()), plus
the forecast curve it's compared against (tasks/sud.py's SudTask.
forecast_t/forecast_theta), for the whole duration of a Sud run -
for later offline analysis. One pair of JSON files per run, scoped
to that run alone - unlike a continuously-rewritten trace file that
keeps growing independently of whether a Sud run is even in
progress.
A fresh, uniquely-named pair of logs starts the moment a run
actually starts (Sud.state leaves IDLE/DONE) and is written out
whole the moment it ends (state reaches DONE, or is aborted back to
IDLE via Stop) - never partially mid-run, since a half-written file
is of no use before the run is over anyway. The forecast file is
written from SudTask.forecast_t/forecast_theta as they stand at
that point - the *final*, most-corrected version (see tasks/sud.py's
SudTask._reanchor_forecast()), not the optimistic guess computed
back at Load.
Samples are taken once per interval regardless of Sud.state
(RAMPING/HOLDING/WAIT_USER/PAUSED all keep recording, mirroring
SudTask.on_state_changed()'s "any of these means a run is in
progress" - a paused run's temperature is still worth capturing,
and the real controller keeps actively driving toward its target
throughout WAIT_USER too)."""
def __init__(self, sud: Sud, tc: APid, heater: AHeater, heater_task, sud_task, interval, path='./logs', config=None):
ATask.__init__(self, interval)
self.sud = sud
self.tc = tc
self.heater = heater
self.heater_task = heater_task
self.sud_task = sud_task
self.path = path
self.config = config
# None while no run is in progress - see on_sud_state_changed().
self._samples = None
# Plant param changes during a run: [{t, params}, ...] - recorded
# whenever apply_plant_params() fires (once per step transition).
self._param_events = None
# Identifies this run's pair of files (log_<run_id>_<name>.json
# and forecast_<run_id>_<name>.json) - fixed at the moment the run
# starts, not when it's written out, so the name reflects when
# the run happened rather than how long it took.
self._run_id = None
@staticmethod
def _is_running(state):
return state not in (SudState.IDLE, SudState.DONE)
def on_sud_state_changed(self, state):
was_running = self._samples is not None
if self._is_running(state) and not was_running:
self._samples = []
self._param_events = []
self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime())
elif not self._is_running(state) and was_running:
self._write()
self._samples = None
self._param_events = None
self._run_id = None
def log_plant_params(self, elapsed, params):
"""Called by SudTask.apply_plant_params() on every step transition
so the log captures which plant params were active at each point
in the run."""
if self._param_events is not None:
self._param_events.append({'t': elapsed, 'params': params})
def _write(self):
if not self._samples:
return
os.makedirs(self.path, exist_ok=True)
name_part = _safe_filename_part(self.sud.name)
log_data = {'Name': self.sud.name}
if self.config is not None:
log_data['Config'] = self.config
if self._param_events:
log_data['PlantParams'] = self._param_events
log_data['Samples'] = self._samples
samples_filename = "log_{}_{}.json".format(self._run_id, name_part)
with open(os.path.join(self.path, samples_filename), "w") as f:
json.dump(log_data, f, indent='\t')
print("Sud log: wrote {} samples to {}".format(len(self._samples), samples_filename))
forecast_filename = "forecast_{}_{}.json".format(self._run_id, name_part)
with open(os.path.join(self.path, forecast_filename), "w") as f:
json.dump({
'Name': self.sud.name,
'T': self.sud_task.forecast_t,
'Theta': self.sud_task.forecast_theta,
'Finished': self.sud_task.forecast_finished,
}, f, indent='\t')
print("Sud log: wrote forecast to {}".format(forecast_filename))
async def on_process(self):
print("SudLogTask: started with interval {} s".format(self.interval))
self.sud.set_on_changed('state', self.on_sud_state_changed)
while True:
if self._samples is not None:
# power_set mirrors the GUI's 'PowerSet' (the continuous,
# pre-PWM commanded power) - tasks/heater.py's HeaterTask
# only ever holds this combined value as a local loop
# variable, recomputed here from the same two attributes
# it derives it from, rather than restructuring that task
# just to expose it.
power_set = max(self.heater_task.power_soll, self.heater_task.power_actor)
self._samples.append({
't': self.sud.elapsed,
'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)