Log each Sud run's measured data and forecast to JSON for offline analysis
Add SudLogTask: starts a fresh in-memory buffer the moment a run actually starts (Sud.state leaves IDLE/DONE) and writes it out whole the moment it ends (DONE, or aborted via Stop) - log_<date>T<time>_<sud-name>.json holds the same six signals the GUI's Automatic tab plots live (temp/rate ist+soll, power set+eff) with per-sample timestamps; forecast_<date>T<time>_<sud-name>.json holds the final, most-corrected forecast curve from the same run, sharing the run's date-time token so the two pair up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
This commit is contained in:
@@ -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 *
|
||||
|
||||
@@ -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_<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._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)
|
||||
Reference in New Issue
Block a user