Make Sud steps ramp based on the actual temperature gap, not a 'ramp' key
Previously, whether a schedule step ramped at all was decided purely by the presence of a 'ramp' key (components/sud.py's _advance()) - a hold-only step jumped straight into its hold countdown, assuming the plant was already at temperature. Now every step ramps toward 'temperature' first, for as long as the controller's own gap-tracking FSM (TempControllerBase, already distinguishing HEAT/COOL/HOLD) says the gap actually warrants it - via the new is_holding() (on APid and TempControllerBase), which replaces a separate, redundant TEMP_REACHED_TOLERANCE constant duplicated across tasks/sud.py, components/sud_forecast.py, and scripts/demos/sud/demo_sud.py. set_theta_soll() now recomputes the FSM eagerly so is_holding() can't read stale HOLD for a tick after a much-further-away target is pushed. components/sud.py's _build_step() always synthesizes a 'ramp' block from default.step.ramp (so 'rate' is always available), but leaves 'temperature' undefaulted - every real sude/*.json's default.step.temperature is inert template filler, and defaulting it would send hold-only steps chasing 0 degrees. SudTask/ SudForecastEstimator/the demo only push a new theta_soll/heatrate_soll when a step actually specifies its own temperature; otherwise the controller keeps whatever the previous step left running. Also fixes a related crash this surfaced: SudTask.remaining_schedule()'s synthetic mid-hold step dropped 'ramp' to signal "don't re-ramp" - now that every step always carries a 'ramp' dict, dropping it left 'rate' missing the moment the synthetic step was re-resolved by SudForecastEstimator. Drops 'temperature' instead, which is what actually signals "no new target" under the new model. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
This commit is contained in:
@@ -155,14 +155,20 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment.
|
||||
## Mash schedules
|
||||
|
||||
Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`,
|
||||
`pot_material` (fixed for the whole brew), and a `steps` list, each a ramp, a
|
||||
hold, or both, plus a step-level `temperature` (the target a ramp step ramps
|
||||
to, and what a hold step holds at):
|
||||
`pot_material` (fixed for the whole brew), and a `steps` list. Every step
|
||||
ramps toward its `temperature`, then optionally holds:
|
||||
|
||||
- a ramp — `"ramp": {"rate": ...}` — ramp to `temperature` at `rate`
|
||||
(°C/min), and/or
|
||||
- ramping toward `temperature` at `"ramp": {"rate": ...}` (°C/min) is not
|
||||
opt-in - it happens for every step, for as long as the temperature
|
||||
controller's own gap-tracking FSM (`components/pid/temp_controller_base.py`)
|
||||
says the gap actually warrants it; a step omitting `temperature` simply
|
||||
has no new target of its own and keeps whatever the previous step left
|
||||
running, so the ramp resolves immediately. `ramp.rate` itself still comes
|
||||
from `default.step.ramp` unless a step overrides it.
|
||||
- a hold — `"hold": {"duration": ...}` — hold the current target for
|
||||
`duration` minutes (omit/`0` for an immediate step).
|
||||
`duration` minutes (omit/`0` for an immediate step) - this part stays
|
||||
opt-in: only a step that specifies `hold` gets a hold-duration phase
|
||||
after the ramp.
|
||||
|
||||
A step with both ramps to `temperature` and then holds there for `duration`
|
||||
- useful for a mash rest ("ramp to 63°C, then hold 40 min") without needing
|
||||
@@ -191,8 +197,11 @@ time the current step changes, instead of deriving them once at startup.
|
||||
The top-level `default.step` object gives every field above (including the
|
||||
nested `ramp`/`hold`/`stirrer` blocks) a default value; a step in `steps`
|
||||
only needs to specify the fields it overrides — anything it omits is filled
|
||||
in from `default.step`, recursively. A step is a ramp or a hold depending on
|
||||
which of those two keys it specifies; the other is not defaulted in.
|
||||
in from `default.step`, recursively. `ramp` is always defaulted in this way
|
||||
(every step ramps); `hold` stays opt-in - only present if the step specifies
|
||||
it. `temperature` is the one exception: it's never defaulted from
|
||||
`default.step` (that's just inert template filler in every `sude/*.json`) -
|
||||
a step either specifies its own, or has none and doesn't change the target.
|
||||
|
||||
The server always runs a `Sud` (`components/sud.py`), starting out empty (no
|
||||
schedule) - `sude/` can hold several schedule files, and a client loads one
|
||||
@@ -202,9 +211,11 @@ restarts; nothing is ever read from or written to `sude/*.json` by the
|
||||
server itself, that's on the client (e.g. the GUI's File menu). `Sud`
|
||||
resolves each raw step against `default.step` and tracks the current
|
||||
(resolved) step, while `tasks/sud.py`'s `SudTask` drives the temperature
|
||||
controller's `theta_soll`/`heatrate_soll` from ramp steps (advancing once
|
||||
`theta_ist` settles close to the target), counts down hold steps'
|
||||
`duration`, and applies each step's `stirrer` block (`interval_time`/
|
||||
controller's `theta_soll`/`heatrate_soll` toward each step's target
|
||||
(advancing once the controller's own FSM reports the gap closed -
|
||||
`TempControllerBase.is_holding()` - rather than a separate ad hoc
|
||||
tolerance), counts down hold steps' `duration`, and applies each step's
|
||||
`stirrer` block (`interval_time`/
|
||||
`on_ratio` map directly onto the stirrer's cycle time/duty cycle). It
|
||||
exposes progress (current step, remaining hold time, state, any
|
||||
`user_message`) on the `"Sud"` WebSocket channel and accepts
|
||||
@@ -245,9 +256,10 @@ 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 within the tight 0.2°C "reached" tolerance
|
||||
(`tasks/sud.py`'s `TEMP_REACHED_TOLERANCE`). The simulated estimate accounts
|
||||
for that (it's driven by the real controller dynamics), so it's normally
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user