Skip to content

Feature/calibration driver - #35

Open
Tinitto wants to merge 16 commits into
mainfrom
feature/calibration-driver
Open

Feature/calibration driver#35
Tinitto wants to merge 16 commits into
mainfrom
feature/calibration-driver

Conversation

@Tinitto

@Tinitto Tinitto commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR implements RFC 0004: Calibration Tuners, bringing automated parameter calibration and drift tracking for superconducting transmon qubits to QPI.

It introduces the calibrate operation across the full stack—from Python SDK calibration tuners (quantify_tuner, qblox_tuner) and backend-agnostic experiment routines to server-side event handling, atomic device file write-backs, and an admin-facing dashboard UI for QPU calibration status and manual triggers.


What's Changed

1. Operation & Event Infrastructure

  • Added calibrate as a first-class operation alongside process and monitor.
  • Introduced CalibrateDispatch and CalibrationResult event types.
  • Added POST /api/op/calibrate/dispatch endpoint in qpi-ui for triggering full, partial, or fidelity-check calibrations.
  • Created calibration_results and calibration_requests server storage collections.

2. Python SDK & Tuners Framework (qpi-driver/py)

  • CalibrateDriver: Worker subprocess architecture handling calibration runs without blocking the main event loop.
  • Tuner & CalibrationDAG: Topological DAG execution of routines configured via calibration.yml.
  • Backend-Agnostic Routines:
    • Single-Qubit: Resonator Spectroscopy, Resonator Punchout, Qubit Spectroscopy, Rabi, Ramsey, T1, T2 Echo, DRAG, AllXY, Fine Amplitude.
    • Two-Qubit: Flux Spectroscopy, CZ Chevron, Conditional Phase.
    • Benchmarks: Clifford Randomized Benchmarking (RB), Interleaved RB, AllXY Smoke Test.
  • Schedulers Supported: quantify-scheduler and qblox-scheduler via a unified SchedulerBackend interface.
  • Persistence & Safety: Atomic writeback to quantify.device.yml with automatic backup (.prev) and verification before swapping files. Fits outside expected parameter bounds or failed acquisitions abort writeback to prevent corrupting hardware state.
  • Drift Monitoring: Periodic background fidelity checks (drift_check_interval) triggering targeted partial recalibrations when gate fidelity drops below configured thresholds.

3. Admin Dashboard UI (qpi-ui)

  • Added Calibration Panel to the web dashboard:
    • Qubit and edge fidelity grid displaying the lowest fidelity metric against configured 1Q/2Q thresholds.
    • Live parameter view grouped by qubit (frequencies, pulse amplitudes, coherence times).
    • Run history with status, duration, and error reporting.
    • Trigger modal defaulting to low-cost drift checks with support for full/partial runs.
    • In-flight status banner tracking active requests in calibration_requests.

4. Testing & Verification

  • Tier 1 Tests (test_fitting.py): Validates parameter recovery from noisy synthetic data and verifies error-handling when fits fail.
  • Tier 2 Tests (test_tuner_routines.py): Verifies schedule compilation for all routines against Quantify and Qblox dummy clusters.
  • Driver & DAG Tests: Verifies DAG topological sorting, config parsing, atomic file writeback, and process isolated worker execution.
  • Dashboard E2E Tests: Added 6 new Cypress end-to-end specs for the calibration tab.
  • Documentation Verification: All make test-docs claims pass strict MkDocs verification.

Documentation

  • RFC 0004: Accepted and updated to reflect backend-agnostic routine design and physical hardware validation status.
  • Tuners Guide: Added qpi-driver/py/qpi_driver/tuners/README.md and linked it in MkDocs navigation (docs/driver/tuners.md).
  • Example Config: Added qpi-driver/py/calibration.example.yml.
  • Operations Runbook & README: Updated with calibrate CLI examples, queue mechanics, and rollback procedures.

How to Test

# Run Python tuner & fitting tests
make test-py

# Run Quantify / Qblox specific tests (requires extras installed)
make test-py-quantify
make test-py-qblox

# Run documentation verification
make test-docs

# Run Dashboard E2E tests
make test-e2e-dashboard SPEC="cypress/e2e/calibration/*"

Tinitto added 16 commits July 30, 2026 22:03
Reviews the draft against qpi-driver, qpi-ui and the SDKs, and fixes what
did not survive contact with them:

- Dispatch: the RFC pushed CalibrateDispatch onto "the driver's NNG PUSH
  socket". That socket is a local in runDriverDispatcher and is fed only by
  scheduler.FetchNextJob; no HTTP handler can reach it. Replaced with a
  calibration_requests queue collection the dispatcher polls, which inherits
  the job path's durability and requeue-on-send-failure.
- Persistence: handleCryostatReading appends to the shared `events` log and
  creates no per-type collection, so calibration_results was a departure from
  the pattern it cited, not an instance of it. Deferred to the events log for
  v1; recorded the model/migration/config/rules work for when it is earned.
- Operation blast radius: an operation is closed across all three SDKs and the
  server (RFC 0003 §13.1), with tests asserting it. Listed every required
  change, including AllEventTypes, which driver registration validates against.
- Citations: 1109.6887 is PRA 2012, not PRL 2011 (that is 1009.3639); rows 15
  and 16 both pointed at the interleaved-RB paper.
- Resolved the pyproject contradiction, dropped the test-py-calibrate target
  (test-py runs one env per extra; tier-1 needs no scheduler), and renamed
  monitor_interval so a calibrate option stops naming another operation.

Adds the Decisions rationale and rejected alternatives, a Security section (the
device YAML is a trust boundary: a tuner writes what every job then reads), and
an implementation-plan pointer, per the conventions in docs/rfcs/README.md.
Takes the reviewed RFC 0004 over the branch's original copy; the former is the
latter plus the corrections in 9c92ac2.
Replaces the scaffold on feature/calibration-driver with working routines,
per RFC 0004. What was there reported success in 22 microseconds having run
nothing, and destroyed the device config doing it.

Routines
- One backend-agnostic routine set replaces 32 per-scheduler stub files. Both
  schedulers expose the same gate vocabulary, so a routine composed against a
  SchedulerBackend runs under either; two copies would be two things to keep in
  step. tuners/base/device.py absorbs the shapes that do differ — qcodes
  callables against pydantic fields, and motzoi against beta.
- All 16 routines now build real swept schedules and compile against the dummy
  cluster. Previously Rabi was a single fixed Rxy(90) with no sweep, T1 was a
  bare Measure, apply() was `pass` everywhere, and the qblox tuner passed None
  where a compiler was expected.
- Fine amplitude gained the pi/2 pre-rotation and two calibration points. Without
  the former the sign of the error is unrecoverable at integer n; without the
  latter only the product of contrast and error is.

Correctness of failure
- A missing or empty calibration.yml no longer means "calibrate nothing and
  report success": absent routines default to enabled, an unknown routine name
  is a startup error, and a run with nothing to do reports failed.
- Fits raise instead of returning zeros, and every fitted device parameter is
  range-checked against the sweep that produced it. A fit that returns zeros on
  failure gets written to the device as though it were a measurement.
- The Clifford group is generated by exact matrix composition and closed under
  inversion, replacing a table with 8 of 24 entries and a "dummy inverse". RB
  measures nothing if the recovery gate does not actually invert the sequence.

Write-back (RFC 0004 §10)
- Writes the schema load_quantum_device reads, via safe_dump, verified to read
  back before anything is replaced, with the previous file kept beside it. It
  was writing a compilation config through yaml.dump, producing python tags the
  loader's safe_load refuses — one calibration bricked the QPU.
- A failed run no longer writes at all.

Driver
- CalibrationResult is emitted flat, matching CalibrationResultPayload. Nested
  under "results" it unmarshalled to an all-zero record without erroring.
- check_fidelity returns a report, so the drift path no longer dies on
  AttributeError every tick; the thresholds are now actually compared and a
  drop queues a partial recalibration.
- recalibrate() honours its qubits argument rather than running the full DAG.
- A second calibration is refused rather than queued behind a multi-hour run.

Also: discovery resolves a Tuner named by import path, and the shipped example
config names routines that exist. Deletes generate_quantify_tuner.py, a codegen
script with a hardcoded home directory that shipped inside the package.

Tests: 359 passing, 99% coverage on the gated modules against a 96% floor.
…tion

The operation existed in the Python SDK and the server but nowhere else, and
nothing could trigger a calibration: CalibrateDispatchPayload was declared and
never constructed, with no route, no queue and no dispatcher change.

Dispatch (RFC 0004 §6.8)
- A `calibration_requests` collection the driver's dispatcher polls, rather than
  a socket write from an HTTP handler — the PUSH socket is a local inside
  runDriverDispatcher and nothing else can reach it. Queueing through the
  database is also what makes a request survive a restart, requeue on a send
  error as a job does, and run when an offline driver reconnects.
- POST /api/op/calibrate/dispatch, admin-only like the /api/op/* routes around
  it: a calibration takes a QPU out of service for hours and rewrites the
  parameters every later job runs against (RFC 0004 §10).
- Calibrations are offered before jobs, or a busy QPU would never calibrate —
  the state calibration exists to get it out of. One at a time per driver.

Result handling
- handleCalibrationResult validates mode and status, as handleCryostatReading
  validates its readings, and records the QPU as well as the driver. A blank
  payload was previously stored as a blank row.
- The report closes out the queued request it answers, so the dispatcher can
  offer the next one.

Cross-language parity (RFC 0003 §8, RFC 0004 §6.1)
- `calibrate` added to the Go and TypeScript SDK operation enums and event
  types. Their closed-set tests failed on the addition, which is what those
  tests are for; all three now assert the same three operations.

Also: the catalog's drift option is `drift_check_interval`, since `monitor` is
already an operation; a new test pins the catalog's -o keys to the ones the
Python builder reads, because an option nothing reads is a startup error and
the two lists are kept in step by hand (RFC 0003 §9). Drops the vestigial
`calibration_data` field from the dashboard's QPU type — no collection has it.
Records the two design changes the implementation forced, with reasons:

- One shared routine set rather than one per scheduler (§6.2). Both expose the
  same gate vocabulary, so the seams that carry the real differences are a
  SchedulerBackend and a device-parameter shim, not sixteen duplicated files.
- calibration_results is a collection after all (§6.8), reversing the earlier
  deferral to the events log. The dashboard history list is a query over months
  rather than a tail of recent events, and a calibration report's retention is
  different in kind from a cryostat reading's.

Also rewrites §6.3/§6.4 to the interfaces that exist (check_fidelity returns a
report; routines declare "updates" and "benchmark", and analyse raises rather
than returning zeros), marks tier 3 as not implemented, and adds §11 saying
plainly what is built and what is not — above all that no routine here has been
run against physical hardware.

Adds the tuners doc page the mkdocs nav already pointed at, and drops the
test-py-calibrate target, which ran an import check and was not in test-py. The
calibration tests live in the existing per-extra targets: tier 1 needs no
scheduler, tier 2 needs the one it tests.
Admin-only tab showing what the chip currently looks like and letting an
operator act on it:

- Measured fidelity per target, each against the threshold that governs it —
  two-qubit for an edge, one-qubit for a qubit. Holding a CZ to the 1Q
  threshold would make the whole grid cry wolf. Worst protocol wins per
  target, as the driver's own drift check does.
- Current parameters, grouped by qubit rather than by routine, because "what
  does q0 look like now" is the question an operator actually has. Values are
  formatted by kind: GHz for frequencies, µs/ns for times.
- History, with per-run status, duration and error counts.
- A trigger modal defaulting to the drift check rather than 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. A partial run with no
  targets is refused in the form as well as at the endpoint, since the driver
  would otherwise widen it to a full one.
- An in-flight banner driven by calibration_requests. A calibration takes
  hours, so the queued request is the only thing that can say "in progress"
  during the gap before a report exists.

Registration now offers the two tuner kinds, and the sidebar and hash router
know the tab — the E2E spec caught the missing hash allow-list entry, which is
what it is there for.

make test-e2e-dashboard gains SPEC=<glob> to run one spec: eight seconds
against four minutes when iterating on a single tab.

E2E: 116 passing, including 6 new.
The page is symlinked into docs/driver/, so relative links resolve from there
rather than from the package — mkdocs strict mode caught both. Uses absolute
GitHub URLs, as the other double-duty READMEs do.

Also rewords the RFC's note about there being no dedicated calibration test
target, which check_docs.py read as a claim that one exists.
The merged docs described an endpoint and an access rule that do not exist, and
gave two CLI examples that would fail on startup. Corrected against the code:

- `POST /api/calibrate` is `POST /api/op/calibrate/dispatch`.
- `calibration_results` is authenticated-read, not public read, and
  `calibration_requests` was missing from the collections table entirely.
- Both tuner examples omitted `calibration_config`, which a tuner cannot start
  without — a calibration with nothing to run would report success having
  measured nothing, so its absence is deliberately a startup error.

Adds the usage that was missing: how to queue a calibration from the server and
what the three modes cost, how to turn on drift monitoring, a Tuner Backends
section in the Python SDK README, and a calibration section in the operations
runbook covering the queue, the `.prev` backup as the way back from a bad
calibration, and why `eventsRetention` does not touch calibration reports.

The changelog entry now covers what actually shipped — the dispatch endpoint and
its queue, drift monitoring, the dashboard tab, the SDK operation enums and the
write-back guarantees — and states plainly that none of it has been run against
physical hardware.

make test-docs passes: 137 claims checked, site builds under strict mode.
…er 3)

Tier 3 was specified and never built. The gap it leaves is not cosmetic: 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 right, and tier 2's dummy cluster returns no data at all.

tests/simulation.py builds a transmon by diagonalising a real Cooper-pair-box
Hamiltonian with scqubits and integrates the Lindblad master equation with
qutip. Nothing is generated from a fitting model, so a routine that recovers the
simulator's parameters has been tested against physics rather than itself.
Covered: qubit spectroscopy against the true eigenspectrum, Rabi, T1, T2 echo,
Ramsey, and randomized benchmarking against real Clifford unitaries.

The RB test is the strongest in the suite. Sequences are composed from this
package's own Clifford decomposition, evolved as unitaries, closed with the
computed recovery gate and depolarised by a known amount; a control test proves
the decay disappears when the recovery gate is removed, so a fit returning a
plausible number on flat data cannot pass.

Tolerances are set from measured accuracy, not by feel, because 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 the
15% I first wrote accepted the wrong physical model. The correct model recovers
T1 to 0.01%, the Ramsey fringe to 0.004% and f01 to 3 kHz; each tolerance is set
just above that. Verified by mutation: swapping the exponential decay for a
Gaussian passes tier 1 and fails tier 3, which is the whole argument for the
tier existing.

Three modelling errors of mine that the tests caught, all fixed here: qutip 5
makes e_ops keyword-only; pulses built from a + a† leak into the second excited
state, so they are now built on the 0-1 subspace as a calibrated pulse is; and
shot noise that did not fall as 1/sqrt(N) made averaging decorative and left a
0.2% gate error unresolvable no matter how many sequences were run.

Dependencies are a `sim` group rather than an extra — nothing shipped to a user
needs them — and the tests skip when it is absent. `make test-py-sim` runs them,
and it is in `make test-py`.

Documents what the tier does not cover: the simulator supplies the acquisition
rather than interpreting the compiled schedule, so a schedule that does not
produce the physics its fit assumes would still pass. Hardware remains the only
answer for that.
The tests so far took a calibration apart: the DAG with stub routines, the
schedules against a dummy cluster, each fit against physics. Every one of them
replaced the pieces either side of the one under test, so nothing covered the
joins.

Put it back together. The 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, the fits,
the write-back and the report.

It found two faults, and both are fixed here:

fit_rb_decay bounded the model's amplitude to ±2. That suits a raw survival
probability but not the rescaled signal the `rb` routine hands it: a decay that
has not reached its asymptote by the deepest sequence has an amplitude far
larger than the range observed, so the bound was met by pulling the decay rate
down instead. The reported fidelity pinned near 0.98 for any chip better than
about 3% error per Clifford — below the 0.999 default drift threshold, so a
healthy chip would have recalibrated on every check, forever. Only the decay
rate is bounded now; it is the only parameter with a physical range.

Tuner.recalibrate never narrowed its routines. CalibrationDAG.partial_order was
written and unit-tested but left unwired, so a partial recalibration ran the
whole enabled graph over fewer targets — including the readout bring-up, which
is most of the cost a partial run exists to avoid. It now runs
RECALIBRATION_ROOTS and everything downstream.

Also moves the simulator into tests/fixtures/ with the backends and tuner built
on it, and corrects the RFC: tier 3 needs no scheduler extra, only the sim
dependency group.
`InstrumentCoordinator.components` is a qcodes ManualParameter holding component
*names*. Both close() paths treated it as an iterable of component objects, so
each raised TypeError before releasing anything:

    TypeError: 0 is not a list; Parameter: quantify_ic.components

QuantifyExecutor.close() is called by the QPU worker on shutdown (qpu.py:276)
and QuantifyTuner.close() by the calibrate worker (calibrate.py:362), so in both
cases a driver stopping left its instruments held against the next driver that
wanted them.

The suite did not catch it. test_tuner_routines' module fixture calls close() in
teardown, but a test earlier in the same module calls Instrument.close_all() —
so by teardown the coordinator is already gone and the loop never runs, which is
the one arrangement in which a broken shutdown looks fine. Both regression tests
therefore build their own live instance, and both fail against the old code with
the TypeError above.

Detaching by name is enough: close_all() closes the components themselves, so
looking each one up to close it individually only duplicated the next step.
quantify reaches its hardware through four calls — prepare, start, wait_done,
retrieve_acquisition — and both QuantifyBackend.run and
QuantifyExecutor._run_circuit go through exactly those. So SimulatedCoordinator
answers them and drops in wherever a cluster does, via `is_simulated=True` on
either the tuner or the executor.

The difference from the dummy cluster is that this one reads the schedule it was
given. A dummy returns nan+nanj regardless, so the calibration a tuner writes
and the pulses an executor plays were never actually connected: an amp180 off by
a factor of ten compiled, ran, and returned the same nothing. Here the compiled
schedule's own G_amp is the amplitude that rotates the state, so a wrong
calibration is a wrong population, measured.

That closes the loop between the two operations, and tests/test_calibration_loop
walks it with nothing faked in the middle: a QuantifyTuner calibrates the
simulated chip, writes a real quantify.device.yml, and a QuantifyExecutor loads
that file and runs circuits on the same chip. The fixture device starts 214 MHz
off resonance, where an X gate does nothing — so an X that lands in |1> is
evidence a number survived the fit, the write-back, the YAML, the loader and the
compiler with its meaning intact. A negative control asserts the uncalibrated
device still gets it wrong.

Three things the loop had to get right, each of which looked like a broken
routine until it was found:

- Clock frequencies come from `resources` once, but SetClockFrequency retunes
  them mid-schedule, and that is how every spectroscopy routine sweeps. Without
  replaying the retunings the whole sweep sits at one point.
- Idles and pulses are propagated by exponentiating the Liouvillian rather than
  by stepping an ODE. A Reset is a 200 us idle, and at a few hundred MHz of
  detuning that is ~1e5 radians of phase, which exhausts any step budget.
- The drift Hamiltonian's detuning sign pairs with the drive's phase convention.
  Ramsey is the experiment that distinguishes them: with this sign a device set
  5 MHz off is pulled back to within 5 Hz, with the other it "corrects" to 2 MHz
  off — twice the artificial detuning.

Not modelled: two-qubit gates. A CZ is a flux pulse whose area sets the
conditional phase, so a flux pulse raises rather than silently acting as an
identity and making a Bell state look merely broken.

The simulator moves from tests/ into the package, since a thing you can put
where the hardware goes is not a test fixture. scqubits and qutip stay lazily
imported and in the sim dependency group, so a base install is unaffected.
Both operations read it, so pointing a calibrate driver and a process driver at
the same quantify.device.yml runs a whole node with no instruments: the tuner
calibrates the simulated chip, and the QPU beside it compiles jobs against what
the tuner wrote.

Distinct from is_dummy, which is the vendor's dummy cluster: that compiles and
runs and then returns nan for every acquisition, so nothing tells a correct
calibration from a wrong one.

qblox raises rather than accepting the flag. qblox-scheduler reaches its
hardware through a HardwareAgent that compiles and runs in one call, not through
quantify's four-call coordinator, so the simulator does not drop in there —
accepting it and quietly falling back to a dummy cluster would return nan and
look like a chip that answered.
A Lorentzian narrower than the spacing between setpoints did not appear in the
data. Whatever the fit converged on came from the noise between the points, and
it arrives with a small linewidth and a confident centre — the worst shape a
wrong answer can take here, because that centre is written straight to the
device as f01 and every gate afterwards is driven at it.

Found while trying to make spectroscopy more precise: narrowing the line to
63 kHz while the sweep still stepped 5 MHz produced a frequency 377 MHz from the
qubit, fitted tidily, reported as success.

The precision attempt itself is not kept, and the reasoning is recorded in the
routine because it is not obvious. A long square saturation tone is what
textbook two-tone spectroscopy uses and it does resolve the line far better —
63 kHz against 13.6 MHz, measured. But this routine's job in the graph is to
*locate* a qubit across hundreds of MHz, and a square tone's frequency response
is a sinc: over a coarse sweep the Lorentzian fit catches a side lobe and lands
91 MHz out. The DRAG envelope is smooth and has no lobes to catch. Precision is
not lost by keeping it, only delegated — `ramsey` runs after `rabi` and refines
f01 to hertz, which is what the dependency order already said.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant