Reanchoring used to only happen when a user confirmed a WAIT_USER step, so anything the schedule advanced through on its own (a ramp reaching target, a hold timing out) left the forecast showing a stale, increasingly wrong prediction once reality diverged from it. _reanchor_forecast() now fires from on_step_changed() on every real transition, splicing in a fresh simulation anchored at the real current temperature/elapsed time instead. Also fixes two bugs surfaced while testing that change: - estimate()'s early-return for an empty/not-yet-loaded schedule still returned the old 4-tuple shape, crashing every connection before a Sud was ever Loaded. - send_forecast() and _reanchor_forecast() can run concurrently (e.g. a fresh Start triggers both at once), and whichever resumed second after its own worker-thread simulation would blindly splice its tail onto whatever the other had already written, producing a spurious connecting line across the plot. Both now carry a generation counter and discard their result if a newer call has since committed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
399 lines
18 KiB
Python
399 lines
18 KiB
Python
import asyncio
|
|
import bisect
|
|
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
|
|
|
|
# Upper bound on how many (t, theta) points the forecast is thinned to
|
|
# before going out over the wire (see _send_forecast()) - a fine enough
|
|
# dt over a multi-hour brew can otherwise produce a single JSON message
|
|
# of several MB, large enough to exceed the websockets library's default
|
|
# 1 MiB max_size and get the connection closed outright (code 1009). The
|
|
# GUI's forecast plot is a few hundred pixels wide, so this many points
|
|
# is already far more resolution than it can show; self.forecast_t/
|
|
# forecast_theta themselves stay at full simulated resolution - this
|
|
# only thins the copy actually sent to clients.
|
|
MAX_FORECAST_POINTS = 1000
|
|
|
|
|
|
def _downsample(t, theta, max_points=MAX_FORECAST_POINTS):
|
|
"""Returns (t, theta) thinned to at most max_points entries by simple
|
|
decimation, always keeping the first and last point - losing a few
|
|
intermediate samples doesn't matter for a plot this size, but losing
|
|
the endpoints would visibly truncate the curve or its final value."""
|
|
n = len(t)
|
|
if n <= max_points:
|
|
return t, theta
|
|
step = -(-n // max_points) # ceil(n / max_points)
|
|
t_ds = t[::step]
|
|
theta_ds = theta[::step]
|
|
if t_ds[-1] != t[-1]:
|
|
t_ds = t_ds + [t[-1]]
|
|
theta_ds = theta_ds + [theta[-1]]
|
|
return t_ds, theta_ds
|
|
|
|
|
|
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
|
|
# The forecast as last computed/corrected (see send_forecast()/
|
|
# _reanchor_forecast()) - T in simulated seconds, Theta in degrees,
|
|
# parallel lists, both monotonically growing as corrections get
|
|
# spliced in. Covers the whole schedule from the very first Load,
|
|
# including through steps requiring user confirmation - those are
|
|
# simulated as a zero-delay auto-confirm (see components/
|
|
# sud_forecast.py's SudForecastEstimator.estimate()) rather than
|
|
# left unforecast, until _reanchor_forecast() corrects it for real.
|
|
self.forecast_t = []
|
|
self.forecast_theta = []
|
|
# Whether the forecast above actually reaches the schedule's real
|
|
# end (sent as 'Finished') - False only in the pathological case
|
|
# of a step whose target can never be reached (see
|
|
# components/sud_forecast.py's MAX_TICKS).
|
|
self.forecast_finished = True
|
|
# Bumped by every call to send_forecast()/_reanchor_forecast() -
|
|
# see either's own comment for why: it lets a call that's still
|
|
# awaiting its worker-thread simulation tell, once it resumes,
|
|
# whether a newer call has since started and already committed a
|
|
# fresher result - if so, it discards its own rather than
|
|
# corrupting forecast_t/forecast_theta with stale data.
|
|
self._forecast_generation = 0
|
|
msg_handler.set_recv_handler(self.recv)
|
|
|
|
def apply_plant_params(self, grain_mass, water_mass):
|
|
"""Keeps the real plant's and the controller's internal model's
|
|
plant params in sync with this Sud's own doc - L/Td come straight
|
|
from it (constant for the whole brew), while M/C also fold in the
|
|
given grain_mass/water_mass, which vary over the brew's course
|
|
(malt going in, water boiling off) - mirrors demo_sud.py's
|
|
apply_plant_params(). Called both on every real step transition
|
|
(via on_step_changed(), with that step's own grain_mass/water_mass)
|
|
and once immediately on Load (see recv(), with the doc's own
|
|
initial Sud.grain_mass/water_mass - no need to parse the first
|
|
step out of the schedule for that), so the controller's behavior
|
|
already matches the expected plant as soon as a Sud is loaded,
|
|
not just once a run actually starts."""
|
|
params = self.sud.derive_plant_params(grain_mass, water_mass)
|
|
self.pot.set_plant_params(params)
|
|
if hasattr(self.tc, 'set_model_plant_params'):
|
|
self.tc.set_model_plant_params(params)
|
|
|
|
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.get('grain_mass', 0), step.get('water_mass', 0))
|
|
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)
|
|
# Every real step boundary (full step change or ramp->hold
|
|
# within one) is a trustworthy checkpoint to correct the
|
|
# forecast against - see _reanchor_forecast(). Catches drift
|
|
# from anything the original simulation couldn't have known
|
|
# (a malt fill-in's actual cooldown, a longer/shorter ramp than
|
|
# modeled, ...) at the next opportunity, not just at the next
|
|
# user confirmation.
|
|
asyncio.create_task(self._reanchor_forecast())
|
|
|
|
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,
|
|
}}))
|
|
|
|
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}))
|
|
|
|
def on_elapsed_changed(self, value):
|
|
asyncio.create_task(self.send({'Elapsed': value}))
|
|
|
|
async def send_forecast(self, doc):
|
|
"""Computes and sends the full forecast for doc, start to finish -
|
|
including through every step requiring user confirmation, which
|
|
is modeled as a zero-delay auto-confirm rather than left
|
|
unforecast (see components/sud_forecast.py's
|
|
SudForecastEstimator.estimate()) - so the whole schedule's
|
|
projected curve is visible right away instead of stopping at the
|
|
first one. Always a fresh start: discards whatever forecast was
|
|
accumulated for the previously loaded schedule.
|
|
|
|
Anchored at the real current temperature
|
|
(self.tc.get_theta_ist_set()), not a cold start at ambient - the
|
|
pot may already be warm (a previous run, or manual heating) at
|
|
the moment this Sud is loaded, and a forecast that assumes
|
|
ambient regardless would reach every target later than it
|
|
actually will, never lining up with the actual trace even at
|
|
t=0. Deliberately the raw sensor reading (theta_ist_set), not
|
|
the controller's own get_theta_ist() (theta_ist) -
|
|
_reanchor_forecast() can trust that one because a real run has
|
|
been actively ticking for a while by the time it runs, but this
|
|
is called right after Load, before this controller may have
|
|
processed even a single tick yet (e.g. its plant params/model
|
|
only just got configured - see SudTask.recv()), so theta_ist
|
|
itself could still be sitting at its never-updated __init__
|
|
default.
|
|
|
|
Those zero-delay assumptions get corrected piecewise as the
|
|
schedule actually reaches each step boundary - see
|
|
_reanchor_forecast()."""
|
|
if self.forecast_estimator is None:
|
|
return
|
|
# Both this and _reanchor_forecast() can end up running
|
|
# concurrently - e.g. a fresh Start triggers this explicitly *and*
|
|
# (via Sud.start() synchronously firing on_step_changed() for the
|
|
# first step) a _reanchor_forecast() of its own; a step whose hold
|
|
# duration is already 0 can likewise advance twice within a single
|
|
# tick, firing on_step_changed() twice back to back. Each such call
|
|
# awaits a worker-thread simulation, so without this guard whichever
|
|
# one resumes second would blindly splice its own tail onto
|
|
# whatever the other already finished writing, producing a
|
|
# spurious connecting line across the plot. Bumping/checking this
|
|
# generation counter across the await ensures only the very latest
|
|
# call's result is ever committed - any older one discards itself.
|
|
self._forecast_generation += 1
|
|
generation = self._forecast_generation
|
|
# 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()
|
|
start_theta = self.tc.get_theta_ist_set()
|
|
t, theta, final_state = await loop.run_in_executor(
|
|
None, self.forecast_estimator.estimate, doc, start_theta)
|
|
if generation != self._forecast_generation:
|
|
return
|
|
self.forecast_t = t
|
|
self.forecast_theta = theta
|
|
self.forecast_finished = (final_state == SudState.DONE)
|
|
await self._send_forecast()
|
|
|
|
async def _send_forecast(self):
|
|
t, theta = _downsample(self.forecast_t, self.forecast_theta)
|
|
await self.send({'Forecast': {
|
|
'T': t,
|
|
'Theta': theta,
|
|
'Finished': self.forecast_finished,
|
|
}})
|
|
|
|
async def _reanchor_forecast(self):
|
|
"""Corrects the optimistic, zero-delay guesses baked into the
|
|
forecast (see SudForecastEstimator.estimate()'s docstring) now
|
|
that the schedule has actually reached a real step boundary -
|
|
called from on_step_changed() on every transition, whether it's
|
|
a user confirmation or fully automatic (e.g. a ramp reaching its
|
|
target, or a hold's duration running out). Truncates the forecast
|
|
back to right now and splices in a freshly anchored simulation of
|
|
the rest of the schedule, anchored at the real elapsed time
|
|
(self.sud.elapsed) and the real current temperature
|
|
(self.tc.get_theta_ist()) - so any divergence the original
|
|
simulation couldn't have predicted (a malt fill-in's actual
|
|
cooldown, a longer/shorter ramp than modeled, ...) gets corrected
|
|
at the next opportunity instead of leaving the forecast stuck
|
|
showing what was once guessed.
|
|
|
|
No-op if the estimator isn't configured.
|
|
|
|
Guards against the same concurrent-call race send_forecast() does
|
|
(see its own comment) by working off local copies of the forecast
|
|
lists throughout, only ever committing them to self.forecast_t/
|
|
forecast_theta right at the end, and only if this is still the
|
|
latest call by then - an older call resuming after a newer one
|
|
has already committed must discard its own (now-stale) result
|
|
rather than splice it onto what the newer call already wrote."""
|
|
if self.forecast_estimator is None:
|
|
return
|
|
self._forecast_generation += 1
|
|
generation = self._forecast_generation
|
|
real_elapsed = self.sud.elapsed
|
|
# Drop the now-stale tail (everything beyond right now) - it's
|
|
# about to be replaced by a freshly anchored simulation.
|
|
cut = bisect.bisect_right(self.forecast_t, real_elapsed)
|
|
forecast_t = self.forecast_t[:cut]
|
|
forecast_theta = self.forecast_theta[:cut]
|
|
|
|
schedule = self.sud.schedule
|
|
index = self.sud.index
|
|
if not (0 <= index < len(schedule)):
|
|
if generation != self._forecast_generation:
|
|
return
|
|
self.forecast_t = forecast_t
|
|
self.forecast_theta = forecast_theta
|
|
self.forecast_finished = True
|
|
await self._send_forecast()
|
|
return
|
|
doc = {
|
|
'Name': self.sud.name,
|
|
'Description': self.sud.description,
|
|
'pot_mass': self.sud.pot_mass,
|
|
'pot_material': self.sud.pot_material,
|
|
'L': self.sud.L,
|
|
'Td': self.sud.Td,
|
|
'grain_mass': self.sud.grain_mass,
|
|
'water_mass': self.sud.water_mass,
|
|
'steps': schedule[index:],
|
|
}
|
|
start_theta = self.tc.get_theta_ist()
|
|
loop = asyncio.get_event_loop()
|
|
t, theta, final_state = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
|
|
if generation != self._forecast_generation:
|
|
return
|
|
# Bridge any gap between the last surviving old point and right
|
|
# now (e.g. the old forecast's timeline had already drifted
|
|
# behind real_elapsed) with the same real measurement the fresh
|
|
# simulation below starts from, so the spliced curve doesn't
|
|
# visibly jump.
|
|
if forecast_t and forecast_t[-1] < real_elapsed:
|
|
forecast_t.append(real_elapsed)
|
|
forecast_theta.append(start_theta)
|
|
forecast_t.extend(real_elapsed + seconds for seconds in t)
|
|
forecast_theta.extend(theta)
|
|
self.forecast_t = forecast_t
|
|
self.forecast_theta = forecast_theta
|
|
self.forecast_finished = (final_state == SudState.DONE)
|
|
await self._send_forecast()
|
|
|
|
async def recv(self, data):
|
|
for pair in data.items():
|
|
if 'Start' in pair[0]:
|
|
# A fresh start (not a resume from Pause, which keeps
|
|
# whatever forecast the run already established) re-
|
|
# anchors the forecast to the real temperature right now
|
|
# - that's the actual "t=0" the about-to-start actual
|
|
# trace will be plotted from, which may no longer match
|
|
# whatever temperature existed back at Load (time passed,
|
|
# possibly manual heating in between).
|
|
fresh_start = self.sud.state in (SudState.IDLE, SudState.DONE)
|
|
self.sud.start()
|
|
if fresh_start:
|
|
asyncio.create_task(self.send_forecast(self.sud.save()))
|
|
elif 'Confirm' in pair[0]:
|
|
# Sud.confirm() synchronously fires on_step_changed() for
|
|
# the now-current step, which schedules the forecast
|
|
# reanchor itself - see _reanchor_forecast().
|
|
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]})
|
|
# on_step_changed() only re-applies plant params once a
|
|
# real step starts (Start) - apply them right away too,
|
|
# so the controller already matches this Sud's own pot/
|
|
# L/Td/initial grain_mass/water_mass from the moment
|
|
# it's loaded, rather than whatever the previously
|
|
# loaded Sud (or the generic startup baseline - see
|
|
# server/brewpi.py) left behind.
|
|
if self.sud.schedule:
|
|
self.apply_plant_params(self.sud.grain_mass, self.sud.water_mass)
|
|
await self.send_forecast(pair[1])
|
|
else:
|
|
# Sud.load() refuses while a run is in progress (state
|
|
# not IDLE/DONE) - tell the client why instead of
|
|
# silently dropping the request. The client has no
|
|
# business pre-emptively guessing this itself from its
|
|
# own (replicated, laggy) view of the state.
|
|
#
|
|
# Immediately cleared back to None - unlike every other
|
|
# field here, this is a one-shot event, not state. The
|
|
# dispatcher has no concept of "don't persist this into
|
|
# global_state" (see ws/user.py's update()), so without
|
|
# clearing it, any client connecting later - even one
|
|
# that never touched Load - would get this stale error
|
|
# replayed on connect, with nothing it just did to
|
|
# explain why.
|
|
await self.send({'Error': 'Cannot load a new schedule while a run is in progress - stop it first.'})
|
|
await self.send({'Error': None})
|
|
|
|
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)
|
|
self.sud.set_on_changed('elapsed', ChangedFloat(self.on_elapsed_changed, prec=1).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)
|