Quickstart¶
qibocal_widgets.Executor is a drop-in replacement of
qibocal.Executor, composed of three roles:
frontends, displaying every output in a notebook (any number, all coexisting);
a runner, deciding where and how the steps run (exactly one: locally by default, or remotely, see Remote execution);
middleware, wrapping every step for anything else (timing, logging, caching, …).
Each notebook frontend lives in its own module:
module |
||
frontend |
|
|
The examples below use Marimo; replace marimo with jupyter and
Marimo with Jupyter for the other frontend. Both can also be
registered together: each uses its own view.
Implicit display¶
Create the executor exactly as a qibocal.Executor: all its
constructors (create, open, and the default one) and the context
manager are inherited, and additionally accept the frontends,
runner, and middleware arguments.
from qibocal import protocols
from qibocal_widgets import Executor
from qibocal_widgets.marimo import Marimo
executor = Executor.create(
platform="mock", path="var/report", update=False, frontends=[Marimo()]
)
bound = protocols.ramsey_signal(
delay_between_pulses_start=10,
delay_between_pulses_end=1000,
delay_between_pulses_step=50,
)
Every phase returns an execution which renders itself when it is the last expression of a cell:
with executor:
acquired = executor.acquire(bound, targets=[0, 1])
acquired
displays an accordion with one raw data table per target, while
reported = executor.report(executor.fit(acquired))
reported
displays two tabs: the raw data, and the per-target reports. Phases not yet executed are simply omitted, and an Error section appears for failed executions.
The initial layout is chosen to show the most relevant content first:
the Reports tab is selected whenever reports are available;
per-target accordions start folded, except when there is a single target, which starts expanded;
within each report, the plots are visible right away, while the fit results table sits above them, centered, in a nested Fit results section folded by default.
Everything can still be expanded or folded interactively.
The returned objects are qibocal_widgets.Completed, a subclass of
Qibocal’s Completed with exactly the same attributes (data, results, reports, targets, …):
existing code keeps working unchanged.
Explicit helpers¶
For custom layouts, the same content is available explicitly:
from qibocal_widgets.marimo import show_data, show_reports
show_data(reported.data)
show_reports(reported.reports)
# or, from the execution itself
show_data(reported)
show_reports(reported)
Sessions¶
An executor with an output folder records every execution in it, as a session: create the executor once, at the beginning of the notebook, and all the protocols executed with it are appended to the same folder, for as long as the instance lives.
executor = Executor.create(platform="chip", path="var/session", frontends=[Marimo()])
with executor:
rabi = executor(protocols.rabi_amplitude(...)) # var/session/data/rabi_amplitude-0
with executor:
ramsey = executor(protocols.ramsey(...)) # var/session/data/ramsey-0
The folder keeps the layout of Qibocal’s runcard outputs, so that Qibocal and the report server of Qibocamp read it unchanged:
every acquisition gets its own
data/<protocol>-<iteration>folder, with its parameters, data, and, once fitted, results (re-fitting overwrites them);executions coming from elsewhere (a plain Qibocal executor, a loaded folder) are recorded as new tasks when passed through any step;
history.jsonandmeta.json(with timings) are updated after every step, and the platform is saved at the start (platform/) and after updates (new_platform/).
Protocols not registered in qibocal.protocols are recorded too, but
kept out of history.json, since Qibocal cannot load them back.
To start over in a new folder, keeping the same executor:
executor.new_session("var/tomorrow")
To continue an existing folder with a new executor (e.g. after restarting the kernel), attach to it: the platform is restored to the latest state recorded in the folder, so the calibration continues where it was left.
executor = Executor.attach("var/session", frontends=[Marimo()])
Executors without a local platform (e.g. with a remote runner) attach through
an explicit session: Executor(None, session=Session.attach(path), ...).
With a remote runner, the recorded platform state is handed to it, and sent
along with its next acquisitions; its updates are recorded as well.
Creating an executor on an existing, non-empty folder raises
SessionExists, unless force (in
Executor.open) replaces it.
Notes¶
Notes on the session, or on one of its executions, are kept next to them, in
Qibocal’s notes.json, where the report server shows (and adds) them too:
executor.note("cooldown 3, fridge at 12 mK")
executor.note("fringes look off", ramsey)
executor.session.notes(ramsey) # read them back
The author defaults to the author of the session metadata (the user name). Writes share the report server’s lock, so notes added from the dashboard while the notebook runs are never lost.
Output location¶
Notebook sessions started by the Qibocal application, or by qibocal notebook
from Qibocamp, may have
a reports folder, exported as the QIBOCAMP_REPORTS_DIR environment
variable. Its report server serves that folder, so calibrations show up in the
dashboard while the session goes on.
Without an explicit path, Executor.create, Executor.open, and
new_session use a fresh subfolder of it, named
<YYYY-MM-DD>_<HH-MM-SS>_<user> (UTC):
executor = Executor.create(platform="chip", frontends=[Marimo()])
executor.path # e.g. <reports folder>/2026-10-20_14-03-11_alice
An explicit
pathalways wins, with its usual meaning (relative to the working directory).Without
pathand without a reports folder,createraises aTypeError, as before.qibocal_widgets.reports_dir()returns the folder (orNone), to build custom paths under it:from qibocal_widgets import reports_dir executor = Executor.create(platform="chip", path=reports_dir() / "rabi-tuning")
The variable can also be set by hand, e.g. for a plain jupyter lab.
Executions from other sources¶
Executions produced by a plain qibocal.Executor, or loaded from disk,
can be made displayable with wrap:
from qibocal_widgets.marimo import wrap
wrap(completed)
They are also converted automatically when passed through any phase of an
executor with the frontend, e.g. executor.fit(completed).
Examples¶
Two complete notebooks, for Marimo and Jupyter, tour all the features on a simulated platform: see Examples, with their executed outputs.
Approving updates¶
update=True, Qibocal’s default, applies the platform update of every fitted
execution right away. Interactively, the fit is usually validated first, by
looking at the plots: with update=False, executions offer the update
instead.
executor = Executor.create(platform="chip", targets=[0, 1], update=False,
frontends=[Marimo()])
rabi = executor(protocols.rabi_amplitude_signal(...))
rabi # with "Update all targets" above the reports, "Update" in each one
rabi.update() # all targets
rabi.update([0]) # some; those already applied are skipped
Buttons and code are equivalent: an applied update shows as “✓ Applied”, also
when applied from code or automatically, and failures offer to retry. The
returned list holds the targets applied now. Protocols whose update does not
take a target apply it to all targets at once. Pending executions offer the
same, once completed (pending.update()). With a remote runner and no local
platform, an update is a short job on the access node (see Remote execution).
An update applies the values fitted from the acquisition: approved later, it overwrites any change made in between to the same parameters.
Background execution¶
executor.submit(bound) runs executor(bound) in the background, and
returns a Pending execution at once: peek at it with
pending(), wait for it with pending.result(), or await pending. In
Marimo, it is reactive state: cells referencing it run again when it completes.
reuse=True returns an identical execution, running or recorded, instead of
acquiring again; confirm=True waits for a click on its Run button before
starting. Both can be set as executor defaults (see Remote execution). While acquiring, the display shows a progress bar from the
duration estimated by qibolab (see Remote execution). Executions are processed in
submission order; with the
local runner, acquisitions wait for each other, sharing the instruments, and
manage the platform connection themselves (no with executor: needed). It
matters most for remote execution, where jobs wait in a queue: see
Remote execution.
Extending the executor¶
Each role has a base class to subclass, overriding only what is needed.
Frontends (qibocal_widgets.Frontend) set the display
protocol they implement, and render executions:
from qibocal_widgets import Frontend
class Summary(Frontend):
protocol = "_display_" # Marimo's display protocol
def render(self, completed):
return f"{len(completed.targets)} targets, fitted: {completed.results is not None}"
The executor attaches all its frontends to every output, after the middleware.
Runners (qibocal_widgets.Runner) run each step. The base class
runs every step locally, as qibocal.Executor does; a runner executing
some steps elsewhere overrides only those, as
Remote does for the acquisition.
Middleware (qibocal_widgets.Middleware) wrap the steps, like in
web servers: each hook (open, close, acquire, fit,
report, update) receives the executor and the next callable of the
chain. The first middleware is the outermost, and the runner always the
innermost, so every middleware sees every step. A hook can alter the
arguments, post-process the output, or not call the next one at all. The
result hook transforms the output of every step.
import time
from qibocal_widgets import Middleware
class Timer(Middleware):
def acquire(self, executor, call_next, *args, **kwargs):
start = time.perf_counter()
completed = call_next(*args, **kwargs)
print(f"acquired in {time.perf_counter() - start:.1f} s")
return completed