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:
2026-06-22 17:44:08 +02:00
co-authored by Claude Sonnet 4.6
parent 031b13ca2c
commit c1f4bac3f2
3 changed files with 127 additions and 2 deletions
+7 -2
View File
@@ -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")))
+1
View File
@@ -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 *
+119
View File
@@ -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)