qibocal-widgets in Jupyter¶

A tour of the features of qibocal-widgets, on a simulated platform: the qutrits platform of qibolab-simulator, copied in the platforms/ folder of the examples.

The executor is a drop-in replacement of Qibocal's, composed of frontends (displaying every output), a runner (where the steps run), and middleware (wrapping every step).

In [1]:
import json
import os
import shutil
import signal
import time
from pathlib import Path

import ipywidgets as widgets
from IPython.display import Markdown
from qibocal import Executor as QibocalExecutor
from qibocal import create_calibration_platform, protocols

from qibocal_widgets import (
    Executor,
    Middleware,
    PlatformState,
    data_frames,
    reports,
    reports_dir,
)
from qibocal_widgets.jupyter import Jupyter, display, show_data, show_reports, wrap

HERE = Path.cwd()  # Jupyter runs notebooks in their folder
os.environ["QIBOLAB_PLATFORMS"] = str(HERE.parent / "platforms")


def qutrits():
    """A fresh simulated platform: two coupled qutrits.

    qibolab replaces the interrupt and termination handlers: they are restored
    right after creating the platform, so that interrupting a cell behaves as
    usual.
    """
    handlers = {s: signal.getsignal(s) for s in (signal.SIGINT, signal.SIGTERM)}
    platform = create_calibration_platform("qutrits")
    for number, handler in handlers.items():
        signal.signal(number, handler)
    return platform

1. The executor and its session¶

Create the executor once, at the beginning of the notebook. Every execution is recorded in its session folder, in Qibocal's output layout. Within a session started by the Qibocal application (or qibocal notebook), the folder defaults to a fresh subfolder of the session's reports folder (QIBOCAMP_REPORTS_DIR), so that calibrations show up in its dashboard; otherwise, it is given explicitly.

In [2]:
if reports_dir() is None:
    path = HERE / "var" / "session"
    if path.exists():
        shutil.rmtree(path)
else:
    path = None  # a fresh folder under reports_dir()

executor = Executor.create(
    path=path,
    platform=qutrits(),
    targets=[0, 1],
    update=False,
    frontends=[Jupyter()],
)
executor.path
/home/runner/work/qibocal-widgets/qibocal-widgets/.venv/lib/python3.12/site-packages/qutip/__init__.py:24: UserWarning: matplotlib not found: Graphics will not work.
  warnings.warn("matplotlib not found: Graphics will not work.")
Out[2]:
PosixPath('/home/runner/work/qibocal-widgets/qibocal-widgets/examples/jupyter/var/session')

2. Implicit display, step by step¶

Every step returns an execution which displays itself: raw data after the acquisition, then reports (selected by default), with the fit table folded above the plots.

In [3]:
bound = protocols.ramsey(delay=(10, 1000, 100))
with executor:
    acquired = executor.acquire(bound, nshots=500)
acquired
Out[3]:
In [4]:
fitted = executor.fit(acquired)
ramsey = executor.report(fitted)
ramsey
Out[4]:

The execution is recorded in the session, where notes can be attached to it (and to the session itself): they are shown, and can be added, in the report dashboard too.

In [5]:
executor.note("Two coupled qutrits, simulated")
executor.note("Fringes as expected", ramsey)
{
    "task folder": ramsey.path.relative_to(executor.path),
    "notes": [note.content for note in executor.session.notes(ramsey)],
}
Out[5]:
{'task folder': PosixPath('data/ramsey-0'), 'notes': ['Fringes as expected']}

3. Updating the platform¶

A full execution (executor(bound)) acquires, fits, and reports. The executor is created with update=False: the platform is updated once the fit is validated, with the Update buttons above the reports (all targets, or per target, inside each report), or from code with rabi.update(). (With update=True, it is updated right away, and the buttons show it.) A snapshot of the platform state taken before shows what changed.

In [6]:
start = executor.platform_state.snapshot()
with executor:
    rabi = executor(
        protocols.rabi_amplitude_signal(
            min_amp=0.0, max_amp=0.6, step_amp=0.03, pulse_length=40
        ),
        nshots=500,
    )
rabi
Out[6]:
In [7]:
rabi.update([0])  # the fit of target 0 looks right: approve it alone
executor.platform_state.diff(start)
Out[7]:
{'parameters.native_gates.single_qubit.0.RX.0.1.amplitude': (0.1265,
  0.12609339128568486)}

Every change of the platform state is recorded in the history of the session: compare any two entries (A and B), and restore one, entirely or for the selected paths.

In [8]:
executor.history.explore()
Out[8]:

4. Exploring and editing the platform state¶

The explorer shows parameters and calibration by path, highlighting the differences with the snapshot. Its navigation bar accepts paths, or pasted code; buttons copy paths and values; with editable=True, edits go through the same API, and show the equivalent code.

In [9]:
executor.platform_state.explore(
    "native_gates.single_qubit.0", editable=True, compare=start
)
Out[9]:

The same paths work from code, in any of the forms the explorer accepts: full paths, paths within a document (as copied from the dashboard), or pasted code.

In [10]:
state = executor.platform_state
frequency = "parameters.configs.0/drive.frequency"
before = state[frequency]
state.update({"configs.0/drive.frequency": before + 1e6})
{
    "before": before,
    "after": state[frequency],
    "resolved": state.resolve(f'executor.platform_state["{frequency}"]'),
    "qubit frequencies": state.find("frequency_01"),
    "session start": PlatformState.load(executor.path / "platform")[frequency],
}
Out[10]:
{'before': 4813586970.0,
 'after': 4814586970.0,
 'resolved': 'parameters.configs.0/drive.frequency',
 'qubit frequencies': {'calibration.single_qubits.0.qubit.frequency_01': 4813586970.0,
  'calibration.single_qubits.1.qubit.frequency_01': 5518133438.0},
 'session start': 4813586970.0}

5. Explicit helpers¶

For custom layouts, the views are available explicitly, as well as the frontend-agnostic content (tables and normalized reports). Executions from a plain Qibocal executor are made displayable with wrap.

In [11]:
tabs = widgets.Tab(children=[show_data(ramsey), show_reports(ramsey), display(ramsey)])
for index, title in enumerate(["show_data", "show_reports", "display"]):
    tabs.set_title(index, title)
tabs
Out[11]:
In [12]:
frames = data_frames(ramsey.data)
normalized = reports(ramsey)
Markdown(
    f"`data_frames`: {len(frames)} tables, columns {list(frames[0].columns)}\n\n"
    f"`reports`: {len(normalized[0].figures)} figure(s) per target, "
    f"fit table of {len(normalized[0].table)} characters"
)
Out[12]:

data_frames: 2 tables, columns ['wait', 'prob', 'error']

reports: 1 figure(s) per target, fit table of 716 characters

In [13]:
platform = qutrits()
plain = QibocalExecutor(platform, targets=[0], update=False)
platform.connect()
completed = plain(protocols.ramsey(delay=(10, 1000, 100)), nshots=200)
platform.disconnect()
wrap(completed)
Out[13]:

6. Middleware¶

Middleware wrap every step, like in web servers. A timer, around the acquisition, on a second executor appending to the same session folder (Executor.attach, e.g. after restarting the kernel): it restores the latest recorded platform state, so the calibration continues from there.

In [14]:
class Timer(Middleware):
    def __init__(self):
        self.times = []

    def acquire(self, executor, call_next, *args, **kwargs):
        start = time.perf_counter()
        completed = call_next(*args, **kwargs)
        self.times.append(time.perf_counter() - start)
        return completed


timer = Timer()
attached = Executor.attach(
    executor.path,
    platform=qutrits(),
    targets=[0, 1],
    update=False,
    middleware=[timer],
    frontends=[Jupyter()],
)
with attached:
    attached(protocols.ramsey(delay=(10, 1000, 100)), nshots=500)
{
    "acquisition time": f"{timer.times[0]:.1f} s",
    "restored frequency": attached.platform_state["configs.0/drive.frequency"],
    "history": json.loads((attached.path / "history.json").read_text()),
}
Out[14]:
{'acquisition time': '3.9 s',
 'restored frequency': 4814586970.0,
 'history': ['ramsey-0', 'rabi_amplitude_signal-0', 'ramsey-1']}

7. Sessions on disk¶

Recorded executions can be loaded back, displayed, and the executor can move on to a new session folder, for instance for a new cooldown.

In [15]:
history = json.loads((attached.path / "history.json").read_text())
attached.load(attached.path / "data" / history[0])
Out[15]:
In [16]:
first = attached.path
attached.new_session(first.parent / "next")
{"previous session": first.name, "new session": attached.path.name}
Out[16]:
{'previous session': 'session', 'new session': 'next'}

Next: remote execution¶

To run the same protocols on a machine with access to the instruments, from a laptop or an access node, see the remote notebook next to this one.