diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48a7ede4..04f40d52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,14 @@ on: branches: [ main ] tags: [ 'v*' ] pull_request: + # `bench-parallel` runs only from here. It walks the whole graph twice and takes + # minutes, which is not something every push should pay for. + workflow_dispatch: + inputs: + benchmark: + description: 'Also measure what grouping saves (make bench-parallel)' + type: boolean + default: false concurrency: # cancel any previous run on this branch or tag that is still in progress @@ -150,6 +158,16 @@ jobs: # ------------------------------------------------------------------------- # qpi-client / Python SDK # ------------------------------------------------------------------------- + benchmark-parallel-calibration: + # Manual only, and only when asked for: see `workflow_dispatch.inputs.benchmark`. + if: github.event_name == 'workflow_dispatch' && inputs.benchmark + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Measure what grouping saves + run: make bench-parallel + lint-and-test-py-client: runs-on: ubuntu-latest strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index dd80423b..035cc8e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project follows versions of format `{year}.{month}.{patch_number}`. +## [Unreleased] + +### Added + +- `qpi-driver/py`: `parallel` in `calibration.yml` groups a routine's targets into sets that + can be measured at once, coloured from the coupling graph — `qubit_spacing`, + `edge_spacing`, `max_group`, `exclude`, or explicit `groups`. Off unless the file says so, + and a walk with no readable coupling graph runs one target at a time rather than guessing. +- `qpi-driver/py`: every routine in the graph can measure a group in one schedule, so a + group costs one arm-and-wait cycle instead of one per target. Each target reads its own + acquisition channel, and a group whose instruments cannot play it at once — readout clocks + outside one LO band, amplitudes that would clip, more clocks than sequencers — is split, + with the measured figure and the ceiling in the message. +- `qpi-driver/py`: `parallel.measure_penalty` benchmarks each target alone as well as in + company and reports `parallel_penalty` per target — the fidelity the group cost it, which + is what a tighter `qubit_spacing` has to be earned with. Off by default: it doubles what + the benchmarks cost. +- `qpi-driver/py`: `make bench-parallel` reports what grouping saves on a 3-qubit, + 2-coupler chain — 52 acquisitions sequentially against 37 grouped. Skipped unless + `QPI_BENCH=1`, so a normal run does not pay for it. +- `docs`: RFC 0009 — Parallel Calibration. Why a group is a colouring of the coupling graph + rather than a hand-written list, why concurrent submission to one cluster cannot work, and + what licenses a tighter spacing. + +### Changed + +- `qpi-driver/py`: a two-qubit routine resets and excites both of an edge's qubits at the + same time rather than one after the other, which halves the reset every CZ sweep waits + through. This applies to a single edge too, not only to a group. + +### Fixed + +- `qpi-driver/py`, `qpi-ui`: the calibration graph draws the node a walk is on. A progress + event only fired after a target finished, so a single-target node went straight from + `pending` to `done` and the `running` style was unreachable; a node whose every target was + blocked stayed `pending` for the whole run. +- `qpi-driver/py`: a routine's swept setpoints belong to the target rather than to the + routine. They were kept on the routine and read back in `analyse`, so a routine measuring + several targets in one schedule would have fitted every one against whichever target built + last — a plausible curve against the wrong axis, not an error. +- `qpi-driver/py`: check schedules are compiled once per scheduler rather than once against + both. The only such compile in the suite needed both installed, so under the per-extra CI + matrix it ran nowhere. + ## [0.4.2] - 2026-08-16 ### Added @@ -359,7 +403,7 @@ and this project follows versions of format `{year}.{month}.{patch_number}`. with `AttributeError: 'SimulatedBackend' object has no attribute 'last_allowance_s'`. - `qpi-driver/py`: a quantify tuner or executor resets the cluster when it opens one. Sequencer offsets, NCO frequencies and `sync_en` survive a reconnect, so the driver - inherited whatever the last process left emitting — which held this chip's qubit in a + inherited whatever the last process left emitting — which held a qubit in a mixture that made X the identity, and deadlocked `wait_sync` before that. - `qpi-driver/py`: a quantify tuner or executor stops the cluster after every run, including a failed one. Only `stop` clears `sync_en` on the modules a schedule did diff --git a/Makefile b/Makefile index ed673f41..7b451e29 100644 --- a/Makefile +++ b/Makefile @@ -10,9 +10,11 @@ DOCS_SITE_VENV := bin/.docs-site-venv DOCS_SITE_OUT := bin/.docs-site # The framework modules the coverage floor applies to: the SDK, the CLI, the device -# registry and its options, and the executors that need no hardware. Everything else -# is reported but not gated — see cov-py. -PY_COV_INCLUDE := qpi_driver/cli.py,qpi_driver/sdk.py,qpi_driver/events.py,qpi_driver/paths.py,qpi_driver/options.py,qpi_driver/builtins/*.py,qpi_driver/executors/__init__.py,qpi_driver/executors/base/*.py,qpi_driver/executors/mock/*.py +# registry and its options, the executors that need no hardware, and the calibration +# grouping and fusion. The last two qualify where the routines do not: they are pure +# functions over a config and a dataset, with no instrument behind them. Everything else is reported but not gated — +# see cov-py. +PY_COV_INCLUDE := qpi_driver/cli.py,qpi_driver/sdk.py,qpi_driver/events.py,qpi_driver/paths.py,qpi_driver/options.py,qpi_driver/builtins/*.py,qpi_driver/executors/__init__.py,qpi_driver/executors/base/*.py,qpi_driver/executors/mock/*.py,qpi_driver/tuners/base/grouping.py,qpi_driver/tuners/base/fusion.py,qpi_driver/tuners/base/sweep.py PY_COV_MIN := 96 # `uv sync` reinstalls qblox_instruments, and macOS strips the code signature @@ -71,7 +73,7 @@ serve-docs: # --------------------------------------------------------------------------- # Test targets # --------------------------------------------------------------------------- -.PHONY: test test-docs test-docs-static test-docs-snippets test-docs-example test-docs-site \ +.PHONY: test bench-parallel test-docs test-docs-static test-docs-snippets test-docs-example test-docs-site \ test-go test-py test-py-driver \ test-py-cli test-py-sim test-py-loop \ test-dashboard test-js-client test-go-client test-py-client \ @@ -187,6 +189,17 @@ test-py-sim: qpi-driver/py/tests/test_physics_simulation.py \ qpi-driver/py/tests/test_calibration_e2e.py +# Not part of `test-py`: it walks the whole graph twice and takes minutes, so it is a +# manual trigger rather than a pre-commit check. The number it reports is *acquisitions* — +# one arm-and-wait cycle each — since a fused schedule's pulses are one target's and the +# sequencers play concurrently. See tests/test_parallel_savings.py. +bench-parallel: + @echo "Measuring what grouping saves on a 3-qubit, 2-coupler chain..." + $(UV) sync --project qpi-driver/py --extra sim --dev + $(RESIGN_Q1ASM) + QPI_BENCH=1 $(UV) run --no-sync --project qpi-driver/py pytest -s -v \ + qpi-driver/py/tests/test_parallel_savings.py + test-py-loop: @echo "Running the calibrate/process loop against the $(EXECUTOR) simulated chip..." $(UV) sync --project qpi-driver/py --extra $(EXECUTOR) --extra sim --dev diff --git a/docs/rfcs/0005-calibration-graph-completion.md b/docs/rfcs/0005-calibration-graph-completion.md index fe0adacb..7a65db97 100644 --- a/docs/rfcs/0005-calibration-graph-completion.md +++ b/docs/rfcs/0005-calibration-graph-completion.md @@ -518,7 +518,7 @@ came after the machinery. §14 records where the result diverged from §6 and § mechanism every calibration routine already uses to sweep a readout, and the same one the EF subspace's `ro2`/`ro_3st_opt` will need. - **What it is worth on this chip, honestly.** The frequency optimum sits about + **What it is worth on the simulated chip, honestly.** The frequency optimum sits about 200 kHz off the resonance — a tenth of a linewidth — so the frequency axis contributes almost nothing here, and the node earns its place through the amplitude, where signal grows linearly with drive while punch-through only bends diff --git a/docs/rfcs/0007-calibration-without-priors.md b/docs/rfcs/0007-calibration-without-priors.md index d9f8a8fb..2e4abc6b 100644 --- a/docs/rfcs/0007-calibration-without-priors.md +++ b/docs/rfcs/0007-calibration-without-priors.md @@ -66,7 +66,7 @@ a search but is never required to make one possible. | Decision | Resolution | |---|---| | New operation or event type? | **No.** `calibrate` carries this. Python driver only. | -| What a `RoutineConfig` means | **Changed, and this is the core of the RFC.** Today a sweep parameter is often load-bearing: omit it and the node cannot work on this chip. After this, every sweep has a derived default that works on any chip the hardware can address; config may only *narrow* a search to save time. A node that cannot run without an operator-supplied range is a bug. | +| What a `RoutineConfig` means | **Changed, and this is the core of the RFC.** Today a sweep parameter is often load-bearing: omit it and the node cannot work on a given chip. After this, every sweep has a derived default that works on any chip the hardware can address; config may only *narrow* a search to save time. A node that cannot run without an operator-supplied range is a bug. | | Where a bound comes from | Hardware config for instrument limits, upstream measurements for physical ones, escalation for the rest. New `tuners/base/limits.py`; the hardware config is already reachable from a routine via `device.hardware_config()`, as `has_flux_port` shows. | | Guards as signals | **Changed.** The six "your window is wrong" guards added in August 2026 raise prose. They gain a structured form the caller can act on, so the same detection drives a retry instead of a failure. §6. | | Routine interface | **Unchanged.** `measure` already absorbs a routine whose setpoints depend on an earlier acquisition — `qubit_spectroscopy` is the second implementor. No third interface. | @@ -118,7 +118,7 @@ codebase and was applied once. `f12_spectroscopy` centres on `f01 + anharmonicity_prior` and **ignores the config's `f12` entirely** — a physical relationship beats an unmeasured field, and its docstring says so. That is exactly the pattern this RFC generalises. It is also why that node was -the only one that ever found the August 2026 chip's qubit: it was the only node +the only one that ever found the qubit in the August 2026 bring-up: it was the only node searching from physics rather than from a prior. `conditional_phase` sweeps 0–360°. A phase has no range to guess at, so it never had @@ -268,7 +268,7 @@ Two changes, both cheap: and `fit_spectroscopy_power` already fits every row; today it picks the best row and discards the rest. Requiring the chosen centre to agree with a second row to within a linewidth costs nothing, since the data is already acquired, and noise does not - reproduce across powers. On the August 2026 chip this would have refused run one rather + reproduce across powers. In the August 2026 bring-up this would have refused run one rather than run six: its three rows fitted 782.7 kHz, 8.5 kHz and 28 kHz, which no real line does. @@ -412,8 +412,8 @@ after the two classes that need no loop at all. register budget, not for a better coefficient. `drag` is deliberately untouched: §5 proposed centring it on the measured - anharmonicity, and nothing measured says the symmetric sweep is wrong. Its failure on - the August 2026 chip was contrast, not placement. + anharmonicity, and nothing measured says the symmetric sweep is wrong. Its failure in + the August 2026 bring-up was contrast, not placement. 5. **Escalation — done.** `OutOfRange` carries the axis and the direction; `escalating` follows it, bounded at three attempts, and leaves an operator who named the axis alone. Two directions turned out to be needed rather than one: a flat *decay* wants a longer @@ -431,7 +431,7 @@ pulled into scope — the accept side of the guards is §6.2. **A prior is still indistinguishable from a measurement.** After this RFC the driver finds the qubit wherever it is, but nothing says whether `clock_freqs.f01` was measured by this -driver or typed in from a design document. The August 2026 chip carried +driver or typed in from a design document. The August 2026 bring-up carried `f01: 4735509751.238763` — nine significant figures, and the line was never there. Three things here want that distinction: §2's definition of a prior, §11's "no @@ -461,7 +461,7 @@ not an index over one. ## 11. Skipping what cannot succeed A node whose prerequisite was never produced cannot measure anything, and running it -anyway is how one failure became six. The August 2026 chip is the worked example: +anyway is how one failure became six. The August 2026 bring-up is the worked example: `qubit_spectroscopy` failed, and `rabi`, `resonator_spectroscopy_excited`, `readout_discrimination`, `allxy`, `drag` and `readout_fidelity` all then measured a qubit still in `|0⟩` and reported confident numbers from its noise. Six failures with @@ -480,7 +480,7 @@ plainly wrong. Twelve of the thirty-three nodes write nothing at all, so nothing depend on their output, and some are still depended on in the walk order. **Disabled is not failed.** `qubit_spectroscopy` depends on `resonator_punchout`, which -is switched off on the August 2026 chip because its amplitude grid never reaches +was switched off in the August 2026 bring-up because its amplitude grid never reaches punch-through, which phase 3 fixes (§12). `time_of_flight` is off too, and under naive propagation disabling either would skip the entire graph beneath it — which is to say, everything. That both are off *because* of range bugs this RFC fixes does not help: the @@ -542,8 +542,8 @@ and `drag` can legitimately run — as can `allxy`, `fine_amplitude`, `rb` and produced by design" from "producer switched off". Two read paths — `measure.integration_time` and `r12.ef_duration` — have no producer anywhere in the graph and are supplied by hand on every chip, so a sole-producer rule fires on them - every run; and the August 2026 chip disables `time_of_flight` while its - `measure.acq_delay` is a perfectly good hand-set 200 ns. Nothing is lost by waiting: + every run; and the August 2026 bring-up disabled `time_of_flight` while its + `measure.acq_delay` was a perfectly good hand-set 200 ns. Nothing is lost by waiting: the parameter view below already declines to block on either case. **Reinstated by RFC 0008 as a report, not an error.** Provenance splits the three cases the rule could not: a prior a routine in this run will measure, a prior whose producer is diff --git a/docs/rfcs/0008-parameter-provenance.md b/docs/rfcs/0008-parameter-provenance.md index 8664e9cd..80b02e32 100644 --- a/docs/rfcs/0008-parameter-provenance.md +++ b/docs/rfcs/0008-parameter-provenance.md @@ -16,7 +16,7 @@ driver measured, and a value somebody typed in. That is all "provenance" means here; §2 says it at more length. -The August 2026 chip carried `clock_freqs.f01: 4735509751.238763`. Nine significant +The August 2026 bring-up carried `clock_freqs.f01: 4735509751.238763`. Nine significant figures, so it reads as a measurement, and the qubit was 302 MHz away; the line had never been there. Six calibration runs were spent on the consequences. Precision is not provenance, and a file that cannot say which it is holding forces every reader, human or @@ -142,7 +142,7 @@ Not *how old* the measurement is — see §8. **Corrected while implementing phase 4: this must not relax the blocking rule.** The tempting reading is that a node blocked because its input failed *this* run should run anyway when an earlier run measured that input — the device does hold a real number. It is -wrong, and the August 2026 chip is the counterexample: a failure to *measure* f01 is +wrong, and the August 2026 bring-up is the counterexample: a failure to *measure* f01 is evidence against whatever f01 the file holds, because the usual reason spectroscopy finds no line is that the qubit is not where the file says. Running the six nodes behind it against last week's value fits the same noise, whatever the value's pedigree. So a failed diff --git a/docs/rfcs/0009-parallel-calibration.md b/docs/rfcs/0009-parallel-calibration.md new file mode 100644 index 00000000..a6b15293 --- /dev/null +++ b/docs/rfcs/0009-parallel-calibration.md @@ -0,0 +1,856 @@ +# RFC 0009 — Parallel Calibration + +- **Status:** Implemented. All six phases, and all 36 routines group — nothing in the graph + is excluded on principle. §9 records where + each phase stands. Not yet run on hardware — §5.6's acceptance measurement is what + would close that, and D10 says why simulation cannot. +- **Author:** Martin Ahindura +- **Created:** 2026-08-18 +- **Depends on:** RFC 0004 (the walk, the progress event, the report), RFC 0005 (the + thirty-three-node graph as it then stood), RFC 0006 (the plan on the wire and the drawing), + RFC 0007 (escalation), RFC 0008 (provenance) +- **Touches:** `qpi-driver` (the DAG, the routine base, two new modules), `qpi-ui` (the + progress accumulator, the graph drawing), `calibration.yml`. No new event type, no new + collection, and no chip-geometry file — see D3. + +Quantities here are per *topology*, never per device. A statement like "two groups" +holds for a class of connectivity graph and for any size of it; where a number depends +on the chip, this RFC says which config field it is computed from rather than quoting a +value. Nothing in it is derived from a particular lab's `quantify.*.yml`, all of which +are untracked local state. + +## 1. The idea + +The walk is two nested loops: for each routine, for each target. Every acquisition waits +for the one before it, and nothing about the chip requires that. A Rabi on two qubits far +enough apart drives different ports, reads different resonator frequencies and shares no +state; run sequentially they cost twice one of them, and run together they cost one. + +The prize is not a constant factor. Sequentially, a full walk costs +`routines × targets`, so it is **linear in qubit count**; grouped, it costs +`routines × groups`, and the group count is a property of the connectivity graph and +**not of its size** (§5.5). A chain needs two groups for its single-qubit routines +whether it has five qubits or fifty. So a walk that takes hours on a small chip takes +days on a large one, and grouped it takes hours on both. + +This RFC decides which targets may run together, how one schedule carries several of +them, and how the dashboard shows a set of components in flight rather than a point in a +list. It also fixes a defect found while writing it: **the calibration graph has a +`running` style and a legend entry that no walk has ever reached** (§7.1). + +## 2. What exists today + +Read, not assumed. + +**The walk is strictly sequential.** `CalibrationDAG.run` iterates `order`, and inside it +`for target in targets`, calling `_run_one` per pair. There is one `SchedulerBackend` and +one `InstrumentCoordinator` behind it. + +**A cluster is one arm-and-start resource, and the constraint is on `start`.** +`QuantifyBackend.run` does `prepare` → `start` → `wait_done` → `retrieve_acquisition`, +with `stop()` in a `finally`. Reading the scheduler's own cluster component: `start()` +*opens* with a cluster-wide `instrument.stop_sequencer()` — its comment says this is to +disarm everything so the no-argument `start_sequencer()` that follows starts only what it +just armed — and `stop()` calls `disable_sync()` on every module before another +cluster-wide `stop_sequencer()`. `wait_done` and `retrieve_acquisition` are cluster-wide +in the same way. D1 is about what that rules out. + +**Sequencer indices are stable, not first-come.** A module's sequencer index comes from +the port-clock ordering in the hardware config (`_construct_all_sequencer_compilers` +walks `_extract_sequencer_compilation_configs()` and keeps the entries with data), so a +given port-clock compiles to the same sequencer in every schedule. Two independently +compiled schedules over disjoint targets would *not* collide on sequencer indices — a +claim an earlier draft of this RFC got wrong, and the reason D1 rests on `start` instead. + +**Every fit reads acquisition channel zero.** `signal_of` takes +`dataset[list(dataset.data_vars)[0]]`. A dataset carrying several channels is read as +whichever one came first. + +**Nothing in the tuner path sets `acq_channel`.** Routines call +`backend.Measure(target, acq_index=index, bin_mode=...)`, so the channel comes from the +element — and `acq_channel` on a transmon element is a parameter with +`initial_value=0`, so *every* element answers to channel 0 unless something says +otherwise. The executor path already solved this: `executors/quantify/conv.py:240` passes +`acq_channel=idx`, commented "Use unique acq_channel per qubit to avoid overlaps". The +fix is a pattern the repo already uses. + +**The scheduler's gates are already multi-qubit.** `Reset(*qubits)` and +`Measure(*qubits, acq_channel=..., acq_index=...)` both take a variadic target list, and +`schedule.add` takes `ref_op`/`ref_pt`/`ref_pt_new`. Simultaneity inside one schedule is +expressible in the API as it stands. + +**The wiring already says which targets contend.** The hardware config's +`connectivity.graph` maps each `:` to an instrument output. Two targets +whose ports resolve to *different* outputs contend for nothing; two that resolve to the +same output share its LO, its DAC and its sequencer pool. Readout is the usual case — +a shared feedline puts many resonators behind one output — and it is the only shared +resource this RFC has to reason about, because drive and flux ports are per target on +every wiring the repo supports. + +**The instruction budget is per sequencer, not per schedule.** `MAX_SWEEP_POINTS = 700` +exists because a QRM_RF rejected a 12700-instruction program against a 12288 ceiling. +That ceiling is a sequencer's, and a fused schedule gives each target its own sequencer +running its own copy of the sweep. **Fusion costs sequencers, not instructions**, so none +of the sweep-size guards move. + +**Routines carry per-target state.** `Rabi.build_schedule` writes `self._amplitudes` and +`self._amplitudes_ceiling`, the latter from `full_scale(device.get_element(target), ...)`. +`escalating` widens a `RoutineConfig` that belongs to the routine, not to a target. + +**Fourteen routines override `measure`.** `resonator_spectroscopy`, +`qubit_spectroscopy`, `rabi`, `ramsey`, `t1`, `t2_echo`, `drag`, `fine_amplitude`, +`fine_amplitude_90`, `rb`, `drag_12`, `fine_amplitude_12`, `coupler_anticrossing`, +`interleaved_rb` — most of them only to call `escalating`. This is where most of a walk's +time goes, and §6.5 is about it. (An earlier draft said eleven, from a grep that +truncated.) + +**A routine's sweep setpoints are per-routine state, not per-target.** `build_schedule` +writes `self._frequencies`, `self._amplitudes` and friends, and `analyse` reads them +back. For a sweep centred on a per-qubit value — every frequency sweep in the graph — +the grid *differs* between targets, so a fused group would leave `analyse` fitting every +target against the last one's grid. This is the constraint that decides what can be +converted, and §6.6 is about it. + +**The device config has a coupling graph and no geometry.** A device config names +elements and edges (an edge carrying `parent_element_name`/`child_element_name`); the +hardware config names ports and outputs. Neither has coordinates, and quantify's +`QuantumDevice` has no field for them. §8. + +**The progress event fires after a target finishes.** `_report_progress` is called once +before the walk with the plan, then inside the target loop *after* `_run_one` returns. +Nothing announces a target starting. + +**The simulator supports several acquisition channels, caps entanglement at three, and +models no crosstalk.** `_to_dataset` groups acquisitions by channel and keys the data +variables by the integer channel. `MAX_ENTANGLED = 3`, because each qubit in a joint +register multiplies the Liouvillian's side by `levels²` and "four is not" survivable. +Crosstalk is listed under "What is not" modelled. D10 and §10.3. + +## 3. The gap, in three parts + +They are independent, and only the second is about physics. + +1. **Nothing knows which targets may run together.** The coupling graph exists but + nothing reads it as a conflict relation, and `calibration.yml` has no way to say + "these three, at once". +2. **One schedule cannot carry several targets.** Every routine builds for one target, + and every fit reads channel zero. +3. **The dashboard tracks a point, not a set.** `progress` names one routine and one + target. There is no shape for "these three qubits are being measured right now" — + and, prior to that, no shape for "this node is running" that ever fires. + +## 4. Decisions + +**D1 — Parallelism is fusion into one schedule, never concurrent submission.** + +The tempting design is a worker per group, each with its own `backend.run`, and the +natural follow-up is to scope the teardown so the groups stop treading on each other. +Scoping is *possible* — `ClusterModuleComponent` has its own `stop()` and +`disable_sync()`, so a cluster-wide teardown could be narrowed to the modules a schedule +actually used. It does not unlock concurrency, for three reasons, and they are worth +separating because only the first is about the API: + +1. **The blocker is on `start`, not `stop`.** `ClusterComponent.start()` begins by + disarming *every* sequencer in the cluster, deliberately, so that its no-argument + `start_sequencer()` fires only what it has just armed. Starting group B therefore + disarms group A mid-flight. A per-module `stop` leaves this untouched, and + `wait_done`/`retrieve_acquisition` are cluster-wide the same way, so B's retrieve + would read A's acquisition memory alongside its own. +2. **The contended resource is usually one module.** The groups worth running at once + are groups of qubits, and their readout typically shares a feedline and therefore one + output on one module. Per-module scoping is precisely useless for the case that + motivates it: the two groups would queue for the same module either way. +3. **Concurrent is not simultaneous, and calibration wants simultaneous.** Two + submissions have no shared time origin. What a grouped measurement must answer is + "how does this qubit behave *while* its neighbours are driven", and that requires the + drives to land at a known offset — which is what one schedule on one sync network + gives and two schedules never can, however carefully they are launched. + +So there is nothing to align between schedules. **The alignment is the schedule** — +`ref_op`/`ref_pt` inside one of them, compiled once, armed once, started once, retrieved +once. Fusion delivers everything per-module scoping was reaching for, and delivers the +simultaneity as well. + +*Two per-module observations are worth keeping even so.* `start()`'s opening disarm is +already a cluster-wide reset before every run, so the current code is consistent on that +point and needs no change. And narrowing `stop()` to the modules in the program would +still be a small, independent latency win on a large cluster — logged here as an aside, +out of scope, and not a prerequisite for anything below. + +**D2 — A group is a colouring of a conflict graph, computed from the coupling graph.** + +Not a hand-written list, because a hand-written list is wrong the first time a coupler is +added and nothing checks it. Not a solver, because greedy colouring is optimal where it +matters: two groups for single-qubit routines on any bipartite lattice at the default +spacing, and Δ for couplers — Vizing's lower bound, and what Sycamore's four coupler +patterns are. It is *not* optimal everywhere, and §5.5 records where it is not rather +than claiming otherwise. Explicit groups remain available as an override (§5.3), for a +chip whose measured crosstalk does not follow its topology. + +**D3 — Grouping needs the coupling graph, not chip geometry. No layout file here.** + +The conflict relation is *graph distance* over the coupling graph, which the device +config's edges already give. Coordinates would add nothing and would mislead: two qubits +physically close with no coupler between them are farther apart, for this purpose, than +two adjacent ones. The layout work is genuinely orthogonal (§8). + +**D4 — Demultiplex in the DAG. `analyse` never learns it ran in company.** + +One fused acquisition returns a Dataset with one data variable per channel. The DAG +slices it — `dataset[[channel]]` — and hands each routine a single-variable Dataset, +exactly the shape `signal_of` already reads. Thirty-three `analyse` implementations, +every fit in `tuners/fitting/`, and every existing test stay untouched. The alternative, +teaching each fit which channel is its own, is one chance per routine to read another +qubit's data and fit a plausible curve to it. + +**D5 — The acquisition channel is the target's position in its group, passed explicitly.** + +Not the element's `measure.acq_channel`, which defaults to 0 on every element and which +fusion would otherwise have to mutate mid-walk. One `Measure` per target with an explicit +`acq_channel`, all aligned to the same start. Nothing on the device is written, so no run +can leave a chip's config carrying a channel assignment made for a group it was in an +hour ago. + +**D6 — Fusion is opt-in per routine, behind a behaviour-preserving default.** + +`build_group_schedule` joins `acquire`, `measure`, `applies_to` and `uncorrected` as a +base-class hook whose default reproduces today's behaviour: a group of one delegates to +`build_schedule`, a larger group is refused. A routine becomes fusable by implementing +it, one at a time, each with its own test. Nothing regresses on the day this lands, +because nothing has opted in. + +**D7 — A group shares one sweep grid, or it is not a group.** + +`Rabi`'s ceiling is `full_scale(element)` per target, so two targets can want different +amplitude grids, and fusing them would sweep one over the other's range. The group +builder compares the setpoints each target resolved and splits out any target that +disagrees, running it alone. Refusing the whole group instead would let one unusual +element disable parallelism for a chip. + +**Two kinds of axis, and only one has to agree exactly.** A *time* axis is the schedule's +own timeline: an idle of 5 us is 5 us for every target, so two targets wanting different +delays cannot be fused at all. A frequency, amplitude or phase axis is per-target +hardware — its own NCO, port and clock — so at setpoint *i* each target may sit at its own +value, and all that has to agree is how many setpoints there are, since the acquisition +index is shared. `grouped_by_grid` is the first, `grouped_by_size` the second. The +distinction is what lets a spectroscopy sweep fuse at all: every one of them is centred on +its own target's line, so the strict rule would split every group of them. + +**Implemented as `compatible_groups`, and it groups rather than isolates.** A routine +whose grid is derived per target overrides the hook and returns the subgroups that agree, +so the qubits that do share a window are still measured together — splitting one outlier +off does not cost the rest their fusion. `t2_echo` is the case it exists for: its window +is scaled from each qubit's measured T1 and an idle is dead time on every port at once, +so there is no per-target time axis to give them. The default returns a single group, +which is right for a fixed gate sequence or a grid the config states outright — and an +operator who names the delays has made one statement about the whole chip, which puts +every target back together. + +**D8 — Failure and escalation are per target, and re-fusion is of the refused subset.** + +A group of five with one refused fit is four results and one `RoutineError`, exactly as +five sequential runs would be. `escalating` then widens for the refused target only and +re-fuses *those*, because widening the whole group would re-sweep satisfied targets over +a range chosen for a different one — and `Rabi` documents why that is not free: sweeping +to full scale where half scale sufficed put `amp180` 6.9% out on the simulated chip. + +**D9 — Shared-output feasibility is computed and refused, not assumed.** + +Simultaneous tones behind one instrument output must fit one LO's addressable band and +one DAC's full scale, and must not need more sequencers than the module has. All three +are arithmetic on the two configs (§5.4). A group that does not fit is split, with the +numbers in the message — the failure mode otherwise is a clipped readout producing +confident wrong contrast on every qubit at once. + +**D10 — The simulator can validate the crosstalk *detector*; it cannot set the radius.** + +The simulator models no crosstalk today, and it can be made to for a **pair or a +triple**: `coupled.py` already holds two transmons in one register with an exchange term, +and a static ZZ coefficient is the same kind of addition. `MAX_ENTANGLED = 3` is the +ceiling, and it is a hard one — the Liouvillian's side grows as `levels^n`, and the +module says four qubits is not survivable — so a chip-scale group can never be simulated. + +That bounds what simulation is for, and the bound is more useful than it sounds. A pair +is enough to test that the acceptance measurement of §5.6 *works*: switch a ZZ term on +and `parallel_penalty` must become non-zero; switch it off and it must return to zero. +That validates the detector. It cannot validate a radius, because the coefficient would +be a number this project chose — `coupled.py` is already candid that several of its +constants were picked rather than measured — and a test asserting a spacing is safe would +be asserting our own constant. **The detector is testable in simulation; the radius is +settled on hardware.** §5.6. + +**D11 — The running node is fixed first, and separately.** + +A node in flight has been undrawable since RFC 0006 shipped (§7.1). It is a small driver +change plus a branch in the server's accumulator, it is worth having on its own, and a +grouped run is unwatchable without it. It goes first, in its own phase, and its tests do +not mention parallelism. + +## 5. Grouping + +### 5.1 What the literature settles + +"Which components may be calibrated at once" is "which gates may be applied at once", +which is well covered. + +**Grouping is a graph colouring.** Kelly et al. put calibration on a DAG and note +parallelism across qubits as its natural extension; the ISCA 2025 hardware-aware protocol +makes it explicit, using graph traversal to identify compatible calibration operations +and splitting oversized subgraphs, and reports 8–25× less calibration overhead than +sequential. Sycamore's two-qubit layers are four patterns of disjoint couplers tiling the +grid — an edge colouring of a degree-4 lattice at Vizing's Δ. + +**Adjacency is the conflict, and the guard band is empirical.** Simultaneous operation +degrades fidelity through residual ZZ coupling and drive leakage, and the degradation is +measured rather than derived: Gambetta et al.'s simultaneous randomized benchmarking +benchmarks each qubit alone and then together, and the difference in average gate +fidelity *is* the addressability. Murali et al. use SRB for pairwise crosstalk +characterisation, reduce it from all-pairs to a tractable set, then serialise the pairs +found to conflict — which is this RFC's structure exactly: characterise, group, and put +conflicting pairs in different groups. CAMEL partitions a frequency-tunable chip into +local windows for the same reason. + +**Simultaneous readout behind a shared feedline is standard and bounded.** Heinsoo et al. +read five qubits in one 1.2 GHz channel with individual Purcell filters and found +simultaneous readout errors within 1% of individual ones. That is the result which makes +a shared feedline workable rather than disqualifying, and its preconditions are what D9 +checks. + +So the rule implemented here is the one the literature converged on: **conflict is +proximity in the coupling graph; the radius is a parameter; the parameter is set by +measurement.** + +### 5.2 The rules + +The conflict graph over qubits has an edge between `u` and `v` when any of: + +- graph distance over the coupling graph is less than `qubit_spacing` — the *minimum* + distance two qubits in a group must be apart. The default 2 excludes adjacent pairs, + which leaves at least one idle qubit between every pair in a group; 1 imposes nothing + and measures the whole chip at once; 3 leaves two; +- the pair is listed in `parallel.exclude`; +- the wiring cannot carry both — their ports resolve to the same instrument output and + their clocks cannot coexist in it (§5.4). + +Over couplers, an edge when the graph distance between the two couplers' endpoint sets is +less than `edge_spacing` — default 1, which requires only that they share no qubit; 2 +additionally puts a qubit between them. + +Groups are the colour classes of a greedy colouring in a deterministic order (the +config's target order), so the same config always produces the same groups and a run is +reproducible. `max_group` caps a class, which is what keeps a wide chip inside its +sequencer count. + +**Added while implementing phase 2: an unreadable coupling graph is not an empty one.** +With no edges to read — a config targeting none, or a scheduler whose device no longer +exposes them — every qubit sits at infinite distance from every other and the colouring +returns a single group, which is the most aggressive setting available arrived at by +accident. So a walk with more than one target and no adjacency runs them one at a time +and says why. + +### 5.3 The configuration + +```yaml +parallel: + enabled: true + qubit_spacing: 2 # minimum graph distance between two qubits in a group + edge_spacing: 1 # 1 = share no qubit; 2 = a qubit between two couplers + max_group: 8 + exclude: # never grouped, whatever the spacing allows + - [q1, q3] + groups: # explicit override; skips the colouring entirely + qubits: [[q0, q2, q4], [q1, q3]] +``` + +Absent, `parallel` means `enabled: false` and the walk is exactly today's. That is the +opposite of `routines`' default — where absent means enabled — and deliberately so: a +missing `routines` entry cannot make a run measure nothing, whereas a `parallel` block +defaulting to on would silently change how every existing chip calibrates. + +### 5.4 Shared-output feasibility + +For a group whose targets' ports resolve to the same instrument output, three checks, +each arithmetic on the hardware and device configs: + +- **One LO band.** Every clock in the group must lie within `backend.if_limit_hz` of the + group's band centre, since one output has one LO and each sequencer's NCO offsets from + it. Read the clocks from the device config, take the midpoint of their range, and + compare the worst offset against the limit. +- **One DAC.** The group's simultaneous pulse amplitudes sum below full scale, because + the tones are added before the converter. Read them from the device config. +- **Sequencer count.** One sequencer per port-clock pair, against the module's own count + from the hardware description. + +A group failing any of them is bisected and both halves retried. The message names the +measured figure and the ceiling, in the house style of `SchedulerBackend.allow` and for +the same reason: an operator can act on "worst offset X MHz against the Y MHz band" and +cannot act on "group too wide". + +### 5.5 What it costs and what it saves + +A sequential walk over `Rq` qubit routines and `Re` edge routines on `N` qubits and `E` +edges costs `Rq·N + Re·E` acquisitions. Grouped it costs `Rq·Gq + Re·Ge`, where `Gq` and +`Ge` are the colour-class counts. The graph currently gives `Rq = 30` and `Re = 6`; +RFC 0005 shipped 27 and 6, and the figures move as nodes are added, which is why the +argument below is about `N/Gq` rather than about a total. + +`Gq` and `Ge` depend on the connectivity graph and not on its size: + +| Topology | `Gq` at spacing 2 | `Gq` at spacing 3 | `Ge` at `edge_spacing: 1` | +| --- | --- | --- | --- | +| linear chain | 2 | 3 | 2 | +| square lattice (Δ=4) | 2 | 6–7 | 4 | +| heavy-hex (Δ≤3) | 2 | 4 | 3 | + +Measured against the implementation, not derived — see `tests/utils/chips.py`. + +Every `Gq` at the default spacing is 2, because each of these graphs is bipartite; `Ge` +is Δ, Vizing's lower bound, on all three. So the grouped cost is a constant — +`27·2 + 6·Ge` — for a chain of five qubits and a lattice of five hundred alike, while +the sequential cost grows with both `N` and `E`. **The speedup is therefore not a fixed +number to quote but `N/Gq`,** which is the whole reason to build this rather than buy a +faster fridge. + +**Greedy is exact at the default spacing and not at spacing 3.** The five-colour Lee +tiling is the optimum for an infinite lattice; greedy colouring reaches six on a small +one and seven at 5×5. That costs runtime and never correctness, and the default spacing +is the case where greedy provably cannot do worse — two classes on a bipartite graph. +Closing the gap is graph colouring, which is NP-hard, and not worth it for one class on +a non-default setting. + +Setting `qubit_spacing: 1` collapses `Gq` to 1 and is what the SRB literature does +routinely; §5.6 is how a chip earns it. + +### 5.6 What licenses a tighter spacing + +`qubit_spacing: 2` ships as the default because no chip has evidence for anything +tighter until the measurement is made. That measurement is Gambetta et al.'s, and the +graph already contains both halves of it: `rb` measures a qubit's average gate fidelity +alone, and a fused `rb` over a group measures it in company. The difference is the +addressability, per qubit, in the units the drift check already thresholds on. + +So the acceptance test is a comparison of two runs the driver can already do, and it +belongs in the report rather than in a paper: `parallel_penalty` per target, beside the +fidelity it is derived from. An operator tightens `qubit_spacing` when that number is +small on their chip and loosens it when it is not. Nothing here decides for them, and +D10 says which half of this is testable without a fridge. + +**Implemented as `parallel.measure_penalty`, off by default.** A benchmark's group runs +first and the same targets are then benchmarked one at a time into a throwaway report; the +difference lands on the grouped benchmark. The isolated pass is a *control* — its rows are +discarded, because the fused numbers are the ones that describe how the chip will actually +be driven. It doubles what benchmarking costs, which is why an operator opts in rather than +paying for it on every run. No new node and no change to the graph's shape (§11). + +## 6. Fusion + +### 6.1 The hook + +```python +def build_group_schedule( + self, targets: Sequence[str], device: Any, config: RoutineConfig, + backend: SchedulerBackend, +) -> Any: + """This experiment over every target at once, in one schedule. + + A fusable routine implements this and lets `build_schedule` delegate to it with a + single target; the default here goes the other way, so an unconverted routine keeps + working and declines a group. + """ + if len(targets) == 1: + return self.build_schedule(targets[0], device, config, backend) + raise RoutineError(f"{self.name} cannot run {len(targets)} targets in one schedule") +``` + +`fusable` mirrors `measures_itself`: whether the subclass overrode the hook. The DAG +always calls `build_group_schedule`, so there is one path, and a group of one exercises +it on every existing routine. + +A converted routine reads the way the sequential one did, with one addition — the +alignment: + +```python +def build_group_schedule(self, targets, device, config, backend): + self._amplitudes = ... + schedule = backend.new_schedule(self.name, repetitions=int(config.get("shots", 1024))) + for index, amplitude in enumerate(self._amplitudes): + schedule.add(backend.Reset(*targets)) + anchor = None + for target in targets: + pulse = backend.Rxy(theta=180, phi=0, qubit=target, amp180=amplitude) + anchor = anchor or schedule.add(pulse) + if schedule.schedulables[-1] is not anchor: + schedule.add(pulse, ref_op=anchor, ref_pt="start", ref_pt_new="start") + for channel, target in enumerate(targets): + schedule.add( + backend.Measure(target, acq_channel=channel, acq_index=index, ...), + ref_op=anchor, ref_pt="end", ref_pt_new="start", + ) + return schedule +``` + +`Reset(*targets)` is one operation over the whole group, so the resets coincide for free. +The drives and measurements need `ref_op`/`ref_pt` — without them `schedule.add` appends, +and a group's resets and pulses serialise into a schedule as long as the sequential run +it was meant to replace. **That is the whole of "aligning the schedules", and it lives +inside one.** + +### 6.2 Demultiplexing + +```python +def channel_of(dataset: Any, channel: int) -> Any: + """Acquisition channel *channel* alone, as a Dataset `signal_of` can read.""" +``` + +Data variables are keyed by the integer channel — the simulator's `_to_dataset` builds +`variables[channel]`, and a cluster agrees — so this is a selection, not a +reconstruction. A group's result is `[channel_of(dataset, i) for i in range(len(group))]` +and each element goes to `analyse` unchanged. + +A channel missing from the returned dataset is that target's failure and nobody else's: +recorded as a `RoutineError` against that target while the rest of the group succeeds +(D8). + +### 6.3 Where fusion lives + +``` +qpi-driver/py/qpi_driver/tuners/base/ + grouping.py # coupling graph -> conflict graph -> colour classes; feasibility + fusion.py # group schedule alignment; dataset demultiplex +``` + +Two modules because they answer unrelated questions and are tested against unrelated +things: `grouping.py` is graph theory over a config and needs no device, `fusion.py` is +scheduler mechanics and needs no topology. `dag.py` gains the group loop and nothing +else. + +### 6.4 Timeouts and the budget + +A fused acquisition's pulses are one target's, not the group's, because the sequencers +play concurrently — so `backend.allow` returns what it does today and the over-budget +refusal is unchanged. `start_accounting` moves from per-target to per-group, which is the +honest scope: the ceiling bounds one arm-and-wait cycle, and a group is one. + +### 6.5 The fourteen that measure themselves + +`escalating` widens a `RoutineConfig` and retries, and it is where most of a walk's hours +go. Over a group it becomes: acquire once, analyse each target, keep the fits that +landed, and re-fuse the subset that raised `OutOfRange` under a config widened for them. +Bounded as it is now, by `MAX_ESCALATIONS` per subset — so the worst case is the current +worst case, and the common case where nothing escalates is one acquisition for the whole +group. + +**A fusable schedule is not enough.** `measure` is where escalation, refinement and any +between-pass write-back live, and the fused path runs none of them — so a routine with its +own loop is grouped only once it has a `measure_group` too, however group-capable its +schedule is. `ramsey` is the case: its loop refines `f01` over several passes, applying to +the device between them, and fusing on the schedule alone would have skipped all of it. +Its schedule is converted; its loop is not, so it still walks one target at a time. + +**Corrected in review: `qubit_spectroscopy` was wrongly listed here.** This section said +its "next window depends on what the last one found", and treated that as a reason it +cannot be grouped. The dependency is real but it is *within one qubit, across passes* — +sweep the configured window, and if no line is there, search wide and then confirm around +the line that search found. It says nothing about other qubits, which are independent. + +That is the shape `escalating_group` already handles: run the stage for the group, then +run the next stage for the subset that needs it. A confirm window centred on each qubit's +own found line is a per-target frequency axis, so those still fuse under +`grouped_by_size`. The routine is convertible, and the reason given for it was a +conflation of "cannot be one schedule" with "cannot be one group". It is now converted: +stage 1 for the group, stage 2 per lost qubit, stage 3 fused again with each qubit's own +confirm window riding on its own `Sweep`, since a config is one object for the group and a +shared `centre_frequency` could only describe one of them. + +**Corrected again, and it was the same mistake a third time.** `coupler_anticrossing` was +then the last routine said to stay sequential, on the grounds that "a chip has one bias +source". It has one *rack*. An S4g has four current outputs, a cluster has many baseband +outputs, and each edge already names its own — `bias.spi_module`/`bias.spi_output`, or the +`qcm` pair. So setting a group's currents is one quick write per edge over the same serial +port and then a *single* acquisition; what is sequential is one coupler across its own +current setpoints, which is where every other routine's dependency also lives. + +Both delivery mechanisms were already built and tested before this RFC — `SpiRackBias` +driving an S4g and `QcmBias` holding the same offset on a baseband output, selected by +`bias.source` — so nothing had to be implemented to group it. The claim was never about a +missing capability; it was the third instance of reading a per-target sequential dependency +as a group-wide one. + +**Nothing in the graph is now excluded on principle.** All thirty-six routines group. + +### 6.6 What can be converted, and what needs more than a hook + +Measured across the graph: **eight** routines store no per-target sweep state and can be +converted as they stand — `allxy`, `allxy_check`, `readout_discrimination`, +`readout_fidelity` and `three_state_discrimination`, plus the four edge routines whose +grids are target-independent (`cz_spectroscopy`, `cz_parametrization`, +`conditional_phase`, `cz_chevron`), which phase 5 takes. The first four are converted. **Ten** more on the plain path centre a grid on a per-qubit value — +`resonator_spectroscopy_excited`, `f12_spectroscopy`, `rabi_12`, `ramsey_12`, +`flux_spectroscopy` and the rest — and need their setpoints moved from routine state to +per-target state before they can fuse. That is a mechanical but broad change, and it is +the real content of the phase-4 work rather than the escalation loop alone. + +The order matters and was not obvious when §9 was written: **per-target setpoint state is +a prerequisite for fusing most of the graph**, and `escalating` needs it too, since +widening a refused target's axis means holding a different grid for that target than for +its group. §9's phases 3 and 4 are re-scoped accordingly. + +## 7. The dashboard + +### 7.1 The running node has never been drawn + +`CalibrationGraph` has a `running` style (`animate-pulse`, blue) and a legend entry for +it. Nothing reaches it. + +`advanceNodes` folds a progress event, and a progress event fires *after* a target +finishes. So for a node with one target the first event it produces already has +`Done >= Total`, and `settledState` sends it straight to `done` — `running` is +unreachable for that node, always. The repo's own tests encode this without naming it: +the reducer test asserts a single-target node ends `done` after one event, and the test +for `running` needs a three-target node to reach the state at all. **A bring-up +calibrating one qubit at a time gives every node one target, so on that run the state is +unreachable everywhere and the legend entry is dead.** Even with several targets it is +late: a node is `pending` — indistinguishable from "not its turn" — for the whole of its +first target, which for a spectroscopy or an RB is minutes. + +A second defect from the same cause: a target skipped for an unsatisfied prior (RFC 0007 +§11) `continue`s before `_report_progress`, so a node whose every target is blocked +produces no event at all and is drawn `pending` for the rest of the run. + +The logs are better than the graph but not complete. `run` logs +`[7/33] rabi running on ` before the target loop, so the journal does name the +current routine; per *target* there is only the `ok`/`FAILED` line afterwards. An +operator watching a long node can see the routine and cannot see which target. + +**The fix.** Emit a progress event *before* the work as well as after, carrying the +targets about to run: + +```python +{"step": position, "total": len(order), "routine": name, "running": [...targets]} +``` + +`advanceNodes` treats an event carrying `running` as a start — set `State = "running"`, +leave `Done` alone — and an event without it exactly as it does now. +`CalibrationNodeState` gains `running: string[]`, and a skipped target emits a completion +event so a blocked node settles instead of sitting at `pending`. A driver that predates +this sends no `running` key and behaves as it does today. + +**Refined while implementing phase 1: a skipped target needs a state of its own.** The +completion event settles the node, but counting a skip as `done` says a node measured +something when nothing ran. So the event also carries a cumulative `skipped`, and +`settledState` gains `blocked` — every target skipped — with `partial` for a mixture. +Failure outranks a skip, since a node with one of each has something to investigate. +`blocked` is a fifth walk-reported state and is distinct from the plan-derived `skipped`, +which still means "applies to nothing here". + +### 7.2 The set in flight + +`running: string[]` on the node state is also the answer to tracking components, and it +needs no new event and no new collection. + +- The node in flight draws as `running`, the style that already exists. +- Its in-flight targets render as chips on the node and in `NodeCard` — target names + under a pulsing box is "these components are being calibrated right now", which is the + question asked. +- The group *is* the set, so nothing has to be joined client-side to recover it. + +`layoutGraph` and `statusOf` do not change shape: `statusOf` already returns +`reported?.state ?? "pending"`, and this makes `reported.state` arrive on time. + +The plan gains `groups` per routine — the colour classes the driver computed — so the +drawing can show a node's group count beside its target count, and an operator can see +where a run's parallelism went before it starts. + +## 8. Chip layout — out of scope, and its own RFC + +Quantify has no geometry: `QuantumDevice` has elements and edges, the hardware config has +ports and outputs, and there is no coordinate and no field to hold one. What exists is a +*coupling graph*, and per D3 that is all grouping needs. + +A layout is wanted for a different job — drawing the chip the way other vendors' consoles +do, in the QPU driver and registry — and it should be designed there, as **RFC 0010**, +not appended here. The only decision this RFC needs to record is that it is not a +dependency: nothing in §5–§7 waits for a layout, and grouping must never be rewritten to +want one, because physical distance is the wrong metric for the question grouping asks. + +## 9. Implementation plan + +Six phases. Each is separately valuable and separately revertable. + +**Where this stands.** All six phases are implemented and **all thirty-six routines +group**. Nothing in the graph is excluded on principle. + +Three claims in earlier drafts of this section were wrong, and all three were the same +mistake: reading a dependency between a routine's own *passes* as a dependency between +*qubits*. `qubit_spectroscopy`'s search, `ramsey`'s refinement and +`coupler_anticrossing`'s bias sweep each need the previous setpoint's result for the same +target, and none of them reads another target at any point. Each groups by running the +stage for the group and the next stage for the subset that still needs it, which is what +`escalating_group` was built to do. "Cannot be one schedule" is not "cannot be one group"; +I conflated them three times, and the shape of the mistake is worth more here than a +corrected list. + +The last of the three also came with a wrong premise about the hardware — that a shared +bias rack forces one coupler at a time. A rack is shared; its channels are not, and both +delivery mechanisms already addressed them per edge. + +**What it saves, measured.** `make bench-parallel` walks the graph twice on a chain of three +qubits and two couplers and reports acquisitions — one arm-and-wait cycle each, which is the +quantity a fused schedule reduces, since its pulses are one target's. 52 sequentially against +37 grouped, so 1.41x. That is a floor rather than the figure §5.5 derives: only 20 of the +graph's 102 routine-targets complete against the simulator, the rest having no physics there. +Off unless `QPI_BENCH=1`, and in CI only from a manual dispatch. + + +Grouping is off unless `calibration.yml` says otherwise, so none of this changes an +existing chip's walk until an operator turns it on. + +**Phase 1 — the node in flight.** §7.1. A pre-work progress event in `dag.py`, a +completion event for a skipped target, the `running` branch in `advanceNodes`, the field +in `types.ts`. No grouping anywhere. Ships a graph that shows where a run has got to, +which every later phase is watched through. + +**Phase 2 — grouping, computed and published, nothing executed.** `grouping.py`, the +`parallel` block, the feasibility checks, `groups` on the plan. `enabled: false` by +default, so the walk is unchanged and the phase is provable by unit test and by `--plan` +output. Answers "which components can run together" before anything depends on it being +right. + +**Phase 3 — the fusion mechanism, and the routines that need only the hook.** +`build_group_schedule`, `add_together`/`add_after`, `channel_of`, the group loop and the +per-output narrowing in `dag.py`, and conversion of the routines with no per-target sweep +state (§6.6). The value is not the speedup — the expensive nodes come later — it is that +the channels, the alignment and the demultiplexing are proven first. + +**Phase 4 — per-target setpoint state, then the routines that escalate.** §6.6 and §6.5. +Move each routine's swept setpoints off the routine and onto the target, which is what +unblocks the ten remaining plain-path nodes *and* group-aware `escalating` with +per-subset widening. This is where the bulk of the saving arrives, and it is a larger +piece of work than phase 3. + +**Phase 5 — edges.** `edge_spacing`, and fusion for the six edge routines. Last because a +CZ occupies both its qubits and a flux line, so it has the most conflicts and the least +to gain, and because it is only meaningful once its qubits calibrate in parallel. + +**Phase 6 — the acceptance measurement.** §5.6. `parallel_penalty` in the report from +fused-versus-isolated `rb`, plus the pair-scale ZZ term in the simulator that D10 says +can test the detector. This is what turns `qubit_spacing` from a guess into a setting. + +## 10. Testing strategy + +### 10.1 The floor + +`grouping.py` and `fusion.py` are added to `PY_COV_INCLUDE` at `PY_COV_MIN = 96`. They +qualify where the routines do not: both are pure functions over configs and datasets with +no instrument behind them, so 96% is reachable honestly rather than by asserting that +mocks were called. + +Shared topology fixtures go in `tests/utils/chips.py` — chain, square lattice, heavy-hex +and star, each parameterised by size — beside the existing `circuits.py` and +`simulation.py`. + +### 10.2 What the tests must establish + +Not "the code runs". Each of these is a way the feature can be wrong: + +- **Grouping.** No two members of a group are within `qubit_spacing`; `exclude` beats the + spacing; groups partition the targets exactly once each; the colouring is deterministic + across runs; the class counts match §5.5's table for each topology at each spacing, and + do so at two different sizes of the same topology — which is what pins the claim that + the count does not depend on `N`. +- **Feasibility.** A group whose clocks straddle more than `if_limit_hz` is split and the + message carries both figures; one whose amplitudes sum past full scale is split; one + needing more sequencers than the module has is split. +- **Fusion.** A fused schedule's per-target operations share a start time — asserted on + the schedule's own timing, not on the calls made to build it, which is why the test + harness's schedule stand-in had to start tracking when each operation begins. A group + of one goes down the path it did before fusion existed. And a following stage starts + after the *longest* operation of the last, since a group whose targets have different + pulse durations would otherwise overlap. +- **Demultiplexing.** Each target's `analyse` receives its own channel. The test that + matters is the adversarial one: fuse two targets whose correct answers differ, and + assert each fit lands on its own. A demultiplexer wired to channel zero passes every + same-answer test ever written. +- **Per-target failure.** One refused fit in a group of five yields four results and one + error against the right target, and the walk continues. +- **Escalation.** A group where one target refuses re-fuses that target alone, and the + satisfied targets are not re-swept over the widened range. +- **The in-flight node.** A single-target node is reported `running` before it is + reported `done` — the regression test for §7.1, and it fails against the code as it + stands. A node whose every target is blocked settles rather than staying `pending`. +- **The crosstalk detector.** With the pair-scale ZZ term on, `parallel_penalty` is + non-zero; with it off, zero. Asserts the detector responds, and asserts nothing about + what spacing is safe (D10). +- **Backward compatibility.** No `parallel` block calibrates exactly as today, asserted + by comparing the walk's schedule sequence against the sequential one. + +### 10.3 What the simulator can and cannot show + +It can run a fused schedule end to end and produce per-qubit populations from per-qubit +amplitudes, so every claim above except the last is testable without hardware. With +D10's addition it can also show that the crosstalk detector detects crosstalk — for a +pair or a triple, since `MAX_ENTANGLED = 3`. It cannot show that a *group* is +crosstalk-free: a chip-scale joint register is beyond the ceiling, and the coefficient +would be one this project chose. §5.6 is the hardware counterpart, and this RFC does not +move to Implemented until it has been run. + +**Implemented as `CoupledTransmons.zz_mhz`, zero by default.** A diagonal term shifting +each qubit's frequency in proportion to the other's excitation, so a phase calibrated with +the neighbour in ``|0>`` is wrong with it in ``|1>`` — crosstalk that costs phase rather +than population, which is exactly why a routine measuring one qubit at a time cannot see +it. The tests assert it is zero by default, non-zero and proportional when set, and +diagonal; nothing asserts a safe spacing, which is the line D10 draws. + +## 11. What this deliberately does not do + +- **No concurrent instrument access.** D1. One cluster, one arm-and-start cycle. The + per-module `stop()` narrowing D1 discusses is noted as an independent latency aside, + not adopted. +- **No parallelism across routines.** Two independent branches of the DAG could in + principle run at once, but they contend for the same cluster and would have to be fused + into one schedule anyway — at which point the grouping is over targets again, and the + ordering guarantees `diagnose` relies on get harder to keep. The win is over targets, + and that is where this stops. +- **No chip-scale crosstalk model.** D10 bounds simulation to validating the detector. + Learning a coupling matrix is a different RFC. +- **No frequency reallocation.** Klimov et al. optimise frequency trajectories to reduce + crosstalk; that changes what the chip *is*, and this RFC only decides what to measure + at the same time. +- **No chip geometry.** §8 — RFC 0010. +- **No change to the graph's shape, checks or provenance.** RFCs 0005, 0007 and 0008 hold + as written; a group is a different number of targets per acquisition and nothing else. + +## 12. References + +- J. Kelly, P. O'Malley, M. Neeley, H. Neven, J. M. Martinis, *Physical qubit calibration + on a directed acyclic graph*, [arXiv:1803.03226](https://arxiv.org/abs/1803.03226) + (2018). The graph and `diagnose` already follow it; §5.1 follows its remarks on + parallelism. +- J. M. Gambetta et al., *Characterization of addressability by simultaneous randomized + benchmarking*, Phys. Rev. Lett. **109**, 240504 (2012), + [arXiv:1204.6308](https://arxiv.org/abs/1204.6308). The acceptance measurement of §5.6. +- P. Murali, D. C. McKay, M. Martonosi, A. Javadi-Abhari, *Software Mitigation of + Crosstalk on Noisy Intermediate-Scale Quantum Computers*, ASPLOS 2020, + [arXiv:2001.02826](https://arxiv.org/abs/2001.02826). Tractable pairwise + characterisation, then serialisation of the conflicting pairs. +- Y. Zhu et al., *Hardware-aware Calibration Protocol for Quantum Computers*, ISCA 2025, + [doi:10.1145/3695053.3731036](https://doi.org/10.1145/3695053.3731036). Parallel + calibration by graph traversal over compatible operations; subgraph splitting; 8–25× + overhead reduction. +- J. Heinsoo et al., *Rapid high-fidelity multiplexed readout of superconducting qubits*, + Phys. Rev. Applied **10**, 034040 (2018), + [arXiv:1801.07904](https://arxiv.org/abs/1801.07904). Five qubits in one readout + channel, simultaneous within 1% of individual — the precondition set of §5.4. +- *Quantum Crosstalk Analysis for Simultaneous Gate Operations on Superconducting + Qubits*, PRX Quantum **3**, 020301 (2022), + [doi:10.1103/PRXQuantum.3.020301](https://doi.org/10.1103/PRXQuantum.3.020301). +- P. V. Klimov et al., *Optimizing quantum gates towards the scale of logical qubits*, + Nature Communications **15**, 2442 (2024), + [arXiv:2308.02321](https://arxiv.org/abs/2308.02321). Frequency allocation as crosstalk + mitigation — the alternative §11 declines. +- *CAMEL: Physically Inspired Crosstalk-Aware Mapping and gatE scheduLing for + Frequency-Tunable Quantum Chips*, + [arXiv:2311.18160](https://arxiv.org/abs/2311.18160). Local-window partitioning. +- F. Arute et al., *Quantum supremacy using a programmable superconducting processor*, + Nature **574**, 505 (2019). The four coupler patterns — an edge colouring of the grid + at Vizing's Δ. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index c1f6a776..bec90522 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -16,6 +16,7 @@ holds both the system design and its phased implementation plan, so a contributo | [0006](./0006-calibration-graph-in-the-dashboard.md) | The Calibration Graph in the Dashboard | Draft | | [0007](./0007-calibration-without-priors.md) | Calibration Without Priors | Implemented (§11.5 open) | | [0008](./0008-parameter-provenance.md) | Parameter Provenance | Implemented | +| [0009](./0009-parallel-calibration.md) | Parallel Calibration | Implemented (untested on hardware) | RFCs 0004 and 0005 were written before the graph had run on a chip, and say so where it matters. RFCs 0007 and 0008 are the opposite case: they exist because of what running it @@ -23,6 +24,10 @@ on one found. 0008 was the piece 0007 deferred; both are now implemented, and ea where building it corrected what it had claimed. 0007 §11.1 was added after both were closed, because hardware found it — a gap the RFC's own mechanism was meant to cover. +0009 is the first to change *how* the graph is walked rather than what it contains, and it +opens by fixing a defect in 0006 that writing it surfaced: the dashboard has a `running` +style no walk has ever reached. + ## Conventions - Number sequentially: `000N-short-slug.md`. diff --git a/mkdocs.yml b/mkdocs.yml index 3f2d02de..a06bbf9b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,10 @@ nav: - 0003 Driver Extensibility: rfcs/0003-driver-extensibility.md - 0004 Calibration Tuners: rfcs/0004-calibration-tuners.md - 0005 Calibration Graph Completion: rfcs/0005-calibration-graph-completion.md + - 0006 The Calibration Graph in the Dashboard: rfcs/0006-calibration-graph-in-the-dashboard.md + - 0007 Calibration Without Priors: rfcs/0007-calibration-without-priors.md + - 0008 Parameter Provenance: rfcs/0008-parameter-provenance.md + - 0009 Parallel Calibration: rfcs/0009-parallel-calibration.md - Changelog: changelog.md markdown_extensions: diff --git a/qpi-driver/py/qpi_driver/simulation/coupled.py b/qpi-driver/py/qpi_driver/simulation/coupled.py index de1550e6..553ba403 100644 --- a/qpi-driver/py/qpi_driver/simulation/coupled.py +++ b/qpi-driver/py/qpi_driver/simulation/coupled.py @@ -54,6 +54,22 @@ #: to a few tens; this is at the low end of normal. G_MHZ = 3.2 +#: Always-on ``ZZ`` coupling between the pair, in MHz. Zero, so nothing changes unless a +#: test asks for it. +#: +#: What it is for, and what it is not. A driven qubit shifts its neighbour's frequency by +#: this much, so a pulse calibrated in isolation is slightly wrong when the neighbour is +#: driven too — which is the crosstalk a fused group is exposed to and a sequential walk is +#: not. Switching it on is how `parallel_penalty` can be shown to *detect* something +#: (RFC 0009 D10). +#: +#: It cannot license a grouping. The number would be one this project chose, so a test +#: asserting that some `qubit_spacing` is safe would be asserting the constant rather than +#: the chip — and `MAX_ENTANGLED` caps a joint register at three qubits, so a chip-scale +#: group is out of reach in principle. The detector is testable here; the radius is settled +#: on hardware, by §5.6's measurement. +ZZ_MHZ = 0.0 + #: Flux-pulse amplitude to control-qubit detuning, in GHz per squared unit of #: amplitude. Also chosen. Quadratic because a transmon sits at a flux sweet spot #: where the first derivative of frequency with flux vanishes, so the leading @@ -190,6 +206,8 @@ class CoupledTransmons: control: the flux-tunable qubit — the one a CZ's flux pulse detunes. target: the fixed-frequency qubit it is tuned towards. g_mhz: exchange coupling. See :data:`G_MHZ`. + zz_mhz: always-on ZZ coupling, which shifts each qubit's frequency by this much + per excitation in the other. See :data:`ZZ_MHZ`. flux_curvature_ghz: flux amplitude to detuning. See :data:`FLUX_CURVATURE_GHZ`. conditional_phase_offset_deg: a static phase error added to the CZ's @@ -200,6 +218,7 @@ class CoupledTransmons: control: TransmonSimulator = field(default_factory=TransmonSimulator) target: TransmonSimulator = field(default_factory=TransmonSimulator) g_mhz: float = G_MHZ + zz_mhz: float = ZZ_MHZ flux_curvature_ghz: float = FLUX_CURVATURE_GHZ sideband_gap_ghz: float = SIDEBAND_GAP_GHZ conditional_phase_offset_deg: float = 0.0 @@ -287,6 +306,11 @@ def _hamiltonian(self, flux_amplitude: float = 0.0): target_alpha = 2 * np.pi * self.target.anharmonicity coupling = 2 * np.pi * self.g_mhz * 1e-3 + # Diagonal, so it is a frequency shift on each qubit proportional to the other's + # excitation rather than an exchange: it costs no population and shows up as phase. + # That is what makes a pulse calibrated alone slightly wrong in company. + zz = 2 * np.pi * self.zz_mhz * 1e-3 + return ( detuning * control_number + (control_alpha / 2) * control_number * (control_number - 1) @@ -296,6 +320,7 @@ def _hamiltonian(self, flux_amplitude: float = 0.0): control_ladder.dag() * target_ladder + control_ladder * target_ladder.dag() ) + + zz * control_number * target_number ) def parametric_rate(self, amplitude: float) -> float: diff --git a/qpi-driver/py/qpi_driver/tuners/base/__init__.py b/qpi-driver/py/qpi_driver/tuners/base/__init__.py index b780bea0..b7d0b3b4 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/__init__.py +++ b/qpi-driver/py/qpi_driver/tuners/base/__init__.py @@ -18,6 +18,7 @@ class does the rest. CalibrationConfig, ConfigError, MonitoringConfig, + ParallelConfig, RoutineConfig, ) from qpi_driver.tuners.base.dag import CalibrationDAG, ProgressSink, utc_timestamp @@ -69,6 +70,7 @@ class does the rest. "CalibrationConfig", "ConfigError", "MonitoringConfig", + "ParallelConfig", "RoutineConfig", "CalibrationDAG", "CalibrationReport", diff --git a/qpi-driver/py/qpi_driver/tuners/base/config.py b/qpi-driver/py/qpi_driver/tuners/base/config.py index 7bf2872f..3134e0ca 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/config.py +++ b/qpi-driver/py/qpi_driver/tuners/base/config.py @@ -23,6 +23,19 @@ #: an instrument would otherwise hang the worker for the life of the driver. DEFAULT_ROUTINE_TIMEOUT_S = 900.0 +#: Hops that must separate two qubits measured at once. Two excludes adjacent pairs, +#: which leaves an idle qubit between every pair in a group — conservative against what +#: the simultaneous-benchmarking literature does routinely, and the right default until +#: a chip has measured its own penalty (RFC 0009 §5.6). +DEFAULT_QUBIT_SPACING = 2 + +#: The same for couplers, where one asks only that two of them share no qubit. +DEFAULT_EDGE_SPACING = 1 + +#: The most targets in one group. A ceiling on the sequencers one schedule can ask +#: for, before the per-output checks in `grouping.readout_misfit` narrow it further. +DEFAULT_MAX_GROUP = 8 + class ConfigError(ValueError): """``calibration.yml`` is not usable as written.""" @@ -71,6 +84,72 @@ class MonitoringConfig: allxy_as_smoke_test: bool = True +@dataclass +class ParallelConfig: + """Which targets a walk may measure at once (RFC 0009 §5.3). + + Off unless the file says otherwise, which is the opposite of `RoutineConfig`'s + default and deliberately so: a missing `routines` entry cannot make a run measure + nothing, whereas defaulting this to on would silently change how every existing + chip calibrates. + """ + + enabled: bool = False + qubit_spacing: int = DEFAULT_QUBIT_SPACING + edge_spacing: int = DEFAULT_EDGE_SPACING + max_group: int = DEFAULT_MAX_GROUP + #: Pairs never grouped, whatever the spacing allows. + exclude: list[list[str]] = field(default_factory=list) + #: Explicit classes per kind, which skip the colouring entirely. For a chip whose + #: measured crosstalk does not follow its topology. + groups: dict[str, list[list[str]]] = field(default_factory=dict) + #: Benchmark each target alone as well as in company, and report the difference + #: (RFC 0009 §5.6). Off by default because it doubles what the benchmarks cost: it is + #: the measurement that licenses a tighter `qubit_spacing`, not something every run + #: needs. Without it the spacing is a guess, which is why the default spacing is the + #: conservative one. + measure_penalty: bool = False + + def spacing_for(self, kind: str) -> int: + """The radius that applies to *kind* — ``"qubits"`` or ``"edges"``.""" + return self.edge_spacing if kind == "edges" else self.qubit_spacing + + @classmethod + def from_dict(cls, data: Any) -> "ParallelConfig": + if not isinstance(data, dict): + raise ConfigError(f"'parallel' must be a mapping, got {type(data)}") + + # Checked before any falsy coalescing, or `groups: []` reads as "none given" + # rather than as the mistake it is. + exclude = data.get("exclude") + if exclude is not None and not isinstance(exclude, (list, tuple)): + raise ConfigError("parallel.exclude must be a list of target pairs") + for pair in exclude or []: + if not isinstance(pair, (list, tuple)) or len(pair) != 2: + raise ConfigError( + f"parallel.exclude takes pairs of target names, got {pair!r}" + ) + groups = data.get("groups") + if groups is not None and not isinstance(groups, dict): + raise ConfigError("parallel.groups must be a mapping of kind to groups") + groups = groups or {} + unknown = sorted(set(groups) - {"qubits", "edges"}) + if unknown: + raise ConfigError( + f"parallel.groups knows 'qubits' and 'edges', not {', '.join(unknown)}" + ) + + return cls( + enabled=bool(data.get("enabled", False)), + qubit_spacing=_positive(data, "qubit_spacing", DEFAULT_QUBIT_SPACING), + edge_spacing=_positive(data, "edge_spacing", DEFAULT_EDGE_SPACING), + max_group=_positive(data, "max_group", DEFAULT_MAX_GROUP), + exclude=[list(pair) for pair in exclude or []], + groups={kind: [list(g) for g in gs] for kind, gs in groups.items()}, + measure_penalty=bool(data.get("measure_penalty", False)), + ) + + @dataclass class CalibrationConfig: """The whole of ``calibration.yml``.""" @@ -80,6 +159,7 @@ class CalibrationConfig: routines: dict[str, RoutineConfig] = field(default_factory=dict) monitoring: MonitoringConfig = field(default_factory=MonitoringConfig) routine_timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S + parallel: ParallelConfig = field(default_factory=ParallelConfig) def is_enabled(self, routine_name: str) -> bool: """Whether *routine_name* should run. Absent means yes — see the module docstring.""" @@ -200,6 +280,7 @@ def from_dict(cls, data: dict[str, Any]) -> "CalibrationConfig": routine_timeout_s=float( data.get("routine_timeout_s", DEFAULT_ROUTINE_TIMEOUT_S) ), + parallel=ParallelConfig.from_dict(data.get("parallel") or {}), ) @classmethod @@ -211,6 +292,21 @@ def from_yaml(cls, path: Path) -> "CalibrationConfig": return cls.from_dict(data) +def _positive(data: dict[str, Any], key: str, default: int) -> int: + """*key* as a whole number of at least one, so a typo is a startup error.""" + if key not in data: + return default + try: + value = int(data[key]) + except (TypeError, ValueError): + raise ConfigError( + f"parallel.{key} must be a whole number, got {data[key]!r}" + ) from None + if value < 1: + raise ConfigError(f"parallel.{key} must be at least 1, got {value}") + return value + + def _routine_timeout(name: str, routine_data: dict[str, Any]) -> float | None: """A routine's own ``timeout_s``, validated, or ``None`` to inherit the walk's.""" if "timeout_s" not in routine_data: diff --git a/qpi-driver/py/qpi_driver/tuners/base/dag.py b/qpi-driver/py/qpi_driver/tuners/base/dag.py index 4315fd4e..9ea570ac 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/dag.py +++ b/qpi-driver/py/qpi_driver/tuners/base/dag.py @@ -7,15 +7,30 @@ import logging import time from collections import defaultdict, deque -from collections.abc import Callable +from collections.abc import Callable, Sequence from datetime import datetime, timezone from typing import Any from qpi_driver.tuners.base.backend import SchedulerBackend from qpi_driver.tuners.base.config import CalibrationConfig -from qpi_driver.tuners.base.device import component_for, has_path +from qpi_driver.tuners.base.device import ( + component_for, + edge_names, + has_path, + read_path, +) +from qpi_driver.tuners.base.fusion import channels_of +from qpi_driver.tuners.base.grouping import ( + by_output, + couplings_of, + groups_of, + outputs_of, + readout_misfit, + split_to_fit, +) from qpi_driver.tuners.base.provenance import ProvenanceStore from qpi_driver.tuners.base.report import CalibrationReport, RoutineResult +from qpi_driver.tuners.base.sweep import Sweep from qpi_driver.tuners.base.routines import ( CalibrationRoutine, CheckOutcome, @@ -24,13 +39,19 @@ log = logging.getLogger(__name__) -#: Called once per routine-and-target as the walk proceeds, with the keys the -#: ``CalibrationProgress`` event carries — and once before any of them with a -#: ``plan`` key instead, which is what tells the two apart (RFC 0006 §5.1). +#: Called as the walk proceeds, with the keys the ``CalibrationProgress`` event +#: carries. Three shapes, told apart by which key is present: ``plan`` once before +#: anything runs (RFC 0006 §5.1), ``running`` when targets are about to be measured, +#: and ``target`` when one has finished (RFC 0009 §7.1). #: Reporting is best-effort — see :meth:`CalibrationDAG.run` — so a sink may #: raise without ending a calibration. ProgressSink = Callable[[dict[str, Any]], None] +#: Sequencers on one Qblox RF module, which bounds how many clocks an output can carry +#: at once. A constant rather than a config walk: every RF module in the family has six, +#: and `parallel.max_group` is the knob for a chip that disagrees. +SEQUENCERS_PER_MODULE = 6 + def _worse_than(candidate: CheckOutcome, incumbent: CheckOutcome) -> bool: """Whether *candidate* is the more alarming of two check outcomes. @@ -155,13 +176,16 @@ def check( worst: CheckOutcome | None = None for target in targets: try: + check = Sweep(target) schedule = routine.build_check_schedule( - target, device, routine_config, backend + target, device, routine_config, backend, check ) if schedule is None: continue dataset = backend.run(schedule, timeout_s=config.timeout_for(name)) - outcome = routine.analyse_check(dataset, target, device, routine_config) + outcome = routine.analyse_check( + dataset, target, device, routine_config, check + ) except Exception: # noqa: BLE001 - an unevaluable check is not drift log.warning( "check for %s on %s could not be evaluated; treating as unknown", @@ -280,6 +304,7 @@ def plan( "is_benchmark": self.routines[name].is_benchmark, "has_check": self.routines[name].has_check, "updates": list(self.routines[name].updates), + "groups": self.groups_for(name, config, device), } for name in order + [name for name in self.routines if name not in planned] @@ -350,6 +375,102 @@ def _producers_of(self, paths: set[str]) -> str: } return ", ".join(sorted(producers)) + def groups_for( + self, + name: str, + config: CalibrationConfig, + device: Any = None, + backend: SchedulerBackend | None = None, + ) -> list[list[str]]: + """Routine *name*'s targets, in the sets that may be measured at once (RFC 0009 §5). + + One target per group unless ``parallel.enabled``, which is what keeps this + additive: every caller sees the sequential shape until a config asks for + another. + + Explicit ``parallel.groups`` are filtered to the targets this run actually + walks rather than used as given — a config naming a qubit the run excludes + would otherwise put it back. + """ + targets = self._targets_for(name, config, device) + parallel = config.parallel + if not parallel.enabled or len(targets) < 2: + return [[target] for target in targets] + + kind = self.routines[name].targets + named = parallel.groups.get(kind) + if named is not None: + wanted = set(targets) + grouped = [[t for t in group if t in wanted] for group in named] + claimed = {t for group in grouped for t in group} + # Whatever the config forgot still has to be calibrated. + groups = [group for group in grouped if group] + [ + [t] for t in targets if t not in claimed + ] + else: + adjacency = couplings_of(edge_names(device) or config.target_edges) + spacing = parallel.spacing_for(kind) + if len(targets) > 1 and spacing > 1 and not adjacency: + # No adjacency is not the same as nothing being adjacent. A device whose + # edges cannot be read, or a config that targets none, would otherwise + # put every qubit at infinite distance and so in one group — the most + # aggressive setting available, arrived at by accident. + log.warning( + "%s: no coupling graph is readable, so a spacing of %d cannot be " + "honoured over %d targets — running them one at a time. Configure " + "'target_edges', name the groups under 'parallel.groups', or set a " + "spacing of 1 to measure the whole chip at once", + name, + spacing, + len(targets), + ) + return [[target] for target in targets] + groups = groups_of( + targets, + adjacency=adjacency, + spacing=spacing, + exclude=parallel.exclude, + max_group=parallel.max_group, + ) + return self._fitted(groups, device, backend) + + def _fitted( + self, groups: list[list[str]], device: Any, backend: SchedulerBackend | None + ) -> list[list[str]]: + """*groups* narrowed to what the instruments can actually play at once (§5.4). + + Bisected rather than refused, and only when both a device and a backend are to + hand — a bare `plan` call has neither and gets the colouring as it stands. + """ + if device is None or backend is None: + return groups + wiring = outputs_of(device) + band_hz = float(getattr(backend, "if_limit_hz", 0.0) or 0.0) + if not band_hz: + return groups + + def misfit(group: Sequence[str]) -> str | None: + # Per output, because targets behind different ones share no LO and no DAC + # and constrain each other not at all. + for shared in by_output(list(group), wiring, "res").values(): + readouts = [_readout_of(device, target) for target in shared] + usable = [pair for pair in readouts if pair is not None] + if len(usable) < 2: + continue + reason = readout_misfit( + [clock for clock, _ in usable], + [amplitude for _, amplitude in usable], + band_hz=band_hz, + sequencers=SEQUENCERS_PER_MODULE, + ) + if reason is not None: + return reason + return None + + return [ + narrowed for group in groups for narrowed in split_to_fit(group, misfit) + ] + def _targets_for( self, name: str, config: CalibrationConfig, device: Any = None ) -> list[str]: @@ -448,10 +569,25 @@ def run( log.info("%s skipped: no %s it applies to", label, routine.targets) continue - log.info("%s running on %s", label, ", ".join(targets)) - for target in targets: - blocked = ledger.blockers(routine, target) - if blocked: + groups = self.groups_for(routine_name, config, device, backend) + if len(groups) < len(targets): + log.info( + "%s running on %s in %d group(s): %s", + label, + ", ".join(targets), + len(groups), + " | ".join(" ".join(group) for group in groups), + ) + else: + log.info("%s running on %s", label, ", ".join(targets)) + head = {"step": position, "total": len(order), "routine": routine_name} + for group in groups: + runnable: list[str] = [] + for target in group: + blocked = ledger.blockers(routine, target) + if not blocked: + runnable.append(target) + continue # Not run and not failed: it has nothing to measure against, so # running it would report a confident number off an uncalibrated # chip, and failing it would invent an error that never happened. @@ -469,19 +605,36 @@ def run( report.notes.append(f"{routine_name}[{target}]: {left}") ledger.unsatisfied(routine, target, blame=ledger.blame(blocked)) skipped += 1 + # Without this the node reports nothing at all, and one whose every + # target is blocked stays `pending` for the rest of the run. + _report_progress( + on_progress, + {**head, **_tally(report, skipped, started), "target": target}, + ) + + if not runnable: continue + # Before the work, so the graph can colour the node it is on rather than + # the node it has just left, and name every target in flight. RFC 0009 §7. + _report_progress( + on_progress, + {**head, **_tally(report, skipped, started), "running": runnable}, + ) ran_any = True - target_started = time.monotonic() + group_started = time.monotonic() # Before the run, not after: the ledger records what this routine # produced, and a routine that refines its own input would otherwise # look as though it had been given a measured one. - priors = ledger.priors( - routine, target, component_for(device, target, routine.targets) - ) - succeeded = self._run_one( + priors = { + target: ledger.priors( + routine, target, component_for(device, target, routine.targets) + ) + for target in runnable + } + outcomes = self._run_group( routine, - target, + runnable, device, backend, routine_config, @@ -489,29 +642,23 @@ def run( report, priors, ) - if succeeded: - ledger.produced(routine, target) - else: - ledger.unsatisfied(routine, target) + for target in runnable: + if outcomes.get(target): + ledger.produced(routine, target) + else: + ledger.unsatisfied(routine, target) log.info( "%s %s %s in %s", label, - target, - "ok" if succeeded else "FAILED", - _human_duration(time.monotonic() - target_started), - ) - _report_progress( - on_progress, - { - "step": position, - "total": len(order), - "routine": routine_name, - "target": target, - "succeeded": len(report.routine_results), - "failed": len(report.errors), - "elapsed_s": round(time.monotonic() - started, 1), - }, + ", ".join(runnable), + "ok" if all(outcomes.get(t) for t in runnable) else "FAILED", + _human_duration(time.monotonic() - group_started), ) + for target in runnable: + _report_progress( + on_progress, + {**head, **_tally(report, skipped, started), "target": target}, + ) if not ran_any: report.status = "failed" @@ -535,6 +682,423 @@ def run( ) return report + def _run_group( + self, + routine: CalibrationRoutine, + targets: list[str], + device: Any, + backend: SchedulerBackend, + routine_config: Any, + config: CalibrationConfig, + report: CalibrationReport, + priors: dict[str, tuple[str, ...]], + ) -> dict[str, bool]: + """Run *routine* over *targets*, fused into one acquisition where it can be. + + A group of one, or a routine that has not opted into fusion, goes through + `_run_one` exactly as it did before any of this existed — which is what keeps + every unconverted routine's behaviour identical. + """ + # Split before running: a routine whose grid is derived per target cannot hold + # two of them in one schedule (RFC 0009 D7). + if len(targets) > 1: + compatible = routine.compatible_groups(targets, device, routine_config) + if len(compatible) > 1: + split: dict[str, bool] = {} + for subgroup in compatible: + split.update( + self._run_group( + routine, + subgroup, + device, + backend, + routine_config, + config, + report, + priors, + ) + ) + return split + # Before any grouped path, not only the fused one: a routine that splits its sweep + # in `acquire` and has no `acquire_group` must not be grouped at all, and checking + # this after `measures_group` would let one with a group loop slip past into + # `_fused_pass`, which reaches `acquire_group` and so the unchunked default. + if len(targets) > 1 and routine.chunks_acquisition: + return { + target: self._run_one( + routine, + target, + device, + backend, + routine_config, + config, + report, + priors.get(target, ()), + ) + for target in targets + } + + # The acceptance measurement: benchmark in company, then alone, and report the + # difference. Before the group runs, because the isolated pass is the control and + # a group that fails should not leave a penalty computed from half a comparison. + if ( + len(targets) > 1 + and routine.is_benchmark + and config.parallel.measure_penalty + ): + return self._run_with_penalty( + routine, + targets, + device, + backend, + routine_config, + config, + report, + priors, + ) + if len(targets) > 1 and routine.measures_group: + return self._run_measured_group( + routine, + targets, + device, + backend, + routine_config, + config, + report, + priors, + ) + # A routine with its own measurement loop, or its own chunking, must not have it + # bypassed merely because its schedule can be fused: `measure` is where escalation, + # refinement and any between-pass write-back live, and `acquire` is where a sweep + # too large for one program is split. The fused path runs neither, so such a routine + # groups only once it has the matching group seam (RFC 0009 §6.5). + own_loop_only = routine.measures_itself and not routine.measures_group + if len(targets) == 1 or not routine.fusable or own_loop_only: + return { + target: self._run_one( + routine, + target, + device, + backend, + routine_config, + config, + report, + priors.get(target, ()), + ) + for target in targets + } + return self._run_fused( + routine, targets, device, backend, routine_config, config, report, priors + ) + + def _run_with_penalty( + self, + routine: CalibrationRoutine, + targets: list[str], + device: Any, + backend: SchedulerBackend, + routine_config: Any, + config: CalibrationConfig, + report: CalibrationReport, + priors: dict[str, tuple[str, ...]], + ) -> dict[str, bool]: + """Benchmark *targets* together and then alone, recording what company cost them. + + The measurement RFC 0009 §5.6 turns `qubit_spacing` from a guess into a setting, + and it is Gambetta et al.'s: benchmark each qubit alone, benchmark them + simultaneously, and the difference in average gate fidelity *is* the + addressability. Both halves are runs this walk can already do, so the whole of it + is arithmetic on two reports. + + The isolated pass runs second and its results are dropped, because the fused + numbers are the ones that describe how the chip will actually be driven. What is + kept is the difference, on the benchmark the group produced. + + Costs twice what benchmarking once does, which is why it is opt-in. + """ + outcomes = self._run_group_without_penalty( + routine, targets, device, backend, routine_config, config, report, priors + ) + together = { + benchmark.target: benchmark + for benchmark in report.benchmarks + if benchmark.protocol == routine.name + } + + # A throwaway report, so the control pass cannot add rows of its own: the graph is + # the fused one and the isolated numbers are a reference, not a result. + control = CalibrationReport( + timestamp=utc_timestamp(), + duration_s=0.0, + mode=report.mode, + backend=backend.name, + ) + for target in targets: + self._run_one( + routine, + target, + device, + backend, + routine_config, + config, + control, + priors.get(target, ()), + ) + alone = { + benchmark.target: benchmark.fidelity for benchmark in control.benchmarks + } + + for target, benchmark in together.items(): + isolated = alone.get(target) + if isolated is None or benchmark.fidelity is None: + continue + benchmark.parallel_penalty = isolated - benchmark.fidelity + log.info( + "%s on %s: %.5f alone against %.5f in company — a penalty of %.5f", + routine.name, + target, + isolated, + benchmark.fidelity, + benchmark.parallel_penalty, + ) + return outcomes + + def _run_group_without_penalty( + self, + routine: CalibrationRoutine, + targets: list[str], + device: Any, + backend: SchedulerBackend, + routine_config: Any, + config: CalibrationConfig, + report: CalibrationReport, + priors: dict[str, tuple[str, ...]], + ) -> dict[str, bool]: + """The grouped run itself, without the control pass — see `_run_with_penalty`.""" + if routine.measures_group: + return self._run_measured_group( + routine, + targets, + device, + backend, + routine_config, + config, + report, + priors, + ) + return self._run_fused( + routine, targets, device, backend, routine_config, config, report, priors + ) + + def _run_measured_group( + self, + routine: CalibrationRoutine, + targets: list[str], + device: Any, + backend: SchedulerBackend, + routine_config: Any, + config: CalibrationConfig, + report: CalibrationReport, + priors: dict[str, tuple[str, ...]], + ) -> dict[str, bool]: + """A group whose routine runs its own loop — escalation included (RFC 0009 §6.5). + + The loop is the routine's, so unlike `_run_fused` this cannot slice one dataset: + a widening splits the group and the pieces are measured separately. What comes back + is one outcome per target, fitted parameters or the refusal that ended it. + """ + started = time.monotonic() + backend.start_accounting() + allowance = config.timeout_for(routine.name) + sweeps = {target: Sweep(target) for target in targets} + try: + measured = routine.measure_group( + targets, + device, + routine_config, + backend, + sweeps, + self.bias, + timeout_s=allowance, + ) + except Exception as exc: # noqa: BLE001 - the loop itself failed, so all of them did + log.exception( + "routine %s failed on the group %s", routine.name, ", ".join(targets) + ) + for target in targets: + report.errors.append(f"{routine.name}[{target}]: {exc}") + _record_refused_fit(report, routine, target, exc, started) + return {target: False for target in targets} + + # The whole loop under one ceiling, as the sequential path judges `measure`. + allowed = max(allowance, backend.total_allowance_s) + elapsed = time.monotonic() - started + over = ( + _over_budget(elapsed, allowed, allowance, routine.name) + if elapsed > allowed + else None + ) + + outcomes: dict[str, bool] = {} + for target in targets: + result = over or measured.get( + target, RoutineError(f"{routine.name} reported nothing for {target}") + ) + if isinstance(result, Exception): + log.error("routine %s on %s: %s", routine.name, target, result) + report.errors.append(f"{routine.name}[{target}]: {result}") + _record_refused_fit(report, routine, target, result, started) + outcomes[target] = False + continue + outcomes[target] = self._record_measured( + routine, target, device, result, report, started, priors.get(target, ()) + ) + return outcomes + + def _record_measured( + self, + routine: CalibrationRoutine, + target: str, + device: Any, + params: dict[str, Any], + report: CalibrationReport, + started: float, + priors: tuple[str, ...], + ) -> bool: + """Write back and record what a routine's own loop measured for one target.""" + try: + fit = params.pop("fit", None) + routine.apply(device, target, params) + if routine.is_benchmark: + report.add_benchmarks_from(routine.name, target, params) + report.add_routine( + RoutineResult( + routine_name=routine.name, + target=target, + parameters=params, + timestamp=utc_timestamp(), + duration_s=time.monotonic() - started, + fit=fit, + priors=priors, + ) + ) + return True + except Exception as exc: # noqa: BLE001 - a write-back failure is this target's + log.exception("routine %s could not apply on %s", routine.name, target) + report.errors.append(f"{routine.name}[{target}]: {exc}") + _record_refused_fit(report, routine, target, exc, started) + return False + + def _run_fused( + self, + routine: CalibrationRoutine, + targets: list[str], + device: Any, + backend: SchedulerBackend, + routine_config: Any, + config: CalibrationConfig, + report: CalibrationReport, + priors: dict[str, tuple[str, ...]], + ) -> dict[str, bool]: + """One schedule over every target, then one fit per target (RFC 0009 §6.2). + + The acquisition is shared and the analysis is not: each target gets its own + channel of the dataset and its own `analyse`, so a refused fit is that target's + failure and the rest of the group still lands. + """ + outcomes = {target: False for target in targets} + started = time.monotonic() + backend.start_accounting() + allowance = config.timeout_for(routine.name) + # One per target, and the same object the fit reads back: a sweep centred on a + # per-qubit value has a different grid per target, and a routine keeping them on + # itself would leave every fit in the group reading whichever built last. + sweeps = {target: Sweep(target) for target in targets} + try: + schedule = routine.build_group_schedule( + targets, device, routine_config, backend, sweeps + ) + dataset = backend.run(schedule, timeout_s=allowance) + elapsed = time.monotonic() - started + # One arm-and-wait cycle for the whole group, so the ceiling bounds the + # group — the sequencers played concurrently and the pulses were one + # target's. + allowed = max(allowance, backend.total_allowance_s) + if elapsed > allowed: + raise _over_budget(elapsed, allowed, allowance, routine.name) + except Exception as exc: + # The acquisition is shared, so its failure is every target's. Recorded once + # each, because a routine-and-target is what the report accounts for. + log.exception( + "routine %s failed on the group %s", routine.name, ", ".join(targets) + ) + for target in targets: + report.errors.append(f"{routine.name}[{target}]: {exc}") + _record_refused_fit(report, routine, target, exc, started) + return outcomes + + sliced = channels_of(dataset, targets) + for target in targets: + acquisition = sliced.get(target) + if acquisition is None: + message = "the fused acquisition carried no channel for it" + log.error("routine %s on %s: %s", routine.name, target, message) + report.errors.append(f"{routine.name}[{target}]: {message}") + continue + outcomes[target] = self._fit_one( + routine, + target, + acquisition, + device, + routine_config, + report, + started, + sweeps[target], + priors.get(target, ()), + ) + return outcomes + + def _fit_one( + self, + routine: CalibrationRoutine, + target: str, + dataset: Any, + device: Any, + routine_config: Any, + report: CalibrationReport, + started: float, + sweep: Sweep, + priors: tuple[str, ...] = (), + ) -> bool: + """Analyse one target's acquisition and record what it produced. + + The tail of `_run_one` from the fit onwards, shared so a fused target is + recorded exactly as a sequential one is. + """ + try: + params = routine.analyse(dataset, target, device, routine_config, sweep) + fit = params.pop("fit", None) + routine.apply(device, target, params) + if routine.is_benchmark: + report.add_benchmarks_from(routine.name, target, params) + report.add_routine( + RoutineResult( + routine_name=routine.name, + target=target, + parameters=params, + timestamp=utc_timestamp(), + duration_s=time.monotonic() - started, + fit=fit, + priors=priors, + ) + ) + return True + except Exception as exc: + log.exception("routine %s failed on %s", routine.name, target) + report.errors.append(f"{routine.name}[{target}]: {exc}") + _record_refused_fit(report, routine, target, exc, started) + return False + def _run_one( self, routine: CalibrationRoutine, @@ -550,6 +1114,7 @@ def _run_one( started = time.monotonic() backend.start_accounting() allowance = config.timeout_for(routine.name) + sweep = Sweep(target) try: if routine.measures_itself: # A routine whose acquisitions cannot be one schedule — DC state set @@ -561,6 +1126,7 @@ def _run_one( device, routine_config, backend, + sweep, self.bias, timeout_s=allowance, ) @@ -598,7 +1164,7 @@ def _run_one( # Through `acquire`, so a routine whose sweep needs more than one schedule # chunks it there rather than here — see `CalibrationRoutine.acquire`. dataset = routine.acquire( - target, device, routine_config, backend, allowance + target, device, routine_config, backend, allowance, sweep ) elapsed = time.monotonic() - started # Against what the backend was prepared to wait for, not against the @@ -611,7 +1177,7 @@ def _run_one( if elapsed > allowed: raise _over_budget(elapsed, allowed, allowance, routine.name) - params = routine.analyse(dataset, target, device, routine_config) + params = routine.analyse(dataset, target, device, routine_config, sweep) # Lifted out before `apply` and before the benchmark's `raw_data` is # built from what is left: the sweep behind the fit is a field of its # own on the result, not a parameter and not something to write to a @@ -696,6 +1262,39 @@ def utc_timestamp() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" +def _readout_of(device: Any, target: str) -> tuple[float, float] | None: + """*target*'s readout frequency and pulse amplitude, or ``None`` if either is absent. + + Absent leaves the group as it was: an element with nowhere to keep a readout is not + an element whose readout collides with anyone's. + """ + element = component_for(device, target) + if element is None: + return None + try: + return ( + float(read_path(element, "clock_freqs.readout")), + float(read_path(element, "measure.pulse_amp")), + ) + except Exception: # noqa: BLE001 - an unreadable readout imposes no constraint + return None + + +def _tally(report: CalibrationReport, skipped: int, started: float) -> dict[str, Any]: + """The walk's running totals, which every progress event carries. + + On the start event too, not only the finish: the server reads whether a target + failed from the difference against the last event's ``failed``, so an event that + reported zero would make the next one look like a failure. + """ + return { + "succeeded": len(report.routine_results), + "failed": len(report.errors), + "skipped": skipped, + "elapsed_s": round(time.monotonic() - started, 1), + } + + def _report_progress(sink: ProgressSink | None, update: dict[str, Any]) -> None: """Hand *update* to *sink*, if there is one, without letting it stop the walk.""" if sink is None: diff --git a/qpi-driver/py/qpi_driver/tuners/base/fusion.py b/qpi-driver/py/qpi_driver/tuners/base/fusion.py new file mode 100644 index 00000000..ee46a517 --- /dev/null +++ b/qpi-driver/py/qpi_driver/tuners/base/fusion.py @@ -0,0 +1,170 @@ +"""One schedule over several targets, and the dataset it returns (RFC 0009 §6). + +A Qblox cluster is one arm-and-start resource, so parallelism is not several +schedules submitted at once — the scheduler's own ``start`` disarms every sequencer +in the cluster before arming its own, and two submissions would have no shared time +origin anyway. Simultaneity has to be expressed *inside* one schedule, and that is +what :func:`add_together` is for. + +The dataset comes back with one variable per acquisition channel, and +:func:`channel_of` slices it so each routine's `analyse` sees the single-variable +shape it already reads. No fit learns that it ran in company. +""" + +import logging +from collections.abc import Callable, Iterable, Sequence +from typing import Any + +log = logging.getLogger(__name__) + + +def add_together(schedule: Any, operations: Iterable[Any]) -> Any: + """Add every operation to *schedule* at one start time, and return the anchor. + + ``schedule.add`` appends, so a group's resets and pulses would otherwise play one + after another and a fused schedule would be exactly as long as the sequential run + it is meant to replace. + + The anchor is the longest of them, so a following stage referencing its end cannot + begin before every operation in this one has finished — which matters when a + group's targets are configured with different pulse or readout durations. + """ + placed: list[tuple[Any, Any]] = [] + anchor = None + for operation in operations: + if anchor is None: + anchor = schedule.add(operation) + placed.append((operation, anchor)) + continue + placed.append( + ( + operation, + schedule.add( + operation, ref_op=anchor, ref_pt="start", ref_pt_new="start" + ), + ) + ) + if not placed: + return None + return max(placed, key=lambda pair: _duration_of(pair[0]))[1] + + +def add_after(schedule: Any, operations: Iterable[Any], anchor: Any) -> Any: + """The same, starting where *anchor* ends rather than where the schedule does.""" + operations = list(operations) + if not operations: + return anchor + first = schedule.add(operations[0], ref_op=anchor, ref_pt="end", ref_pt_new="start") + placed = [(operations[0], first)] + for operation in operations[1:]: + placed.append( + ( + operation, + schedule.add( + operation, ref_op=first, ref_pt="start", ref_pt_new="start" + ), + ) + ) + return max(placed, key=lambda pair: _duration_of(pair[0]))[1] + + +def channel_of(dataset: Any, channel: int) -> Any: + """Acquisition *channel* alone, in the shape `signal_of` reads. + + Data variables are keyed by the integer channel, so this is a selection and not a + reconstruction. Falls back to the whole dataset when it carries no such channel and + only one variable, which is the unfused shape: a group of one must go down exactly + the path it did before fusion existed. + """ + variables = list(getattr(dataset, "data_vars", {}) or {}) + if channel in variables: + return dataset[[channel]] + if len(variables) == 1 and channel == 0: + return dataset + raise KeyError( + f"the acquisition has no channel {channel}; it carries {variables or 'nothing'}" + ) + + +def readouts(backend: Any, targets: Iterable[str], index: int) -> list[Any]: + """One averaged measurement per target at acquisition *index*, each on its own channel. + + The channel is the target's position in the group (RFC 0009 D5), which is what + :func:`channels_of` slices the result back apart by. + """ + return [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, target in enumerate(targets) + ] + + +def grouped_by_grid( + targets: Iterable[str], grid_of: Callable[[str], Iterable[float]] +) -> list[list[str]]: + """*targets* partitioned by the grid each one resolves (RFC 0009 D7). + + What `CalibrationRoutine.compatible_groups` is usually built from: a routine whose + sweep is derived per target hands the grid function in and gets back the subgroups + that agree. Partitioned rather than isolating the odd one out, so three qubits that + agree are still measured together when a fourth does not. + + Order is the targets' own, so the same config always produces the same subgroups. + """ + by_grid: dict[tuple[float, ...], list[str]] = {} + for target in targets: + by_grid.setdefault(tuple(grid_of(target)), []).append(target) + return list(by_grid.values()) + + +def grouped_by_size( + targets: Iterable[str], grid_of: Callable[[str], Iterable[float]] +) -> list[list[str]]: + """*targets* partitioned by how many setpoints each resolves, not by which. + + The looser counterpart of :func:`grouped_by_grid`, and which of the two applies depends + on whether the axis is shared hardware or per-target hardware. + + A *time* axis is shared: there is one timeline in a schedule, so an idle of 5 us is 5 us + for every target and two targets wanting different delays cannot be fused at all. That + is `grouped_by_grid`. + + A frequency, amplitude or phase axis is not: each target has its own NCO, its own port + and its own clock, so at setpoint *i* every target can be at a different value of its + own. All that has to agree is how many setpoints there are, since the acquisition index + is shared. That is this — and it is what lets a spectroscopy sweep centred on each + target's own line still fuse, which is most of the value of fusing one at all. + """ + by_size: dict[int, list[str]] = {} + for target in targets: + by_size.setdefault(len(list(grid_of(target))), []).append(target) + return list(by_size.values()) + + +def channels_of(dataset: Any, targets: Sequence[str]) -> dict[str, Any]: + """Each target's own slice of a fused acquisition, by position in the group. + + A target whose channel is missing is left out rather than given someone else's + data — the walk records that as its own failure and the rest of the group stands. + """ + sliced: dict[str, Any] = {} + for channel, target in enumerate(targets): + try: + sliced[target] = channel_of(dataset, channel) + except KeyError as absent: + log.warning("%s has no acquisition in this group: %s", target, absent) + return sliced + + +def _duration_of(operation: Any) -> float: + duration = getattr(operation, "duration", None) + if duration is None: + duration = getattr(operation, "kwargs", {}).get("duration") + try: + return float(duration) + except (TypeError, ValueError): + return 0.0 diff --git a/qpi-driver/py/qpi_driver/tuners/base/grouping.py b/qpi-driver/py/qpi_driver/tuners/base/grouping.py new file mode 100644 index 00000000..a8b17e8c --- /dev/null +++ b/qpi-driver/py/qpi_driver/tuners/base/grouping.py @@ -0,0 +1,218 @@ +"""Which targets may be calibrated at once (RFC 0009 §5). + +Conflict is proximity in the coupling graph, and the radius is a parameter — the +literature settles the shape and leaves the number to measurement (RFC 0009 §5.1). +Chip geometry does not come into it: two qubits close together with no coupler +between them are farther apart, for this purpose, than two adjacent ones. + +Everything here is a function of a config and a few numbers. The device is read in +one place, :func:`outputs_of`, and only to find out which targets share an +instrument output. +""" + +import logging +from collections import defaultdict +from collections.abc import Callable, Iterable, Mapping, Sequence +from typing import Any + +log = logging.getLogger(__name__) + +#: Qubit names in an edge name, as ``calibration.yml`` writes them. +_EDGE_PARTS = 2 + + +def endpoints_of(target: str) -> tuple[str, ...]: + """The qubits *target* occupies — itself for a qubit, both ends for an edge. + + Unifying the two is what lets one distance rule serve both: a CZ conflicts with + whatever either of its qubits conflicts with. + """ + parts = target.split("_") + if len(parts) == _EDGE_PARTS and all(parts): + return tuple(parts) + return (target,) + + +def couplings_of(edges: Iterable[str]) -> dict[str, set[str]]: + """Each qubit's neighbours, from ``_`` edge names.""" + adjacency: dict[str, set[str]] = defaultdict(set) + for edge in edges: + endpoints = endpoints_of(edge) + if len(endpoints) != _EDGE_PARTS: + continue + parent, child = endpoints + adjacency[parent].add(child) + adjacency[child].add(parent) + return dict(adjacency) + + +def is_too_close( + a: str, b: str, adjacency: Mapping[str, set[str]], spacing: int +) -> bool: + """Whether *a* and *b* are nearer than *spacing* hops apart. + + The distance between two edges is the shortest between their endpoint sets, and + zero when they share a qubit — so ``spacing=1`` asks only that two couplers be + disjoint, and ``spacing=2`` puts a qubit between them. + + Breadth-first and bounded by *spacing*, because the question is never how far + apart two targets are, only whether they are far enough. + """ + if spacing <= 0: + return False + ends_a = set(endpoints_of(a)) + ends_b = set(endpoints_of(b)) + frontier, seen = ends_a, set(ends_a) + for _ in range(spacing): + if frontier & ends_b: + return True + frontier = { + neighbour + for qubit in frontier + for neighbour in adjacency.get(qubit, ()) + if neighbour not in seen + } + seen |= frontier + return False + + +def groups_of( + targets: Sequence[str], + *, + adjacency: Mapping[str, set[str]], + spacing: int, + exclude: Iterable[Sequence[str]] = (), + max_group: int, +) -> list[list[str]]: + """*targets* split into sets that can be measured at once. + + Greedy colouring of the conflict graph, in the order *targets* are given, so the + same config always produces the same groups and a run is reproducible. Greedy + reaches the optimum on the topologies anyone builds: two classes on any bipartite + lattice at the default spacing, and Vizing's bound for couplers. + """ + banned = {frozenset(pair) for pair in exclude if len(pair) == _EDGE_PARTS} + classes: list[list[str]] = [] + for target in targets: + for members in classes: + if len(members) < max_group and not any( + _conflicts(target, member, adjacency, spacing, banned) + for member in members + ): + members.append(target) + break + else: + classes.append([target]) + return classes + + +def readout_misfit( + clocks: Sequence[float], + amplitudes: Sequence[float], + *, + band_hz: float, + sequencers: int = 0, +) -> str | None: + """Why these readouts cannot share one output, or ``None`` if they can (§5.4). + + Three ceilings, all arithmetic on the configs. The message names the figure and + the ceiling rather than saying the group is too wide, because only the first of + those is something an operator can act on. + """ + if len(clocks) > 1: + centre = (min(clocks) + max(clocks)) / 2 + worst = max(abs(clock - centre) for clock in clocks) + if worst > band_hz: + return ( + f"readout clocks span {(max(clocks) - min(clocks)) / 1e6:.1f} MHz, so the " + f"furthest sits {worst / 1e6:.1f} MHz from the band centre against an " + f"addressable {band_hz / 1e6:.0f} MHz" + ) + + total = sum(abs(amplitude) for amplitude in amplitudes) + if total > 1.0: + return ( + f"readout amplitudes sum to {total:.3f} of full scale, so the tones would " + f"clip when added" + ) + + if sequencers and len(clocks) > sequencers: + return ( + f"{len(clocks)} readout clocks on one output needs that many sequencers, " + f"and the module has {sequencers}" + ) + return None + + +def split_to_fit( + group: Sequence[str], misfit: Callable[[Sequence[str]], str | None] +) -> list[list[str]]: + """*group* bisected until every part fits, per *misfit*. + + Bisected rather than refused: a group an operator's spacing produced is a + statement about their chip, and the answer to one the instruments cannot play is + fewer targets per schedule. A single target that still does not fit is returned + alone, which is the sequential behaviour. + """ + reason = misfit(group) + if reason is None: + return [list(group)] + if len(group) < 2: + log.warning("%s alone does not fit: %s", group[0], reason) + return [list(group)] + log.info("splitting %s: %s", ", ".join(group), reason) + half = len(group) // 2 + return split_to_fit(group[:half], misfit) + split_to_fit(group[half:], misfit) + + +def outputs_of(device: Any) -> dict[str, str]: + """Which instrument output each port is wired to, from the hardware config. + + The one thing here that reads a device, and it reads the connectivity as an + iterable of ``(a, b)`` pairs — the shape both the config file and a graph object + present. Empty when the wiring cannot be read, which leaves every target looking + like it has an output to itself: the same convention `has_flux_port` uses, and it + leaves the group exactly as the colouring produced it. + """ + try: + graph = device.hardware_config().connectivity.graph + pairs = graph.edges if hasattr(graph, "edges") else graph + wiring: dict[str, str] = {} + for first, second in pairs: + # The port is the end naming a target, and either end may be it. + port, output = (second, first) if ":" in str(second) else (first, second) + wiring[str(port)] = str(output) + return wiring + except Exception: # noqa: BLE001 - unreadable wiring imposes no constraint + log.debug("could not read the wiring; no shared-output limits", exc_info=True) + return {} + + +def by_output( + targets: Sequence[str], wiring: Mapping[str, str], port: str +) -> dict[str, list[str]]: + """*targets* grouped by the output their *port* resolves to. + + Targets whose port the wiring does not name are grouped under the port itself, so + an unreadable or partial wiring keeps them together and the checks still apply. + """ + shared: dict[str, list[str]] = defaultdict(list) + for target in targets: + shared[wiring.get(f"{target}:{port}", port)].append(target) + return dict(shared) + + +def _conflicts( + a: str, + b: str, + adjacency: Mapping[str, set[str]], + spacing: int, + banned: set[frozenset[str]], +) -> bool: + if is_too_close(a, b, adjacency, spacing): + return True + return any( + frozenset((first, second)) in banned + for first in endpoints_of(a) + for second in endpoints_of(b) + ) diff --git a/qpi-driver/py/qpi_driver/tuners/base/report.py b/qpi-driver/py/qpi_driver/tuners/base/report.py index 00c7b724..8ff9e61b 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/report.py +++ b/qpi-driver/py/qpi_driver/tuners/base/report.py @@ -117,6 +117,11 @@ class BenchmarkResult: fidelity: float | None error_per_gate: float | None raw_data: dict[str, Any] = field(default_factory=dict) + #: How much fidelity this target loses to being measured in company rather than alone + #: — the addressability of Gambetta et al., in the units the drift check thresholds on + #: (RFC 0009 §5.6). ``None`` unless the run asked for it; positive means the group cost + #: this target something, and it is what licenses a tighter `qubit_spacing`. + parallel_penalty: float | None = None def to_dict(self) -> dict[str, Any]: return { @@ -124,6 +129,7 @@ def to_dict(self) -> dict[str, Any]: "target": self.target, "fidelity": self.fidelity, "error_per_gate": self.error_per_gate, + "parallel_penalty": self.parallel_penalty, "raw_data": self.raw_data, } diff --git a/qpi-driver/py/qpi_driver/tuners/base/routines.py b/qpi-driver/py/qpi_driver/tuners/base/routines.py index b516b8db..79841391 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/routines.py +++ b/qpi-driver/py/qpi_driver/tuners/base/routines.py @@ -12,6 +12,7 @@ import logging from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any, Literal @@ -20,6 +21,8 @@ from qpi_driver.tuners.base.backend import SchedulerBackend from qpi_driver.tuners.base.config import DEFAULT_ROUTINE_TIMEOUT_S, RoutineConfig +from qpi_driver.tuners.base.fusion import channels_of +from qpi_driver.tuners.base.sweep import Sweep from qpi_driver.tuners.fitting.core import ( MIN_LINE_REACH, CarriesFit, @@ -151,13 +154,77 @@ class CalibrationRoutine(ABC): @abstractmethod def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: """Compose the schedule for this experiment over *target*.""" + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """This experiment over every target in *targets*, in one schedule (RFC 0009 §6.1). + + A fusable routine implements this and lets :meth:`build_schedule` delegate to it + with a single target; the default here goes the other way, so a routine not yet + converted keeps working and declines a group. :attr:`fusable` is how the walk + tells which it has. + + An implementation owes two things `build_schedule` does not. Every target's + operations must start together, which `fusion.add_together` does — appending + them would make the schedule as long as the sequential run it replaces. And each + target's `Measure` must name its own ``acq_channel``, its position in *targets*, + because an element's own channel defaults to zero on all of them and the walk + slices the result back apart by position. + """ + if len(targets) == 1: + return self.build_schedule( + targets[0], device, config, backend, sweeps[targets[0]] + ) + raise RoutineError( + f"{self.name} cannot measure {len(targets)} targets in one schedule" + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """*targets* split into subgroups one schedule can hold (RFC 0009 D7). + + The default is a single group, which is right for a routine whose sweep is the + same on every target — a fixed gate sequence, or a grid the config states outright. + + A routine whose grid is derived per target overrides this. `t2_echo` is the case it + exists for: its delay window is scaled from each qubit's measured T1, so two + targets can want different windows, and an idle is dead time on every port at once + — there is no per-target time axis to sweep. Fusing them anyway would sweep one + qubit over the other's window and fit the result. + """ + return [list(targets)] + + @property + def fusable(self) -> bool: + """Whether this routine overrides :meth:`build_group_schedule`.""" + return ( + type(self).build_group_schedule + is not CalibrationRoutine.build_group_schedule + ) + @abstractmethod def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: """Fit *dataset* and return the extracted parameters. @@ -166,7 +233,7 @@ def analyse( the range that produced it. """ - def uncorrected(self, device: Any, target: str) -> dict[str, Any]: + def uncorrected(self, device: Any, target: str, sweep: Sweep) -> dict[str, Any]: """This node's parameters with no correction applied — the prior, reported as such. For a refining node whose sweep could not be described by its own model. The value @@ -188,7 +255,12 @@ def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: """ def build_check_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: """A short schedule testing whether this routine's parameters still hold. @@ -208,7 +280,12 @@ def build_check_schedule( return None def analyse_check( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> CheckOutcome: """Whether the parameters still hold, from the check schedule's data. @@ -227,6 +304,7 @@ def measure( device: Any, config: Any, backend: Any, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -277,6 +355,7 @@ def acquire( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, ) -> Any: """Build this routine's schedule, run it, and return the dataset. @@ -289,7 +368,7 @@ def acquire( The default is exactly what both call sites did before this existed, so a routine that does not override it behaves identically. """ - schedule = self.build_schedule(target, device, config, backend) + schedule = self.build_schedule(target, device, config, backend, sweep) return backend.run(schedule, timeout_s=timeout_s) def acquire_in_row_chunks( @@ -299,6 +378,7 @@ def acquire_in_row_chunks( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, *, rows_axis: str, columns_axis: str = "frequencies", @@ -325,9 +405,9 @@ def acquire_in_row_chunks( there is nothing at its edges to lose. `analyse` reshapes the result exactly as it would one schedule's, because the rows arrive in the order it expects. """ - schedule = self.build_schedule(target, device, config, backend) - rows = list(getattr(self, f"_{rows_axis}", ()) or ()) - columns = len(getattr(self, f"_{columns_axis}", ()) or ()) + schedule = self.build_schedule(target, device, config, backend, sweep) + rows = list(sweep.get(rows_axis, ()) or ()) + columns = len(sweep.get(columns_axis, ()) or ()) per_schedule = max(1, MAX_SWEEP_POINTS // max(columns, 1)) if len(rows) <= per_schedule: return backend.run(schedule, timeout_s=timeout_s) @@ -356,15 +436,99 @@ def acquire_in_row_chunks( chunk = RoutineConfig( enabled=config.enabled, params={**config.params, rows_axis: list(group)} ) - piece = self.build_schedule(target, device, chunk, backend) + piece = self.build_schedule(target, device, chunk, backend, sweep) dataset = backend.run(piece, timeout_s=timeout_s) gathered.append(np.asarray(signal_of(dataset), dtype=float)) # The full grid restored, so `analyse` reshapes against what was actually swept # rather than against the last chunk. - setattr(self, f"_{rows_axis}", rows) + sweep[rows_axis] = rows return xr.Dataset({"y0": ("acq_index", np.concatenate(gathered))}) + def acquire_group_in_row_chunks( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + *, + rows_axis: str, + columns_axis: str = "frequencies", + ) -> Any: + """A group's 2-D sweep as one schedule per band of rows, stitched back together. + + The group counterpart of :meth:`acquire_in_row_chunks`, and chunked on the same + budget: the ceiling is a *sequencer's* instruction count, and a fused schedule gives + each target its own sequencer running its own copy of the sweep, so the number of + rows one program holds is the same whether it carries one target or five. + + Each chunk is demultiplexed and each target's rows concatenated, so `analyse` + reshapes against the whole grid exactly as it would one schedule's. + """ + + # Built and run here rather than through `self.acquire_group`, which is the method + # that called this one: re-dispatching would come straight back and recurse until + # the stack gave out. `acquire_in_row_chunks` has always run the unchunked case + # directly for the same reason. + def one_pass(single: RoutineConfig) -> Any: + schedule = self.build_group_schedule( + targets, device, single, backend, sweeps + ) + return backend.run(schedule, timeout_s=timeout_s) + + rows = list(sweeps[targets[0]].get(rows_axis, ()) or ()) + columns = len(sweeps[targets[0]].get(columns_axis, ()) or ()) + per_schedule = max(1, MAX_SWEEP_POINTS // max(columns, 1)) + if not rows or len(rows) <= per_schedule: + return one_pass(config) + + groups = [ + rows[start : start + per_schedule] + for start in range(0, len(rows), per_schedule) + ] + log.info( + "%s on %s: %d %s x %d %s is %d acquisitions, past the %d one schedule holds — " + "running %d schedules of at most %d rows", + self.name, + ", ".join(targets), + len(rows), + rows_axis, + columns, + columns_axis, + len(rows) * columns, + MAX_SWEEP_POINTS, + len(groups), + per_schedule, + ) + + gathered: dict[str, list[Any]] = {target: [] for target in targets} + for band in groups: + chunk = RoutineConfig( + enabled=config.enabled, params={**config.params, rows_axis: list(band)} + ) + dataset = one_pass(chunk) + sliced = channels_of(dataset, targets) + for target in targets: + piece = sliced.get(target) + if piece is None: + raise RoutineError( + f"the fused acquisition carried no channel for {target}" + ) + gathered[target].append(np.asarray(signal_of(piece), dtype=float)) + + # The full grid restored on every target, so each `analyse` reshapes against what + # was actually swept rather than against the last chunk. + for target in targets: + sweeps[target][rows_axis] = rows + return xr.Dataset( + { + channel: ("acq_index", np.concatenate(gathered[target])) + for channel, target in enumerate(targets) + } + ) + def escalating( self, target: str, @@ -372,6 +536,7 @@ def escalating( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, ) -> dict[str, Any]: """Build, run and analyse, widening the sweep if the fit says the window was wrong. @@ -393,13 +558,15 @@ def escalating( attempted: list[str] = [] for attempt in range(self.MAX_ESCALATIONS + 1): try: - dataset = self.acquire(target, device, config, backend, timeout_s) - return self.analyse(dataset, target, device, config) + dataset = self.acquire( + target, device, config, backend, timeout_s, sweep + ) + return self.analyse(dataset, target, device, config, sweep) except OutOfRange as refusal: attempted.append(f"{refusal.axis} x{refusal.factor**attempt:g}") if attempt == self.MAX_ESCALATIONS or refusal.axis in operator_set: raise - widened = _widened(self, config, refusal) + widened = _widened(self, config, refusal, sweep) if widened is config: raise config = widened @@ -417,6 +584,195 @@ def escalating( f"{self.name} exhausted its escalations on {target}: {', '.join(attempted)}" ) + def escalating_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + ) -> dict[str, dict[str, Any] | Exception]: + """`escalating` over a group: one acquisition for all, re-fused for the refused. + + The saving is that the common case — nothing refuses — is a single acquisition for + the whole group. What makes it more than a loop is what happens when one target + does refuse: widening for the group would re-sweep the satisfied targets over a + range chosen for a different qubit, and `Rabi` documents why that is not free. + So only the refused subset is widened, and only it is measured again. + + A widening therefore splits the group, since a config belongs to the targets that + asked for it and a fused schedule needs one grid (RFC 0009 D7). Subsequent + attempts fuse whatever targets are still on the same config, which on a chip where + two qubits refuse the same axis is still one acquisition rather than two. + + Bounded exactly as `escalating` is, per subset: `MAX_ESCALATIONS` attempts, an axis + the operator named is left alone, and a widening that cannot move re-raises. Each + target's outcome is its own — fitted parameters, or the exception that refused it, + so one bad qubit does not cost the group its results (RFC 0009 D8). + """ + # Captured once, before any widening puts its own setpoints into a config: asking + # afterwards would find this method's own work and read it as an instruction. + operator_set = frozenset(config.params) + results: dict[str, dict[str, Any] | Exception] = {} + pending: list[tuple[RoutineConfig, list[str]]] = [(config, list(targets))] + + for attempt in range(self.MAX_ESCALATIONS + 1): + if not pending: + break + widened_next: dict[str, tuple[RoutineConfig, list[str]]] = {} + for shared, subgroup in pending: + fitted, refused = self._fused_pass( + subgroup, device, shared, backend, timeout_s, sweeps + ) + results.update(fitted) + for target, refusal in refused.items(): + wider = ( + None + if attempt == self.MAX_ESCALATIONS + or not isinstance(refusal, OutOfRange) + or refusal.axis in operator_set + else _widened(self, shared, refusal, sweeps[target]) + ) + if wider is None or wider is shared: + results[target] = refusal + continue + log.info( + "%s on %s: %s — widening %s by %gx and trying again (%d of %d)", + self.name, + target, + refusal, + refusal.axis, + refusal.factor, + attempt + 1, + self.MAX_ESCALATIONS, + ) + # Keyed on the config's *contents*, not its identity: `_widened` + # returns a fresh object per call, so two targets refusing the same + # axis by the same factor would otherwise be measured one after the + # other despite asking for exactly the same sweep. + slot = widened_next.setdefault(_axes_key(wider), (wider, [])) + slot[1].append(target) + pending = list(widened_next.values()) + + return results + + def acquire_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Build this group's schedule, run it, and return the dataset. + + The group counterpart of :meth:`acquire`, and it exists for the same reason: a + routine whose sweep does not fit in one program chunks it across several, and the + fused path has to go through the same seam or the chunking is simply skipped. + + That is not hypothetical. `rb` chunks by default — ten circuits over the shipped + depths is 1270 Cliffords against the 1000 one schedule holds — so a fused RB that + bypassed this would build a program too long to assemble, which is the failure the + chunking exists to prevent. + """ + schedule = self.build_group_schedule(targets, device, config, backend, sweeps) + return backend.run(schedule, timeout_s=timeout_s) + + @property + def chunks_acquisition(self) -> bool: + """Whether this routine overrides :meth:`acquire` but not :meth:`acquire_group`. + + Such a routine must not be fused: its chunking lives in `acquire`, and the group + path would go straight past it. The same argument as `measures_itself` against + `measure_group`, one seam down. + """ + return ( + type(self).acquire is not CalibrationRoutine.acquire + and type(self).acquire_group is CalibrationRoutine.acquire_group + ) + + def _fused_pass( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + ) -> tuple[dict[str, dict[str, Any]], dict[str, Exception]]: + """One fused acquisition, analysed per target. Returns what fitted and what did not. + + A failure of the *acquisition* is every target's, since they shared it; a failure + of a fit is only that target's. + """ + try: + dataset = self.acquire_group( + targets, device, config, backend, timeout_s, sweeps + ) + except Exception as exc: # noqa: BLE001 - recorded against every target below + return {}, {target: exc for target in targets} + + fitted: dict[str, dict[str, Any]] = {} + refused: dict[str, Exception] = {} + sliced = channels_of(dataset, targets) + for target in targets: + acquisition = sliced.get(target) + if acquisition is None: + refused[target] = RoutineError( + "the fused acquisition carried no channel for it" + ) + continue + try: + fitted[target] = self.analyse( + acquisition, target, device, config, sweeps[target] + ) + except Exception as exc: # noqa: BLE001 - one target's refusal, not the group's + refused[target] = exc + return fitted, refused + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """This routine's own measurement loop over a group (RFC 0009 §6.5). + + The counterpart of `build_group_schedule` for a routine that overrides `measure`. + The default declines a group and delegates a group of one, so a routine that has + not opted in behaves exactly as it did. + """ + if len(targets) == 1: + target = targets[0] + try: + return { + target: self.measure( + target, + device, + config, + backend, + sweeps[target], + bias, + timeout_s=timeout_s, + ) + } + except Exception as exc: # noqa: BLE001 - the walk records it per target + return {target: exc} + raise RoutineError( + f"{self.name} cannot measure {len(targets)} targets in one loop" + ) + + @property + def measures_group(self) -> bool: + """Whether this routine overrides :meth:`measure_group`.""" + return type(self).measure_group is not CalibrationRoutine.measure_group + @property def measures_itself(self) -> bool: """Whether this routine overrides :meth:`measure`.""" @@ -617,22 +973,34 @@ def _unresolved(message: str, *, axis: str | None, direction: str) -> Exception: return OutOfRange(message, axis=axis, direction=direction, factor=2.0) +def _axes_key(config: RoutineConfig) -> str: + """A config's sweep parameters as a comparable key, for grouping equal sweeps. + + ``repr`` rather than a frozenset because the values are setpoint *lists*, which are + unhashable — and equal lists must produce equal keys, which is the whole point. + """ + return repr(sorted((axis, repr(value)) for axis, value in config.params.items())) + + def _widened( - routine: CalibrationRoutine, config: RoutineConfig, refusal: OutOfRange + routine: CalibrationRoutine, + config: RoutineConfig, + refusal: OutOfRange, + sweep: Sweep, ) -> RoutineConfig: """*config* with the axis *refusal* named stretched by its factor. The setpoints come from what the routine actually built rather than from the config, because the default case is the one that matters: a config with no ``delays`` in it is exactly the config whose sweep needs widening, and reading only the config would find - nothing to stretch. Every routine keeps its setpoints as ``_`` for `analyse` to - fit against, which is what makes this readable from outside. + nothing to stretch. A routine records its setpoints under the axis's own name on the + target's `Sweep`, which is what makes them readable from here. That name is load-bearing and was not being checked. Four routines stored their setpoints under a name of their own — `drag` as ``_betas`` against an axis of ``motzois``, and three more — so this found nothing, returned *config* unchanged, and `escalating` re-raised. The refusal named the range it had already swept, which reads - exactly like a chip that has no answer in it: on the August 2026 B chip `drag` failed + exactly like a chip that has no answer in it: in an August 2026 bring-up `drag` failed with an optimum of -0.614 against a swept +/-0.2 and never widened once. `test_every_swept_axis_is_readable_from_outside` now holds the convention. @@ -650,9 +1018,7 @@ def _widened( instead leaves centring, resolution and the band clamp where they already live. """ if refusal.axis in AVERAGING_AXES: - current = int( - config.get(refusal.axis, getattr(routine, f"_{refusal.axis}", 0)) or 0 - ) + current = int(config.get(refusal.axis, sweep.get(refusal.axis, 0)) or 0) # Two ceilings, because they bound different things and only one of them was # here. `MAX_CIRCUITS_PER_DEPTH` is about runtime — five times a node that # already takes half a minute. `__ceiling` is about the *assembler*, and @@ -662,7 +1028,7 @@ def _widened( # qcodes returned the whole 2.4 MB program in the message — which then blew the # report past what the server would store, so the calibration was never # reported at all. A sweep that cannot assemble is not a bigger sweep. - ceiling = getattr(routine, f"_{refusal.axis}_ceiling", None) + ceiling = sweep.get(f"{refusal.axis}_ceiling", None) wanted = min(int(current * refusal.factor), MAX_CIRCUITS_PER_DEPTH) if ceiling is not None: wanted = min(wanted, int(ceiling)) @@ -681,17 +1047,15 @@ def _widened( # catches. return config - scalar = _scalar_axis(routine, config, refusal) + scalar = _scalar_axis(routine, config, refusal, sweep) if scalar is not None: return scalar - current = list( - config.get(refusal.axis) or getattr(routine, f"_{refusal.axis}", ()) or () - ) + current = list(config.get(refusal.axis) or sweep.get(refusal.axis, ()) or ()) if not current: return config low, high = min(current), max(current) - ceiling = getattr(routine, f"_{refusal.axis}_ceiling", None) + ceiling = sweep.get(f"{refusal.axis}_ceiling", None) if refusal.direction == "finer": # The same window, sampled harder. An aliased fringe needs resolution, not reach — # and lengthening the sweep would make the aliasing worse while costing more. @@ -735,7 +1099,10 @@ def _widened( def _scalar_axis( - routine: CalibrationRoutine, config: RoutineConfig, refusal: OutOfRange + routine: CalibrationRoutine, + config: RoutineConfig, + refusal: OutOfRange, + sweep: Sweep, ) -> RoutineConfig | None: """*config* with a scalar *refusal* axis multiplied out, or ``None`` if it is a list. @@ -749,7 +1116,7 @@ def _scalar_axis( return None # The same fallback the list branch uses, and for the same reason: the default case # is a config with no `span` in it, which is exactly the one needing widened. - current = config.get(refusal.axis, getattr(routine, f"_{refusal.axis}", None)) + current = config.get(refusal.axis, sweep.get(refusal.axis, None)) if current is None: return None diff --git a/qpi-driver/py/qpi_driver/tuners/base/sweep.py b/qpi-driver/py/qpi_driver/tuners/base/sweep.py new file mode 100644 index 00000000..ad6c343c --- /dev/null +++ b/qpi-driver/py/qpi_driver/tuners/base/sweep.py @@ -0,0 +1,52 @@ +"""What one target's schedule swept, carried to its analysis (RFC 0009 §6.6). + +A routine used to keep its setpoints on itself — ``self._frequencies`` written in +`build_schedule` and read back in `analyse`. That was correct while a routine ran one +target at a time, and silently wrong the moment it ran several: a sweep centred on a +per-qubit value has a different grid per target, so every fit in a fused group would +have used whichever target built last. Not an error — a plausible curve fitted against +the wrong axis, which is the failure this graph is most scarred by. + +So the setpoints belong to the target, and the target's `Sweep` is threaded from the +build to the fit. Keyed by axis name rather than held as attributes because that is how +`escalating` reaches them: a guard refuses an axis by name, and the retry has to widen +that axis and no other. +""" + +from typing import Any + + +class Sweep: + """One target's swept setpoints, by axis name. + + Dict-like on purpose. A routine writes ``sweep["frequencies"] = ...`` where it used + to write ``self._frequencies``, and reads it back the same way; nothing else about + the routine changes. + """ + + __slots__ = ("target", "axes") + + def __init__(self, target: str, **axes: Any) -> None: + self.target = target + self.axes: dict[str, Any] = dict(axes) + + def __getitem__(self, axis: str) -> Any: + try: + return self.axes[axis] + except KeyError: + raise KeyError( + f"{self.target} swept no {axis!r}; " + f"it swept {', '.join(sorted(self.axes)) or 'nothing'}" + ) from None + + def __setitem__(self, axis: str, setpoints: Any) -> None: + self.axes[axis] = setpoints + + def __contains__(self, axis: str) -> bool: + return axis in self.axes + + def get(self, axis: str, default: Any = None) -> Any: + return self.axes.get(axis, default) + + def __repr__(self) -> str: + return f"Sweep({self.target!r}, {', '.join(sorted(self.axes))})" diff --git a/qpi-driver/py/qpi_driver/tuners/routines/benchmarks.py b/qpi-driver/py/qpi_driver/tuners/routines/benchmarks.py index 09ebf534..dc4cef8c 100644 --- a/qpi-driver/py/qpi_driver/tuners/routines/benchmarks.py +++ b/qpi-driver/py/qpi_driver/tuners/routines/benchmarks.py @@ -7,6 +7,7 @@ """ import logging +from collections.abc import Mapping, Sequence import random from typing import Any @@ -14,12 +15,19 @@ import xarray as xr from qpi_driver.tuners.base.backend import SchedulerBackend +from qpi_driver.tuners.base.fusion import ( + add_after, + add_together, + channels_of, + readouts, +) from qpi_driver.tuners.base.config import RoutineConfig from qpi_driver.tuners.base.routines import ( DEFAULT_ROUTINE_TIMEOUT_S, CalibrationRoutine, RoutineError, ) +from qpi_driver.tuners.base.sweep import Sweep from qpi_driver.tuners.fitting import fit_rb_decay, signal_of from qpi_driver.tuners.routines.single_qubit import ( ALLXY_IDEAL, @@ -98,6 +106,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -110,7 +119,22 @@ def measure( untouched — which matters here, because RB's depths are a statement about what the operator wants benchmarked. """ - return self.escalating(target, device, config, backend, timeout_s) + return self.escalating(target, device, config, backend, timeout_s, sweep) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """Average harder only for the targets whose decay was lost in their own scatter.""" + return self.escalating_group( + targets, device, config, backend, timeout_s, sweeps + ) def acquire( self, @@ -119,6 +143,7 @@ def acquire( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, ) -> Any: """Run this sweep as however many schedules it takes, and combine them. @@ -138,7 +163,7 @@ def acquire( wanted = int(config.get("circuits_per_depth", DEFAULT_RB_CIRCUITS)) per_schedule = max(1, MAX_RB_CLIFFORDS // max(sum(depths), 1)) if wanted <= per_schedule or not depths: - return super().acquire(target, device, config, backend, timeout_s) + return super().acquire(target, device, config, backend, timeout_s, sweep) seed = int(config.get("seed", DEFAULT_RB_SEED)) sizes = [per_schedule] * (wanted // per_schedule) @@ -170,7 +195,7 @@ def acquire( "seed": seed + index, }, ) - dataset = super().acquire(target, device, chunk, backend, timeout_s) + dataset = super().acquire(target, device, chunk, backend, timeout_s, sweep) signal = np.asarray(signal_of(dataset), dtype=float) taken = len(depths) * size expected = taken + REFERENCE_ACQUISITIONS @@ -189,8 +214,8 @@ def acquire( # Each depth's circuits from every chunk, side by side, so `analyse` reshapes it # exactly as it would one schedule's worth, references included. combined = np.hstack(rows) - self._depths = depths - self._circuits = self._circuits_per_depth = int(combined.shape[1]) + sweep["depths"] = depths + sweep["circuits"] = sweep["circuits_per_depth"] = int(combined.shape[1]) return xr.Dataset( { "y0": ( @@ -201,32 +226,143 @@ def acquire( ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - self._depths = [int(d) for d in config.get("depths", DEFAULT_RB_DEPTHS)] - # Named `_circuits_per_depth` as well, because escalation reads the setpoints a - # routine actually used off `_` — see `_widened`. - self._circuits = self._circuits_per_depth = int( - config.get("circuits_per_depth", DEFAULT_RB_CIRCUITS) + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} ) - if not self._depths or self._circuits < 1: - raise RoutineError("RB needs at least one depth and one circuit per depth") - # How deep escalation may go — see `MAX_RB_CLIFFORDS`. Independent of the circuit - # count, which is the whole point of chunking: circuits are split across schedules - # by `acquire`, so only *one circuit's worth of every depth* has to fit in a - # program. Widening builds `linear_setpoints(1, top, n)`, whose sum is - # `n*(1+top)/2`, so the budget inverts to a bound on `top`. Read by `_widened` off - # `__ceiling`; when it bites the config comes back unchanged and the refusal - # is re-raised rather than the same sweep re-run. - # - # This one is a real ceiling and cannot become a chunk size. A single sequence of - # depth m is m Cliffords in one program and there is nowhere to cut it: a Clifford - # sequence is only an RB sequence closed by its own recovery gate. - self._depths_ceiling = 2.0 * MAX_RB_CLIFFORDS / len(self._depths) - 1.0 + def acquire_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The circuit split of :meth:`acquire`, over a group. + + Chunked on the same budget and for the same reason: the ceiling is a sequencer's + instruction count, and a fused schedule gives each target its own sequencer running + its own copy of the sweep, so how many circuits one program holds does not depend on + how many targets are in it. Without this the fused path would skip the split — and + it is active on the shipped defaults, so every grouped RB would have built a program + too long to assemble. + + Each chunk is seeded apart, or they would be copies of the same circuits and average + to nothing. + """ + depths = [int(d) for d in config.get("depths", DEFAULT_RB_DEPTHS)] + wanted = int(config.get("circuits_per_depth", DEFAULT_RB_CIRCUITS)) + per_schedule = max(1, MAX_RB_CLIFFORDS // max(sum(depths), 1)) + if wanted <= per_schedule or not depths: + return super().acquire_group( + targets, device, config, backend, timeout_s, sweeps + ) + + seed = int(config.get("seed", DEFAULT_RB_SEED)) + sizes = [per_schedule] * (wanted // per_schedule) + if wanted % per_schedule: + sizes.append(wanted % per_schedule) + log.info( + "%s on %s: %d circuits over depths summing %d is %d Cliffords, past the %d " + "one schedule holds — running %d schedules of %s", + self.name, + ", ".join(targets), + wanted, + sum(depths), + wanted * sum(depths), + MAX_RB_CLIFFORDS, + len(sizes), + sizes, + ) + + gathered: dict[str, list[Any]] = {target: [] for target in targets} + references: dict[str, Any] = {} + for index, size in enumerate(sizes): + chunk = RoutineConfig( + enabled=config.enabled, + params={ + **config.params, + "depths": depths, + "circuits_per_depth": size, + # Apart, or every chunk benchmarks the same circuits. + "seed": seed + index, + }, + ) + dataset = super().acquire_group( + targets, device, chunk, backend, timeout_s, sweeps + ) + sliced = channels_of(dataset, targets) + for target in targets: + piece = sliced.get(target) + if piece is None: + raise RoutineError( + f"the fused acquisition carried no channel for {target}" + ) + signal = np.asarray(signal_of(piece), dtype=float) + expected = len(depths) * size + REFERENCE_ACQUISITIONS + if signal.size < expected: + raise RoutineError( + f"{target} returned {signal.size} acquisitions, expected " + f"{expected} for {size} circuits over {len(depths)} depths" + ) + # The |0> and X|0> references lead every chunk; one copy is what `analyse` + # reads, and the rest are the same two points measured again. + references.setdefault(target, signal[:REFERENCE_ACQUISITIONS]) + gathered[target].append(signal[REFERENCE_ACQUISITIONS:expected]) + + # Restored so each `analyse` reshapes against the whole sweep rather than a chunk. + for target in targets: + sweeps[target]["circuits"] = sweeps[target]["circuits_per_depth"] = wanted + return xr.Dataset( + { + channel: ( + "acq_index", + np.concatenate([references[target], *gathered[target]]), + ) + for channel, target in enumerate(targets) + } + ) - # Seeded so a rerun benchmarks the same circuits: an unseeded RB would - # move under the drift check it exists to detect. + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The same Clifford sequences on every target at once — simultaneous RB. + + The depths and the circuit count come from the config, so they are one grid for the + group and it never splits. One seed too: a fused run benchmarks every target + against the *same* circuits, which is what makes the per-target fidelities + comparable with each other and with an isolated run. + """ + depths = [int(d) for d in config.get("depths", DEFAULT_RB_DEPTHS)] + circuits = int(config.get("circuits_per_depth", DEFAULT_RB_CIRCUITS)) + if not depths or circuits < 1: + raise RoutineError("RB needs at least one depth and one circuit per depth") + for target in targets: + sweep = sweeps[target] + sweep["depths"] = depths + # Named `circuits_per_depth` as well, because escalation reads the setpoints a + # routine actually used off the axis's own name — see `_widened`. + sweep["circuits"] = sweep["circuits_per_depth"] = circuits + # How deep escalation may go — see `MAX_RB_CLIFFORDS`. Independent of the + # circuit count, which is the point of chunking: only one circuit's worth of + # every depth has to fit in a program. + sweep["depths_ceiling"] = 2.0 * MAX_RB_CLIFFORDS / len(depths) - 1.0 + + # Seeded so a rerun benchmarks the same circuits: an unseeded RB would move under + # the drift check it exists to detect. rng = random.Random(int(config.get("seed", DEFAULT_RB_SEED))) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) @@ -235,28 +371,22 @@ def build_schedule( # |0> and X|0> first, so the decay is read as a survival probability rather than # scaled against its own extremes — see :meth:`analyse`. for index, prepare in enumerate((0, 1)): - schedule.add(backend.Reset(target)) + anchor = add_together(schedule, [backend.Reset(t) for t in targets]) if prepare: - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) - ) + anchor = add_together(schedule, [backend.X(t) for t in targets]) + add_after(schedule, readouts(backend, targets, index), anchor) index = REFERENCE_ACQUISITIONS - for depth in self._depths: - for _ in range(self._circuits): + for depth in depths: + for _ in range(circuits): sequence = sequence_with_recovery( generate_clifford_sequence(depth, rng) ) - schedule.add(backend.Reset(target)) - self._add_sequence(schedule, target, sequence, backend) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + add_together(schedule, [backend.Reset(t) for t in targets]) + anchor = self._add_group_sequence( + schedule, targets, sequence, backend, sweeps ) + add_after(schedule, readouts(backend, targets, index), anchor) index += 1 return schedule @@ -266,6 +396,7 @@ def _add_sequence( target: str, sequence: list[int], backend: SchedulerBackend, + sweep: Sweep, ) -> None: """Play *sequence* as native rotations, interleaving where asked.""" for position, clifford in enumerate(sequence): @@ -273,18 +404,65 @@ def _add_sequence( schedule.add(backend.Rxy(theta=theta, phi=phi, qubit=target)) # The recovery Clifford closes the sequence, so nothing follows it. if self.interleaved and position < len(sequence) - 1: - self._add_interleaved(schedule, target, backend) + self._add_interleaved(schedule, target, backend, sweep) + + def _add_group_sequence( + self, + schedule: Any, + targets: Sequence[str], + sequence: list[int], + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """*sequence* played on every target at once, returning the last anchor. + + The same Cliffords on each, which is what makes a fused RB the *simultaneous* RB + of Gambetta et al. rather than several independent ones: the point of running them + together is that each qubit is driven while its neighbours are, so comparing this + against the isolated fidelity is what measures the addressability (RFC 0009 §5.6). + """ + anchor = None + for position, clifford in enumerate(sequence): + for theta, phi in clifford_to_gates(clifford): + anchor = add_together( + schedule, + [backend.Rxy(theta=theta, phi=phi, qubit=t) for t in targets], + ) + # The recovery Clifford closes the sequence, so nothing follows it. + if self.interleaved and position < len(sequence) - 1: + interleaved = self._group_interleaved(targets, backend, sweeps) + if interleaved: + anchor = add_together(schedule, interleaved) + return anchor + + def _group_interleaved( + self, + targets: Sequence[str], + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> list[Any]: + """The operation to interleave on each target. None for standard RB. + + The group counterpart of :meth:`_add_interleaved`, returning the operations rather + than adding them so they can be placed at one start time. + """ + return [] def _add_interleaved( - self, schedule: Any, target: str, backend: SchedulerBackend + self, schedule: Any, target: str, backend: SchedulerBackend, sweep: Sweep ) -> None: # pragma: no cover - overridden where it matters raise NotImplementedError def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - circuits = len(self._depths) * self._circuits + circuits = len(sweep["depths"]) * sweep["circuits"] expected = circuits + REFERENCE_ACQUISITIONS if signal.size < expected: raise RoutineError( @@ -303,7 +481,7 @@ def analyse( # Average the circuits at each depth; the decay is over depth, and the # spread within a depth is what averaging is for. survival = signal[REFERENCE_ACQUISITIONS:expected].reshape( - len(self._depths), self._circuits + len(sweep["depths"]), sweep["circuits"] ) mean = survival.mean(axis=1) @@ -316,9 +494,9 @@ def analyse( # could have changed either — the endpoints were arithmetic, not measurement. normalised = (mean - excited) / contrast - fitted = fit_rb_decay(np.asarray(self._depths, dtype=float), normalised) - fitted["depths"] = list(self._depths) - fitted["circuits_per_depth"] = self._circuits + fitted = fit_rb_decay(np.asarray(sweep["depths"], dtype=float), normalised) + fitted["depths"] = list(sweep["depths"]) + fitted["circuits_per_depth"] = sweep["circuits"] return fitted @@ -338,20 +516,68 @@ class InterleavedRB(RandomizedBenchmarking): interleaved = "CZ" def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, + ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], ) -> Any: - self._control, self._spectator = qubits_of(target) - return super().build_schedule(self._control, device, config, backend) + """The endpoints resolved per edge first, since the CZ and the readout need them. + + The Cliffords are played on each edge's *control*, so the acquisition channel is + the edge's position in the group and the qubit measured is its control — which is + what `analyse` reads back. + """ + for target in targets: + sweeps[target]["control"], sweeps[target]["spectator"] = qubits_of(target) + controls = [sweeps[target]["control"] for target in targets] + return super().build_group_schedule( + controls, + device, + config, + backend, + {c: sweeps[t] for c, t in zip(controls, targets)}, + ) def _add_interleaved( - self, schedule: Any, target: str, backend: SchedulerBackend + self, schedule: Any, target: str, backend: SchedulerBackend, sweep: Sweep ) -> None: - schedule.add(backend.CZ(self._control, self._spectator)) + schedule.add(backend.CZ(sweep["control"], sweep["spectator"])) + + def _group_interleaved( + self, + targets: Sequence[str], + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> list[Any]: + """One CZ per edge, all at the same start — the gate being isolated.""" + return [ + backend.CZ(sweeps[target]["control"], sweeps[target]["spectator"]) + for target in targets + ] def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - fitted = super().analyse(dataset, self._control, device, config) + fitted = super().analyse(dataset, sweep["control"], device, config, sweep) fitted["interleaved_gate"] = "CZ" return fitted @@ -370,26 +596,61 @@ class AllXYCheck(CalibrationRoutine): reads = ("clock_freqs.f01", "rxy.amp180") benchmark = True - def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], ) -> Any: + """The 21 pairs on every target at once — the sequence is the same on each.""" schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 512)) ) for index, (first, second) in enumerate(ALLXY_PAIRS): - schedule.add(backend.Reset(target)) + anchor = add_together(schedule, [backend.Reset(t) for t in targets]) for theta, phi in (first, second): if theta: - schedule.add(backend.Rxy(theta=theta, phi=phi, qubit=target)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + anchor = add_after( + schedule, + [backend.Rxy(theta=theta, phi=phi, qubit=t) for t in targets], + anchor, + ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule + def build_schedule( + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, + ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) if signal.size < len(ALLXY_PAIRS): diff --git a/qpi-driver/py/qpi_driver/tuners/routines/ef.py b/qpi-driver/py/qpi_driver/tuners/routines/ef.py index f929972e..2ab3c16b 100644 --- a/qpi-driver/py/qpi_driver/tuners/routines/ef.py +++ b/qpi-driver/py/qpi_driver/tuners/routines/ef.py @@ -16,6 +16,7 @@ same opt-in every other addition in this RFC makes. """ +from collections.abc import Mapping, Sequence from typing import Any import logging @@ -42,6 +43,14 @@ linear_setpoints, setpoints_of, ) +from qpi_driver.tuners.base.fusion import ( + add_after, + add_together, + grouped_by_grid, + grouped_by_size, + readouts, +) +from qpi_driver.tuners.base.sweep import Sweep from qpi_driver.tuners.fitting import ( fit_drag, fit_fine_amplitude, @@ -55,9 +64,13 @@ #: Shared with `resonator_spectroscopy_excited`: the same experiment one rung up wants #: the same window, and two constants that must agree are one written twice. -from qpi_driver.tuners.routines.single_qubit import amplified # noqa: E402 +from qpi_driver.tuners.routines.single_qubit import ( # noqa: E402 + amplified, + amplified_group, +) from qpi_driver.tuners.routines.spectroscopy import ( # noqa: E402 EXCITED_SPAN_IN_LINEWIDTHS, + sweep_readout_frequency, ) log = logging.getLogger(__name__) @@ -206,6 +219,24 @@ def ef_path(element: Any, name: str) -> str | None: return f"{EF}.{name}" +def ef_amplitude_grid(element: Any, config: RoutineConfig) -> list[float]: + """The EF drive amplitudes `rabi_12` and `ef_ladder` both sweep. + + Half scale, and deliberately *not* full scale the way `rabi` is. The bound here is the + model rather than the hardware: `_drive_ef` neglects the off-resonant 0-1 term, and + `f12_spectroscopy` records that half a pi pulse is already where that starts to matter. + Sweeping to 1.0 samples a regime the cosine this fit assumes does not describe. + + `full_scale` is still the ceiling on the ceiling, for an element declaring something + tighter still. + """ + return setpoints_of( + config, + "amplitudes", + linear_setpoints(0.0, min(0.5, full_scale(element, f"{EF}.ef_amp180")), 41), + ) + + def ef_duration(element: Any, config: RoutineConfig) -> float: """How long an EF pulse plays: the config, the element, or ``rxy.duration``. @@ -241,9 +272,15 @@ def add_ef_pulse( phase_deg: float = 0.0, drag: float = 0.0, transition: str = "12", -) -> None: + ref_op: Any = None, +) -> Any: """One pulse on the ``.`` clock, into the port ``rxy`` uses. + Returns the schedulable it added, and takes *ref_op* to start against, so a fused group + can place one per target at the same time. Without that these append, and a group's EF + pulses would play one after another — which is not merely slower: whatever the readout + was anchored to would no longer be the last thing before it. + *transition* is ``"12"`` for every caller that calibrates the EF chain. `ef_ladder` passes ``"01"`` to play this exact pulse on the lower transition instead, which is the only way to measure the ladder without the pulse shape and duration in the way. @@ -259,8 +296,13 @@ def add_ef_pulse( """ clock = f"{target}.{transition}" port = f"{target}:mw" + placement = ( + {"ref_op": ref_op, "ref_pt": "start", "ref_pt_new": "start"} + if ref_op is not None + else {} + ) if drag or phase_deg % 360.0: - schedule.add( + return schedule.add( backend.drag_pulse( amp=amplitude, drag=drag, @@ -268,14 +310,54 @@ def add_ef_pulse( port=port, clock=clock, phase_deg=phase_deg, - ) + ), + **placement, ) - return - schedule.add( - backend.SquarePulse(amp=amplitude, duration=duration, port=port, clock=clock) + return schedule.add( + backend.SquarePulse(amp=amplitude, duration=duration, port=port, clock=clock), + **placement, ) +def add_ef_pulses( + schedule: Any, + backend: SchedulerBackend, + targets: Sequence[str], + per_target: Mapping[str, tuple[float, float]], + transition: str = "12", + phase_deg: float = 0.0, + drag: float = 0.0, +) -> Any: + """One EF pulse per target, all starting together, returning the longest as the anchor. + + The group counterpart of :func:`add_ef_pulse`. Each target's pulse is on its own + ``.`` clock and its own ``:mw`` port, so they can coincide; *per_target* + gives each one's ``(amplitude, duration)``, since both are read from that element. + + *phase_deg* and *drag* are shared rather than per target, because where they vary they + are the swept axis — the same value on every target of the group. + """ + anchor = None + longest = (0.0, None) + for target in targets: + amplitude, duration = per_target[target] + placed = add_ef_pulse( + schedule, + backend, + target, + amplitude, + duration, + phase_deg=phase_deg, + drag=drag, + transition=transition, + ref_op=anchor, + ) + anchor = anchor or placed + if duration >= longest[0]: + longest = (duration, placed) + return longest[1] + + class Rabi12(CalibrationRoutine): """Sweep the EF drive amplitude to find the pi pulse between ``|1>`` and ``|2>``. @@ -309,84 +391,104 @@ def applies_to(self, device: Any, target: str) -> bool: return has_ef_drive(device, target) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = device.get_element(target) - # Half scale, and deliberately *not* full scale the way `rabi` now is. The bound - # here is the model rather than the hardware: `_drive_ef` neglects the - # off-resonant 0-1 term, and `f12_spectroscopy` records that half a pi pulse is - # already where that starts to matter. Sweeping to 1.0 samples a regime the - # cosine this fit assumes does not describe, and it showed: the fitted ef pi - # moved to 0.1577 against 0.1429 for a sqrt(2) ladder, and `ramsey_12`'s fringe - # fell to 2.8x its scatter against the 3x its guard allows. - # - # So the ef ceiling is physics-bounded (RFC 0007 §5) and lower than full scale. - # `full_scale` is still the ceiling on the ceiling, for an element that declares - # something tighter still. - self._amplitudes = setpoints_of( - config, - "amplitudes", - linear_setpoints(0.0, min(0.5, full_scale(element, f"{EF}.ef_amp180")), 41), + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} ) - self._duration = ef_duration(element, config) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By grid size: the ceiling is each element's own — see `ef_amplitude_grid`.""" + return grouped_by_size( + targets, lambda t: ef_amplitude_grid(device.get_element(t), config) + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The EF Rabi on every target at once, read back through ``|0>``.""" + grids = {} + for target in targets: + element = device.get_element(target) + grids[target] = ef_amplitude_grid(element, config) + sweeps[target]["amplitudes"] = grids[target] + sweeps[target]["duration"] = ef_duration(element, config) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 2048)) ) - for index, amplitude in enumerate(self._amplitudes): - schedule.add(backend.Reset(target)) - # Into |1> first, which is what makes this the 1-2 transition rather than - # a second look at 0-1. - schedule.add(backend.X(target)) - add_ef_pulse(schedule, backend, target, amplitude, self._duration) + for index in range(len(grids[targets[0]])): + add_together(schedule, [backend.Reset(t) for t in targets]) + # Into |1> first, which is what makes this the 1-2 transition rather than a + # second look at 0-1. + add_together(schedule, [backend.X(t) for t in targets]) + add_ef_pulses( + schedule, + backend, + targets, + { + target: (grids[target][index], sweeps[target]["duration"]) + for target in targets + }, + ) # Back to |0> if the ef drive did nothing, and left in |2> if it turned a pi. # - # Without this the readout has to tell |1> from |2> *directly*, and it is sitting - # at an operating point chosen to separate |0> from |1> — where the two upper - # levels project close together, because a 0-1 discriminator is tuned to put its - # threshold between the first two and not the second two. The trace then barely - # oscillates, and `fit_rabi` halves the period of whatever cosine it can find: on - # the August 2026 B chip that returned an ef pi of 0.0677 against the 0.4071 the - # sqrt(2) ladder predicts, six times out and near the *bottom* of a sweep that - # reached 0.5, so no range guard could see it either. + # Without this the readout has to tell |1> from |2> *directly*, and it sits at + # an operating point chosen to separate |0> from |1> — where the two upper + # levels project close together. The trace then barely oscillates, and + # `fit_rabi` halves the period of whatever cosine it can find: in an August + # 2026 bring-up that returned an ef pi six times out and near the *bottom* of a + # sweep that reached 0.5, so no range guard could see it either. # # A second 0-1 pi maps |1> back to |0> and leaves |2> where it is, off-resonant - # by the 250 MHz anharmonicity against a pulse whose bandwidth is some 18 MHz. So - # the ef oscillation appears in the |0> population, which is the one quantity this - # readout is already good at, and the contrast is the full readout contrast rather - # than the difference between two dispersive shifts. + # by the anharmonicity against a pulse whose bandwidth is far narrower. So the + # ef oscillation appears in the |0> population — the one quantity this readout + # is already good at — at the full readout contrast. # # This is also what unblocks the chain's bootstrap: every other EF node reads at - # `measure_3state`, which cannot be calibrated until something has populated |2>, - # and this is the node that has to do it first. - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) - ) + # `measure_3state`, which cannot be calibrated until something has populated + # |2>, and this is the node that has to do it first. + anchor = add_together(schedule, [backend.X(t) for t in targets]) + add_after(schedule, readouts(backend, targets, index), anchor) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: # Seeded with what the ladder predicts, not checked against it afterwards. The two # cosines that fit a thin 1-2 sweep differ by a factor of two in period and the # data often cannot separate them; `_best_rabi_fit` keeps the physical one only # while that stays true, so this steers the fit without deciding it. fitted = fit_rabi( - np.asarray(self._amplitudes), + np.asarray(sweep["amplitudes"]), signal_of(dataset), - expected_amp180=ladder_amplitude(device, target, self._duration) or None, + expected_amp180=ladder_amplitude(device, target, sweep["duration"]) or None, ) _require_ef_ladder( device, target, fitted["amp180"], - self._duration, + sweep["duration"], contrast=float(fitted.get("contrast", 0.0)), reference_contrast=measured_contrast(device.get_element(target)), fit=fitted.get("fit"), - span=float(max(self._amplitudes)) - float(min(self._amplitudes)), + span=float(max(sweep["amplitudes"])) - float(min(sweep["amplitudes"])), ) # The trace on success too, which only a refusal carried before. The shape is the # one thing separating the two ways this node comes back wrong, and they are @@ -395,7 +497,7 @@ def analyse( # the second has happened on this chip before. return { "ef_amp180": fitted["amp180"], - "ef_duration": self._duration, + "ef_duration": sweep["duration"], "fit": fitted.get("fit"), } @@ -489,38 +591,84 @@ def applies_to(self, device: Any, target: str) -> bool: return has_three_state_readout(device, target) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = device.get_element(target) - self._ef_amplitude = _required_ef_amplitude(element, target) - self._duration = ef_duration(element, config) - self._settings = self._grid(element, config) + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By grid size: each point is this qubit's own frequency and drive amplitude.""" + return grouped_by_size( + targets, lambda t: self._grid(device.get_element(t), config) + ) + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """All three levels at every setting, for every target at once.""" + grids = {} + ef = {} + for target in targets: + element = device.get_element(target) + grids[target] = self._grid(element, config) + sweeps[target]["settings"] = grids[target] + sweeps[target]["ef_amplitude"] = _required_ef_amplitude(element, target) + sweeps[target]["duration"] = ef_duration(element, config) + ef[target] = ( + sweeps[target]["ef_amplitude"], + sweeps[target]["duration"], + ) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 300)) ) index = 0 - for frequency, amplitude in self._settings: - schedule.add( - backend.SetClockFrequency( - clock=f"{target}.ro", clock_freq_new=frequency - ) + for position in range(len(grids[targets[0]])): + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=f"{target}.ro", + clock_freq_new=grids[target][position][0], + ) + for target in targets + ], ) for level in (0, 1, 2): - schedule.add(backend.Reset(target)) + anchor = add_together( + schedule, [backend.Reset(target) for target in targets] + ) if level >= 1: - schedule.add(backend.X(target)) - if level >= 2: - add_ef_pulse( - schedule, backend, target, self._ef_amplitude, self._duration - ) - schedule.add( - backend.Measure( - target, - acq_index=index, - bin_mode=backend.BinMode.APPEND, - pulse_amp=amplitude, + anchor = add_together( + schedule, [backend.X(target) for target in targets] ) + if level >= 2: + anchor = add_ef_pulses(schedule, backend, targets, ef) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.APPEND, + pulse_amp=grids[target][position][1], + ) + for channel, target in enumerate(targets) + ], + anchor, ) index += 1 return schedule @@ -597,11 +745,16 @@ def _grid(self, element: Any, config: RoutineConfig) -> list[tuple[float, float] return grid def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - shots = _prepared_clouds(dataset, 3 * len(self._settings)) - clouds = [shots[i * 3 : i * 3 + 3] for i in range(len(self._settings))] - return fit_three_state_operating_point(self._settings, clouds) + shots = _prepared_clouds(dataset, 3 * len(sweep["settings"])) + clouds = [shots[i * 3 : i * 3 + 3] for i in range(len(sweep["settings"]))] + return fit_three_state_operating_point(sweep["settings"], clouds) def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: element = device.get_element(target) @@ -673,15 +826,29 @@ def applies_to(self, device: Any, target: str) -> bool: return has_ef_drive(device, target) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By point count: the span is sized from each resonator's own linewidth.""" + return grouped_by_size(targets, lambda t: self._band(device, t, config)[0]) + + def _band( + self, device: Any, target: str, config: RoutineConfig + ) -> tuple[list[float], float]: + """This target's sweep and the ground-state centre it is measured against.""" element = device.get_element(target) - amplitude = _required_ef_amplitude(element, target) - duration = ef_duration(element, config) - # The reference `analyse` differences against, read here rather than there: a - # prerequisite has to be readable before the acquisition to be one at all, and - # this sweep is already centred on the same value. - self._ground = centre = float(read_path(element, "clock_freqs.readout")) + centre = float(read_path(element, "clock_freqs.readout")) # From the measured linewidth, as `resonator_spectroscopy_excited` does and for the # same reason: this has to find a resonance the ladder has moved, so it wants # several linewidths rather than a refinement's fraction of one. @@ -692,38 +859,64 @@ def build_schedule( ) ) points = int(config.get("points", 51)) - self._frequencies = setpoints_of( - config, - "frequencies", - linear_setpoints(centre - span / 2, centre + span / 2, points), + return ( + setpoints_of( + config, + "frequencies", + linear_setpoints(centre - span / 2, centre + span / 2, points), + ), + centre, ) + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The same sweep with every qubit taken up the ladder to ``|2>`` first.""" + bands = {} + ef = {} + for target in targets: + element = device.get_element(target) + bands[target], centre = self._band(device, target, config) + sweeps[target]["frequencies"] = bands[target] + # The reference `analyse` differences against, read here rather than there: a + # prerequisite has to be readable before the acquisition to be one at all, and + # this sweep is already centred on the same value. + sweeps[target]["ground"] = centre + ef[target] = ( + _required_ef_amplitude(element, target), + ef_duration(element, config), + ) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - for index, frequency in enumerate(self._frequencies): - schedule.add(backend.Reset(target)) - schedule.add(backend.X(target)) - add_ef_pulse(schedule, backend, target, amplitude, duration) - schedule.add( - backend.SetClockFrequency( - clock=f"{target}.ro", clock_freq_new=frequency - ) - ) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) - ) + + def prepare(sched: Any, group: Sequence[str], _anchor: Any) -> Any: + add_together(sched, [backend.X(t) for t in group]) + # A raw pulse per target on its own ``.12`` clock, so they cannot be one + # operation the way a gate can — but they must still coincide, and the readout + # must follow the longest of them. + return add_ef_pulses(sched, backend, group, ef) + + sweep_readout_frequency(schedule, backend, targets, bands, prepare=prepare) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - fitted = fit_resonator_spectroscopy(self._frequencies, signal_of(dataset)) - require_resolved_line(fitted, self._frequencies) + fitted = fit_resonator_spectroscopy(sweep["frequencies"], signal_of(dataset)) + require_resolved_line(fitted, sweep["frequencies"]) second = fitted["readout_frequency"] - ground = self._ground + ground = sweep["ground"] return { "readout_frequency_second_excited": second, # See `resonator_spectroscopy_excited`: the reference is not measured here. @@ -792,6 +985,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -804,43 +998,77 @@ def measure( spanned 1.707 of its contrast, asked to be shortened, and was refused outright on every run because this method was not here. """ - return amplified(self, target, device, config, backend, timeout_s, step=1) + return amplified( + self, target, device, config, backend, timeout_s, sweep, step=1 + ) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The same shortening, applied only to the ladders that outran their model.""" + return amplified_group( + self, targets, device, config, backend, timeout_s, sweeps, step=1 + ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = device.get_element(target) - self._amplitude = _required_ef_amplitude(element, target) - self._duration = ef_duration(element, config) - self._repetitions = [ + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The amplified EF ladder on every target at once. The counts are one grid.""" + repetitions = [ int(n) for n in setpoints_of(config, "repetitions", list(range(1, 26))) ] + pulses = {} + for target in targets: + element = device.get_element(target) + sweeps[target]["repetitions"] = repetitions + sweeps[target]["amplitude"] = _required_ef_amplitude(element, target) + sweeps[target]["duration"] = ef_duration(element, config) + pulses[target] = ( + sweeps[target]["amplitude"], + sweeps[target]["duration"], + ) + halves = {t: (amp / 2.0, dur) for t, (amp, dur) in pulses.items()} schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - - for index, count in enumerate(self._repetitions): - schedule.add(backend.Reset(target)) - schedule.add(backend.X(target)) - # Half the amplitude is half the rotation: the drive is linear in it at - # fixed duration, which is the same assumption `rabi_12` fits under. - add_ef_pulse( - schedule, backend, target, self._amplitude / 2.0, self._duration - ) + for index, count in enumerate(repetitions): + add_together(schedule, [backend.Reset(t) for t in targets]) + add_together(schedule, [backend.X(t) for t in targets]) + # Half the amplitude is half the rotation: the drive is linear in it at fixed + # duration, which is the same assumption `rabi_12` fits under. + add_ef_pulses(schedule, backend, targets, halves) for _ in range(count): - add_ef_pulse(schedule, backend, target, self._amplitude, self._duration) - # Back to |0> if the ef pulses left the qubit in |1>, and untouched in |2>. - # See the class docstring: this is what puts the accumulated ef error into - # the |0> population and lets a 0-1 readout resolve it. - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, - acq_index=index, - bin_mode=backend.BinMode.AVERAGE, - ) - ) + add_ef_pulses(schedule, backend, targets, pulses) + # Back to |0> if the ef pulses left the qubit in |1>, and untouched in |2>. See + # the class docstring: this is what puts the accumulated ef error into the |0> + # population and lets a 0-1 readout resolve it. + anchor = add_together(schedule, [backend.X(t) for t in targets]) + add_after(schedule, readouts(backend, targets, index), anchor) # The EF subspace's two states, measured through the *same* map-back as the sweep # above — otherwise the contrast the fit divides by is not the contrast the sweep @@ -851,46 +1079,45 @@ def build_schedule( # So both references end with the mapping pi: no ef pulse leaves |1>, which maps to # |0>, and one ef pi leaves |2>, which does not. They are the two ends of the # population axis this sweep actually moves along. - reference = len(self._repetitions) + reference = len(repetitions) for offset, prepare_two in enumerate((False, True)): - schedule.add(backend.Reset(target)) - schedule.add(backend.X(target)) + add_together(schedule, [backend.Reset(t) for t in targets]) + add_together(schedule, [backend.X(t) for t in targets]) if prepare_two: - add_ef_pulse(schedule, backend, target, self._amplitude, self._duration) - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, - acq_index=reference + offset, - bin_mode=backend.BinMode.AVERAGE, - ) - ) + add_ef_pulses(schedule, backend, targets, pulses) + anchor = add_together(schedule, [backend.X(t) for t in targets]) + add_after(schedule, readouts(backend, targets, reference + offset), anchor) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - expected = len(self._repetitions) + 2 + expected = len(sweep["repetitions"]) + 2 if signal.size < expected: raise RoutineError( f"fine amplitude 12 expected {expected} acquisitions, got {signal.size}" ) - swept = signal[: len(self._repetitions)] + swept = signal[: len(sweep["repetitions"])] in_one, in_two = ( - float(signal[len(self._repetitions)]), - float(signal[len(self._repetitions) + 1]), + float(signal[len(sweep["repetitions"])]), + float(signal[len(sweep["repetitions"]) + 1]), ) fitted = fit_fine_amplitude( - np.asarray(self._repetitions, dtype=float), + np.asarray(sweep["repetitions"], dtype=float), swept, - self._amplitude, + sweep["amplitude"], ground=in_one, excited=in_two, ) return {"ef_amp180": fitted["amplitude"], **fitted} - def uncorrected(self, device: Any, target: str) -> dict[str, Any]: + def uncorrected(self, device: Any, target: str, sweep: Sweep) -> dict[str, Any]: element = device.get_element(target) path = ef_path(element, "ef_amp180") current = float(read_path(element, path)) if path else 0.0 @@ -950,50 +1177,84 @@ def applies_to(self, device: Any, target: str) -> bool: return has_ef_drive(device, target) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = device.get_element(target) - self._duration = ef_duration(element, config) - # `rabi_12`'s own sweep, so the two amplitudes are read off the same grid. Any - # difference between them is then the transitions and not the sampling. - self._amplitudes = setpoints_of( - config, - "amplitudes", - linear_setpoints(0.0, min(0.5, full_scale(element, f"{EF}.ef_amp180")), 41), + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By grid size, since it is `rabi_12`'s grid — see `ef_amplitude_grid`.""" + return grouped_by_size( + targets, lambda t: ef_amplitude_grid(device.get_element(t), config) + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The 0-1 half of the ladder on every target at once. + + `rabi_12`'s own grid, so the two amplitudes are read off the same sampling and any + difference between them is the transitions rather than the grid. + """ + grids = {} + for target in targets: + element = device.get_element(target) + grids[target] = ef_amplitude_grid(element, config) + sweeps[target]["amplitudes"] = grids[target] + sweeps[target]["duration"] = ef_duration(element, config) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 2048)) ) - for index, amplitude in enumerate(self._amplitudes): - schedule.add(backend.Reset(target)) + for index in range(len(grids[targets[0]])): + anchor = add_together(schedule, [backend.Reset(t) for t in targets]) # From the ground state and on the lower clock, so this is an ordinary Rabi — # the only thing borrowed from the EF chain is the pulse itself. - add_ef_pulse( + anchor = add_ef_pulses( schedule, backend, - target, - float(amplitude), - self._duration, + targets, + { + target: ( + float(grids[target][index]), + sweeps[target]["duration"], + ) + for target in targets + }, transition="01", ) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) - ) + add_after(schedule, readouts(backend, targets, index), anchor) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - if signal.size < len(self._amplitudes): + if signal.size < len(sweep["amplitudes"]): raise RoutineError( - f"ef ladder expected {len(self._amplitudes)} acquisitions, " + f"ef ladder expected {len(sweep['amplitudes'])} acquisitions, " f"got {signal.size}" ) fitted = fit_rabi( - np.asarray(self._amplitudes, dtype=float), signal[: len(self._amplitudes)] + np.asarray(sweep["amplitudes"], dtype=float), + signal[: len(sweep["amplitudes"])], ) matched = float(fitted["amp180"]) measured = _measured_ef_amplitude(device, target) @@ -1003,7 +1264,7 @@ def analyse( "a ladder of %.3f against the %.3f a transmon's sqrt(2) requires (%.2fx out)", self.name, target, - self._duration * 1e9, + sweep["duration"] * 1e9, matched, measured, ratio, @@ -1018,7 +1279,7 @@ def analyse( # The number to read: one means the ladder holds and the pulse conventions # explain everything; anything else is the output chain. "ladder_agreement": ratio / LADDER_RATIO if LADDER_RATIO else 0.0, - "pulse_duration": self._duration, + "pulse_duration": sweep["duration"], "contrast": float(fitted.get("contrast", 0.0)), "fit": fitted.get("fit"), } @@ -1076,76 +1337,129 @@ def applies_to(self, device: Any, target: str) -> bool: return has_three_state_readout(device, target) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = device.get_element(target) - # Half the pi amplitude is half the rotation, at fixed duration. - self._half = _required_ef_amplitude(element, target) / 2.0 - self._duration = ef_duration(element, config) - # The clock this run corrects, read before the acquisition rather than after it. - self._current_f12 = float(read_path(element, "clock_freqs.f12")) - # On the instrument's 1 ns grid. A linear sweep between two round numbers - # generally is not — 41 points from 4 ns to 2 us step 49.9 ns — and the - # compiler rejects a schedule whose operations do not land on it, some way - # from the sweep that asked for them. - # Squeezed from both ends, like `ramsey`'s, and measured rather than guessed. - # - # Long enough to *contain* the decay. The fit refuses a T2* the window never - # saw, and rightly: a 2 us sweep against this coherence returned 1.4 seconds. - # A 12 us one then fitted 15.5 us — accepted by the fit, but extrapolated past - # its own window, which is a number to distrust. The 1-2 coherence here runs - # about 15 us, so 30 us holds two time constants of it. - # - # Fine enough for the fringe: 125 ns steps put Nyquist at 4 MHz, well clear of - # the 1 MHz advance below. - self._delays = [ + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """Exactly, since the axis is delay — one timeline for the whole group.""" + return grouped_by_grid(targets, lambda _target: self._delays(config)) + + def _delays(self, config: RoutineConfig) -> list[float]: + """The EF Ramsey delays, on the instrument's 1 ns grid. + + A linear sweep between two round numbers generally is not on it — 41 points from + 4 ns to 2 us step 49.9 ns — and the compiler rejects a schedule whose operations do + not land on it, some way from the sweep that asked for them. + + Squeezed from both ends, and measured rather than guessed. Long enough to *contain* + the decay: the fit refuses a T2* the window never saw, and rightly — a 2 us sweep + returned 1.4 seconds, and a 12 us one fitted 15.5 us, accepted by the fit but + extrapolated past its own window. The 1-2 coherence runs about 15 us, so 30 us holds + two time constants. Fine enough for the fringe: 125 ns steps put Nyquist at 4 MHz, + well clear of the 1 MHz advance below. + """ + return [ grid_duration(delay) for delay in setpoints_of( config, "delays", linear_setpoints(4e-9, 30e-6, 241) ) ] - self._detuning = float(config.get("artificial_detuning", 1e6)) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The EF fringe on every target at once, each on its own detuned ``.12`` clock.""" + delays = self._delays(config) + detuning = float(config.get("artificial_detuning", 1e6)) + halves = {} + for target in targets: + element = device.get_element(target) + sweeps[target]["delays"] = delays + sweeps[target]["detuning"] = detuning + # Half the pi amplitude is half the rotation, at fixed duration. + sweeps[target]["half"] = _required_ef_amplitude(element, target) / 2.0 + sweeps[target]["duration"] = ef_duration(element, config) + # The clock this run corrects, read before the acquisition rather than after. + sweeps[target]["current_f12"] = float(read_path(element, "clock_freqs.f12")) + halves[target] = ( + sweeps[target]["half"], + sweeps[target]["duration"], + ) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - measure = open_three_state_readout(schedule, backend, target, element) - # The clock is detuned rather than the second pulse phase-advanced, which is - # the opposite of what `ramsey` does one rung down and is not a preference. - # A phase advance is a `ShiftClockPhase`, and on the ``.12`` clock it produced - # no fringe at all: the fitted detuning came back at minus the artificial one - # whatever the device's f12 was set to, so the sweep was measuring nothing. - # Detuning the clock does work — the fringe tracks the offset — and it is what - # the routine's own frame offset is built from. - current = float(read_path(element, "clock_freqs.f12")) - schedule.add( - backend.SetClockFrequency( - clock=f"{target}.12", clock_freq_new=current + self._detuning + measure = { + target: open_three_state_readout( + schedule, backend, target, device.get_element(target) ) + for target in targets + } + # The clock is detuned rather than the second pulse phase-advanced, which is the + # opposite of what `ramsey` does one rung down and is not a preference. A phase + # advance is a `ShiftClockPhase`, and on the ``.12`` clock it produced no fringe at + # all: the fitted detuning came back at minus the artificial one whatever the + # device's f12 was set to, so the sweep was measuring nothing. Detuning the clock + # does work, and it is what the routine's own frame offset is built from. + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=f"{target}.12", + clock_freq_new=sweeps[target]["current_f12"] + detuning, + ) + for target in targets + ], ) - for index, delay in enumerate(self._delays): - schedule.add(backend.Reset(target)) - schedule.add(backend.X(target)) - add_ef_pulse(schedule, backend, target, self._half, self._duration) + for index, delay in enumerate(delays): + add_together(schedule, [backend.Reset(t) for t in targets]) + add_together(schedule, [backend.X(t) for t in targets]) + add_ef_pulses(schedule, backend, targets, halves) backend.idle(schedule, delay) - add_ef_pulse(schedule, backend, target, self._half, self._duration) - schedule.add( - backend.Measure( - target, - acq_index=index, - bin_mode=backend.BinMode.AVERAGE, - **measure, - ) + anchor = add_ef_pulses(schedule, backend, targets, halves) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + **measure[target], + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: fitted = fit_ramsey( - np.asarray(self._delays), signal_of(dataset), self._detuning + np.asarray(sweep["delays"]), signal_of(dataset), sweep["detuning"] ) - fitted["clock_freq_12"] = self._current_f12 - fitted["detuning"] + fitted["clock_freq_12"] = sweep["current_f12"] - fitted["detuning"] return fitted def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: @@ -1194,6 +1508,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -1204,69 +1519,115 @@ def measure( came out at 0.298 against a range of +/-0.2, so the node refused a fit that had found its answer. """ - return self.escalating(target, device, config, backend, timeout_s) + return self.escalating(target, device, config, backend, timeout_s, sweep) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The same widening, for the targets whose optimum fell outside the sweep.""" + return self.escalating_group( + targets, device, config, backend, timeout_s, sweeps + ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = device.get_element(target) - amplitude = _required_ef_amplitude(element, target) - duration = ef_duration(element, config) - # In the backend's own units, for the reason `drag` gives: a span sized for - # quantify's ratio is nine orders out for qblox's seconds. - self._drags = setpoints_of( + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Both sequences on every target at once, two acquisitions per setpoint. + + The DRAG span is the backend's own, so it is one grid for the group and it never + splits. The pulse amplitude and duration are per target; the phase and the DRAG + coefficient are the swept axis, so they are shared. + """ + drags = setpoints_of( config, "drags", linear_setpoints(-backend.drag_span, backend.drag_span, 31) ) + pulses = {} + for target in targets: + element = device.get_element(target) + sweeps[target]["drags"] = drags + pulses[target] = ( + _required_ef_amplitude(element, target), + ef_duration(element, config), + ) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - measure = open_three_state_readout(schedule, backend, target, element) - for index, drag in enumerate(self._drags): + measure = { + target: open_three_state_readout( + schedule, backend, target, device.get_element(target) + ) + for target in targets + } + halves = {t: (amp / 2.0, dur) for t, (amp, dur) in pulses.items()} + for index, drag in enumerate(drags): for offset, (first, second) in enumerate(((0.0, 90.0), (90.0, 0.0))): - schedule.add(backend.Reset(target)) - schedule.add(backend.X(target)) - add_ef_pulse( - schedule, - backend, - target, - amplitude / 2.0, - duration, - phase_deg=first, - drag=drag, + add_together(schedule, [backend.Reset(t) for t in targets]) + add_together(schedule, [backend.X(t) for t in targets]) + add_ef_pulses( + schedule, backend, targets, halves, phase_deg=first, drag=drag ) - add_ef_pulse( - schedule, - backend, - target, - amplitude, - duration, - phase_deg=second, - drag=drag, + anchor = add_ef_pulses( + schedule, backend, targets, pulses, phase_deg=second, drag=drag ) - schedule.add( - backend.Measure( - target, - acq_index=2 * index + offset, - bin_mode=backend.BinMode.AVERAGE, - **measure, - ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=2 * index + offset, + bin_mode=backend.BinMode.AVERAGE, + **measure[target], + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - if signal.size < 2 * len(self._drags): + if signal.size < 2 * len(sweep["drags"]): raise RoutineError( - f"drag_12 expected {2 * len(self._drags)} acquisitions, got {signal.size}" + f"drag_12 expected {2 * len(sweep['drags'])} acquisitions, got {signal.size}" ) - paired = signal[: 2 * len(self._drags)].reshape(-1, 2) + paired = signal[: 2 * len(sweep["drags"])].reshape(-1, 2) element = device.get_element(target) path = ef_path(element, "ef_motzoi") fitted = fit_drag( - np.asarray(self._drags), + np.asarray(sweep["drags"]), paired[:, 0] - paired[:, 1], axis="drags", current=float(read_path(element, path)) if path else 0.0, @@ -1315,36 +1676,80 @@ def applies_to(self, device: Any, target: str) -> bool: return has_three_state_readout(device, target) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = device.get_element(target) - amplitude = _required_ef_amplitude(element, target) - duration = ef_duration(element, config) + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The three prepared states on every target at once. + Three acquisitions per target and no sweep, so the group never splits: what is + being counted is how often each prepared level is misread, and the levels are the + same three on every chip. + """ + ef = {} schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 2000)) ) - # At the three-state point, not the two-state one: there |1> and |2> collapse - # together and there is nothing to classify. - measure_kwargs = open_three_state_readout(schedule, backend, target, element) + measure_kwargs = {} + for target in targets: + element = device.get_element(target) + ef[target] = ( + _required_ef_amplitude(element, target), + ef_duration(element, config), + ) + # At the three-state point, not the two-state one: there |1> and |2> collapse + # together and there is nothing to classify. + measure_kwargs[target] = open_three_state_readout( + schedule, backend, target, element + ) for index, level in enumerate(self.STATES): - schedule.add(backend.Reset(target)) + anchor = add_together( + schedule, [backend.Reset(target) for target in targets] + ) if level >= 1: - schedule.add(backend.X(target)) - if level >= 2: - add_ef_pulse(schedule, backend, target, amplitude, duration) - schedule.add( - backend.Measure( - target, - acq_index=index, - bin_mode=backend.BinMode.APPEND, - **measure_kwargs, + anchor = add_together( + schedule, [backend.X(target) for target in targets] ) + if level >= 2: + anchor = add_ef_pulses(schedule, backend, targets, ef) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.APPEND, + **measure_kwargs[target], + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: return fit_three_state_discrimination( _prepared_clouds(dataset, len(self.STATES)) diff --git a/qpi-driver/py/qpi_driver/tuners/routines/readout.py b/qpi-driver/py/qpi_driver/tuners/routines/readout.py index c460c95e..baa34d1d 100644 --- a/qpi-driver/py/qpi_driver/tuners/routines/readout.py +++ b/qpi-driver/py/qpi_driver/tuners/routines/readout.py @@ -19,12 +19,20 @@ from typing import Any import math +from collections.abc import Mapping, Sequence import numpy as np import xarray as xr from qpi_driver.tuners.base.backend import SchedulerBackend from qpi_driver.tuners.base.config import RoutineConfig +from qpi_driver.tuners.base.fusion import ( + add_after, + add_together, + channels_of, + grouped_by_grid, + grouped_by_size, +) from qpi_driver.tuners.base.device import ( measured_linewidth, read_path, @@ -38,6 +46,7 @@ linear_setpoints, setpoints_of, ) +from qpi_driver.tuners.base.sweep import Sweep from qpi_driver.tuners.fitting import ( fit_readout_discrimination, fit_readout_integration_time, @@ -124,30 +133,79 @@ def applies_to(self, device: Any, target: str) -> bool: MAX_SINGLE_SHOT_ACQUISITIONS = 32 def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = device.get_element(target) - self._settings = self._grid(element, config) - shots = int(config.get("shots", 300)) - schedule = backend.new_schedule(self.name, repetitions=shots) + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By grid size: each point is this qubit's own frequency and drive amplitude.""" + return grouped_by_size( + targets, lambda t: self._grid(device.get_element(t), config) + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Both prepared states at every setting, for every target at once. + + Single-shot, so the acquisitions are the measurement: each target's spread is the + noise its separation is quoted in. The frequency and the amplitude are both + per-target hardware, so each sweeps its own grid over a shared index. + """ + grids = {} + for target in targets: + grids[target] = self._grid(device.get_element(target), config) + sweeps[target]["settings"] = grids[target] + schedule = backend.new_schedule( + self.name, repetitions=int(config.get("shots", 300)) + ) index = 0 - for frequency, amplitude in self._settings: - schedule.add( - backend.SetClockFrequency( - clock=f"{target}.ro", clock_freq_new=frequency - ) + for position in range(len(grids[targets[0]])): + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=f"{target}.ro", + clock_freq_new=grids[target][position][0], + ) + for target in targets + ], ) for prepare in (0, 1): - schedule.add(backend.Reset(target)) + anchor = add_together( + schedule, [backend.Reset(target) for target in targets] + ) if prepare: - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, - acq_index=index, - bin_mode=backend.BinMode.APPEND, - pulse_amp=amplitude, + anchor = add_together( + schedule, [backend.X(target) for target in targets] ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.APPEND, + pulse_amp=grids[target][position][1], + ) + for channel, target in enumerate(targets) + ], + anchor, ) index += 1 return schedule @@ -188,10 +246,15 @@ def _grid(self, element: Any, config: RoutineConfig) -> list[tuple[float, float] return grid def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - ground, excited = _swept_clouds(dataset, len(self._settings)) - return fit_readout_operating_point(self._settings, ground, excited) + ground, excited = _swept_clouds(dataset, len(sweep["settings"])) + return fit_readout_operating_point(sweep["settings"], ground, excited) def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: element = device.get_element(target) @@ -293,6 +356,7 @@ def acquire( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, ) -> Any: """One schedule per window, because a schedule may only have one of them. @@ -312,7 +376,7 @@ def acquire( enabled=config.enabled, params={**config.params, "windows": [window]}, ) - dataset = super().acquire(target, device, single, backend, timeout_s) + dataset = super().acquire(target, device, single, backend, timeout_s, sweep) # ``(shots, 2)`` — the two prepared states of this one window. Kept 2-D, # because the shots *are* the measurement here: their spread is the noise the # separation is quoted in, and flattening them reads as one shot per state. @@ -323,7 +387,7 @@ def acquire( f"expected |0> and |1>" ) rows.append(values[..., :2]) - self._windows = windows + sweep["windows"] = windows # Side by side, so the acquisition axis unpacks as |0>,|1> per window — the same # interleaving `_swept_clouds` expects from a single-schedule sweep. return xr.Dataset( @@ -331,28 +395,122 @@ def acquire( ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: """``|0>`` and ``|1>`` at *one* window — see :meth:`acquire` for why only one.""" - windows = self._grid(device.get_element(target), config) - self._windows = windows[:1] + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """Exactly, because an integration length is shared hardware. + + Every square acquisition compiled into one Qblox program shares it — see + :meth:`acquire` — so the group cannot hold two targets wanting different windows any + more than one schedule can hold two windows. `grouped_by_grid`, not + `grouped_by_size`. + """ + return grouped_by_grid( + targets, lambda t: self._grid(device.get_element(t), config) + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """``|0>`` and ``|1>`` at one window, on every target at once.""" + windows = self._grid(device.get_element(targets[0]), config) + for target in targets: + sweeps[target]["windows"] = windows[:1] schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 300)) ) for index, prepare in enumerate((0, 1)): - schedule.add(backend.Reset(target)) + anchor = add_together( + schedule, [backend.Reset(target) for target in targets] + ) if prepare: - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, - acq_index=index, - bin_mode=backend.BinMode.APPEND, - acq_duration=self._windows[0], + anchor = add_together( + schedule, [backend.X(target) for target in targets] ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.APPEND, + acq_duration=windows[0], + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule + def acquire_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + ) -> Any: + """One schedule per window for the whole group — see :meth:`acquire`. + + The windows are the group's, not each target's: `compatible_groups` has already + split any target wanting a different grid, because the integration length is one + property of the program rather than one per target. + """ + windows = self._grid(device.get_element(targets[0]), config) + rows: dict[str, list[Any]] = {target: [] for target in targets} + for window in windows: + single = RoutineConfig( + enabled=config.enabled, + params={**config.params, "windows": [window]}, + ) + dataset = super().acquire_group( + targets, device, single, backend, timeout_s, sweeps + ) + sliced = channels_of(dataset, targets) + for target in targets: + piece = sliced.get(target) + if piece is None: + raise RoutineError( + f"the fused acquisition carried no channel for {target}" + ) + values = np.atleast_2d(_acquisition_values(piece)) + if values.shape[-1] < 2: + raise RoutineError( + f"window {window:.4g} s returned {values.shape[-1]} acquisitions " + f"for {target}, expected |0> and |1>" + ) + rows[target].append(values[..., :2]) + for target in targets: + sweeps[target]["windows"] = windows + return xr.Dataset( + { + channel: ( + ("shot", "acq_index"), + np.concatenate(rows[target], axis=-1), + ) + for channel, target in enumerate(targets) + } + ) + def _grid(self, element: Any, config: RoutineConfig) -> list[float]: ceiling = float( config.get("max_integration_time", self._usable_window(element)) @@ -427,11 +585,16 @@ def _usable_window(self, element: Any) -> float: return min(instrument, driven + ringdown) def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - ground, excited = _swept_clouds(dataset, len(self._windows)) + ground, excited = _swept_clouds(dataset, len(sweep["windows"])) return fit_readout_integration_time( - self._windows, + sweep["windows"], ground, excited, incumbent=float( @@ -470,42 +633,76 @@ class ReadoutDiscrimination(CalibrationRoutine): "rxy.amp180", ) - def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], ) -> Any: + """|0> and |1> on every target at once — the preparation is the same on each.""" shots = int(config.get("shots", 2000)) schedule = backend.new_schedule(f"{self.name}", repetitions=shots) - point = _operating_point(device.get_element(target)) - if point: - # At the point that will be used, not at the one the calibration reads on. - # A line fitted where the clouds are not is a line fitted somewhere else, - # which is the whole reason this depends on `readout_operating_point`. - schedule.add( - backend.SetClockFrequency( - clock=f"{target}.ro", clock_freq_new=point["frequency"] + points = { + target: _operating_point(device.get_element(target)) for target in targets + } + for target, point in points.items(): + if point: + # At the point that will be used, not at the one the calibration reads + # on. A line fitted where the clouds are not is a line fitted somewhere + # else, which is the whole reason this depends on + # `readout_operating_point`. + schedule.add( + backend.SetClockFrequency( + clock=f"{target}.ro", clock_freq_new=point["frequency"] + ) ) - ) - measure_kwargs = {"pulse_amp": point["pulse_amp"]} if point else {} # Single shots, not an average: the whole measurement is the *distribution* of - # each cloud, and its width is what sets the threshold and the fidelity. An - # averaged acquisition gives two points and no way to say how often they are - # confused. + # each cloud, and its width is what sets the threshold and the fidelity. for index, prepare in enumerate((0, 1)): - schedule.add(backend.Reset(target)) + anchor = add_together(schedule, [backend.Reset(t) for t in targets]) if prepare: - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, - acq_index=index, - bin_mode=backend.BinMode.APPEND, - **measure_kwargs, - ) + anchor = add_after(schedule, [backend.X(t) for t in targets], anchor) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.APPEND, + **( + {"pulse_amp": points[target]["pulse_amp"]} + if points[target] + else {} + ), + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule + def build_schedule( + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, + ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: ground, excited = _shot_clouds(dataset) return fit_readout_discrimination(ground, excited) @@ -530,7 +727,12 @@ def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: CHECK_MIN_FIDELITY = 0.95 def build_check_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: """The same experiment with fewer shots — the one case where that is right. @@ -545,10 +747,16 @@ def build_check_schedule( device, RoutineConfig(params={"shots": int(config.get("check_shots", 400))}), backend, + sweep, ) def analyse_check( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> CheckOutcome: ground, excited = _shot_clouds(dataset) fitted = fit_readout_discrimination(ground, excited) @@ -619,38 +827,75 @@ class ReadoutFidelity(CalibrationRoutine): "rxy.amp180", ) - def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], ) -> Any: + """|0> and |1> on every target at once — the preparation is the same on each.""" shots = int(config.get("shots", 2000)) schedule = backend.new_schedule(self.name, repetitions=shots) - point = _operating_point(device.get_element(target)) - if point: - # Where the discriminator was fitted, which is where the shots it grades - # will be taken. Reading anywhere else measures a line against clouds it - # was not drawn for. - schedule.add( - backend.SetClockFrequency( - clock=f"{target}.ro", clock_freq_new=point["frequency"] + points = { + target: _operating_point(device.get_element(target)) for target in targets + } + for target, point in points.items(): + if point: + # Where the discriminator was fitted, which is where the shots it + # grades will be taken. Reading anywhere else measures a line against + # clouds it was not drawn for. + schedule.add( + backend.SetClockFrequency( + clock=f"{target}.ro", clock_freq_new=point["frequency"] + ) ) - ) - measure_kwargs = {"pulse_amp": point["pulse_amp"]} if point else {} + # Single shots, not an average: the whole measurement is the *distribution* of + # each cloud, and its width is what sets the threshold and the fidelity. for index, prepare in enumerate((0, 1)): - schedule.add(backend.Reset(target)) + anchor = add_together(schedule, [backend.Reset(t) for t in targets]) if prepare: - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, - acq_index=index, - bin_mode=backend.BinMode.APPEND, - **measure_kwargs, - ) + anchor = add_after(schedule, [backend.X(t) for t in targets], anchor) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.APPEND, + **( + {"pulse_amp": points[target]["pulse_amp"]} + if points[target] + else {} + ), + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule + def build_schedule( + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, + ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: ground, excited = _shot_clouds(dataset) fitted = fit_readout_discrimination(ground, excited) diff --git a/qpi-driver/py/qpi_driver/tuners/routines/single_qubit.py b/qpi-driver/py/qpi_driver/tuners/routines/single_qubit.py index 93e2b1d2..ec8a8a38 100644 --- a/qpi-driver/py/qpi_driver/tuners/routines/single_qubit.py +++ b/qpi-driver/py/qpi_driver/tuners/routines/single_qubit.py @@ -7,12 +7,19 @@ from typing import Any import logging +from collections.abc import Mapping, Sequence import math import numpy as np import xarray as xr from qpi_driver.tuners.base.backend import SchedulerBackend +from qpi_driver.tuners.base.fusion import ( + add_after, + add_together, + grouped_by_grid, + readouts, +) from qpi_driver.tuners.base.config import RoutineConfig from qpi_driver.executors.base.rotations import ( QUARTER_TURN_DEGREES, @@ -37,7 +44,9 @@ linear_setpoints, setpoints_of, ) +from qpi_driver.tuners.base.sweep import Sweep from qpi_driver.tuners.fitting import ( + FitError, fit_drag, fit_fine_amplitude, fit_rabi, @@ -190,14 +199,53 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: """Reach further when the fit says the pi pulse was above the sweep.""" - return self.escalating(target, device, config, backend, timeout_s) + return self.escalating(target, device, config, backend, timeout_s, sweep) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The same reaching, widened only for the targets whose pi pulse was above it.""" + return self.escalating_group( + targets, device, config, backend, timeout_s, sweeps + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """Targets whose amplitude grid agrees — the ceiling is the element's own.""" + return grouped_by_grid(targets, lambda t: self._amplitudes(device, t, config)) + + def _amplitudes( + self, device: Any, target: str, config: RoutineConfig + ) -> list[float]: + """This target's amplitude grid, half scale by default. See `build_group_schedule`.""" + return setpoints_of( + config, + "amplitudes", + linear_setpoints( + 0.0, 0.5 * full_scale(device.get_element(target), "rxy.amp180"), 41 + ), + ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: # Half scale by default, and *escalating* to full scale rather than starting # there. Both bounds are real and they pull against each other. @@ -218,29 +266,61 @@ def build_schedule( # a waveform past it clips. # Recorded for `_widened` to clamp against, under the same `_` convention it # already reads setpoints by. Without it escalation walks straight past full scale. - self._amplitudes_ceiling = full_scale(device.get_element(target), "rxy.amp180") - self._amplitudes = setpoints_of( - config, - "amplitudes", - linear_setpoints(0.0, 0.5 * self._amplitudes_ceiling, 41), + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """One amplitude sweep driving every target at once, read out per channel.""" + amplitudes = self._amplitudes(device, targets[0], config) + for target in targets: + sweeps[target]["amplitudes_ceiling"] = full_scale( + device.get_element(target), "rxy.amp180" + ) + sweeps[target]["amplitudes"] = amplitudes schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - for index, amplitude in enumerate(self._amplitudes): - schedule.add(backend.Reset(target)) - schedule.add(backend.Rxy(theta=180, phi=0, qubit=target, amp180=amplitude)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + for index, amplitude in enumerate(amplitudes): + add_together(schedule, [backend.Reset(t) for t in targets]) + anchor = add_together( + schedule, + [ + backend.Rxy(theta=180, phi=0, qubit=t, amp180=amplitude) + for t in targets + ], + ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - return fit_rabi(np.asarray(self._amplitudes), signal_of(dataset)) + return fit_rabi(np.asarray(sweep["amplitudes"]), signal_of(dataset)) def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: element = device.get_element(target) @@ -262,7 +342,12 @@ def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: CHECK_MAX_ROTATION_ERROR = 0.05 def build_check_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: """Amplify any error in the stored `amp180` over a few repetitions. @@ -297,11 +382,16 @@ def build_check_schedule( schedule.add( backend.Measure(target, acq_index=2, bin_mode=backend.BinMode.AVERAGE) ) - self._check_repetitions = repetitions + sweep["check_repetitions"] = repetitions return schedule def analyse_check( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> CheckOutcome: """Recover the per-pulse rotation error from the amplified sequence. @@ -326,7 +416,7 @@ def analyse_check( # On the ground-to-excited axis, so a change in readout gain cancels. fraction = (amplified - ground) / contrast - repetitions = getattr(self, "_check_repetitions", self.CHECK_REPETITIONS) + repetitions = sweep.get("check_repetitions", self.CHECK_REPETITIONS) deviation = min(abs(2.0 * (fraction - 0.5)), 1.0) error = float(np.arcsin(deviation) / max(repetitions, 1)) @@ -364,6 +454,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -389,23 +480,23 @@ def measure( full `escalating` call, so a window too short for this chip is still widened by the guard that already knows how. """ - refined = self.escalating(target, device, config, backend, timeout_s) + refined = self.escalating(target, device, config, backend, timeout_s, sweep) # Zero when nothing has built a schedule yet, and then there is no bias to compare # against and no sign to resolve — the same reading `_detuning_floor` gives an # unswept `_delays`. - artificial = float(getattr(self, "_detuning", 0.0) or 0.0) + artificial = float(sweep.get("detuning", 0.0) or 0.0) if artificial and abs(float(refined.get("detuning", 0.0))) >= artificial: refined = self._resolved_root( - target, device, config, backend, timeout_s, refined + target, device, config, backend, timeout_s, sweep, refined ) - floor = self._detuning_floor(config) + floor = self._detuning_floor(config, sweep) for _attempt in range(self.MAX_REFINEMENTS): if abs(float(refined.get("detuning", 0.0))) <= floor: break # Applied here so the next pass drives at the corrected frequency, which is the # whole mechanism. The DAG applies again afterwards, and a write is idempotent. self.apply(device, target, refined) - again = self.escalating(target, device, config, backend, timeout_s) + again = self.escalating(target, device, config, backend, timeout_s, sweep) if abs(float(again.get("detuning", 0.0))) >= abs( float(refined.get("detuning", 0.0)) ): @@ -421,6 +512,100 @@ def measure( refined = again return refined + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The refinement loop over a group. The passes are per qubit; the qubits are not. + + Every branch of :meth:`measure` reads one qubit: its own residual, its own floor + derived from its own window, its own fringe sign, and its own write-back. What + differs between qubits is only *how many passes* each needs, which is the shape + `escalating_group` already handles — run the pass for the group, then the next pass + for the subset still above its floor. + + Two things stay per target. The sign resolution applies to the device between its + own two sweeps, so those cannot be shared; and it only runs for a qubit whose + residual leaves the sign in doubt, which is the uncommon case. + """ + results: dict[str, dict[str, Any] | Exception] = {} + refined: dict[str, dict[str, Any]] = {} + first = self.escalating_group( + targets, device, config, backend, timeout_s, sweeps + ) + for target, outcome in first.items(): + if isinstance(outcome, Exception): + results[target] = outcome + else: + refined[target] = outcome + + for target in list(refined): + artificial = float(sweeps[target].get("detuning", 0.0) or 0.0) + if artificial and abs(float(refined[target].get("detuning", 0.0))) >= ( + artificial + ): + try: + refined[target] = self._resolved_root( + target, + device, + config, + backend, + timeout_s, + sweeps[target], + refined[target], + ) + except (RoutineError, FitError) as exc: + results[target] = exc + del refined[target] + + # A qubit leaves the loop when its residual is under its own floor, when another + # pass stopped improving it, or when a pass refused. Tracked rather than recomputed + # so a qubit that stopped falling is not measured again on the next attempt — which + # is what `break` does in the single-target loop. + settled: set[str] = set() + for _attempt in range(self.MAX_REFINEMENTS): + again = [ + target + for target in refined + if target not in settled + and abs(float(refined[target].get("detuning", 0.0))) + > self._detuning_floor(config, sweeps[target]) + ] + if not again: + break + for target in again: + self.apply(device, target, refined[target]) + passes = self.escalating_group( + again, device, config, backend, timeout_s, sweeps + ) + for target, outcome in passes.items(): + if isinstance(outcome, Exception): + # The previous pass's value stands: it is still the best available, and + # refusing here would discard a measurement over a failed refinement. + settled.add(target) + continue + before = abs(float(refined[target].get("detuning", 0.0))) + after = abs(float(outcome.get("detuning", 0.0))) + if after >= before: + log.info( + "%s on %s: detuning stopped falling at %.0f Hz, keeping it", + self.name, + target, + before, + ) + settled.add(target) + continue + refined[target] = outcome + + results.update(refined) + return results + def _resolved_root( self, target: str, @@ -428,6 +613,7 @@ def _resolved_root( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, fitted: dict[str, Any], ) -> dict[str, Any]: """Which of the fringe's two roots is this chip's, measured rather than assumed. @@ -447,7 +633,9 @@ def _resolved_root( measured = [] for candidate in (fitted, others): self.apply(device, target, candidate) - measured.append(self.escalating(target, device, config, backend, timeout_s)) + measured.append( + self.escalating(target, device, config, backend, timeout_s, sweep) + ) best = min(measured, key=lambda pass_: abs(float(pass_.get("detuning", 0.0)))) log.info( "%s on %s: fringe %.0f Hz against a %.0f Hz artificial detuning leaves the " @@ -455,13 +643,13 @@ def _resolved_root( self.name, target, float(fitted.get("fringe_frequency", 0.0)), - self._detuning, + sweep["detuning"], " and ".join(f"{abs(float(m.get('detuning', 0.0))):.0f}" for m in measured), abs(float(best.get("detuning", 0.0))), ) return best - def _detuning_floor(self, config: RoutineConfig) -> float: + def _detuning_floor(self, config: RoutineConfig, sweep: Sweep) -> float: """The smallest detuning this sweep could tell from zero, in Hz. A fringe frequency fitted over a window ``T`` is resolved to about ``1/(2*pi*T)``, @@ -470,14 +658,19 @@ def _detuning_floor(self, config: RoutineConfig) -> float: which is the same reasoning `_confirm_points` uses: their sweep is their statement about the resolution their chip needs. """ - delays = [float(d) for d in getattr(self, "_delays", ()) or ()] + delays = [float(d) for d in sweep.get("delays", ()) or ()] if not delays: return 0.0 window = max(delays) - min(delays) return 1.0 / (2.0 * math.pi * window) if window > 0 else 0.0 def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: # 601 points from 4 ns to 24 us, and both ends are load-bearing — this sweep has # to satisfy two constraints at once, which is why it cannot be small. @@ -502,48 +695,77 @@ def build_schedule( # On the grid, as `ramsey_12` does: a delay that is not a whole number of # nanoseconds does not compile. Gridded here rather than on the way into the # schedule because `analyse` fits against these same numbers. - self._delays = [ + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """One fringe on every target at once, each read out on its own channel. + + The delays are one grid for the group — an idle is dead time on every port — and so + is the artificial detuning, which is a choice rather than a property of a qubit. So + the phase advance is the same on each and the group never splits. What differs per + target is only the ``f01`` the fringe is measured against, which `analyse` reads + from that target's own sweep. + """ + delays = [ grid_duration(delay) for delay in setpoints_of( config, "delays", linear_setpoints(4e-9, 24e-6, 601) ) ] - self._detuning = float(config.get("artificial_detuning", 1e6)) - # The clock this run corrects, read before the acquisition rather than after it. - self._current_f01 = float( - read_path(device.get_element(target), "clock_freqs.f01") - ) + detuning = float(config.get("artificial_detuning", 1e6)) + for target in targets: + sweeps[target]["delays"] = delays + sweeps[target]["detuning"] = detuning + # The clock this run corrects, read before the acquisition rather than after. + sweeps[target]["current_f01"] = float( + read_path(device.get_element(target), "clock_freqs.f01") + ) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - for index, delay in enumerate(self._delays): - # The second π/2 is phase-advanced rather than the clock detuned, so - # the fringe is deliberate and its direction known. - phase = 360.0 * self._detuning * delay - schedule.add(backend.Reset(target)) - schedule.add(backend.Rxy(theta=90, phi=0, qubit=target)) + for index, delay in enumerate(delays): + # The second pi/2 is phase-advanced rather than the clock detuned, so the + # fringe is deliberate and its direction known. + phase = (360.0 * detuning * delay) % 360.0 + add_together(schedule, [backend.Reset(t) for t in targets]) + add_together( + schedule, [backend.Rxy(theta=90, phi=0, qubit=t) for t in targets] + ) backend.idle(schedule, delay) - schedule.add(backend.Rxy(theta=90, phi=phase % 360.0, qubit=target)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + anchor = add_together( + schedule, + [backend.Rxy(theta=90, phi=phase, qubit=t) for t in targets], ) + add_after(schedule, readouts(backend, targets, index), anchor) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: fitted = fit_ramsey( - np.asarray(self._delays), signal_of(dataset), self._detuning + np.asarray(sweep["delays"]), signal_of(dataset), sweep["detuning"] ) - fitted["clock_freq_01"] = self._current_f01 - fitted["detuning"] + fitted["clock_freq_01"] = sweep["current_f01"] - fitted["detuning"] # A fringe frequency is a magnitude, so `-fringe - artificial` is the residual # just as consistently as `+fringe - artificial`. Carried alongside rather than # chosen here: which root is the chip's takes another sweep to find out, and # `_resolved_root` is where that happens. - fitted["clock_freq_01_alternative"] = self._current_f01 + ( - fitted["fringe_frequency"] + self._detuning + fitted["clock_freq_01_alternative"] = sweep["current_f01"] + ( + fitted["fringe_frequency"] + sweep["detuning"] ) return fitted @@ -569,6 +791,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -577,32 +800,84 @@ def measure( A window too short for this chip is the commonest way this node fails, and the guard already knows it — see `CalibrationRoutine.escalating`. """ - return self.escalating(target, device, config, backend, timeout_s) + return self.escalating(target, device, config, backend, timeout_s, sweep) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The same escalation over a group, widening only for the targets that refuse.""" + return self.escalating_group( + targets, device, config, backend, timeout_s, sweeps + ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, + ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], ) -> Any: - self._delays = setpoints_of( + """Excite every target, wait, and read them all out on their own channel. + + The delays are one grid for the group, which is what lets it be one schedule: an + idle is dead time on every port at once, so there is nothing per-target to sweep. + """ + delays = setpoints_of( config, "delays", linear_setpoints(0.0, DEFAULT_COHERENCE_WINDOW_S, 41) ) + for target in targets: + sweeps[target]["delays"] = delays schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - for index, delay in enumerate(self._delays): - schedule.add(backend.Reset(target)) - schedule.add(backend.X(target)) + for index, delay in enumerate(delays): + add_together(schedule, [backend.Reset(t) for t in targets]) + anchor = add_together(schedule, [backend.X(t) for t in targets]) backend.idle(schedule, delay) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - return fit_t1(np.asarray(self._delays), signal_of(dataset)) + return fit_t1(np.asarray(sweep["delays"]), signal_of(dataset)) def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: """Keep T1 where `t2_echo` can find it, when the element has somewhere for it. @@ -632,6 +907,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -640,24 +916,44 @@ def measure( A window too short for this chip is the commonest way this node fails, and the guard already knows it — see `CalibrationRoutine.escalating`. """ - return self.escalating(target, device, config, backend, timeout_s) + return self.escalating(target, device, config, backend, timeout_s, sweep) - def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend - ) -> Any: - # Snapped so that *half* a delay lands on the grid, because that is what `idle` - # is given. A window scaled from a measured T1 divides into steps of no particular - # length — 73.82 us of T1 gave 5536.857838 ns — and the schedule then compiles - # right up until qblox refuses a time value, in a routine that looks fine. - # Read by `_widened` under the `__ceiling` convention, so escalation cannot - # widen past what physics allows — see :data:`MAX_ECHO_WINDOW_IN_T1`. - t1 = measured_t1(device.get_element(target)) - self._delays_ceiling = ( - MAX_ECHO_WINDOW_IN_T1 * t1 - if t1 - else MAX_ECHO_WINDOW_IN_T1 * DEFAULT_COHERENCE_WINDOW_S / T2_WINDOW_IN_T1 + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The same escalation over a group, widening only for the targets that refuse.""" + return self.escalating_group( + targets, device, config, backend, timeout_s, sweeps ) - self._delays = [ + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """Targets whose windows agree, since an idle is dead time on every port at once. + + The window is scaled from each qubit's measured T1 (see :meth:`_window`), so a + chip whose qubits relax at different rates wants different sweeps — and there is + no per-target time axis to give them. Grouped by window, so the qubits that do + agree are still measured together. + """ + return grouped_by_grid(targets, lambda t: self._delays(device, t, config)) + + def _delays(self, device: Any, target: str, config: RoutineConfig) -> list[float]: + """This target's echo delays. + + Snapped so that *half* a delay lands on the grid, because that is what `idle` is + given. A window scaled from a measured T1 divides into steps of no particular + length — 73.82 us of T1 gave 5536.857838 ns — and the schedule then compiles right + up until qblox refuses a time value, in a routine that looks fine. + """ + return [ 2.0 * grid_duration(delay / 2.0) for delay in setpoints_of( config, @@ -665,28 +961,86 @@ def build_schedule( linear_setpoints(0.0, self._window(device, target), 41), ) ] + + def build_schedule( + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, + ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """One echo on every target at once, over the window they agree on. + + `compatible_groups` has already split the targets whose windows differ, so the + first target's grid is the group's. + """ + delays = self._delays(device, targets[0], config) + for target in targets: + # Read by `_widened` under the `_ceiling` convention, so escalation + # cannot widen past what physics allows — see `MAX_ECHO_WINDOW_IN_T1`. + t1 = measured_t1(device.get_element(target)) + sweeps[target]["delays_ceiling"] = ( + MAX_ECHO_WINDOW_IN_T1 * t1 + if t1 + else MAX_ECHO_WINDOW_IN_T1 + * DEFAULT_COHERENCE_WINDOW_S + / T2_WINDOW_IN_T1 + ) + sweeps[target]["delays"] = delays schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - for index, delay in enumerate(self._delays): - schedule.add(backend.Reset(target)) - schedule.add(backend.Rxy(theta=90, phi=0, qubit=target)) + for index, delay in enumerate(delays): + add_together(schedule, [backend.Reset(t) for t in targets]) + add_together( + schedule, [backend.Rxy(theta=90, phi=0, qubit=t) for t in targets] + ) backend.idle(schedule, delay / 2) - schedule.add(backend.Rxy(theta=180, phi=0, qubit=target)) + add_together( + schedule, [backend.Rxy(theta=180, phi=0, qubit=t) for t in targets] + ) backend.idle(schedule, delay / 2) - schedule.add(backend.Rxy(theta=90, phi=0, qubit=target)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + anchor = add_together( + schedule, [backend.Rxy(theta=90, phi=0, qubit=t) for t in targets] + ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: return fit_t2( - np.asarray(self._delays), + np.asarray(sweep["delays"]), signal_of(dataset), t1=measured_t1(device.get_element(target)), ) @@ -723,6 +1077,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -734,10 +1089,30 @@ def measure( -0.4803 against a range of +/-0.2, so the node refused a fit that had found its answer — and everything downstream of `drag` then ran on an uncorrected pulse. """ - return self.escalating(target, device, config, backend, timeout_s) + return self.escalating(target, device, config, backend, timeout_s, sweep) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The same widening, for the targets whose optimum fell outside the sweep.""" + return self.escalating_group( + targets, device, config, backend, timeout_s, sweeps + ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: # The DRAG parameter's *units differ between the two schedulers*, so the # default sweep cannot be a constant here — see `SchedulerBackend.drag_span`. @@ -746,51 +1121,83 @@ def build_schedule( # one is nine orders of magnitude wrong for the other, and being wrong in # the large direction does not merely mis-fit: it pushes the derivative # term past full scale and the schedule stops compiling. - self._motzois = setpoints_of( + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Both sequences on every target at once, two acquisitions per setpoint. + + The grid is the backend's own span either side of zero, so it is the same on every + target and the group never splits. + """ + motzois = setpoints_of( config, "motzois", linear_setpoints(-backend.drag_span, backend.drag_span, 31), ) + for target in targets: + sweeps[target]["motzois"] = motzois schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) # X90-Y180 against Y90-X180: the two sequences are equal only at the # right beta, so their difference crosses zero there and is linear about it. - for index, beta in enumerate(self._motzois): - schedule.add(backend.Reset(target)) + for index, beta in enumerate(motzois): override = {backend.drag_parameter: beta} - schedule.add(backend.Rxy(theta=90, phi=0, qubit=target, **override)) - schedule.add(backend.Rxy(theta=180, phi=90, qubit=target, **override)) - schedule.add( - backend.Measure( - target, acq_index=2 * index, bin_mode=backend.BinMode.AVERAGE + for offset, pair in enumerate((((90, 0), (180, 90)), ((90, 90), (180, 0)))): + add_together(schedule, [backend.Reset(t) for t in targets]) + anchor = None + for theta, phi in pair: + anchor = add_together( + schedule, + [ + backend.Rxy(theta=theta, phi=phi, qubit=t, **override) + for t in targets + ], + ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=2 * index + offset, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, target in enumerate(targets) + ], + anchor, ) - ) - schedule.add(backend.Reset(target)) - schedule.add(backend.Rxy(theta=90, phi=90, qubit=target, **override)) - schedule.add(backend.Rxy(theta=180, phi=0, qubit=target, **override)) - schedule.add( - backend.Measure( - target, acq_index=2 * index + 1, bin_mode=backend.BinMode.AVERAGE - ) - ) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - if signal.size < 2 * len(self._motzois): + if signal.size < 2 * len(sweep["motzois"]): raise RoutineError( - f"DRAG expected {2 * len(self._motzois)} acquisitions, got {signal.size}" + f"DRAG expected {2 * len(sweep['motzois'])} acquisitions, got {signal.size}" ) - paired = signal[: 2 * len(self._motzois)].reshape(-1, 2) + paired = signal[: 2 * len(sweep["motzois"])].reshape(-1, 2) # Named, so a refusal is escalatable rather than prose — see `measure`. element = device.get_element(target) # ``rxy.motzoi`` under quantify, ``rxy.beta`` under qblox — see `apply`. name = drag_parameter_name(element) return fit_drag( - np.asarray(self._motzois), + np.asarray(sweep["motzois"]), paired[:, 0] - paired[:, 1], axis="motzois", current=float(read_path(element, f"rxy.{name}")) if name else 0.0, @@ -814,26 +1221,61 @@ class AllXY(CalibrationRoutine): updates = () reads = ("clock_freqs.f01", "rxy.amp180") - def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], ) -> Any: + """The 21 pairs on every target at once — the sequence is the same on each.""" schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) for index, (first, second) in enumerate(ALLXY_PAIRS): - schedule.add(backend.Reset(target)) + anchor = add_together(schedule, [backend.Reset(t) for t in targets]) for theta, phi in (first, second): if theta: - schedule.add(backend.Rxy(theta=theta, phi=phi, qubit=target)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + anchor = add_after( + schedule, + [backend.Rxy(theta=theta, phi=phi, qubit=t) for t in targets], + anchor, + ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule + def build_schedule( + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, + ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) if signal.size < len(ALLXY_PAIRS): @@ -866,6 +1308,7 @@ def amplified( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, step: int, ) -> dict[str, Any]: """Run *routine*, shortening its repetitions if the rotation outran its own model. @@ -881,14 +1324,12 @@ def amplified( """ for attempt in range(MAX_SHORTENINGS + 1): try: - return routine.escalating(target, device, config, backend, timeout_s) + return routine.escalating(target, device, config, backend, timeout_s, sweep) except OutOfRange as refusal: counts = [ int(n) for n in ( - config.get("repetitions") - or getattr(routine, "_repetitions", ()) - or () + config.get("repetitions") or sweep.get("repetitions", ()) or () ) ] shorter = _shortened(counts, refusal.factor, step) @@ -916,7 +1357,7 @@ def amplified( target, refusal, ) - return routine.uncorrected(device, target) + return routine.uncorrected(device, target, sweep) log.info( "%s on %s: %s — repeating %d times instead of %d (%d of %d)", routine.name, @@ -936,6 +1377,96 @@ def amplified( ) +def _add_references( + schedule: Any, backend: SchedulerBackend, targets: Sequence[str], at: int +) -> None: + """Append the ``|0>`` and ``|1>`` calibration points a fine-amplitude fit needs.""" + for offset, excite in enumerate((False, True)): + anchor = add_together(schedule, [backend.Reset(t) for t in targets]) + if excite: + anchor = add_together(schedule, [backend.X(t) for t in targets]) + add_after(schedule, readouts(backend, targets, at + offset), anchor) + + +def amplified_group( + routine: CalibrationRoutine, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + step: int, +) -> dict[str, dict[str, Any] | Exception]: + """`amplified` over a group: shorten only the ladders that outran their own model. + + The group counterpart of `amplified`, and the same argument as + `CalibrationRoutine.escalating_group`: how far a ladder can be amplified depends on how + large the error turns out to be, so it is per target. Shortening the group would cut + the ladders that were fine, and a shorter ladder measures a smaller error less + precisely. + + A shortening therefore splits the group, and targets asking for the same ladder are + measured together. A target that runs out of ladder keeps its prior and reports, which + is what `amplified` does for the same reason: the finding is real and the correction + is not. + """ + outcomes: dict[str, dict[str, Any] | Exception] = {} + pending: list[tuple[RoutineConfig, list[str]]] = [(config, list(targets))] + + for attempt in range(MAX_SHORTENINGS + 1): + if not pending: + break + shortened_next: dict[str, tuple[RoutineConfig, list[str]]] = {} + for shared, subgroup in pending: + measured = routine.escalating_group( + subgroup, device, shared, backend, timeout_s, sweeps + ) + for target, result in measured.items(): + if not isinstance(result, OutOfRange) or result.direction != "shorter": + outcomes[target] = result + continue + counts = [ + int(n) + for n in ( + shared.get("repetitions") + or sweeps[target].get("repetitions", ()) + ) + ] + shorter = _shortened(counts, result.factor, step) if counts else [] + if attempt == MAX_SHORTENINGS or not shorter or shorter == counts: + log.warning( + "%s on %s: %s — keeping the existing amplitude rather than " + "correcting from a fit its own model does not describe", + routine.name, + target, + result, + ) + outcomes[target] = routine.uncorrected( + device, target, sweeps[target] + ) + continue + log.info( + "%s on %s: %s — repeating %d times instead of %d (%d of %d)", + routine.name, + target, + result, + max(shorter), + max(counts), + attempt + 1, + MAX_SHORTENINGS, + ) + narrower = RoutineConfig( + enabled=shared.enabled, + params={**shared.params, "repetitions": shorter}, + ) + slot = shortened_next.setdefault(repr(shorter), (narrower, [])) + slot[1].append(target) + pending = list(shortened_next.values()) + + return outcomes + + def _shortened(counts: list[int], factor: float, step: int) -> list[int]: """*counts* rebuilt no longer than *factor* of their reach, on the same ladder. @@ -973,6 +1504,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -981,59 +1513,89 @@ def measure( On the August 2026 B chip they turned 2.3 radians — a full swing of the sine, fitted as a straight line, and written to the amplitude every X pulse plays at. """ - return amplified(self, target, device, config, backend, timeout_s, step=1) + return amplified( + self, target, device, config, backend, timeout_s, sweep, step=1 + ) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The same shortening, applied only to the ladders that outran their model.""" + return amplified_group( + self, targets, device, config, backend, timeout_s, sweeps, step=1 + ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - self._repetitions = [ + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The amplified pi ladder on every target at once, read out per channel. + + The ladder is the same on every target, so the group never splits: the counts come + from the config or from a constant, not from the chip. + """ + repetitions = [ int(n) for n in setpoints_of(config, "repetitions", list(range(1, 26))) ] - # The amplitude this run refines, read before the acquisition rather than after - # it — it is what every X below is played at, so reading it later described a - # sweep that had already happened. - self._current_amp180 = float( - read_path(device.get_element(target), "rxy.amp180") - ) + for target in targets: + sweeps[target]["repetitions"] = repetitions + # The amplitude this run refines, read before the acquisition rather than + # after it — it is what every X below is played at, so reading it later + # described a sweep that had already happened. + sweeps[target]["current_amp180"] = float( + read_path(device.get_element(target), "rxy.amp180") + ) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - for index, count in enumerate(self._repetitions): - schedule.add(backend.Reset(target)) - schedule.add(backend.Rxy(theta=90, phi=0, qubit=target)) - for _ in range(count): - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + for index, count in enumerate(repetitions): + add_together(schedule, [backend.Reset(t) for t in targets]) + anchor = add_together( + schedule, [backend.Rxy(theta=90, phi=0, qubit=t) for t in targets] ) + for _ in range(count): + anchor = add_together(schedule, [backend.X(t) for t in targets]) + add_after(schedule, readouts(backend, targets, index), anchor) - # Two reference points, |0> and |1>, so the fit knows the full contrast. - # Without them only the product of contrast and rotation error is - # recoverable, and the error comes out scaled by whatever fraction of - # the contrast this sweep happened to cover. - reference = len(self._repetitions) - schedule.add(backend.Reset(target)) - schedule.add( - backend.Measure( - target, acq_index=reference, bin_mode=backend.BinMode.AVERAGE - ) - ) - schedule.add(backend.Reset(target)) - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, acq_index=reference + 1, bin_mode=backend.BinMode.AVERAGE - ) - ) + # Two reference points, |0> and |1>, so the fit knows the full contrast. Without + # them only the product of contrast and rotation error is recoverable, and the + # error comes out scaled by whatever fraction of the contrast this sweep covered. + _add_references(schedule, backend, targets, len(repetitions)) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - count = len(self._repetitions) + count = len(sweep["repetitions"]) if signal.size < count + 2: raise RoutineError( f"fine amplitude expected {count + 2} acquisitions " @@ -1041,15 +1603,15 @@ def analyse( ) fitted = fit_fine_amplitude( - np.asarray(self._repetitions, dtype=float), + np.asarray(sweep["repetitions"], dtype=float), signal[:count], - self._current_amp180, + sweep["current_amp180"], ground=float(signal[count]), excited=float(signal[count + 1]), ) return {"amp180": fitted["amplitude"], **fitted} - def uncorrected(self, device: Any, target: str) -> dict[str, Any]: + def uncorrected(self, device: Any, target: str, sweep: Sweep) -> dict[str, Any]: current = float(read_path(device.get_element(target), "rxy.amp180")) return { "amp180": current, @@ -1134,6 +1696,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -1149,20 +1712,20 @@ def measure( Bounded the same three ways as `ramsey`: by convergence, by the correction becoming smaller than the noise, and by `MAX_REFINEMENTS`. """ - refined = self._pass(target, device, config, backend, timeout_s) + refined = self._pass(target, device, config, backend, timeout_s, sweep) # Carry forward whatever the first pass settled on, so a sweep that had to be # shortened is not rediscovered — and paid for — on every pass after it. # `build_schedule` leaves the counts it used here. config = RoutineConfig( enabled=config.enabled, - params={**config.params, "repetitions": list(self._repetitions)}, + params={**config.params, "repetitions": list(sweep["repetitions"])}, ) for _attempt in range(self.MAX_REFINEMENTS): previous = float(refined["amp90"]) # Applied here so the next pass plays the corrected pi/2, which is the whole # mechanism. The DAG applies again afterwards, and a write is idempotent. self.apply(device, target, refined) - again = self._pass(target, device, config, backend, timeout_s) + again = self._pass(target, device, config, backend, timeout_s, sweep) moved = abs(float(again["amp90"]) - previous) / max(previous, 1e-12) refined = again if moved <= self.CONVERGED_FRACTION: @@ -1175,7 +1738,9 @@ def measure( ) return refined - def _pass(self, target, device, config, backend, timeout_s) -> dict[str, Any]: + def _pass( + self, target, device, config, backend, timeout_s, sweep + ) -> dict[str, Any]: """One refinement pass, shortened if the rotation outran the linearisation. Every fourth count, because only after ``4k+1`` quarter turns does the accumulated @@ -1189,63 +1754,89 @@ def _pass(self, target, device, config, backend, timeout_s) -> dict[str, Any]: # overran could only be cut to something `align` refuses. On 2 the same four points # become [1, 3, 5, 7] — 0.81 rad where 13 pulses gave 1.51, which is the difference # between refining this pulse and refusing it. - return amplified(self, target, device, config, backend, timeout_s, step=2) + return amplified( + self, target, device, config, backend, timeout_s, sweep, step=2 + ) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The same shortening, applied only to the ladders that outran their model.""" + return amplified_group( + self, targets, device, config, backend, timeout_s, sweeps, step=2 + ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, + ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], ) -> Any: - self._repetitions = [ + """The amplified pi/2 ladder on every target at once, read out per channel.""" + repetitions = [ int(n) for n in setpoints_of( config, "repetitions", list(DEFAULT_AMP90_REPETITIONS) ) ] - # What the compiler will actually play for a 90, which is not amp180/2 once this - # has run once. Read before the acquisition rather than after it, for the reason - # `fine_amplitude` states: it is the amplitude every pulse below is played at. - element = device.get_element(target) - self._current_amp90 = amplitude_for_angle( - QUARTER_TURN_DEGREES, - float(read_path(element, "rxy.amp180")), - float(read_path(element, AMP90_PATH) or 0.0), - ) - + for target in targets: + sweeps[target]["repetitions"] = repetitions + # What the compiler will actually play for a 90, which is not amp180/2 once + # this has run once. Read before the acquisition, for the reason + # `fine_amplitude` states: it is the amplitude every pulse below is played at. + element = device.get_element(target) + sweeps[target]["current_amp90"] = amplitude_for_angle( + QUARTER_TURN_DEGREES, + float(read_path(element, "rxy.amp180")), + float(read_path(element, AMP90_PATH) or 0.0), + ) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - for index, count in enumerate(self._repetitions): - schedule.add(backend.Reset(target)) + for index, count in enumerate(repetitions): + anchor = add_together(schedule, [backend.Reset(t) for t in targets]) for _ in range(count): - schedule.add(backend.Rxy(theta=90, phi=0, qubit=target)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE + anchor = add_together( + schedule, [backend.Rxy(theta=90, phi=0, qubit=t) for t in targets] ) - ) + add_after(schedule, readouts(backend, targets, index), anchor) - # |0> and |1>, so the fit knows the full contrast rather than whatever fraction - # of it this sweep reached. See `fit_fine_amplitude`. - reference = len(self._repetitions) - schedule.add(backend.Reset(target)) - schedule.add( - backend.Measure( - target, acq_index=reference, bin_mode=backend.BinMode.AVERAGE - ) - ) - schedule.add(backend.Reset(target)) - schedule.add(backend.X(target)) - schedule.add( - backend.Measure( - target, acq_index=reference + 1, bin_mode=backend.BinMode.AVERAGE - ) - ) + # |0> and |1>, so the fit knows the full contrast rather than whatever fraction of + # it this sweep reached. See `fit_fine_amplitude`. + _add_references(schedule, backend, targets, len(repetitions)) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - count = len(self._repetitions) + count = len(sweep["repetitions"]) if signal.size < count + 2: raise RoutineError( f"fine amplitude 90 expected {count + 2} acquisitions " @@ -1253,9 +1844,9 @@ def analyse( ) fitted = fit_fine_amplitude( - np.asarray(self._repetitions, dtype=float), + np.asarray(sweep["repetitions"], dtype=float), signal[:count], - self._current_amp90, + sweep["current_amp90"], ground=float(signal[count]), excited=float(signal[count + 1]), turn=math.pi / 2, @@ -1263,7 +1854,7 @@ def analyse( ) return {"amp90": fitted["amplitude"], **fitted} - def uncorrected(self, device: Any, target: str) -> dict[str, Any]: + def uncorrected(self, device: Any, target: str, sweep: Sweep) -> dict[str, Any]: current = float(read_path(device.get_element(target), AMP90_PATH)) return { "amp90": current, diff --git a/qpi-driver/py/qpi_driver/tuners/routines/spectroscopy.py b/qpi-driver/py/qpi_driver/tuners/routines/spectroscopy.py index bd7cb69b..1f6892d7 100644 --- a/qpi-driver/py/qpi_driver/tuners/routines/spectroscopy.py +++ b/qpi-driver/py/qpi_driver/tuners/routines/spectroscopy.py @@ -7,6 +7,7 @@ import logging import math +from collections.abc import Callable, Mapping, Sequence from typing import Any import numpy as np @@ -35,6 +36,13 @@ linear_setpoints, setpoints_of, ) +from qpi_driver.tuners.base.fusion import ( + add_after, + add_together, + grouped_by_size, + readouts, +) +from qpi_driver.tuners.base.sweep import Sweep from qpi_driver.tuners.fitting import ( FitError, fit_punchout, @@ -97,6 +105,42 @@ } +def sweep_readout_frequency( + schedule: Any, + backend: SchedulerBackend, + targets: Sequence[str], + bands: Mapping[str, Sequence[float]], + prepare: Callable[[Any, Sequence[str], Any], Any] | None = None, +) -> None: + """A readout-frequency sweep over a group, each target on its own clock and band. + + Shared by the three resonator spectroscopies and the two operating points, which differ + only in how they prepare the qubit before reading it: not at all, an X, or an X and an + EF pulse. *prepare* is handed the schedule, the targets and the reset's anchor, and + returns the anchor the readout should follow. + + Every target keeps its own band, since a readout clock is per-target hardware and each + sweep is centred on that target's own resonance — see `grouped_by_size`. What has to + agree is only the number of setpoints. + """ + for index in range(len(bands[targets[0]])): + anchor = add_together(schedule, [backend.Reset(target) for target in targets]) + if prepare is not None: + anchor = prepare(schedule, targets, anchor) + # Zero duration, so it does not move the anchor: it retunes the clock the readout + # that follows will play on. + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=f"{target}.ro", clock_freq_new=bands[target][index] + ) + for target in targets + ], + ) + add_after(schedule, readouts(backend, targets, index), anchor) + + def _frequency_sweep( config: RoutineConfig, device: Any, @@ -185,33 +229,70 @@ class _ReadoutTraceRoutine(CalibrationRoutine): SAMPLING_RATE = 1e9 def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = device.get_element(target) - self._restore = { - "measure.acq_delay": read_path(element, "measure.acq_delay"), - "measure.integration_time": read_path(element, "measure.integration_time"), - } + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """One raw trace per target, captured at the same instant. + + No sweep at all — one acquisition each — so the group never splits. Each target + opens its own window on its own channel, and the windows coincide, which is what + makes the arrival times comparable across the group. + """ window = float(config.get("window", 2e-6)) - write_path(element, "measure.acq_delay", 0.0) - write_path(element, "measure.integration_time", window) + for target in targets: + element = device.get_element(target) + sweeps[target]["restore"] = { + "measure.acq_delay": read_path(element, "measure.acq_delay"), + "measure.integration_time": read_path( + element, "measure.integration_time" + ), + } + write_path(element, "measure.acq_delay", 0.0) + write_path(element, "measure.integration_time", window) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - schedule.add(backend.Reset(target)) - schedule.add( - backend.Measure( - target, - acq_index=0, - acq_protocol="Trace", - bin_mode=backend.BinMode.AVERAGE, - ) + anchor = add_together(schedule, [backend.Reset(target) for target in targets]) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=0, + acq_protocol="Trace", + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, target in enumerate(targets) + ], + anchor, ) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: try: trace = _trace_of(dataset) @@ -222,7 +303,7 @@ def analyse( # and the long window this routine set to make its own measurement # possible. `apply` writes the calibrated value over the restored one. element = device.get_element(target) - for path, value in self._restore.items(): + for path, value in sweep["restore"].items(): write_path(element, path, value) @@ -329,6 +410,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -340,44 +422,87 @@ def measure( reads the frequency this one writes, so a refusal here stops the chip rather than one routine. """ - return self.escalating(target, device, config, backend, timeout_s) + return self.escalating(target, device, config, backend, timeout_s, sweep) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The same widening, for the resonators whose line fell outside their window.""" + return self.escalating_group( + targets, device, config, backend, timeout_s, sweeps + ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - # Recorded for escalation to read back, under the `_` convention `_widened` - # already uses for setpoint lists. - self._span = float(config.get("span", self.SPAN)) - self._frequencies = _frequency_sweep( + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By point count, not by value: each band is centred on its own resonance.""" + return grouped_by_size(targets, lambda t: self._band(device, t, config, None)) + + def _band( + self, device: Any, target: str, config: RoutineConfig, backend: Any + ) -> list[float]: + """This target's readout-frequency sweep.""" + return _frequency_sweep( config, device, target, "readout", default_span=self.SPAN, backend=backend ) - clock = f"{target}.ro" + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Every resonator swept at once, each over its own band on its own clock.""" + bands = {} + for target in targets: + bands[target] = self._band(device, target, config, backend) + # Recorded for escalation to read back, under the same axis names `_widened` + # reads setpoint lists by. + sweeps[target]["span"] = float(config.get("span", self.SPAN)) + sweeps[target]["frequencies"] = bands[target] schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - for index, frequency in enumerate(self._frequencies): - schedule.add(backend.Reset(target)) - schedule.add( - backend.SetClockFrequency(clock=clock, clock_freq_new=frequency) - ) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) - ) + sweep_readout_frequency(schedule, backend, targets, bands) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - fitted = fit_resonator_spectroscopy(self._frequencies, signal_of(dataset)) + fitted = fit_resonator_spectroscopy(sweep["frequencies"], signal_of(dataset)) # The root of the graph, and the one frequency every other node reads at. # It had no guard: a 72% dip confined to one 400 kHz bin was fitted as a # 2379 Hz linewidth at Q = 2.9 million, and the centre it wrote was 47 kHz # off the deepest sample it had actually measured. # span so a flat window widens and a line thinner than the grid gets a # finer one, instead of both ending the run (RFC 0007 §11.2). - require_resolved_line(fitted, self._frequencies, axis="span") + require_resolved_line(fitted, sweep["frequencies"], axis="span") return fitted def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: @@ -406,7 +531,12 @@ def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: CHECK_MAX_OFFSET_LINEWIDTHS = 0.35 def build_check_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: """Three points across the line: is the configured frequency still its peak? @@ -427,14 +557,14 @@ def build_check_schedule( config, device, target ) centre = _current_clock(device, target, "readout") - self._check_frequencies = [centre - span / 2, centre, centre + span / 2] - self._check_span = span + sweep["check_frequencies"] = [centre - span / 2, centre, centre + span / 2] + sweep["check_span"] = span clock = f"{target}.ro" schedule = backend.new_schedule( f"{self.name}_check", repetitions=int(config.get("check_shots", 1024)) ) - for index, frequency in enumerate(self._check_frequencies): + for index, frequency in enumerate(sweep["check_frequencies"]): schedule.add(backend.Reset(target)) schedule.add( backend.SetClockFrequency(clock=clock, clock_freq_new=frequency) @@ -447,7 +577,12 @@ def build_check_schedule( return schedule def analyse_check( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> CheckOutcome: signal = signal_of(dataset) if signal.size < 3: @@ -455,7 +590,7 @@ def analyse_check( f"readout check expected 3 acquisitions, got {signal.size}" ) low, centre, high = (float(signal[i]) for i in range(3)) - step = self._check_span / 2.0 + step = sweep["check_span"] / 2.0 # Vertex of the parabola through the three points, in units of *step*. denominator = low - 2.0 * centre + high @@ -519,59 +654,143 @@ def acquire( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, ) -> Any: """A row per readout amplitude, chunked when the grid outgrows one schedule.""" return self.acquire_in_row_chunks( - target, device, config, backend, timeout_s, rows_axis="amplitudes" + target, device, config, backend, timeout_s, sweep, rows_axis="amplitudes" + ) + + def acquire_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The same chunking over a group — see `acquire_group_in_row_chunks`.""" + return self.acquire_group_in_row_chunks( + targets, + device, + config, + backend, + timeout_s, + sweeps, + rows_axis="amplitudes", ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - # To full scale, not to half. Punch-through is by definition the *high*-power end - # of the sweep, so a grid stopping at 0.5 finds it only on a line lossless enough - # to punch through at half drive — and on a chip carrying `output_att: 20` it is - # some 26 dB short of what the module can emit, which is to say it cannot find it - # at all. This is the §5 hardware bound that got the node switched off on the - # August 2026 chips, and that §12 recorded as fixed in phase 3 when it was not. - self._amplitudes = setpoints_of( + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By grid size on both axes: the power ceiling and the band are each the + target's own, and only the counts have to agree.""" + return grouped_by_size( + targets, + lambda t: ( + list(self._powers(device, t, config)) + + list(self._band(device, t, config, None)) + ), + ) + + def _powers(self, device: Any, target: str, config: RoutineConfig) -> list[float]: + """The readout amplitudes swept, up to this element's own full scale.""" + return setpoints_of( config, "amplitudes", linear_setpoints( 0.01, full_scale(device.get_element(target), "measure.pulse_amp"), 11 ), ) - self._frequencies = _frequency_sweep( + + def _band( + self, device: Any, target: str, config: RoutineConfig, backend: Any + ) -> list[float]: + """The readout frequencies swept, around this target's own resonance.""" + return _frequency_sweep( config, device, target, "readout", default_span=20e6, backend=backend ) - clock = f"{target}.ro" + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Every resonator's power-frequency grid at once, each on its own clock. + + Both axes are per-target hardware — the readout amplitude is that port's, the + frequency that clock's — so each target sweeps its own grid over a shared index. + """ + powers = {} + bands = {} + for target in targets: + powers[target] = self._powers(device, target, config) + bands[target] = self._band(device, target, config, backend) + sweeps[target]["amplitudes"] = powers[target] + sweeps[target]["frequencies"] = bands[target] schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 512)) ) index = 0 - for power in self._amplitudes: - for frequency in self._frequencies: - schedule.add(backend.Reset(target)) - schedule.add( - backend.SetClockFrequency(clock=clock, clock_freq_new=frequency) + for row in range(len(powers[targets[0]])): + for column in range(len(bands[targets[0]])): + anchor = add_together( + schedule, [backend.Reset(target) for target in targets] ) - schedule.add( - backend.Measure( - target, - acq_index=index, - bin_mode=backend.BinMode.AVERAGE, - pulse_amp=power, - ) + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=f"{target}.ro", + clock_freq_new=bands[target][column], + ) + for target in targets + ], + ) + add_after( + schedule, + [ + backend.Measure( + target, + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + pulse_amp=powers[target][row], + ) + for channel, target in enumerate(targets) + ], + anchor, ) index += 1 return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - rows = len(self._amplitudes) - columns = len(self._frequencies) + rows = len(sweep["amplitudes"]) + columns = len(sweep["frequencies"]) if signal.size < rows * columns: raise RoutineError( f"punchout expected {rows * columns} acquisitions, got {signal.size}" @@ -581,9 +800,9 @@ def analyse( frequencies = [] for row in range(rows): chunk = signal[row * columns : (row + 1) * columns] - fitted = fit_resonator_spectroscopy(self._frequencies, chunk) + fitted = fit_resonator_spectroscopy(sweep["frequencies"], chunk) frequencies.append(fitted["readout_frequency"]) - return fit_punchout(self._amplitudes, frequencies) + return fit_punchout(sweep["amplitudes"], frequencies) def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: element = device.get_element(target) @@ -601,7 +820,12 @@ def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: CHECK_MAX_WALK_LINEWIDTHS = 0.5 def build_check_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: """Two short resonator scans, at the configured power and at half of it. @@ -617,13 +841,13 @@ def build_check_schedule( """ element = device.get_element(target) power = float(read_path(element, "measure.pulse_amp")) - self._check_powers = [power, power / 2.0] + sweep["check_powers"] = [power, power / 2.0] centre = _current_clock(device, target, "readout") span = float(config.get("check_span", 0.0)) or 6.0 * self._check_linewidth( config, device, target ) points = int(config.get("check_points", 8)) - self._check_frequencies = linear_setpoints( + sweep["check_frequencies"] = linear_setpoints( centre - span / 2, centre + span / 2, points ) @@ -632,8 +856,8 @@ def build_check_schedule( f"{self.name}_check", repetitions=int(config.get("check_shots", 512)) ) index = 0 - for probe in self._check_powers: - for frequency in self._check_frequencies: + for probe in sweep["check_powers"]: + for frequency in sweep["check_frequencies"]: schedule.add(backend.Reset(target)) schedule.add( backend.SetClockFrequency(clock=clock, clock_freq_new=frequency) @@ -658,17 +882,22 @@ def _check_linewidth( return measured_linewidth(device.get_element(target), self.CHECK_LINEWIDTH_HZ) def analyse_check( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> CheckOutcome: signal = signal_of(dataset) - columns = len(self._check_frequencies) + columns = len(sweep["check_frequencies"]) if signal.size < 2 * columns: raise RoutineError( f"punchout check expected {2 * columns} acquisitions, got {signal.size}" ) resonances = [ fit_resonator_spectroscopy( - self._check_frequencies, signal[row * columns : (row + 1) * columns] + sweep["check_frequencies"], signal[row * columns : (row + 1) * columns] )["readout_frequency"] for row in range(2) ] @@ -713,10 +942,29 @@ class ResonatorSpectroscopyExcited(CalibrationRoutine): ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By point count: the span is sized from each resonator's own linewidth.""" + return grouped_by_size(targets, lambda t: self._band(device, t, config, None)) + + def _band( + self, device: Any, target: str, config: RoutineConfig, backend: Any + ) -> list[float]: + """This target's sweep, spanning several of its own measured linewidths.""" element = device.get_element(target) - self._frequencies = _frequency_sweep( + return _frequency_sweep( config, device, target, @@ -725,33 +973,49 @@ def build_schedule( * measured_linewidth(element, 2.5e6), backend=backend, ) - # The reference `analyse` differences against, read here rather than there: a - # prerequisite has to be readable before the acquisition to be one at all. - self._ground = _current_clock(device, target, "readout") - clock = f"{target}.ro" + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The same sweep with every qubit excited first, so the dispersive shift shows.""" + bands = {} + for target in targets: + bands[target] = self._band(device, target, config, backend) + sweeps[target]["frequencies"] = bands[target] + # The reference `analyse` differences against, read here rather than there: a + # prerequisite has to be readable before the acquisition to be one at all. + sweeps[target]["ground"] = _current_clock(device, target, "readout") schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) - for index, frequency in enumerate(self._frequencies): - schedule.add(backend.Reset(target)) - schedule.add(backend.X(target)) - schedule.add( - backend.SetClockFrequency(clock=clock, clock_freq_new=frequency) - ) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) - ) + sweep_readout_frequency( + schedule, + backend, + targets, + bands, + prepare=lambda sched, group, _anchor: add_together( + sched, [backend.X(t) for t in group] + ), + ) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - fitted = fit_resonator_spectroscopy(self._frequencies, signal_of(dataset)) - require_resolved_line(fitted, self._frequencies) + fitted = fit_resonator_spectroscopy(sweep["frequencies"], signal_of(dataset)) + require_resolved_line(fitted, sweep["frequencies"]) excited = fitted["readout_frequency"] - ground = self._ground + ground = sweep["ground"] shift = 0.5 * (excited - ground) linewidth = float(fitted["linewidth"]) # The one place the X gate is checked against a resonance instead of against @@ -915,10 +1179,31 @@ def acquire( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, ) -> Any: """A row per drive amplitude, chunked when the grid outgrows one schedule.""" return self.acquire_in_row_chunks( - target, device, config, backend, timeout_s, rows_axis="drive_amps" + target, device, config, backend, timeout_s, sweep, rows_axis="drive_amps" + ) + + def acquire_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The same chunking over a group — see `acquire_group_in_row_chunks`.""" + return self.acquire_group_in_row_chunks( + targets, + device, + config, + backend, + timeout_s, + sweeps, + rows_axis="drive_amps", ) def measure( @@ -927,6 +1212,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -951,7 +1237,7 @@ def measure( after the narrow one has already failed. """ try: - return self._sweep(target, device, config, backend, timeout_s) + return self._sweep(target, device, config, backend, timeout_s, sweep) except (RoutineError, FitError) as narrow: near = str(narrow) log.info( @@ -963,7 +1249,7 @@ def measure( float(config.get("search_span", self.SEARCH_SPAN)) / 1e6, ) - found, width = self._search(target, device, config, backend, timeout_s) + found, width = self._search(target, device, config, backend, timeout_s, sweep) confirming = RoutineConfig( enabled=config.enabled, params={ @@ -981,7 +1267,7 @@ def measure( }, ) try: - return self._sweep(target, device, confirming, backend, timeout_s) + return self._sweep(target, device, confirming, backend, timeout_s, sweep) except (RoutineError, FitError) as exc: raise RoutineError( f"the widened search put {target}'s strongest line at {found:.0f} Hz, " @@ -996,6 +1282,7 @@ def _sweep( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, ) -> dict[str, Any]: """The ordinary pass: build, run, fit, and refuse anything unresolved. @@ -1015,7 +1302,7 @@ def _sweep( An operator who sets ``drive_amps`` keeps it: `escalating` leaves an axis the config names alone, so this only ever moves a default. """ - return self.escalating(target, device, config, backend, timeout_s) + return self.escalating(target, device, config, backend, timeout_s, sweep) def _search( self, @@ -1024,6 +1311,7 @@ def _search( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, ) -> tuple[float, float]: """Where the strongest line in a wide window is, and roughly how wide. @@ -1071,6 +1359,7 @@ def _search( [amplitude], backend, int(config.get("search_shots", 256)), + sweep, ) signal = signal_of(backend.run(schedule, timeout_s=timeout_s)) @@ -1165,23 +1454,227 @@ def _confirm_points( return max(self.CONFIRM_POINTS, min(wanted, affordable)) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - return self._probe_schedule( - target, - _frequency_sweep( + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By point count on both axes. Each band is centred on that qubit's own f01, so + the values differ and only the counts have to agree.""" + return grouped_by_size( + targets, + lambda t: ( + list(self._drive_amplitudes(config, device, t)) + + list(self._band(device, t, config, None, Sweep(t))) + ), + ) + + def _band( + self, + device: Any, + target: str, + config: RoutineConfig, + backend: Any, + sweep: Sweep, + ) -> list[float]: + """This target's frequency sweep — around its own found line if one was searched. + + The confirming pass is centred per target, because the wide search finds a + different line for each qubit. Carried on that target's `Sweep` rather than in the + config, which is one object for the group: a shared `centre_frequency` could only + describe one of them, and that is what would have forced the confirm stage to run + a qubit at a time. + """ + confirming = sweep.get("confirm") + if confirming is None: + return _frequency_sweep( config, device, target, "f01", default_span=self.NARROW_SPAN, backend=backend, - ), - self._drive_amplitudes(config, device, target), + ) + centre, span, points = confirming + return setpoints_of( + config, + "frequencies", + linear_setpoints(centre - span / 2, centre + span / 2, points), + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Every qubit's drive swept at once, each over its own band on its own clock.""" + return self._probe_group_schedule( + targets, + { + target: self._band(device, target, config, backend, sweeps[target]) + for target in targets + }, + { + target: self._drive_amplitudes(config, device, target) + for target in targets + }, backend, int(config.get("shots", 1024)), + sweeps, ) + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """The two-pass search over a group. The passes are per qubit; the qubits are not. + + The dependency this routine has is *within* one qubit and across passes — a confirm + window is centred on the line that qubit's own wide search found. Two qubits never + depend on each other, so the stages fuse and only the membership changes: + + 1. the configured window, for the whole group; + 2. a wide search, for the qubits whose line was not there; + 3. a confirming sweep, for those, each around its own found line. + + Stage 3 still fuses because a frequency axis is per-target hardware, and each + target's centre rides on its own `Sweep` (see :meth:`_band`). Stage 2 does not: it + is one acquisition per lost qubit and it only runs when a qubit is not where the + config says, so there is little to win and a per-target band to keep simple. + """ + results: dict[str, dict[str, Any] | Exception] = {} + first = self.escalating_group( + targets, device, config, backend, timeout_s, sweeps + ) + unresolved = [] + for target, outcome in first.items(): + if isinstance(outcome, Exception): + unresolved.append(target) + else: + results[target] = outcome + if not unresolved: + return results + + for target in unresolved: + log.info( + "%s: no line within the configured window for %s — widening to %.0f MHz", + self.name, + target, + float(config.get("search_span", self.SEARCH_SPAN)) / 1e6, + ) + try: + found, width = self._search( + target, device, config, backend, timeout_s, sweeps[target] + ) + except (RoutineError, FitError) as exc: + results[target] = exc + continue + sweeps[target]["confirm"] = ( + found, + float(config.get("confirm_span", self._confirm_span(config, width))), + int( + config.get( + "confirm_points", + self._confirm_points(config, device, target, width), + ) + ), + ) + + confirming = [t for t in unresolved if "confirm" in sweeps[t]] + if not confirming: + return results + # Split again, since two qubits' confirm windows need not hold the same number of + # points — the count comes from the width each search measured. + for subgroup in grouped_by_size( + confirming, lambda t: self._band(device, t, config, backend, sweeps[t]) + ): + confirmed = self.escalating_group( + subgroup, device, config, backend, timeout_s, sweeps + ) + for target, outcome in confirmed.items(): + if isinstance(outcome, Exception): + found = sweeps[target]["confirm"][0] + results[target] = RoutineError( + f"the widened search put {target}'s strongest line at " + f"{found:.0f} Hz, but sweeping finely there did not confirm it: " + f"{outcome}" + ) + else: + results[target] = outcome + return results + + def _probe_group_schedule( + self, + targets: Sequence[str], + bands: Mapping[str, list[float]], + powers: Mapping[str, list[float]], + backend: SchedulerBackend, + shots: int, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The group counterpart of :meth:`_probe_schedule`. + + Each target drives its own ``.01`` clock at its own frequency and power, so the + grids may differ in value; only their sizes have to match, which is what + `compatible_groups` enforces. + """ + for target in targets: + sweeps[target]["frequencies"] = bands[target] + sweeps[target]["drive_amps"] = powers[target] + sweeps[target]["drive_amps_ceiling"] = MAX_SPECTROSCOPY_AMPLITUDE + + schedule = backend.new_schedule(self.name, repetitions=shots) + index = 0 + for row in range(len(powers[targets[0]])): + for column in range(len(bands[targets[0]])): + anchor = add_together( + schedule, [backend.Reset(target) for target in targets] + ) + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=f"{target}.01", + clock_freq_new=bands[target][column], + ) + for target in targets + ], + ) + anchor = add_after( + schedule, + [ + backend.Rxy( + theta=180, + phi=0, + qubit=target, + amp180=powers[target][row], + ) + for target in targets + ], + anchor, + ) + add_after(schedule, readouts(backend, targets, index), anchor) + index += 1 + return schedule + def _probe_schedule( self, target: str, @@ -1189,17 +1682,18 @@ def _probe_schedule( amplitudes: list[float], backend: SchedulerBackend, shots: int, + sweep: Sweep, ) -> Any: # Recorded here rather than by each caller, so `analyse` cannot read a grid other # than the one the schedule it is handed actually swept — which two passes over # different windows makes a live possibility rather than a theoretical one. - self._frequencies = frequencies - self._drive_amps = amplitudes + sweep["frequencies"] = frequencies + sweep["drive_amps"] = amplitudes # Recorded for `_widened` to clamp against, under the `_` convention it # reads setpoints by. Without it escalation walks straight past full scale and # the compiler refuses the waveform — `Rabi` carries the same line for the same # reason. A drive amplitude is a fraction of full scale, so that is the bound. - self._drive_amps_ceiling = MAX_SPECTROSCOPY_AMPLITUDE + sweep["drive_amps_ceiling"] = MAX_SPECTROSCOPY_AMPLITUDE clock = f"{target}.01" # A weak drive at the calibrated pulse shape, deliberately. @@ -1252,22 +1746,27 @@ def _drive_amplitudes( ] def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - columns = len(self._frequencies) - expected = len(self._drive_amps) * columns + columns = len(sweep["frequencies"]) + expected = len(sweep["drive_amps"]) * columns if signal.size < expected: raise RoutineError( f"qubit spectroscopy expected {expected} acquisitions, got {signal.size}" ) - rows = signal[:expected].reshape(len(self._drive_amps), columns) - fitted = fit_spectroscopy_power(self._drive_amps, self._frequencies, rows) + rows = signal[:expected].reshape(len(sweep["drive_amps"]), columns) + fitted = fit_spectroscopy_power(sweep["drive_amps"], sweep["frequencies"], rows) # The centre is still written — `ramsey` refines it either way — but a line wider # than the window it was fitted in has a linewidth nobody measured. return { **fitted, - "unresolved": require_resolved_line(fitted, self._frequencies), + "unresolved": require_resolved_line(fitted, sweep["frequencies"]), } def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: @@ -1297,17 +1796,46 @@ class F12Spectroscopy(CalibrationRoutine): reads = ("clock_freqs.f01", "rxy.amp180") def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - # Centred on f01 plus the anharmonicity rather than on the configured f12, - # unless the config says otherwise: a chip whose f12 has never been measured - # carries whatever was typed in, and scanning around that finds nothing. A - # transmon's anharmonicity is a few hundred MHz and negative, so f01 - 300 MHz - # is a far better prior than an unmeasured field. - # Read here rather than in `analyse`, where the anharmonicity was differenced - # against it: a prerequisite has to be readable before the acquisition to be one - # at all, and this sweep is already centred on it. - self._f01 = _current_clock(device, target, "f01") + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By grid size on both axes. The band is centred on each target's own f01 plus + the anharmonicity prior, so the values differ and only the counts must agree.""" + return grouped_by_size( + targets, + lambda t: ( + list(self._drive_amplitudes(config, device, t)) + + list(self._band(device, t, config)[0]) + ), + ) + + def _band( + self, device: Any, target: str, config: RoutineConfig + ) -> tuple[list[float], float]: + """This target's ef sweep, and the f01 it is measured against. + + Centred on f01 plus the anharmonicity rather than on the configured f12, unless the + config says otherwise: a chip whose f12 has never been measured carries whatever was + typed in, and scanning around that finds nothing. A transmon's anharmonicity is a + few hundred MHz and negative, so f01 - 300 MHz is a far better prior than an + unmeasured field. + + f01 is read here rather than in `analyse`, where the anharmonicity was differenced + against it: a prerequisite has to be readable before the acquisition to be one at + all, and this sweep is already centred on it. + """ + f01 = _current_clock(device, target, "f01") centre = config.get("centre_frequency") if centre is None: offset = float(config.get("anharmonicity_prior", -300e6)) @@ -1326,60 +1854,84 @@ def build_schedule( f"Searching around f01 plus this would look where no transition is. " f"Set `anharmonicity_range` for a device built outside it" ) - centre = self._f01 + offset + centre = f01 + offset span = float(config.get("span", 400e6)) points = int(config.get("points", 81)) - self._frequencies = setpoints_of( - config, - "frequencies", - linear_setpoints(centre - span / 2, centre + span / 2, points), + return ( + setpoints_of( + config, + "frequencies", + linear_setpoints(centre - span / 2, centre + span / 2, points), + ), + f01, ) - clock = f"{target}.12" - # A tenth, not the 3% this asked for before the simulator learned where |2> - # lands. That default was tuned against an artefact: with |2> reported on - # |0>'s cloud, a 5% population transfer swung the signal across the whole - # readout axis and the line looked strong. Read correctly, |1> and |2> sit - # close together at a 0-1 readout point and the same transfer is a 5% wiggle — - # measured, and enough to put the fitted centre 7 MHz out. - # - # A tenth gives 26% contrast and lands within a megahertz. Not more: half a pi - # pulse is already the point where `_drive_ef`'s neglected off-resonant 0-1 - # term starts to matter, and this routine only has to find the line for - # `rabi_12` to refine. - self._drive_amps = self._drive_amplitudes(config, device, target) - # Recorded for `_widened` to clamp against, under the `_` convention it - # reads setpoints by. Without it escalation walks straight past full scale and - # the compiler refuses the waveform — `Rabi` carries the same line for the same - # reason. A drive amplitude is a fraction of full scale, so that is the bound. - self._drive_amps_ceiling = MAX_SPECTROSCOPY_AMPLITUDE + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Every qubit's ef line searched at once, each on its own ``.12`` clock.""" + bands = {} + powers = {} + for target in targets: + bands[target], f01 = self._band(device, target, config) + sweeps[target]["frequencies"] = bands[target] + sweeps[target]["f01"] = f01 + # A tenth of full scale, not the 3% this asked for before the simulator learned + # where |2> lands. That default was tuned against an artefact: with |2> reported + # on |0>'s cloud a 5% transfer swung the signal across the whole readout axis and + # the line looked strong. Read correctly, |1> and |2> sit close together at a 0-1 + # readout point and the same transfer is a 5% wiggle — enough to put the fitted + # centre 7 MHz out. A tenth gives 26% contrast. Not more: half a pi pulse is + # already where `_drive_ef`'s neglected off-resonant 0-1 term starts to matter, + # and this only has to find the line for `rabi_12` to refine. + powers[target] = self._drive_amplitudes(config, device, target) + sweeps[target]["drive_amps"] = powers[target] + # Recorded for `_widened` to clamp against. Without it escalation walks past + # full scale and the compiler refuses the waveform — `Rabi` carries the same + # line for the same reason. + sweeps[target]["drive_amps_ceiling"] = MAX_SPECTROSCOPY_AMPLITUDE duration = float(config.get("duration", 20e-9)) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 1024)) ) index = 0 - for amplitude in self._drive_amps: - for frequency in self._frequencies: - schedule.add(backend.Reset(target)) - # Into |1> first, which is what makes this the *ef* transition rather - # than a second look at 0-1. - schedule.add(backend.X(target)) - schedule.add( - backend.SetClockFrequency(clock=clock, clock_freq_new=frequency) + for row in range(len(powers[targets[0]])): + for column in range(len(bands[targets[0]])): + add_together(schedule, [backend.Reset(target) for target in targets]) + # Into |1> first, which is what makes this the *ef* transition rather than + # a second look at 0-1. + anchor = add_together( + schedule, [backend.X(target) for target in targets] ) - schedule.add( - backend.SquarePulse( - amp=amplitude, - duration=duration, - port=f"{target}:mw", - clock=clock, - ) + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=f"{target}.12", + clock_freq_new=bands[target][column], + ) + for target in targets + ], ) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + anchor = add_after( + schedule, + [ + backend.SquarePulse( + amp=powers[target][row], + duration=duration, + port=f"{target}:mw", + clock=f"{target}.12", + ) + for target in targets + ], + anchor, ) + add_after(schedule, readouts(backend, targets, index), anchor) index += 1 return schedule @@ -1424,24 +1976,29 @@ def _drive_amplitudes( ] def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - columns = len(self._frequencies) - expected = len(self._drive_amps) * columns + columns = len(sweep["frequencies"]) + expected = len(sweep["drive_amps"]) * columns if signal.size < expected: raise RoutineError( f"f12 spectroscopy expected {expected} acquisitions for " - f"{len(self._drive_amps)} amplitudes x {columns} frequencies, got " + f"{len(sweep['drive_amps'])} amplitudes x {columns} frequencies, got " f"{signal.size}" ) fitted = fit_spectroscopy_power( - np.asarray(self._drive_amps), - np.asarray(self._frequencies), - signal[:expected].reshape(len(self._drive_amps), columns), + np.asarray(sweep["drive_amps"]), + np.asarray(sweep["frequencies"]), + signal[:expected].reshape(len(sweep["drive_amps"]), columns), ) try: - require_resolved_line(fitted, self._frequencies) + require_resolved_line(fitted, sweep["frequencies"]) except (FitError, RoutineError) as unresolved: # The prior stands. Nothing here can invent an f12, but the last one measured # is still the best available, and every ef node reads `clock_freqs.f12` — so @@ -1463,7 +2020,7 @@ def analyse( ) return { "clock_freq_12": prior, - "anharmonicity": prior - self._f01, + "anharmonicity": prior - sweep["f01"], "unresolved": 1.0, "fit": fitted.get("fit"), } @@ -1476,7 +2033,7 @@ def analyse( # measures it: the anharmonicity is f12 - f01, and it sets both the DRAG # optimum and where |02> sits for a CZ. "anharmonicity": self._require_transmon_anharmonicity( - fitted["clock_freq_01"] - self._f01, target + fitted["clock_freq_01"] - sweep["f01"], target ), "unresolved": 0.0, } @@ -1532,68 +2089,146 @@ def acquire( config: RoutineConfig, backend: SchedulerBackend, timeout_s: float, + sweep: Sweep, ) -> Any: """A row per flux offset, chunked when the grid outgrows one schedule.""" return self.acquire_in_row_chunks( - target, device, config, backend, timeout_s, rows_axis="flux_offsets" + target, device, config, backend, timeout_s, sweep, rows_axis="flux_offsets" + ) + + def acquire_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + timeout_s: float, + sweeps: Mapping[str, Sweep], + ) -> Any: + """The same chunking over a group — see `acquire_group_in_row_chunks`.""" + return self.acquire_group_in_row_chunks( + targets, + device, + config, + backend, + timeout_s, + sweeps, + rows_axis="flux_offsets", ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - self._flux_offsets = setpoints_of( - config, "flux_offsets", linear_setpoints(-0.2, 0.2, 11) + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} ) - self._frequencies = _frequency_sweep( + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """The flux axis is one grid from the config; the frequency axis is per target, + so only its point count has to agree.""" + return grouped_by_size(targets, lambda t: self._band(device, t, config, None)) + + def _band( + self, device: Any, target: str, config: RoutineConfig, backend: Any + ) -> list[float]: + """The drive frequencies swept, around this target's own f01.""" + return _frequency_sweep( config, device, target, "f01", default_span=100e6, backend=backend ) - clock = f"{target}.01" + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Every qubit's flux-frequency grid at once, each on its own flux port. + + The flux offsets are one axis for the group — the pulse length is shared time — and + each target's drive frequency is its own. + """ + offsets = setpoints_of(config, "flux_offsets", linear_setpoints(-0.2, 0.2, 11)) + bands = {} + for target in targets: + bands[target] = self._band(device, target, config, backend) + sweeps[target]["flux_offsets"] = offsets + sweeps[target]["frequencies"] = bands[target] duration = float(config.get("flux_duration", 200e-9)) - port = f"{target}:fl" schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 512)) ) index = 0 - for offset in self._flux_offsets: - for frequency in self._frequencies: - schedule.add(backend.Reset(target)) - schedule.add( - backend.SquarePulse( - amp=offset, duration=duration, port=port, clock="cl0.baseband" - ) + for offset in offsets: + for column in range(len(bands[targets[0]])): + anchor = add_together( + schedule, [backend.Reset(target) for target in targets] ) - schedule.add( - backend.SetClockFrequency(clock=clock, clock_freq_new=frequency) + anchor = add_after( + schedule, + [ + backend.SquarePulse( + amp=offset, + duration=duration, + port=f"{target}:fl", + clock="cl0.baseband", + ) + for target in targets + ], + anchor, ) - schedule.add(backend.Rxy(theta=180, phi=0, qubit=target)) - schedule.add( - backend.Measure( - target, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=f"{target}.01", + clock_freq_new=bands[target][column], + ) + for target in targets + ], ) + anchor = add_after( + schedule, + [backend.Rxy(theta=180, phi=0, qubit=target) for target in targets], + anchor, + ) + add_after(schedule, readouts(backend, targets, index), anchor) index += 1 return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - columns = len(self._frequencies) + columns = len(sweep["frequencies"]) arc = [] - for row in range(len(self._flux_offsets)): + for row in range(len(sweep["flux_offsets"])): chunk = signal[row * columns : (row + 1) * columns] if chunk.size < columns: break arc.append( - fit_qubit_spectroscopy(self._frequencies, chunk)["clock_freq_01"] + fit_qubit_spectroscopy(sweep["frequencies"], chunk)["clock_freq_01"] ) if not arc: raise RoutineError("flux spectroscopy produced no usable frequency arc") sweet_spot = max(range(len(arc)), key=lambda i: arc[i]) return { - "flux_offsets": list(self._flux_offsets[: len(arc)]), + "flux_offsets": list(sweep["flux_offsets"][: len(arc)]), "frequencies": arc, - "sweet_spot_offset": float(self._flux_offsets[sweet_spot]), + "sweet_spot_offset": float(sweep["flux_offsets"][sweet_spot]), "sweet_spot_frequency": float(arc[sweet_spot]), } diff --git a/qpi-driver/py/qpi_driver/tuners/routines/two_qubit.py b/qpi-driver/py/qpi_driver/tuners/routines/two_qubit.py index c4a704b0..b7a25ec5 100644 --- a/qpi-driver/py/qpi_driver/tuners/routines/two_qubit.py +++ b/qpi-driver/py/qpi_driver/tuners/routines/two_qubit.py @@ -4,6 +4,7 @@ which is how the two qubits it acts on are recovered. """ +from collections.abc import Mapping, Sequence from typing import Any import numpy as np @@ -11,6 +12,13 @@ from qpi_driver.tuners.base.backend import SchedulerBackend from qpi_driver.tuners.base.config import DEFAULT_ROUTINE_TIMEOUT_S, RoutineConfig +from qpi_driver.tuners.base.fusion import ( + add_after, + add_together, + channels_of, + grouped_by_grid, + grouped_by_size, +) from qpi_driver.tuners.base.device import ( has_flux_port, phase_correction_names, @@ -24,6 +32,7 @@ linear_setpoints, setpoints_of, ) +from qpi_driver.tuners.base.sweep import Sweep from qpi_driver.tuners.fitting import ( FitError, fit_chevron, @@ -34,6 +43,39 @@ ) +def prepare_11(schedule: Any, backend: SchedulerBackend, edges: Sequence[str]) -> Any: + """Reset and excite both qubits of every edge, returning the anchor. + + ``|11>`` is the state that exchanges with ``|02>``, so both qubits are excited before + the pulse brings them into resonance. Shared because all four CZ sweeps open this way, + and a fused group needs two edges' four resets and four X pulses to coincide rather + than to queue. + """ + pairs = [qubits_of(edge) for edge in edges] + add_together(schedule, [backend.Reset(q) for pair in pairs for q in pair]) + return add_together(schedule, [backend.X(q) for pair in pairs for q in pair]) + + +def measure_parents( + backend: SchedulerBackend, edges: Sequence[str], index: int +) -> list[Any]: + """One measurement per edge, on its parent and on the edge's own channel. + + The parent is the qubit the exchange leaves population in, so it is the one every CZ + sweep reads. The channel is the edge's position in the group, which is what + `channels_of` slices the result apart by. + """ + return [ + backend.Measure( + qubits_of(edge)[0], + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, edge in enumerate(edges) + ] + + def qubits_of(edge: str) -> tuple[str, str]: """The two element names an edge joins. @@ -117,7 +159,12 @@ def applies_to(self, device: Any, target: str) -> bool: return bias is not None and hasattr(bias, "parking_current") def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: """There is no single schedule. See :meth:`measure`. @@ -131,7 +178,12 @@ def build_schedule( ) def analyse( - self, dataset: Any, target: str, device: Any, config: RoutineConfig + self, + dataset: Any, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: """Likewise: the fitting happens inside :meth:`measure`, per bias point.""" raise RoutineError( @@ -144,6 +196,7 @@ def measure( device: Any, config: RoutineConfig, backend: SchedulerBackend, + sweep: Sweep, bias: Any = None, timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, ) -> dict[str, Any]: @@ -196,6 +249,163 @@ def measure( return self._locate(found, base, config) + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """By probe size: each band is centred on that edge's own parent's f01.""" + return grouped_by_size(targets, lambda t: self._probe_band(device, t, config)) + + def _probe_band( + self, device: Any, target: str, config: RoutineConfig + ) -> list[float]: + """Where to look for this edge's parent, around where it currently sits.""" + parent, _child = qubits_of(target) + base = float(read_path(device.get_element(parent), "clock_freqs.f01")) + span = float(config.get("span", 200e6)) + points = int(config.get("points", 11)) + return list(linear_setpoints(base - span / 2, base + span / 2, points)) + + def measure_group( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + bias: Any = None, + timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S, + ) -> dict[str, dict[str, Any] | Exception]: + """Sweep every coupler's parking current at once, one probe per setpoint. + + A rack is shared; its *channels* are not. Each edge names its own S4g output or its + own baseband output — `bias.spi_module`/`bias.spi_output`, or the `qcm` pair — so + setting a group's currents is one quick write per edge over the same serial port and + then a *single* acquisition, not one per edge. What is sequential here is within one + coupler, across its own current setpoints, exactly as everywhere else in this graph. + + The parents are distinct within a group because `edge_spacing` will not put two + edges sharing a qubit in one, and that is what lets a single probe read all of them. + """ + if bias is None or not getattr(bias, "holds_current", False): + return {target: self._no_bias_source(target) for target in targets} + from qpi_driver.executors.utils.coupler_bias import bias_settings + + settings = {t: bias_settings(device.get_edge(t)) for t in targets} + originals = { + t: float(read_path(device.get_edge(t), "bias.parking_current")) + for t in targets + } + bands = {t: self._probe_band(device, t, config) for t in targets} + parents = {t: qubits_of(t)[0] for t in targets} + bases = { + t: float(read_path(device.get_element(parents[t]), "clock_freqs.f01")) + for t in targets + } + currents = setpoints_of(config, "currents", linear_setpoints(0.0, 3.0e-3, 13)) + for target in targets: + sweeps[target]["currents"] = currents + + found: dict[str, list[tuple[float, float]]] = {t: [] for t in targets} + try: + for current in currents: + for target in targets: + bias.apply(target, float(current), settings[target]) + schedule = self._probe_group(targets, parents, bands, config, backend) + sliced = channels_of( + backend.run(schedule, timeout_s=timeout_s), list(targets) + ) + for target in targets: + acquisition = sliced.get(target) + if acquisition is None: + continue + try: + fitted = fit_resonator_spectroscopy( + bands[target], signal_of(acquisition) + ) + except FitError: + continue + found[target].append( + (float(current), float(fitted["readout_frequency"])) + ) + finally: + # Every coupler back where it was, whatever happened — an abandoned sweep + # must not leave the chip parked at the last current it happened to try. + for target in targets: + bias.apply(target, originals[target], settings[target]) + + results: dict[str, dict[str, Any] | Exception] = {} + for target in targets: + try: + results[target] = self._locate(found[target], bases[target], config) + except RoutineError as exc: + results[target] = exc + return results + + def _probe_group( + self, + targets: Sequence[str], + parents: Mapping[str, str], + bands: Mapping[str, list[float]], + config: RoutineConfig, + backend: SchedulerBackend, + ) -> Any: + """One short spectroscopy per edge, on its parent and its own channel.""" + amplitude = float(config.get("drive_amp", 0.10)) + schedule = backend.new_schedule( + self.name, repetitions=int(config.get("shots", 512)) + ) + for index in range(len(bands[targets[0]])): + anchor = add_together( + schedule, [backend.Reset(parents[t]) for t in targets] + ) + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=f"{parents[t]}.01", clock_freq_new=bands[t][index] + ) + for t in targets + ], + ) + anchor = add_after( + schedule, + [ + backend.Rxy(theta=180, phi=0, qubit=parents[t], amp180=amplitude) + for t in targets + ], + anchor, + ) + add_after( + schedule, + [ + backend.Measure( + parents[target], + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, target in enumerate(targets) + ], + anchor, + ) + return schedule + + def _no_bias_source(self, target: str) -> RoutineError: + """The refusal both paths give, worded once. + + A recorder counts as nothing here, and that distinction is the point. Against one, + every bias point returns the same qubit frequency, the sweep is flat, and the fit + reports a crossing with total confidence — a number written to the device that no + instrument ever produced. It is the exact failure this node exists to prevent. + """ + return RoutineError( + f"{target} has no source that can actually hold a parking current, so " + f"its coupler's crossing cannot be swept — the bias is delivered out " + f"of band, and a recorder would make this measure nothing at all. On " + f"hardware: check `bias.source` on the edge, and pass " + f"-o spi_rack_address= if it says `spi`" + ) + def _probe( self, qubit: str, @@ -286,76 +496,123 @@ def applies_to(self, device: Any, target: str) -> bool: return parametric_edge(device, target) is not None def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """Edges whose sweeps have the same *number* of points, not the same points. + + Each edge drives its own clock on its own port, so at setpoint *i* every edge can + sit at its own frequency — which it must, since the band is centred on that edge's + own CZ clock. Only the point count has to agree, because the acquisition index is + shared. See `grouped_by_size`. + """ + return grouped_by_size(targets, lambda t: self._band(device, t, config)) + + def _band(self, device: Any, target: str, config: RoutineConfig) -> list[float]: + """This edge's drive-frequency sweep, centred on its own CZ clock.""" element = parametric_edge(device, target) if element is None: raise RoutineError( f"edge {target!r} drives its CZ with a baseband flux pulse, so it has " f"no drive frequency to find — see `cz_chevron` for its amplitude" ) - parent, child = qubits_of(target) centre = config.get("centre_frequency") if centre is None: configured = float(read_path(element, "clock_freqs.cz")) - # An uncalibrated edge carries zero, which is not a frequency to scan - # around. The default centre is a plausible coupler sideband rather than - # a measurement, and a chip that knows better says so in its config. + # An uncalibrated edge carries zero, which is not a frequency to scan around. + # The default centre is a plausible coupler sideband rather than a measurement, + # and a chip that knows better says so in its config. centre = configured or float(config.get("prior", 4.0e9)) span = float(config.get("span", 400e6)) points = int(config.get("points", 81)) - self._frequencies = setpoints_of( + return setpoints_of( config, "frequencies", linear_setpoints(centre - span / 2, centre + span / 2, points), ) - amplitude = float( - config.get("amplitude", read_path(element, "cz.square_amp") or 0.5) - ) - # Long enough that a resonant drive moves most of the population, short - # enough that it has not come back: a quarter of a round trip at the - # nominal rate. Off resonance the length makes no difference, which is the - # asymmetry the sweep reads. + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Every edge's drive swept at once, each over its own band on its own clock.""" + bands = {} + amplitudes = {} + for target in targets: + element = parametric_edge(device, target) + bands[target] = self._band(device, target, config) + sweeps[target]["frequencies"] = bands[target] + amplitudes[target] = float( + config.get("amplitude", read_path(element, "cz.square_amp") or 0.5) + ) + # Long enough that a resonant drive moves most of the population, short enough that + # it has not come back: a quarter of a round trip at the nominal rate. Off resonance + # the length makes no difference, which is the asymmetry the sweep reads. duration = grid_duration(float(config.get("duration", 100e-9))) schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 512)) ) - # The edge's CZ clock is declared inside the CZ gate's own subschedule, not - # by the device, so a raw pulse on it has nothing to reference. Every other - # spectroscopy sweeps a clock the element already owns; this one brings its - # own, then retunes it point by point like the rest. - clock = f"{target}.cz" - schedule.add_resource( - backend.ClockResource(name=clock, freq=self._frequencies[0]) - ) - for index, frequency in enumerate(self._frequencies): - schedule.add(backend.Reset(parent)) - schedule.add(backend.Reset(child)) - schedule.add(backend.X(parent)) - schedule.add(backend.X(child)) - schedule.add( - backend.SetClockFrequency(clock=clock, clock_freq_new=frequency) + # An edge's CZ clock is declared inside the CZ gate's own subschedule, not by the + # device, so a raw pulse on it has nothing to reference. Every other spectroscopy + # sweeps a clock the element already owns; these bring their own, one per edge, then + # retune them point by point like the rest. + clocks = {target: f"{target}.cz" for target in targets} + for target in targets: + schedule.add_resource( + backend.ClockResource(name=clocks[target], freq=bands[target][0]) ) - schedule.add( - backend.SquarePulse( - amp=amplitude, - duration=duration, - port=f"{target}:fl", - clock=clock, - ) + for index in range(len(bands[targets[0]])): + anchor = prepare_11(schedule, backend, targets) + add_together( + schedule, + [ + backend.SetClockFrequency( + clock=clocks[target], clock_freq_new=bands[target][index] + ) + for target in targets + ], ) - schedule.add( - backend.Measure( - parent, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + anchor = add_after( + schedule, + [ + backend.SquarePulse( + amp=amplitudes[target], + duration=duration, + port=f"{target}:fl", + clock=clocks[target], + ) + for target in targets + ], + anchor, ) + add_after(schedule, measure_parents(backend, targets, index), anchor) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - fitted = fit_resonator_spectroscopy(self._frequencies, signal_of(dataset)) + fitted = fit_resonator_spectroscopy(sweep["frequencies"], signal_of(dataset)) return {"clock_freq_cz": fitted["readout_frequency"], **fitted} def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: @@ -398,55 +655,106 @@ def applies_to(self, device: Any, target: str) -> bool: return parametric_edge(device, target) is not None def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - element = parametric_edge(device, target) - if element is None: - raise RoutineError( - f"edge {target!r} drives its CZ with a baseband flux pulse — see " - f"`cz_chevron`, which sweeps the amplitude its resonance lives in" - ) - parent, child = qubits_of(target) - self._amplitude = float( - config.get("amplitude", read_path(element, "cz.square_amp") or 0.5) + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} ) - self._durations = [ + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """Edges whose durations agree exactly, since a pulse length is shared time. + + Unlike `cz_spectroscopy`'s frequency axis, this one is the timeline itself: a 300 ns + pulse occupies 300 ns of the schedule for every edge in it. `grouped_by_grid`, not + `grouped_by_size`. In practice they always agree — the durations come from the + config, not from the chip. + """ + return grouped_by_grid(targets, lambda _target: self._durations(config)) + + def _durations(self, config: RoutineConfig) -> list[float]: + """The pulse lengths swept, on the instrument's nanosecond grid.""" + return [ grid_duration(duration) for duration in setpoints_of( config, "durations", linear_setpoints(20e-9, 400e-9, 39) ) ] - frequency = float(read_path(element, "clock_freqs.cz")) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """Every edge's coupler driven for the same lengths, each at its own operating point. + + The durations are one axis for the group; the drive frequency and amplitude are read + per edge, since each has its own calibrated point and its own clock. + """ + durations = self._durations(config) + clocks = {} + amplitudes = {} + for target in targets: + element = parametric_edge(device, target) + if element is None: + raise RoutineError( + f"edge {target!r} drives its CZ with a baseband flux pulse — see " + f"`cz_chevron`, which sweeps the amplitude its resonance lives in" + ) + sweeps[target]["durations"] = durations + sweeps[target]["amplitude"] = amplitudes[target] = float( + config.get("amplitude", read_path(element, "cz.square_amp") or 0.5) + ) + clocks[target] = f"{target}.cz" schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 512)) ) - clock = f"{target}.cz" - schedule.add_resource(backend.ClockResource(name=clock, freq=frequency)) - for index, duration in enumerate(self._durations): - schedule.add(backend.Reset(parent)) - schedule.add(backend.Reset(child)) - schedule.add(backend.X(parent)) - schedule.add(backend.X(child)) - schedule.add( - backend.SquarePulse( - amp=self._amplitude, - duration=duration, - port=f"{target}:fl", - clock=clock, + for target in targets: + schedule.add_resource( + backend.ClockResource( + name=clocks[target], + freq=float( + read_path(parametric_edge(device, target), "clock_freqs.cz") + ), ) ) - schedule.add( - backend.Measure( - parent, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + for index, duration in enumerate(durations): + anchor = prepare_11(schedule, backend, targets) + anchor = add_after( + schedule, + [ + backend.SquarePulse( + amp=amplitudes[target], + duration=duration, + port=f"{target}:fl", + clock=clocks[target], + ) + for target in targets + ], + anchor, ) + add_after(schedule, measure_parents(backend, targets, index), anchor) return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: - durations = np.asarray(self._durations, dtype=float) + durations = np.asarray(sweep["durations"], dtype=float) # `fit_rabi` fits a cosine and reports where the *half* period falls. Its axis # is normally a drive amplitude and here it is a duration, which changes # nothing about the arithmetic: a cosine is a cosine. @@ -455,12 +763,12 @@ def analyse( if half <= 0: raise RoutineError( f"the parametric exchange did not oscillate over {durations[-1] * 1e9:.0f} " - f"ns at amplitude {self._amplitude:.4g} — the drive may be off the " + f"ns at amplitude {sweep['amplitude']:.4g} — the drive may be off the " f"transition, which is `cz_spectroscopy`'s job to place" ) round_trip = 2.0 * half return { - "cz_amplitude": self._amplitude, + "cz_amplitude": sweep["amplitude"], "cz_duration": grid_duration(round_trip), # Per unit amplitude, which is the parametrization. The factor is four # rather than two and that is a convention rather than an accident: the @@ -470,7 +778,7 @@ def analyse( # is the constant this routine exists to replace — a chip calibrated in # some other convention would differ by exactly this factor, which is why # it is written down rather than folded in. - "exchange_rate_hz_per_unit": 1.0 / (4.0 * half * self._amplitude), + "exchange_rate_hz_per_unit": 1.0 / (4.0 * half * sweep["amplitude"]), "half_period": half, } @@ -506,52 +814,84 @@ def applies_to(self, device: Any, target: str) -> bool: ) def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, ) -> Any: - control, _child = qubits_of(target) - self._amplitudes = setpoints_of( - config, "amplitudes", linear_setpoints(0.1, 0.6, 11) + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} ) - self._durations = setpoints_of( - config, "durations", linear_setpoints(20e-9, 200e-9, 11) + + def compatible_groups( + self, targets: Sequence[str], device: Any, config: RoutineConfig + ) -> list[list[str]]: + """Both axes come from the config, so every edge's grid is the same one.""" + return grouped_by_grid(targets, lambda _target: self._grid(config)[0]) + + def _grid(self, config: RoutineConfig) -> tuple[list[float], list[float]]: + """The chevron's amplitude and duration axes.""" + return ( + setpoints_of(config, "amplitudes", linear_setpoints(0.1, 0.6, 11)), + setpoints_of(config, "durations", linear_setpoints(20e-9, 200e-9, 11)), ) - port = f"{control}:fl" + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], + ) -> Any: + """One chevron per edge, each pulse on its own control's flux port. + + The amplitude axis is per-edge hardware — a baseband pulse on ``q:fl`` — so the + edges can share a grid without their pulses interfering. That holds only because + `edge_spacing` will not put two edges sharing a qubit in one group. + """ + amplitudes, durations = self._grid(config) + for target in targets: + sweeps[target]["amplitudes"] = amplitudes + sweeps[target]["durations"] = durations schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 512)) ) index = 0 - for amplitude in self._amplitudes: - for duration in self._durations: - parent, child = qubits_of(target) - schedule.add(backend.Reset(parent)) - schedule.add(backend.Reset(child)) - # |11> is the state that exchanges with |02>, so both qubits are - # excited before the flux pulse brings them into resonance. - schedule.add(backend.X(parent)) - schedule.add(backend.X(child)) - schedule.add( - backend.SquarePulse( - amp=amplitude, - duration=duration, - port=port, - clock="cl0.baseband", - ) - ) - schedule.add( - backend.Measure( - parent, acq_index=index, bin_mode=backend.BinMode.AVERAGE - ) + for amplitude in amplitudes: + for duration in durations: + anchor = prepare_11(schedule, backend, targets) + anchor = add_after( + schedule, + [ + backend.SquarePulse( + amp=amplitude, + duration=duration, + port=f"{qubits_of(edge)[0]}:fl", + clock="cl0.baseband", + ) + for edge in targets + ], + anchor, ) + add_after(schedule, measure_parents(backend, targets, index), anchor) index += 1 return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: return fit_chevron( - np.asarray(self._amplitudes), - np.asarray(self._durations), + np.asarray(sweep["amplitudes"]), + np.asarray(sweep["durations"]), signal_of(dataset), ) @@ -581,53 +921,112 @@ class ConditionalPhase(CalibrationRoutine): reads = ("clock_freqs.f01", "rxy.amp180") def build_schedule( - self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend + self, + target: str, + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweep: Sweep, + ) -> Any: + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def build_group_schedule( + self, + targets: Sequence[str], + device: Any, + config: RoutineConfig, + backend: SchedulerBackend, + sweeps: Mapping[str, Sweep], ) -> Any: - parent, child = qubits_of(target) - self._phases = setpoints_of(config, "phases", linear_setpoints(0.0, 360.0, 25)) + """The four fringes on every edge at once, each read on the edge's own channel. + + The phase axis is a virtual-Z on the qubit being measured, so it is per-edge + hardware and the grid can be shared. The group never splits: the phases come from + the config or from a full turn in 25 steps. + """ + phases = setpoints_of(config, "phases", linear_setpoints(0.0, 360.0, 25)) + for target in targets: + sweeps[target]["phases"] = phases schedule = backend.new_schedule( self.name, repetitions=int(config.get("shots", 512)) ) - # Four fringes, not two. A Ramsey on one qubit with the other down and - # then up gives the conditional phase from the offset between them, and - # the *ground* fringe's own phase gives that qubit's single-qubit phase - # over the CZ. Both qubits are needed because each carries its own, and - # the edge has a separate correction for each — measuring one and - # assuming the other is how a CZ ends up building the wrong Bell state - # while every reported number looks right. + # Four fringes, not two. A Ramsey on one qubit with the other down and then up + # gives the conditional phase from the offset between them, and the *ground* + # fringe's own phase gives that qubit's single-qubit phase over the CZ. Both qubits + # are needed because each carries its own, and the edge has a separate correction + # for each — measuring one and assuming the other is how a CZ ends up building the + # wrong Bell state while every reported number looks right. index = 0 - for measured, spectator in ((parent, child), (child, parent)): + for role in (0, 1): + # Which qubit of each edge this pass reads, and which merely sits excited. + roles = { + edge: (qubits_of(edge)[role], qubits_of(edge)[1 - role]) + for edge in targets + } for spectator_excited in (False, True): - for phase in self._phases: - schedule.add(backend.Reset(measured)) - schedule.add(backend.Reset(spectator)) + for phase in phases: + add_together( + schedule, + [backend.Reset(q) for edge in targets for q in qubits_of(edge)], + ) if spectator_excited: - schedule.add(backend.X(spectator)) - schedule.add(backend.Rxy(theta=90, phi=0, qubit=measured)) - schedule.add(backend.CZ(parent, child)) - schedule.add(backend.Rxy(theta=90, phi=phase, qubit=measured)) - schedule.add( - backend.Measure( - measured, - acq_index=index, - bin_mode=backend.BinMode.AVERAGE, + add_together( + schedule, + [backend.X(roles[edge][1]) for edge in targets], ) + add_together( + schedule, + [ + backend.Rxy(theta=90, phi=0, qubit=roles[edge][0]) + for edge in targets + ], + ) + add_together( + schedule, + [backend.CZ(*qubits_of(edge)) for edge in targets], + ) + anchor = add_together( + schedule, + [ + backend.Rxy(theta=90, phi=phase, qubit=roles[edge][0]) + for edge in targets + ], + ) + add_after( + schedule, + [ + backend.Measure( + roles[edge][0], + acq_channel=channel, + acq_index=index, + bin_mode=backend.BinMode.AVERAGE, + ) + for channel, edge in enumerate(targets) + ], + anchor, ) index += 1 return schedule def analyse( - self, dataset: xr.Dataset, target: str, device: Any, config: RoutineConfig + self, + dataset: xr.Dataset, + target: str, + device: Any, + config: RoutineConfig, + sweep: Sweep, ) -> dict[str, Any]: signal = signal_of(dataset) - count = len(self._phases) + count = len(sweep["phases"]) if signal.size < 4 * count: raise RoutineError( f"conditional phase expected {4 * count} acquisitions, got {signal.size}" ) - phases = np.asarray(self._phases) + phases = np.asarray(sweep["phases"]) # Both fringes of a pair, not their difference: the measured qubit's # dynamical phase over the flux pulse cancels between them and does not # cancel within either. diff --git a/qpi-driver/py/tests/test_calibration_config.py b/qpi-driver/py/tests/test_calibration_config.py index 1e3d6ddf..b9a30297 100644 --- a/qpi-driver/py/tests/test_calibration_config.py +++ b/qpi-driver/py/tests/test_calibration_config.py @@ -7,6 +7,7 @@ ConfigError, RoutineConfig, ) +from qpi_driver.tuners.base.sweep import Sweep def test_a_routine_the_file_does_not_mention_is_enabled(): @@ -173,15 +174,16 @@ def test_a_recorder_cannot_be_used_to_measure_a_parking_current(self): a crossing with complete confidence. That number then goes to the device. A routine that measures the chip has to refuse a source that cannot touch it. """ + sweep = Sweep("q0_q1") from qpi_driver.executors.utils.coupler_bias import RecordingBias from qpi_driver.tuners.base.routines import RoutineError from qpi_driver.tuners.routines import all_routines routine = next(r for r in all_routines() if r.name == "coupler_anticrossing") with pytest.raises(RoutineError, match="hold a parking current"): - routine.measure("q0_q1", None, None, None, RecordingBias()) + routine.measure("q0_q1", None, None, None, RecordingBias(), sweep) with pytest.raises(RoutineError, match="hold a parking current"): - routine.measure("q0_q1", None, None, None, None) + routine.measure("q0_q1", None, None, None, None, sweep) def test_the_sources_that_do_hold_a_current_say_so(self): """The flag is what separates a rack from a notebook, and both are BiasSource.""" diff --git a/qpi-driver/py/tests/test_calibration_dag.py b/qpi-driver/py/tests/test_calibration_dag.py index 072811c6..fa52a621 100644 --- a/qpi-driver/py/tests/test_calibration_dag.py +++ b/qpi-driver/py/tests/test_calibration_dag.py @@ -71,10 +71,10 @@ def __init__(self, name, depends_on=(), targets="qubits", benchmark=False): self.benchmark = benchmark self.applied: list[tuple[str, dict]] = [] - def build_schedule(self, target, device, config, backend): + def build_schedule(self, target, device, config, backend, sweep): return backend.new_schedule(self.name) - def analyse(self, dataset, target, device, config): + def analyse(self, dataset, target, device, config, sweep): return {"fidelity": 0.999, "value": 1.0} def apply(self, device, target, params): @@ -82,7 +82,7 @@ def apply(self, device, target, params): class FailingRoutine(StubRoutine): - def analyse(self, dataset, target, device, config): + def analyse(self, dataset, target, device, config, sweep): raise RoutineError("could not fit") @@ -95,6 +95,7 @@ def measure( device, config, backend, + sweep, bias=None, timeout_s=DEFAULT_ROUTINE_TIMEOUT_S, ): @@ -119,13 +120,13 @@ def __init__(self, name, depends_on=(), verdict=None, **kwargs): def has_check(self) -> bool: return self.verdict is not None - def build_check_schedule(self, target, device, config, backend): + def build_check_schedule(self, target, device, config, backend, sweep): if self.verdict is None: return None self.checked.append(target) return backend.new_schedule(f"{self.name}_check") - def analyse_check(self, dataset, target, device, config): + def analyse_check(self, dataset, target, device, config, sweep): return CheckOutcome( passed=bool(self.verdict), margin=0.5 if self.verdict else 2.0 ) @@ -137,7 +138,7 @@ class UnevaluableCheck(CheckableRoutine): def __init__(self, name, depends_on=()): super().__init__(name, depends_on=depends_on, verdict=True) - def analyse_check(self, dataset, target, device, config): + def analyse_check(self, dataset, target, device, config, sweep): raise RoutineError("the check itself could not be evaluated") @@ -327,7 +328,7 @@ def test_the_worst_target_decides(self): """Drift on one qubit of several is drift. A mean would hide it.""" class PerTarget(CheckableRoutine): - def analyse_check(self, dataset, target, device, config): + def analyse_check(self, dataset, target, device, config, sweep): passed = target != "q1" return CheckOutcome(passed=passed, margin=0.1 if passed else 3.0) @@ -614,21 +615,54 @@ def test_every_target_reports_its_position_and_the_running_totals(self): # The plan is the first thing said, and the only one that is not a position. assert "plan" in updates.pop(0) - assert [(u["step"], u["routine"], u["target"]) for u in updates] == [ + finished = [u for u in updates if "target" in u] + assert [(u["step"], u["routine"], u["target"]) for u in finished] == [ (1, "a", "q0"), (1, "a", "q1"), (2, "b", "q0"), (2, "b", "q1"), ] - assert all(u["total"] == 2 for u in updates) + assert all(u["total"] == 2 for u in finished) # The counts are the report's own as it stands, so a watcher sees them climb. - assert [(u["succeeded"], u["failed"]) for u in updates] == [ + assert [(u["succeeded"], u["failed"]) for u in finished] == [ (1, 0), (2, 0), (2, 1), (2, 2), ] + def test_a_target_is_reported_before_it_runs_and_after(self): + """RFC 0009 §7.1 — a node reported only on finishing is never drawn running.""" + updates: list[dict] = [] + config = _config(target_qubits=["q0", "q1"]) + + CalibrationDAG([StubRoutine("a")], config).run( + device=None, + backend=FakeBackend(), + config=config, + on_progress=updates.append, + ) + + assert [ + (u.get("running"), u.get("target")) for u in updates if "plan" not in u + ] == [(["q0"], None), (None, "q0"), (["q1"], None), (None, "q1")] + + def test_a_start_carries_the_totals_the_finish_will_be_compared_against(self): + """A start reporting zeroes would make the next finish look like a failure.""" + updates: list[dict] = [] + routines = [FailingRoutine("a")] + config = _config(target_qubits=["q0", "q1"]) + + CalibrationDAG(routines, config).run( + device=None, + backend=FakeBackend(), + config=config, + on_progress=updates.append, + ) + + starts = [u for u in updates if "running" in u] + assert [u["failed"] for u in starts] == [0, 1] + def test_a_sink_that_raises_does_not_end_the_walk(self): """A calibration outlives whoever is watching it.""" @@ -668,6 +702,8 @@ def test_a_node_carries_what_the_dashboard_draws_it_from(self): "is_benchmark": False, "has_check": True, "updates": ["rxy.amp180"], + # One per target unless `parallel.enabled` — see test_calibration_grouping. + "groups": [["q0"], ["q1"]], } def test_an_excluded_routine_is_still_sent_marked_unplanned(self): @@ -757,7 +793,7 @@ def test_it_lands_on_the_result_not_in_the_parameters(self): """`parameters` is what gets written to a device; a sweep is not a parameter.""" class Fitting(StubRoutine): - def analyse(self, dataset, target, device, config): + def analyse(self, dataset, target, device, config, sweep): return {"amp180": 0.2, "fit": {"x": [1.0], "measured": [2.0]}} routine = Fitting("a") @@ -773,8 +809,9 @@ def analyse(self, dataset, target, device, config): assert routine.applied == [("q0", {"amp180": 0.2})] def test_a_benchmark_does_not_carry_it_into_raw_data_as_well(self): + class FittingBenchmark(StubRoutine): - def analyse(self, dataset, target, device, config): + def analyse(self, dataset, target, device, config, sweep): return {"fidelity": 0.999, "depths": [1, 2], "fit": {"x": [1.0]}} config = _config() @@ -938,7 +975,7 @@ def __init__(self, name, depends_on=(), updates=(), reads=(), targets="qubits"): class FailingProducer(Producer): - def analyse(self, dataset, target, device, config): + def analyse(self, dataset, target, device, config, sweep): raise RoutineError("could not fit") @@ -972,6 +1009,29 @@ def test_a_reader_is_skipped_when_its_parameter_was_not_produced(self): for note in report.notes ), report.notes + def test_a_skipped_target_still_reports_so_its_node_settles(self): + """RFC 0009 §7.1 — a node reporting nothing at all is drawn `pending` forever.""" + updates: list[dict] = [] + config = _config() + routines = [ + FailingProducer("root", updates=("clock_freqs.f01",)), + Producer("reader", depends_on=("root",), reads=("clock_freqs.f01",)), + ] + + CalibrationDAG(routines, config).run( + device=None, + backend=FakeBackend(), + config=config, + on_progress=updates.append, + ) + + reader = [u for u in updates if u.get("routine") == "reader"] + # Reported, and counted as a skip rather than as a success or a failure. + assert [u.get("target") for u in reader if "target" in u] == ["q0"] + assert [u["skipped"] for u in reader if "target" in u] == [1] + # And never announced as running, because it never ran. + assert not any("running" in u for u in reader) + def test_a_failed_refiner_blocks_nothing(self): """Seven parameters have two writers. The second failing leaves the first's. @@ -1049,7 +1109,7 @@ def test_a_failure_on_one_qubit_does_not_skip_another(self): """The ledger is keyed on the target too — q1 is a different chip site.""" class FailsOnQ0(Producer): - def analyse(self, dataset, target, device, config): + def analyse(self, dataset, target, device, config, sweep): if target == "q0": raise RoutineError("could not fit") return {"value": 1.0} @@ -1083,14 +1143,14 @@ def __init__(self, name, needs): self.needs = needs self.attempts: list[float] = [] - def build_schedule(self, target, device, config, backend): - self._delays = setpoints_of( + def build_schedule(self, target, device, config, backend, sweep): + sweep["delays"] = setpoints_of( config, "delays", linear_setpoints(0.0, 1e-5, 41) ) return backend.new_schedule(self.name) - def analyse(self, dataset, target, device, config): - extent = max(self._delays) + def analyse(self, dataset, target, device, config, sweep): + extent = max(sweep["delays"]) self.attempts.append(extent) if extent < self.needs: raise OutOfRange( @@ -1098,8 +1158,10 @@ def analyse(self, dataset, target, device, config): ) return {"t1": extent / 3.0} - def measure(self, target, device, config, backend, bias=None, timeout_s=300.0): - return self.escalating(target, device, config, backend, timeout_s) + def measure( + self, target, device, config, backend, sweep, bias=None, timeout_s=300.0 + ): + return self.escalating(target, device, config, backend, timeout_s, sweep) def _run(self, routines, config=None): config = config or _config() diff --git a/qpi-driver/py/tests/test_calibration_e2e.py b/qpi-driver/py/tests/test_calibration_e2e.py index 869481fd..70e56cd0 100644 --- a/qpi-driver/py/tests/test_calibration_e2e.py +++ b/qpi-driver/py/tests/test_calibration_e2e.py @@ -160,7 +160,15 @@ def test_progress_reaches_the_queue_as_the_walk_proceeds(self, tmp_path): _execute_calibration({"mode": "full", "job_id": "job-1"}, tuner, config, queue) reported = [item for item in queue.items if "progress" in item] - updates = [item["progress"] for item in reported] + # Finishes only. A walk also reports each target *before* it runs (RFC 0009 + # §7.1), and those carry `running` instead of `target`. + updates = [ + item["progress"] for item in reported if "target" in item["progress"] + ] + starting = [ + item["progress"] for item in reported if "running" in item["progress"] + ] + assert [u["routine"] for u in starting] == [u["routine"] for u in updates] assert [u["routine"] for u in updates] == [ r["routine_name"] for r in queue.items[-1]["report"]["routine_results"] ] diff --git a/qpi-driver/py/tests/test_calibration_fusion.py b/qpi-driver/py/tests/test_calibration_fusion.py new file mode 100644 index 00000000..29e840b8 --- /dev/null +++ b/qpi-driver/py/tests/test_calibration_fusion.py @@ -0,0 +1,912 @@ +"""One schedule over several targets, and the dataset it returns (RFC 0009 §6).""" + +import numpy as np +import pytest +import xarray as xr + +from qpi_driver.tuners.base.config import ( + CalibrationConfig, + ParallelConfig, + RoutineConfig, +) +from qpi_driver.tuners.base.dag import CalibrationDAG +from qpi_driver.tuners.base.fusion import ( + add_after, + add_together, + channel_of, + channels_of, +) +from qpi_driver.tuners.base.routines import CalibrationRoutine, RoutineError +from qpi_driver.tuners.fitting.core import OutOfRange, signal_of +from qpi_driver.tuners.routines import ROUTINE_CLASSES +from tests.utils.simulation import FakeDevice, FakeElement, StubBackend +from qpi_driver.tuners.base.sweep import Sweep + + +def _sweeps(targets): + """One `Sweep` per target, as the walk hands them over.""" + return {target: Sweep(target) for target in targets} + + +def _routine(name): + return next(cls() for cls in ROUTINE_CLASSES if cls().name == name) + + +def _grouped(*qubits): + """A device and backend a group schedule can be built against, without the simulator. + + `SimulatedTuner` would do it and needs `scqubits`, which lives in the ``sim`` extra — + and `test-py-driver` installs one executor extra and never ``sim``, so these tests + failed on the import under every leg of that matrix. Nothing here runs physics: they + check which acquisition channel each target lands on and that the resets coincide. + """ + return ( + FakeDevice( + { + qubit: FakeElement( + name=qubit, + clock_freqs={"f01": 5.0e9, "f12": 4.75e9, "readout": 7.1e9}, + rxy={"amp180": 0.18, "motzoi": 0.0}, + measure={"pulse_amp": 0.25}, + ) + for qubit in qubits + }, + {}, + ), + StubBackend(), + ) + + +def _dataset(channels): + """An acquisition dataset keyed by integer channel, as a cluster returns it. + + Positional, not keyword: the keys are integers, which `**` cannot carry. + """ + return xr.Dataset( + { + channel: (("acq_index",), np.asarray(values)) + for channel, values in channels.items() + } + ) + + +class TestAligningOneSchedule: + """`schedule.add` appends, so a fused schedule has to say otherwise.""" + + def test_operations_added_together_share_a_start(self): + backend = StubBackend() + schedule = backend.new_schedule("t") + + add_together(schedule, [backend.Reset(t) for t in ("q0", "q1", "q2")]) + + assert schedule.starts_of("Reset") == [0.0, 0.0, 0.0] + + def test_appending_instead_would_serialise_them(self): + """The failure this exists to prevent: a group as long as the sequential run.""" + backend = StubBackend() + schedule = backend.new_schedule("t") + + for target in ("q0", "q1", "q2"): + schedule.add(backend.Reset(target)) + + assert len(set(schedule.starts_of("Reset"))) == 3 + + def test_a_following_stage_starts_after_the_longest_of_the_last(self): + backend = StubBackend() + schedule = backend.new_schedule("t") + + anchor = add_together( + schedule, + [ + backend.Reset("q0", duration=1e-6), + backend.Reset("q1", duration=5e-6), + ], + ) + add_after(schedule, [backend.Rxy(qubit="q0"), backend.Rxy(qubit="q1")], anchor) + + # 5 us, not 1: referencing the shorter one would overlap the longer. + assert schedule.starts_of("Rxy") == [5e-6, 5e-6] + + def test_adding_nothing_together_anchors_nothing(self): + backend = StubBackend() + assert add_together(backend.new_schedule("t"), []) is None + + def test_adding_nothing_after_keeps_the_anchor(self): + backend = StubBackend() + schedule = backend.new_schedule("t") + anchor = add_together(schedule, [backend.Reset("q0")]) + + assert add_after(schedule, [], anchor) is anchor + + +class TestSlicingTheAcquisitionApart: + def test_each_channel_is_read_on_its_own(self): + dataset = _dataset({0: [1.0, 2.0], 1: [3.0, 4.0]}) + + assert list(channel_of(dataset, 1).data_vars) == [1] + + def test_an_unfused_single_variable_dataset_reads_as_channel_zero(self): + """A group of one must go down exactly the path it did before fusion existed.""" + dataset = xr.Dataset({"magnitude": (("acq_index",), [1.0, 2.0])}) + + assert channel_of(dataset, 0) is dataset + + def test_a_missing_channel_is_an_error_and_not_someone_elses_data(self): + dataset = _dataset({0: [1.0], 1: [2.0]}) + + with pytest.raises(KeyError, match="no channel 5"): + channel_of(dataset, 5) + + def test_targets_are_sliced_by_their_position_in_the_group(self): + dataset = _dataset({0: [1.0], 1: [2.0], 2: [3.0]}) + + sliced = channels_of(dataset, ["q0", "q2", "q4"]) + + assert list(sliced) == ["q0", "q2", "q4"] + assert float(sliced["q2"][1].values[0]) == 2.0 + + def test_a_target_with_no_channel_is_left_out_rather_than_misfed(self): + dataset = _dataset({0: [1.0]}) + + assert list(channels_of(dataset, ["q0", "q1"])) == ["q0"] + + +class TestTheHook: + def test_an_unconverted_routine_declines_a_group(self): + """A local class rather than a named routine: this used to point at whichever + routine happened to be unconverted, and broke each time one was converted.""" + + class Unconverted(CalibrationRoutine): + name = "unconverted" + + def build_schedule(self, target, device, config, backend, sweep): + return backend.new_schedule(self.name) + + def analyse(self, dataset, target, device, config, sweep): + return {} + + routine = Unconverted() + assert not routine.fusable + + with pytest.raises(RoutineError, match="cannot measure 3 targets"): + routine.build_group_schedule( + ["q0", "q1", "q2"], + None, + RoutineConfig(), + StubBackend(), + _sweeps(["q0", "q1", "q2"]), + ) + + def test_an_unconverted_routine_still_builds_for_one_target(self): + """The default has to reproduce today's behaviour exactly.""" + routine = _routine("allxy") + device, backend = _grouped("q0") + + schedule = routine.build_group_schedule( + ["q0"], device, RoutineConfig(), backend, _sweeps(["q0"]) + ) + + assert schedule is not None + + def test_a_converted_routine_measures_every_target_on_its_own_channel(self): + routine = _routine("allxy") + device, backend = _grouped("q0", "q1", "q2") + + schedule = routine.build_group_schedule( + ["q0", "q2"], device, RoutineConfig(), backend, _sweeps(["q0", "q2"]) + ) + + channels = [ + op.kwargs["acq_channel"] + for op in schedule.operations + if op.kind == "Measure" + ] + # Two per pair, one channel each, and never both on zero. + assert set(channels) == {0, 1} + + def test_a_converted_routines_pulses_coincide(self): + routine = _routine("allxy") + device, backend = _grouped("q0", "q1") + + schedule = routine.build_group_schedule( + ["q0", "q1"], device, RoutineConfig(), backend, _sweeps(["q0", "q1"]) + ) + + resets = schedule.starts_of("Reset") + # Pairwise equal within each pair of the 21: every target resets together. + assert resets[0] == resets[1] + + +class FusableProbe(CalibrationRoutine): + """A routine whose fit is whatever its own channel says. + + Every target's correct answer differs, which is what makes a demultiplexing + mistake visible: wired to channel zero, all three would report the same number + and every same-answer test ever written would still pass. + """ + + name = "probe" + targets = "qubits" + + def build_group_schedule(self, targets, device, config, backend, sweeps): + schedule = backend.new_schedule(self.name) + add_together( + schedule, + [ + backend.Measure(target, acq_channel=channel) + for channel, target in enumerate(targets) + ], + ) + return schedule + + def build_schedule(self, target, device, config, backend, sweep): + return self.build_group_schedule( + [target], device, config, backend, {target: sweep} + ) + + def analyse(self, dataset, target, device, config, sweep): + return {"value": float(signal_of(dataset)[0])} + + +class ChannelBackend(StubBackend): + """Returns one value per channel, and counts the acquisitions it was asked for.""" + + def __init__(self, per_channel): + self.per_channel = per_channel + self.runs = [] + + def run(self, schedule, timeout_s=None): + self.runs.append(schedule) + return _dataset({c: [v] for c, v in enumerate(self.per_channel)}) + + +class TestAFusedWalk: + """The walk's own plumbing: one acquisition, one fit per target.""" + + def _walk(self, backend, **parallel): + config = CalibrationConfig( + target_qubits=["q0", "q1", "q2"], + parallel=ParallelConfig(**parallel), + ) + report = CalibrationDAG([FusableProbe()], config).run( + device=None, backend=backend, config=config + ) + return report, {r.target: r.parameters["value"] for r in report.routine_results} + + def test_every_target_is_fitted_from_its_own_channel(self): + """The adversarial one. Channel zero for all three would give 10, 10, 10.""" + backend = ChannelBackend([10.0, 20.0, 30.0]) + + report, values = self._walk(backend, enabled=True, qubit_spacing=1) + + assert report.status == "success", report.errors + assert values == {"q0": 10.0, "q1": 20.0, "q2": 30.0} + + def test_one_acquisition_serves_the_whole_group(self): + backend = ChannelBackend([10.0, 20.0, 30.0]) + + self._walk(backend, enabled=True, qubit_spacing=1) + + assert len(backend.runs) == 1 + + def test_a_walk_that_did_not_ask_runs_one_acquisition_per_target(self): + backend = ChannelBackend([10.0, 20.0, 30.0]) + + report, values = self._walk(backend) + + assert len(backend.runs) == 3 + # Each ran alone, so each read channel zero — its own. + assert values == {"q0": 10.0, "q1": 10.0, "q2": 10.0} + + def test_a_target_with_no_channel_fails_and_the_rest_of_the_group_lands(self): + backend = ChannelBackend([10.0, 20.0]) + + report, values = self._walk(backend, enabled=True, qubit_spacing=1) + + assert values == {"q0": 10.0, "q1": 20.0} + assert report.status == "partial_failure" + assert any("q2" in error and "no channel" in error for error in report.errors) + + def test_a_failed_acquisition_is_recorded_against_every_target_in_the_group(self): + class Broken(ChannelBackend): + def run(self, schedule, timeout_s=None): + raise RuntimeError("the cluster went away") + + report, values = self._walk(Broken([]), enabled=True, qubit_spacing=1) + + assert values == {} + assert len(report.errors) == 3 + assert all("the cluster went away" in error for error in report.errors) + + def test_the_walk_names_every_target_in_flight(self): + backend = ChannelBackend([10.0, 20.0, 30.0]) + config = CalibrationConfig( + target_qubits=["q0", "q1", "q2"], + parallel=ParallelConfig(enabled=True, qubit_spacing=1), + ) + updates: list[dict] = [] + + CalibrationDAG([FusableProbe()], config).run( + device=None, + backend=backend, + config=config, + on_progress=updates.append, + ) + + starts = [u["running"] for u in updates if "running" in u] + assert starts == [["q0", "q1", "q2"]] + + +class TestEscalationOverAGroup: + """RFC 0009 §6.5 — one acquisition for the group, re-fused only for the refused.""" + + def _grouped(self, *qubits): + """The `_grouped` device, with a backend that answers and counts.""" + device, _stub = _grouped(*qubits) + return device, ChannelBackend([1.0] * len(qubits)) + + def _node(self, refuse_until): + """A fusable routine whose fit refuses *refuse_until* on the named targets.""" + + class Widening(FusableProbe): + name = "widening" + attempts: list[tuple[str, float]] = [] + + def build_group_schedule(self, targets, device, config, backend, sweeps): + # A real setpoint list, because that is what `_widened` stretches. + reach = [float(r) for r in config.get("reach", [0.0, 1.0])] + for target in targets: + sweeps[target]["reach"] = reach + return super().build_group_schedule( + targets, device, config, backend, sweeps + ) + + def analyse(self, dataset, target, device, config, sweep): + reach = max(sweep["reach"]) + self.attempts.append((target, reach)) + if reach < refuse_until.get(target, 0.0): + raise OutOfRange("too short", axis="reach", factor=4.0) + return {"reach": reach} + + node = Widening() + node.attempts = [] + return node + + def test_a_group_that_all_fits_is_one_acquisition(self): + node = self._node({}) + device, backend = self._grouped("q0", "q1", "q2") + targets = ["q0", "q1", "q2"] + + results = node.escalating_group( + targets, device, RoutineConfig(), backend, 60.0, _sweeps(targets) + ) + + assert set(results) == set(targets) + assert len(backend.runs) == 1, ( + "nothing refused, so nothing should be re-measured" + ) + + def test_only_the_refused_target_is_measured_again(self): + node = self._node({"q1": 3.0}) + device, backend = self._grouped("q0", "q1", "q2") + targets = ["q0", "q1", "q2"] + + results = node.escalating_group( + targets, device, RoutineConfig(), backend, 60.0, _sweeps(targets) + ) + + assert results["q1"]["reach"] == pytest.approx(4.0) + # q0 and q2 fitted on the first pass and were not swept over q1's wider range. + assert [t for t, _ in node.attempts if t == "q0"] == ["q0"] + assert [t for t, _ in node.attempts if t == "q2"] == ["q2"] + assert len(backend.runs) == 2 + + def test_two_targets_refusing_the_same_axis_are_refused_together(self): + node = self._node({"q0": 3.0, "q2": 3.0}) + device, backend = self._grouped("q0", "q1", "q2") + targets = ["q0", "q1", "q2"] + + node.escalating_group( + targets, device, RoutineConfig(), backend, 60.0, _sweeps(targets) + ) + + # One widened acquisition for both, not one each. + assert len(backend.runs) == 2 + + def test_a_target_that_never_fits_carries_its_refusal_and_the_rest_land(self): + node = self._node({"q1": 1e9}) + device, backend = self._grouped("q0", "q1") + targets = ["q0", "q1"] + + results = node.escalating_group( + targets, device, RoutineConfig(), backend, 60.0, _sweeps(targets) + ) + + assert isinstance(results["q1"], OutOfRange) + assert results["q0"]["reach"] == pytest.approx(1.0) + + def test_an_axis_the_operator_named_is_left_alone(self): + """RFC 0007 §7 — a named axis is a statement about the chip.""" + node = self._node({"q0": 3.0}) + device, backend = self._grouped("q0") + targets = ["q0"] + + results = node.escalating_group( + targets, + device, + RoutineConfig(params={"reach": [0.0, 1.0]}), + backend, + 60.0, + _sweeps(targets), + ) + + assert isinstance(results["q0"], OutOfRange) + assert len(backend.runs) == 1, "it must not widen an axis the operator set" + + +class TestAGridDerivedPerTargetSplitsTheGroup: + """RFC 0009 D7 — one schedule cannot hold two different sweeps of the same axis.""" + + def _device(self, **t1s): + return FakeDevice( + { + qubit: FakeElement( + name=qubit, + clock_freqs={"f01": 5.0e9, "f12": 4.75e9, "readout": 7.1e9}, + rxy={"amp180": 0.18, "motzoi": 0.0}, + measure={"pulse_amp": 0.25}, + coherence={"t1": t1}, + ) + for qubit, t1 in t1s.items() + }, + {}, + ) + + def test_targets_whose_windows_agree_stay_one_group(self): + node = _routine("t2_echo") + device = self._device(q0=40e-6, q1=40e-6) + + assert node.compatible_groups(["q0", "q1"], device, RoutineConfig()) == [ + ["q0", "q1"] + ] + + def test_targets_whose_windows_differ_are_split(self): + """An idle is dead time on every port at once, so there is no per-target time + axis — a qubit with twice the T1 wants twice the window and cannot share one.""" + node = _routine("t2_echo") + device = self._device(q0=40e-6, q1=90e-6, q2=40e-6) + + groups = node.compatible_groups(["q0", "q1", "q2"], device, RoutineConfig()) + + assert sorted(sorted(g) for g in groups) == [["q0", "q2"], ["q1"]] + + def test_a_stated_window_puts_every_target_back_together(self): + """An operator who names the delays has made one statement about the whole chip.""" + node = _routine("t2_echo") + device = self._device(q0=40e-6, q1=90e-6) + config = RoutineConfig(params={"delays": [0.0, 10e-6, 20e-6]}) + + assert node.compatible_groups(["q0", "q1"], device, config) == [["q0", "q1"]] + + def test_a_routine_whose_sweep_is_fixed_never_splits(self): + node = _routine("allxy") + + assert node.compatible_groups(["q0", "q1"], None, RoutineConfig()) == [ + ["q0", "q1"] + ] + + +def test_no_routine_keeps_per_target_state_on_itself(): + """RFC 0009 §6.6 — a value derived from one target and read back in `analyse` is the + bug the `Sweep` exists to prevent, and a grep for it is the only thing that scales. + + Found five that a narrower search missed: `_current_amp180`, `_current_amp90`, + `_current_f01`, `_current_f12` and `_f01` all name a digit, so a pattern of + `[a-z_]+` walked straight past them. + """ + import pathlib + import re + + import qpi_driver.tuners.routines as package + + root = pathlib.Path(package.__file__).parent + assignment = re.compile(r"^\s+self\._([a-z_0-9]+)\s*=", re.M) + offenders = { + module.name: sorted(set(assignment.findall(module.read_text()))) + for module in sorted(root.glob("*.py")) + if assignment.search(module.read_text()) + } + + assert not offenders, ( + "these keep per-target state on the routine, which a fused group shares: " + f"{offenders}" + ) + + +class TestARoutineWithItsOwnLoopIsNotBypassed: + """A fusable schedule must not cost a routine its `measure` (RFC 0009 §6.5). + + `ramsey` is the case: its loop refines f01 across several passes, applying to the + device between them. Giving it a group schedule made it `fusable`, and the walk would + then have taken the fused path — one acquisition, one fit, no refinement at all. + """ + + class _OwnLoop(FusableProbe): + name = "own_loop" + + def measure( + self, target, device, config, backend, sweep, bias=None, timeout_s=300.0 + ): + sweep["looped"] = True + return {"value": 1.0} + + def test_the_walk_runs_it_one_target_at_a_time(self): + node = self._OwnLoop() + assert node.fusable and node.measures_itself and not node.measures_group + + config = CalibrationConfig( + target_qubits=["q0", "q1", "q2"], + parallel=ParallelConfig(enabled=True, qubit_spacing=1), + ) + backend = ChannelBackend([1.0, 2.0, 3.0]) + report = CalibrationDAG([node], config).run( + device=None, backend=backend, config=config + ) + + assert report.status == "success" + # Its own loop ran for each target, and no fused acquisition was taken. + assert len(report.routine_results) == 3 + assert backend.runs == [] + + def test_it_groups_once_it_has_a_group_loop(self): + """The guard is about the loop being absent, not about the routine measuring itself.""" + node = _routine("t1") + assert node.measures_itself and node.measures_group + + +def test_an_operation_carrying_its_duration_directly_anchors_the_group(): + """`add_together` returns the longest operation so the next stage cannot start early, + and an operation may state its duration as an attribute rather than in kwargs.""" + from types import SimpleNamespace + + from qpi_driver.tuners.base.fusion import _duration_of + + assert _duration_of(SimpleNamespace(duration=4e-7)) == pytest.approx(4e-7) + assert _duration_of(SimpleNamespace(kwargs={"duration": 2e-7})) == pytest.approx( + 2e-7 + ) + # Neither, which is every gate whose length the device config decides. + assert _duration_of(SimpleNamespace(kwargs={})) == 0.0 + + +class TestTheAcceptanceMeasurement: + """RFC 0009 §5.6 — what turns `qubit_spacing` from a guess into a setting. + + Gambetta et al.'s measurement: benchmark each qubit alone, benchmark them together, + and the difference in average gate fidelity is the addressability. + """ + + class _Benchmark(FusableProbe): + """A benchmark whose fidelity is lower when it is measured in company.""" + + name = "penalised" + benchmark = True + alone = 0.999 + together = 0.990 + + def analyse(self, dataset, target, device, config, sweep): + fused = len(sweep.get("group", ())) > 1 + return {"fidelity": self.together if fused else self.alone} + + def build_group_schedule(self, targets, device, config, backend, sweeps): + for target in targets: + sweeps[target]["group"] = list(targets) + return super().build_group_schedule( + targets, device, config, backend, sweeps + ) + + def _walk(self, **parallel): + config = CalibrationConfig( + target_qubits=["q0", "q1", "q2"], + parallel=ParallelConfig(enabled=True, qubit_spacing=1, **parallel), + ) + backend = ChannelBackend([1.0, 1.0, 1.0]) + report = CalibrationDAG([self._Benchmark()], config).run( + device=None, backend=backend, config=config + ) + return report, backend + + def test_it_is_absent_unless_the_run_asks_for_it(self): + """It doubles what benchmarking costs, so it cannot be the default.""" + report, backend = self._walk() + + assert [b.parallel_penalty for b in report.benchmarks] == [None] * 3 + assert len(backend.runs) == 1, "one grouped acquisition and no control pass" + + def test_it_reports_what_company_cost_each_target(self): + report, backend = self._walk(measure_penalty=True) + + penalty = self._Benchmark.alone - self._Benchmark.together + assert [b.parallel_penalty for b in report.benchmarks] == [ + pytest.approx(penalty) + ] * 3 + # The grouped pass, then one isolated acquisition per target. + assert len(backend.runs) == 4 + + def test_the_reported_fidelity_stays_the_grouped_one(self): + """The fused numbers describe how the chip will actually be driven; the isolated + pass is a control, and its results are dropped.""" + report, _backend = self._walk(measure_penalty=True) + + assert [b.fidelity for b in report.benchmarks] == [ + pytest.approx(self._Benchmark.together) + ] * 3 + # Three benchmarks, not six: the control pass added no rows of its own. + assert len(report.benchmarks) == 3 + assert len(report.routine_results) == 3 + + def test_a_routine_that_is_not_a_benchmark_is_left_alone(self): + """There is no fidelity to difference, so there is nothing to measure.""" + config = CalibrationConfig( + target_qubits=["q0", "q1"], + parallel=ParallelConfig( + enabled=True, qubit_spacing=1, measure_penalty=True + ), + ) + backend = ChannelBackend([1.0, 2.0]) + CalibrationDAG([FusableProbe()], config).run( + device=None, backend=backend, config=config + ) + + assert len(backend.runs) == 1 + + +class TestNothingIsReadBeforeItIsDriven: + """A fused schedule's readout must follow every pulse it is meant to measure. + + The bug this exists for: a raw pulse — an EF drive, say — is added by a helper that + *appends*, so a group's pulses played one after another instead of together, and the + readout stayed anchored to whatever came before them. It then preceded some targets' + pulses entirely. Nothing raised: each target's own sequence was in order, so only the + timings show it. + """ + + def _timings(self, name, *targets): + node = _routine(name) + device, _stub = _grouped(*targets) + backend = StubBackend() + schedule = node.build_group_schedule( + list(targets), + device, + RoutineConfig(params={"shots": 8}), + backend, + _sweeps(targets), + ) + return schedule + + @pytest.mark.parametrize("name", ["rabi_12", "ef_ladder"]) + def test_every_readout_follows_every_drive_pulse(self, name): + schedule = self._timings(name, "q0", "q1") + + pulses = [ + p + for p in schedule.placed + if getattr(p.operation, "kind", "") in ("SquarePulse", "DRAGPulse") + ] + readouts_placed = [ + p for p in schedule.placed if getattr(p.operation, "kind", "") == "Measure" + ] + assert pulses and readouts_placed + + # Every pulse ends before the readout that comes after it starts. Compared + # against the *earliest* readout following each pulse, since the sweep repeats. + for pulse in pulses: + after = [r.start for r in readouts_placed if r.start >= pulse.start] + assert after, f"a {name} pulse at {pulse.start} has no readout after it" + assert min(after) >= pulse.end - 1e-15, ( + f"{name} reads at {min(after)} a pulse that ends at {pulse.end}" + ) + + @pytest.mark.parametrize("name", ["rabi_12", "ef_ladder"]) + def test_the_drive_pulses_of_a_group_coincide(self, name): + schedule = self._timings(name, "q0", "q1") + + starts = [ + p.start + for p in schedule.placed + if getattr(p.operation, "kind", "") in ("SquarePulse", "DRAGPulse") + ] + # Two targets per setpoint, so the starts come in equal pairs. + assert len(starts) % 2 == 0 + assert all( + starts[i] == pytest.approx(starts[i + 1]) for i in range(0, len(starts), 2) + ), "a group's pulses should start together, not queue" + + +class TestChunkingSurvivesFusion: + """A routine that splits its sweep across schedules must still split it in a group. + + The bug this exists for: the fused path called `build_group_schedule` and `run` + directly, so it went straight past `acquire` — where the split lives. `rb` chunks on + the shipped defaults (ten circuits over the shipped depths is 1270 Cliffords against + the 1000 one schedule holds), so every grouped RB would have built a program too long + to assemble, which is the failure the chunking exists to prevent. + """ + + def test_rb_still_chunks_when_it_is_grouped(self): + from qpi_driver.tuners.routines.benchmarks import ( + DEFAULT_RB_CIRCUITS, + DEFAULT_RB_DEPTHS, + MAX_RB_CLIFFORDS, + ) + + node = _routine("rb") + per_schedule = max(1, MAX_RB_CLIFFORDS // sum(DEFAULT_RB_DEPTHS)) + assert DEFAULT_RB_CIRCUITS > per_schedule, ( + "this test is only meaningful while the defaults chunk" + ) + + device, _stub = _grouped("q0", "q1") + # Enough acquisitions per channel for a chunk to unpack: the references plus one + # per depth per circuit. + wide = per_schedule * len(DEFAULT_RB_DEPTHS) + 2 + backend = _WideChannelBackend(channels=2, per_channel=wide) + targets = ["q0", "q1"] + node.acquire_group( + targets, device, RoutineConfig(), backend, 60.0, _sweeps(targets) + ) + + assert len(backend.runs) > 1, "a grouped RB must split its circuits too" + + def test_a_chunking_routine_is_not_fused_without_a_group_acquire(self): + """The guard that makes an unconverted one safe rather than silently unchunked.""" + + class Unconverted(FusableProbe): + name = "unconverted" + + def acquire(self, target, device, config, backend, timeout_s, sweep): + return super().acquire( + target, device, config, backend, timeout_s, sweep + ) + + assert Unconverted().chunks_acquisition + assert not _routine("rb").chunks_acquisition + + +class _WideChannelBackend(StubBackend): + """Answers every channel with *per_channel* points, and counts the acquisitions. + + `ChannelBackend` returns one point per channel, which is enough for a probe whose fit + reads a single value and not enough for a chunked sweep to unpack. + """ + + def __init__(self, channels: int, per_channel: int): + self.channels = channels + self.per_channel = per_channel + self.runs: list = [] + + def run(self, schedule, timeout_s=None): + self.runs.append(schedule) + return _dataset( + {c: list(range(self.per_channel)) for c in range(self.channels)} + ) + + +def test_a_group_loop_cannot_smuggle_past_the_chunking_guard(): + """The guard has to come before every grouped path, not only the fused one. + + `measures_group` was checked first, so a routine with a group loop but no + `acquire_group` would have reached `_fused_pass` — and through it the unchunked + default — with the guard never consulted. + """ + + class LoopButNoChunking(FusableProbe): + name = "smuggler" + + def acquire(self, target, device, config, backend, timeout_s, sweep): + return super().acquire(target, device, config, backend, timeout_s, sweep) + + def measure_group( + self, targets, device, config, backend, sweeps, bias=None, timeout_s=300.0 + ): + raise AssertionError("the guard should have run this one target at a time") + + def measure( + self, target, device, config, backend, sweep, bias=None, timeout_s=300.0 + ): + return {"value": 1.0} + + node = LoopButNoChunking() + assert node.chunks_acquisition and node.measures_group + + config = CalibrationConfig( + target_qubits=["q0", "q1"], + parallel=ParallelConfig(enabled=True, qubit_spacing=1), + ) + report = CalibrationDAG([node], config).run( + device=None, backend=ChannelBackend([1.0, 1.0]), config=config + ) + + assert report.status == "success" + assert len(report.routine_results) == 2 + + +class TestABiasSweepGroupsToo: + """A rack is shared; its channels are not (RFC 0009 §6.5, corrected twice over). + + `coupler_anticrossing` was the last routine said to be unable to group, on the grounds + that "a chip has one bias source". It has one *rack* — and an S4g has four current + outputs, a cluster has many baseband outputs, and each edge already names its own + (`bias.spi_output`, `bias.qcm_output`). So setting a group's currents is one write per + edge over the same port, then a single acquisition. + """ + + class _Rack: + """A bias source that holds a current, recording the order it was asked in.""" + + holds_current = True + + def __init__(self): + self.calls: list[tuple[str, float]] = [] + + def apply(self, edge, current_a, settings): + self.calls.append((edge, float(current_a))) + + def close(self): + pass + + def _device(self, *edges): + qubits = sorted({q for edge in edges for q in edge.split("_")}) + return FakeDevice( + { + qubit: FakeElement( + name=qubit, + clock_freqs={"f01": 5.0e9, "f12": 4.75e9, "readout": 7.1e9}, + rxy={"amp180": 0.18, "motzoi": 0.0}, + measure={"pulse_amp": 0.25}, + ) + for qubit in qubits + }, + { + edge: FakeElement(name=edge, bias={"parking_current": 0.0}) + for edge in edges + }, + ) + + def test_every_coupler_is_biased_before_each_single_acquisition(self): + node = _routine("coupler_anticrossing") + edges = ["q0_q1", "q2_q3"] + device = self._device(*edges) + rack = self._Rack() + backend = ChannelBackend([1.0, 1.0]) + config = RoutineConfig( + params={"shots": 8, "points": 3, "currents": [0.0, 1e-3]} + ) + + node.measure_group( + edges, device, config, backend, _sweeps(edges), rack, timeout_s=60.0 + ) + + # Two current setpoints, so two acquisitions for the pair — not four. + assert len(backend.runs) == 2 + # Both couplers set at each setpoint, and both restored at the end. + swept = [c for c in rack.calls if c[1] in (0.0, 1e-3)] + assert ("q0_q1", 1e-3) in swept and ("q2_q3", 1e-3) in swept + assert rack.calls[-2:] == [("q0_q1", 0.0), ("q2_q3", 0.0)] + + def test_it_declines_the_whole_group_without_a_real_source(self): + """A recorder makes the sweep flat and the fit confident — worse than declining.""" + node = _routine("coupler_anticrossing") + edges = ["q0_q1", "q2_q3"] + + outcomes = node.measure_group( + edges, + self._device(*edges), + RoutineConfig(), + StubBackend(), + _sweeps(edges), + None, + ) + + assert set(outcomes) == set(edges) + assert all(isinstance(v, RoutineError) for v in outcomes.values()) diff --git a/qpi-driver/py/tests/test_calibration_grouping.py b/qpi-driver/py/tests/test_calibration_grouping.py new file mode 100644 index 00000000..ad57a609 --- /dev/null +++ b/qpi-driver/py/tests/test_calibration_grouping.py @@ -0,0 +1,465 @@ +"""Which targets may be measured at once (RFC 0009 §5).""" + +import logging +from types import SimpleNamespace + +import pytest + +from qpi_driver.tuners.base.config import ( + DEFAULT_EDGE_SPACING, + DEFAULT_MAX_GROUP, + DEFAULT_QUBIT_SPACING, + CalibrationConfig, + ConfigError, + ParallelConfig, +) +from qpi_driver.tuners.base.dag import CalibrationDAG +from qpi_driver.tuners.base.grouping import ( + by_output, + couplings_of, + endpoints_of, + groups_of, + is_too_close, + outputs_of, + readout_misfit, + split_to_fit, +) +from tests.test_calibration_dag import StubRoutine +from tests.utils.chips import chain, heavy_hex, lattice, qubits_of +from qpi_driver.tuners.base.fusion import ( + grouped_by_grid, + grouped_by_size, +) + +#: Wide enough not to be the thing under test where the topology is. +UNBOUNDED = 999 + + +def _groups(edges, targets, spacing, **kwargs): + return groups_of( + targets, + adjacency=couplings_of(edges), + spacing=spacing, + max_group=kwargs.pop("max_group", UNBOUNDED), + **kwargs, + ) + + +class TestWhatATargetOccupies: + def test_a_qubit_is_its_own_endpoint(self): + assert endpoints_of("q3") == ("q3",) + + def test_an_edge_is_both_its_qubits(self): + assert endpoints_of("q0_q1") == ("q0", "q1") + + def test_a_name_that_is_not_a_pair_stays_whole(self): + """Otherwise a stray underscore would silently become a two-qubit target.""" + assert endpoints_of("q0_") == ("q0_",) + assert endpoints_of("q0_q1_q2") == ("q0_q1_q2",) + + +class TestTheCouplingGraph: + def test_an_edge_couples_both_ways(self): + assert couplings_of(["q0_q1"]) == {"q0": {"q1"}, "q1": {"q0"}} + + def test_a_chain_couples_only_its_neighbours(self): + adjacency = couplings_of(chain(5)) + assert adjacency["q0"] == {"q1"} + assert adjacency["q2"] == {"q1", "q3"} + + def test_a_name_that_is_not_an_edge_couples_nothing(self): + assert couplings_of(["q0"]) == {} + + +class TestHowFarApartIsFarEnough: + """`spacing` is the minimum distance, so conflict is distance < spacing.""" + + def setup_method(self): + self.chain = couplings_of(chain(5)) + + def test_a_spacing_of_one_constrains_nothing(self): + assert not is_too_close("q0", "q1", self.chain, 1) + + def test_the_default_spacing_excludes_adjacent_qubits(self): + assert is_too_close("q0", "q1", self.chain, DEFAULT_QUBIT_SPACING) + assert not is_too_close("q0", "q2", self.chain, DEFAULT_QUBIT_SPACING) + + def test_a_spacing_of_three_wants_two_qubits_between(self): + assert is_too_close("q0", "q2", self.chain, 3) + assert not is_too_close("q0", "q3", self.chain, 3) + + def test_couplers_sharing_a_qubit_are_never_far_enough(self): + assert is_too_close("q0_q1", "q1_q2", self.chain, DEFAULT_EDGE_SPACING) + + def test_disjoint_couplers_clear_the_default_edge_spacing(self): + """Adjacent but disjoint: `edge_spacing` 1 asks only that they share no qubit.""" + assert not is_too_close("q0_q1", "q2_q3", self.chain, DEFAULT_EDGE_SPACING) + + def test_a_higher_edge_spacing_puts_a_qubit_between_two_couplers(self): + assert is_too_close("q0_q1", "q2_q3", self.chain, 2) + assert not is_too_close("q0_q1", "q3_q4", self.chain, 2) + + def test_a_spacing_of_zero_is_no_question_at_all(self): + assert not is_too_close("q0", "q0", self.chain, 0) + + def test_an_unknown_qubit_conflicts_with_nothing_but_itself(self): + assert not is_too_close("q9", "q0", self.chain, 4) + + +class TestTheGroupsATopologyAllows: + """The measured figures behind RFC 0009 §5.5. + + Each topology is asserted at two sizes, which is what pins the claim that the + group count follows the connectivity graph and not the qubit count. + """ + + @pytest.mark.parametrize("qubits", [5, 10]) + def test_a_chain_needs_two_groups_at_the_default_spacing(self, qubits): + edges = chain(qubits) + groups = _groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING) + assert len(groups) == 2 + + @pytest.mark.parametrize("side", [3, 5]) + def test_a_lattice_needs_two_groups_at_the_default_spacing(self, side): + """Two whatever the size, because a lattice is bipartite.""" + edges = lattice(side) + groups = _groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING) + assert len(groups) == 2 + + def test_heavy_hex_needs_two_groups_at_the_default_spacing(self): + edges = heavy_hex() + assert len(_groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING)) == 2 + + def test_a_chain_needs_three_groups_with_two_qubits_between(self): + edges = chain(10) + assert len(_groups(edges, qubits_of(edges), 3)) == 3 + + def test_greedy_exceeds_the_optimum_on_a_lattice_at_spacing_three(self): + """Measured, not derived. The five-colour Lee tiling is the *optimum* for an + infinite lattice; greedy colouring reaches six or seven, which costs runtime + and never correctness. The default spacing is where greedy is exact.""" + edges = lattice(5) + assert len(_groups(edges, qubits_of(edges), 3)) == 7 + + @pytest.mark.parametrize( + "edges,expected", [(chain(10), 2), (lattice(5), 4), (heavy_hex(), 3)] + ) + def test_couplers_group_into_one_class_per_degree(self, edges, expected): + """Vizing's bound, reached: a chain is degree 2, a lattice 4, heavy-hex 3.""" + assert len(_groups(edges, edges, DEFAULT_EDGE_SPACING)) == expected + + def test_every_target_lands_in_exactly_one_group(self): + edges = lattice(4) + targets = qubits_of(edges) + groups = _groups(edges, targets, DEFAULT_QUBIT_SPACING) + + placed = [target for group in groups for target in group] + assert sorted(placed) == sorted(targets) + assert len(placed) == len(set(placed)) + + def test_no_group_holds_two_targets_that_are_too_close(self): + edges = lattice(4) + adjacency = couplings_of(edges) + groups = _groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING) + + for group in groups: + for i, first in enumerate(group): + for second in group[i + 1 :]: + assert not is_too_close( + first, second, adjacency, DEFAULT_QUBIT_SPACING + ) + + def test_the_same_config_always_produces_the_same_groups(self): + edges = lattice(4) + targets = qubits_of(edges) + once = _groups(edges, targets, DEFAULT_QUBIT_SPACING) + assert _groups(edges, targets, DEFAULT_QUBIT_SPACING) == once + + def test_an_excluded_pair_is_never_grouped(self): + edges = chain(5) + groups = _groups( + edges, qubits_of(edges), DEFAULT_QUBIT_SPACING, exclude=[["q0", "q2"]] + ) + + assert not any({"q0", "q2"} <= set(group) for group in groups) + + def test_a_coupler_is_excluded_through_either_of_its_qubits(self): + edges = chain(5) + groups = _groups(edges, edges, DEFAULT_EDGE_SPACING, exclude=[["q0", "q3"]]) + + assert not any({"q0_q1", "q2_q3"} <= set(group) for group in groups) + + def test_an_exclusion_that_is_not_a_pair_is_ignored(self): + edges = chain(5) + groups = _groups( + edges, qubits_of(edges), DEFAULT_QUBIT_SPACING, exclude=[["q0"]] + ) + assert len(groups) == 2 + + def test_max_group_caps_a_class(self): + edges = chain(10) + groups = _groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING, max_group=2) + + assert all(len(group) <= 2 for group in groups) + assert sum(len(group) for group in groups) == 10 + + +class TestWhatOneOutputCanPlayAtOnce: + """§5.4 — three ceilings, all arithmetic on the configs.""" + + def test_readouts_inside_the_band_and_the_scale_fit(self): + assert readout_misfit([6.4e9, 6.8e9], [0.03, 0.04], band_hz=500e6) is None + + def test_a_lone_readout_is_never_out_of_band(self): + assert readout_misfit([6.4e9], [0.03], band_hz=1.0) is None + + def test_clocks_further_apart_than_the_band_do_not_fit(self): + reason = readout_misfit([6.0e9, 7.5e9], [0.03, 0.03], band_hz=500e6) + + assert reason is not None + # The figure and the ceiling, so an operator can act on it. + assert "750.0 MHz from the band centre" in reason + assert "500 MHz" in reason + + def test_amplitudes_that_would_clip_do_not_fit(self): + reason = readout_misfit([6.4e9] * 3, [0.4, 0.4, 0.4], band_hz=500e6) + + assert reason is not None and "1.200 of full scale" in reason + + def test_more_clocks_than_the_module_has_sequencers_do_not_fit(self): + reason = readout_misfit([6.4e9] * 7, [0.01] * 7, band_hz=500e6, sequencers=6) + + assert reason is not None and "the module has 6" in reason + + def test_a_group_that_fits_is_left_alone(self): + assert split_to_fit(["q0", "q1"], lambda _: None) == [["q0", "q1"]] + + def test_a_group_that_does_not_fit_is_bisected_until_it_does(self): + def misfit(group): + return "too many" if len(group) > 2 else None + + assert split_to_fit(["q0", "q1", "q2", "q3"], misfit) == [ + ["q0", "q1"], + ["q2", "q3"], + ] + + def test_a_single_target_that_cannot_fit_runs_alone_and_says_so(self, caplog): + with caplog.at_level(logging.WARNING): + assert split_to_fit(["q0"], lambda _: "nothing fits") == [["q0"]] + + assert "q0 alone does not fit" in caplog.text + + +class TestReadingTheWiring: + def _device(self, pairs, as_graph=False): + graph = SimpleNamespace(edges=pairs) if as_graph else pairs + return SimpleNamespace( + hardware_config=lambda: SimpleNamespace( + connectivity=SimpleNamespace(graph=graph) + ) + ) + + def test_it_maps_a_port_to_the_output_it_hangs_off(self): + wiring = outputs_of(self._device([["clusterA.module20.out0", "q0:res"]])) + + assert wiring == {"q0:res": "clusterA.module20.out0"} + + def test_either_end_may_be_the_port(self): + """A graph object may present an edge in either direction.""" + wiring = outputs_of(self._device([["q0:res", "clusterA.module20.out0"]])) + + assert wiring == {"q0:res": "clusterA.module20.out0"} + + def test_a_graph_object_is_read_through_its_edges(self): + wiring = outputs_of( + self._device([("clusterA.module20.out0", "q0:res")], as_graph=True) + ) + + assert wiring == {"q0:res": "clusterA.module20.out0"} + + def test_unreadable_wiring_imposes_no_constraint(self): + assert outputs_of(SimpleNamespace()) == {} + + def test_targets_are_grouped_by_the_output_they_share(self): + wiring = {"q0:res": "out0", "q1:res": "out0", "q2:res": "out1"} + + assert by_output(["q0", "q1", "q2"], wiring, "res") == { + "out0": ["q0", "q1"], + "out1": ["q2"], + } + + def test_a_target_the_wiring_does_not_name_stays_with_the_rest(self): + """An unreadable or partial wiring must not quietly drop the checks.""" + assert by_output(["q0", "q1"], {}, "res") == {"res": ["q0", "q1"]} + + +class TestTheParallelConfig: + def test_it_is_off_unless_the_file_says_otherwise(self): + assert not CalibrationConfig.from_dict({}).parallel.enabled + + def test_it_reads_the_spacings_and_the_ceiling(self): + config = ParallelConfig.from_dict( + {"enabled": True, "qubit_spacing": 3, "edge_spacing": 2, "max_group": 4} + ) + + assert config.enabled + assert config.spacing_for("qubits") == 3 + assert config.spacing_for("edges") == 2 + assert config.max_group == 4 + + def test_the_defaults_are_the_conservative_ones(self): + config = ParallelConfig.from_dict({"enabled": True}) + + assert config.spacing_for("qubits") == DEFAULT_QUBIT_SPACING + assert config.spacing_for("edges") == DEFAULT_EDGE_SPACING + assert config.max_group == DEFAULT_MAX_GROUP + + @pytest.mark.parametrize( + "data,message", + [ + ({"qubit_spacing": "wide"}, "must be a whole number"), + ({"max_group": 0}, "must be at least 1"), + ({"exclude": [["q0", "q1", "q2"]]}, "takes pairs"), + ({"groups": {"couplers": []}}, "knows 'qubits' and 'edges'"), + ({"groups": []}, "must be a mapping"), + ({"exclude": {"q0": "q1"}}, "must be a list of target pairs"), + ], + ) + def test_a_setting_it_cannot_use_is_a_startup_error(self, data, message): + with pytest.raises(ConfigError, match=message): + ParallelConfig.from_dict(data) + + def test_a_parallel_block_that_is_not_a_mapping_is_a_startup_error(self): + with pytest.raises(ConfigError, match="'parallel' must be a mapping"): + CalibrationConfig.from_dict({"parallel": ["enabled"]}) + + +class TestTheGroupsAWalkPublishes: + """`CalibrationDAG.groups_for`, and the `groups` key it puts on the plan.""" + + def _dag(self, **parallel): + config = CalibrationConfig( + target_qubits=["q0", "q1", "q2", "q3", "q4"], + target_edges=chain(5), + parallel=ParallelConfig(**parallel), + ) + return CalibrationDAG([StubRoutine("a")], config), config + + def test_a_walk_that_did_not_ask_runs_one_target_at_a_time(self): + """The default has to leave every existing chip calibrating as it did.""" + dag, config = self._dag() + + assert dag.groups_for("a", config) == [["q0"], ["q1"], ["q2"], ["q3"], ["q4"]] + + def test_enabling_it_groups_by_the_coupling_graph(self): + dag, config = self._dag(enabled=True) + + assert dag.groups_for("a", config) == [["q0", "q2", "q4"], ["q1", "q3"]] + + def test_explicit_groups_skip_the_colouring(self): + dag, config = self._dag(enabled=True, groups={"qubits": [["q0", "q1"], ["q2"]]}) + + groups = dag.groups_for("a", config) + + assert groups[:2] == [["q0", "q1"], ["q2"]] + + def test_a_target_the_explicit_groups_forgot_is_still_calibrated(self): + dag, config = self._dag(enabled=True, groups={"qubits": [["q0", "q2"]]}) + + assert dag.groups_for("a", config) == [["q0", "q2"], ["q1"], ["q3"], ["q4"]] + + def test_explicit_groups_cannot_add_a_target_the_run_excludes(self): + """A config naming a qubit this run does not walk would otherwise put it back.""" + dag, config = self._dag(enabled=True, groups={"qubits": [["q0", "q9"]]}) + + placed = [target for group in dag.groups_for("a", config) for target in group] + assert "q9" not in placed + + def test_the_plan_carries_the_groups_for_the_drawing(self): + dag, config = self._dag(enabled=True) + + node = dag.plan(["a"], config)["nodes"][0] + + assert node["groups"] == [["q0", "q2", "q4"], ["q1", "q3"]] + + def test_a_single_target_needs_no_colouring(self): + config = CalibrationConfig( + target_qubits=["q0"], parallel=ParallelConfig(enabled=True) + ) + dag = CalibrationDAG([StubRoutine("a")], config) + + assert dag.groups_for("a", config) == [["q0"]] + + def test_an_unknown_routine_has_no_groups(self): + dag, config = self._dag(enabled=True) + + assert dag.groups_for("nope", config) == [] + + +class TestAnUnreadableCouplingGraph: + """No adjacency must not read as nothing being adjacent (RFC 0009 §5.2).""" + + def _groups(self, targets, edges, spacing=DEFAULT_QUBIT_SPACING): + config = CalibrationConfig( + target_qubits=list(targets), + target_edges=list(edges), + parallel=ParallelConfig(enabled=True, qubit_spacing=spacing), + ) + dag = CalibrationDAG([StubRoutine("a")], config) + return dag.groups_for("a", config) + + def test_targets_run_one_at_a_time_when_nothing_says_what_couples(self): + """Otherwise every qubit sits at infinite distance and lands in one group — the + most aggressive setting there is, arrived at by accident.""" + assert self._groups(["q0", "q1", "q2"], []) == [["q0"], ["q1"], ["q2"]] + + def test_a_spacing_of_one_needs_no_coupling_graph(self): + """It imposes nothing, so there is nothing for an absent graph to get wrong — + the operator has asked for the whole chip at once and said so.""" + assert self._groups(["q0", "q1", "q2"], [], spacing=1) == [["q0", "q1", "q2"]] + + def test_one_target_needs_no_coupling_graph(self): + assert self._groups(["q0"], []) == [["q0"]] + + def test_a_configured_edge_is_enough_to_group_from(self): + groups = self._groups(["q0", "q1", "q2"], ["q0_q1", "q1_q2"]) + + assert sorted(sorted(g) for g in groups) == [["q0", "q2"], ["q1"]] + + +class TestWhichAxesMustAgree: + """RFC 0009 D7 — shared hardware must agree exactly; per-target hardware need not. + + A time axis is the schedule's own timeline: an idle of 5 us is 5 us for everyone, so + two targets wanting different delays cannot be fused. A frequency, amplitude or phase + is per-target hardware — its own NCO, port and clock — so at setpoint *i* each target + may sit at its own value and only the point count has to match. Getting this wrong in + the strict direction costs fusion on every spectroscopy sweep, since each is centred on + its own target's line. + """ + + def test_a_shared_axis_splits_on_differing_values(self): + grids = {"q0": [0.0, 1e-6], "q1": [0.0, 2e-6], "q2": [0.0, 1e-6]} + + groups = grouped_by_grid(["q0", "q1", "q2"], lambda t: grids[t]) + + assert sorted(sorted(g) for g in groups) == [["q0", "q2"], ["q1"]] + + def test_a_per_target_axis_keeps_differing_values_together(self): + """Every edge centred on its own CZ clock still fuses, which is the point.""" + bands = { + "q0_q1": [3.9e9, 4.0e9, 4.1e9], + "q1_q2": [5.1e9, 5.2e9, 5.3e9], + } + + groups = grouped_by_size(["q0_q1", "q1_q2"], lambda t: bands[t]) + + assert groups == [["q0_q1", "q1_q2"]] + + def test_a_per_target_axis_still_splits_on_differing_lengths(self): + """The acquisition index is shared, so the point counts have to agree.""" + bands = {"q0": [1.0, 2.0, 3.0], "q1": [1.0, 2.0], "q2": [4.0, 5.0, 6.0]} + + groups = grouped_by_size(["q0", "q1", "q2"], lambda t: bands[t]) + + assert sorted(sorted(g) for g in groups) == [["q0", "q2"], ["q1"]] diff --git a/qpi-driver/py/tests/test_calibration_sweep.py b/qpi-driver/py/tests/test_calibration_sweep.py new file mode 100644 index 00000000..9a1b23e4 --- /dev/null +++ b/qpi-driver/py/tests/test_calibration_sweep.py @@ -0,0 +1,49 @@ +"""What a target's `Sweep` carries, and what it says when asked for what it has not.""" + +import pytest +from qpi_driver.tuners.base.sweep import Sweep + + +def test_it_carries_setpoints_by_axis_name(): + sweep = Sweep("q0") + sweep["delays"] = [0.0, 1e-6] + + assert sweep["delays"] == [0.0, 1e-6] + assert "delays" in sweep + assert "amplitudes" not in sweep + + +def test_axes_can_be_supplied_at_construction(): + sweep = Sweep("q1", depths=[1, 2, 4]) + + assert sweep["depths"] == [1, 2, 4] + assert sweep.target == "q1" + + +def test_a_missing_axis_names_the_target_and_what_was_swept(): + """`_widened` reads an axis by the name a refusal gave it, so a typo has to say so + rather than raising a bare KeyError against a name nobody can place.""" + sweep = Sweep("q2") + sweep["frequencies"] = [1.0] + + with pytest.raises(KeyError, match="q2 swept no 'motzois'; it swept frequencies"): + sweep["motzois"] + + +def test_an_empty_sweep_says_it_swept_nothing(): + with pytest.raises(KeyError, match="swept nothing"): + Sweep("q0")["delays"] + + +def test_get_falls_back_rather_than_raising(): + """Several routines read an axis they may not have swept — a check schedule that + reuses the calibration's grid only when there is one.""" + assert Sweep("q0").get("delays") is None + assert Sweep("q0").get("delays", ()) == () + + +def test_it_reprs_its_target_and_axes(): + """It appears in test failures and log lines, so it has to name the target.""" + assert repr(Sweep("q0", delays=[1], amplitudes=[2])) == ( + "Sweep('q0', amplitudes, delays)" + ) diff --git a/qpi-driver/py/tests/test_parallel_savings.py b/qpi-driver/py/tests/test_parallel_savings.py new file mode 100644 index 00000000..d782f01d --- /dev/null +++ b/qpi-driver/py/tests/test_parallel_savings.py @@ -0,0 +1,120 @@ +"""What grouping actually saves, measured rather than argued (RFC 0009 §5.5). + +Skipped unless ``QPI_BENCH=1``, because it walks the whole graph twice and that is minutes +rather than the seconds the rest of the suite takes. Run it with:: + + make bench-parallel + +The number that matters is **acquisitions**, not wall clock. Each acquisition is one +arm-and-wait cycle on the cluster, and a fused schedule's pulses are one target's — the +sequencers play concurrently — so on hardware the saving is the ratio of cycles. Wall clock +here is the simulator's, which integrates a Hamiltonian per operation and so scales with +the *schedule* rather than with the instrument; it is reported for information and is not +the claim. +""" + +import os +import time + +import pytest +from qpi_driver.tuners.base.config import ( + CalibrationConfig, + ParallelConfig, + RoutineConfig, +) +from qpi_driver.tuners.base.dag import CalibrationDAG +from qpi_driver.tuners.routines import all_routines + +pytestmark = pytest.mark.skipif( + os.environ.get("QPI_BENCH") != "1", + reason="a whole-graph benchmark; set QPI_BENCH=1 or run `make bench-parallel`", +) + +#: The chip RFC 0009 §5.5's table is about at its smallest interesting size: a linear chain +#: of three qubits and the two couplers between them. Small enough to walk twice in a test, +#: and large enough that a spacing of 2 gives two qubit groups and one edge group. +QUBITS = ["q0", "q1", "q2"] +EDGES = ["q0_q1", "q1_q2"] + +#: Shots trimmed to the smallest thing that still walks every routine. The benchmark counts +#: acquisitions, and how long each one takes does not change the ratio — but it does change +#: how long the benchmark takes, and a full-size walk is not something to put in front of a +#: reviewer. +BENCH_SHOTS = 8 + + +def _config(**parallel): + routines = { + r.name: RoutineConfig(params={"shots": BENCH_SHOTS}) for r in all_routines() + } + return CalibrationConfig( + target_qubits=list(QUBITS), + target_edges=list(EDGES), + routines=routines, + parallel=ParallelConfig(**parallel) if parallel else ParallelConfig(), + ) + + +class _CountingBackend: + """Wraps a backend and counts the acquisitions asked of it.""" + + def __init__(self, inner): + self._inner = inner + self.acquisitions = 0 + + def __getattr__(self, name): + return getattr(self._inner, name) + + def run(self, schedule, timeout_s=None): + self.acquisitions += 1 + return self._inner.run(schedule, timeout_s=timeout_s) + + +def _walk(parallel: dict) -> tuple[int, float, int]: + """Walk the whole graph once. Returns (acquisitions, seconds, routines that ran).""" + from tests.utils.simulation import SimulatedTuner + + tuner = SimulatedTuner(qubits=tuple(QUBITS), edges=tuple(EDGES)) + config = _config(**parallel) + backend = _CountingBackend(tuner.backend) + started = time.monotonic() + report = CalibrationDAG(all_routines(), config).run( + device=tuner.device, backend=backend, config=config + ) + elapsed = time.monotonic() - started + return backend.acquisitions, elapsed, len(report.routine_results) + + +def test_grouping_costs_fewer_acquisitions_than_walking_one_at_a_time(): + """The claim of RFC 0009 §1, on the smallest chip that can show it.""" + serial, serial_s, serial_ran = _walk({}) + grouped, grouped_s, grouped_ran = _walk( + {"enabled": True, "qubit_spacing": 2, "edge_spacing": 1} + ) + + reachable = len(QUBITS) * sum( + 1 for r in all_routines() if r.targets == "qubits" + ) + len(EDGES) * sum(1 for r in all_routines() if r.targets == "edges") + print( + f"\n chain of {len(QUBITS)} qubits and {len(EDGES)} couplers\n" + f" serial : {serial:5d} acquisitions, {serial_s:7.1f}s\n" + f" grouped: {grouped:5d} acquisitions, {grouped_s:7.1f}s\n" + f" saving : {serial / max(grouped, 1):.2f}x fewer acquisitions\n" + f"\n Measured over the {serial_ran} routine-targets that complete against the\n" + f" simulator, of {reachable} the graph defines — most of the rest have no physics\n" + f" here and are skipped, so this is a floor rather than the full-graph figure.\n" + f" RFC 0009 §5.5 gives that one as arithmetic: routines x groups against\n" + f" routines x targets, which on this chain is 2 qubit groups and 1 edge group.\n" + f" Wall clock is the simulator's, which integrates per operation and so scales\n" + f" with the schedule rather than with an instrument. It is not the claim." + ) + + assert grouped < serial, ( + f"grouping took {grouped} acquisitions against {serial} sequentially, " + f"which is no saving at all" + ) + # Both walks have to measure the same chip, or the comparison is between two + # different runs rather than between two ways of doing one. + assert grouped_ran == serial_ran, ( + f"grouped produced {grouped_ran} results against {serial_ran} sequentially" + ) diff --git a/qpi-driver/py/tests/test_physics_simulation.py b/qpi-driver/py/tests/test_physics_simulation.py index f4ab5386..2b42de84 100644 --- a/qpi-driver/py/tests/test_physics_simulation.py +++ b/qpi-driver/py/tests/test_physics_simulation.py @@ -27,6 +27,7 @@ from tests.utils.simulation import SimulatedTuner from qpi_driver.tuners.base.config import RoutineConfig from qpi_driver.tuners.base.routines import RoutineError +from qpi_driver.tuners.base.sweep import Sweep from qpi_driver.tuners.fitting import FitError, fit_rb_decay from qpi_driver.tuners.routines import all_routines @@ -179,21 +180,22 @@ def test_qubit_spectroscopy_finds_the_transmons_real_f01(self, simulator): The device is handed an f01 that is 3 MHz off, so the routine has to scan around it and find the true one rather than being given it. """ + sweep = Sweep("q0") device = device_for(simulator) spectroscopy = routine("qubit_spectroscopy") config = RoutineConfig(params={"span": 30e6, "points": 61}) - spectroscopy.build_schedule("q0", device, config, StubBackend()) + spectroscopy.build_schedule("q0", device, config, StubBackend(), sweep) acquisition = simulator.qubit_spectroscopy( - spectroscopy._frequencies, spectroscopy._drive_amps + sweep["frequencies"], sweep["drive_amps"] ) - fitted = spectroscopy.analyse(acquisition, "q0", device, config) + fitted = spectroscopy.analyse(acquisition, "q0", device, config, sweep) assert fitted["clock_freq_01"] == pytest.approx(simulator.f01 * GHZ, abs=5e4) # Chosen from the sweep, not from the config: the master equation broadens the # line at the top of the range and buries it in noise at the bottom, so a power # in between has to win on its own. - assert fitted["drive_amplitude"] in spectroscopy._drive_amps + assert fitted["drive_amplitude"] in sweep["drive_amps"] # Applying it moves the device onto the true frequency. spectroscopy.apply(device, "q0", fitted) @@ -227,11 +229,12 @@ def test_a_configured_f01_hundreds_of_mhz_out_is_still_located(self, simulator): chip = dataclasses.replace(simulator) device = device_for(chip) node = routine("qubit_spectroscopy") + sweep = Sweep("q0") true_f01 = chip.f01 * GHZ write_path(device.get_element("q0"), "clock_freqs.f01", true_f01 - 250e6) found, width = node._search( - "q0", device, RoutineConfig(params={}), SimulatedBackend(chip), 300.0 + "q0", device, RoutineConfig(params={}), SimulatedBackend(chip), 300.0, sweep ) # Within a step of the 2 MHz grid. Locating is all this pass owes; the narrow @@ -257,6 +260,7 @@ def test_a_search_that_finds_nothing_says_so_rather_than_fitting_noise( chip = dataclasses.replace(simulator) device = device_for(chip) node = routine("qubit_spectroscopy") + sweep = Sweep("q0") write_path(device.get_element("q0"), "clock_freqs.f01", chip.f01 * GHZ - 3e9) with pytest.raises(RoutineError, match="nothing above the noise between"): @@ -266,6 +270,7 @@ def test_a_search_that_finds_nothing_says_so_rather_than_fitting_noise( RoutineConfig(params={"search_points": 101}), SimulatedBackend(chip), 300.0, + sweep, ) def test_scanning_the_wrong_window_cannot_invent_the_right_answer(self, simulator): @@ -283,6 +288,7 @@ def test_scanning_the_wrong_window_cannot_invent_the_right_answer(self, simulato never report a frequency it did not look at, and the error is bounded by the operator's own sweep rather than by the optimiser's imagination. """ + sweep = Sweep("q0") spectroscopy = routine("qubit_spectroscopy") device = device_for(simulator) offset = 500e6 # nowhere near the real line @@ -294,12 +300,12 @@ def test_scanning_the_wrong_window_cannot_invent_the_right_answer(self, simulato } ) - spectroscopy.build_schedule("q0", device, config, StubBackend()) - acquisition = simulator.qubit_spectroscopy(spectroscopy._frequencies) - scanned = spectroscopy._frequencies + spectroscopy.build_schedule("q0", device, config, StubBackend(), sweep) + acquisition = simulator.qubit_spectroscopy(sweep["frequencies"]) + scanned = sweep["frequencies"] try: - fitted = spectroscopy.analyse(acquisition, "q0", device, config) + fitted = spectroscopy.analyse(acquisition, "q0", device, config, sweep) except (FitError, RoutineError): return # refusing outright is the other acceptable outcome @@ -316,26 +322,28 @@ class TestTimeDomainRoutines: def test_rabi_finds_the_pi_pulse_from_simulated_dynamics(self, simulator): """The oscillation emerges from integrating the drive, not from a cosine.""" + sweep = Sweep("q0") rabi = routine("rabi") device = device_for(simulator) config = RoutineConfig(params={"amplitudes": list(np.linspace(0.0, 0.5, 41))}) - rabi.build_schedule("q0", device, config, StubBackend()) - acquisition = simulator.rabi(rabi._amplitudes) - fitted = rabi.analyse(acquisition, "q0", device, config) + rabi.build_schedule("q0", device, config, StubBackend(), sweep) + acquisition = simulator.rabi(sweep["amplitudes"]) + fitted = rabi.analyse(acquisition, "q0", device, config, sweep) # The simulator is built so a pi rotation lands at 0.2 in the sweep's units. assert fitted["amp180"] == pytest.approx(0.2, rel=0.03) def test_t1_recovers_the_simulated_relaxation_time(self, simulator): """The decay comes from a collapse operator, not from an exponential.""" + sweep = Sweep("q0") t1 = routine("t1") device = device_for(simulator) config = RoutineConfig(params={"delays": list(np.linspace(0.0, 80e-6, 25))}) - t1.build_schedule("q0", device, config, StubBackend()) - acquisition = simulator.t1(t1._delays) - fitted = t1.analyse(acquisition, "q0", device, config) + t1.build_schedule("q0", device, config, StubBackend(), sweep) + acquisition = simulator.t1(sweep["delays"]) + fitted = t1.analyse(acquisition, "q0", device, config, sweep) # The correct model recovers T1 to 0.01%; a Gaussian decay fitted to this # same data lands 7% out, so 2% is what makes this test discriminating @@ -344,13 +352,14 @@ def test_t1_recovers_the_simulated_relaxation_time(self, simulator): def test_t2_echo_recovers_the_simulated_dephasing_time(self, simulator): """A Hahn echo, evolved through both halves with the refocusing pulse between.""" + sweep = Sweep("q0") t2 = routine("t2_echo") device = device_for(simulator) config = RoutineConfig(params={"delays": list(np.linspace(0.0, 60e-6, 25))}) - t2.build_schedule("q0", device, config, StubBackend()) - acquisition = simulator.t2_echo(t2._delays) - fitted = t2.analyse(acquisition, "q0", device, config) + t2.build_schedule("q0", device, config, StubBackend(), sweep) + acquisition = simulator.t2_echo(sweep["delays"]) + fitted = t2.analyse(acquisition, "q0", device, config, sweep) assert fitted["t2"] == pytest.approx(simulator.t2_ns * 1e-9, rel=0.05) @@ -362,6 +371,7 @@ def test_ramsey_measures_the_deliberate_detuning_and_reports_no_residual( So the *residual* detuning is the real assertion — it is the number that gets written to the device, and it should be near zero here. """ + sweep = Sweep("q0") ramsey = routine("ramsey") device = device_for(simulator) detuning = 1e6 @@ -372,9 +382,9 @@ def test_ramsey_measures_the_deliberate_detuning_and_reports_no_residual( } ) - ramsey.build_schedule("q0", device, config, StubBackend()) - acquisition = simulator.ramsey(ramsey._delays, detuning) - fitted = ramsey.analyse(acquisition, "q0", device, config) + ramsey.build_schedule("q0", device, config, StubBackend(), sweep) + acquisition = simulator.ramsey(sweep["delays"], detuning) + fitted = ramsey.analyse(acquisition, "q0", device, config, sweep) assert fitted["fringe_frequency"] == pytest.approx(detuning, rel=0.01) # The qubit is on resonance, so the residual should be kHz, not a @@ -413,15 +423,16 @@ def test_a_drive_hundreds_of_mhz_off_resonance_barely_moves_the_qubit( def test_a_rabi_fit_refuses_the_sweep_that_drove_nothing(self, simulator): """Refusing is the whole point: an `amp180` read off this would be noise, and every later X pulse would play it.""" + sweep = Sweep("q0") rabi = routine("rabi") device = device_for(simulator) config = RoutineConfig(params={"amplitudes": self.AMPLITUDES}) - rabi.build_schedule("q0", device, config, StubBackend()) - acquisition = simulator.rabi(rabi._amplitudes, self.DETUNING_HZ / GHZ) + rabi.build_schedule("q0", device, config, StubBackend(), sweep) + acquisition = simulator.rabi(sweep["amplitudes"], self.DETUNING_HZ / GHZ) with pytest.raises(FitError): - rabi.analyse(acquisition, "q0", device, config) + rabi.analyse(acquisition, "q0", device, config, sweep) def test_the_backend_drives_a_gate_at_the_frequency_the_device_configures( self, simulator @@ -437,11 +448,12 @@ def test_the_backend_drives_a_gate_at_the_frequency_the_device_configures( config = RoutineConfig(params={"amplitudes": self.AMPLITUDES}) def run(configured_f01_hz: float): + sweep = Sweep("q0") device = device_for(simulator) device.get_element("q0").clock_freqs.f01 = configured_f01_hz backend = SimulatedBackend(simulator, device=device) - schedule = rabi.build_schedule("q0", device, config, backend) - return rabi.analyse(backend.run(schedule), "q0", device, config) + schedule = rabi.build_schedule("q0", device, config, backend, sweep) + return rabi.analyse(backend.run(schedule), "q0", device, config, sweep) assert run(simulator.f01 * GHZ)["amp180"] == pytest.approx(0.2, rel=0.03) with pytest.raises(FitError): @@ -598,13 +610,16 @@ def test_a_cz_needs_the_coupling_to_exist(self, coupled): assert angle_apart(phase, 0.0) < 1.0 def test_cz_chevron_finds_the_avoided_crossing(self, coupled): + sweep = Sweep("q0_q1") tuner = two_qubit_tuner(coupled) config = chevron_config(0.365, 0.388, 21, 45) routine_ = routine("cz_chevron") - schedule = routine_.build_schedule("q0_q1", tuner.device, config, tuner.backend) + schedule = routine_.build_schedule( + "q0_q1", tuner.device, config, tuner.backend, sweep + ) fit = routine_.analyse( - tuner.backend.run(schedule), "q0_q1", tuner.device, config + tuner.backend.run(schedule), "q0_q1", tuner.device, config, sweep ) assert fit["cz_amplitude"] == pytest.approx( @@ -622,24 +637,32 @@ def test_cz_chevron_refuses_a_sweep_that_stepped_over_the_crossing(self, coupled within its noise. A peak-finder would still return a confident answer from it, and that answer would go to the device as a CZ. """ + sweep = Sweep("q0_q1") tuner = two_qubit_tuner(coupled) config = chevron_config(0.1, 0.6, 11, 11) routine_ = routine("cz_chevron") - schedule = routine_.build_schedule("q0_q1", tuner.device, config, tuner.backend) + schedule = routine_.build_schedule( + "q0_q1", tuner.device, config, tuner.backend, sweep + ) with pytest.raises(FitError, match="no flux amplitude drove"): - routine_.analyse(tuner.backend.run(schedule), "q0_q1", tuner.device, config) + routine_.analyse( + tuner.backend.run(schedule), "q0_q1", tuner.device, config, sweep + ) def test_conditional_phase_recovers_the_gates_real_phase(self, coupled): + sweep = Sweep("q0_q1") tuner = two_qubit_tuner(coupled) config = RoutineConfig( params={"phases": list(np.linspace(0.0, 360.0, 25)), "shots": 512} ) routine_ = routine("conditional_phase") - schedule = routine_.build_schedule("q0_q1", tuner.device, config, tuner.backend) + schedule = routine_.build_schedule( + "q0_q1", tuner.device, config, tuner.backend, sweep + ) fit = routine_.analyse( - tuner.backend.run(schedule), "q0_q1", tuner.device, config + tuner.backend.run(schedule), "q0_q1", tuner.device, config, sweep ) assert ( @@ -658,6 +681,7 @@ def test_conditional_phase_measures_a_gate_that_is_wrong(self, coupled): recalibrates forever; this is the opposite failure, and the more dangerous one — a fit that reports every gate as fine leaves a broken CZ in service. """ + sweep = Sweep("q0_q1") import dataclasses detuned = dataclasses.replace(coupled, conditional_phase_offset_deg=40.0) @@ -667,9 +691,11 @@ def test_conditional_phase_measures_a_gate_that_is_wrong(self, coupled): ) routine_ = routine("conditional_phase") - schedule = routine_.build_schedule("q0_q1", tuner.device, config, tuner.backend) + schedule = routine_.build_schedule( + "q0_q1", tuner.device, config, tuner.backend, sweep + ) fit = routine_.analyse( - tuner.backend.run(schedule), "q0_q1", tuner.device, config + tuner.backend.run(schedule), "q0_q1", tuner.device, config, sweep ) assert ( @@ -1000,14 +1026,19 @@ def test_the_routine_itself_now_runs_against_the_simulator(self): with it — which is also why `MAX_EF_LADDER_ERROR` allows a factor of two rather than the few percent the relation itself holds to. """ + sweep = Sweep("q0") tuner = SimulatedTuner() amp180 = float(read_path(tuner.device.get_element("q0"), "rxy.amp180")) node = next(r for r in all_routines() if r.name == "rabi_12") config = RoutineConfig(params={}) - schedule = node.build_schedule("q0", tuner.device, config, tuner.backend) + schedule = node.build_schedule("q0", tuner.device, config, tuner.backend, sweep) params = node.analyse( - tuner.backend.run(schedule, timeout_s=120), "q0", tuner.device, config + tuner.backend.run(schedule, timeout_s=120), + "q0", + tuner.device, + config, + sweep, ) assert tuner.backend._maps_back(schedule), ( @@ -1017,3 +1048,57 @@ def test_the_routine_itself_now_runs_against_the_simulator(self): assert 0.5 <= ratio <= 2.0, ( f"the ladder guard would refuse this at {ratio:.2f}x" ) + + +class TestTheCrosstalkDetector: + """RFC 0009 D10 — the simulator can show that the penalty *detects*, not what is safe. + + A ZZ coupling shifts each qubit's frequency in proportion to the other's excitation, + so a phase calibrated with the neighbour in |0> is wrong with it in |1>. That is the + crosstalk a fused group is exposed to and a sequential walk is not, and it is what + §5.6's measurement has to be able to see. + + What this cannot do is license a spacing: `ZZ_MHZ` is a number this project chose, and + `MAX_ENTANGLED` caps a joint register at three qubits, so a chip-scale group is out of + reach in principle. The radius is settled on hardware. + """ + + def _phase_shift(self, zz_mhz: float) -> float: + """How far a spectator in |1> moves the measured qubit's accumulated phase.""" + import dataclasses + + from qpi_driver.simulation.coupled import CoupledTransmons + + pair = dataclasses.replace(CoupledTransmons(), zz_mhz=zz_mhz) + idle = pair._hamiltonian(0.0).full() + # |11> against |01>: the difference is what the control's excitation adds to the + # target's energy, which is a phase rate in rad/ns. + return float(np.real(idle[4, 4] - idle[1, 1] - idle[3, 3] + idle[0, 0])) + + def test_no_zz_means_a_spectator_costs_nothing(self): + """The default has to leave every existing simulated result untouched.""" + assert self._phase_shift(0.0) == pytest.approx(0.0, abs=1e-12) + + def test_a_zz_coupling_shifts_the_measured_qubit(self): + """Non-zero and proportional, so a detector comparing company against isolation + has something to find — and finds twice as much when the coupling doubles.""" + one = self._phase_shift(1.0) + two = self._phase_shift(2.0) + + assert abs(one) > 1e-6 + assert two == pytest.approx(2.0 * one, rel=1e-9) + + def test_it_stays_diagonal_so_it_moves_no_population(self): + """A frequency shift, not an exchange: it costs phase and not population, which is + why it is invisible to a routine that measures one qubit at a time.""" + import dataclasses + + from qpi_driver.simulation.coupled import CoupledTransmons + + quiet = CoupledTransmons()._hamiltonian(0.0).full() + noisy = ( + dataclasses.replace(CoupledTransmons(), zz_mhz=3.0)._hamiltonian(0.0).full() + ) + difference = noisy - quiet + + assert np.allclose(difference, np.diag(np.diag(difference))) diff --git a/qpi-driver/py/tests/test_tuner_routines.py b/qpi-driver/py/tests/test_tuner_routines.py index 25031e7b..5fe005ca 100644 --- a/qpi-driver/py/tests/test_tuner_routines.py +++ b/qpi-driver/py/tests/test_tuner_routines.py @@ -28,6 +28,7 @@ from qpi_driver.tuners.base.config import CalibrationConfig, RoutineConfig from qpi_driver.tuners.base.routines import MAX_SWEEP_POINTS, RoutineError from qpi_driver.tuners.routines import ROUTINE_CLASSES, all_routines +from qpi_driver.tuners.base.sweep import Sweep FIXTURES = Path(__file__).parent / "fixtures" @@ -141,14 +142,18 @@ def _build(routine, tuner): """Build *routine*'s schedule for the right kind of target.""" target = _target_for(routine, tuner.device) assert target is not None, f"{routine.name} applies to no edge on the fixture" + sweep = Sweep(target) config = RoutineConfig(params=SMALL_SWEEPS.get(routine.name, {})) - return routine.build_schedule(target, tuner.device, config, tuner.backend) + schedule = routine.build_schedule( + target, tuner.device, config, tuner.backend, sweep + ) + return schedule, sweep @pytest.mark.parametrize("routine_name", ROUTINE_NAMES) def test_every_routine_compiles_under_quantify(routine_name, quantify_tuner): routine = next(r for r in all_routines() if r.name == routine_name) - schedule = _build(routine, quantify_tuner) + schedule, _sweep = _build(routine, quantify_tuner) compiled = quantify_tuner._compiler.compile(schedule) assert compiled is not None @@ -166,7 +171,7 @@ def test_every_routine_builds_a_schedule_under_qblox(routine_name, qblox_tuner): backends are now held to the same standard. """ routine = next(r for r in all_routines() if r.name == routine_name) - schedule = _build(routine, qblox_tuner) + schedule, _sweep = _build(routine, qblox_tuner) assert schedule is not None assert len(schedule.operations) > 0 @@ -178,39 +183,57 @@ def test_every_routine_builds_a_schedule_under_qblox(routine_name, qblox_tuner): CHECKABLE = [r.name for r in all_routines() if r.has_check] +def _check_schedule_of(routine_name, tuner): + """The check schedule *routine_name* builds against *tuner*'s real device.""" + routine = next(r for r in all_routines() if r.name == routine_name) + config = RoutineConfig(params=SMALL_SWEEPS.get(routine.name, {})) + target = _target_for(routine, tuner.device) + assert target is not None, f"{routine_name} applies to nothing on the fixture" + schedule = routine.build_check_schedule( + target, tuner.device, config, tuner.backend, Sweep(target) + ) + assert schedule is not None, ( + f"{routine_name} reports has_check but built no check schedule" + ) + return schedule + + +# Split per scheduler rather than asserting both in one test, and the reason is the test +# harness rather than the product. Both tuner fixtures are module-scoped and both call +# `Instrument.close_all()`, so whichever is built second closes the first's instruments and +# leaves it holding a dead `QuantumDevice` — which fails as +# `'QuantumDevice' object ... has no attribute 'elements'`, a message that reads like an +# upstream API change and is not one. +# +# It also closed a real coverage hole. A test needing *both* schedulers skips unless both +# are installed, and CI installs one extra per leg — so the only check-schedule compile in +# the suite ran nowhere CI runs. The two main-schedule tests above are already split this +# way; these now match them. @pytest.mark.parametrize("routine_name", CHECKABLE) -def test_every_check_schedule_compiles_under_both_schedulers( - routine_name, quantify_tuner, qblox_tuner -): +def test_every_check_schedule_compiles_under_quantify(routine_name, quantify_tuner): """A check that cannot be built is silent, not failing — so it needs a test here. - `diagnose` deliberately treats an unevaluable check as *unknown* rather than as - drift, because a broken check must not trigger a recalibration. The cost of that - choice is that a check which raises every time reports nothing, forever, and no - test fails. One did: the first version of `resonator_spectroscopy`'s read its - tolerance from ``measure.readout_linewidth``, a path no transmon element has, so - `read_path` raised `ParameterError` on every call and the check was dead on - arrival. + `diagnose` deliberately treats an unevaluable check as *unknown* rather than as drift, + because a broken check must not trigger a recalibration. The cost of that choice is + that a check which raises every time reports nothing, forever, and no test fails. One + did: the first version of `resonator_spectroscopy`'s read its tolerance from + ``measure.readout_linewidth``, a path no transmon element has, so `read_path` raised + `ParameterError` on every call and the check was dead on arrival. - Building the check schedule against a real device is what catches that, because - that is where a check reads the parameters it is judging. + Building the check schedule against a real device is what catches that, because that is + where a check reads the parameters it is judging. """ - routine = next(r for r in all_routines() if r.name == routine_name) - config = RoutineConfig(params=SMALL_SWEEPS.get(routine.name, {})) + schedule = _check_schedule_of(routine_name, quantify_tuner) - for tuner, compile_with in ( - (quantify_tuner, lambda s: quantify_tuner._compiler.compile(s)), - (qblox_tuner, lambda s: qblox_tuner._agent.compile(s)), - ): - target = _target_for(routine, tuner.device) - assert target is not None - schedule = routine.build_check_schedule( - target, tuner.device, config, tuner.backend - ) - assert schedule is not None, ( - f"{routine_name} reports has_check but built no check schedule" - ) - assert compile_with(schedule) is not None + assert quantify_tuner._compiler.compile(schedule) is not None + + +@pytest.mark.parametrize("routine_name", CHECKABLE) +def test_every_check_schedule_compiles_under_qblox(routine_name, qblox_tuner): + """The same standard under the other scheduler — see the quantify twin above.""" + schedule = _check_schedule_of(routine_name, qblox_tuner) + + assert qblox_tuner._agent.compile(schedule) is not None def test_a_dummy_acquisition_fails_the_routine_rather_than_fitting_zeros( @@ -632,25 +655,26 @@ class TestExcitingTheQubitHasToMoveItsResonator: @pytest.mark.parametrize("fraction,accepted", MEASURED) def test_only_a_shift_readout_could_resolve_is_reported(self, fraction, accepted): + sweep = Sweep("q0") import numpy as np node = routine("resonator_spectroscopy_excited") # What `build_schedule` records: the sweep, and the ground-state resonance the # shift is measured against. Set directly because this test supplies the # acquisition rather than running one, and `analyse` reads no device at all now. - node._frequencies = [self.GROUND - 2e6 + 40e3 * i for i in range(101)] - node._ground = self.GROUND + sweep["frequencies"] = [self.GROUND - 2e6 + 40e3 * i for i in range(101)] + sweep["ground"] = self.GROUND excited = self.GROUND - 2.0 * fraction * self.LINEWIDTH - detuning = (np.asarray(node._frequencies) - excited) / (self.LINEWIDTH / 2) + detuning = (np.asarray(sweep["frequencies"]) - excited) / (self.LINEWIDTH / 2) signal = 0.027 - 0.02 / (1.0 + detuning**2) signal += np.random.default_rng(0).normal(0.0, 2e-5, signal.size) if accepted: - found = node.analyse(signal, "q0", None, RoutineConfig(params={})) + found = node.analyse(signal, "q0", None, RoutineConfig(params={}), sweep) assert found["dispersive_shift"] < 0 else: with pytest.raises(RoutineError, match="not exciting this qubit"): - node.analyse(signal, "q0", None, RoutineConfig(params={})) + node.analyse(signal, "q0", None, RoutineConfig(params={}), sweep) @pytest.fixture @@ -743,6 +767,7 @@ def test_a_rabi_sweep_can_reach_full_scale_but_does_not_start_there(own_quantify waveform past that clips. The element does not bound this at all: quantify validates `rxy.amp180` in [-10, 10], a sanity range rather than a drive bound. """ + sweep = Sweep("q0") from qpi_driver.tuners.base.limits import FULL_SCALE, full_scale element = own_quantify_tuner.device.get_element("q0") @@ -754,8 +779,9 @@ def test_a_rabi_sweep_can_reach_full_scale_but_does_not_start_there(own_quantify own_quantify_tuner.device, RoutineConfig(params={}), own_quantify_tuner.backend, + sweep, ) - assert max(node._amplitudes) == pytest.approx(0.5 * FULL_SCALE), ( + assert max(sweep["amplitudes"]) == pytest.approx(0.5 * FULL_SCALE), ( "the default should measure where the cosine model holds" ) @@ -803,18 +829,25 @@ def _fitted(self): def _stubbed(self, residuals): """A `ramsey` whose passes leave *residuals* in order, recording what it applies.""" node = routine("ramsey") - node._detuning = self.ARTIFICIAL + sweep = Sweep("q0") + sweep["detuning"] = self.ARTIFICIAL applied, remaining = [], iter(residuals) node.apply = lambda device, target, params: applied.append( params["clock_freq_01"] ) node.escalating = lambda *args, **kwargs: self._pass(next(remaining)) - return node, applied + return node, applied, sweep def test_both_roots_are_measured_and_the_better_one_kept(self): - node, applied = self._stubbed([4.18e6, 0.06e6]) + node, applied, sweep = self._stubbed([4.18e6, 0.06e6]) best = node._resolved_root( - "q0", object(), RoutineConfig(params={}), None, 1.0, self._fitted() + "q0", + object(), + RoutineConfig(params={}), + None, + 1.0, + sweep, + self._fitted(), ) assert applied == pytest.approx( @@ -839,9 +872,10 @@ def test_the_two_roots_are_the_fringe_reflected_about_the_bias(self): def test_a_residual_under_the_artificial_detuning_never_tries_the_other_root(self): """The sign is unambiguous there, so the extra sweep would be waste.""" - node, applied = self._stubbed([0.2e6, 0.01e6, 1e3]) - node._delays = [0.0, 24e-6] - node.measure("q0", object(), RoutineConfig(params={}), None) + sweep = Sweep("q0") + node, applied, sweep = self._stubbed([0.2e6, 0.01e6, 1e3]) + sweep["delays"] = [0.0, 24e-6] + node.measure("q0", object(), RoutineConfig(params={}), None, sweep) alternative = self._fitted()["clock_freq_01_alternative"] assert alternative not in applied @@ -918,21 +952,22 @@ def _with_linewidth(self, tuner, linewidth): write_path(tuner.device.get_element("q0"), "resonator.linewidth", linewidth) - def _span_of(self, node): + def _span_of(self, sweep): """The frequency span the node built, however it stored it. The operating points keep `(frequency, amplitude)` pairs, since they sweep both; the spectroscopy sweeps keep frequencies alone. """ - grid = getattr(node, "_frequencies", None) + grid = sweep.get("frequencies", None) if grid is None: - grid = [frequency for frequency, _amplitude in node._settings] + grid = [frequency for frequency, _amplitude in sweep["settings"]] return max(grid) - min(grid) @pytest.mark.parametrize("linewidth", (370e3, 3.31e6)) def test_the_operating_point_span_tracks_the_linewidth( self, own_quantify_tuner, linewidth ): + sweep = Sweep("q0") from qpi_driver.tuners.routines.readout import SPAN_IN_LINEWIDTHS self._with_linewidth(own_quantify_tuner, linewidth) @@ -942,8 +977,9 @@ def test_the_operating_point_span_tracks_the_linewidth( own_quantify_tuner.device, RoutineConfig(params={}), own_quantify_tuner.backend, + sweep, ) - assert self._span_of(node) == pytest.approx( + assert self._span_of(sweep) == pytest.approx( SPAN_IN_LINEWIDTHS * linewidth, rel=1e-6 ) @@ -951,6 +987,7 @@ def test_the_operating_point_span_tracks_the_linewidth( def test_the_excited_sweep_span_tracks_the_linewidth( self, own_quantify_tuner, linewidth ): + sweep = Sweep("q0") from qpi_driver.tuners.routines.spectroscopy import EXCITED_SPAN_IN_LINEWIDTHS self._with_linewidth(own_quantify_tuner, linewidth) @@ -960,8 +997,9 @@ def test_the_excited_sweep_span_tracks_the_linewidth( own_quantify_tuner.device, RoutineConfig(params={}), own_quantify_tuner.backend, + sweep, ) - assert self._span_of(node) == pytest.approx( + assert self._span_of(sweep) == pytest.approx( EXCITED_SPAN_IN_LINEWIDTHS * linewidth, rel=1e-6 ) @@ -969,6 +1007,7 @@ def test_an_unmeasured_resonator_falls_back_rather_than_sweeping_nothing( self, own_quantify_tuner ): """Zero means "not measured", and a zero-wide span would sweep one point.""" + sweep = Sweep("q0") self._with_linewidth(own_quantify_tuner, 0.0) node = routine("readout_operating_point") node.build_schedule( @@ -976,8 +1015,9 @@ def test_an_unmeasured_resonator_falls_back_rather_than_sweeping_nothing( own_quantify_tuner.device, RoutineConfig(params={}), own_quantify_tuner.backend, + sweep, ) - assert self._span_of(node) > 0.0 + assert self._span_of(sweep) > 0.0 class TestAnAnharmonicityHasToBeATransmons: @@ -989,6 +1029,7 @@ class TestAnAnharmonicityHasToBeATransmons: """ def test_a_positive_prior_is_refused(self): + sweep = Sweep("q0") node = routine("f12_spectroscopy") class _Device: @@ -1008,6 +1049,7 @@ class clock_freqs: _Device, RoutineConfig(params={"anharmonicity_prior": 100e6}), None, + sweep, ) @pytest.mark.parametrize("anharmonicity", (100e6, -20e6, -900e6)) @@ -1179,6 +1221,7 @@ def test_a_punchout_sweep_reaches_full_readout_scale(own_quantify_tuner): it. The B chip made the cost concrete — carrying `output_att: 20` on its readout, a grid stopping at 0.5 is around 26 dB short of what the module can emit. """ + sweep = Sweep("q0") from qpi_driver.tuners.base.limits import FULL_SCALE, full_scale element = own_quantify_tuner.device.get_element("q0") @@ -1190,10 +1233,11 @@ def test_a_punchout_sweep_reaches_full_readout_scale(own_quantify_tuner): own_quantify_tuner.device, RoutineConfig(params={}), own_quantify_tuner.backend, + sweep, ) - assert max(node._amplitudes) == pytest.approx(FULL_SCALE) + assert max(sweep["amplitudes"]) == pytest.approx(FULL_SCALE) # And still starts low enough to have a dressed regime to compare against. - assert min(node._amplitudes) < 0.05 + assert min(sweep["amplitudes"]) < 0.05 class TestTheResonatorSweepWidensItself: @@ -1254,24 +1298,26 @@ def test_widening_a_span_scales_its_points_to_hold_the_step(self): from qpi_driver.tuners.fitting.core import OutOfRange node = routine("resonator_spectroscopy") - node._span = 20e6 + sweep = Sweep("q0") + sweep["span"] = 20e6 refusal = OutOfRange("out", axis="span", factor=4.0) - widened = _widened(node, RoutineConfig(params={}), refusal) + widened = _widened(node, RoutineConfig(params={}), refusal, sweep) assert widened.get("span") == pytest.approx(80e6) assert widened.get("points") == 51 * 4 # And it stops before the sequencer does. - huge = _widened(node, RoutineConfig(params={"points": 400}), refusal) + huge = _widened(node, RoutineConfig(params={"points": 400}), refusal, sweep) assert huge.get("points") == MAX_SWEEP_POINTS def test_it_finds_a_resonator_outside_its_first_window(self): """The whole point, end to end through `escalating`.""" + sweep = Sweep("q0") configured = 7.12899e9 truth = configured - 12.8e6 node = routine("resonator_spectroscopy") device = _FakeDevice(configured) - backend = _DipBackend(node, truth, self.LINEWIDTH_HZ) + backend = _DipBackend(sweep, truth, self.LINEWIDTH_HZ) # `points` and not `span`, so escalation stays free to widen the axis it names. # 501 over the 20 MHz default is a 40 kHz grid, which a 330 kHz resonator needs: @@ -1282,6 +1328,7 @@ def test_it_finds_a_resonator_outside_its_first_window(self): device, RoutineConfig(params={"points": 501}), backend, + sweep, None, timeout_s=60, ) @@ -1296,12 +1343,13 @@ def test_it_finds_a_resonator_outside_its_first_window(self): def test_an_operator_who_set_the_span_is_not_overruled(self): """RFC 0007 §7: a named axis is a statement about the chip, not a default.""" + sweep = Sweep("q0") from qpi_driver.tuners.fitting.core import OutOfRange configured = 7.12899e9 node = routine("resonator_spectroscopy") device = _FakeDevice(configured) - backend = _DipBackend(node, configured - 12.8e6, self.LINEWIDTH_HZ) + backend = _DipBackend(sweep, configured - 12.8e6, self.LINEWIDTH_HZ) with pytest.raises(OutOfRange): node.measure( @@ -1309,6 +1357,7 @@ def test_an_operator_who_set_the_span_is_not_overruled(self): device, RoutineConfig(params={"span": 20e6, "points": 501}), backend, + sweep, None, timeout_s=60, ) @@ -1356,18 +1405,20 @@ class _DipBackend(SchedulerBackend): SetClockFrequency = staticmethod(lambda *a, **k: ("clock", a, k)) BinMode = SimpleNamespace(AVERAGE="average", APPEND="append") - def __init__(self, node: Any, centre: float, linewidth: float) -> None: - self._node = node + def __init__(self, sweep: Any, centre: float, linewidth: float) -> None: + self.sweep = sweep self._centre = centre self._linewidth = linewidth #: The span of each attempt, so a test can see escalation happen. self.spans: list[float] = [] def new_schedule(self, name: str, repetitions: int = 1) -> Any: - return SimpleNamespace(ops=[], add=lambda op: None) + # `add` takes the reference keywords too, since the fused path places + # operations against an anchor rather than appending them. + return SimpleNamespace(ops=[], add=lambda op, **_refs: op) def run(self, schedule: Any, timeout_s: float = 0.0) -> xr.Dataset: - frequencies = np.asarray(self._node._frequencies, dtype=float) + frequencies = np.asarray(self.sweep["frequencies"], dtype=float) self.spans.append(float(frequencies[-1] - frequencies[0])) return xr.Dataset( {"y": ("x", _dip(frequencies, self._centre, self._linewidth))} @@ -1643,18 +1694,19 @@ def _rabi_at(self, top: float): from qpi_driver.tuners.base.routines import linear_setpoints node = routine("rabi") - node._amplitudes = linear_setpoints(0.0, top, 41) - node._amplitudes_ceiling = 1.0 - return node + sweep = Sweep("q0") + sweep["amplitudes"] = linear_setpoints(0.0, top, 41) + sweep["amplitudes_ceiling"] = 1.0 + return node, sweep def test_widening_clamps_to_the_ceiling(self): from qpi_driver.tuners.base.routines import _widened from qpi_driver.tuners.fitting.core import OutOfRange - node = self._rabi_at(0.5) + node, sweep = self._rabi_at(0.5) refusal = OutOfRange("above the sweep", axis="amplitudes", factor=4.0) - widened = _widened(node, RoutineConfig(params={}), refusal) + widened = _widened(node, RoutineConfig(params={}), refusal, sweep) assert max(widened.get("amplitudes")) == pytest.approx(1.0) assert len(widened.get("amplitudes")) == 41 @@ -1664,7 +1716,7 @@ def test_a_sweep_already_at_the_ceiling_stops_rather_than_repeating(self): from qpi_driver.tuners.base.routines import _widened from qpi_driver.tuners.fitting.core import OutOfRange - node = self._rabi_at(1.0) + node, sweep = self._rabi_at(1.0) config = RoutineConfig(params={}) assert ( @@ -1672,6 +1724,7 @@ def test_a_sweep_already_at_the_ceiling_stops_rather_than_repeating(self): node, config, refusal := OutOfRange("above the sweep", axis="amplitudes", factor=4.0), + sweep, ) is config ), refusal @@ -1682,12 +1735,14 @@ def test_an_axis_with_no_ceiling_is_unbounded(self): from qpi_driver.tuners.fitting.core import OutOfRange node = routine("t1") - node._delays = linear_setpoints(0.0, 80e-6, 21) + sweep = Sweep("q0") + sweep["delays"] = linear_setpoints(0.0, 80e-6, 21) widened = _widened( node, RoutineConfig(params={}), OutOfRange("no decay", axis="delays", factor=4.0), + sweep, ) assert max(widened.get("delays")) == pytest.approx(320e-6) @@ -1945,31 +2000,32 @@ class TestRamseyRefinesUntilTheResidualIsUnresolvable: def _ramsey_with(self, delays): node = routine("ramsey") - node._delays = list(delays) - return node + sweep = Sweep("q0") + sweep["delays"] = list(delays) + return node, sweep def test_the_floor_comes_from_the_window_the_operator_swept(self): """A fringe over a window T is resolved to about 1/(2 pi T); below that is noise.""" - node = self._ramsey_with([4e-9, 24e-6]) + node, sweep = self._ramsey_with([4e-9, 24e-6]) - floor = node._detuning_floor(RoutineConfig(params={})) + floor = node._detuning_floor(RoutineConfig(params={}), sweep) assert floor == pytest.approx(1.0 / (2 * np.pi * (24e-6 - 4e-9)), rel=1e-6) # A shorter window resolves less, so it stops sooner. - assert ( - self._ramsey_with([0.0, 6e-6])._detuning_floor(RoutineConfig(params={})) - > floor - ) + shorter, shorter_sweep = self._ramsey_with([0.0, 6e-6]) + assert shorter._detuning_floor(RoutineConfig(params={}), shorter_sweep) > floor def test_no_delays_yet_means_no_floor_rather_than_a_crash(self): node = routine("ramsey") - node._delays = [] + sweep = Sweep("q0") + sweep["delays"] = [] - assert node._detuning_floor(RoutineConfig(params={})) == 0.0 + assert node._detuning_floor(RoutineConfig(params={}), sweep) == 0.0 def test_it_refines_until_the_detuning_is_under_the_floor(self): """Each pass returns a smaller residual, and the loop stops when one is small.""" - node = self._ramsey_with([4e-9, 24e-6]) + sweep = Sweep("q5") + node, sweep = self._ramsey_with([4e-9, 24e-6]) residuals = iter([1.032e6, 4.1e4, 1.2e3]) applied: list[float] = [] @@ -1981,7 +2037,7 @@ def test_it_refines_until_the_detuning_is_under_the_floor(self): params["detuning"] ) - result = node.measure("q5", None, RoutineConfig(params={}), None) + result = node.measure("q5", None, RoutineConfig(params={}), None, sweep) assert result["detuning"] == pytest.approx(1.2e3), ( "it should keep the last, best pass" @@ -1991,7 +2047,8 @@ def test_it_refines_until_the_detuning_is_under_the_floor(self): def test_it_stops_when_the_residual_stops_falling(self): """Another pass would be measuring noise, so keep the better of the two.""" - node = self._ramsey_with([4e-9, 24e-6]) + sweep = Sweep("q5") + node, sweep = self._ramsey_with([4e-9, 24e-6]) residuals = iter([5.0e4, 6.0e4, 7.0e4]) node.escalating = lambda *a, **k: { # type: ignore[method-assign] "detuning": next(residuals), @@ -1999,12 +2056,13 @@ def test_it_stops_when_the_residual_stops_falling(self): } node.apply = lambda *a, **k: None # type: ignore[method-assign] - result = node.measure("q5", None, RoutineConfig(params={}), None) + result = node.measure("q5", None, RoutineConfig(params={}), None, sweep) assert result["detuning"] == pytest.approx(5.0e4), "the first was the best" def test_a_first_pass_already_on_resonance_costs_nothing(self): - node = self._ramsey_with([4e-9, 24e-6]) + sweep = Sweep("q5") + node, sweep = self._ramsey_with([4e-9, 24e-6]) passes = [] def once(*a, **k): @@ -2013,7 +2071,7 @@ def once(*a, **k): node.escalating = once # type: ignore[method-assign] - result = node.measure("q5", None, RoutineConfig(params={}), None) + result = node.measure("q5", None, RoutineConfig(params={}), None, sweep) assert len(passes) == 1, "500 Hz is under the 6.6 kHz this window resolves" assert result["detuning"] == 500.0 @@ -2073,11 +2131,12 @@ def test_the_generic_widening_declines_to_shorten(self): from qpi_driver.tuners.fitting.core import OutOfRange node = routine("fine_amplitude_90") - node._repetitions = [1, 5, 9, 13] + sweep = Sweep("q0") + sweep["repetitions"] = [1, 5, 9, 13] config = RoutineConfig(params={}) refusal = OutOfRange("x", axis="repetitions", direction="shorter", factor=0.66) - assert _widened(node, config, refusal) is config + assert _widened(node, config, refusal, sweep) is config def test_the_ladder_is_rebuilt_rather_than_interpolated(self): from qpi_driver.tuners.fitting.core import MIN_FIT_POINTS @@ -2125,10 +2184,11 @@ def test_rb_averages_harder_when_the_decay_is_lost_in_its_own_scatter(self): from qpi_driver.tuners.fitting.core import OutOfRange node = routine("rb") - node._circuits_per_depth = 10 + sweep = Sweep("q0") + sweep["circuits_per_depth"] = 10 refusal = OutOfRange("x", axis="circuits_per_depth", factor=4.0) - assert _widened(node, RoutineConfig(params={}), refusal).get( + assert _widened(node, RoutineConfig(params={}), refusal, sweep).get( "circuits_per_depth" ) == min(40, MAX_CIRCUITS_PER_DEPTH) @@ -2141,12 +2201,13 @@ def test_rb_stops_at_the_ceiling_rather_than_running_forever(self): from qpi_driver.tuners.fitting.core import OutOfRange node = routine("rb") - node._circuits_per_depth = MAX_CIRCUITS_PER_DEPTH + sweep = Sweep("q0") + sweep["circuits_per_depth"] = MAX_CIRCUITS_PER_DEPTH config = RoutineConfig(params={}) refusal = OutOfRange("x", axis="circuits_per_depth", factor=4.0) # Unchanged, which is how `escalating` knows to re-raise instead of re-running. - assert _widened(node, config, refusal) is config + assert _widened(node, config, refusal, sweep) is config def test_a_benchmark_that_measures_itself_still_reaches_the_report(self): """`add_benchmarks_from` was only on the branch for routines that do *not* run @@ -2192,19 +2253,18 @@ class TestEscalationIsBoundedOnEveryAxisItMoves: an instruction budget — which they did not have when depths first became escalatable. """ - def _widen(self, node, config, axis, factor=2.0): + def _widen(self, node, config, axis, sweep, factor=2.0): from qpi_driver.tuners.base.routines import _widened from qpi_driver.tuners.fitting.core import OutOfRange - return _widened(node, config, OutOfRange("x", axis=axis, factor=factor)) + return _widened(node, config, OutOfRange("x", axis=axis, factor=factor), sweep) def _rb(self, depths, circuits): from qpi_driver.tuners.routines.benchmarks import MAX_RB_CLIFFORDS - return SimpleNamespace( - name="rb", - _depths=list(depths), - _depths_ceiling=2.0 * MAX_RB_CLIFFORDS / (circuits * len(depths)) - 1.0, + ceiling = 2.0 * MAX_RB_CLIFFORDS / (circuits * len(depths)) - 1.0 + return SimpleNamespace(name="rb"), Sweep( + "q0", depths=list(depths), depths_ceiling=ceiling ) def test_rb_depths_stop_at_the_instruction_budget(self): @@ -2215,7 +2275,8 @@ def test_rb_depths_stop_at_the_instruction_budget(self): depths, circuits = [1, 2, 4, 8, 16, 32, 64], 10 config = RoutineConfig(params={}) for _ in range(4): - widened = self._widen(self._rb(depths, circuits), config, "depths") + node, sweep = self._rb(depths, circuits) + widened = self._widen(node, config, "depths", sweep) if widened is config: break depths = [int(d) for d in widened.get("depths")] @@ -2239,12 +2300,13 @@ def test_rb_circuits_stop_at_the_instruction_budget_too(self): depths, circuits = [1, 2, 4, 8, 16, 32, 64], 10 config = RoutineConfig(params={}) for _ in range(4): - node = SimpleNamespace( - name="rb", - _circuits_per_depth=circuits, - _circuits_per_depth_ceiling=MAX_RB_CLIFFORDS / sum(depths), + node = SimpleNamespace(name="rb") + sweep = Sweep( + "q0", + circuits_per_depth=circuits, + circuits_per_depth_ceiling=MAX_RB_CLIFFORDS / sum(depths), ) - widened = self._widen(node, config, "circuits_per_depth", factor=4.0) + widened = self._widen(node, config, "circuits_per_depth", sweep, factor=4.0) if widened is config: break circuits = int(widened.get("circuits_per_depth")) @@ -2258,8 +2320,11 @@ def test_the_runtime_bound_still_applies_on_its_own(self): are independent rather than one replacing the other.""" from qpi_driver.tuners.base.routines import MAX_CIRCUITS_PER_DEPTH - node = SimpleNamespace(name="rb", _circuits_per_depth=40) - widened = self._widen(node, RoutineConfig(params={}), "circuits_per_depth", 4.0) + node = SimpleNamespace(name="rb") + sweep = Sweep("q0", circuits_per_depth=40) + widened = self._widen( + node, RoutineConfig(params={}), "circuits_per_depth", sweep, 4.0 + ) assert widened.get("circuits_per_depth") == MAX_CIRCUITS_PER_DEPTH @@ -2267,9 +2332,9 @@ def test_a_config_at_the_ceiling_comes_back_unchanged(self): """Which is how `escalating` learns to re-raise rather than re-run the same sweep for the same refusal.""" config = RoutineConfig(params={}) - node = self._rb([1, 400, 800], 10) + node, sweep = self._rb([1, 400, 800], 10) - assert self._widen(node, config, "depths") is config + assert self._widen(node, config, "depths", sweep) is config def test_the_count_is_bounded_even_where_the_reach_is_not(self): from qpi_driver.tuners.base.routines import CalibrationRoutine @@ -2322,19 +2387,21 @@ def test_the_pi_over_two_ladder_has_somewhere_to_shorten_to(self): def test_every_swept_axis_is_readable_from_outside(monkeypatch, own_quantify_tuner): - """A routine must keep each swept axis as ``_``, or escalation cannot widen it. + """A routine must record each swept axis under its own name, or escalation cannot + widen it. - `_widened` reads the setpoints a routine actually built off ``_``, because the - case that matters is a config that names the axis nowhere — which is exactly the config - whose sweep needs widening. The name is therefore load-bearing, and nothing checked it. + `_widened` reads the setpoints a routine actually built off the target's `Sweep`, + because the case that matters is a config that names the axis nowhere — which is + exactly the config whose sweep needs widening. The key is therefore load-bearing, and + nothing checked it. Four routines stored theirs under a name of their own: `drag` as ``_betas`` against an axis of ``motzois``, `fine_amplitude_12` as ``_counts``, `resonator_punchout` as ``_powers``, `flux_spectroscopy` as ``_offsets``. For all four `_widened` found nothing, returned the config unchanged, and `escalating` re-raised — so the refusal named the - range it had already swept, which reads exactly like a chip with no answer in it. On the - August 2026 B chip `drag` failed with an optimum of -0.614 against a swept +/-0.2, run - after run, and never widened once. + range it had already swept, which reads exactly like a chip with no answer in it. In an + August 2026 bring-up `drag` failed with an optimum of -0.614 against a swept +/-0.2, + run after run, and never widened once. Instrumented rather than read off the source, the way the `reads` ledger is: what matters is the axis a routine passes at runtime, not the one a grep can see. @@ -2366,8 +2433,8 @@ def recording(config, axis, default=None): for name in ROUTINE_NAMES: node = routine(name) swept.clear() - _build(node, own_quantify_tuner) - missing = [a for a in swept if not hasattr(node, f"_{a}")] + _schedule, sweep = _build(node, own_quantify_tuner) + missing = [a for a in swept if a not in sweep] if missing: unreachable[name] = missing @@ -2453,13 +2520,14 @@ class TestASweepTooLargeForOneScheduleIsSplit: def _acquire(self, circuits, depths, monkeypatch): """Run `acquire`, recording the circuit count and seed of each schedule built.""" + sweep = Sweep("q0") from qpi_driver.tuners.routines import benchmarks node = routine("rb") seen: list[tuple[int, int]] = [] original = benchmarks.RandomizedBenchmarking.build_schedule - def recording(self, target, device, config, backend): + def recording(self, target, device, config, backend, sweep): seen.append( ( int( @@ -2468,7 +2536,7 @@ def recording(self, target, device, config, backend): int(config.get("seed", benchmarks.DEFAULT_RB_SEED)), ) ) - return original(self, target, device, config, backend) + return original(self, target, device, config, backend, sweep) monkeypatch.setattr( benchmarks.RandomizedBenchmarking, "build_schedule", recording @@ -2477,8 +2545,8 @@ def recording(self, target, device, config, backend): config = RoutineConfig( params={"depths": list(depths), "circuits_per_depth": circuits, "shots": 1} ) - dataset = node.acquire("q0", device, config, _CountingBackend(), 60.0) - return node, seen, dataset + dataset = node.acquire("q0", device, config, _CountingBackend(), 60.0, sweep) + return node, seen, dataset, sweep def test_survival_is_scored_against_the_references_not_the_sweep(self): """A decay running the wrong way has to survive normalisation to be caught. @@ -2490,12 +2558,13 @@ def test_survival_is_scored_against_the_references_not_the_sweep(self): ``|0>`` and ``X|0>`` the numbers keep their meaning and `fit_rb_decay` sees what the sequences actually did. """ + sweep = Sweep("q0") from qpi_driver.tuners.fitting.core import FitError from qpi_driver.tuners.routines.benchmarks import REFERENCE_ACQUISITIONS node = routine("rb") - node._depths = [1, 2, 4] - node._circuits = 1 + sweep["depths"] = [1, 2, 4] + sweep["circuits"] = 1 # |0> reads 1.0 and |1> reads 0.0, then a survival that *rises* with depth. signal = np.array([1.0, 0.0, 0.30, 0.45, 0.60]) assert signal.size == REFERENCE_ACQUISITIONS + 3 @@ -2506,13 +2575,14 @@ def test_survival_is_scored_against_the_references_not_the_sweep(self): "q0", SimpleNamespace(get_element=lambda _n: SimpleNamespace(name="q0")), RoutineConfig(params={}), + sweep, ) def test_a_sweep_inside_the_budget_runs_as_one_schedule(self, monkeypatch): from qpi_driver.tuners.routines.benchmarks import MAX_RB_CLIFFORDS depths = [1, 2, 4] - _node, seen, _ = self._acquire(10, depths, monkeypatch) + _node, seen, _dataset, _sweep = self._acquire(10, depths, monkeypatch) assert len(seen) == 1 assert 10 * sum(depths) <= MAX_RB_CLIFFORDS @@ -2523,20 +2593,20 @@ def test_a_sweep_past_the_budget_is_split_and_every_piece_fits(self, monkeypatch REFERENCE_ACQUISITIONS, ) - node, seen, dataset = self._acquire(50, self.DEEP, monkeypatch) + node, seen, dataset, sweep = self._acquire(50, self.DEEP, monkeypatch) assert len(seen) > 1, "50 circuits over these depths must not be one schedule" for circuits, _seed in seen: assert circuits * sum(self.DEEP) <= MAX_RB_CLIFFORDS # Every circuit the operator asked for is present, and none is dropped. assert sum(c for c, _ in seen) == 50 - assert node._circuits == 50 + assert sweep["circuits"] == 50 # Plus the |0> and X|0> references, which `analyse` reads off the front. assert signal_of(dataset).size == REFERENCE_ACQUISITIONS + 50 * len(self.DEEP) def test_each_piece_benchmarks_different_circuits(self, monkeypatch): """Or the chunks would be copies of one another and average to nothing.""" - _node, seen, _ = self._acquire(50, self.DEEP, monkeypatch) + _node, seen, _dataset, _sweep = self._acquire(50, self.DEEP, monkeypatch) seeds = [seed for _c, seed in seen] assert len(set(seeds)) == len(seeds), f"chunks shared a seed: {seeds}" @@ -2560,13 +2630,14 @@ class TestATwoDimensionalGridIsSplitByRows: POINTS = 101 def _acquire(self, name, rows_axis, rows): + sweep = Sweep("q0") from qpi_driver.tuners.base.routines import MAX_SWEEP_POINTS node = routine(name) backend = _CountingGrid() config = RoutineConfig(params={rows_axis: rows, "points": self.POINTS}) - dataset = node.acquire("q0", _grid_device(), config, backend, 60.0) - return node, backend.sizes, signal_of(dataset), MAX_SWEEP_POINTS + dataset = node.acquire("q0", _grid_device(), config, backend, 60.0, sweep) + return node, backend.sizes, signal_of(dataset), MAX_SWEEP_POINTS, sweep @pytest.mark.parametrize( "name,rows_axis", @@ -2578,13 +2649,13 @@ def _acquire(self, name, rows_axis, rows): ) def test_every_piece_fits_and_nothing_is_lost(self, name, rows_axis): rows = [0.02 * (i + 1) for i in range(self.ROWS)] - node, sizes, signal, budget = self._acquire(name, rows_axis, rows) + _node, sizes, signal, budget, sweep = self._acquire(name, rows_axis, rows) assert len(sizes) > 1, f"{self.ROWS}x{self.POINTS} must not be one schedule" assert max(sizes) <= budget # Every row swept, and the grid restored so `analyse` reshapes against it. assert signal.size == self.ROWS * self.POINTS - assert len(getattr(node, f"_{rows_axis}")) == self.ROWS + assert len(sweep[rows_axis]) == self.ROWS @pytest.mark.parametrize( "name,rows_axis", @@ -2600,14 +2671,14 @@ def test_the_seams_fall_between_whole_rows(self, name, rows_axis): on the seam is fitted from half its shoulders. Rows are not a band: each is an independent full sweep over the same grid, so a seam costs nothing.""" rows = [0.02 * (i + 1) for i in range(self.ROWS)] - _node, sizes, _signal, _budget = self._acquire(name, rows_axis, rows) + _node, sizes, _signal, _budget, _sweep = self._acquire(name, rows_axis, rows) assert all(size % self.POINTS == 0 for size in sizes), ( f"a schedule held a partial row: {sizes} against {self.POINTS} points" ) def test_a_grid_inside_the_budget_runs_as_one_schedule(self): - _node, sizes, _signal, _budget = self._acquire( + _node, sizes, _signal, _budget, _sweep = self._acquire( "resonator_punchout", "amplitudes", [0.1, 0.2, 0.3] ) diff --git a/qpi-driver/py/tests/utils/chips.py b/qpi-driver/py/tests/utils/chips.py new file mode 100644 index 00000000..8d8f6976 --- /dev/null +++ b/qpi-driver/py/tests/utils/chips.py @@ -0,0 +1,41 @@ +"""Coupling graphs to group against, by topology (RFC 0009 §10.2). + +Each returns edge names, which is what `couplings_of` reads and what a device config +holds. Parameterised by size so a test can assert that a group count depends on the +topology and not on how big it is — the claim the whole RFC rests on. +""" + + +def chain(qubits: int) -> list[str]: + """A line of *qubits*, as every small bring-up chip is.""" + return [f"q{i}_q{i + 1}" for i in range(qubits - 1)] + + +def lattice(side: int) -> list[str]: + """A square grid, ``side`` by ``side`` — degree 4 in the interior.""" + edges = [ + f"q{row * side + column}_q{row * side + column + 1}" + for row in range(side) + for column in range(side - 1) + ] + edges += [ + f"q{row * side + column}_q{(row + 1) * side + column}" + for row in range(side - 1) + for column in range(side) + ] + return edges + + +def heavy_hex() -> list[str]: + """Three fused hexagons — degree at most 3, as the larger vendors' chips are.""" + return [ + "q0_q1", "q1_q2", "q2_q3", "q3_q4", "q4_q5", "q5_q0", + "q2_q6", "q6_q7", "q7_q8", "q8_q9", "q9_q3", + "q5_q10", "q10_q11", "q11_q12", "q12_q13", "q13_q0", + ] # fmt: skip + + +def qubits_of(edges: list[str]) -> list[str]: + """Every qubit the *edges* name, in index order.""" + names = {qubit for edge in edges for qubit in edge.split("_")} + return sorted(names, key=lambda name: int(name[1:])) diff --git a/qpi-driver/py/tests/utils/simulation.py b/qpi-driver/py/tests/utils/simulation.py index 7fb04ace..49aeb8b0 100644 --- a/qpi-driver/py/tests/utils/simulation.py +++ b/qpi-driver/py/tests/utils/simulation.py @@ -175,16 +175,70 @@ def _operation(kind: str) -> type[_Operation]: return type(kind, (_Operation,), {"kind": kind}) +#: Duration given to a recorded operation that does not carry one. A `Reset` or a +#: `Measure` takes its length from the device, which this stand-in does not read — so +#: without a nominal figure every operation would be instantaneous and `_Schedule` +#: could not tell an appended schedule from an aligned one, which is the whole thing a +#: fused schedule has to get right (RFC 0009 §6.1). +_NOMINAL_DURATION_S = 1e-6 + + +class _Placed: + """One operation and when it starts, which is what `ref_op` refers back to.""" + + def __init__(self, operation: Any, start: float, duration: float) -> None: + self.operation = operation + self.start = start + self.duration = duration + + @property + def end(self) -> float: + return self.start + self.duration + + class _Schedule: - """A recorded schedule: its name, its repetitions, and its operations in order.""" + """A recorded schedule: its name, its repetitions, and its operations in order. + + Timing is kept as well as order. `schedule.add` appends by default and places an + operation alongside another when given ``ref_op``, so a test can assert that a + group's pulses actually coincide rather than that the right arguments were passed. + """ def __init__(self, name: str, repetitions: int = 1) -> None: self.name = name self.repetitions = repetitions self.operations: list[_Operation] = [] + self.placed: list[_Placed] = [] - def add(self, operation: Any, **kwargs: Any) -> None: + def add( + self, + operation: Any, + *, + ref_op: Any = None, + ref_pt: str | None = None, + ref_pt_new: str | None = None, + rel_time: float = 0.0, + **kwargs: Any, + ) -> _Placed: + if ref_op is None: + start = self.placed[-1].end if self.placed else 0.0 + else: + start = ref_op.start if ref_pt == "start" else ref_op.end + duration = getattr(operation, "kwargs", {}).get("duration") + placed = _Placed( + operation, + start + rel_time, + float(duration) if duration else _NOMINAL_DURATION_S, + ) self.operations.append(operation) + self.placed.append(placed) + return placed + + def starts_of(self, kind: str) -> list[float]: + """When each operation of *kind* starts — what an alignment test asserts on.""" + return [ + p.start for p in self.placed if getattr(p.operation, "kind", "") == kind + ] def __repr__(self) -> str: return f"<_Schedule {self.name!r} with {len(self.operations)} operations>" @@ -259,6 +313,57 @@ def run( ) +def _channels_of(schedule: _Schedule) -> dict[int, str]: + """Which target each acquisition channel of *schedule* belongs to. + + Read off the `Measure` operations, which is where a fused routine states it: the channel + is the target's position in its group (RFC 0009 D5). + """ + channels: dict[int, str] = {} + for op in schedule.operations: + if op.kind != "Measure": + continue + channel = op.kwargs.get("acq_channel") + target = op.args[0] if op.args else op.kwargs.get("qubit") + if channel is not None and target is not None: + channels[int(channel)] = str(target) + return channels + + +def _mentions(op: _Operation, target: str) -> bool: + """Whether *op* acts on *target* — by name, or through its port or clock.""" + if target in op.args: + return True + for key in ("qubit", "target"): + if op.kwargs.get(key) == target: + return True + for key in ("port", "clock"): + value = op.kwargs.get(key) + if isinstance(value, str) and value.split(":")[0].split(".")[0] == target: + return True + return False + + +def _only(schedule: _Schedule, target: str) -> _Schedule: + """*schedule* with only the operations acting on *target*. + + An operation naming no target at all — an `IdlePulse`, which is dead time on every port + — is kept, because it is part of every target's sequence. + """ + narrowed = _Schedule(schedule.name, repetitions=schedule.repetitions) + for op in schedule.operations: + if _mentions(op, target) or not _names_a_target(op): + narrowed.operations.append(op) + return narrowed + + +def _names_a_target(op: _Operation) -> bool: + """Whether *op* is addressed to some particular qubit.""" + if op.args: + return True + return any(key in op.kwargs for key in ("qubit", "target", "port", "clock")) + + class SimulatedBackend(RecordingBackend): """A backend that answers ``run`` from the simulator. @@ -311,8 +416,29 @@ def run( f"data that means nothing. Simulated: " f"{', '.join(sorted(self._ACQUISITIONS))}" ) - values = np.asarray(acquire(self, schedule), dtype=float) - return xr.Dataset({"y0": ("acq_index", values)}) + channels = _channels_of(schedule) + if len(channels) <= 1: + values = np.asarray(acquire(self, schedule), dtype=float) + return xr.Dataset({"y0": ("acq_index", values)}) + + # A fused schedule carries several targets, each measured on its own channel. The + # physics here is written for one qubit at a time, so rather than teach every + # acquisition about groups, the schedule is split back into one per target and each + # is answered as it was before. That keeps every existing acquisition unchanged and + # is what lets a grouped routine be checked against real dynamics at all — without + # it a fused walk silently loses every target but the first. + # + # Crosstalk is not modelled either way (RFC 0009 D10), so splitting loses nothing + # a joined evaluation would have had. + return xr.Dataset( + { + channel: ( + "acq_index", + np.asarray(acquire(self, _only(schedule, target)), dtype=float), + ) + for channel, target in sorted(channels.items()) + } + ) @staticmethod def _of_kind(schedule: _Schedule, kind: str) -> list[_Operation]: diff --git a/qpi-ui/internal/api/nng_driver.go b/qpi-ui/internal/api/nng_driver.go index e02bd571..8543f145 100644 --- a/qpi-ui/internal/api/nng_driver.go +++ b/qpi-ui/internal/api/nng_driver.go @@ -698,15 +698,22 @@ func handleCalibrationProgress(ctx context.Context, app core.App, qpuID string, // CalibrationNodeState is one routine's state within a walk in flight, on the // request's `progress.nodes` map (RFC 0006 §5.3). // -// Done counts the targets that have finished, Failed how many of those failed, so -// the drawing reads `3/5` from Done and Total and colours from Failed. The states a -// walk produces are `running`, `done`, `partial` and `failed`; `pending`, `skipped` -// and `not_planned` are properties of the plan and are read from it directly. +// Done counts the targets that have finished, Failed how many of those failed and +// Skipped how many were never run for want of a prerequisite, so the drawing reads +// `3/5` from Done and Total and colours from the other two. Running names the targets +// being measured right now, which is what lets the drawing say *which* components are +// in flight rather than only that the node is (RFC 0009 §7.2). +// +// The states a walk produces are `running`, `done`, `partial`, `failed` and `blocked`; +// `pending`, `skipped` and `not_planned` are properties of the plan and are read from +// it directly. type CalibrationNodeState struct { - State string `json:"state"` - Done int `json:"done"` - Total int `json:"total"` - Failed int `json:"failed"` + State string `json:"state"` + Done int `json:"done"` + Total int `json:"total"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Running []string `json:"running,omitempty"` } // advanceNodes folds one progress event into the tallies a walk has accumulated. @@ -714,25 +721,38 @@ type CalibrationNodeState struct { // prior is the `progress` object already on the row — the previous event's payload, // node map included — and is nil before the first one. // -// Two things make this less obvious than a counter. A progress event fires *after* a -// target finishes, so the routine it names has just completed one and is still the -// running one until an event names a different routine; and the payload carries the -// walk's running totals rather than the outcome of the target it names, so whether -// that target failed is the difference from the last event's total. Exactly one of -// the two totals grows per target, which is what makes the difference readable. +// Two shapes arrive, told apart by whether Running is set. A start event names the +// targets about to be measured and advances no tally; a finish event names the one +// target that is done and advances exactly one of the totals. +// +// Two things make this less obvious than a counter. A finish event carries the walk's +// running totals rather than the outcome of the target it names, so whether that +// target failed is the difference from the last event's total — which is why a start +// event has to carry the same totals rather than zeroes. And a routine stays the +// running one until an event names a different routine, so the walk moving on is what +// settles whatever it was on before. func advanceNodes(prior map[string]any, event *CalibrationProgressPayload, plan *CalibrationPlan) map[string]CalibrationNodeState { nodes := priorNodes(prior) node := nodes[event.Routine] node.Total = plan.targetCount(event.Routine, node.Total) - node.Done++ - if float64(event.Failed) > numberOf(prior["failed"]) { - node.Failed++ - } - if node.Total > 0 && node.Done >= node.Total { - node.State = settledState(node) - } else { + if len(event.Running) > 0 { + node.Running = event.Running node.State = "running" + } else { + node.Done++ + if float64(event.Failed) > numberOf(prior["failed"]) { + node.Failed++ + } + if float64(event.Skipped) > numberOf(prior["skipped"]) { + node.Skipped++ + } + node.Running = without(node.Running, event.Target) + if node.Total > 0 && node.Done >= node.Total { + node.State = settledState(node) + } else { + node.State = "running" + } } nodes[event.Routine] = node @@ -742,6 +762,7 @@ func advanceNodes(prior map[string]any, event *CalibrationProgressPayload, plan if previous, ok := prior["routine"].(string); ok && previous != event.Routine { if done, seen := nodes[previous]; seen && done.State == "running" { done.State = settledState(done) + done.Running = nil nodes[previous] = done } } @@ -749,17 +770,40 @@ func advanceNodes(prior map[string]any, event *CalibrationProgressPayload, plan } // settledState is what a node that has finished its targets looks like. +// +// Failure outranks a skip: a node with one of each has something to investigate, and +// reporting it as merely blocked would bury that. `blocked` is every target skipped — +// nothing ran, so neither `done` nor `failed` is true of it (RFC 0007 §11). func settledState(node CalibrationNodeState) string { switch { - case node.Failed == 0: - return "done" - case node.Failed >= node.Done: + case node.Failed > 0 && node.Failed >= node.Done: return "failed" - default: + case node.Failed > 0: return "partial" + case node.Skipped > 0 && node.Skipped >= node.Done: + return "blocked" + case node.Skipped > 0: + return "partial" + default: + return "done" } } +// without is *targets* less *done*, for shrinking the in-flight set as a group's +// targets report back one at a time. +func without(targets []string, done string) []string { + kept := make([]string, 0, len(targets)) + for _, target := range targets { + if target != done { + kept = append(kept, target) + } + } + if len(kept) == 0 { + return nil + } + return kept +} + // priorNodes recovers the accumulated node map from the stored progress object, // which round-trips through JSON and so arrives as floats in maps. func priorNodes(prior map[string]any) map[string]CalibrationNodeState { @@ -775,15 +819,36 @@ func priorNodes(prior map[string]any) map[string]CalibrationNodeState { } state, _ := fields["state"].(string) nodes[name] = CalibrationNodeState{ - State: state, - Done: int(numberOf(fields["done"])), - Total: int(numberOf(fields["total"])), - Failed: int(numberOf(fields["failed"])), + State: state, + Done: int(numberOf(fields["done"])), + Total: int(numberOf(fields["total"])), + Failed: int(numberOf(fields["failed"])), + Skipped: int(numberOf(fields["skipped"])), + Running: stringsOf(fields["running"]), } } return nodes } +// stringsOf reads a JSON string array back out of an `any`. Nil for anything that is +// not one, absent included. +func stringsOf(value any) []string { + items, ok := value.([]any) + if !ok { + return nil + } + strings := make([]string, 0, len(items)) + for _, item := range items { + if text, ok := item.(string); ok { + strings = append(strings, text) + } + } + if len(strings) == 0 { + return nil + } + return strings +} + // numberOf reads a JSON number back out of an `any`, whatever numeric shape the // decoder chose. Zero for anything that is not one, absent included. func numberOf(value any) float64 { diff --git a/qpi-ui/internal/api/nng_driver_test.go b/qpi-ui/internal/api/nng_driver_test.go index 112cd2ff..f427a65f 100644 --- a/qpi-ui/internal/api/nng_driver_test.go +++ b/qpi-ui/internal/api/nng_driver_test.go @@ -1143,3 +1143,134 @@ func TestToStringSlice_NarrowsWhateverTheQueueStored(t *testing.T) { }) } } + +// TestAdvanceNodes_MarksASingleTargetNodeRunningBeforeItFinishes is the regression +// test for RFC 0009 §7.1. A finish event alone puts a one-target node straight to +// `done`, so before the driver reported a start there was no walk in which the +// `running` style the graph draws could ever be reached. +func TestAdvanceNodes_MarksASingleTargetNodeRunningBeforeItFinishes(t *testing.T) { + start := CalibrationProgressPayload{ + Step: 1, Total: 2, Routine: "resonator_spectroscopy", Running: []string{"q0"}, + } + nodes := advanceNodes(nil, &start, aPlan()) + + node := nodes["resonator_spectroscopy"] + if node.State != "running" || node.Done != 0 { + t.Fatalf("after start = %+v, want running with nothing done", node) + } + if len(node.Running) != 1 || node.Running[0] != "q0" { + t.Errorf("running = %v, want [q0]", node.Running) + } + + finish := CalibrationProgressPayload{ + Step: 1, Total: 2, Routine: "resonator_spectroscopy", Target: "q0", Succeeded: 1, + } + nodes = advanceNodes(storedAfter(t, &start, nodes), &finish, aPlan()) + + if node := nodes["resonator_spectroscopy"]; node.State != "done" || node.Done != 1 { + t.Errorf("after finish = %+v, want done 1/1", node) + } + if node := nodes["resonator_spectroscopy"]; len(node.Running) != 0 { + t.Errorf("running = %v, want empty once the target is done", node.Running) + } +} + +// TestAdvanceNodes_ShrinksTheInFlightSetAsAGroupReportsBack covers a fused group, +// whose start names every target at once and whose finishes arrive one at a time +// (RFC 0009 §7.2). +func TestAdvanceNodes_ShrinksTheInFlightSetAsAGroupReportsBack(t *testing.T) { + start := CalibrationProgressPayload{ + Step: 2, Total: 2, Routine: "rabi", Running: []string{"q0", "q1", "q2"}, + } + nodes := advanceNodes(nil, &start, aPlan()) + if got := nodes["rabi"]; len(got.Running) != 3 || got.Total != 3 { + t.Fatalf("after start = %+v, want 3 in flight out of 3", got) + } + + prior := storedAfter(t, &start, nodes) + finish := CalibrationProgressPayload{ + Step: 2, Total: 2, Routine: "rabi", Target: "q1", Succeeded: 1, + } + nodes = advanceNodes(prior, &finish, aPlan()) + + got := nodes["rabi"] + if got.State != "running" || got.Done != 1 { + t.Fatalf("after one finish = %+v, want running 1/3", got) + } + if len(got.Running) != 2 || got.Running[0] != "q0" || got.Running[1] != "q2" { + t.Errorf("running = %v, want the two targets still in flight", got.Running) + } +} + +// TestAdvanceNodes_SettlesANodeWhoseTargetsWereAllSkipped proves a node that never +// ran stops at `blocked` rather than sitting at `pending` for the rest of the walk. +// Skipped is neither done nor failed (RFC 0007 §11), and before RFC 0009 a blocked +// target reported nothing at all. +func TestAdvanceNodes_SettlesANodeWhoseTargetsWereAllSkipped(t *testing.T) { + nodes := map[string]CalibrationNodeState{} + prior := map[string]any{} + for i, target := range []string{"q0", "q1", "q2"} { + event := CalibrationProgressPayload{ + Step: 2, Total: 2, Routine: "rabi", Target: target, Skipped: i + 1, + } + nodes = advanceNodes(prior, &event, aPlan()) + prior = storedAfter(t, &event, nodes) + } + + if got := nodes["rabi"]; got.State != "blocked" || got.Skipped != 3 { + t.Errorf("rabi = %+v, want blocked with 3 skipped", got) + } +} + +// TestAdvanceNodes_PrefersFailureToASkip: a node with one of each has something to +// investigate, and reporting it as merely blocked would bury that. +func TestAdvanceNodes_PrefersFailureToASkip(t *testing.T) { + nodes := map[string]CalibrationNodeState{} + prior := map[string]any{} + walk := []CalibrationProgressPayload{ + {Step: 2, Total: 2, Routine: "rabi", Target: "q0", Skipped: 1}, + {Step: 2, Total: 2, Routine: "rabi", Target: "q1", Skipped: 1, Failed: 1}, + {Step: 2, Total: 2, Routine: "rabi", Target: "q2", Skipped: 1, Failed: 1, Succeeded: 1}, + } + for i := range walk { + nodes = advanceNodes(prior, &walk[i], aPlan()) + prior = storedAfter(t, &walk[i], nodes) + } + + if got := nodes["rabi"]; got.State != "partial" || got.Failed != 1 || got.Skipped != 1 { + t.Errorf("rabi = %+v, want partial with one failure and one skip", got) + } +} + +// TestAdvanceNodes_IgnoresAStartFromAnOlderDriver: no `running` key means the payload +// came from a driver predating RFC 0009, which must reduce exactly as it used to. +func TestAdvanceNodes_IgnoresAStartFromAnOlderDriver(t *testing.T) { + event := CalibrationProgressPayload{ + Step: 2, Total: 2, Routine: "rabi", Target: "q0", Succeeded: 1, + } + nodes := advanceNodes(nil, &event, aPlan()) + + if got := nodes["rabi"]; got.State != "running" || got.Done != 1 || got.Total != 3 { + t.Errorf("rabi = %+v, want running 1/3 as before", got) + } +} + +// storedAfter is the row's `progress` field as the handler writes it, round-tripped +// through JSON — the reducer reads its own previous output back out of a json field, +// not out of memory. +func storedAfter( + t *testing.T, event *CalibrationProgressPayload, nodes map[string]CalibrationNodeState, +) map[string]any { + t.Helper() + stored := event.ToMap() + stored["nodes"] = nodes + encoded, err := json.Marshal(stored) + if err != nil { + t.Fatalf("marshal progress: %v", err) + } + prior := map[string]any{} + if err := json.Unmarshal(encoded, &prior); err != nil { + t.Fatalf("unmarshal progress: %v", err) + } + return prior +} diff --git a/qpi-ui/internal/api/schema.go b/qpi-ui/internal/api/schema.go index b34503b2..d17ae819 100644 --- a/qpi-ui/internal/api/schema.go +++ b/qpi-ui/internal/api/schema.go @@ -277,7 +277,14 @@ type CalibrationProgressPayload struct { Target string `json:"target"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` + Skipped int `json:"skipped"` ElapsedS float64 `json:"elapsed_s"` + + // Targets this routine is about to measure. Present on the event the driver + // sends before the work and absent on the one after it, which is what tells a + // start from a finish (RFC 0009 §7.1). Empty from a driver predating it, so + // such a driver still reduces exactly as it used to. + Running []string `json:"running"` } func (cpp *CalibrationProgressPayload) SetDefaults() { @@ -294,7 +301,9 @@ func (cpp *CalibrationProgressPayload) ToMap() map[string]any { "target": cpp.Target, "succeeded": cpp.Succeeded, "failed": cpp.Failed, + "skipped": cpp.Skipped, "elapsed_s": cpp.ElapsedS, + "running": cpp.Running, } } diff --git a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/CalibrationGraph.tsx b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/CalibrationGraph.tsx index eadee506..655ff53e 100644 --- a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/CalibrationGraph.tsx +++ b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/CalibrationGraph.tsx @@ -43,6 +43,11 @@ const NODE_STYLES: Record< box: "fill-red-500/15 stroke-red-500", label: "fill-red-700 dark:fill-red-400", }, + blocked: { + box: "fill-transparent stroke-amber-500/60", + label: "fill-amber-700/80 dark:fill-amber-300/80", + dashed: true, + }, pending: { box: "fill-transparent stroke-gray-300 dark:stroke-zinc-700", label: "fill-gray-500 dark:fill-zinc-400", @@ -64,6 +69,7 @@ const LEGEND: { status: CalibrationNodeStatus; label: string }[] = [ { status: "running", label: "running" }, { status: "partial", label: "some targets failed" }, { status: "failed", label: "failed" }, + { status: "blocked", label: "prerequisite never measured" }, { status: "pending", label: "not yet run" }, { status: "skipped", label: "applies to nothing here" }, { status: "not_planned", label: "not in this run" }, @@ -144,7 +150,11 @@ export const CalibrationGraph: React.FC = ({ onClick={() => onSelect?.(placed.node.name)} className={onSelect ? "cursor-pointer" : undefined} > - {`${placed.node.name} — ${placed.status}`} + + {placed.running.length + ? `${placed.node.name} — ${placed.status} on ${placed.running.join(", ")}` + : `${placed.node.name} — ${placed.status}`} + = ({ {line} ))} + {/* Which components are being measured, not merely that some are. */} + {placed.running.length > 0 && ( + + {placed.running.join(" ")} + + )} {/* Only when there is more than one, or `1/1` on every node is noise. */} {placed.total > 1 && ( = { running: "running", done: "done", partial: "some targets failed", + blocked: "prerequisite never measured", failed: "failed", pending: "not yet run", skipped: "applies to nothing configured here", @@ -41,12 +44,15 @@ export const NodeCard: React.FC = ({ status, done, total, + running, plan, report, onSelect, }) => { const dependents = dependentsOf(node.name, plan); const outcomes = outcomesFor(node.name, node.targets, report); + const groups = node.groups ?? []; + const inFlight = running ?? []; return (