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
+79 -10
View File
@@ -5,16 +5,19 @@ Usage:
python analyze_log.py <date_time> <sud_name> [--log-dir <dir>]
python analyze_log.py <path/to/log_*.json>
The two-argument form loads logs/<date_time>_<sud_name>.json and its paired
forecast file. The single-argument form accepts a direct path to any
log_*.json (e.g. a server-session log without a Sud name); the forecast panel
is shown only if a matching forecast_*.json exists alongside it.
The two-argument form loads logs/<date_time>_<sud_name>.json; the
single-argument form accepts a direct path to any log_*.json (e.g. a
server-session log without a Sud name). The forecast-vs-actual panel is
shown only for a Sud log (one carrying a 'Doc', 'Config' and 'HeaterPowers'
- see tasks/sud_log.py), by re-simulating that run's own schedule with
SudForecastEstimator, the same as the live server does at Play.
Renders the 3-panel realtime strip chart and, when available, the
forecast-vs-actual comparison.
"""
import argparse
import bisect
import json
import sys
from pathlib import Path
@@ -24,6 +27,11 @@ matplotlib.use("TkAgg")
import matplotlib.pyplot as plt
from matplotlib.ticker import AutoMinorLocator
sys.path.insert(0, str(Path(__file__).parent.parent))
from components.sud import SudState
from components.sud_forecast import SudForecastEstimator
def _style_axis(ax):
ax.set_facecolor('white')
@@ -102,6 +110,70 @@ def plot_forecast(forecast, samples, name):
return fig
def build_forecast(log):
"""Re-simulates the schedule a Sud log's own 'Doc' recorded, the same
way the live server does at Play (see components/sud_forecast.py) -
returns a {'T', 'Theta', 'Finished'} dict matching plot_forecast()'s
expected shape, or None if log isn't a Sud log (no 'Doc'/'Config'/
'HeaterPowers' - e.g. a server-session log with no run playing).
Replays every real _reanchor_forecast() trigger recorded in the log's
'ForecastAnchors' (see tasks/sud.py's SudTask.set_on_reanchor()), in
order, splicing a freshly anchored simulation of the remaining
schedule onto the truncated forecast at each one - the exact same
correction a live client's forecast plot goes through as a run
progresses. Without this, the single cold-start estimate() below is
all a client ever saw at Load - it can't have anticipated things like
a WAIT_USER confirmation's real timing (estimate() can only ever model
that as zero-delay - see its own docstring), so a plot comparing it
straight against Samples would show a small, spurious gap at every
such step that isn't actually a forecast error."""
if 'Doc' not in log or 'Config' not in log or 'HeaterPowers' not in log:
return None
doc = log['Doc']
config = log['Config']
tempctrl = config['TempCtrl']
# Old logs predate 'SimWarpFactor' being recorded - 1.0 (real time) is
# the right fallback for those, since it's also what every real-hardware
# run already used (see server/brewpi.py's plant_sim check).
estimator = SudForecastEstimator(
log['dt'], config['ambient_temperature'], tempctrl['pid_type'], tempctrl,
log['HeaterPowers'], log.get('SimWarpFactor', 1.0), config.get('Pot', {}))
t, theta, state, step_starts = estimator.estimate(doc)
finished = (state == SudState.DONE)
for anchor in log.get('ForecastAnchors', []):
real_elapsed = anchor['t']
index = anchor['index']
if not (0 <= index < len(doc['steps'])):
continue
# Cut at 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 its zero-delay
# guess (a long WAIT_USER confirm, above all) 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 - see tasks/sud.py's
# SudTask._reanchor_forecast(), which this mirrors exactly.
cut = bisect.bisect_right(t, step_starts.get(index, real_elapsed))
t, theta = t[:cut], theta[:cut]
step_starts = {i: tt for i, tt in step_starts.items() if i < index}
sub_doc = {**doc, 'steps': doc['steps'][index:]}
sub_t, sub_theta, sub_state, sub_step_starts = estimator.estimate(sub_doc, anchor['theta_ist'])
if t and t[-1] < real_elapsed:
t.append(real_elapsed)
theta.append(anchor['theta_ist'])
t.extend(real_elapsed + seconds for seconds in sub_t)
theta.extend(sub_theta)
step_starts.update((index + local_index, real_elapsed + local_t)
for local_index, local_t in sub_step_starts.items())
finished = (sub_state == SudState.DONE)
return {'T': t, 'Theta': theta, 'Finished': finished}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('date_time_or_path',
@@ -118,12 +190,10 @@ def main():
if args.sud_name is None:
log_path = Path(args.date_time_or_path)
stem = log_path.stem[len('log_'):] if log_path.stem.startswith('log_') else log_path.stem
forecast_path = log_path.parent / 'forecast_{}.json'.format(stem)
else:
log_dir = Path(args.log_dir)
stem = '{}_{}'.format(args.date_time_or_path, args.sud_name)
log_path = log_dir / 'log_{}.json'.format(stem)
forecast_path = log_dir / 'forecast_{}.json'.format(stem)
log_path = log_dir / 'log_{}.json'.format(stem)
if not log_path.exists():
print('Error: file not found: {}'.format(log_path), file=sys.stderr)
@@ -136,9 +206,8 @@ def main():
samples = log['Samples']
plot_realtime(samples, name)
if forecast_path.exists():
with open(forecast_path) as f:
forecast = json.load(f)
forecast = build_forecast(log)
if forecast is not None:
plot_forecast(forecast, samples, name)
plt.show()