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:
+19
-20
@@ -6,9 +6,6 @@ from components import APid, AStirrer
|
||||
from components.plant import APlant
|
||||
from components.sud import Sud, SudState
|
||||
|
||||
# How close theta_ist needs to be to theta_soll_set to count as "reached".
|
||||
TEMP_REACHED_TOLERANCE = 0.2
|
||||
|
||||
|
||||
class SudTask(ATask):
|
||||
def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, pot: APlant, dt, interval, msg_handler: MsgIo,
|
||||
@@ -60,26 +57,26 @@ class SudTask(ATask):
|
||||
def on_step_changed(self, step):
|
||||
ramp = step.get('ramp') if step else None
|
||||
hold = step.get('hold') if step else None
|
||||
# A step may carry both 'ramp' and 'hold' (ramp to temp, then hold) -
|
||||
# Every step ramps to 'temperature' first, then optionally holds -
|
||||
# self.sud.state tells us which phase is currently active; it's
|
||||
# already up to date by the time this callback fires, both on a
|
||||
# full step transition and on the ramp->hold phase switch within
|
||||
# one step (components/sud.py's temp_reached()).
|
||||
ramping = self.sud.state == SudState.RAMPING
|
||||
phase = ramp if ramping and ramp is not None else hold
|
||||
phase = ramp if ramping else hold
|
||||
|
||||
if step is not None:
|
||||
self.apply_plant_params(step)
|
||||
if ramping and ramp is not None:
|
||||
if ramping and step['temperature'] is not None:
|
||||
self.tc.set_theta_soll(step['temperature'])
|
||||
self.tc.set_heatrate_soll(ramp['rate'])
|
||||
self.apply_stirrer(phase)
|
||||
|
||||
asyncio.create_task(self.send({'Step': {
|
||||
'Index': self.sud.index,
|
||||
'Type': 'ramp' if ramping and ramp is not None else 'hold' if hold is not None else None,
|
||||
'Type': 'ramp' if ramping else 'hold' if hold is not None else None,
|
||||
'Descr': step.get('descr') if step else None,
|
||||
'Temp': step.get('temperature') if ramp is not None else None,
|
||||
'Temp': step.get('temperature') if step else None,
|
||||
'Rate': ramp.get('rate') if ramp else None,
|
||||
'Duration': hold.get('duration') if (hold is not None and not ramping) else None,
|
||||
'WaitForUser': step.get('user_wait_for_continue', False) if step else None,
|
||||
@@ -136,14 +133,20 @@ class SudTask(ATask):
|
||||
return rest
|
||||
if self.sud.state == SudState.HOLDING:
|
||||
# Keep the rest of the current (already fully-resolved) step -
|
||||
# grain_mass/water_mass/temperature/etc. - SudForecastEstimator
|
||||
# re-resolves each step against an empty default.step, so a
|
||||
# bare {'hold': {...}} here would leave those None instead of
|
||||
# inherited, breaking derive_plant_params(). Only the ramp
|
||||
# phase is actually done; drop it and override the hold's
|
||||
# grain_mass/water_mass/etc. - SudForecastEstimator re-resolves
|
||||
# each step against an empty default.step, so a bare
|
||||
# {'hold': {...}} here would leave those None instead of
|
||||
# inherited, breaking derive_plant_params(). The ramp phase is
|
||||
# already done, so drop 'temperature' (not 'ramp' - every step
|
||||
# always carries a 'ramp' dict now, dropping it would leave
|
||||
# 'rate' missing the moment this synthetic step is re-resolved):
|
||||
# without a 'temperature' of its own, the rebuilt step pushes
|
||||
# no new target, start_theta already seeds the simulation at
|
||||
# the target anyway, so it resolves out of its (now synthetic)
|
||||
# ramp phase in a tick or two regardless. Override the hold's
|
||||
# duration to what's actually left.
|
||||
remaining_minutes = max(self.sud.hold_remaining, 0.0) / 60.0
|
||||
synthetic = {k: v for k, v in current.items() if k != 'ramp'}
|
||||
synthetic = {k: v for k, v in current.items() if k != 'temperature'}
|
||||
synthetic['hold'] = {**current.get('hold', {}), 'duration': remaining_minutes}
|
||||
return [synthetic] + rest
|
||||
return [current] + rest
|
||||
@@ -205,11 +208,7 @@ class SudTask(ATask):
|
||||
asyncio.create_task(self.send({'Name': self.sud.name, 'Description': self.sud.description}))
|
||||
|
||||
while True:
|
||||
if self.sud.state == SudState.RAMPING:
|
||||
# Compare against the controller's own live setpoint/measurement
|
||||
# rather than its state machine, which can still read HOLD from
|
||||
# the previous step for one tick after a new target is pushed.
|
||||
if abs(self.tc.get_theta_ist() - self.tc.get_theta_soll_set()) < TEMP_REACHED_TOLERANCE:
|
||||
self.sud.temp_reached()
|
||||
if self.sud.state == SudState.RAMPING and self.tc.is_holding():
|
||||
self.sud.temp_reached()
|
||||
self.sud.tick(self.dt)
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
Reference in New Issue
Block a user