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
80 lines
2.8 KiB
Python
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 = []
|