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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
This commit is contained in:
2026-06-22 16:33:40 +02:00
co-authored by Claude Sonnet 4.6
parent 41b06d8961
commit 57b86bc075
3 changed files with 172 additions and 167 deletions
+96 -105
View File
@@ -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:
+18 -12
View File
@@ -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
+58 -50
View File
@@ -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]: