From 57b86bc07577e160d3c168549cc912240f10a909 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 22 Jun 2026 16:33:40 +0200 Subject: [PATCH] Make the Sud forecast piecewise, computed once per segment Previously, the dynamic forecast's projected remainder was fully recomputed on every step change, and a step requiring user confirmation was simulated with zero added delay ("count it as instant for estimation purposes"). Both undercut the actual goal of the forecast: an honest comparison between real control behavior and a simulation-based prediction. A forecast that keeps re-anchoring itself to match reality isn't a useful baseline to compare reality against, and assuming zero confirmation delay quietly understates the schedule. components/sud_forecast.py: SudForecastEstimator.estimate() now stops simulating at the first step requiring user confirmation instead of auto-confirming, returning the final SudState alongside the data so the caller knows whether it stopped there or actually finished. tasks/sud.py: SudTask now accumulates the forecast across piecewise segments (forecast_t/forecast_theta). send_forecast() computes only the first segment, at Load/Save. The real Confirm handler triggers _continue_forecast_after_confirm(), which computes the next segment anchored at the real elapsed time and real current temperature - bridging the unforecastable wait with a flat segment rather than guessing at its length - and sends the updated, stitched Forecast (now carrying a Finished flag). The old per-step-change RemainingForecast recompute is gone entirely, along with remaining_schedule()/send_remaining_forecast(). client/brewpi_gui.py: SudForecastPlot redesigned around this - the dashed line is the fixed, piecewise forecast (locked axes, untouched except when a genuinely new segment arrives via show_forecast()); the solid line is purely the actual measured trace (show_dynamic(), now only touching that). extend_forecast_while_ waiting() repeats the forecast's last value while the real Sud sits in WAIT_USER, so the dashed line doesn't just stop, then snaps to the new segment the moment the real confirmation appends one. Verified via a raw-protocol test (segment boundaries, the Finished flag, and real-time stitching across a confirm delay) and visually in the GUI (locked dashed forecast, "X min so far total" title while incomplete, solid/dashed comparison rendering with realistic convergence/divergence). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K --- client/brewpi_gui.py | 201 ++++++++++++++++++------------------- components/sud_forecast.py | 30 +++--- tasks/sud.py | 108 +++++++++++--------- 3 files changed, 172 insertions(+), 167 deletions(-) diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index 33551ce..b8fa1d3 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -109,22 +109,22 @@ class RealtimePlot(FigureCanvasQTAgg): class SudForecastPlot(FigureCanvasQTAgg): - """A preview of a Sud schedule's temperature course, simulated - server-side with the same plant/controller model the real run uses + """Compares a Sud schedule's actual control behavior (line, solid) + against a fixed, simulation-based forecast (line_projected, dashed) - + the same plant/controller model the real run uses (components/sud_forecast.py's SudForecastEstimator - see - show_computing()/show_precomputed_course()). There used to also be an - immediate, naive abs(delta)/rate placeholder shown the instant a - schedule loaded, before the simulated one arrived a moment later - - removed because it was structurally wrong often enough (no model of - PID settling time, ramp dead time, etc.) to not be worth showing even - briefly. - - Once running, nonideal ramp rates/user pauses make any static estimate - drift from reality - show_dynamic() instead re-anchors the projection - every tick: the already-elapsed part is the actual measured trace - (line, solid), and only the remaining steps are projected forward from - the live current temperature/step/hold_remaining (line_projected, - dashed).""" + show_computing()/show_forecast()). The forecast is computed once per + piecewise segment and never revisited once a run is using it - the + whole point is an honest, unmoving baseline to compare reality + against, not a prediction that keeps re-anchoring itself to match + whatever's actually happening. The one exception is a step requiring + user confirmation: a human's response time genuinely can't be + forecast, so the segment just stops there, and the next one is + computed for real (anchored at the real elapsed time and + temperature) only once the user actually confirms - see + tasks/sud.py's SudTask._continue_forecast_after_confirm(). + extend_forecast_while_waiting() keeps the dashed line visually flat + through the wait in the meantime.""" def __init__(self): figure = Figure(facecolor='white') @@ -143,32 +143,66 @@ class SudForecastPlot(FigureCanvasQTAgg): figure.tight_layout() + # The forecast as last received from the server (see + # show_forecast()) - kept around so extend_forecast_while_waiting() + # can repeat its last value without the caller needing to pass it + # again every tick. + self._forecast_t_min = [] + self._forecast_theta = [] + def show_computing(self, name): - """Shown right after a schedule loads, while the simulation-based - forecast is still being computed server-side (see - Window.on_sud_forecast_received()) - typically resolved within a - second. Deliberately shows nothing rather than an approximate - placeholder; leaves the axes' current view alone (there's nothing - meaningful to fit yet), so whatever was on screen before just - stays frozen underneath the title change until show_precomputed_ - course() replaces it.""" + """Shown right after a schedule loads, while the first + simulation-based forecast segment is still being computed + server-side (see Window.on_sud_forecast_received()) - typically + resolved within a second. Deliberately shows nothing rather than + an approximate placeholder; leaves the axes' current view alone + (there's nothing meaningful to fit yet), so whatever was on + screen before just stays frozen underneath the title change + until show_forecast() replaces it.""" + self._forecast_t_min = [] + self._forecast_theta = [] self.line.set_data([], []) self.line_projected.set_data([], []) self.ax.set_title("{} - computing forecast...".format(name), fontsize='small') self.figure.tight_layout() self.draw_idle() - def show_precomputed_course(self, t_min, theta, name): - """Static forecast computed by the server simulating the schedule - with its real plant/controller (components/sud_forecast.py) - see - Window.on_sud_forecast_received().""" - self.line.set_data(t_min, theta) - self.line_projected.set_data([], []) + def show_forecast(self, t_min, theta, name, finished): + """The forecast curve (dashed) - computed once, piecewise, by the + server simulating the schedule with its real plant/controller + (components/sud_forecast.py) - see Window.on_sud_forecast_ + received(). Called again, with a longer t_min/theta, each time a + further piecewise segment gets appended after a real user + confirmation; finished is False while the forecast doesn't yet + cover the rest of the schedule (stopped at a step requiring + confirmation - see extend_forecast_while_waiting()). Locks the + axes to fit the forecast alone, regardless of whatever the + actual trace (line) is currently doing.""" + self._forecast_t_min = t_min + self._forecast_theta = theta + self.line_projected.set_data(t_min, theta) self._set_fixed_limits(t_min, theta) - self.ax.set_title("{} - {:.0f} min total".format(name, t_min[-1] if t_min else 0.0), fontsize='small') + total = t_min[-1] if t_min else 0.0 + suffix = "" if finished else " so far" + self.ax.set_title("{} - {:.0f} min{} total".format(name, total, suffix), fontsize='small') self.figure.tight_layout() self.draw_idle() + def extend_forecast_while_waiting(self, elapsed_min): + """Called every tick while the real Sud is waiting on user + confirmation: a human's response time can't be forecast, so + rather than guess, the dashed line just repeats its last value + out to 'now' until the next segment is appended for real (see + tasks/sud.py's SudTask._continue_forecast_after_confirm(), fired + the moment the real confirmation happens) - at which point + show_forecast() overwrites this temporary extension with the + actual next segment.""" + if not self._forecast_t_min or elapsed_min <= self._forecast_t_min[-1]: + return + last_theta = self._forecast_theta[-1] + self.line_projected.set_data(self._forecast_t_min + [elapsed_min], self._forecast_theta + [last_theta]) + self.draw_idle() + def _set_fixed_limits(self, t_list, theta_list): """Explicitly sets (and, unlike relim()+autoscale_view(), locks) the axes' view to fit exactly the given data, with matplotlib's @@ -187,30 +221,22 @@ class SudForecastPlot(FigureCanvasQTAgg): self.ax.set_xlim(t_lo - t_pad, t_hi + t_pad) self.ax.set_ylim(th_lo - th_pad, th_hi + th_pad) - def show_dynamic(self, history, t_rem_min, theta_rem, name): - """Re-anchored forecast for a run in progress: history is the - [(t_min, theta), ...] actually measured so far (drawn solid); - t_rem_min/theta_rem is the projected remainder (dashed), the - server's simulation-based SudForecastEstimator result for - whatever's left of the schedule, already anchored by the caller - (see Window.on_plot_timer()). Either can be None - meaning the - server hasn't recomputed it yet for the step just entered - in - which case the dashed line is simply left showing whatever it - last did, rather than guessing at a replacement.""" + def show_dynamic(self, history): + """Updates the actual measured trace (solid) only - the forecast + (dashed) is left exactly as show_forecast() last drew it (that's + the whole point: an honest, unmoving baseline - see this class's + docstring), and the axes stay locked to whatever it set, so + divergence between the two is visible rather than auto-hidden by + the view rescaling to chase whichever line is currently + drawing.""" hist_t = [t for t, _ in history] hist_theta = [theta for _, theta in history] self.line.set_data(hist_t, hist_theta) - if t_rem_min is not None: - self.line_projected.set_data(t_rem_min, theta_rem) - - self.ax.relim() - self.ax.autoscale_view() - total_min = t_rem_min[-1] if t_rem_min else (hist_t[-1] if hist_t else 0.0) - self.ax.set_title("{} - {:.0f} min total (est.)".format(name, total_min), fontsize='small') - self.figure.tight_layout() self.draw_idle() def show_no_schedule(self): + self._forecast_t_min = [] + self._forecast_theta = [] self.line.set_data([], []) self.line_projected.set_data([], []) self.clear_progress() @@ -286,10 +312,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): # load, which used to make the live trace visibly drift from the # forecast. self.sud_elapsed_seconds = None - # Resolved schedule + live progress within it, kept around so the - # forecast plot can be dynamically re-anchored every tick instead of - # only estimated once when the schedule was (re)loaded - see - # refresh_forecast()/SudForecastPlot.show_dynamic(). self.sud_name = '' self.sud_schedule = [] self.sud_step_index = None @@ -297,16 +319,9 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_step_type = None self.sud_hold_remaining = 0.0 # [(t_min, theta), ...] actually measured since the current run - # started - the "already happened" part of the dynamic forecast. + # started - the "actual" half of the forecast-vs-actual + # comparison (SudForecastPlot.show_dynamic()). self.forecast_history = [] - # (anchor_min, T, Theta) from the server's simulation-based estimate - # of the remaining steps, recomputed on every step change - - # anchor_min is elapsed_min as of receipt, so on_plot_timer() can - # re-anchor the projection without it drifting right every tick. - # None until it arrives (or after a step change, until the next one - # does), in which case on_plot_timer() falls back to the naive - # estimate. - self.server_remaining_forecast = None self.msg_pot = self.msg_dispatch.msgio_get('Pot') self.msg_sensor = self.msg_dispatch.msgio_get('Sensor') self.msg_heater = self.msg_dispatch.msgio_get('Heater') @@ -450,30 +465,20 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): if elapsed_min is not None: self.forecast_plot.set_progress(elapsed_min) - # Re-anchor the forecast from the live state instead of redrawing - # the static, increasingly-stale estimate from t=0. Frozen while - # PAUSED (elapsed_min isn't advancing either, and the paused - # phase - ramp or hold - isn't visible to the client to resume - # the projection from correctly) - just leave the last draw up. + # Frozen while PAUSED (elapsed_min isn't advancing either, and + # the paused phase - ramp or hold - isn't visible to the client + # to resume the trace from correctly) - just leave the last + # draw up. if self.sud_state != SUD_PAUSED_STATE and self.plot_temp_ist: self.forecast_history.append((elapsed_min, self.plot_temp_ist)) - if self.server_remaining_forecast is not None: - # Anchored to elapsed_min as of when the server computed - # this forecast, not the current tick's - it's only - # recomputed on step changes, so re-adding the live, - # ever-growing elapsed_min here would push the whole - # projection further right every tick until the next - # step change, instead of holding it fixed. - anchor_min, t_rem, theta_rem = self.server_remaining_forecast - t_rem_min = [anchor_min + seconds / 60.0 for seconds in t_rem] - self.forecast_plot.show_dynamic(self.forecast_history, t_rem_min, theta_rem, self.sud_name) - else: - # Server's simulation-based remainder hasn't arrived yet - # for the step just entered (recomputed off the event - # loop on every step change) - leave the dashed - # projection as whatever it last showed instead of - # guessing; it catches up within a tick or two. - self.forecast_plot.show_dynamic(self.forecast_history, None, None, self.sud_name) + self.forecast_plot.show_dynamic(self.forecast_history) + if self.sud_state == SUD_WAIT_USER_STATE: + # A human's confirm time can't be forecast - the dashed + # line just repeats its last value out to 'now' until + # the real confirmation appends the next segment (see + # tasks/sud.py's SudTask._continue_forecast_after_ + # confirm()). + self.forecast_plot.extend_forecast_while_waiting(elapsed_min) else: self.forecast_plot.clear_progress() @@ -523,7 +528,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_step_type = None self.sud_hold_remaining = 0.0 self.forecast_history = [] - self.server_remaining_forecast = None self.set_tc_cooling(False) self.update_sud_actions() self.update_status_env_label() @@ -839,10 +843,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_step_index = step.get('Index') self.sud_step_descr = step.get('Descr') self.sud_step_type = step.get('Type') - # Stale for the step that just ended - the server is - # already recomputing it (see on_plot_timer()'s fallback - # to the naive estimate in the meantime). - self.server_remaining_forecast = None self.update_status_step_label() elif "HoldRemaining" in key: self.sud_hold_remaining = msg['HoldRemaining'] @@ -857,15 +857,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): # a run starting. if self.sud_elapsed_seconds is not None: self.sud_elapsed_seconds = msg['Elapsed'] - elif "RemainingForecast" in key: - forecast = msg['RemainingForecast'] - # Anchor to elapsed_min as of receipt - close enough to - # when the server actually computed it (it's seeded from - # the live theta_ist at that moment) - rather than the - # live elapsed_min on_plot_timer() sees on later ticks. - anchor_min = self._elapsed_min() - if anchor_min is not None: - self.server_remaining_forecast = (anchor_min, forecast['T'], forecast['Theta']) elif "Forecast" in key: self.on_sud_forecast_received(msg['Forecast']) elif "Error" in key: @@ -879,16 +870,16 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.update_sud_actions() def on_sud_forecast_received(self, forecast): - # The server computes this asynchronously (it simulates the whole - # schedule) and it can arrive slightly after the schedule itself - - # only apply it if we're still looking at the static "not running - # yet" preview show_computing() drew; a run already in progress (or - # one that's since finished) owns the plot via show_dynamic()/its - # frozen last frame instead. - if self.sud_empty or self.sud_elapsed_seconds is not None: + # Computed asynchronously server-side and can arrive slightly + # after the schedule itself - applies regardless of whether a + # run is in progress, since this can also be a later piecewise + # segment appended after a real user confirmation (see + # tasks/sud.py's SudTask._continue_forecast_after_confirm()), not + # just the initial one computed at Load. + if self.sud_empty: return t_min = [seconds / 60.0 for seconds in forecast['T']] - self.forecast_plot.show_precomputed_course(t_min, forecast['Theta'], self.sud_name) + self.forecast_plot.show_forecast(t_min, forecast['Theta'], self.sud_name, forecast['Finished']) def update_status_step_label(self): if not self.sud_step_descr: diff --git a/components/sud_forecast.py b/components/sud_forecast.py index ae7eab4..fa06210 100644 --- a/components/sud_forecast.py +++ b/components/sud_forecast.py @@ -34,17 +34,27 @@ class SudForecastEstimator: self.theta_amb = theta_amb def estimate(self, doc, start_theta=None): - """Returns (t, theta): parallel lists of elapsed simulated seconds - and temperature, one point per tick, for the given sud.json - document. start_theta defaults to the configured ambient - temperature - i.e. a cold start, same as the GUI's static - estimate.""" + """Returns (t, theta, final_state): t/theta are parallel lists of + elapsed simulated seconds and temperature for one piecewise + segment of the schedule, starting from doc['steps'][0] - either + all the way to the end (final_state is SudState.DONE), or, if a + step requires user confirmation, only up to and including that + step's hold (final_state is SudState.WAIT_USER). A real user's + confirm time can't be forecast, so the simulation simply stops + there instead of guessing zero delay or otherwise - the caller is + expected to compute the next segment for real once the user + actually confirms (see tasks/sud.py's SudTask), anchored at + whatever the real elapsed time and temperature are by then, + rather than this estimate continuing to guess at it. + + start_theta defaults to the configured ambient temperature - i.e. + a cold start, same as the GUI's static estimate.""" if start_theta is None: start_theta = self.theta_amb sud = Sud() if not sud.load(doc) or not sud.schedule: - return [0.0], [start_theta] + return [0.0], [start_theta], SudState.DONE pot = Pot(self.dt, self.plant_params, self.theta_amb) pot.initial(start_theta) @@ -80,7 +90,7 @@ class SudForecastEstimator: sud.start() ticks = 0 - while sud.state != SudState.DONE and ticks < MAX_TICKS: + while sud.state not in (SudState.DONE, SudState.WAIT_USER) and ticks < MAX_TICKS: pot.process() tc.set_theta_ist(pot.get_temperature()) tc.process() @@ -89,14 +99,10 @@ class SudForecastEstimator: if sud.state == SudState.RAMPING: if tc.is_holding(): sud.temp_reached() - elif sud.state == SudState.WAIT_USER: - # A real user's confirm time isn't predictable - count it - # as instant for estimation purposes. - sud.confirm() sud.tick(self.dt) t.append(t[-1] + self.dt) theta.append(pot.get_temperature()) ticks += 1 - return t, theta + return t, theta, sud.state diff --git a/tasks/sud.py b/tasks/sud.py index db733e4..e4f0862 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -29,6 +29,18 @@ class SudTask(ATask): # build a SudTask without one still work; the server always passes # one. self.forecast_estimator = forecast_estimator + # Accumulated forecast across all piecewise segments computed so + # far (see send_forecast()/_continue_forecast_after_confirm()) - + # T in simulated seconds, Theta in degrees, parallel lists, both + # growing monotonically as segments get appended; never re-walked + # from scratch once a run is in progress, except for the one + # genuinely unforecastable case (a real user confirmation). + self.forecast_t = [] + self.forecast_theta = [] + # Whether the most recently computed segment stopped at a step + # requiring user confirmation rather than reaching the + # schedule's actual end - see _continue_forecast_after_confirm(). + self.forecast_waiting_for_confirm = False msg_handler.set_recv_handler(self.recv) def apply_plant_params(self, step): @@ -81,7 +93,6 @@ class SudTask(ATask): '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)})) @@ -108,6 +119,12 @@ class SudTask(ATask): asyncio.create_task(self.send({'Elapsed': value})) async def send_forecast(self, doc): + """Computes and sends the first piecewise segment of doc's + forecast - from the very start, up to either the schedule's end + or its first step requiring user confirmation (see + components/sud_forecast.py's SudForecastEstimator.estimate()). + Always a fresh start: resets any segments accumulated for + whatever was loaded before.""" if self.forecast_estimator is None: return # Runs the simulation in a worker thread - it's CPU-bound and can @@ -115,67 +132,57 @@ class SudTask(ATask): # 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}}) + t, theta, final_state = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc) + self.forecast_t = t + self.forecast_theta = theta + self.forecast_waiting_for_confirm = (final_state == SudState.WAIT_USER) + await self._send_forecast() - 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.""" + async def _send_forecast(self): + await self.send({'Forecast': { + 'T': self.forecast_t, + 'Theta': self.forecast_theta, + 'Finished': not self.forecast_waiting_for_confirm, + }}) + + async def _continue_forecast_after_confirm(self): + """Computes and appends the next piecewise segment once a real + user confirmation has actually happened, rather than guessing at + the delay - see SudForecastEstimator.estimate()'s docstring for + why. Anchored at the real elapsed time (self.sud.elapsed), so the + unforecastable wait shows up honestly as a gap in the forecast + rather than as zero delay, and at the real current temperature + (more trustworthy than whatever the now-superseded previous + segment's simulation predicted it would be by now).""" + if self.forecast_estimator is None or not self.forecast_waiting_for_confirm: + return 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: + if not (0 <= index < len(schedule)): + self.forecast_waiting_for_confirm = False return doc = { 'Name': self.sud.name, 'Description': self.sud.description, 'pot_mass': self.sud.pot_mass, 'pot_material': self.sud.pot_material, - 'steps': remaining, + 'steps': schedule[index:], } start_theta = self.tc.get_theta_ist() + real_elapsed = self.sud.elapsed 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}}) + t, theta, final_state = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta) + # Bridge the gap between where the previous segment's own + # simulated time left off and the real elapsed time the confirm + # actually happened at, holding flat at its last temperature - + # then append the new segment, offset to start exactly there. + if self.forecast_t and self.forecast_t[-1] < real_elapsed: + self.forecast_t.append(real_elapsed) + self.forecast_theta.append(self.forecast_theta[-1]) + self.forecast_t.extend(real_elapsed + seconds for seconds in t) + self.forecast_theta.extend(theta) + self.forecast_waiting_for_confirm = (final_state == SudState.WAIT_USER) + await self._send_forecast() async def recv(self, data): for pair in data.items(): @@ -183,6 +190,7 @@ class SudTask(ATask): self.sud.start() elif 'Confirm' in pair[0]: self.sud.confirm() + asyncio.create_task(self._continue_forecast_after_confirm()) elif 'Pause' in pair[0]: self.sud.pause() elif 'Stop' in pair[0]: