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:
+79
-10
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user