From 6e5fd23ccbe2fd4cbfcfa0455978738ac98c0327 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 22 Jun 2026 16:37:38 +0200 Subject: [PATCH] docs: refresh README for piecewise Sud forecasting Rewrites the "Forecast vs. actual duration" section to match the previous commit: no more naive abs(delta)/rate placeholder, no more per-tick re-anchoring of the projected remainder - the forecast is computed once, piecewise per segment, deliberately left alone as a fixed comparison baseline. Documents the Finished flag, the real-confirm-anchored segment stitching for steps requiring user confirmation, and extend_forecast_while_waiting()'s flat-repeat display in the meantime. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K --- README.md | 77 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 9b4bed3..472fb96 100644 --- a/README.md +++ b/README.md @@ -278,47 +278,50 @@ disabled, its temperature/heat-rate setpoint controls are inactive. ### Forecast vs. actual duration -The GUI's Automatic tab shows up to three time estimates for a schedule: +The GUI's Automatic tab plots two things against the same time axis: a +*forecast* (dashed) and, once a run starts, the *actual* measured trace +(solid) overlaid on top of it - a comparison between predicted and real +control behavior, not just a progress display. -- Immediately on load, a quick *naive* preview: walk the schedule assuming - every ramp instantly achieves and holds its declared `rate` - (`abs(delta)/rate`) and every hold lasts exactly its declared `duration`. - Deliberately approximate - it's just a placeholder until the next one - arrives a moment later. -- A *simulated* estimate, computed server-side (`components/sud_forecast.py`'s - `SudForecastEstimator`) by actually running the schedule through a - throwaway `Sud`/`Pot`/temperature-controller trio built from the same - params (`pid_type`, `TempCtrl` gains, plant params, ambient, heater max - power) the real server uses - a multi-hour brew simulates in well under a - second since it's pure CPU-bound iteration, no real time/IO involved. This - replaces the naive preview a moment after a schedule is loaded. -- Once running, a *dynamic* one: the already-elapsed part is the actual - measured trace, plotted against `Sud.elapsed` (see "State replay on - connect" above) rather than wall-clock time, and only the remaining steps - are re-projected from the live temperature/step/`hold_remaining` each - tick. +The forecast is computed server-side (`components/sud_forecast.py`'s +`SudForecastEstimator`) by actually running the schedule through a +throwaway `Sud`/`Pot`/temperature-controller trio built from the same +params (`pid_type`, `TempCtrl` gains, plant params, ambient, heater max +power) the real server uses - a multi-hour brew simulates in well under a +second since it's pure CPU-bound iteration, no real time/IO involved. This +is deliberately *not* recomputed as a run progresses: a forecast that keeps +re-anchoring itself to match whatever's actually happening would no longer +be a meaningful baseline to compare reality against. It's computed once, +up front, and left alone. -The naive estimate is structurally optimistic - it assumes the plant -instantly tracks the declared rate and that "reached" is instant once the -math says so, when the real PID cascade (`theta_err -> pid_hold -> -heatrate_soll -> pid_heat -> heater power -> actual heat rate`) needs real -time to spin up and settle into the controller's own `HOLD` state -(`TempControllerBase.is_holding()`, gated by the `HoldHeat`/`HoldCool` -thresholds). The simulated estimate accounts for that (it's driven by the -real controller dynamics), so it's normally -within a few percent of how the dynamic one settles once a run actually -finishes - if the two diverge by far more than that, suspect a real control -issue rather than a forecasting one (see the `HoldCool` threshold note in +The one exception is a step with `user_wait_for_continue`: a human's +response time genuinely can't be forecast, so `SudForecastEstimator. +estimate()` simply stops simulating there instead of assuming a delay (the +old behavior - zero delay - quietly understated the schedule). The +forecast is therefore piecewise: `tasks/sud.py`'s `SudTask` sends the first +segment (up to the first such step, or the end of the schedule if there is +none) on Load, marking it `Finished: false` if it stopped early. The *next* +segment is only computed once the real confirmation actually happens, +anchored at the real elapsed time and real current temperature at that +moment - so the unforecastable wait shows up honestly as a gap in the +timeline rather than as a guess - and appended to what's already shown. +While waiting, the GUI just repeats the forecast's last value out to "now" +(`SudForecastPlot.extend_forecast_while_waiting()`) rather than leaving a +visual gap, then snaps to the new segment the moment it arrives. + +Comparing the two lines: real-world divergence from the forecast - more +than a transient blip during a ramp's settling - is worth investigating as +an actual control issue (see the `HoldCool` threshold note in `components/pid/temp_controller_base.py` - too tight a threshold there once caused exactly this kind of large, otherwise-unexplained gap, by making the -controller chatter in and out of `COOL` and never actually converge). Rule -out a *display* artifact first, though: the dynamic trace used to position -itself using wall-clock time times the configured `sim_warp_factor`, but -that factor is only nominal - real asyncio/Python scheduling overhead means -the actually-achieved speedup runs measurably below it (confirmed: ~80x -instead of a configured 100x), which alone produced a divergence large -enough to look like a control problem. `Sud.elapsed` (tick-counted, immune -to wall-clock pacing) replaced that reconstruction for exactly this reason. +controller chatter in and out of `COOL` and never actually converge). The +GUI plots the actual trace using `Sud.elapsed` (tick-counted simulated +seconds, see "State replay on connect" above) rather than wall-clock time +times the configured `sim_warp_factor` - the latter is only nominal, and +real asyncio/Python scheduling overhead means the actually-achieved +speedup runs measurably below it (confirmed: ~80x instead of a configured +100x), which on its own used to produce a divergence large enough to look +like a control problem. ## Logging & analysis