RFC 0004 — Calibration Tuners
- Status: Implemented — except §9's manual verification, which needs a chip. RFC 0005 completes the graph this one framed.
- Author: Martin Ahindura
- Created: 2026-07-30
- Depends on: RFC 0001 (driver framework, events), RFC 0003 (operations and devices)
- Touches:
qpi-driver(Python and, for the operation enum, Go and TypeScript),qpi-ui(Go/PocketBase), dashboard (React)
1. The idea
Superconducting transmon qubits suffer from continuous parameter drift (qubit
frequencies, pulse amplitudes, coherence times) on timescales of minutes to
hours due to TLS fluctuations, thermal variations, and magnetic flux noise.
Today, qpi can store calibration parameters in quantify.device.yml and
execute circuits through the process operation, but it has no mechanism to
produce or maintain those parameters. A lab operator must run calibration
experiments externally, manually update the YAML, and hope parameters haven't
drifted by the time jobs run.
This RFC adds a third operation — calibrate — and two devices,
quantify_tuner (using quantify-scheduler) and qblox_tuner (using
qblox-scheduler), that automate the full calibration lifecycle from initial
bring-up through continuous drift recalibration for superconducting transmon
chips with flux-tunable couplers on the Qblox hardware stack.
2. Vocabulary
| Term | Meaning |
|---|---|
| Tuner | The calibration backend, analogous to Executor for process. A Tuner runs calibration routines against a QuantumDevice and updates its parameters. |
| Routine | A single calibration experiment (e.g. Rabi, Ramsey, T1). Routines are nodes in a dependency DAG. |
| Calibration DAG | The directed acyclic graph of routine dependencies. A full calibration walks the DAG in topological order. |
| Fidelity check | A lightweight benchmarking pass (quick RB) used for periodic drift detection. |
3. Background & academic references
The calibration workflow for transmon qubits is well-established in the literature and follows a structured dependency graph:
| # | Experiment | Depends on | Calibrates | Reference |
|---|---|---|---|---|
| 1 | Resonator Spectroscopy | — | Resonator frequency $f_r$ | Koch et al., PRA 2007 |
| 2 | Resonator Punchout | 1 | Optimal readout power | Qibocal docs |
| 3 | Qubit Spectroscopy (Two-Tone) | 1, 2 | Qubit frequency $f_{01}$ | Schuster et al., Nature 2007 |
| 4 | Rabi Oscillation | 3 | $\pi$-pulse amplitude (amp180) |
Vion et al., Science 2002 |
| 5 | Ramsey Interferometry | 4 | Fine $f_{01}$, $T_2^*$ | Ramsey, Phys. Rev. 1950 |
| 6 | $T_1$ Measurement | 4 | Relaxation time $T_1$ | — |
| 7 | $T_2$ Echo (Hahn/CPMG) | 4 | Dephasing time $T_2$ | Bylander et al., Nature Physics 2011 |
| 8 | DRAG Calibration | 5 | Motzoi parameter $\beta$ | Motzoi et al., PRL 2009 (arXiv:0901.0534) |
| 9 | AllXY Diagnostic | 8 | Validates 1Q gate params | Reed, PhD thesis, Yale 2013 |
| 10 | Fine Amplitude Cal. | 8 | Sub-% amplitude correction | — |
| 11 | Single-Qubit RB | 10 | 1Q gate fidelity $F_{1Q}$ | Magesan et al., PRL 2011 (arXiv:1009.3639) |
| 12 | Flux Spectroscopy | 3 | Coupler freq. vs. flux | — |
| 13 | CZ Chevron | 11, 12 | CZ amp/duration | DiCarlo et al., Nature 2009 |
| 14 | Conditional Phase | 13 | CZ $\phi_{\text{cond}} = \pi$ | Sung et al., PRX 2021 (arXiv:2011.01261) |
| 15 | Two-Qubit RB | 14 | 2Q gate fidelity $F_{2Q}$ | Magesan et al., PRA 2012 (arXiv:1109.6887) |
| 16 | Interleaved RB (CZ) | 14 | CZ-specific fidelity | Magesan et al., PRL 2012 (arXiv:1203.4550) |
The three RB citations are deliberately distinct and easy to conflate: 1009.3639 is the scalable-and-robust RB protocol (PRL 106, 180504), 1109.6887 is the general multi-qubit characterisation framework the fidelity formula comes from (PRA 85, 042311), and 1203.4550 is interleaved RB (PRL 109, 080505).
Recent advances in automated calibration:
- Millisecond-Scale Calibration and Benchmarking of Superconducting Qubits (arXiv:2602.11912): a closed-loop on-FPGA recalibration protocol, reporting more than 74,000 consecutive recalibrations over 6 hours of continuous operation. (Preprint. Nothing in the design below depends on it; it is cited as motivation only.)
- CMA-ES Optimization (arXiv:2509.08555): CMA-ES outperforms Nelder-Mead for high-dimensional pulse optimization.
- End-to-End Framework (arXiv:2501.17825): Theoretical framework linking hardware design to calibration optimization.
- Qibocal (qibo.science): Open-source automated calibration with runcard-defined DAGs — the closest existing framework.
4. Decisions
Recorded here rather than in a separate ADR, per the RFC conventions.
| Decision | Resolution |
|---|---|
| Device naming | quantify_tuner (quantify-scheduler) and qblox_tuner (qblox-scheduler) |
| Event type | New CALIBRATION_RESULT — structurally different from JOB_RESULT |
| YAML write-back | Driver-only (local). QPI-UI does not manage calibration files |
| Trigger model | Both: QPI-UI dispatches CalibrateDispatch + driver self-schedules periodic fidelity checks |
| v1 scope | Full DAG (steps 1–16), individually disableable |
| Package location | New tuners/ at same level as executors/, mirroring its structure |
| Dispatch transport | A calibration_requests collection the existing driver dispatcher polls — not a direct socket write from an HTTP handler. §6.8 |
| Result persistence | A calibration_results collection. This reverses an earlier decision here in favour of the events log; §6.8 records why. |
| pyproject.toml | Alias extras quantify_tuner/qblox_tuner pointing at the scheduler extras, following the existing qiskit_aer = ["qpi-driver[aer]"] precedent. The tuners add no dependency of their own. §6.9 |
Why a new operation rather than a new device
calibrate is a third value in an enum RFC 0003 §13.1 calls closed on purpose:
every value needs a server-side handler, so growing it is a coordinated change
across three SDKs and QPI-UI (§6.1 lists the whole blast radius). That cost is
worth paying only because the two alternatives are worse:
- A
processdevice. The contract ofprocessis "a job is pushed to you, you emit aJobResult", and a job is a user-submitted circuit that bills against that user's QPU seconds. A calibration run bills nobody, produces no counts, and takes hours. It would have to be smuggled throughquantum_jobswith a sentinel payload, and every consumer of the jobs collection — the scheduler, the billing deduction inhandleDriverJobResult, the dashboard's job list — would need to learn to skip it. - A
monitordevice. A monitor never handles an inbound event (BlueforsGen1Driver.handle_eventlogs and drops everything). Calibration is triggered as well as scheduled, so it needs the inbound half.
The distinguishing property is genuinely the contract, not the backend, which is exactly what RFC 0003 says an operation is.
Rejected. Writing calibration parameters back through QPI-UI: the device
YAML is read by the process driver on the same node, so a round trip through
the server would add a network partition between a file's two local users for no
gain. Deriving the DAG from the routines' depends_on alone at import time: it
is derived from them, but the enabled set comes from calibration.yml, so the
graph is built per run rather than once per process. One set of routines per
scheduler: the two expose the same gate vocabulary, so the duplication buys
nothing and costs a second copy to keep in step — §6.2 records what to do with
the differences that are real.
5. How it works
flowchart LR
UI["QPI-UI"] -->|"CalibrateDispatch"| Drv["CalibrateDriver"]
Drv -->|"CalibrationResult"| UI
Drv -->|"worker subprocess"| W["Tuner (quantify / qblox)"]
W -->|"calibration DAG"| HW["Qblox Cluster + Chip"]
W -->|"write-back"| YML["quantify.device.yml"]
Timer["self.every()"] -.->|"fidelity_check"| Drv
- Dispatch (QPI-UI or periodic timer): A
CalibrateDispatchevent reaches the driver with a mode (full,partial,fidelity_check) and optional target qubits/edges. - Worker: The driver drops the request onto a job queue. A subprocess
running the
Tunerpicks it up. - DAG walk: The tuner builds a
CalibrationDAGfrom the enabled routines, topologically sorts it, and runs each routine: build schedule → compile → acquire → fit → apply → persist. - Report: A
CalibrationResultevent is emitted back with per-qubit parameters, benchmarks, and status. - Drift monitoring: If
drift_check_interval > 0, the driver usesself.every()to run periodic lightweight RB checks. If fidelity drops below threshold, a partial recalibration is triggered automatically.
The full calibration DAG
graph TD
RS["Resonator Spectroscopy"] --> RP["Resonator Punchout"]
RS --> QS["Qubit Spectroscopy"]
RP --> QS
QS --> RABI["Rabi Oscillation"]
RABI --> RAMSEY["Ramsey"]
RABI --> T1["T1"]
RABI --> T2["T2 Echo"]
RAMSEY --> DRAG["DRAG Calibration"]
DRAG --> ALLXY["AllXY Diagnostic"]
DRAG --> FINEAMP["Fine Amplitude"]
FINEAMP --> RB1Q["Single-Qubit RB"]
QS --> FS["Flux Spectroscopy"]
RB1Q --> CZC["CZ Chevron"]
FS --> CZC
CZC --> CP["Conditional Phase"]
CP --> RB2Q["Two-Qubit RB"]
CP --> IRB["Interleaved RB"]
6. Implementation
The changes span both the Python driver (qpi-driver/py) and the Go server
(qpi-ui).
6.1 New operation & event types
An operation is closed across all three SDKs and the server (RFC 0003 §8,
§13.1), and the event-type list is closed in the same way. Adding calibrate
is therefore not a Python-only change; every row below is required for the
build to stay green, and several are guarded by tests that assert the current
sets are exactly what they are.
| Where | Change | Note |
|---|---|---|
qpi-driver/py/qpi_driver/builtins/registry.py |
CALIBRATE = "calibrate" on Operation |
_DEVICES is built per operation, so this alone opens the registry slot |
qpi-driver/py/qpi_driver/events.py |
CALIBRATE_DISPATCH, CALIBRATION_RESULT on EventType |
|
qpi-driver/go/devices |
Calibrate const, Operations()/OperationNames() |
TestOperationsAreAClosedSet in devices_test.go asserted a pair; it becomes a triple, and was renamed for it |
qpi-driver/go/events.go |
CalibrateDispatch, CalibrationResult |
|
qpi-driver/js/src/devices.ts |
Operation.Calibrate and the allOperations array |
same closed-set assertion as Go |
qpi-driver/js/src/events.ts |
the two event types | |
qpi-ui/internal/drivers/drivers.go |
Calibrate Operation = "calibrate", QuantifyTuner/QbloxTuner Kind consts |
|
qpi-ui/internal/drivers/catalog.go |
eventCalibrateDispatch/eventCalibrationResult, a calibrateOptions() helper mirroring processOptions(), and the two specs |
Languages: []Language{Python} — no other SDK ships a tuner |
qpi-ui/internal/api/schema.go |
both types in the EventType consts and in AllEventTypes |
driver registration validates a driver's chosen events against this list, and drivers_test.go asserts every catalog event name has a handler |
qpi-ui/internal/api/nng_driver.go |
registry.Register(EventCalibrationResult, handleCalibrationResult) |
|
README.md, docs/driver/operations.md |
the two-operation prose | both currently enumerate process and monitor as the whole set |
Neither the Go nor the TypeScript SDK ships a tuner device — as with process,
they gain the operation name and must say plainly that they ship no devices for
it (RFC 0003 §8) rather than report an empty list.
6.2 Tuners package (qpi_driver/tuners/)
Mirrors the executors/ structure, with one deliberate difference: there is
one set of routines, not one per scheduler.
quantify-scheduler and qblox-scheduler expose the same gate vocabulary — Rxy,
X, Reset, Measure, CZ, IdlePulse, SquarePulse, SetClockFrequency —
under two import paths. A routine composed against that vocabulary is therefore
backend-agnostic already, and a per-backend copy would be sixteen more files to
keep in step for no gain. Two seams carry everything that genuinely differs:
base/backend.py— aSchedulerBackendbinds one scheduler's operation classes and knows how to turn a finished schedule into a dataset. quantify compiles and drives an instrument coordinator; qblox hands the schedule to aHardwareAgent. That is the whole of the difference in execution.base/device.py— the device models are not the same shape. quantify builds on qcodes, where a parameter is callable (element.rxy.amp180()); qblox uses pydantic, where it is a plain attribute. They also disagree on names: the DRAG coefficient ismotzoiin one andbetain the other. A routine names the concept and this module finds whichever the device has.
qpi_driver/tuners/
├── __init__.py # Tuner, resolve_tuner()
├── base/
│ ├── __init__.py # Tuner ABC — the DAG walk and write-back live here
│ ├── backend.py # SchedulerBackend: the operations a routine composes
│ ├── config.py # CalibrationConfig (from calibration.yml)
│ ├── dag.py # CalibrationDAG — graph, topological sort, the walk
│ ├── device.py # Reading/writing device parameters across both models
│ ├── report.py # CalibrationReport, RoutineResult, BenchmarkResult
│ └── routines.py # CalibrationRoutine ABC
├── routines/ # ONE set, shared by every tuner
│ ├── __init__.py # ROUTINE_CLASSES, all_routines(), routine_names()
│ ├── spectroscopy.py # resonator, punchout, qubit, flux
│ ├── single_qubit.py # rabi, ramsey, t1, t2_echo, drag, allxy, fine_amplitude
│ ├── two_qubit.py # cz_chevron, conditional_phase
│ └── benchmarks.py # rb, interleaved_rb, allxy_check
├── quantify/__init__.py # QuantifyTuner + QuantifyBackend
├── qblox/__init__.py # QbloxTuner + QbloxBackend
├── fitting/ # Backend-agnostic curve fits
│ ├── __init__.py
│ ├── core.py # FitError, range guards, dataset access
│ ├── lorentzian.py # spectroscopy, punchout
│ ├── cosine.py # rabi, ramsey, drag, fine amplitude
│ ├── exponential.py # t1, t2, RB decay
│ └── chevron.py # CZ chevron, conditional phase
└── utils/
├── __init__.py
├── persistence.py # Verified, atomic YAML write-back (§10)
└── clifford.py # The single-qubit Clifford group, for RB
A tuner therefore supplies a backend and a device, and inherits the DAG walk,
the routines, the fitting and the write-back. Adding a third scheduler is a
SchedulerBackend and a Tuner; it is not sixteen more experiments.
6.3 Tuner abstract base class
class Tuner(ABC):
"""Runs calibration routines against a quantum device and updates it."""
def __init__(self, name: str, **kwargs): ...
@property
@abstractmethod
def backend(self) -> SchedulerBackend: ...
@property
@abstractmethod
def device(self) -> Any: ...
def calibrate(self, config: CalibrationConfig) -> CalibrationReport: ...
def recalibrate(self, qubits: list[str], config: CalibrationConfig) -> CalibrationReport: ...
def check_fidelity(self, config: CalibrationConfig) -> CalibrationReport: ...
def close(self) -> None: ...
The three entry points are the same DAG walk over different subsets — the whole enabled graph, a partial run, the benchmarks alone — so they are implemented once on the base class rather than per tuner.
recalibrate narrows twice, and both narrowings are what make it worth having
over a full run. The targets narrow to the given qubits plus any edge
touching one of them. The routines narrow to RECALIBRATION_ROOTS
(qubit_spectroscopy) and everything downstream of it: downstream because
re-finding a frequency invalidates the gates tuned against it, and rooted there
because the readout chain is a bring-up step rather than a drift one and is most
of the cost of a full calibration. A drift the qubit routines cannot fix
therefore needs a full run, which is an operator's decision rather than the
drift check's.
check_fidelity returns a CalibrationReport rather than a bare
dict[str, float] so that every mode emits the same payload shape;
CalibrationReport.fidelities() is what reduces it to the per-target numbers a
threshold is compared against, taking the worst protocol per target because a
drift check should fire on the worst evidence it has.
6.4 Calibration routine interface
class CalibrationRoutine(ABC):
"""One node in the calibration DAG."""
name: str
depends_on: tuple[str, ...] = ()
targets: Literal["qubits", "edges"] = "qubits"
updates: tuple[str, ...] = () # device parameters written
benchmark: bool = False # whether its result is a gate fidelity
@abstractmethod
def build_schedule(self, target, device, config, backend) -> Schedule: ...
@abstractmethod
def analyse(self, dataset, target, device, config) -> dict[str, Any]: ...
def apply(self, device, target, params) -> None: ...
benchmark is declared rather than inferred from an empty updates: T1 writes
no device parameter either, and recording it as a benchmark would put a None
fidelity in front of the drift check.
analyse raises rather than returning zeros when it cannot fit. This is the
difference between a calibration that fails and one that quietly writes
t1 = 0.0 to the device as though it had measured it.
6.5 CalibrateDriver (builtins/calibrate.py)
Follows QpuDriver's subprocess-isolation pattern:
CALIBRATE_DEVICES = ("quantify_tuner", "qblox_tuner")
class CalibrateDriver(QpiDriver):
"""Calibrates transmon qubits via a tuner backend, emitting CalibrationResult events."""
def __init__(self, *, tuner, calibration_config, drift_check_interval=0,
fidelity_threshold=0.999, fidelity_2q_threshold=0.99, ...):
...
if drift_check_interval > 0:
self.every(drift_check_interval, self._check_fidelity)
def handle_event(self, event):
if event.type is EventType.CALIBRATE_DISPATCH:
self._job_queue.put(event.payload)
else:
log.warning(
"dropping event %s: calibrate driver does not handle %s",
event.id,
event.type.value,
)
def _check_fidelity(self):
"""Periodic lightweight RB; triggers recalibration on drift."""
self._job_queue.put({"mode": "fidelity_check"})
Registering the timer in __init__ is safe even though _job_queue is created
in _on_start: QpiDriver.run calls _on_start() before _start_periodic(),
which is the same order BlueforsGen1Driver relies on.
Unlike a QPU's worker, this one is long-running per item — a full DAG walk is
hours, not seconds. Two consequences the implementation has to face rather than
inherit: _on_stop's two-second join before terminate() will always time out
mid-calibration, so the poison pill needs a cooperative cancellation check
between routines if a clean stop is to mean anything; and a routine that hangs
on an instrument has no job_timeout equivalent, so the per-routine timeout
belongs in calibration.yml (§6.6).
Key -o options:
| Option | Default | Description |
|---|---|---|
quantify_device_config |
./quantify.device.yml |
Device calibration YAML |
quantify_hardware_config |
./quantify.hardware.json |
Hardware connectivity config |
calibration_config |
./calibration.yml |
Routines, sweep ranges, thresholds |
drift_check_interval |
0 (disabled) |
Seconds between periodic RB fidelity checks |
fidelity_threshold |
0.999 |
1Q gate fidelity recalibration trigger |
fidelity_2q_threshold |
0.99 |
2Q gate fidelity threshold |
is_dummy |
false |
Use dummy Qblox cluster |
Named drift_check_interval rather than monitor_interval because monitor is
already an operation, and an option on a calibrate device that appears to name
a different operation is the kind of collision this vocabulary is careful about
(§2, RFC 0003 §2). The three -o keys shared with process — the two quantify
paths and is_dummy — keep their existing names and defaults deliberately: a
tuner and the QPU beside it read the same two files, and an operator who had to
spell the same path two different ways would eventually spell it two different
values.
All of them have working defaults, so — as with process — a tuner starts with
no -o at all, and a missing calibration.yml is the error that surfaces
first. Reading an option is what declares it (RFC 0003 §13.6), so the catalog's
calibrateOptions() must list exactly the keys build_from_options reads: an
option nothing reads is rejected at startup, so a key in the catalog and not in
the builder turns a generated snippet into a driver that refuses to launch.
6.6 Calibration config (calibration.yml)
target_qubits: [q0, q1, q2]
target_edges: [q0_q1, q1_q2]
# Per-routine wall-clock ceiling. The `process` operation's job_timeout has no
# equivalent here — a routine that hangs on an instrument would otherwise hang
# the worker for the life of the driver.
routine_timeout_s: 900
routines:
resonator_spectroscopy:
freq_range: [5.8e9, 6.5e9]
freq_step: 0.5e6
readout_power_dbm: -30
rabi:
amp_range: [0.0, 0.5]
amp_step: 0.005
ramsey:
delay_range: [4e-9, 10e-6]
n_points: 50
artificial_detuning: 1e6
# ... (all 16 routines configurable)
monitoring:
rb_depths: [1, 10, 50]
n_circuits_per_depth: 10
shots: 512
allxy_as_smoke_test: true
6.7 Benchmarking
Benchmarking routines sit at the leaves of the DAG. They produce fidelity metrics but don't update device parameters.
Randomized Benchmarking (protocol per Magesan et al., PRL 2011, fidelity formula per Magesan et al., PRA 2012): generate random Clifford sequences of depths $m$, append inverse Clifford, fit survival probability $p(m) = A \cdot r^m + B$, compute fidelity $F = 1 - \frac{(1-r)(d-1)}{d}$ where $d = 2^n$ — so $d = 2$ for single-qubit and $d = 4$ for two-qubit.
Interleaved RB (per Magesan et al., PRL 2012): interleave a target gate (e.g. CZ) between random Cliffords to isolate its error rate.
6.8 Go server (qpi-ui)
The event-type constants and catalog entries are listed in §6.1. What follows is the mechanism, which is where this operation differs from the two that exist.
Dispatching a calibration
A driver's PUSH socket is not reachable from an HTTP handler. It is a local
variable inside runDriverDispatcher, one goroutine per connected driver, and
the only thing that goroutine sends is whatever scheduler.FetchNextJob hands
it. activeDrivers holds a context.CancelFunc per driver and nothing else —
there is no registry of sockets or channels to write to. So "push a
CalibrateDispatch onto the driver's socket" from a route handler is not a
small addition; it is a new outbound path.
Two ways to build it:
- A queue collection the dispatcher polls — the recommended one. A
calibration_requestscollection withdriver,mode,target_qubits,target_edgesandstatus, plus aFetchNextCalibration(app, driverID)mirroringFetchNextJob.runDriverDispatcherselects between the two sources and wraps whichever it gets in the matching event. This inherits, for free, everything the job path already solved: the request survives a server restart, a failedsock.Sendcan flip it back topendingexactly as a job is requeued, and a calibration requested while the driver is offline runs when it reconnects instead of being dropped on the floor. Given a calibration is the slowest thing in the system, losing one silently is the worst failure mode available. - An in-memory channel per driver, registered beside
activeDriversand selected on in the dispatcher loop. Less code, but a request is lost on restart, on a send error, and whenever the driver is not currently attached — and it introduces a second, differently-shaped outbound mechanism.
This RFC takes (1). The HTTP surface stays as sketched but is a plain create against the queue, not a socket write:
POST /api/op/calibrate/dispatch (admin-only — see §10)
{
"driver_id": "...",
"mode": "full",
"target_qubits": ["q0"],
"target_edges": ["q0_q1"]
}
One calibration at a time, and the unit is the chip, not the driver. A second
run delivered to a busy tuner would queue inside the driver where the server cannot
see it; and nothing stops two tuners on one QPU, each with its own dispatcher, so a
per-driver wait would let both sweep the same qubits and both write the device YAML
— whole per write (§10), a mix of two runs afterwards. FetchNextCalibration
withholds while any request is running on that driver's QPU.
Two full runs back to back are ordinary, though: the endpoint takes both as
pending and the second goes out when the first reports. The dispatch carries the
request ID and the report echoes it, so attribution never depended on there being
one in flight.
The queue is not the whole answer: drift_check_interval puts a fidelity_check
on the tuner's own internal queue without asking the server, guarded by a
threading.Event that means nothing to a second process. Two things close that. A
second tuner is refused at /api/op/drivers/connect while one of the same operation
is connected (§10) — registering one stays allowed, for a standby. And the QPUState
event (RFC 0001 §4) tells a tuner when its QPU is under maintenance, which is when it
stops scheduling its own checks while still honouring a dispatched one.
Receiving a result
handleCalibrationResult validates the payload — as handleCryostatReading
rejects a reading with no readings, this rejects a report with no mode or status
— and stores it in a calibration_results collection, attributed to both the
driver that sent it and the QPU that driver belongs to.
Why a collection rather than the events log. An earlier version of this RFC
deferred it: CryostatReading appends to the shared events trace log and
creates no per-type collection, and a new collection costs a model, a migration,
a config key and access rules. Two things settled it the other way. The dashboard
panel below is a history list and a fidelity trend, which is a query over
months of reports rather than a tail of recent events — exactly the case the
deferral named as the trigger for promoting it. And retention is different in
kind: a calibration report is far larger and far rarer than a cryostat reading,
so the events log's prune policy, tuned for a monitor emitting every few seconds,
would discard the reports on entirely the wrong schedule. Sharing the log would
have meant a second retention rule inside it, which is most of a collection
without the query.
The full cost is therefore paid, and is: a CalibrationResult model in
internal/db/models.go with the struct tags the migrator reads, a collection in
internal/db/migrate.go, DefaultCalibrationResultsCollection plus its flag and
its GetCollectionName case in internal/config/config.go, and
authenticated-read/superuser-CUD access rules.
| Field | Type | Description |
|---|---|---|
driver |
relation | Driver that produced this result |
qpu |
relation | QPU that driver belongs to |
timestamp |
date | When the calibration completed |
duration_s |
number | How long it took |
mode |
text | "full", "partial", or "fidelity_check" |
backend |
text | Which scheduler ran it |
routine_results |
json | Per-routine, per-target fitted parameters |
benchmarks |
json | Fidelity metrics |
errors |
json | What failed, per routine and target |
status |
text | "success", "partial_failure", "failed" |
The payload's fields are flat, beside job_id — the handler unmarshals the
event payload straight into CalibrationResultPayload. Nested under a results
key it parses without error and leaves every field at its zero value, saving a
blank row for a calibration that really ran; a test asserts the shape in both
languages.
A report also closes out the queued request it answers, which is what lets the dispatcher offer the next one for that given driver.
Dashboard (v1 — minimal)
An admin-only Calibration tab reading calibration_results for what the chip
looks like and calibration_requests for whether something is running. The two
are separate for a reason: a calibration takes hours, and the queued request is
the only thing that can say "in progress" during the gap before a report exists.
It shows measured fidelity per target, the current parameters, and the run history, and it queues a calibration through the endpoint above. Three details are load-bearing rather than cosmetic:
- Each target is judged against the threshold that governs it — the two-qubit one for an edge, the one-qubit one for a qubit. A CZ an order of magnitude worse than a single-qubit gate is normal; holding it to the 1Q threshold would make the panel cry wolf and train operators to ignore it.
- Parameters are grouped by qubit, not by routine. "What does q0 look like now" is the question an operator has; a routine spans every target while a target spans only a handful of routines.
- The trigger modal defaults to the drift check, not the full run. It is the common case, it is cheap, and it changes nothing — whereas a mis-clicked full calibration costs hours of QPU time.
Trend graphs, drift plots and live DAG progress are still deferred.
One incidental thing settled while there: the QPU interface carried a
calibration_data?: unknown field that no collection on the server has —
vestigial, and removed rather than quietly repurposed as this feature's hook.
6.9 Dependencies
The tuners add no dependency, so the quantify and qblox extras carry their
scheduler and instruments only. The fitting is three scipy.optimize.curve_fit
calls and scipy is a core dependency already; lmfit is imported nowhere here,
and could not be dropped from an install even if it were — quantify-core is in
both schedulers' dependency chains and requires it. Nothing about the extras can
make either install smaller.
The *_tuner extras are therefore plain aliases —
quantify_tuner = ["qpi-driver[quantify]"] and
qblox_tuner = ["qpi-driver[qblox]"], following the existing
qiskit_aer = ["qpi-driver[aer]"] precedent, so the extra an operator installs
matches the --device they were given. Spec.Extra in the catalog names these,
which is what the generated setup snippet pastes.
7. Testing strategy
Tier 1: Fitting unit tests (no simulator)
Generate synthetic data with numpy using known analytic forms + Gaussian
noise, verify fits recover known parameters within tolerance. This is the bulk
of testing and validates the most error-prone component.
Tier 2: Schedule compilation tests (dummy Cluster)
Use the dummy Cluster from qblox-instruments to verify each routine's
build_schedule() produces a valid, compilable Schedule. The dummy Cluster
returns all-zeros — this validates compilation, not analysis.
Tier 3: Physics simulation (scqubits + qutip)
Tiers 1 and 2 leave a gap that matters. Tier 1 generates its data from the same analytic form the fit assumes — a decaying cosine in, a decaying cosine fitted — so it proves the optimiser converges but not that the model is the right one. Tier 2 proves a schedule compiles, and the dummy cluster returns no data at all.
Tier 3 closes it. scqubits (BSD-3)
diagonalises a real Cooper-pair-box Hamiltonian for the transmon's levels, and
qutip integrates the Lindblad master equation for the time-domain responses.
Nothing is generated from a fitting model, so a routine that recovers the
simulator's parameters has been tested against physics rather than against
itself. Tests are marked @pytest.mark.scqubits, live in the sim dependency
group, and skip when it is absent.
Both schedulers, not one. Everything in this tier runs under
quantify-scheduler and qblox-scheduler, parametrised rather than duplicated —
test_calibration_loop proves each of its claims twice. That is not symmetry for
its own sake: quantify-scheduler is being deprecated, and the qblox path having
been a stub is how a tuner that could never complete a write-back went
unnoticed. A claim proved for one scheduler and untested for the other is the
shape this gap took, so the tests are arranged to make it impossible.
Two qubits, and where the argument above weakens. A CZ needs a joint state —
two independent density matrices cannot be entangled, so a Bell state could not
come out wrong because it could not come out at all. simulation/coupled.py
holds both transmons in one 9-dimensional register and couples them, and the
gate falls out of walking the control through the |11⟩–|02⟩ avoided
crossing: conditional phase 179.3°, Bell concurrence above 0.98, leakage under
0.5%, none of it written down.
But two of its numbers are chosen rather than derived — the exchange coupling
G_MHZ and the flux-to-detuning curve FLUX_CURVATURE_GHZ — because they
describe a coupler and a flux line this project has no device to measure. Given
them the dynamics are real, and a routine still has to find a crossing it was
not told the location of. The sentence above about nothing being generated from
a fitting model is a claim about the one-qubit tier; it is weaker here. Read a
two-qubit result as "the routine recovers the operating point of a plausible
coupler", not "of a real one". That is a smaller claim than the one-qubit tier
makes, and larger than the nothing that preceded it.
The readout chain, and the same weakening again. Two routines have the readout
itself as their subject, and against two fixed points in the IQ plane both swept a
flat line — which is why they were the last to be simulated.
simulation/resonator.py gives each qubit a resonance with a linewidth to find
and a power at which the resonance walks from dressed to bare, so
resonator_spectroscopy fits a real Lorentzian (recovering the modelled kappa,
1.96 MHz against 2.00) and resonator_punchout measures a shift bounded by one
dispersive shift. Reading out away from the resonance costs contrast, which is what
makes a wrong clock_freqs.readout degrade every routine after it rather than only
the one that found it.
What is not modelled is the two qubit states pulling the resonance to two different frequencies. The lineshape follows the ground state and the discrimination stays in the blob geometry the coordinator already had, so the best point to read out at here is the resonance itself — where a real chip has an optimum between the two pulled peaks, and reaching it is a calibration this simulator cannot pose. Read a readout result as "the routine measures the resonator's lineshape and its power dependence", which is what these two routines do, and not as a model of dispersive readout.
Pulse envelopes, because a derivative cannot survive averaging. A Rxy
compiles to a DRAG pulse: a Gaussian with a scaled derivative of itself on the
other quadrature, and that derivative is what cancels the phase error a fast pulse
picks up from the |1⟩–|2⟩ transition. A model that averaged the pulse to a
constant amplitude therefore made the DRAG parameter do nothing — averaged over
the pulse the derivative is identically zero — so drag had no optimum to find.
Shaped pulses are integrated in steps now, and the leakage DRAG corrects comes from
the same three-level ladder that produces it, so the optimum is found rather than
asserted: 0.113 as a ratio, against the -1/(2·alpha) theory gives for this
transmon's −280 MHz anharmonicity.
Two properties of that integration are load-bearing rather than incidental. The
envelope is normalised to unit mean, so the rotation angle stays the pulse area
and amp180 keeps the meaning it had before shapes were modelled — otherwise
adding envelopes would silently recalibrate the one number the whole loop turns on.
And the step count is set by convergence, not by argument: the fitted optimum agrees
to five significant figures with a run at eight times the resolution.
Tolerances are part of the design here. A loose one makes a test that passes without discriminating. Fitting a Gaussian decay to this simulator's exponential relaxation still recovers T1 to within 7%, so a 15% tolerance would accept the wrong physical model — the one thing this tier exists to reject. Each tolerance is set from the measured accuracy of the correct model (T1 to 0.01%, the Ramsey fringe to 0.004%, f01 to 3 kHz) with margin, and no looser. Swapping the exponential decay for a Gaussian passes tier 1 and fails tier 3, which is the whole argument for having it.
The whole calibration, not only its routines. The same simulator behind a
SchedulerBackend gives the routines a run to call, and behind the real
Tuner gives a calibration everything it needs bar a scheduler. So
_execute_calibration — the calibrate worker's own entry point — can be handed
a job dict and driven through the DAG walk, every fit, the write-back and the
report. That covers the joins rather than the pieces: that the follow-up job a
drift check emits is a job the worker accepts, and that a fitted frequency
reaches the file the process driver reads. It is what found the RB
normalisation fault described in §11.
What each half of the tier covers. There are two simulators here and the difference between them is the point.
test_physics_simulation.py supplies the acquisition. It reads a schedule for
the sweep encoded in it — the frequencies of its SetClockFrequencys, the
durations of its idles — and produces that experiment; only RB is played gate by
gate. So it validates that each fit model describes real physics, and that each
routine swept the axis it meant to. It cannot show that a routine's schedule
produces the physics its fit assumes, because it never reads the pulses.
SimulationCoordinator does read them, and test_calibration_loop.py runs the
whole DAG through it — every routine, over a real QuantumDevice loaded from YAML,
with the report required to come back success and to have run every routine
rather than a subset. Nothing there is a double except the instrument. That is
what caught cz.amp, cz.phase_correction, both DRAG faults and the chevron's
half-duration: each was a routine that measured correctly and then failed, or
silently declined, to write, and each is invisible to a simulator that never
compiles the schedule.
The pulses are not a formality to read. A CZ's flux pulse arrives not as one pulse
but as a held DC offset plus a 4 ns tail, so a reader of the pulses alone would
apply 4 ns of a gate that ran for 110; a readout arrives the same way, and its
amplitude is what resonator_punchout sweeps. Entanglement survives to the counts
only because the shots are drawn from the pair's joint distribution — sampling
each qubit from its own marginal reproduces both marginals perfectly and destroys
the correlation that was the whole content of the state.
What neither covers is hardware (§9).
Test files
make test-py runs one target per extra — base, cli, aer, quantify,
qblox, sim, loop — each in its own environment, so which environment
a test can run in is a property of the test, not a detail. Tier 1 imports
nothing vendor-specific and belongs in the base environment. Tier 2 exists to
compile a schedule, so it belongs with the extra that ships the compiler. Tier 3
needs no scheduler at all: the routines, the fits and the DAG are numpy and
scipy, and a scheduler is only needed to run a schedule — which is exactly
what the simulator does instead. test-py-sim therefore syncs --extra sim
alone. The last column below is part of the design, not bookkeeping:
| Test file | Tier | Runs under | Tests |
|---|---|---|---|
tests/test_calibration_dag.py |
1 | test-py-base |
DAG topological sort, cycle detection, partial DAG, recalibrate's narrowing |
tests/test_calibration_config.py |
1 | test-py-base |
Config parsing, defaults, validation |
tests/test_fitting.py |
1 | test-py-base |
All fitting functions against synthetic data |
tests/test_persistence.py |
1 | test-py-base |
YAML write-back round-trip |
tests/test_clifford.py |
1 | test-py-base |
Clifford group generation + inverse correctness |
tests/test_calibrate_driver.py |
1 | test-py-base |
Driver event handling, worker lifecycle — over a stub Tuner, no scheduler |
tests/test_tuner_routines.py |
2 | test-py-quantify + test-py-qblox |
Schedule compilation for each routine |
tests/test_physics_simulation.py |
3 | test-py-sim |
Routines against scqubits/qutip data; RB against real Clifford unitaries; the CZ routines against a coupled pair; the readout resonator's own lineshape and punchout curve |
tests/test_calibration_e2e.py |
3 | test-py-sim |
A whole calibration through _execute_calibration: full, partial, drift and the job it queues, write-back |
tests/fixtures/simulation.py |
3 | — | The backends, fake device and tuner built on the simulators |
tests/test_calibration_loop.py |
3 | test-py-loop |
The whole DAG through the shipped tuner, then circuits on what it wrote; CZ, Bell state and all three measurement levels |
Keeping the driver's own tests in tier 1 is what makes CalibrateDriver
testable without a lab: the tuner is resolved by name, class or instance
(mirroring resolve_executor), and a builder that returns an unstarted driver
(RFC 0003 §7) means the whole event path can be asserted with no server and no
hardware.
There is deliberately no dedicated calibration Makefile target. The tier-1 files run
under test-py-base, the tier-2 file runs under the two existing scheduler
targets, and a sixth target would mean a sixth environment that installs a
scheduler in order to run tests that do not need one. §9 lists the targets that
actually cover this feature.
8. CLI usage
# Full calibration (quantify-scheduler)
qpi-driver start --operation calibrate --device quantify_tuner \
-o quantify_device_config=./quantify.device.yml \
-o quantify_hardware_config=./quantify.hardware.json \
-o calibration_config=./calibration.yml \
-o is_dummy=true
# Full calibration (qblox-scheduler)
qpi-driver start --operation calibrate --device qblox_tuner \
-o quantify_device_config=./quantify.device.yml \
-o quantify_hardware_config=./quantify.hardware.json \
-o calibration_config=./calibration.yml
# With periodic drift monitoring (every 30 min)
qpi-driver start --operation calibrate --device quantify_tuner \
-o drift_check_interval=1800 \
-o fidelity_threshold=0.999 \
-o fidelity_2q_threshold=0.99
A tuner registers as its own driver, separate from the QPU driver on the same node, exactly as a cryostat monitor does (RFC 0001 §4). The two share the device YAML through the filesystem and nothing else — which is the whole of the write-back contract, and the reason §10 treats that file as the trust boundary.
The QPU driver re-reads that file between jobs when it changes, and a tuner at the start of each DAG walk, so a calibration — or parameters restored by hand — reaches a running driver without a restart. Applied onto the live device rather than rebuilt around a new one, which keeps the compiler, the coordinator and the cluster connection intact. A new element is structural, not calibration, and still needs a restart.
The tuner re-reads for a second reason: it writes this file, so one calibrating from startup values would overwrite whatever changed since. At DAG start rather than per routine — a device moving mid-walk leaves a fit and the parameters it was measured against disagreeing.
The hardware config does not reload. It builds the coordinator and the Cluster, so applying a new one means redialling the rack, and a failed reconnect leaves the driver with no coordinator and no way back. The device config falls back to memory; this has nothing. A driver warns once per change and keeps running.
9. Verification plan
Automated tests
make test-py-base # Fitting, DAG, config, Clifford, persistence, driver lifecycle
make test-py-cli # the same suite under [cli], plus the coverage floor on the CLI and registry
make test-py-quantify # quantify_tuner routine compilation (dummy Cluster)
make test-py-qblox # qblox_tuner routine compilation (dummy Cluster)
make test-py-sim # the routines against scqubits/qutip physics
make test-py-loop # the whole DAG, then circuits on what it wrote — both schedulers
make test-go # Event routing, handler, dispatcher queue, catalog invariants
make test-go-driver # Go SDK: operations are no longer a closed pair
make test-js-driver # TypeScript SDK: same
make test-e2e-driver # A live server and a real driver, per executor
make test-e2e-dashboard # The Calibration and Jobs tabs against a simulated chip
test-py-loop is the one that covers most of what this RFC claims, and it is the
slowest for the same reason: it calibrates a simulated chip through the shipped
tuner and then runs circuits against the file that calibration wrote, under both
schedulers. A green run of it is the only evidence that a fitted value — a qubit
frequency, a DRAG ratio — keeps its meaning through the fit, the write-back, the
YAML, the loader and the compiler. The instrument is the only simulated thing in
that path; the tuner, the file and the executor are the shipped ones.
The two SDK Makefile targets are in the list because the closed-set assertions there
(TestOperationsAreAClosedSet and its TypeScript counterpart) fail the moment
calibrate is added, and a green run of those is the cheapest proof the
operation landed in all three SDKs rather than just the one that needed it.
CI runs all of the above. test-py-sim and test-py-loop need both schedulers and
the sim extra, which is why the matrix carries a sim entry that names no
executor. The driver-start leg is skipped for that entry, there being no
--device sim to start (.github/workflows/ci.yml).
Manual verification
- Deploy to a lab node with a Qblox Cluster + transmon chip
- Run full calibration DAG and verify parameter convergence
- Compare RB fidelities before and after calibration
- Run periodic monitoring overnight — verify drift detection triggers recalibration
- Verify
quantify.device.ymlis correctly updated after calibration - Verify the
processdriver picks up the updated parameters on its next job, with no restart, and again after a device config written by hand - Verify a second tuner is refused at
/api/op/drivers/connectwhile the first is connected, and accepted once it is not - Dispatch a calibration while the tuner is offline, then start it — the request must run rather than have been dropped (§6.8)
- Stop the driver mid-DAG and confirm it exits without leaving the cluster in a half-configured state, and that the YAML is either fully updated or untouched
10. Security
Calibration crosses a trust boundary the other two operations do not, and the boundary is a file rather than a socket.
The device YAML is the attack surface. A tuner writes quantify.device.yml;
the process driver on the same node reads it for every job. A tuner that
writes bad parameters — through a compromised driver token, a mis-fitted
routine, or a truncated write — silently degrades every subsequent job on that
QPU, and does so in a way that looks like hardware drift rather than an attack.
Three consequences:
- The write-back must be atomic (write to a temporary file in the same
directory,
fsync, rename).tuners/utils/persistence.pyexists for this; §6.2 should be read as requiring it, not offering it. A crash mid-write that leaves a half-parsed YAML takes the QPU down with it. - Fitted values must be range-checked against the sweep window that produced them before they are applied. A Lorentzian fit that wanders outside its own scan range is a failed fit, not a new qubit frequency.
- Keep the previous file. A calibration that makes fidelity worse must be revertible without a second calibration run, and the operator needs to be able to answer "what changed" from the node itself.
The dispatch endpoint is privileged. POST /api/op/calibrate/dispatch puts the
chip under a tuner for hours and rewrites the parameters every later job runs
against. It is admin-only, as the /api/op/* routes around it are. It is not
rate-limited, and neither are they: --event-rate-limit bounds a driver's inbound
NNG events, not the HTTP surface, so admin-only is the whole of the control. An
unauthenticated trigger would be a denial-of-service with a plausible cover story.
One driver per role per QPU, enforced at connect. Two QPU drivers would hand one
chip two schedules; two tuners would each write the device YAML, leaving it valid and
a mix of two calibrations. /api/op/drivers/connect returns 409 while another driver
of the same operation is connected — by operation, not kind, since a
quantify_tuner and a qblox_tuner calibrate the same chip. custom is exempt, its
operation being whatever its author wrote. Registration is unrestricted: a standby
harms nothing until it connects.
Connected means socket attached and this server dispatching. Either alone would
wedge a QPU no driver is on: a crash leaves status at online, and a restart
leaves rows claiming a connection this process never made.
Reports are not secrets, but they are inventory. A CalibrationResult
describes the chip in more detail than anything else QPI stores — per-qubit
frequencies, coherence times, gate fidelities. The events log is public-read
(RFC 0001 §9), and that rule is not inherited here: calibration_results
and calibration_requests are both authenticated-read, superuser-only CUD. A
public events log leaks that a calibration happened; a public report leaks the
chip.
11. What is built, and what is not
Implemented: the operation and event types across all three SDKs and the server;
the tuners package, the sixteen routines, the fitting and the Clifford group; the
verified write-back; the calibrate driver with its drift check; the dispatch
queue, endpoint and result handler; the catalog entries; the dashboard panel; and
the docs. All of it under both schedulers, including -o is_simulated=true,
which qblox refused until its HardwareAgent turned out to compile offline — a
real agent keeps compilation and only execution is simulated.
Fourteen faults were found by testing a whole calibration end to end rather than routine by routine, all fixed. Most were fix-and-forget; what follows is only what outlives them — facts about the chips, the schedulers or the sweeps that the next routine author needs:
- The DRAG parameter is not the same quantity in the two schedulers. quantify's
D_ampmultiplies(t−µ)/σ, so it is the dimensionless ratio of derivative to Gaussian, validated to ±1. qblox'sbetamultiplies(t−µ)/σ², so it is in seconds, larger by one pulse sigma — 2.5 ns for a 20 ns gate. One sweep range cannot serve both: nine orders of magnitude out, the derivative term exceeds full scale and the schedule stops compiling.SchedulerBackend.drag_spangives each its own, and both recover the same optimum — 0.113 as a ratio, 2.8e-10 s as a beta. - The readout LO must be pinned and the IFs left free, not the reverse. Pin each port's IF and let the LO float, and three qubits sharing a QRM_RF agree on an LO only while their readout frequencies happen to. Move one by a kilohertz and the module is asked for two LOs, and every later schedule fails to compile — a long way from the routine that caused it. The reverse arrangement is what lets a readout frequency be recalibrated at all.
- A chevron sweep can step straight over the crossing. The avoided crossing is about 4.5 MHz wide and the default amplitude grid moves the control ~75 MHz per step, so the surface comes back flat to within its noise — from which a peak-finder still returns a confident answer that goes to the device as a CZ. Hence the minimum contrast guard, the same one qubit spectroscopy applies to a line narrower than its own step size.
- A CZ chevron's round trip must be found by level crossing, not by walking. The
trough is not smooth: the exchange beats against the other transitions the flux
pulse sits near, so its bottom carries a wiggle a few per cent of the full swing,
and a walk that stops when the signal stops rising stops at the top of that —
still deep in
|02⟩. That measured 55 ns for a round trip of 110, calibrating a complete population transfer, which is a perfectly good gate and the wrong one. - Readout frequency and readout power are one measurement, not two. The resonance moves because the power changed — that is the entire content of punchout — so choosing a new power invalidates the frequency spectroscopy measured at the old one. Punchout writes both, from the spectrum row at the power it selects; it was already fitting it and discarding it.
- Qubit spectroscopy needs ~3% drive, not 1%. At a tenth of a radian the population moves half a per cent, and a Lorentzian fitted at that ratio lands anywhere: the same sweep returned centres 14 MHz low, 12 MHz high and 62 MHz low on noise alone. Three per cent is inside a megahertz and still weak enough not to saturate the line or reach the two-photon 0–2 transition 70 MHz above it.
- The two schedulers name the same parameters differently, and a
hasattrguard over a name neither has is silently false forever. quantify's DRAG parameter isrxy.motzoi, qblox's isrxy.beta; the phase corrections are named after the qubits under one and the roles under the other.drag_parameter_nameandphase_correction_namesask the element which it has. - They differ in shape as well as naming.
device.elements()is a method under quantify and a dict under qblox; qblox's edges are pydantic models, so they take no positional constructor arguments. Andelement_type,name,edge_typeand an edge's two endpoints are structural, not calibration — write them back as parameters and the loader tries to assign to fields that refuse it. Both loaders also need elements before edges, or an edge listed first fails. - Tier 2 must compile, not merely build. A test that asserts a schedule was
built passes on a schedule that would never assemble;
HardwareAgent.compileis what catches it. - The two phase-correction parameters are not the conditional phase. They cancel each qubit's single-qubit phase, which is not derivable from the conditional one, so the routine measures four fringes rather than two.
- An RB amplitude bound suits a raw survival probability, not a rescaled signal.
Bounding it to ±2 met the bound by pulling the decay rate down instead, pinning
reported fidelity near 0.98 for any chip better than ~3% error per Clifford — and
with a 0.999 drift threshold, a healthy chip would recalibrate forever. Only
ris bounded.
Two of these were encoded as expectations rather than found by them. The tier-1
test for fit_conditional_phase asserted
phase_correction == np.pi - conditional_phase, and the tier-2 sweep for drag
carried the same dimensionless scale the routine did. A test written from the
code cannot contradict it.
The common cause of the last group is worth naming, because it is structural
rather than a series of accidents: only one scheduler was ever exercised. Every
one of those faults sat in shared code that quantify happened to satisfy.
test_calibration_loop is parametrised over both now rather than duplicated, and
make test-py-loop installs both, so a claim cannot be proved for one and left
untested for the other. quantify-scheduler is being deprecated, which makes the
qblox path the one that has to keep working.
Every routine now runs against the compiled schedule. This used to be listed
below as deliberately not done. test_calibration_loop.py drives the whole DAG —
sixteen routines, both qubits, the edge between them — through the shipped tuner
against SimulatedCoordinator, which reads pulses rather than sweeps, and requires
the report to come back success having skipped none of them. Getting there needed
two additions to the simulator, each of which had been the reason a routine was
excluded:
- A readout resonator (
simulation/resonator.py), so the two routines whose subject is the readout chain have an experiment rather than a flat line. - Pulse envelopes and their derivative, so the DRAG parameter changes the answer instead of averaging to nothing.
§7 describes both, and what each still does not model.
Not implemented, deliberately:
- A coupler measured rather than assumed (§7).
SIDEBAND_GAP_GHZ,G_MHZ,FLUX_CURVATURE_GHZ,STARK_SHIFT_MHZandSTARK_ASYMMETRYare chosen numbers. An edge can now declare the transition its drive bridges (clock_freqs.sideband_gap) and the simulator prefers it, so the constant is a fallback for an uncharacterised coupler rather than the only answer — but no coupler here has been characterised, andPARAMETRIC_RATE_MHZis the only one derived from anything measured (four calibrated operating points off a real chip). - The SPI and QCM bias paths against hardware. Which current goes where, by which mechanism, and every refusal are tested; the qcodes calls that would drive an S4g or hold a cluster offset are not, and mocking them would only assert the mock.
- Hardware validation (§9). No routine here has been run against a physical transmon. Until the manual verification in §9 has been done on a lab node, the honest description of this feature is "complete and untested against hardware". Every routine's schedule now produces the physics its fit assumes in the simulator, which is a real claim and a bounded one: what remains are the failure modes a model does not have — the analogue chain, crosstalk, TLS defects, drift on the timescale of a calibration, and every one of the coupler constants §7 admits is chosen.
12. Implementation plan
Complete, bar §9's manual verification. The sequence was: the operation and event types across all three SDKs and the server first, since everything else fails to compile without them; then tier-1 Python internals (DAG, config, fitting, Clifford, persistence), which need no hardware and carry most of the risk; then the routines behind each scheduler; then the dispatch queue and the result handler; then the dashboard panel; then documentation. The phased plan itself was kept outside the repository.