diff --git a/server/brewpi.py b/server/brewpi.py index 3891037..9b9cb57 100755 --- a/server/brewpi.py +++ b/server/brewpi.py @@ -13,7 +13,7 @@ from components.plant import Pot from components.actor import HeaterFactory, StirrerFactory from components.sud import Sud from components.sud_forecast import SudForecastEstimator -from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, TracerTask, SudTask +from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, TracerTask, SudTask, SudLogTask from tracer import Tracer import argparse as ap @@ -109,7 +109,12 @@ if __name__ == '__main__': # see components/sud_forecast.py. forecast_estimator = SudForecastEstimator( DT, theta_amb, DEFAULT_PLANT_PARAMS, config['Controller']['pid_type'], config['TempCtrl'], heater.get_power_max()) - taskmgr.add(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) + # 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. + taskmgr.add(SudLogTask(sud, tc, heater, heater_task, sud_task, DT_TASK)) # Tracer taskmgr.add(TracerTask(sensor, heater, tc, trace_tc, DT_TASK_TRACER, dispatcher.msgio_get("Tracer"))) diff --git a/tasks/__init__.py b/tasks/__init__.py index c0c8578..0179870 100644 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -6,3 +6,4 @@ from tasks.pot import * from tasks.tempctrl import * from tasks.tracer import * from tasks.sud import * +from tasks.sud_log import * diff --git a/tasks/sud_log.py b/tasks/sud_log.py new file mode 100644 index 0000000..212003f --- /dev/null +++ b/tasks/sud_log.py @@ -0,0 +1,119 @@ +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, not the + ever-growing, continuously-rewritten .mat tasks/tracer.py's + TracerTask already produces independently of any Sud run. + + 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._continue_forecast_after_confirm()), 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'): + 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 + # None while no run is in progress - see on_sud_state_changed(). + self._samples = 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._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._run_id = None + + def _write(self): + if not self._samples: + return + os.makedirs(self.path, exist_ok=True) + name_part = _safe_filename_part(self.sud.name) + + samples_filename = "log_{}_{}.json".format(self._run_id, name_part) + with open(os.path.join(self.path, samples_filename), "w") as f: + json.dump({'Name': self.sud.name, 'Samples': self._samples}, 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)