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:
+2
-7
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
|
||||||
Reference in New Issue
Block a user