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
This commit is contained in:
+50
-8
@@ -16,7 +16,7 @@ class ServerLogTask(ATask):
|
||||
Sud state — useful for verifying controller and heater behaviour
|
||||
outside of a scheduled brew."""
|
||||
|
||||
def __init__(self, tc: APid, heater: AHeater, heater_task, interval, path='./logs', config=None, dt=None, log_interval=None):
|
||||
def __init__(self, tc: APid, heater: AHeater, heater_task, interval, path='./logs', config=None, dt=None, log_interval=None, sim_warp_factor=None):
|
||||
ATask.__init__(self, interval)
|
||||
self.tc = tc
|
||||
self.heater = heater
|
||||
@@ -25,14 +25,15 @@ class ServerLogTask(ATask):
|
||||
self.config = config
|
||||
self.dt = dt
|
||||
self.log_interval = log_interval
|
||||
self.sim_warp_factor = sim_warp_factor
|
||||
self._samples = []
|
||||
self._param_events = []
|
||||
self._t0 = time.monotonic()
|
||||
self._elapsed_total = 0.0
|
||||
self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime())
|
||||
self._last_write = self._t0
|
||||
self._last_write = time.monotonic()
|
||||
|
||||
def log_plant_params(self, elapsed, params):
|
||||
self._param_events.append({'t': time.monotonic() - self._t0, 'params': params})
|
||||
self._param_events.append({'t': elapsed, 'params': params})
|
||||
|
||||
async def on_process(self):
|
||||
while True:
|
||||
@@ -40,20 +41,42 @@ class ServerLogTask(ATask):
|
||||
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.
|
||||
# up to log_interval seconds, not the whole session/run. This
|
||||
# checkpoint cadence is deliberately real (wall-clock) time,
|
||||
# unlike _elapsed() below - it's bounding real data-loss risk,
|
||||
# not tracking brew progress.
|
||||
now = time.monotonic()
|
||||
if self.log_interval and now - self._last_write >= self.log_interval:
|
||||
# Gated on _should_sample(): for SudLogTask, a completed run's
|
||||
# stop_run() already wrote the correct final state and cleared
|
||||
# _param_events for the *next* run - an unguarded checkpoint
|
||||
# here would otherwise fire on the very next iteration (once
|
||||
# log_interval real seconds have passed since the last one,
|
||||
# regardless of the run having ended in between) and clobber
|
||||
# that already-correct file with _samples unchanged but
|
||||
# _param_events now empty.
|
||||
if self.log_interval and self._should_sample() and now - self._last_write >= self.log_interval:
|
||||
self.write()
|
||||
self._last_write = now
|
||||
await asyncio.sleep(self.interval)
|
||||
self._elapsed_total += self.dt
|
||||
|
||||
def _should_sample(self):
|
||||
return True
|
||||
|
||||
def _elapsed(self):
|
||||
"""Simulated seconds elapsed since this task started sampling -
|
||||
tick-counted (advances by self.dt once per on_process() iteration),
|
||||
never derived from wall-clock time: under sim_warp_factor, real and
|
||||
simulated time diverge (see components/sud.py's Sud.elapsed, and
|
||||
the README's "Forecast vs. actual duration" section, for the same
|
||||
reasoning), so a wall-clock 't' would desync from the forecast's
|
||||
own tick-counted timeline it's meant to be compared against."""
|
||||
return self._elapsed_total
|
||||
|
||||
def _take_sample(self):
|
||||
power_set = max(self.heater_task.power_soll, self.heater_task.power_actor)
|
||||
return {
|
||||
't': time.monotonic() - self._t0,
|
||||
't': self._elapsed(),
|
||||
'timestamp': time.time(),
|
||||
'temp_ist': self.tc.theta_ist,
|
||||
'temp_soll': self.tc.theta_soll_set,
|
||||
@@ -66,19 +89,38 @@ class ServerLogTask(ATask):
|
||||
def _filename(self):
|
||||
return 'log_{}.json'.format(self._run_id)
|
||||
|
||||
def _extra_log_data(self):
|
||||
"""Hook for subclasses (SudLogTask) to contribute additional keys
|
||||
to write()'s output - see there."""
|
||||
return {}
|
||||
|
||||
def _log_name(self):
|
||||
"""Hook for subclasses (SudLogTask) - a server-session log covers
|
||||
the whole process lifetime rather than one named run, so it has
|
||||
none of its own."""
|
||||
return ''
|
||||
|
||||
def write(self):
|
||||
if not self._samples:
|
||||
return
|
||||
os.makedirs(self.path, exist_ok=True)
|
||||
filename = self._filename()
|
||||
path = os.path.join(self.path, filename)
|
||||
log_data = {'Name': ''}
|
||||
log_data = {'Name': self._log_name()}
|
||||
if self.dt is not None:
|
||||
log_data['dt'] = self.dt
|
||||
if self.sim_warp_factor is not None:
|
||||
log_data['SimWarpFactor'] = self.sim_warp_factor
|
||||
if self.config is not None:
|
||||
log_data['Config'] = self.config
|
||||
# Queried live from the connected device rather than reconstructed
|
||||
# offline (utils/analyze_log.py has no hardware to ask) - needed to
|
||||
# reproduce this run's SudForecastEstimator (see components/
|
||||
# sud_forecast.py) for a post-hoc forecast-vs-actual comparison.
|
||||
log_data['HeaterPowers'] = self.heater.get_powers()
|
||||
if self._param_events:
|
||||
log_data['PlantParams'] = self._param_events
|
||||
log_data.update(self._extra_log_data())
|
||||
log_data['Samples'] = self._samples
|
||||
with open(path, 'w') as f:
|
||||
json.dump(log_data, f, indent='\t')
|
||||
|
||||
Reference in New Issue
Block a user