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'): 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)