feat: periodically checkpoint server/sud logs to disk

Both loggers previously only wrote their JSON log on their end trigger
(shutdown / Stop-or-DONE), losing the whole session/run's data on a
hard crash or power loss. Add a log_interval (config.json, default
300s) that rewrites the same file whole every N seconds in between.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
This commit is contained in:
2026-07-01 09:44:11 +02:00
co-authored by Claude Sonnet 5
parent d3c8a47562
commit 0e60860cf0
6 changed files with 38 additions and 15 deletions
+10 -8
View File
@@ -689,20 +689,22 @@ Both loggers record the same six signals the GUI's Automatic tab plots live
(`temp_ist`/`temp_soll`, `rate_ist`/`rate_soll`, `power_set`/`power_eff`), (`temp_ist`/`temp_soll`, `rate_ist`/`rate_soll`, `power_set`/`power_eff`),
each sample with both simulated-elapsed-seconds and a real wall-clock each sample with both simulated-elapsed-seconds and a real wall-clock
timestamp, plus any plant-param changes along the way (see timestamp, plus any plant-param changes along the way (see
`apply_plant_params()`) - written as JSON via `write()`, whole and only `apply_plant_params()`) - written as JSON via `write()`, always the whole
once, never partially mid-run. accumulated sample list rewritten to the same file, never appended.
`tasks/server_log.py`'s `ServerLogTask` records continuously for the whole `tasks/server_log.py`'s `ServerLogTask` records continuously for the whole
server session, regardless of Sud state - useful for verifying controller server session, regardless of Sud state - useful for verifying controller
and heater behaviour outside of a scheduled brew. It starts on construction and heater behaviour outside of a scheduled brew. It starts on construction
and is written to `logs/log_<date>T<time>.json` on shutdown. and is written to `logs/log_<date>T<time>.json` on shutdown, and again
every `log_interval` seconds (`config.json`, default 300) in between so a
hard crash or power loss only loses up to that much data.
`tasks/sud_log.py`'s `SudLogTask` subclasses `ServerLogTask` to reuse the `tasks/sud_log.py`'s `SudLogTask` subclasses `ServerLogTask` to reuse the
same sample format, but only records while a Sud run is actually active. A same sample format and `log_interval` checkpointing, but only records while
fresh run starts recording on a genuine `Start` from `IDLE`/`DONE` (a a Sud run is actually active. A fresh run starts recording on a genuine
`Pause`→resume `Start` is a no-op - it's still the same run) and is written `Start` from `IDLE`/`DONE` (a `Pause`→resume `Start` is a no-op - it's still
out on `Stop` or natural completion (`DONE`), to the same run) and is written out on `Stop` or natural completion (`DONE`),
`logs/log_<date>T<time>_<sud-name>.log`. to `logs/log_<date>T<time>_<sud-name>.log`.
`server/brewpi.py` also mirrors everything it prints (every component's `server/brewpi.py` also mirrors everything it prints (every component's
`print()`-based status/debug output) to a plain text log, `print()`-based status/debug output) to a plain text log,
+1
View File
@@ -1,5 +1,6 @@
{ {
"ambient_temperature": 20, "ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": { "TempCtrl": {
"pid_type": "Smith", "pid_type": "Smith",
"beta": 0.05, "beta": 0.05,
+1
View File
@@ -1,5 +1,6 @@
{ {
"ambient_temperature": 20, "ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": { "TempCtrl": {
"pid_type": "Smith", "pid_type": "Smith",
"beta": 0.05, "beta": 0.05,
+13 -4
View File
@@ -131,16 +131,25 @@ if __name__ == '__main__':
sud_task = 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) taskmgr.add(sud_task)
# How often the server/sud logs checkpoint themselves to disk (in
# addition to always writing on their own end trigger) - so a hard
# crash/power loss only loses up to this much data. Config-only (no CLI
# flag): it's a plant/deployment concern like the rest of config.json,
# not a per-invocation run-mode knob.
log_interval = config.get('log_interval', 300)
# Continuous server-session log - records from startup to shutdown # Continuous server-session log - records from startup to shutdown
# regardless of Sud state; written to logs/log_{date_time}.json on exit. # 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, dt=DT) # and every log_interval seconds in between.
server_log_task = ServerLogTask(tc, heater, heater_task, DT_TASK, args.logdir, config=config, dt=DT, log_interval=log_interval)
if startup_params is not None: if startup_params is not None:
server_log_task.log_plant_params(0, startup_params) server_log_task.log_plant_params(0, startup_params)
taskmgr.add(server_log_task) taskmgr.add(server_log_task)
# Per-run log - only records while a Sud is actually playing; written to # Per-run log - only records while a Sud is actually playing; written to
# logs/log_{date_time}_{sud_name}.log on Stop (or natural completion). # logs/log_{date_time}_{sud_name}.log on Stop (or natural completion) and
sud_log_task = SudLogTask(tc, heater, heater_task, sud, DT_TASK, args.logdir, config=config, dt=DT) # every log_interval seconds in between.
sud_log_task = SudLogTask(tc, heater, heater_task, sud, DT_TASK, args.logdir, config=config, dt=DT, log_interval=log_interval)
taskmgr.add(sud_log_task) taskmgr.add(sud_log_task)
sud_task.set_on_plant_params(lambda elapsed, params: ( sud_task.set_on_plant_params(lambda elapsed, params: (
+10 -1
View File
@@ -16,7 +16,7 @@ class ServerLogTask(ATask):
Sud state — useful for verifying controller and heater behaviour Sud state — useful for verifying controller and heater behaviour
outside of a scheduled brew.""" outside of a scheduled brew."""
def __init__(self, tc: APid, heater: AHeater, heater_task, interval, path='./logs', config=None, dt=None): def __init__(self, tc: APid, heater: AHeater, heater_task, interval, path='./logs', config=None, dt=None, log_interval=None):
ATask.__init__(self, interval) ATask.__init__(self, interval)
self.tc = tc self.tc = tc
self.heater = heater self.heater = heater
@@ -24,10 +24,12 @@ class ServerLogTask(ATask):
self.path = path self.path = path
self.config = config self.config = config
self.dt = dt self.dt = dt
self.log_interval = log_interval
self._samples = [] self._samples = []
self._param_events = [] self._param_events = []
self._t0 = time.monotonic() self._t0 = time.monotonic()
self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime()) self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime())
self._last_write = self._t0
def log_plant_params(self, elapsed, params): def log_plant_params(self, elapsed, params):
self._param_events.append({'t': time.monotonic() - self._t0, 'params': params}) self._param_events.append({'t': time.monotonic() - self._t0, 'params': params})
@@ -36,6 +38,13 @@ class ServerLogTask(ATask):
while True: while True:
if self._should_sample(): if self._should_sample():
self._samples.append(self._take_sample()) self._samples.append(self._take_sample())
# Periodically checkpoints to disk (same file, overwritten whole
# each time - see write()) so a hard crash/power loss only loses
# up to log_interval seconds, not the whole session/run.
now = time.monotonic()
if self.log_interval and now - self._last_write >= self.log_interval:
self.write()
self._last_write = now
await asyncio.sleep(self.interval) await asyncio.sleep(self.interval)
def _should_sample(self): def _should_sample(self):
+3 -2
View File
@@ -14,8 +14,8 @@ class SudLogTask(ServerLogTask):
logs/log_{date_time}_{sud_name}.log. A Pause/resume doesn't start a new logs/log_{date_time}_{sud_name}.log. A Pause/resume doesn't start a new
file - only a fresh Play (from IDLE/DONE) does.""" file - only a fresh Play (from IDLE/DONE) does."""
def __init__(self, tc: APid, heater: AHeater, heater_task, sud, interval, path='./logs', config=None, dt=None): def __init__(self, tc: APid, heater: AHeater, heater_task, sud, interval, path='./logs', config=None, dt=None, log_interval=None):
ServerLogTask.__init__(self, tc, heater, heater_task, interval, path, config, dt) ServerLogTask.__init__(self, tc, heater, heater_task, interval, path, config, dt, log_interval)
self.sud = sud self.sud = sud
self._active = False self._active = False
@@ -31,6 +31,7 @@ class SudLogTask(ServerLogTask):
return return
self._samples = [] self._samples = []
self._t0 = time.monotonic() self._t0 = time.monotonic()
self._last_write = self._t0
self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime()) self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime())
self._active = True self._active = True