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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
This commit is contained in:
2026-06-22 16:37:38 +02:00
co-authored by Claude Sonnet 4.6
parent 57b86bc075
commit 6e5fd23ccb
+35 -32
View File
@@ -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
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
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.
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