diff --git a/server/brewpi.py b/server/brewpi.py index fda4d34..0d25d2d 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, SudLogTask, ServerLogTask +from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, SudTask, ServerLogTask import argparse as ap @@ -128,16 +128,11 @@ if __name__ == '__main__': 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) 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 # 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) + sud_task.set_on_plant_params(server_log_task.log_plant_params) taskmgr.add(server_log_task) # Assign data flow diff --git a/tasks/__init__.py b/tasks/__init__.py index 281e094..25541ad 100644 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -5,5 +5,4 @@ from tasks.stirrer import StirrerTask from tasks.pot import PotTask from tasks.tempctrl import TcTask from tasks.sud import SudTask -from tasks.sud_log import SudLogTask from tasks.server_log import ServerLogTask diff --git a/tasks/server_log.py b/tasks/server_log.py index f5b1ed5..29afd88 100644 --- a/tasks/server_log.py +++ b/tasks/server_log.py @@ -24,9 +24,13 @@ class ServerLogTask(ATask): self.path = path self.config = config self._samples = [] + self._param_events = [] self._t0 = time.monotonic() 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): while True: power_set = max(self.heater_task.power_soll, self.heater_task.power_actor) @@ -51,6 +55,8 @@ class ServerLogTask(ATask): log_data = {'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 with open(path, 'w') as f: json.dump(log_data, f, indent='\t') diff --git a/tasks/sud_log.py b/tasks/sud_log.py deleted file mode 100644 index 40ddf27..0000000 --- a/tasks/sud_log.py +++ /dev/null @@ -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__.json - # and forecast__.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)