diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index c1755e5..c9ef99f 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -273,14 +273,15 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): # that arrives, or for a server that doesn't send it at all. self.ambient_temp = None self.warp_factor = 1.0 - # time.monotonic() of the last IDLE/DONE -> running transition, used - # to position the forecast plot's progress line; None while not - # running. sud_paused_total accumulates time spent PAUSED so the - # progress line freezes during a pause instead of jumping ahead; - # sud_pause_started_at is when the current pause began, if any. - self.sud_start_time = None - self.sud_paused_total = 0.0 - self.sud_pause_started_at = None + # Elapsed simulated seconds of the current run, as reported by the + # server (Sud's own tick-counted 'elapsed' - see components/sud.py) - + # None while not running. Used to position the forecast plot's + # progress line; ground truth, not reconstructed from wall-clock + # time, since the server's actual achieved speedup runs measurably + # below its nominal configured warp factor under real scheduling + # 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 @@ -428,16 +429,12 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): def _elapsed_min(self): """Simulated-schedule minutes elapsed since the run started, or - None if no run is in progress. While paused, uses the moment the - pause began as "now" so it freezes instead of drifting ahead of - actual progress.""" - if self.sud_start_time is None: + None if no run is in progress - straight from the server's own + tick-counted Sud.elapsed (see on_sud_changed()'s 'Elapsed' + handling), already frozen while PAUSED there.""" + if self.sud_elapsed_seconds is None: return None - now = self.sud_pause_started_at if self.sud_pause_started_at is not None else time.monotonic() - elapsed_real_s = now - self.sud_start_time - self.sud_paused_total - # The forecast's x-axis is simulated-schedule time; scale real - # elapsed time by the server's warp factor to match it. - return elapsed_real_s * self.warp_factor / 60.0 + return self.sud_elapsed_seconds / 60.0 def on_plot_timer(self): self.plot.sample( @@ -538,9 +535,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.lineEdit_ambient.clear() self.btn_pot_reset.setVisible(False) self.warp_factor = 1.0 - self.sud_start_time = None - self.sud_paused_total = 0.0 - self.sud_pause_started_at = None + self.sud_elapsed_seconds = None self.sud_name = '' self.sud_schedule = [] self.sud_step_index = None @@ -823,24 +818,16 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): prev_state = self.sud_state self.sud_state = msg['State'] - if self.sud_state == SUD_PAUSED_STATE and prev_state != SUD_PAUSED_STATE: - self.sud_pause_started_at = time.monotonic() - elif self.sud_state != SUD_PAUSED_STATE and prev_state == SUD_PAUSED_STATE: - self.sud_paused_total += time.monotonic() - self.sud_pause_started_at - self.sud_pause_started_at = None - - if self.sud_state in SUD_RUNNING_STATES and self.sud_start_time is None: - self.sud_start_time = time.monotonic() + if self.sud_state in SUD_RUNNING_STATES and self.sud_elapsed_seconds is None: + self.sud_elapsed_seconds = 0.0 # Start the dynamic forecast's measured-history trace # fresh for this run. self.forecast_history = [] elif self.sud_state not in SUD_RUNNING_STATES and self.sud_state != SUD_PAUSED_STATE: - was_running = self.sud_start_time is not None - self.sud_start_time = None - self.sud_paused_total = 0.0 - self.sud_pause_started_at = None + was_running = self.sud_elapsed_seconds is not None + self.sud_elapsed_seconds = None # on_plot_timer() only redraws the dynamic forecast while - # sud_start_time is set. + # sud_elapsed_seconds is set. if was_running: # A run just finished or was stopped - leave the # dynamic view's last frame (the actual measured @@ -876,6 +863,8 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): elif "HoldRemaining" in key: self.sud_hold_remaining = msg['HoldRemaining'] self.update_status_step_label() + elif "Elapsed" in key: + self.sud_elapsed_seconds = msg['Elapsed'] elif "RemainingForecast" in key: forecast = msg['RemainingForecast'] # Anchor to elapsed_min as of receipt - close enough to @@ -896,7 +885,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): # yet" preview show_schedule() 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_start_time is not None: + if self.sud_empty or self.sud_elapsed_seconds is not None: return t_min = [seconds / 60.0 for seconds in forecast['T']] self.forecast_plot.show_precomputed_course(t_min, forecast['Theta'], self.sud_name) diff --git a/components/sud.py b/components/sud.py index ecc5638..bf20191 100644 --- a/components/sud.py +++ b/components/sud.py @@ -106,6 +106,7 @@ class Sud(AttributeChange): self.step = None self.user_message = None self._paused_from = None + self.elapsed = 0.0 def save(self): """Returns the currently running sud.json document (EMPTY_SUD if @@ -161,6 +162,7 @@ class Sud(AttributeChange): if self.state not in (SudState.IDLE, SudState.DONE): return self.index = -1 + self.elapsed = 0.0 self._advance() def pause(self): @@ -195,6 +197,16 @@ class Sud(AttributeChange): self._finish_step() def tick(self, dt): + """Advances the run by dt simulated seconds. 'elapsed' is the tick- + counted ground truth for how far the run has actually progressed - + clients used to reconstruct this themselves from wall-clock time + times the configured warp factor, but the warp factor is only + nominal (real asyncio/Python scheduling overhead means the actual + achieved speedup runs measurably below it, especially under heavy + message/print load), making that reconstruction drift from the + truth. Frozen while PAUSED/IDLE/DONE, same as hold_remaining.""" + if self.state not in (SudState.IDLE, SudState.DONE, SudState.PAUSED): + self.elapsed += dt if self.state == SudState.HOLDING: self.hold_remaining -= dt if self.hold_remaining <= 0: diff --git a/tasks/sud.py b/tasks/sud.py index d1a6252..ec38653 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -104,6 +104,9 @@ class SudTask(ATask): 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): if self.forecast_estimator is None: return @@ -204,6 +207,7 @@ class SudTask(ATask): 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}))