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:
+35
-4
@@ -113,6 +113,7 @@ class SudTask(ATask):
|
||||
self._on_end = None
|
||||
self._on_start = None
|
||||
self._on_plant_params = None
|
||||
self._on_reanchor = None
|
||||
msg_handler.set_recv_handler(self.recv)
|
||||
|
||||
def set_on_plant_params(self, callback):
|
||||
@@ -120,6 +121,16 @@ class SudTask(ATask):
|
||||
so external observers (e.g. SudLogTask) can record each change."""
|
||||
self._on_plant_params = callback
|
||||
|
||||
def set_on_reanchor(self, callback):
|
||||
"""Register a callback invoked with (elapsed, index, theta_ist)
|
||||
every time _reanchor_forecast() fires (see on_step_changed()) -
|
||||
lets an external observer (SudLogTask) record the exact anchor
|
||||
points a live client's forecast got corrected at, so an offline
|
||||
re-simulation (utils/analyze_log.py) can reproduce the same
|
||||
splice-per-transition forecast instead of just the single,
|
||||
uncorrected cold-start one."""
|
||||
self._on_reanchor = callback
|
||||
|
||||
def apply_plant_params(self, grain_mass, water_mass):
|
||||
"""Keeps the real plant's and the controller's internal model's
|
||||
plant params in sync with this Sud's own doc - L/Td come straight
|
||||
@@ -215,6 +226,12 @@ class SudTask(ATask):
|
||||
# (a malt fill-in's actual cooldown, a longer/shorter ramp than
|
||||
# modeled, ...) at the next opportunity, not just at the next
|
||||
# user confirmation.
|
||||
if self._on_reanchor:
|
||||
# Read synchronously, right here - the same values
|
||||
# _reanchor_forecast() itself will read moments later, off
|
||||
# the same unyielded call stack, before anything else can
|
||||
# mutate them.
|
||||
self._on_reanchor(self.sud.elapsed, self.sud.index, self.tc.get_theta_ist())
|
||||
asyncio.create_task(self._reanchor_forecast())
|
||||
|
||||
asyncio.create_task(self.send({'Step': {
|
||||
@@ -373,16 +390,30 @@ class SudTask(ATask):
|
||||
self._forecast_generation += 1
|
||||
generation = self._forecast_generation
|
||||
real_elapsed = self.sud.elapsed
|
||||
schedule = self.sud.schedule
|
||||
index = self.sud.index
|
||||
# Drop the now-stale tail (everything beyond right now) - it's
|
||||
# about to be replaced by a freshly anchored simulation.
|
||||
cut = bisect.bisect_right(self.forecast_t, real_elapsed)
|
||||
# about to be replaced by a freshly anchored simulation. Cut at
|
||||
# forecast_step_starts[index], the old forecast's own (possibly
|
||||
# very wrong) belief of where step `index` begins - not at
|
||||
# real_elapsed directly: a step whose real timing blew way past
|
||||
# what its zero-delay guess assumed (a long WAIT_USER confirm,
|
||||
# above all - see SudForecastEstimator.estimate()'s docstring)
|
||||
# leaves the old forecast's *entire* speculative remainder sitting
|
||||
# at timestamps still numerically less than real_elapsed, so
|
||||
# bisecting against real_elapsed itself would find nothing to
|
||||
# discard and just tack the fresh, correct simulation on after it
|
||||
# - a doubled-back, self-overlapping curve instead of a clean cut.
|
||||
# forecast_step_starts[index] doesn't have this problem: it lives
|
||||
# in the same (speculative) coordinate space as forecast_t itself,
|
||||
# so the cut lands in the right place regardless of how far real
|
||||
# and speculated time have diverged by now.
|
||||
cut = bisect.bisect_right(self.forecast_t, self.forecast_step_starts.get(index, real_elapsed))
|
||||
forecast_t = self.forecast_t[:cut]
|
||||
forecast_theta = self.forecast_theta[:cut]
|
||||
# Steps already passed (< index) have their real, now-immutable
|
||||
# start time; anything from index on is about to be resimulated
|
||||
# fresh below and must not keep a stale prediction around.
|
||||
schedule = self.sud.schedule
|
||||
index = self.sud.index
|
||||
forecast_step_starts = {i: tt for i, tt in self.forecast_step_starts.items() if i < index}
|
||||
|
||||
if not (0 <= index < len(schedule)):
|
||||
|
||||
Reference in New Issue
Block a user