Files
brewpi/tasks/sud.py
T
jensandClaude Sonnet 4.6 2bdd3203cf 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
2026-06-22 09:35:53 +02:00

215 lines
8.6 KiB
Python

import asyncio
from tasks import ATask
from ws.message import MsgIo
from utils.value import ChangedFloat
from components import APid, AStirrer
from components.plant import APlant
from components.sud import Sud, SudState
class SudTask(ATask):
def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, pot: APlant, dt, interval, msg_handler: MsgIo,
forecast_estimator=None):
ATask.__init__(self, interval)
self.sud = sud
self.tc = tc
self.stirrer = stirrer
self.pot = pot
# Simulated seconds per tick, vs. interval's wall-clock seconds per
# tick - same dt/interval split Pot/TempController/Stirrer already
# use internally. Their warp-induced speedup falls out naturally
# from ticking real physics at simulated dt; Sud has no physics of
# its own, so hold_remaining must be ticked by dt explicitly to get
# the same speedup instead of running in real time.
self.dt = dt
self.msg_handler = msg_handler
# Predicts a schedule's actual duration by simulating it with the
# same plant/controller machinery this server uses for real - see
# components/sud_forecast.py. Optional only so tests/demos that
# build a SudTask without one still work; the server always passes
# one.
self.forecast_estimator = forecast_estimator
msg_handler.set_recv_handler(self.recv)
def apply_plant_params(self, step):
"""Keeps the real plant's and the controller's internal model's
lumped (M, C) in sync with the step's grain_mass/water_mass, since
those vary over the course of a brew (malt going in, water boiling
off) - mirrors demo_sud.py's apply_plant_params()."""
params = self.sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0))
self.pot.set_thermal_params(params['M'], params['C'])
if hasattr(self.tc, 'set_model_params'):
self.tc.set_model_params(params['M'], params['C'])
def apply_stirrer(self, phase):
stirrer_cfg = phase.get('stirrer', {})
speed = stirrer_cfg.get('speed', 0)
interval_time = stirrer_cfg.get('interval_time', 0)
on_ratio = stirrer_cfg.get('on_ratio', 1.0)
if interval_time > 0:
self.stirrer.set_cycle_time(interval_time)
self.stirrer.set_duty_cycle(on_ratio)
else:
self.stirrer.set_cycle_time(1.0)
self.stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
self.stirrer.set_speed(speed)
def on_step_changed(self, step):
ramp = step.get('ramp') if step else None
hold = step.get('hold') if step else None
# 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 else hold
if step is not None:
self.apply_plant_params(step)
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 else 'hold' if hold is not None else None,
'Descr': step.get('descr') if step 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,
}}))
asyncio.create_task(self.send_remaining_forecast())
def on_state_changed(self, value):
asyncio.create_task(self.send({'State': str(value)}))
if value in (SudState.DONE, SudState.IDLE):
self.stirrer.set_duty_cycle(1.0)
self.stirrer.set_speed(0)
# A finished/stopped run no longer owns the controller - hand
# control back to manual mode (off by default there too).
self.tc.set_enabled(False)
else:
# Any other state (RAMPING/HOLDING/WAIT_USER/PAUSED) means a
# run is in progress and needs the controller actively driving
# the heater.
self.tc.set_enabled(True)
def on_user_message_changed(self, value):
asyncio.create_task(self.send({'UserMessage': value}))
def on_hold_remaining_changed(self, value):
asyncio.create_task(self.send({'HoldRemaining': value}))
async def send_forecast(self, doc):
if self.forecast_estimator is None:
return
# Runs the simulation in a worker thread - it's CPU-bound and can
# take a couple hundred ms for a long schedule, which would
# otherwise stall every other task (heater, sensor, ...) for that
# whole window.
loop = asyncio.get_event_loop()
t, theta = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc)
await self.send({'Forecast': {'T': t, 'Theta': theta}})
def remaining_schedule(self):
"""The not-yet-done part of the running schedule - the current
step's already-finished phase is dropped, and its still-to-run
phase is left for SudForecastEstimator to size from the live
start_theta/hold_remaining it's seeded with, rather than the
step's nominal start. Mirrors client/brewpi_gui.py's
Window._remaining_schedule(), which does the same thing
client-side for its own (less accurate) naive fallback."""
schedule = self.sud.schedule
index = self.sud.index
if not schedule or not (0 <= index < len(schedule)):
return []
current = schedule[index]
rest = schedule[index + 1:]
if self.sud.state == SudState.WAIT_USER:
return rest
if self.sud.state == SudState.HOLDING:
# Keep the rest of the current (already fully-resolved) step -
# 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 != 'temperature'}
synthetic['hold'] = {**current.get('hold', {}), 'duration': remaining_minutes}
return [synthetic] + rest
return [current] + rest
async def send_remaining_forecast(self):
"""Like send_forecast(), but for the remaining steps only, seeded
from the live current temperature - the dynamic forecast's
projected-remainder line (GUI's SudForecastPlot.line_projected)
uses this instead of its own naive abs(delta)/rate estimate
whenever it's available."""
if self.forecast_estimator is None:
return
remaining = self.remaining_schedule()
if not remaining:
return
doc = {
'Name': self.sud.name,
'Description': self.sud.description,
'pot_mass': self.sud.pot_mass,
'pot_material': self.sud.pot_material,
'steps': remaining,
}
start_theta = self.tc.get_theta_ist()
loop = asyncio.get_event_loop()
t, theta = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
await self.send({'RemainingForecast': {'T': t, 'Theta': theta}})
async def recv(self, data):
for pair in data.items():
if 'Start' in pair[0]:
self.sud.start()
elif 'Confirm' in pair[0]:
self.sud.confirm()
elif 'Pause' in pair[0]:
self.sud.pause()
elif 'Stop' in pair[0]:
self.sud.stop()
elif 'Save' in pair[0]:
doc = self.sud.save()
await self.send({'Json': doc})
await self.send_forecast(doc)
elif 'Load' in pair[0]:
if self.sud.load(pair[1]):
await self.send({'Name': self.sud.name, 'Description': self.sud.description})
await self.send({'Json': pair[1]})
await self.send_forecast(pair[1])
async def send(self, data):
await self.msg_handler.send(data)
async def on_process(self):
print("{}: Started with interval {} s".format(self.msg_handler.get_key(), self.interval))
self.sud.set_on_changed('step', self.on_step_changed)
self.sud.set_on_changed('state', self.on_state_changed)
self.sud.set_on_changed('user_message', self.on_user_message_changed)
self.sud.set_on_changed('hold_remaining', ChangedFloat(self.on_hold_remaining_changed, prec=0).set)
asyncio.create_task(self.send({'Name': self.sud.name, 'Description': self.sud.description}))
while True:
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)