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:
2026-07-01 19:50:50 +02:00
co-authored by Claude Sonnet 5
parent 27317d9efd
commit da7e954b7c
6 changed files with 268 additions and 37 deletions
+10 -4
View File
@@ -76,9 +76,14 @@ if __name__ == '__main__':
taskmgr = TaskManager()
DT = args.dt
DT_TASK = 1.0 / args.sim_warp_factor
theta_amb = config['ambient_temperature']
plant_sim = config['Heater'].get('type', 'sim') == 'sim'
# sim_warp_factor only reciprocally scales *wait* time between task
# ticks (DT_TASK) - it has no meaning against real hardware, which
# ticks at its own physical pace regardless of how this flag is set,
# so real runs are always paced at DT_TASK == 1.0 (true real time).
sim_warp_factor = args.sim_warp_factor if plant_sim else 1.0
DT_TASK = 1.0 / sim_warp_factor
# Mash schedule - starts out empty; a client loads one of sude/*.json's
# several schedules onto it later (see components/sud.py's Sud).
@@ -127,7 +132,7 @@ if __name__ == '__main__':
# see components/sud_forecast.py.
forecast_estimator = SudForecastEstimator(
DT, theta_amb, config['TempCtrl']['pid_type'], config['TempCtrl'],
heater.get_powers(), args.sim_warp_factor, config.get('Pot', {}))
heater.get_powers(), sim_warp_factor, config.get('Pot', {}))
sud_task = SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"), forecast_estimator)
taskmgr.add(sud_task)
@@ -141,7 +146,7 @@ if __name__ == '__main__':
# Continuous server-session log - records from startup to shutdown
# regardless of Sud state; written to logs/log_{date_time}.json on exit
# 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)
server_log_task = ServerLogTask(tc, heater, heater_task, DT_TASK, args.logdir, config=config, dt=DT, log_interval=log_interval, sim_warp_factor=sim_warp_factor)
if startup_params is not None:
server_log_task.log_plant_params(0, startup_params)
taskmgr.add(server_log_task)
@@ -149,12 +154,13 @@ if __name__ == '__main__':
# 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) and
# 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)
sud_log_task = SudLogTask(tc, heater, heater_task, sud, DT_TASK, args.logdir, config=config, dt=DT, log_interval=log_interval, sim_warp_factor=sim_warp_factor)
taskmgr.add(sud_log_task)
sud_task.set_on_plant_params(lambda elapsed, params: (
server_log_task.log_plant_params(elapsed, params),
sud_log_task.log_plant_params(elapsed, params)))
sud_task.set_on_reanchor(sud_log_task.log_reanchor)
# Assign data flow
# Assign tc control value to heater