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 # Where each schedule step begins, in the same absolute timeline as # forecast_t - keyed by the schedule's own (absolute) step index, # rebased the same way as forecast_t/forecast_theta themselves on # every send_forecast()/_reanchor_forecast() call (see either's own # comment). Lets a client (the GUI's Progress tab) show each step's # predicted total/remaining duration without re-deriving it itself. self.forecast_step_starts = {} # 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 # Energy consumption per step (Wh), integrated from the heater's # own live effective power - see pot.get_power() (set from # heater.power_eff - server/brewpi.py wires that up) - rather # than recomputed from the forecast like the timing fields above, # since energy actually used can't be predicted in advance, only # measured as it happens. energy_step_accum_j is the *current* # step's running total, in Joules (integrated every tick in # on_process() - converted to Wh only at the message boundary, # see _on_energy_changed()); energy_by_step holds each *finished* # step's final Wh total, keyed by its (absolute) schedule index - # both reset on every Load (see recv()) since neither make sense # carried over to a different schedule. _energy_index is simply # which index the running accumulator currently belongs to, so # on_step_changed() can tell a genuine new step (a different # index) from its own ramp->hold phase switch (same index, must # not reset the accumulator mid-step). self.energy_step_accum_j = 0.0 self.energy_by_step = {} self._energy_index = None self._energy_changed = ChangedFloat(self._on_energy_changed, prec=2) # Saved on pause so resume can restore the effective setpoint even # when the current step has temperature=None (inherits from a prior step). self._paused_temp_soll = None self._on_end = None 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) 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): # A genuinely new step (this index differs from whichever one the # running accumulator currently belongs to - including the # transition to DONE, step=None, self.sud.index past the last # real one) banks the just-finished step's total and starts a # fresh one; the ramp->hold phase switch within the *same* step # re-fires this callback too (see below) but must not reset # mid-step, hence comparing the index itself rather than reacting # to every call. if self.sud.index != self._energy_index: if self._energy_index is not None: self.energy_by_step[self._energy_index] = self.energy_step_accum_j / 3600.0 self.energy_step_accum_j = 0.0 self._energy_index = self.sud.index 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: pot = step.get('pot', {}) self.apply_plant_params(pot.get('grain_mass', 0), pot.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) # Records the moment this step actually began *synchronously* - # only on the genuine first entry (ramping, per Sud._advance() # unconditionally setting state to RAMPING first - see this # method's own comment above), not the same step's later ramp-> # hold switch, so this can't itself get overwritten by that # switch's slightly later timestamp. Unconditional, not # setdefault: an *earlier* reanchor's own forward-looking # simulation may already have written a prediction for this # same index (it simulates every remaining step from its own # anchor point, real ones included) - that guess must be # replaced now that the real thing has actually happened, # never left to linger as if it still were one. # # _reanchor_forecast() recomputes this same entry too, but # asynchronously, so it can be (and routinely is, e.g. on the # very next step boundary arriving before its own simulation # finishes) superseded and discarded before ever committing - # see its own comment. Without this synchronous copy, a later # reanchor's "everything before my own index is real and # immutable" filter would then preserve whatever *that* # discarded call's predecessor had left behind instead - a # stale prediction, sometimes even one that hasn't happened # yet by the schedule's real current position. if ramping: self.forecast_step_starts[self.sud.index] = self.sud.elapsed # 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 set_on_end(self, callback): self._on_end = callback 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) if self._on_end: self._on_end() 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})) def _on_energy_changed(self, value): """value is the currently active step's running total (Wh) - throttled to once per self._energy_changed's rounding step (see __init__), same pattern as on_hold_remaining_changed()/ on_elapsed_changed() above, just driven manually from on_process() each tick rather than via Sud's own AttributeChange (energy isn't one of Sud's own attributes). energy_by_step (every *finished* step's own final Wh total) rides along on every such push rather than only on change - it's a handful of entries at most, and piggybacking means the client never has to reconcile two differently-timed messages to know "the rest of the totals plus what's happening right now".""" asyncio.create_task(self.send({'Energy': { 'StepEnergy': sorted(self.energy_by_step.items()), 'Current': 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, step_starts = 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) self.forecast_step_starts = step_starts 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, # Sorted [index, t] pairs rather than a {index: t} object - JSON # object keys are always strings, which would force every # consumer to int() them back; a plain sorted list sidesteps # that and is just as easy to look up from (the GUI's Progress # tab only ever needs it index-aligned with its own step list). 'StepStarts': sorted(self.forecast_step_starts.items()), }}) 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] # Steps already passed (< index) have their real, now-immutable # start time; anything from index on is about to be resimulated # fresh below and must not keep a stale prediction around. schedule = self.sud.schedule index = self.sud.index forecast_step_starts = {i: tt for i, tt in self.forecast_step_starts.items() if i < 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 self.forecast_step_starts = forecast_step_starts await self._send_forecast() return doc = { 'Name': self.sud.name, 'Description': self.sud.description, 'pot': { 'mass': self.sud.pot_mass, '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, step_starts = 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) # step_starts' indices/times are relative to this sub-schedule # (starting fresh at doc['steps'][0]) - rebase both onto the real # schedule's absolute indices and the master forecast timeline, # same as t/theta above. forecast_step_starts.update( (index + local_index, real_elapsed + local_t) for local_index, local_t in step_starts.items()) self.forecast_t = forecast_t self.forecast_theta = forecast_theta self.forecast_finished = (final_state == SudState.DONE) self.forecast_step_starts = forecast_step_starts 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) if fresh_start: # Belongs to the run that just ended (Stop, or running # the schedule through to DONE), not the one about to # begin - a Pause->resume (fresh_start False) keeps it, # same as the forecast above. self.energy_step_accum_j = 0.0 self.energy_by_step = {} self._energy_index = None self.sud.start() if fresh_start: asyncio.create_task(self.send_forecast(self.sud.save())) else: # Resume from PAUSED: re-apply current step's schedule # parameters so any manual TC/stirrer changes made while # paused are overwritten by the schedule on play. index = self.sud.index if index is not None and 0 <= index < len(self.sud.schedule): step = self.sud.schedule[index] ramp = step.get('ramp') hold = step.get('hold') ramping = self.sud.state == SudState.RAMPING # Use step's own temperature if set; fall back to # the value saved at pause for steps that inherit # their target from a prior step (temperature=None). target_temp = step.get('temperature') if target_temp is None: target_temp = self._paused_temp_soll if target_temp is not None: self.tc.set_theta_soll(target_temp) if ramping and ramp: self.tc.set_heatrate_soll(ramp['rate']) phase = ramp if ramping else hold if phase is not None: self.apply_stirrer(phase) 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._paused_temp_soll = self.tc.theta_soll_set 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}) # A run already in progress (e.g. a client connecting mid- # brew, which always fires this on connect - see client/ # brewpi_gui.py's Window.connect()) must NOT get send_ # forecast()'s cold, from-step-0 simulation here: it knows # nothing of the real current step/elapsed/temperature, so # it would silently overwrite the forecast/forecast_step_ # starts that's been accurately, continuously maintained # by _reanchor_forecast() all along with a context-free # "starting fresh right now" guess - the exact bug that # made a freshly-connected client highlight/countdown the # wrong step. Just re-send what's already there instead; # only a genuinely not-yet-started schedule (IDLE/DONE) # has nothing accurate yet to preserve, so it alone still # gets the real, full computation. if self.sud.state in (SudState.IDLE, SudState.DONE): await self.send_forecast(doc) else: await self._send_forecast() elif 'Load' in pair[0]: if self.sud.load(pair[1]): # Energy consumption belongs to a specific schedule's # run, same as forecast_step_starts' timings - neither # means anything carried over to a different one. self.energy_step_accum_j = 0.0 self.energy_by_step = {} self._energy_index = None 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() # Energy actually drawn this tick - pot.get_power() reflects # the heater's own live effective power (see server/brewpi. # py's heater.set_on_changed("power_eff", ...pot.set_power) # wiring), in Watts; dt is this tick's simulated seconds, so # the product is Joules, accumulated for whichever step is # current (see on_step_changed()). Counted through every # non-idle state, WAIT_USER/PAUSED included - the controller # stays enabled (see on_state_changed()) and may well still # be actively holding, drawing real power, even though the # schedule itself isn't progressing. if self.sud.state not in (SudState.IDLE, SudState.DONE): self.energy_step_accum_j += self.pot.get_power() * self.dt self._energy_changed.set(self.energy_step_accum_j / 3600.0) self.sud.tick(self.dt) await asyncio.sleep(self.interval)