Files
brewpi/tasks/sud_log.py
T
jensandClaude Sonnet 5 da7e954b7c fix: make sim_warp_factor real-hardware-safe and sud logs forecast-reproducible
sim_warp_factor was scaling DT_TASK unconditionally, including against real
hardware, and was leaking into recorded sample timestamps via wall-clock
time - both violate its intended meaning (a reciprocal scale on task wait
time only). Clamp it to 1.0 whenever Heater.type isn't "sim", and track
elapsed time via tick-counted accumulators (Sud.elapsed for SudLogTask, a
matching one for ServerLogTask) instead of time.monotonic().

Sud/server logs now also carry HeaterPowers, the effective SimWarpFactor,
the raw Doc, a correct Name, and ForecastAnchors (one entry per real
_reanchor_forecast() trigger) - enough for utils/analyze_log.py to rebuild
a SudForecastEstimator and replay the exact same splice-per-transition
forecast correction offline that a live client sees, replacing the old
(always-dead) forecast_*.json sidecar lookup.

Two bugs found and fixed along the way:
- ServerLogTask's periodic wall-clock checkpoint had no way to know a
  SudLogTask run had just ended, so it would immediately re-write the
  just-completed log with PlantParams/ForecastAnchors cleared back to
  empty. Gated the checkpoint on _should_sample().
- _reanchor_forecast() truncated the old forecast against real elapsed
  time, which can end up numerically less than the old forecast's own
  (very wrong) speculative timestamps once a WAIT_USER confirmation runs
  long - leaving a "ghost" segment instead of a clean cut. Truncate
  against forecast_step_starts[index] instead, which lives in the same
  coordinate space as the forecast being cut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDeoTKBDkXRhPfnyDEoBt
2026-07-01 19:50:50 +02:00

80 lines
2.8 KiB
Python

import time
from tasks.server_log import ServerLogTask
from components import APid, AHeater
def _safe_filename_part(text):
return "".join(c if c.isalnum() or c in "-_" else "_" for c in text) or "Sud"
class SudLogTask(ServerLogTask):
"""Records temp/power samples for a single Sud run - same sample format
as ServerLogTask, but only while a run is active: recording starts on
Play and is written out on Stop (or natural completion), to
logs/log_{date_time}_{sud_name}.log. A Pause/resume doesn't start a new
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, log_interval=None, sim_warp_factor=None):
ServerLogTask.__init__(self, tc, heater, heater_task, interval, path, config, dt, log_interval, sim_warp_factor)
self.sud = sud
self._active = False
self._doc = None
self._reanchors = []
def _should_sample(self):
return self._active
def _filename(self):
name_part = _safe_filename_part(self.sud.name or 'Sud')
return 'log_{}_{}.log'.format(self._run_id, name_part)
def _log_name(self):
return self.sud.name
def _elapsed(self):
# The Sud's own tick-counted ground truth (see components/sud.py's
# Sud.elapsed) rather than the base class's own tick counter -
# already resets to 0 on every fresh Play (Sud.start()), so samples
# line up with SudForecastEstimator's t=0 the same way Sud.elapsed
# does everywhere else it's consumed (e.g. SudTask's forecast
# re-anchoring).
return self.sud.elapsed
def log_reanchor(self, elapsed, index, theta_ist):
# One entry per real _reanchor_forecast() trigger (see SudTask.
# set_on_reanchor()) - lets utils/analyze_log.py replay the exact
# same splice-per-transition forecast correction a live client
# would have seen, instead of just the single, uncorrected
# cold-start estimate() run - see build_forecast() there.
self._reanchors.append({'t': elapsed, 'index': index, 'theta_ist': theta_ist})
def _extra_log_data(self):
# The raw sud.json doc this run was playing, exactly as loaded (see
# Sud.save()) - lets utils/analyze_log.py re-simulate this run's own
# SudForecastEstimator forecast for comparison against Samples,
# without depending on the sude/*.json file it came from still
# existing or being unchanged.
data = {}
if self._doc is not None:
data['Doc'] = self._doc
if self._reanchors:
data['ForecastAnchors'] = self._reanchors
return data
def start_run(self):
if self._active:
return
self._samples = []
self._last_write = time.monotonic()
self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime())
self._doc = self.sud.save()
self._active = True
def stop_run(self):
if not self._active:
return
self._active = False
self.write()
self._param_events = []
self._reanchors = []