diff --git a/.gitignore b/.gitignore index 6280943f..a1830af3 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,11 @@ quantify.device.yaml **.nc **.pkl +# Python bytecode. The SDKs ignore this in their own .gitignore; this catches the +# rest of the tree, such as scripts/. +__pycache__/ +*.py[cod] + # Unit test / coverage reports htmlcov/ .tox/ diff --git a/e2e/verify.py b/e2e/verify.py index 93e428c7..0e520725 100644 --- a/e2e/verify.py +++ b/e2e/verify.py @@ -42,6 +42,9 @@ # level (drivers/toggle, drivers/connect) rather than on the QPU itself. DRIVER_FRAMEWORK = True +# Channels every bluefors_gen1 monitor case polls, in the CLI/-o string form. +_BLUEFORS_CHANNELS = "mapper.bf.tmc:K,mapper.bf.pmc:mbar" + s = requests.Session() @@ -133,11 +136,6 @@ def print_summary(jobs): ) -# --------------------------------------------------------------------------- -# New E2E tests for recently added features -# --------------------------------------------------------------------------- - - def test_qpu_seconds_deduction(): """Verify that QPU seconds were deducted from the test user after jobs ran.""" print("\n[verify] Testing QPU seconds deduction …") @@ -1564,10 +1562,6 @@ def forward(src, dst): return True -# Channels every bluefors_gen1 monitor case polls, in the CLI/-o string form. -_BLUEFORS_CHANNELS = "mapper.bf.tmc:K,mapper.bf.pmc:mbar" - - def _monitor_flags(token, fingerprint, mock_port): """The qpi-driver CLI flags for the bluefors_gen1 monitor. Identical across languages — the Go (cobra) and TypeScript (commander) CLIs share the Python diff --git a/qpi-client/py/qpi_client/client.py b/qpi-client/py/qpi_client/client.py index ce49ccdd..52b916b2 100644 --- a/qpi-client/py/qpi_client/client.py +++ b/qpi-client/py/qpi_client/client.py @@ -32,8 +32,6 @@ def __init__(self, base_url: str, api_token: str | None = None) -> None: if api_token: self._session.headers["X-API-Token"] = api_token - # -- public API ---------------------------------------------------------- - def submit_job( self, circuits: list[dict[str, Any]], @@ -122,8 +120,6 @@ def cancel_job(self, job_id: str) -> dict[str, Any]: resp.raise_for_status() return resp.json() - # -- high-level helpers -------------------------------------------------- - def get_backend(self, name: str = "qpi") -> "QPIBackend": """Return a :class:`QPIBackend` handle for the named QPU. @@ -150,8 +146,6 @@ def job(self, job_id: str) -> "QPIJob": return QPIJob(backend=None, job_id=job_id, client=self) - # -- QPU discovery ------------------------------------------------------- - def list_qpus(self) -> list[dict[str, Any]]: """List all online QPUs. @@ -175,8 +169,6 @@ def get_qpu(self, name: str) -> dict[str, Any]: resp.raise_for_status() return resp.json() - # -- QPU Registry & Toggles (admin-only) --------------------------------- - def create_qpu( self, name: str, @@ -259,8 +251,6 @@ def toggle_qpu(self, qpu_id: str, enabled: bool) -> dict[str, Any]: resp.raise_for_status() return resp.json() - # -- Notifications ------------------------------------------------------- - def list_notifications(self) -> list[dict[str, Any]]: """List notifications visible to the authenticated user. @@ -291,8 +281,6 @@ def dismiss_notification(self, notification_id: str) -> dict[str, Any]: resp.raise_for_status() return resp.json() - # -- Booking Slots (time_slots) ------------------------------------------ - def list_time_slots(self) -> list[dict[str, Any]]: """List all booking slots. @@ -368,8 +356,6 @@ def delete_time_slot(self, slot_id: str) -> None: ) resp.raise_for_status() - # -- QPU Time Requests --------------------------------------------------- - def list_time_requests(self) -> list[dict[str, Any]]: """List QPU time requests. @@ -430,8 +416,6 @@ def update_time_request( resp.raise_for_status() return resp.json() - # -- Admin User Management ----------------------------------------------- - def list_users(self) -> list[dict[str, Any]]: """List all registered users (admin-only). @@ -460,8 +444,6 @@ def allocate_qpu_time(self, user_id: str, seconds: int) -> dict[str, Any]: resp.raise_for_status() return resp.json() - # -- Auth helpers -------------------------------------------------------- - def auth_with_password(self, identity: str, password: str) -> dict[str, Any]: """Authenticate as a regular user using email/password. @@ -483,8 +465,6 @@ def auth_with_password(self, identity: str, password: str) -> dict[str, Any]: self._session.headers["Authorization"] = f"Bearer {token}" return data - # -- lifecycle ----------------------------------------------------------- - def close(self) -> None: """Close the underlying HTTP session.""" self._session.close() diff --git a/qpi-client/py/qpi_client/provider.py b/qpi-client/py/qpi_client/provider.py index 8504f9b9..113389fc 100644 --- a/qpi-client/py/qpi_client/provider.py +++ b/qpi-client/py/qpi_client/provider.py @@ -34,10 +34,6 @@ from qpi_client.client import QPIClient -# --------------------------------------------------------------------------- -# QPIJob -# --------------------------------------------------------------------------- - class QPIJob(JobV1): """A Qiskit-compatible job handle backed by the QPI REST API. @@ -62,8 +58,6 @@ def id(self) -> str: """Server-assigned job ID.""" return self.job_id() - # -- JobV1 interface ----------------------------------------------------- - def submit(self) -> None: """No-op — the job was already submitted by the backend.""" @@ -131,8 +125,6 @@ def cancel(self) -> None: """Request cancellation of this job on the server.""" self._client.cancel_job(self.job_id()) - # -- internal helpers ---------------------------------------------------- - def _build_result(self, data: dict[str, Any]) -> Result: """Construct a :class:`qiskit.result.Result` from the API response. @@ -201,11 +193,6 @@ def _build_result(self, data: dict[str, Any]) -> Result: ) -# --------------------------------------------------------------------------- -# QPIBackend -# --------------------------------------------------------------------------- - - class QPIBackend(BackendV2): """A Qiskit ``BackendV2`` that submits circuits to the QPI server. @@ -246,8 +233,6 @@ def _resolve_num_qubits(self, name: str) -> int: f"QPU '{name}' has no valid num_qubits (got {qpu.get('num_qubits')!r})" ) from exp - # -- BackendV2 required properties --------------------------------------- - @property def target(self) -> Target: """Return the transpiler :class:`Target` for this backend.""" @@ -262,8 +247,6 @@ def max_circuits(self) -> int | None: def _default_options(cls) -> Options: return Options(shots=1024, meas_level=2, meas_return="single") - # -- execution ----------------------------------------------------------- - def run( self, circuit: QuantumCircuit | Sequence[QuantumCircuit] | None = None, diff --git a/qpi-driver/py/qpi_driver/executors/utils/coupler_bias.py b/qpi-driver/py/qpi_driver/executors/utils/coupler_bias.py index ce9c93f1..6139c759 100644 --- a/qpi-driver/py/qpi_driver/executors/utils/coupler_bias.py +++ b/qpi-driver/py/qpi_driver/executors/utils/coupler_bias.py @@ -36,6 +36,17 @@ SPI = "spi" QCM = "qcm" +#: The keys a bias submodule may carry, in either scheduler's spelling. +BIAS_KEYS = ( + "parking_current", + "source", + "spi_module", + "spi_output", + "qcm_module", + "qcm_output", + "line_resistance_ohm", +) + class BiasSource(Protocol): """Something that can hold a coupler at a DC current.""" @@ -181,18 +192,6 @@ def edge_names(device: Any) -> list[str]: return list(edges) -#: The keys a bias submodule may carry, in either scheduler's spelling. -BIAS_KEYS = ( - "parking_current", - "source", - "spi_module", - "spi_output", - "qcm_module", - "qcm_output", - "line_resistance_ohm", -) - - def bias_settings(edge: Any) -> dict[str, Any]: """Everything an edge's ``bias`` submodule says, as plain values. diff --git a/qpi-driver/py/qpi_driver/simulation/_compiled_schedule.py b/qpi-driver/py/qpi_driver/simulation/_compiled_schedule.py new file mode 100644 index 00000000..e835e0bc --- /dev/null +++ b/qpi-driver/py/qpi_driver/simulation/_compiled_schedule.py @@ -0,0 +1,56 @@ +"""Reading either scheduler's compiled schedule. + +quantify-scheduler and qblox-scheduler describe a compiled schedule almost +identically and not quite. The differences are small, undocumented, and would +otherwise be scattered through the coordinator's walk, so they are named here +instead: + +- `pulse_info` and `acquisition_info` are a *list* of dicts under quantify and + a single dict under qblox; +- a subschedule is a `Schedule` with `.operations` under quantify, and a + `TimeableSchedule` carrying `operation_dict` inside `.data` under qblox — + and qblox nests them several deep where quantify has one level; +- a drive pulse's amplitude is `G_amp` under quantify and `amplitude` under + qblox, with `amp` used by both for square pulses. + +quantify-scheduler is being deprecated, so the qblox spelling is the one that +has to keep working; supporting both is what makes that transition a +non-event rather than a rewrite of the simulator. +""" + +from typing import Any + + +def read_operations(schedule: Any) -> Any: + """The operation table of a compiled schedule, by either spelling.""" + operations = getattr(schedule, "operations", None) + if operations is not None: + return operations + return schedule.data["operation_dict"] + + +def is_subschedule(operation: Any) -> bool: + """Whether an operation is itself a schedule to descend into.""" + if getattr(operation, "schedulables", None) is not None: + return True + data = getattr(operation, "data", None) + return isinstance(data, dict) and "schedulables" in data + + +def read_info_entries(operation: Any, key: str) -> list[dict]: + """``pulse_info`` or ``acquisition_info`` as a list, whichever shape it is.""" + data = getattr(operation, "data", None) + if not isinstance(data, dict): + return [] + info = data.get(key) + if not info: + return [] + return list(info) if isinstance(info, (list, tuple)) else [info] + + +def read_amplitude(pulse: dict) -> Any: + """A drive pulse's amplitude, by whichever key the scheduler used.""" + for key in ("G_amp", "amplitude", "amp"): + if pulse.get(key) is not None: + return pulse[key] + return None diff --git a/qpi-driver/py/qpi_driver/simulation/agent.py b/qpi-driver/py/qpi_driver/simulation/agent.py index db57ecf0..12d9b0fc 100644 --- a/qpi-driver/py/qpi_driver/simulation/agent.py +++ b/qpi-driver/py/qpi_driver/simulation/agent.py @@ -45,8 +45,6 @@ def __init__(self, agent: Any, coordinator: Any) -> None: self._agent = agent self._coordinator = coordinator - # --- the parts that are genuinely the agent's ---------------------------- - def compile(self, schedule: Any) -> Any: """The real compiler. An invalid schedule fails here exactly as on a node.""" return self._agent.compile(schedule) @@ -66,8 +64,6 @@ def latest_compiled_schedule(self) -> Any: def set_output_data_dir(self, *args: Any, **kwargs: Any) -> Any: return self._agent.set_output_data_dir(*args, **kwargs) - # --- the part that is the chip's ----------------------------------------- - def run(self, schedule: Any, timeout: int = 10, **kwargs: Any) -> xr.Dataset: """Compile, then play through the simulator rather than a cluster. diff --git a/qpi-driver/py/qpi_driver/simulation/coordinator.py b/qpi-driver/py/qpi_driver/simulation/coordinator.py index 0b876d20..f2da2da7 100644 --- a/qpi-driver/py/qpi_driver/simulation/coordinator.py +++ b/qpi-driver/py/qpi_driver/simulation/coordinator.py @@ -63,6 +63,12 @@ import numpy as np import xarray as xr +from qpi_driver.simulation._compiled_schedule import ( + is_subschedule, + read_amplitude, + read_info_entries, + read_operations, +) from qpi_driver.simulation.transmon import ( GHZ, NS, @@ -89,6 +95,25 @@ #: single-shot readout fidelity a discriminator can reach. READOUT_NOISE = 0.25 +#: The most qubits allowed in one entangled register. Each one multiplies the +#: Liouvillian's side by ``levels²``: three transmons is a 729×729 matrix to +#: exponentiate per operation, which is slow but survivable, and four is not. +MAX_ENTANGLED = 3 + +#: Steps per nanosecond when integrating a shaped pulse. Chosen by convergence +#: rather than by argument: the fitted DRAG optimum agrees to five significant +#: figures with a run at eight times this resolution, and the drift term is +#: exponentiated exactly at every step, so what the steps have to resolve is only +#: how the envelope varies. +_STEPS_PER_NS = 2 +_MIN_DRIVE_STEPS = 16 +_MAX_DRIVE_STEPS = 256 + +#: Cached propagators are one per distinct pulse, and a two-qubit register's is a +#: dense (levels^2)^2 matrix. Bounded so a long sweep cannot grow without limit; +#: past the bound the physics is the same and only the speed suffers. +_PROPAGATOR_CACHE_LIMIT = 512 + class SimulationError(RuntimeError): """The schedule asks for something this simulator does not model.""" @@ -134,12 +159,6 @@ class _Acquisition: dead_time: float = 0.0 -#: The most qubits allowed in one entangled register. Each one multiplies the -#: Liouvillian's side by ``levels²``: three transmons is a 729×729 matrix to -#: exponentiate per operation, which is slow but survivable, and four is not. -MAX_ENTANGLED = 3 - - @dataclass class _Register: """A set of qubits sharing one density matrix, and their drive detunings. @@ -349,8 +368,6 @@ def __init__( #: same few pulses thousands of times. self._propagator_cache: dict[Any, Any] = {} - # --- the InstrumentCoordinator surface ------------------------------------ - def prepare(self, compiled_schedule: Any) -> None: self._compiled = compiled_schedule self._acquisitions = [] @@ -376,8 +393,6 @@ def remove_component(self, name: str) -> None: # pragma: no cover - no componen def close(self) -> None: self._compiled = None - # --- walking the schedule -------------------------------------------------- - def _simulate(self, compiled: Any) -> list[_Acquisition]: """Play *compiled* through the transmons and collect what was measured.""" self._repetitions = int(getattr(compiled, "repetitions", 1) or 1) @@ -401,11 +416,11 @@ def _simulate(self, compiled: Any) -> list[_Acquisition]: last_time: dict[str, float] = {} for time, operation, gate_qubits in self._flatten(compiled): - for pulse in _infos(operation, "pulse_info"): + for pulse in read_info_entries(operation, "pulse_info"): self._apply_pulse( pulse, time, registers, clocks, last_time, gate_qubits ) - for acquisition in _infos(operation, "acquisition_info"): + for acquisition in read_info_entries(operation, "acquisition_info"): self._acquire(acquisition, registers, acquisitions, clocks, time) return acquisitions @@ -432,17 +447,21 @@ def _flatten( *previous* one's readout settings. """ found: list[tuple[float, Any, tuple[str, ...]]] = [] - operations = _operations_of(schedule) + operations = read_operations(schedule) for schedulable in schedule.schedulables.values(): operation = operations[schedulable["operation_id"]] start = offset + float(schedulable["abs_time"]) inherited = _gate_qubits(operation) or gate_qubits - if _is_subschedule(operation): + if is_subschedule(operation): found.extend(self._flatten(operation, start, inherited)) else: found.append((start, operation, inherited)) return sorted( - found, key=lambda item: (item[0], bool(_infos(item[1], "acquisition_info"))) + found, + key=lambda item: ( + item[0], + bool(read_info_entries(item[1], "acquisition_info")), + ), ) @staticmethod @@ -455,8 +474,6 @@ def _clock_frequencies(compiled: Any) -> dict[str, float]: continue return frequencies - # --- the physics ----------------------------------------------------------- - def _apply_pulse( self, pulse: dict, @@ -510,7 +527,7 @@ def _apply_pulse( # square tail — the same shape a flux pulse does — so the amplitude # is whichever of those was non-zero, and the zero that clears the # offset must not be mistaken for a readout at zero power. - amplitude = _amplitude_of(pulse) + amplitude = read_amplitude(pulse) if amplitude is None: amplitude = pulse.get("offset_path_I") if amplitude is not None and abs(np.real(amplitude)) > 0: @@ -529,7 +546,7 @@ def _apply_pulse( qubit = _qubit_of(clock) or _qubit_of(port) if qubit is not None and clock.endswith(".12"): - amplitude = _amplitude_of(pulse) + amplitude = read_amplitude(pulse) register = registers.of(qubit) if amplitude is None or duration <= 0: self._idle(register, duration) @@ -566,7 +583,7 @@ def _apply_pulse( qubit ) - amplitude = _amplitude_of(pulse) + amplitude = read_amplitude(pulse) if amplitude is None or duration <= 0: self._idle(register, duration) return @@ -631,7 +648,7 @@ def _apply_flux( ) return - amplitude = _amplitude_of(pulse) + amplitude = read_amplitude(pulse) if amplitude is None or duration <= 0: return self._run_flux( @@ -1297,8 +1314,6 @@ def _joint_outcomes(self, register: _Register) -> dict[str, Any]: self._sample_cache[id(register)] = outcomes return outcomes - # --- the acquisition dataset ------------------------------------------------ - def _to_dataset(self, acquisitions: list[_Acquisition]) -> xr.Dataset: """The shape a real cluster returns, which depends on the protocol asked for. @@ -1509,75 +1524,6 @@ def _gate_qubits(operation: Any) -> tuple[str, ...]: return tuple(str(qubit) for qubit in qubits) -# --- reading either scheduler's compiled schedule --------------------------- -# -# quantify-scheduler and qblox-scheduler describe a compiled schedule almost -# identically and not quite. The differences are small, undocumented, and would -# otherwise be scattered through the walk below, so they are named here instead: -# -# - `pulse_info` and `acquisition_info` are a *list* of dicts under quantify and -# a single dict under qblox; -# - a subschedule is a `Schedule` with `.operations` under quantify, and a -# `TimeableSchedule` carrying `operation_dict` inside `.data` under qblox — -# and qblox nests them several deep where quantify has one level; -# - a drive pulse's amplitude is `G_amp` under quantify and `amplitude` under -# qblox, with `amp` used by both for square pulses. -# -# quantify-scheduler is being deprecated, so the qblox spelling is the one that -# has to keep working; supporting both is what makes that transition a -# non-event rather than a rewrite of the simulator. - - -def _operations_of(schedule: Any) -> Any: - """The operation table of a compiled schedule, by either spelling.""" - operations = getattr(schedule, "operations", None) - if operations is not None: - return operations - return schedule.data["operation_dict"] - - -def _is_subschedule(operation: Any) -> bool: - """Whether an operation is itself a schedule to descend into.""" - if getattr(operation, "schedulables", None) is not None: - return True - data = getattr(operation, "data", None) - return isinstance(data, dict) and "schedulables" in data - - -def _infos(operation: Any, key: str) -> list[dict]: - """``pulse_info`` or ``acquisition_info`` as a list, whichever shape it is.""" - data = getattr(operation, "data", None) - if not isinstance(data, dict): - return [] - info = data.get(key) - if not info: - return [] - return list(info) if isinstance(info, (list, tuple)) else [info] - - -def _amplitude_of(pulse: dict) -> Any: - """A drive pulse's amplitude, by whichever key the scheduler used.""" - for key in ("G_amp", "amplitude", "amp"): - if pulse.get(key) is not None: - return pulse[key] - return None - - -#: Steps per nanosecond when integrating a shaped pulse. Chosen by convergence -#: rather than by argument: the fitted DRAG optimum agrees to five significant -#: figures with a run at eight times this resolution, and the drift term is -#: exponentiated exactly at every step, so what the steps have to resolve is only -#: how the envelope varies. -_STEPS_PER_NS = 2 -_MIN_DRIVE_STEPS = 16 -_MAX_DRIVE_STEPS = 256 - -#: Cached propagators are one per distinct pulse, and a two-qubit register's is a -#: dense (levels^2)^2 matrix. Bounded so a long sweep cannot grow without limit; -#: past the bound the physics is the same and only the speed suffers. -_PROPAGATOR_CACHE_LIMIT = 512 - - def _drive_steps(duration: float) -> int: steps = int(round(duration / NS * _STEPS_PER_NS)) return max(_MIN_DRIVE_STEPS, min(_MAX_DRIVE_STEPS, steps)) diff --git a/qpi-driver/py/qpi_driver/simulation/coupled.py b/qpi-driver/py/qpi_driver/simulation/coupled.py index f88cc258..de1550e6 100644 --- a/qpi-driver/py/qpi_driver/simulation/coupled.py +++ b/qpi-driver/py/qpi_driver/simulation/coupled.py @@ -216,8 +216,6 @@ def __post_init__(self) -> None: "excited state — levels must be at least 3" ) - # --- the register --------------------------------------------------------- - @property def levels(self) -> tuple[int, int]: return self.control.levels, self.target.levels @@ -300,8 +298,6 @@ def _hamiltonian(self, flux_amplitude: float = 0.0): ) ) - # --- the parametric coupler ---------------------------------------------- - def parametric_rate(self, amplitude: float) -> float: """``|11⟩``–``|02⟩`` exchange rate in GHz at a coupler drive *amplitude*.""" return PARAMETRIC_RATE_MHZ * 1e-3 * abs(float(amplitude)) @@ -440,8 +436,6 @@ def _evolve(self, state, flux_amplitude: float, duration_ns: float): ) return result.final_state - # --- populations ---------------------------------------------------------- - def _population(self, density, *, qubit_level: int, on_control: bool) -> float: """Probability that one qubit is in *qubit_level*, tracing out the other.""" import qutip @@ -465,8 +459,6 @@ def _measure(self, values: np.ndarray, averages: int = 1) -> np.ndarray: scale = self.shot_noise / np.sqrt(max(averages, 1)) return values + self._rng.normal(0.0, scale, len(values)) - # --- acquisitions, in the units the routines expect ------------------------ - def chevron(self, amplitudes, durations, averages: int = 1) -> np.ndarray: """Population left on the control after a flux pulse, over the grid. diff --git a/qpi-driver/py/qpi_driver/simulation/transmon.py b/qpi-driver/py/qpi_driver/simulation/transmon.py index ebf4ca71..d2ecc21f 100644 --- a/qpi-driver/py/qpi_driver/simulation/transmon.py +++ b/qpi-driver/py/qpi_driver/simulation/transmon.py @@ -143,8 +143,6 @@ class TransmonSimulator: def __post_init__(self) -> None: self._rng = np.random.default_rng(self.seed) - # --- the spectrum, from scqubits ------------------------------------------ - def eigenvalues(self, count: int | None = None) -> np.ndarray: """Transmon eigenenergies in GHz, by diagonalising the real Hamiltonian.""" import scqubits @@ -191,8 +189,6 @@ def at_f01(self, target_ghz: float, **overrides: Any) -> "TransmonSimulator": candidate = dataclasses.replace(candidate, EJ=candidate.EJ - step) return candidate - # --- the readout resonator ------------------------------------------------- - def resonator( self, qubit: str, configured_ghz: float | None = None ) -> "ReadoutResonator": @@ -217,8 +213,6 @@ def readout_phase(self, qubit: str) -> float: """How far *qubit*'s chain rotates its IQ plane, in degrees.""" return float(self.readout_phases_deg.get(qubit, self.readout_phase_deg)) - # --- the dynamics, from qutip --------------------------------------------- - def _operators(self): """Ladder, projector and collapse operators for a `levels`-level transmon.""" import qutip @@ -273,8 +267,6 @@ def _measure(self, values: np.ndarray, averages: int = 1) -> np.ndarray: scale = self.shot_noise / np.sqrt(max(averages, 1)) return values + self._rng.normal(0.0, scale, len(values)) - # --- acquisitions, in the units the routines expect ------------------------ - def rabi(self, amplitudes) -> np.ndarray: """Excited-state population after driving at each amplitude. diff --git a/qpi-driver/py/qpi_driver/tuners/base/dag.py b/qpi-driver/py/qpi_driver/tuners/base/dag.py index 41ed136d..0e047ed5 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/dag.py +++ b/qpi-driver/py/qpi_driver/tuners/base/dag.py @@ -119,8 +119,6 @@ def partial_order(self, targets: list[str]) -> list[str]: return [name for name in self.execution_order() if name in affected] - # --- checking, and deciding what to recalibrate (RFC 0005 §8) -------------- - def check( self, name: str, diff --git a/qpi-driver/py/qpi_driver/tuners/base/routines.py b/qpi-driver/py/qpi_driver/tuners/base/routines.py index 1a89a754..e6d820a9 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/routines.py +++ b/qpi-driver/py/qpi_driver/tuners/base/routines.py @@ -22,6 +22,11 @@ log = logging.getLogger(__name__) +#: The instrument plays pulses on a 1 ns grid and the compiler refuses anything +#: else, so any *time* written to a device is only usable once it is a whole number +#: of nanoseconds. +GRID_NS = 1e-9 + class RoutineError(Exception): """A routine could not produce a usable result. @@ -98,27 +103,23 @@ def apply(self, device: Any, target: str, params: dict[str, Any]) -> None: calibrate overrides this and lists what it writes in :attr:`updates`. """ - # --- the check form (RFC 0005 §8) ----------------------------------------- - # - # A check answers "does this parameter still hold?" without re-deriving it. - # It is cheap because it is a *different, simpler* experiment, not because it - # is the calibration with fewer setpoints: checking a pi pulse means playing - # the calibrated one and reading the population, which a narrow Rabi sweep - # does not do more cheaply. - # - # The pair below mirrors `build_schedule`/`analyse` deliberately, so that the - # DAG runs both through the same seam and inherits its timeout and error - # recording. Returning `None` from the first means this routine has no check, - # which is the default and is what makes all of this additive. - def build_check_schedule( self, target: str, device: Any, config: RoutineConfig, backend: SchedulerBackend ) -> Any: """A short schedule testing whether this routine's parameters still hold. - ``None`` — the default — means this routine cannot be checked, so its - state is *unknown* rather than stale. That distinction is what stops - `diagnose` blaming a node it has no evidence against. + A check answers "does this parameter still hold?" without re-deriving it + (RFC 0005 §8). It is cheap because it is a *different, simpler* experiment, + not because it is the calibration with fewer setpoints: checking a pi pulse + means playing the calibrated one and reading the population, which a + narrow Rabi sweep does not do more cheaply. + + This pair mirrors `build_schedule`/`analyse` deliberately, so that the DAG + runs both through the same seam and inherits its timeout and error + recording. ``None`` — the default — means this routine cannot be checked, + so its state is *unknown* rather than stale, which is what stops + `diagnose` blaming a node it has no evidence against, and is what makes + all of this additive. """ return None @@ -229,12 +230,6 @@ def linear_setpoints(start: float, stop: float, count: int) -> list[float]: return [start + step * i for i in range(count)] -#: The instrument plays pulses on a 1 ns grid and the compiler refuses anything -#: else, so any *time* written to a device is only usable once it is a whole number -#: of nanoseconds. -GRID_NS = 1e-9 - - def grid_duration(seconds: float) -> float: """*seconds* rounded to the hardware's pulse-time grid. diff --git a/qpi-driver/py/qpi_driver/tuners/fitting/chevron.py b/qpi-driver/py/qpi_driver/tuners/fitting/chevron.py index a8cf7fed..e0e28f0b 100644 --- a/qpi-driver/py/qpi_driver/tuners/fitting/chevron.py +++ b/qpi-driver/py/qpi_driver/tuners/fitting/chevron.py @@ -14,6 +14,13 @@ #: :func:`fit_chevron`. MIN_CHEVRON_CONTRAST = 0.15 +#: Fractions of a resonant row's full swing that count as having left ``|11⟩`` +#: and as having come back to it. Deliberately far apart: what lies between them +#: is the exchange in flight, and anything that only wanders around inside the +#: band has not completed either leg. +_DEPARTED = 0.25 +_RETURNED = 0.75 + def fit_chevron( amplitudes: np.ndarray, durations: np.ndarray, signal: np.ndarray @@ -113,14 +120,6 @@ def _refine_amplitude(contrasts: np.ndarray, amps: np.ndarray, row: int) -> floa return float(np.sum(weights * amps[low:high]) / total) -#: Fractions of a resonant row's full swing that count as having left ``|11⟩`` -#: and as having come back to it. Deliberately far apart: what lies between them -#: is the exchange in flight, and anything that only wanders around inside the -#: band has not completed either leg. -_DEPARTED = 0.25 -_RETURNED = 0.75 - - def _no_exchange_level(row: np.ndarray) -> float: """The level this row reads when the pair is still ``|11>``. diff --git a/qpi-driver/py/qpi_driver/tuners/fitting/lorentzian.py b/qpi-driver/py/qpi_driver/tuners/fitting/lorentzian.py index c4de4e54..a2fd6a62 100644 --- a/qpi-driver/py/qpi_driver/tuners/fitting/lorentzian.py +++ b/qpi-driver/py/qpi_driver/tuners/fitting/lorentzian.py @@ -9,6 +9,11 @@ log = logging.getLogger(__name__) +#: How much broader than the narrowest line a drive power may make it before it +#: counts as power broadening. Generous: at 2x the linewidth the fitted centre is +#: still sound, and the point is to exclude the powers where it is not. +MAX_BROADENING = 2.0 + def lorentzian( f: np.ndarray | float, @@ -100,12 +105,6 @@ def fit_qubit_spectroscopy( } -#: How much broader than the narrowest line a drive power may make it before it -#: counts as power broadening. Generous: at 2x the linewidth the fitted centre is -#: still sound, and the point is to exclude the powers where it is not. -MAX_BROADENING = 2.0 - - def fit_spectroscopy_power( amplitudes: np.ndarray, frequencies: np.ndarray, signal: np.ndarray ) -> dict[str, float]: diff --git a/qpi-driver/py/tests/fixtures/__init__.py b/qpi-driver/py/tests/fixtures/__init__.py deleted file mode 100644 index 6b8d4176..00000000 --- a/qpi-driver/py/tests/fixtures/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Inputs the tests feed to the code under test, rather than tests themselves. - -Mostly data files (``quantify.device.yml``, ``quantify.hardware.json``), but a -fixture can also be a module — ``half_imported_device`` is one, and it needs this -package to be importable by name. -""" diff --git a/qpi-driver/py/tests/test_calibrate_driver.py b/qpi-driver/py/tests/test_calibrate_driver.py index 292d4891..ddbc51b8 100644 --- a/qpi-driver/py/tests/test_calibrate_driver.py +++ b/qpi-driver/py/tests/test_calibrate_driver.py @@ -94,156 +94,148 @@ def _driver(**kwargs) -> CalibrateDriver: return driver -# --- options and registration ------------------------------------------------- - - -def test_build_from_options_reads_every_key(): - driver = build_from_options( - tuner="quantify", - **_transport(), - options=Options( - { - "calibration_config": "./cal.yml", - "drift_check_interval": "1800", - "fidelity_threshold": "0.9995", - "fidelity_2q_threshold": "0.98", - "is_dummy": "yes", - "is_simulated": "no", - "quantify_hardware_config": "./hw.json", - "quantify_device_config": "./dev.yml", - } - ), - ) - - assert driver.calibration_config_path == Path("./cal.yml") - assert driver.drift_check_interval == 1800 - assert driver.fidelity_threshold == 0.9995 - assert driver.fidelity_2q_threshold == 0.98 - assert driver.tuner_options["is_dummy"] is True - assert driver.tuner_options["is_simulated"] is False - assert driver.tuner_options["quantify_device_config"] == Path("./dev.yml") - - -def test_is_simulated_reaches_the_tuner(): - """``-o is_simulated=true`` is what runs a node with no hardware at all.""" - driver = build_from_options( - tuner="quantify", - **_transport(), - options=Options({"is_simulated": "true"}), - ) - assert driver.tuner_options["is_simulated"] is True - assert driver.tuner_options["is_dummy"] is False - - -def test_build_from_options_needs_no_options_at_all(): - """Every option has a working default, so a tuner starts with no ``-o``.""" - driver = build_from_options(tuner="quantify", **_transport(), options=Options({})) - - assert driver.calibration_config_path == Path("./calibration.yml") - assert driver.drift_check_interval == 0 - assert driver.tuner_options["quantify_device_config"] == Path( - "./quantify.device.yml" - ) - - -def test_an_option_nothing_reads_is_visible_as_unread(): - """Reading an option is what declares it (RFC 0003 §13.6). - - The CLI turns an unread option into a startup error; what this asserts is - that the builder leaves it unread rather than swallowing it. - """ - options = Options({"calibration_config": "./cal.yml", "no_such_option": "1"}) - build_from_options(tuner="quantify", **_transport(), options=options) - - assert options.unread() == ("no_such_option",) - - -def test_both_tuner_devices_are_registered(): - registered = {spec.name for spec in devices(Operation.CALIBRATE)} - assert registered == set(CALIBRATE_DEVICES) - assert ( - resolve(Operation.CALIBRATE, "quantify_tuner").operation is Operation.CALIBRATE - ) - - -def test_device_name_drops_the_tuner_suffix_to_name_its_tuner(): - """``quantify_tuner`` is the device; ``quantify`` is the tuner it runs.""" - spec = device_spec("quantify_tuner") - assert spec.build.keywords["tuner"] == "quantify" - +class TestOptionsAndRegistration: + """Building a driver from `Options`, and the tuners it registers under `resolve_tuner`.""" + + def test_build_from_options_reads_every_key(self): + driver = build_from_options( + tuner="quantify", + **_transport(), + options=Options( + { + "calibration_config": "./cal.yml", + "drift_check_interval": "1800", + "fidelity_threshold": "0.9995", + "fidelity_2q_threshold": "0.98", + "is_dummy": "yes", + "is_simulated": "no", + "quantify_hardware_config": "./hw.json", + "quantify_device_config": "./dev.yml", + } + ), + ) -# --- event handling ----------------------------------------------------------- + assert driver.calibration_config_path == Path("./cal.yml") + assert driver.drift_check_interval == 1800 + assert driver.fidelity_threshold == 0.9995 + assert driver.fidelity_2q_threshold == 0.98 + assert driver.tuner_options["is_dummy"] is True + assert driver.tuner_options["is_simulated"] is False + assert driver.tuner_options["quantify_device_config"] == Path("./dev.yml") + + def test_is_simulated_reaches_the_tuner(self): + """``-o is_simulated=true`` is what runs a node with no hardware at all.""" + driver = build_from_options( + tuner="quantify", + **_transport(), + options=Options({"is_simulated": "true"}), + ) + assert driver.tuner_options["is_simulated"] is True + assert driver.tuner_options["is_dummy"] is False + def test_build_from_options_needs_no_options_at_all(self): + """Every option has a working default, so a tuner starts with no ``-o``.""" + driver = build_from_options( + tuner="quantify", **_transport(), options=Options({}) + ) -def test_a_dispatch_is_queued_for_the_worker(): - driver = _driver() - driver.handle_event( - Event( - type=EventType.CALIBRATE_DISPATCH, - payload={"job_id": "j1", "mode": "full"}, + assert driver.calibration_config_path == Path("./calibration.yml") + assert driver.drift_check_interval == 0 + assert driver.tuner_options["quantify_device_config"] == Path( + "./quantify.device.yml" ) - ) - assert driver._job_queue.items == [{"job_id": "j1", "mode": "full"}] + def test_an_option_nothing_reads_is_visible_as_unread(self): + """Reading an option is what declares it (RFC 0003 §13.6). -def test_any_other_event_is_dropped(): - driver = _driver() - driver.handle_event(Event(type=EventType.JOB_DISPATCH, payload={"job_id": "j1"})) - assert driver._job_queue.items == [] + The CLI turns an unread option into a startup error; what this asserts is + that the builder leaves it unread rather than swallowing it. + """ + options = Options({"calibration_config": "./cal.yml", "no_such_option": "1"}) + build_from_options(tuner="quantify", **_transport(), options=options) + assert options.unread() == ("no_such_option",) -def test_a_second_calibration_is_refused_rather_than_queued(monkeypatch): - """One at a time: a report nobody can connect to a request is worse than a refusal.""" - driver = _driver() - emitted: list[Event] = [] - monkeypatch.setattr(driver, "emit", emitted.append) + def test_both_tuner_devices_are_registered(self): + registered = {spec.name for spec in devices(Operation.CALIBRATE)} + assert registered == set(CALIBRATE_DEVICES) + assert ( + resolve(Operation.CALIBRATE, "quantify_tuner").operation + is Operation.CALIBRATE + ) - driver.handle_event( - Event(type=EventType.CALIBRATE_DISPATCH, payload={"job_id": "first"}) - ) - driver.handle_event( - Event(type=EventType.CALIBRATE_DISPATCH, payload={"job_id": "second"}) - ) + def test_device_name_drops_the_tuner_suffix_to_name_its_tuner(self): + """``quantify_tuner`` is the device; ``quantify`` is the tuner it runs.""" + spec = device_spec("quantify_tuner") + assert spec.build.keywords["tuner"] == "quantify" - assert [job["job_id"] for job in driver._job_queue.items] == ["first"] - assert len(emitted) == 1 - assert "already running" in emitted[0].payload["error"] - assert emitted[0].payload["job_id"] == "second" +class TestEventHandling: + """Which events a dispatch queues, and which it drops.""" -# --- the drift timer ---------------------------------------------------------- + def test_a_dispatch_is_queued_for_the_worker(self): + driver = _driver() + driver.handle_event( + Event( + type=EventType.CALIBRATE_DISPATCH, + payload={"job_id": "j1", "mode": "full"}, + ) + ) + assert driver._job_queue.items == [{"job_id": "j1", "mode": "full"}] + def test_any_other_event_is_dropped(self): + driver = _driver() + driver.handle_event( + Event(type=EventType.JOB_DISPATCH, payload={"job_id": "j1"}) + ) + assert driver._job_queue.items == [] -def test_no_drift_timer_is_registered_when_the_interval_is_zero(): - assert _driver(drift_check_interval=0)._periodic == [] + def test_a_second_calibration_is_refused_rather_than_queued(self, monkeypatch): + """One at a time: a report nobody can connect to a request is worse than a refusal.""" + driver = _driver() + emitted: list[Event] = [] + monkeypatch.setattr(driver, "emit", emitted.append) + driver.handle_event( + Event(type=EventType.CALIBRATE_DISPATCH, payload={"job_id": "first"}) + ) + driver.handle_event( + Event(type=EventType.CALIBRATE_DISPATCH, payload={"job_id": "second"}) + ) -def test_the_drift_timer_is_registered_when_an_interval_is_given(): - driver = _driver(drift_check_interval=900) - assert [interval for interval, _ in driver._periodic] == [900] + assert [job["job_id"] for job in driver._job_queue.items] == ["first"] + assert len(emitted) == 1 + assert "already running" in emitted[0].payload["error"] + assert emitted[0].payload["job_id"] == "second" -def test_a_drift_check_carries_the_thresholds_it_is_judged_against(): - driver = _driver( - drift_check_interval=900, fidelity_threshold=0.99, fidelity_2q_threshold=0.9 - ) - driver._check_fidelity() +class TestTheDriftTimer: + """The periodic drift check: when it is registered, and what it is judged against.""" - queued = driver._job_queue.items[0] - assert queued["mode"] == "fidelity_check" - assert queued["job_id"] == DRIFT_CHECK_JOB_ID - assert queued["fidelity_threshold"] == 0.99 - assert queued["fidelity_2q_threshold"] == 0.9 + def test_no_drift_timer_is_registered_when_the_interval_is_zero(self): + assert _driver(drift_check_interval=0)._periodic == [] + def test_the_drift_timer_is_registered_when_an_interval_is_given(self): + driver = _driver(drift_check_interval=900) + assert [interval for interval, _ in driver._periodic] == [900] -def test_a_drift_check_is_skipped_while_a_calibration_is_running(): - driver = _driver(drift_check_interval=900) - driver._busy.set() - driver._check_fidelity() - assert driver._job_queue.items == [] + def test_a_drift_check_carries_the_thresholds_it_is_judged_against(self): + driver = _driver( + drift_check_interval=900, fidelity_threshold=0.99, fidelity_2q_threshold=0.9 + ) + driver._check_fidelity() + queued = driver._job_queue.items[0] + assert queued["mode"] == "fidelity_check" + assert queued["job_id"] == DRIFT_CHECK_JOB_ID + assert queued["fidelity_threshold"] == 0.99 + assert queued["fidelity_2q_threshold"] == 0.9 -# --- result pump -------------------------------------------------------------- + def test_a_drift_check_is_skipped_while_a_calibration_is_running(self): + driver = _driver(drift_check_interval=900) + driver._busy.set() + driver._check_fidelity() + assert driver._job_queue.items == [] def _pump_once(driver, item, monkeypatch): @@ -263,173 +255,58 @@ def get(self): return self._items.pop(0) -def test_a_report_is_emitted_flat_not_nested(monkeypatch): - """QPI-UI unmarshals the payload directly, so the fields are top level. - - Nesting them under a ``results`` key parses without error and leaves every - field at its zero value — a blank record saved for a real calibration. - """ - driver = _driver() - report = CalibrationReport( - timestamp="2026-07-30T00:00:00.000Z", - duration_s=3.0, - mode="full", - backend="stub", - ) - emitted = _pump_once( - driver, {"job_id": "j1", "report": report.to_event_payload()}, monkeypatch - ) - - payload = emitted[0].payload - assert emitted[0].type is EventType.CALIBRATION_RESULT - assert payload["job_id"] == "j1" - assert payload["mode"] == "full" - assert payload["status"] == "success" - assert payload["duration_s"] == 3.0 - assert "results" not in payload - - -def test_a_worker_error_is_emitted_as_an_error(monkeypatch): - driver = _driver() - emitted = _pump_once(driver, {"job_id": "j1", "error": "boom"}, monkeypatch) - assert emitted[0].payload["error"] == "boom" - - -def test_a_drift_follow_up_is_queued_as_a_partial_recalibration(monkeypatch): - driver = _driver() - report = CalibrationReport( - timestamp="t", duration_s=1.0, mode="fidelity_check", backend="stub" - ) - _pump_once( - driver, - { - "job_id": DRIFT_CHECK_JOB_ID, - "report": report.to_event_payload(), - "follow_up": [{"mode": "partial", "job_id": "r1", "target_qubits": ["q0"]}], - }, - monkeypatch, - ) - assert driver._job_queue.items == [ - {"mode": "partial", "job_id": "r1", "target_qubits": ["q0"]} - ] - - -# --- the worker --------------------------------------------------------------- +class TestResultPump: + """What the pump emits for a report, a worker error, and a drift follow-up.""" + def test_a_report_is_emitted_flat_not_nested(self, monkeypatch): + """QPI-UI unmarshals the payload directly, so the fields are top level. -def test_each_mode_calls_its_tuner_method(): - tuner = StubTuner() - results = RecordingQueue() - config = CalibrationConfig(target_qubits=["q0"]) - - _execute_calibration({"job_id": "a", "mode": "full"}, tuner, config, results) - _execute_calibration( - {"job_id": "b", "mode": "partial", "target_qubits": ["q1"]}, - tuner, - config, - results, - ) - _execute_calibration( - {"job_id": "c", "mode": "fidelity_check"}, tuner, config, results - ) - - assert tuner.calls == [ - ("calibrate", None), - ("recalibrate", ["q1"]), - ("check_fidelity", None), - ] - assert [item["job_id"] for item in results.items] == ["a", "b", "c"] - - -def test_a_partial_without_targets_is_refused_not_widened_to_a_full_run(): - """Silently running a full calibration instead would take the QPU out for hours.""" - results = RecordingQueue() - _execute_calibration( - {"job_id": "a", "mode": "partial"}, - StubTuner(), - CalibrationConfig(target_qubits=["q0"]), - results, - ) - assert "target_qubits" in results.items[0]["error"] - - -def test_an_unknown_mode_is_reported(): - results = RecordingQueue() - _execute_calibration( - {"job_id": "a", "mode": "sideways"}, - StubTuner(), - CalibrationConfig(), - results, - ) - assert "sideways" in results.items[0]["error"] - - -def test_a_tuner_that_raises_is_reported_not_fatal(): - class Exploding(StubTuner): - def calibrate(self, config): - raise ValueError("no cluster") - - results = RecordingQueue() - _execute_calibration( - {"job_id": "a", "mode": "full"}, Exploding(), CalibrationConfig(), results - ) - assert "no cluster" in results.items[0]["error"] - - -def test_a_missing_calibration_config_is_an_init_error(tmp_path): - """A tuner with no config would calibrate nothing and report success.""" - results = RecordingQueue() - calibrate_worker( - job_queue=_QueueOf([None]), - result_queue=results, - tuner=StubTuner(), - calibration_config_path=tmp_path / "missing.yml", - ) - assert results.items[0]["job_id"] == "init_error" - assert "not found" in results.items[0]["error"] - - -def test_an_unresolvable_tuner_is_an_init_error(tmp_path): - results = RecordingQueue() - calibrate_worker( - job_queue=_QueueOf([None]), - result_queue=results, - tuner="no_such_tuner", - calibration_config_path=tmp_path / "cal.yml", - ) - assert results.items[0]["job_id"] == "init_error" - assert "Failed to resolve tuner" in results.items[0]["error"] - - -def test_an_unparseable_calibration_config_is_an_init_error(tmp_path): - config = tmp_path / "cal.yml" - config.write_text("routines: [not, a, mapping]\n") - results = RecordingQueue() - calibrate_worker( - job_queue=_QueueOf([None]), - result_queue=results, - tuner=StubTuner(), - calibration_config_path=config, - ) - assert results.items[0]["job_id"] == "init_error" - assert "Failed to load calibration config" in results.items[0]["error"] - + Nesting them under a ``results`` key parses without error and leaves every + field at its zero value — a blank record saved for a real calibration. + """ + driver = _driver() + report = CalibrationReport( + timestamp="2026-07-30T00:00:00.000Z", + duration_s=3.0, + mode="full", + backend="stub", + ) + emitted = _pump_once( + driver, {"job_id": "j1", "report": report.to_event_payload()}, monkeypatch + ) -def test_the_worker_runs_queued_jobs_until_the_poison_pill(tmp_path): - config = tmp_path / "cal.yml" - config.write_text("target_qubits: [q0]\n") - tuner = StubTuner() - results = RecordingQueue() + payload = emitted[0].payload + assert emitted[0].type is EventType.CALIBRATION_RESULT + assert payload["job_id"] == "j1" + assert payload["mode"] == "full" + assert payload["status"] == "success" + assert payload["duration_s"] == 3.0 + assert "results" not in payload - calibrate_worker( - job_queue=_QueueOf([{"job_id": "a", "mode": "full"}, None]), - result_queue=results, - tuner=tuner, - calibration_config_path=config, - ) + def test_a_worker_error_is_emitted_as_an_error(self, monkeypatch): + driver = _driver() + emitted = _pump_once(driver, {"job_id": "j1", "error": "boom"}, monkeypatch) + assert emitted[0].payload["error"] == "boom" - assert [item["job_id"] for item in results.items] == ["a"] - assert tuner.closed is True + def test_a_drift_follow_up_is_queued_as_a_partial_recalibration(self, monkeypatch): + driver = _driver() + report = CalibrationReport( + timestamp="t", duration_s=1.0, mode="fidelity_check", backend="stub" + ) + _pump_once( + driver, + { + "job_id": DRIFT_CHECK_JOB_ID, + "report": report.to_event_payload(), + "follow_up": [ + {"mode": "partial", "job_id": "r1", "target_qubits": ["q0"]} + ], + }, + monkeypatch, + ) + assert driver._job_queue.items == [ + {"mode": "partial", "job_id": "r1", "target_qubits": ["q0"]} + ] class _ConfigRecordingTuner(StubTuner): @@ -444,206 +321,315 @@ def calibrate(self, config): return super().calibrate(config) -def test_an_edited_calibration_config_takes_effect_on_the_next_dispatch(tmp_path): - config = tmp_path / "cal.yml" - config.write_text("target_qubits: [q0]\n") - tuner = _ConfigRecordingTuner() - results = RecordingQueue() - - def rewrite_then_return(items): - """Edit the file between the first job being taken and the second.""" - yielded = [] - for item in items: - if len(yielded) == 1: - config.write_text("target_qubits: [q0, q1]\n") - yielded.append(item) - yield item - - class _EditingQueue: - def __init__(self, items): - self._gen = rewrite_then_return(items) - - def get(self): - return next(self._gen) - - calibrate_worker( - job_queue=_EditingQueue( - [{"job_id": "a", "mode": "full"}, {"job_id": "b", "mode": "full"}, None] - ), - result_queue=results, - tuner=tuner, - calibration_config_path=config, - ) - - assert tuner.configs[0].target_qubits == ["q0"] - assert tuner.configs[1].target_qubits == ["q0", "q1"], ( - "the second calibration should have used the edited config" - ) - +class TestTheWorker: + """The worker loop: each dispatch mode, an unresolvable tuner, and a config edited between dispatches.""" -def test_a_broken_edit_fails_that_calibration_and_leaves_the_worker_alive(tmp_path): - """Failing the job, not carrying on: this config decides which routines run.""" - config = tmp_path / "cal.yml" - config.write_text("target_qubits: [q0]\n") - tuner = StubTuner() - results = RecordingQueue() - - class _BreakingQueue: - def __init__(self, items): - self._items = list(items) - self._taken = 0 - - def get(self): - item = self._items.pop(0) - self._taken += 1 - if self._taken == 1: - config.write_text("routines: [not, a, mapping]\n") - return item - - calibrate_worker( - job_queue=_BreakingQueue( - [{"job_id": "a", "mode": "full"}, {"job_id": "b", "mode": "full"}, None] - ), - result_queue=results, - tuner=tuner, - calibration_config_path=config, - ) + def test_each_mode_calls_its_tuner_method(self): + tuner = StubTuner() + results = RecordingQueue() + config = CalibrationConfig(target_qubits=["q0"]) - assert results.items[0]["job_id"] == "a" - assert "no longer loads" in results.items[0]["error"] - # The worker survived to take the next job and shut down cleanly. - assert tuner.closed is True + _execute_calibration({"job_id": "a", "mode": "full"}, tuner, config, results) + _execute_calibration( + {"job_id": "b", "mode": "partial", "target_qubits": ["q1"]}, + tuner, + config, + results, + ) + _execute_calibration( + {"job_id": "c", "mode": "fidelity_check"}, tuner, config, results + ) + assert tuner.calls == [ + ("calibrate", None), + ("recalibrate", ["q1"]), + ("check_fidelity", None), + ] + assert [item["job_id"] for item in results.items] == ["a", "b", "c"] + + def test_a_partial_without_targets_is_refused_not_widened_to_a_full_run(self): + """Silently running a full calibration instead would take the QPU out for hours.""" + results = RecordingQueue() + _execute_calibration( + {"job_id": "a", "mode": "partial"}, + StubTuner(), + CalibrationConfig(target_qubits=["q0"]), + results, + ) + assert "target_qubits" in results.items[0]["error"] + + def test_an_unknown_mode_is_reported(self): + results = RecordingQueue() + _execute_calibration( + {"job_id": "a", "mode": "sideways"}, + StubTuner(), + CalibrationConfig(), + results, + ) + assert "sideways" in results.items[0]["error"] -# --- the QPU's service state -------------------------------------------------- + def test_a_tuner_that_raises_is_reported_not_fatal(self): + class Exploding(StubTuner): + def calibrate(self, config): + raise ValueError("no cluster") + results = RecordingQueue() + _execute_calibration( + {"job_id": "a", "mode": "full"}, Exploding(), CalibrationConfig(), results + ) + assert "no cluster" in results.items[0]["error"] + + def test_a_missing_calibration_config_is_an_init_error(self, tmp_path): + """A tuner with no config would calibrate nothing and report success.""" + results = RecordingQueue() + calibrate_worker( + job_queue=_QueueOf([None]), + result_queue=results, + tuner=StubTuner(), + calibration_config_path=tmp_path / "missing.yml", + ) + assert results.items[0]["job_id"] == "init_error" + assert "not found" in results.items[0]["error"] + + def test_an_unresolvable_tuner_is_an_init_error(self, tmp_path): + results = RecordingQueue() + calibrate_worker( + job_queue=_QueueOf([None]), + result_queue=results, + tuner="no_such_tuner", + calibration_config_path=tmp_path / "cal.yml", + ) + assert results.items[0]["job_id"] == "init_error" + assert "Failed to resolve tuner" in results.items[0]["error"] + + def test_an_unparseable_calibration_config_is_an_init_error(self, tmp_path): + config = tmp_path / "cal.yml" + config.write_text("routines: [not, a, mapping]\n") + results = RecordingQueue() + calibrate_worker( + job_queue=_QueueOf([None]), + result_queue=results, + tuner=StubTuner(), + calibration_config_path=config, + ) + assert results.items[0]["job_id"] == "init_error" + assert "Failed to load calibration config" in results.items[0]["error"] + + def test_the_worker_runs_queued_jobs_until_the_poison_pill(self, tmp_path): + config = tmp_path / "cal.yml" + config.write_text("target_qubits: [q0]\n") + tuner = StubTuner() + results = RecordingQueue() + + calibrate_worker( + job_queue=_QueueOf([{"job_id": "a", "mode": "full"}, None]), + result_queue=results, + tuner=tuner, + calibration_config_path=config, + ) -def test_a_drift_check_is_skipped_unless_the_qpu_is_online(): - """The one path no server-side gate can reach: it runs on the driver's clock.""" - for state, expected in (("online", 1), ("maintenance", 0), ("disabled", 0)): - driver = _driver() - driver._job_queue = RecordingQueue() - driver.handle_event( - Event(type=EventType.QPU_STATE, payload={"state": state}, driver="d") + assert [item["job_id"] for item in results.items] == ["a"] + assert tuner.closed is True + + def test_an_edited_calibration_config_takes_effect_on_the_next_dispatch( + self, tmp_path + ): + config = tmp_path / "cal.yml" + config.write_text("target_qubits: [q0]\n") + tuner = _ConfigRecordingTuner() + results = RecordingQueue() + + def rewrite_then_return(items): + """Edit the file between the first job being taken and the second.""" + yielded = [] + for item in items: + if len(yielded) == 1: + config.write_text("target_qubits: [q0, q1]\n") + yielded.append(item) + yield item + + class _EditingQueue: + def __init__(self, items): + self._gen = rewrite_then_return(items) + + def get(self): + return next(self._gen) + + calibrate_worker( + job_queue=_EditingQueue( + [{"job_id": "a", "mode": "full"}, {"job_id": "b", "mode": "full"}, None] + ), + result_queue=results, + tuner=tuner, + calibration_config_path=config, ) - driver._check_fidelity() + assert tuner.configs[0].target_qubits == ["q0"] + assert tuner.configs[1].target_qubits == ["q0", "q1"], ( + "the second calibration should have used the edited config" + ) - assert len(driver._job_queue.items) == expected, ( - f"state {state} should have queued {expected} drift check(s)" + def test_a_broken_edit_fails_that_calibration_and_leaves_the_worker_alive( + self, tmp_path + ): + """Failing the job, not carrying on: this config decides which routines run.""" + config = tmp_path / "cal.yml" + config.write_text("target_qubits: [q0]\n") + tuner = StubTuner() + results = RecordingQueue() + + class _BreakingQueue: + def __init__(self, items): + self._items = list(items) + self._taken = 0 + + def get(self): + item = self._items.pop(0) + self._taken += 1 + if self._taken == 1: + config.write_text("routines: [not, a, mapping]\n") + return item + + calibrate_worker( + job_queue=_BreakingQueue( + [{"job_id": "a", "mode": "full"}, {"job_id": "b", "mode": "full"}, None] + ), + result_queue=results, + tuner=tuner, + calibration_config_path=config, ) + assert results.items[0]["job_id"] == "a" + assert "no longer loads" in results.items[0]["error"] + # The worker survived to take the next job and shut down cleanly. + assert tuner.closed is True -def test_a_dispatched_calibration_still_runs_under_maintenance(): - """Maintenance is the state a chip is in while someone works on it.""" - driver = _driver() - driver._job_queue = RecordingQueue() - driver.handle_event( - Event(type=EventType.QPU_STATE, payload={"state": "maintenance"}, driver="d") - ) - driver.handle_event( - Event( - type=EventType.CALIBRATE_DISPATCH, - payload={"job_id": "j1", "mode": "full"}, - driver="d", - ) - ) +class TestTheQpusServiceState: + """A dispatched or drift-checked calibration, judged against the QPU's own online/maintenance/disabled state.""" - assert driver._job_queue.items == [{"job_id": "j1", "mode": "full"}] + def test_a_drift_check_is_skipped_unless_the_qpu_is_online(self): + """The one path no server-side gate can reach: it runs on the driver's clock.""" + for state, expected in (("online", 1), ("maintenance", 0), ("disabled", 0)): + driver = _driver() + driver._job_queue = RecordingQueue() + driver.handle_event( + Event(type=EventType.QPU_STATE, payload={"state": state}, driver="d") + ) + driver._check_fidelity() -def test_a_dispatched_calibration_is_refused_on_a_switched_off_qpu(monkeypatch): - driver = _driver() - driver._job_queue = RecordingQueue() - emitted: list[Event] = [] - monkeypatch.setattr(driver, "emit", emitted.append) - driver.handle_event( - Event(type=EventType.QPU_STATE, payload={"state": "disabled"}, driver="d") - ) + assert len(driver._job_queue.items) == expected, ( + f"state {state} should have queued {expected} drift check(s)" + ) - driver.handle_event( - Event( - type=EventType.CALIBRATE_DISPATCH, - payload={"job_id": "j1", "mode": "full"}, - driver="d", + def test_a_dispatched_calibration_still_runs_under_maintenance(self): + """Maintenance is the state a chip is in while someone works on it.""" + driver = _driver() + driver._job_queue = RecordingQueue() + driver.handle_event( + Event( + type=EventType.QPU_STATE, payload={"state": "maintenance"}, driver="d" + ) ) - ) - - assert driver._job_queue.items == [] - assert "switched off" in emitted[0].payload["error"] + driver.handle_event( + Event( + type=EventType.CALIBRATE_DISPATCH, + payload={"job_id": "j1", "mode": "full"}, + driver="d", + ) + ) -def test_a_driver_calibrates_before_it_is_ever_told_a_state(): - """A server that predates the event says nothing; that is not "disabled".""" - driver = _driver() - driver._job_queue = RecordingQueue() + assert driver._job_queue.items == [{"job_id": "j1", "mode": "full"}] - driver.handle_event( - Event( - type=EventType.CALIBRATE_DISPATCH, - payload={"job_id": "j1", "mode": "full"}, - driver="d", + def test_a_dispatched_calibration_is_refused_on_a_switched_off_qpu( + self, monkeypatch + ): + driver = _driver() + driver._job_queue = RecordingQueue() + emitted: list[Event] = [] + monkeypatch.setattr(driver, "emit", emitted.append) + driver.handle_event( + Event(type=EventType.QPU_STATE, payload={"state": "disabled"}, driver="d") ) - ) - - assert driver._job_queue.items == [{"job_id": "j1", "mode": "full"}] + driver.handle_event( + Event( + type=EventType.CALIBRATE_DISPATCH, + payload={"job_id": "j1", "mode": "full"}, + driver="d", + ) + ) -# --- lifecycle ---------------------------------------------------------------- + assert driver._job_queue.items == [] + assert "switched off" in emitted[0].payload["error"] + def test_a_driver_calibrates_before_it_is_ever_told_a_state(self): + """A server that predates the event says nothing; that is not "disabled".""" + driver = _driver() + driver._job_queue = RecordingQueue() -def test_start_spawns_a_worker_and_a_pump_then_stop_joins_them(tmp_path): - """The one test that really starts the subprocess, so the wiring is proved.""" - driver = CalibrateDriver( - tuner=StubTuner(), calibration_config=tmp_path / "missing.yml" - ) + driver.handle_event( + Event( + type=EventType.CALIBRATE_DISPATCH, + payload={"job_id": "j1", "mode": "full"}, + driver="d", + ) + ) - driver._on_start() - try: - assert driver._worker is not None and driver._worker.is_alive() - assert driver._result_pump is not None and driver._result_pump.is_alive() - assert driver._worker.daemon and driver._result_pump.daemon - finally: - driver._on_stop() + assert driver._job_queue.items == [{"job_id": "j1", "mode": "full"}] - assert not driver._worker.is_alive() +class TestLifecycle: + """Starting and stopping the worker and its pump.""" -def test_stop_is_safe_before_start(tmp_path): - """SIGINT during the handshake means stopping something never started.""" - CalibrateDriver( - tuner=StubTuner(), calibration_config=tmp_path / "cal.yml" - )._on_stop() + def test_start_spawns_a_worker_and_a_pump_then_stop_joins_them(self, tmp_path): + """The one test that really starts the subprocess, so the wiring is proved.""" + driver = CalibrateDriver( + tuner=StubTuner(), calibration_config=tmp_path / "missing.yml" + ) + driver._on_start() + try: + assert driver._worker is not None and driver._worker.is_alive() + assert driver._result_pump is not None and driver._result_pump.is_alive() + assert driver._worker.daemon and driver._result_pump.daemon + finally: + driver._on_stop() -def test_stop_terminates_a_worker_that_will_not_exit(tmp_path): - """A calibration will not finish inside the join window, so terminate is the norm.""" + assert not driver._worker.is_alive() - class Stubborn: - def __init__(self): - self.terminated = False - self.joins = 0 + def test_stop_is_safe_before_start(self, tmp_path): + """SIGINT during the handshake means stopping something never started.""" + CalibrateDriver( + tuner=StubTuner(), calibration_config=tmp_path / "cal.yml" + )._on_stop() - def join(self, timeout=None): - self.joins += 1 + def test_stop_terminates_a_worker_that_will_not_exit(self, tmp_path): + """A calibration will not finish inside the join window, so terminate is the norm.""" - def is_alive(self): - return not self.terminated + class Stubborn: + def __init__(self): + self.terminated = False + self.joins = 0 - def terminate(self): - self.terminated = True + def join(self, timeout=None): + self.joins += 1 - driver = _driver() - driver._result_queue = RecordingQueue() - driver._worker = Stubborn() - driver._on_stop() + def is_alive(self): + return not self.terminated - assert driver._worker.terminated is True - assert driver._worker.joins == 2 # once with a timeout, once after terminate + def terminate(self): + self.terminated = True + driver = _driver() + driver._result_queue = RecordingQueue() + driver._worker = Stubborn() + driver._on_stop() -# --- drift detection ---------------------------------------------------------- + assert driver._worker.terminated is True + assert driver._worker.joins == 2 # once with a timeout, once after terminate def _report_with(**fidelities) -> CalibrationReport: @@ -659,46 +645,45 @@ def _report_with(**fidelities) -> CalibrationReport: return report -def test_nothing_drifts_when_every_fidelity_is_above_threshold(): - job = {"fidelity_threshold": 0.999, "fidelity_2q_threshold": 0.99} - assert _drifted_targets(_report_with(q0=0.9995), job) == [] - - -def test_a_qubit_below_its_threshold_drifts(): - job = {"fidelity_threshold": 0.999, "fidelity_2q_threshold": 0.99} - assert _drifted_targets(_report_with(q0=0.99, q1=0.9999), job) == ["q0"] +class TestDriftDetection: + """Which fidelities drift, and which protocol's threshold decides a target's worst case.""" + def test_nothing_drifts_when_every_fidelity_is_above_threshold(self): + job = {"fidelity_threshold": 0.999, "fidelity_2q_threshold": 0.99} + assert _drifted_targets(_report_with(q0=0.9995), job) == [] -def test_an_edge_is_judged_against_the_two_qubit_threshold(): - """A CZ an order of magnitude worse than a 1Q gate is normal, not drift.""" - job = {"fidelity_threshold": 0.999, "fidelity_2q_threshold": 0.99} - assert _drifted_targets(_report_with(q0_q1=0.995), job) == [] - assert _drifted_targets(_report_with(q0_q1=0.95), job) == ["q0", "q1"] + def test_a_qubit_below_its_threshold_drifts(self): + job = {"fidelity_threshold": 0.999, "fidelity_2q_threshold": 0.99} + assert _drifted_targets(_report_with(q0=0.99, q1=0.9999), job) == ["q0"] + def test_an_edge_is_judged_against_the_two_qubit_threshold(self): + """A CZ an order of magnitude worse than a 1Q gate is normal, not drift.""" + job = {"fidelity_threshold": 0.999, "fidelity_2q_threshold": 0.99} + assert _drifted_targets(_report_with(q0_q1=0.995), job) == [] + assert _drifted_targets(_report_with(q0_q1=0.95), job) == ["q0", "q1"] -def test_the_worst_protocol_wins_for_a_target(): - """A drift check should fire on the worst evidence it has, not the best.""" - report = _report_with(q0=0.9999) - report.add_benchmark( - BenchmarkResult( - protocol="allxy_check", target="q0", fidelity=0.90, error_per_gate=None + def test_the_worst_protocol_wins_for_a_target(self): + """A drift check should fire on the worst evidence it has, not the best.""" + report = _report_with(q0=0.9999) + report.add_benchmark( + BenchmarkResult( + protocol="allxy_check", target="q0", fidelity=0.90, error_per_gate=None + ) ) - ) - assert report.fidelities()["q0"] == 0.90 - - -# --- tuner resolution --------------------------------------------------------- + assert report.fidelities()["q0"] == 0.90 -def test_resolve_tuner_accepts_an_instance_a_class_and_a_name(): - instance = StubTuner() - assert resolve_tuner(instance) is instance - assert isinstance(resolve_tuner(StubTuner), StubTuner) - with pytest.raises(ValueError, match="Unknown tuner name"): - resolve_tuner("nope") - with pytest.raises(TypeError, match="Invalid tuner type"): - resolve_tuner(42) +class TestTunerResolution: + """`resolve_tuner` over an instance, a class, and a name.""" + def test_resolve_tuner_accepts_an_instance_a_class_and_a_name(self): + instance = StubTuner() + assert resolve_tuner(instance) is instance + assert isinstance(resolve_tuner(StubTuner), StubTuner) + with pytest.raises(ValueError, match="Unknown tuner name"): + resolve_tuner("nope") + with pytest.raises(TypeError, match="Invalid tuner type"): + resolve_tuner(42) -def test_a_tuner_class_is_named_after_itself_when_unnamed(): - assert resolve_tuner(StubTuner).name == "stub" + def test_a_tuner_class_is_named_after_itself_when_unnamed(self): + assert resolve_tuner(StubTuner).name == "stub" diff --git a/qpi-driver/py/tests/test_calibration_config.py b/qpi-driver/py/tests/test_calibration_config.py index e21e8443..1e3d6ddf 100644 --- a/qpi-driver/py/tests/test_calibration_config.py +++ b/qpi-driver/py/tests/test_calibration_config.py @@ -131,70 +131,66 @@ def test_the_shipped_example_config_is_valid(): assert config.target_qubits -# --- targets ------------------------------------------------------------------ - - -def test_an_edge_over_an_uncalibrated_qubit_is_refused(): - """The wrong answer here is a calibrated-looking gate, not an exception. - - A CZ is measured *through* its qubits: the chevron prepares ``|11>`` with a pi - pulse on each and reads one back. Over a qubit whose frequency and pi pulse are - whatever the file happened to say, it still fits a curve and still writes an - amplitude and a duration — and the gate does not work. Startup is the only cheap - moment to catch that. - """ - config = CalibrationConfig(target_qubits=["q0", "q1"], target_edges=["q1_q2"]) - with pytest.raises(ConfigError, match="q1_q2 needs q2"): - config.validate_targets() - - -def test_an_edge_whose_qubits_are_both_targeted_is_fine(): - CalibrationConfig( - target_qubits=["q0", "q1", "q2"], target_edges=["q0_q1", "q1_q2"] - ).validate_targets() - - -def test_an_edge_that_is_not_a_pair_is_refused(): - config = CalibrationConfig(target_qubits=["q0"], target_edges=["coupler"]) - with pytest.raises(ConfigError, match="'_' pair"): - config.validate_targets() - - -def test_no_edges_is_not_an_error(): - """A single-qubit-only calibration is an ordinary thing to ask for.""" - CalibrationConfig(target_qubits=["q0"]).validate_targets() - - -# --- the coupler bias, on hardware that may not have one ------------------------ - - -def test_a_recorder_cannot_be_used_to_measure_a_parking_current(): - """`RecordingBias` applies nothing, so sweeping against it measures nothing. - - The dangerous shape, and the reason this is an error rather than a warning: every - bias point returns the same qubit frequency, the sweep is flat, and the fit reports - 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. - """ - 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()) - with pytest.raises(RoutineError, match="hold a parking current"): - routine.measure("q0_q1", None, None, None, None) - - -def test_the_sources_that_do_hold_a_current_say_so(): - """The flag is what separates a rack from a notebook, and both are BiasSource.""" - from qpi_driver.executors.utils.coupler_bias import ( - QcmBias, - RecordingBias, - SpiRackBias, - ) - - assert RecordingBias.holds_current is False - assert QcmBias.holds_current is True - assert SpiRackBias.holds_current is True +class TestTargets: + """``target_qubits``/``target_edges`` validation.""" + + def test_an_edge_over_an_uncalibrated_qubit_is_refused(self): + """The wrong answer here is a calibrated-looking gate, not an exception. + + A CZ is measured *through* its qubits: the chevron prepares ``|11>`` with a pi + pulse on each and reads one back. Over a qubit whose frequency and pi pulse are + whatever the file happened to say, it still fits a curve and still writes an + amplitude and a duration — and the gate does not work. Startup is the only cheap + moment to catch that. + """ + config = CalibrationConfig(target_qubits=["q0", "q1"], target_edges=["q1_q2"]) + with pytest.raises(ConfigError, match="q1_q2 needs q2"): + config.validate_targets() + + def test_an_edge_whose_qubits_are_both_targeted_is_fine(self): + CalibrationConfig( + target_qubits=["q0", "q1", "q2"], target_edges=["q0_q1", "q1_q2"] + ).validate_targets() + + def test_an_edge_that_is_not_a_pair_is_refused(self): + config = CalibrationConfig(target_qubits=["q0"], target_edges=["coupler"]) + with pytest.raises(ConfigError, match="'_' pair"): + config.validate_targets() + + def test_no_edges_is_not_an_error(self): + """A single-qubit-only calibration is an ordinary thing to ask for.""" + CalibrationConfig(target_qubits=["q0"]).validate_targets() + + +class TestCouplerBiasOnHardwareThatMayNotHaveOne: + """A coupler's bias source, when the routine also has to run without one.""" + + def test_a_recorder_cannot_be_used_to_measure_a_parking_current(self): + """`RecordingBias` applies nothing, so sweeping against it measures nothing. + + The dangerous shape, and the reason this is an error rather than a warning: every + bias point returns the same qubit frequency, the sweep is flat, and the fit reports + 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. + """ + 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()) + with pytest.raises(RoutineError, match="hold a parking current"): + routine.measure("q0_q1", None, None, None, None) + + 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.""" + from qpi_driver.executors.utils.coupler_bias import ( + QcmBias, + RecordingBias, + SpiRackBias, + ) + + assert RecordingBias.holds_current is False + assert QcmBias.holds_current is True + assert SpiRackBias.holds_current is True diff --git a/qpi-driver/py/tests/test_calibration_dag.py b/qpi-driver/py/tests/test_calibration_dag.py index 6fec78b4..baac63d1 100644 --- a/qpi-driver/py/tests/test_calibration_dag.py +++ b/qpi-driver/py/tests/test_calibration_dag.py @@ -120,437 +120,407 @@ def _config(**kwargs) -> CalibrationConfig: return CalibrationConfig(**kwargs) -# --- ordering ----------------------------------------------------------------- - - -def test_execution_order_is_topological(): - dag = CalibrationDAG( - [ - StubRoutine("c", depends_on=("b",)), - StubRoutine("a"), - StubRoutine("b", depends_on=("a",)), - ], - _config(), - ) - assert dag.execution_order() == ["a", "b", "c"] - - -def test_a_cycle_is_reported(): - dag = CalibrationDAG( - [StubRoutine("a", depends_on=("b",)), StubRoutine("b", depends_on=("a",))], - _config(), - ) - with pytest.raises(ValueError, match="cycle detected"): - dag.execution_order() - - -def test_a_dependency_on_an_unknown_routine_is_reported(): - """A typo in depends_on would silently drop an edge and reorder the walk.""" - dag = CalibrationDAG([StubRoutine("a", depends_on=("ghost",))], _config()) - with pytest.raises(ValueError, match="depends on unknown routine"): - dag.execution_order() - - -def test_a_disabled_routine_is_dropped_but_its_dependents_keep_their_order(): - config = _config(routines={"b": RoutineConfig(enabled=False)}) - dag = CalibrationDAG( - [ - StubRoutine("a"), - StubRoutine("b", depends_on=("a",)), - StubRoutine("c", depends_on=("b",)), - ], - config, - ) - assert dag.execution_order() == ["a", "c"] - - -def test_partial_order_includes_everything_downstream(): - """Recalibrating a routine invalidates what was tuned from it.""" - dag = CalibrationDAG( - [ - StubRoutine("a"), - StubRoutine("b", depends_on=("a",)), - StubRoutine("c", depends_on=("b",)), - ], - _config(), - ) - assert dag.partial_order(["b"]) == ["b", "c"] - assert dag.partial_order(["a"]) == ["a", "b", "c"] - - -def test_a_partial_recalibration_skips_the_readout_bring_up(): - """A partial run has to be cheaper than a full one, or there is no reason for it. +class TestOrdering: + """The DAG's topological execution order, and what a cycle or an unknown dependency reports.""" + + def test_execution_order_is_topological(self): + dag = CalibrationDAG( + [ + StubRoutine("c", depends_on=("b",)), + StubRoutine("a"), + StubRoutine("b", depends_on=("a",)), + ], + _config(), + ) + assert dag.execution_order() == ["a", "b", "c"] - The saving is the readout chain: `resonator_spectroscopy` and - `resonator_punchout` are a bring-up step, not a drift one, and they are most - of the cost. Everything from `qubit_spectroscopy` down still runs, because - re-finding a frequency invalidates the gates tuned against it. + def test_a_cycle_is_reported(self): + dag = CalibrationDAG( + [StubRoutine("a", depends_on=("b",)), StubRoutine("b", depends_on=("a",))], + _config(), + ) + with pytest.raises(ValueError, match="cycle detected"): + dag.execution_order() + + def test_a_dependency_on_an_unknown_routine_is_reported(self): + """A typo in depends_on would silently drop an edge and reorder the walk.""" + dag = CalibrationDAG([StubRoutine("a", depends_on=("ghost",))], _config()) + with pytest.raises(ValueError, match="depends on unknown routine"): + dag.execution_order() + + def test_a_disabled_routine_is_dropped_but_its_dependents_keep_their_order(self): + config = _config(routines={"b": RoutineConfig(enabled=False)}) + dag = CalibrationDAG( + [ + StubRoutine("a"), + StubRoutine("b", depends_on=("a",)), + StubRoutine("c", depends_on=("b",)), + ], + config, + ) + assert dag.execution_order() == ["a", "c"] + + def test_partial_order_includes_everything_downstream(self): + """Recalibrating a routine invalidates what was tuned from it.""" + dag = CalibrationDAG( + [ + StubRoutine("a"), + StubRoutine("b", depends_on=("a",)), + StubRoutine("c", depends_on=("b",)), + ], + _config(), + ) + assert dag.partial_order(["b"]) == ["b", "c"] + assert dag.partial_order(["a"]) == ["a", "b", "c"] + + def test_a_partial_recalibration_skips_the_readout_bring_up(self): + """A partial run has to be cheaper than a full one, or there is no reason for it. + + The saving is the readout chain: `resonator_spectroscopy` and + `resonator_punchout` are a bring-up step, not a drift one, and they are most + of the cost. Everything from `qubit_spectroscopy` down still runs, because + re-finding a frequency invalidates the gates tuned against it. + """ + dag = CalibrationDAG(all_routines(), _config()) + order = dag.partial_order(list(RECALIBRATION_ROOTS)) + + assert "resonator_spectroscopy" not in order + assert "resonator_punchout" not in order + assert order[0] == "qubit_spectroscopy" + assert {"rabi", "ramsey", "drag", "fine_amplitude", "rb"} <= set(order) + assert set(order) < set(dag.execution_order()) + + +class TestDiagnose: + """RFC 0005 §8's diagnose: which nodes recalibrate, given scripted check outcomes. + + Graph logic, so it belongs here: a fabricated graph with scripted check outcomes + and no simulator. What is asserted is *which* nodes get recalibrated, because + that is the entire value of having checks — the alternative is re-running from + the root and hoping. """ - dag = CalibrationDAG(all_routines(), _config()) - order = dag.partial_order(list(RECALIBRATION_ROOTS)) - - assert "resonator_spectroscopy" not in order - assert "resonator_punchout" not in order - assert order[0] == "qubit_spectroscopy" - assert {"rabi", "ramsey", "drag", "fine_amplitude", "rb"} <= set(order) - assert set(order) < set(dag.execution_order()) - -# --- diagnose (RFC 0005 §8) ---------------------------------------------------- -# -# Graph logic, so it belongs here: a fabricated graph with scripted check outcomes -# and no simulator. What is asserted is *which* nodes get recalibrated, because -# that is the entire value of having checks — the alternative is re-running from -# the root and hoping. - - -def test_with_no_checks_diagnose_reproduces_the_old_behaviour(): - """The additive property, and the reason this could ship without a flag. - - No routine had a check before this, so every node's state is unknown, no - dependency can be blamed, and blame stops at the seed — which is `partial_order` - of the seed, exactly what `recalibrate` did in RFC 0004. - """ - routines = [ - CheckableRoutine("a"), - CheckableRoutine("b", depends_on=("a",)), - CheckableRoutine("c", depends_on=("b",)), - ] - order, _notes = _diagnose(routines, ["b"]) - assert order == ["b", "c"] - - -def test_a_node_whose_check_passes_is_not_recalibrated(): - """The cheapest outcome, and one RFC 0004 could not reach at all.""" - routines = [ - CheckableRoutine("a", verdict=True), - CheckableRoutine("b", depends_on=("a",), verdict=True), - CheckableRoutine("c", depends_on=("b",), verdict=True), - ] - order, notes = _diagnose(routines, ["b"]) - assert order == [] - assert any("nothing to recalibrate" in note for note in notes) - - -def test_blame_moves_up_to_the_failing_dependency(): - """The whole point of the recursion. - - `c` is out of spec, but so is `a` two levels above it. Recalibrating `c` would - measure the wrong thing twice, because its input is wrong. Blame stops at `a`, - and everything downstream of `a` follows. - """ - routines = [ - CheckableRoutine("a", verdict=False), - CheckableRoutine("b", depends_on=("a",), verdict=False), - CheckableRoutine("c", depends_on=("b",), verdict=False), - ] - order, _notes = _diagnose(routines, ["c"]) - assert order == ["a", "b", "c"] - - -def test_blame_stops_where_the_dependency_is_verified_good(): - """`b` is out of spec and `a` is fine, so the fault is `b`'s own.""" - routines = [ - CheckableRoutine("a", verdict=True), - CheckableRoutine("b", depends_on=("a",), verdict=False), - CheckableRoutine("c", depends_on=("b",), verdict=False), - ] - order, _notes = _diagnose(routines, ["c"]) - assert order == ["b", "c"] - assert "a" not in order - - -def test_an_unknown_dependency_does_not_attract_blame(): - """The asymmetry that keeps this from recalibrating everything. - - `b` failed and `a` has no check. Unknown is not failure: with no evidence - against `a` there is no reason to re-run the bring-up, so `b` is blamed. Were - unknown treated as failed, every diagnose would walk to the root and a partial - recalibration would cost more than a full one. - """ - routines = [ - CheckableRoutine("a", verdict=None), - CheckableRoutine("b", depends_on=("a",), verdict=False), - ] - order, _notes = _diagnose(routines, ["b"]) - assert order == ["b"] - - -def test_a_check_that_raises_leaves_the_node_unknown(): - """A broken check is not a drifting parameter, and must not read as one.""" - routines = [ - UnevaluableCheck("a"), - CheckableRoutine("b", depends_on=("a",), verdict=False), - ] - order, _notes = _diagnose(routines, ["b"]) - assert order == ["b"], "an unevaluable check on `a` should not blame `a`" - - -def test_diagnose_checks_each_routine_once_however_many_paths_reach_it(): - """A diamond reaches `a` twice. Checks cost instrument time, so they are cached.""" - root = CheckableRoutine("a", verdict=False) - routines = [ - root, - CheckableRoutine("b", depends_on=("a",), verdict=False), - CheckableRoutine("c", depends_on=("a",), verdict=False), - CheckableRoutine("d", depends_on=("b", "c"), verdict=False), - ] - order, _notes = _diagnose(routines, ["d"]) - assert order == ["a", "b", "c", "d"] - assert root.checked == ["q0"], f"`a` was checked {len(root.checked)} times" - - -def test_the_worst_target_decides(): - """Drift on one qubit of several is drift. A mean would hide it.""" - - class PerTarget(CheckableRoutine): - def analyse_check(self, dataset, target, device, config): - passed = target != "q1" - return CheckOutcome(passed=passed, margin=0.1 if passed else 3.0) - - config = _config(target_qubits=["q0", "q1", "q2"]) - dag = CalibrationDAG([PerTarget("a", verdict=True)], config) - outcome = dag.check("a", config.target_qubits, None, FakeBackend(), config) - - assert outcome is not None and not outcome.passed - assert "q1" in outcome.detail - - -def test_the_real_graph_reports_a_readout_drift_instead_of_hiding_it(): - """The behaviour change that motivated all of this. - - RFC 0004 excluded the readout chain from a partial run, so a drifted readout - frequency had no symptom other than every downstream fit getting worse. - `resonator_spectroscopy` has a check now, so when it fails the blame walks up - into it and the bring-up runs after all — which is more expensive, and correct. - """ - routines = all_routines() - by_name = {r.name: r for r in routines} - assert by_name["resonator_spectroscopy"].has_check - assert by_name["rabi"].has_check - - # Script the two real checks: readout has moved, and the pi pulse is off with it. - scripted = [ - CheckableRoutine( - r.name, - depends_on=r.depends_on, - targets=r.targets, - verdict=False if r.name in ("resonator_spectroscopy", "rabi") else None, + def test_with_no_checks_diagnose_reproduces_the_old_behaviour(self): + """The additive property, and the reason this could ship without a flag. + + No routine had a check before this, so every node's state is unknown, no + dependency can be blamed, and blame stops at the seed — which is `partial_order` + of the seed, exactly what `recalibrate` did in RFC 0004. + """ + routines = [ + CheckableRoutine("a"), + CheckableRoutine("b", depends_on=("a",)), + CheckableRoutine("c", depends_on=("b",)), + ] + order, _notes = _diagnose(routines, ["b"]) + assert order == ["b", "c"] + + def test_a_node_whose_check_passes_is_not_recalibrated(self): + """The cheapest outcome, and one RFC 0004 could not reach at all.""" + routines = [ + CheckableRoutine("a", verdict=True), + CheckableRoutine("b", depends_on=("a",), verdict=True), + CheckableRoutine("c", depends_on=("b",), verdict=True), + ] + order, notes = _diagnose(routines, ["b"]) + assert order == [] + assert any("nothing to recalibrate" in note for note in notes) + + def test_blame_moves_up_to_the_failing_dependency(self): + """The whole point of the recursion. + + `c` is out of spec, but so is `a` two levels above it. Recalibrating `c` would + measure the wrong thing twice, because its input is wrong. Blame stops at `a`, + and everything downstream of `a` follows. + """ + routines = [ + CheckableRoutine("a", verdict=False), + CheckableRoutine("b", depends_on=("a",), verdict=False), + CheckableRoutine("c", depends_on=("b",), verdict=False), + ] + order, _notes = _diagnose(routines, ["c"]) + assert order == ["a", "b", "c"] + + def test_blame_stops_where_the_dependency_is_verified_good(self): + """`b` is out of spec and `a` is fine, so the fault is `b`'s own.""" + routines = [ + CheckableRoutine("a", verdict=True), + CheckableRoutine("b", depends_on=("a",), verdict=False), + CheckableRoutine("c", depends_on=("b",), verdict=False), + ] + order, _notes = _diagnose(routines, ["c"]) + assert order == ["b", "c"] + assert "a" not in order + + def test_an_unknown_dependency_does_not_attract_blame(self): + """The asymmetry that keeps this from recalibrating everything. + + `b` failed and `a` has no check. Unknown is not failure: with no evidence + against `a` there is no reason to re-run the bring-up, so `b` is blamed. Were + unknown treated as failed, every diagnose would walk to the root and a partial + recalibration would cost more than a full one. + """ + routines = [ + CheckableRoutine("a", verdict=None), + CheckableRoutine("b", depends_on=("a",), verdict=False), + ] + order, _notes = _diagnose(routines, ["b"]) + assert order == ["b"] + + def test_a_check_that_raises_leaves_the_node_unknown(self): + """A broken check is not a drifting parameter, and must not read as one.""" + routines = [ + UnevaluableCheck("a"), + CheckableRoutine("b", depends_on=("a",), verdict=False), + ] + order, _notes = _diagnose(routines, ["b"]) + assert order == ["b"], "an unevaluable check on `a` should not blame `a`" + + def test_diagnose_checks_each_routine_once_however_many_paths_reach_it(self): + """A diamond reaches `a` twice. Checks cost instrument time, so they are cached.""" + root = CheckableRoutine("a", verdict=False) + routines = [ + root, + CheckableRoutine("b", depends_on=("a",), verdict=False), + CheckableRoutine("c", depends_on=("a",), verdict=False), + CheckableRoutine("d", depends_on=("b", "c"), verdict=False), + ] + order, _notes = _diagnose(routines, ["d"]) + assert order == ["a", "b", "c", "d"] + assert root.checked == ["q0"], f"`a` was checked {len(root.checked)} times" + + 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): + passed = target != "q1" + return CheckOutcome(passed=passed, margin=0.1 if passed else 3.0) + + config = _config(target_qubits=["q0", "q1", "q2"]) + dag = CalibrationDAG([PerTarget("a", verdict=True)], config) + outcome = dag.check("a", config.target_qubits, None, FakeBackend(), config) + + assert outcome is not None and not outcome.passed + assert "q1" in outcome.detail + + def test_the_real_graph_reports_a_readout_drift_instead_of_hiding_it(self): + """The behaviour change that motivated all of this. + + RFC 0004 excluded the readout chain from a partial run, so a drifted readout + frequency had no symptom other than every downstream fit getting worse. + `resonator_spectroscopy` has a check now, so when it fails the blame walks up + into it and the bring-up runs after all — which is more expensive, and correct. + """ + routines = all_routines() + by_name = {r.name: r for r in routines} + assert by_name["resonator_spectroscopy"].has_check + assert by_name["rabi"].has_check + + # Script the two real checks: readout has moved, and the pi pulse is off with it. + scripted = [ + CheckableRoutine( + r.name, + depends_on=r.depends_on, + targets=r.targets, + verdict=False if r.name in ("resonator_spectroscopy", "rabi") else None, + ) + for r in routines + ] + order, notes = _diagnose(scripted, list(RECALIBRATION_ROOTS)) + + assert "resonator_spectroscopy" in order, ( + "a failing readout check should pull the bring-up back into the run" + ) + assert order.index("resonator_spectroscopy") < order.index("rabi") + assert any("resonator_spectroscopy" in note for note in notes) + + def test_recalibrate_narrows_both_the_targets_and_the_routines(self): + """The entry point, not the helper: `partial_order` was written and left unwired.""" + + class StubTuner(Tuner): + """The real `Tuner`, over a graph shaped like the real one.""" + + def __init__(self): + super().__init__(name="stub") + self._backend = FakeBackend() + + @property + def backend(self): + return self._backend + + @property + def device(self): + return None + + def routines(self): + return [ + StubRoutine("resonator_spectroscopy"), + StubRoutine( + "qubit_spectroscopy", depends_on=("resonator_spectroscopy",) + ), + StubRoutine("rabi", depends_on=("qubit_spectroscopy",)), + StubRoutine("cz_chevron", depends_on=("rabi",), targets="edges"), + ] + + report = StubTuner().recalibrate( + ["q0"], + # q2 is targeted because ``q1_q2`` is: an edge is calibrated *through* its two + # qubits, so a config naming one without the other is refused outright — see + # `CalibrationConfig.validate_targets`. + _config(target_qubits=["q0", "q1", "q2"], target_edges=["q0_q1", "q1_q2"]), ) - for r in routines - ] - order, notes = _diagnose(scripted, list(RECALIBRATION_ROOTS)) - - assert "resonator_spectroscopy" in order, ( - "a failing readout check should pull the bring-up back into the run" - ) - assert order.index("resonator_spectroscopy") < order.index("rabi") - assert any("resonator_spectroscopy" in note for note in notes) - - -def test_recalibrate_narrows_both_the_targets_and_the_routines(): - """The entry point, not the helper: `partial_order` was written and left unwired.""" - - class StubTuner(Tuner): - """The real `Tuner`, over a graph shaped like the real one.""" - - def __init__(self): - super().__init__(name="stub") - self._backend = FakeBackend() - - @property - def backend(self): - return self._backend - - @property - def device(self): - return None - - def routines(self): - return [ - StubRoutine("resonator_spectroscopy"), - StubRoutine( - "qubit_spectroscopy", depends_on=("resonator_spectroscopy",) - ), - StubRoutine("rabi", depends_on=("qubit_spectroscopy",)), - StubRoutine("cz_chevron", depends_on=("rabi",), targets="edges"), - ] - - report = StubTuner().recalibrate( - ["q0"], - # q2 is targeted because ``q1_q2`` is: an edge is calibrated *through* its two - # qubits, so a config naming one without the other is refused outright — see - # `CalibrationConfig.validate_targets`. - _config(target_qubits=["q0", "q1", "q2"], target_edges=["q0_q1", "q1_q2"]), - ) - - ran = {(r.routine_name, r.target) for r in report.routine_results} - assert report.status == "success" - # q0 drifted. `q0_q1` touches it, so it comes in — and q1 with it, because an edge - # brings both its ends. `q1_q2` touches neither and stays out, which is the - # narrowing this test is about. - assert {target for _, target in ran} == {"q0", "q1", "q0_q1"} - # And the readout bring-up is not re-run for a drift the gates can fix. - assert {name for name, _ in ran} == {"qubit_spectroscopy", "rabi", "cz_chevron"} - - -def test_the_real_graph_matches_the_rfc_dependency_table(): - dag = CalibrationDAG(all_routines(), _config()) - order = dag.execution_order() - - assert order[0] == "resonator_spectroscopy" - assert order.index("rabi") < order.index("ramsey") - assert order.index("ramsey") < order.index("drag") - assert order.index("drag") < order.index("fine_amplitude") - assert order.index("fine_amplitude") < order.index("rb") - assert order.index("cz_chevron") < order.index("conditional_phase") - assert order.index("conditional_phase") < order.index("interleaved_rb") - - -def test_every_routine_name_is_unique(): - names = [r.name for r in all_routines()] - assert len(names) == len(set(names)) == len(routine_names()) - - -# --- the walk ----------------------------------------------------------------- - - -def test_a_clean_walk_reports_success_and_applies_each_routine(): - routines = [StubRoutine("a"), StubRoutine("b", depends_on=("a",))] - report = CalibrationDAG(routines, _config()).run( - device=None, backend=FakeBackend(), config=_config() - ) - - assert report.status == "success" - assert [r.routine_name for r in report.routine_results] == ["a", "b"] - assert routines[0].applied == [("q0", {"fidelity": 0.999, "value": 1.0})] - - -def test_running_nothing_is_a_failure_not_a_success(): - """A run that measured nothing must never report success.""" - config = _config(routines={"a": RoutineConfig(enabled=False)}) - report = CalibrationDAG([StubRoutine("a")], config).run( - device=None, backend=FakeBackend(), config=config - ) - - assert report.status == "failed" - assert "every routine is disabled" in report.errors[0] - - -def test_no_target_qubits_is_a_failure(): - config = CalibrationConfig(target_qubits=[]) - report = CalibrationDAG([StubRoutine("a")], config).run( - device=None, backend=FakeBackend(), config=config - ) - assert report.status == "failed" - assert "no target_qubits" in report.errors[0] - - -def test_edge_routines_with_no_edges_configured_leave_nothing_run(): - config = _config(target_edges=[]) - report = CalibrationDAG([StubRoutine("cz", targets="edges")], config).run( - device=None, backend=FakeBackend(), config=config - ) - assert report.status == "failed" - assert "target edges" in report.errors[0] - - -def test_one_failing_routine_does_not_abandon_the_rest(): - routines = [FailingRoutine("a"), StubRoutine("b")] - report = CalibrationDAG(routines, _config()).run( - device=None, backend=FakeBackend(), config=_config() - ) - - assert report.status == "partial_failure" - assert [r.routine_name for r in report.routine_results] == ["b"] - assert "a[q0]: could not fit" in report.errors[0] - - -def test_every_routine_failing_is_a_failure(): - report = CalibrationDAG([FailingRoutine("a")], _config()).run( - device=None, backend=FakeBackend(), config=_config() - ) - assert report.status == "failed" - - -def test_a_benchmark_is_recorded_as_a_benchmark(): - report = CalibrationDAG([StubRoutine("rb", benchmark=True)], _config()).run( - device=None, backend=FakeBackend(), config=_config() - ) - assert [b.protocol for b in report.benchmarks] == ["rb"] - assert report.fidelities() == {"q0": 0.999} + ran = {(r.routine_name, r.target) for r in report.routine_results} + assert report.status == "success" + # q0 drifted. `q0_q1` touches it, so it comes in — and q1 with it, because an edge + # brings both its ends. `q1_q2` touches neither and stays out, which is the + # narrowing this test is about. + assert {target for _, target in ran} == {"q0", "q1", "q0_q1"} + # And the readout bring-up is not re-run for a drift the gates can fix. + assert {name for name, _ in ran} == {"qubit_spectroscopy", "rabi", "cz_chevron"} + + def test_the_real_graph_matches_the_rfc_dependency_table(self): + dag = CalibrationDAG(all_routines(), _config()) + order = dag.execution_order() + + assert order[0] == "resonator_spectroscopy" + assert order.index("rabi") < order.index("ramsey") + assert order.index("ramsey") < order.index("drag") + assert order.index("drag") < order.index("fine_amplitude") + assert order.index("fine_amplitude") < order.index("rb") + assert order.index("cz_chevron") < order.index("conditional_phase") + assert order.index("conditional_phase") < order.index("interleaved_rb") + + def test_every_routine_name_is_unique(self): + names = [r.name for r in all_routines()] + assert len(names) == len(set(names)) == len(routine_names()) + + +class TestTheWalk: + """Walking the DAG end to end: a clean run, failures, timeouts, benchmarks, and the report's own backend name.""" + + def test_a_clean_walk_reports_success_and_applies_each_routine(self): + routines = [StubRoutine("a"), StubRoutine("b", depends_on=("a",))] + report = CalibrationDAG(routines, _config()).run( + device=None, backend=FakeBackend(), config=_config() + ) -def test_a_non_benchmark_records_no_fidelity(): - """T1 writes nothing either; only a benchmark has a fidelity to compare.""" - report = CalibrationDAG([StubRoutine("t1")], _config()).run( - device=None, backend=FakeBackend(), config=_config() - ) - assert report.benchmarks == [] - assert report.fidelities() == {} + assert report.status == "success" + assert [r.routine_name for r in report.routine_results] == ["a", "b"] + assert routines[0].applied == [("q0", {"fidelity": 0.999, "value": 1.0})] + def test_running_nothing_is_a_failure_not_a_success(self): + """A run that measured nothing must never report success.""" + config = _config(routines={"a": RoutineConfig(enabled=False)}) + report = CalibrationDAG([StubRoutine("a")], config).run( + device=None, backend=FakeBackend(), config=config + ) -def test_the_only_subset_runs_just_those_routines(): - routines = [StubRoutine("a"), StubRoutine("b")] - report = CalibrationDAG(routines, _config()).run( - device=None, backend=FakeBackend(), config=_config(), only=["b"] - ) - assert [r.routine_name for r in report.routine_results] == ["b"] + assert report.status == "failed" + assert "every routine is disabled" in report.errors[0] + def test_no_target_qubits_is_a_failure(self): + config = CalibrationConfig(target_qubits=[]) + report = CalibrationDAG([StubRoutine("a")], config).run( + device=None, backend=FakeBackend(), config=config + ) + assert report.status == "failed" + assert "no target_qubits" in report.errors[0] -def test_a_routine_over_its_timeout_is_failed(): - config = _config() - config.routine_timeout_s = -1.0 # anything measurable exceeds it - report = CalibrationDAG([StubRoutine("a")], config).run( - device=None, backend=FakeBackend(), config=config - ) - assert report.status == "failed" - assert "routine_timeout_s" in report.errors[0] + def test_edge_routines_with_no_edges_configured_leave_nothing_run(self): + config = _config(target_edges=[]) + report = CalibrationDAG([StubRoutine("cz", targets="edges")], config).run( + device=None, backend=FakeBackend(), config=config + ) + assert report.status == "failed" + assert "target edges" in report.errors[0] + def test_one_failing_routine_does_not_abandon_the_rest(self): + routines = [FailingRoutine("a"), StubRoutine("b")] + report = CalibrationDAG(routines, _config()).run( + device=None, backend=FakeBackend(), config=_config() + ) -def test_the_report_names_its_backend(): - report = CalibrationDAG([StubRoutine("a")], _config()).run( - device=None, backend=FakeBackend(), config=_config() - ) - assert report.backend == "fake" + assert report.status == "partial_failure" + assert [r.routine_name for r in report.routine_results] == ["b"] + assert "a[q0]: could not fit" in report.errors[0] + def test_every_routine_failing_is_a_failure(self): + report = CalibrationDAG([FailingRoutine("a")], _config()).run( + device=None, backend=FakeBackend(), config=_config() + ) + assert report.status == "failed" -# --- setpoint helpers --------------------------------------------------------- + def test_a_benchmark_is_recorded_as_a_benchmark(self): + report = CalibrationDAG([StubRoutine("rb", benchmark=True)], _config()).run( + device=None, backend=FakeBackend(), config=_config() + ) + assert [b.protocol for b in report.benchmarks] == ["rb"] + assert report.fidelities() == {"q0": 0.999} + def test_a_non_benchmark_records_no_fidelity(self): + """T1 writes nothing either; only a benchmark has a fidelity to compare.""" + report = CalibrationDAG([StubRoutine("t1")], _config()).run( + device=None, backend=FakeBackend(), config=_config() + ) + assert report.benchmarks == [] + assert report.fidelities() == {} -def test_linear_setpoints_span_the_range_inclusively(): - assert linear_setpoints(0.0, 1.0, 5) == [0.0, 0.25, 0.5, 0.75, 1.0] - assert linear_setpoints(3.0, 9.0, 1) == [3.0] + def test_the_only_subset_runs_just_those_routines(self): + routines = [StubRoutine("a"), StubRoutine("b")] + report = CalibrationDAG(routines, _config()).run( + device=None, backend=FakeBackend(), config=_config(), only=["b"] + ) + assert [r.routine_name for r in report.routine_results] == ["b"] + def test_a_routine_over_its_timeout_is_failed(self): + config = _config() + config.routine_timeout_s = -1.0 # anything measurable exceeds it + report = CalibrationDAG([StubRoutine("a")], config).run( + device=None, backend=FakeBackend(), config=config + ) + assert report.status == "failed" + assert "routine_timeout_s" in report.errors[0] -def test_setpoints_fall_back_to_the_default(): - assert setpoints_of(RoutineConfig(), "amps", [1, 2]) == [1, 2] + def test_the_report_names_its_backend(self): + report = CalibrationDAG([StubRoutine("a")], _config()).run( + device=None, backend=FakeBackend(), config=_config() + ) + assert report.backend == "fake" -def test_an_empty_sweep_is_refused(): - """An empty sweep compiles to an empty schedule and measures nothing.""" - with pytest.raises(RoutineError, match="non-empty list"): - setpoints_of(RoutineConfig(params={"amps": []}), "amps", [1]) - with pytest.raises(RoutineError, match="non-empty list"): - setpoints_of(RoutineConfig(params={"amps": 5}), "amps", [1]) +class TestSetpointHelpers: + """Small standalone helpers: setpoints, a routine's repr, the backend's idle, and signal_of.""" + def test_linear_setpoints_span_the_range_inclusively(self): + assert linear_setpoints(0.0, 1.0, 5) == [0.0, 0.25, 0.5, 0.75, 1.0] + assert linear_setpoints(3.0, 9.0, 1) == [3.0] -def test_a_routine_reprs_as_its_name(): - assert repr(StubRoutine("rabi")) == "" + def test_setpoints_fall_back_to_the_default(self): + assert setpoints_of(RoutineConfig(), "amps", [1, 2]) == [1, 2] + def test_an_empty_sweep_is_refused(self): + """An empty sweep compiles to an empty schedule and measures nothing.""" + with pytest.raises(RoutineError, match="non-empty list"): + setpoints_of(RoutineConfig(params={"amps": []}), "amps", [1]) + with pytest.raises(RoutineError, match="non-empty list"): + setpoints_of(RoutineConfig(params={"amps": 5}), "amps", [1]) -def test_the_backend_idle_helper_appends_an_idle(): - class Recording(FakeBackend): - IdlePulse = staticmethod(lambda duration: ("idle", duration)) + def test_a_routine_reprs_as_its_name(self): + assert repr(StubRoutine("rabi")) == "" - schedule = type( - "S", (), {"added": [], "add": lambda self, op: self.added.append(op)} - )() - Recording().idle(schedule, 1e-6) - assert schedule.added == [("idle", 1e-6)] + def test_the_backend_idle_helper_appends_an_idle(self): + class Recording(FakeBackend): + IdlePulse = staticmethod(lambda duration: ("idle", duration)) + schedule = type( + "S", (), {"added": [], "add": lambda self, op: self.added.append(op)} + )() + Recording().idle(schedule, 1e-6) + assert schedule.added == [("idle", 1e-6)] -def test_signal_of_handles_a_bare_array(): - from qpi_driver.tuners.fitting import signal_of + def test_signal_of_handles_a_bare_array(self): + from qpi_driver.tuners.fitting import signal_of - assert list(signal_of(np.array([1.0, 2.0]))) == [1.0, 2.0] + assert list(signal_of(np.array([1.0, 2.0]))) == [1.0, 2.0] diff --git a/qpi-driver/py/tests/test_calibration_e2e.py b/qpi-driver/py/tests/test_calibration_e2e.py index 94939fb2..6a6fa3b5 100644 --- a/qpi-driver/py/tests/test_calibration_e2e.py +++ b/qpi-driver/py/tests/test_calibration_e2e.py @@ -33,7 +33,7 @@ pytest.importorskip("scqubits", reason="needs the [sim] extra") pytest.importorskip("qutip", reason="needs the [sim] extra") -from tests.fixtures.simulation import GHZ, SimulatedTuner # noqa: E402 +from tests.utils.simulation import GHZ, SimulatedTuner # noqa: E402 pytestmark = pytest.mark.scqubits @@ -104,199 +104,207 @@ def run_job(job: dict, tuner: SimulatedTuner, config: CalibrationConfig) -> dict return queue.items[0] -# --- a full calibration -------------------------------------------------------- - - -def test_a_full_calibration_walks_the_graph_and_writes_the_device_back(tmp_path): - """The whole path, from a job dict to a device file the QPU driver can read.""" - device_path = tmp_path / "quantify.device.yml" - tuner = SimulatedTuner(device_config_path=device_path) - config = write_calibration_config(tmp_path) - - result = run_job({"mode": "full", "job_id": "job-1"}, tuner, config) - - assert "error" not in result, result - report = result["report"] - assert report["status"] == "success", report["errors"] - assert report["mode"] == "full" - assert {r["routine_name"] for r in report["routine_results"]} == set(SIMULATED) - - # The device started 3 MHz off the true line, and the calibration moved it on. - true_f01 = tuner.simulator.f01 * GHZ - element = tuner.device.get_element("q0") - assert read_path(element, "clock_freqs.f01") == pytest.approx(true_f01, abs=2e5) - assert read_path(element, "rxy.amp180") == pytest.approx(0.2, rel=0.05) - - # And it reached the file every later job is compiled against, which is the - # only reason any of the above matters. - written = yaml.safe_load(device_path.read_text()) - assert written["q0"]["clock_freqs"]["f01"] == pytest.approx(true_f01, abs=2e5) - assert written["q0"]["rxy"]["amp180"] == pytest.approx(0.2, rel=0.05) - - -def test_a_full_calibration_measures_coherence_it_does_not_tune(tmp_path): - """T1 and T2 write nothing, so their value is only in the report.""" - tuner = SimulatedTuner() - config = write_calibration_config(tmp_path) - - report = run_job({"mode": "full", "job_id": "job-1"}, tuner, config)["report"] - measured = { - r["routine_name"]: r["parameters"] - for r in report["routine_results"] - if r["routine_name"] in ("t1", "t2_echo") - } - - assert measured["t1"]["t1"] == pytest.approx(tuner.simulator.t1_ns * 1e-9, rel=0.05) - assert measured["t2_echo"]["t2"] == pytest.approx( - tuner.simulator.t2_ns * 1e-9, rel=0.1 - ) - - -# --- the drift check, and what it queues --------------------------------------- - - -def test_a_fidelity_check_benchmarks_without_moving_the_calibration(tmp_path): - tuner = SimulatedTuner() - config = write_calibration_config(tmp_path) - element = tuner.device.get_element("q0") - before = ( - read_path(element, "clock_freqs.f01"), - read_path(element, "rxy.amp180"), - ) - - report = run_job( - {"mode": "fidelity_check", "job_id": "drift_check"}, tuner, config - )["report"] - - assert report["mode"] == "fidelity_check" - assert [r["routine_name"] for r in report["routine_results"]] == ["rb"] - assert [b["protocol"] for b in report["benchmarks"]] == ["rb"] - assert 0.9 < report["benchmarks"][0]["fidelity"] <= 1.0 - - after = (read_path(element, "clock_freqs.f01"), read_path(element, "rxy.amp180")) - assert after == before, "a fidelity check measures; it does not calibrate" - - -def test_drift_queues_a_follow_up_the_worker_can_actually_run(tmp_path): - """The join that matters most: what the drift check emits is a runnable job. - - A follow-up whose shape the worker rejects would leave a chip that reported - drift and then did nothing about it — and nothing in a unit test of either - side would say so, because each side is correct on its own. - """ - tuner = SimulatedTuner(gate_error=0.02, device_config_path=tmp_path / "device.yml") - config = write_calibration_config(tmp_path) - check = { - "mode": "fidelity_check", - "job_id": "drift_check", - "fidelity_threshold": 0.999, - "fidelity_2q_threshold": 0.99, - } - - checked = run_job(check, tuner, config) - assert checked["report"]["benchmarks"][0]["fidelity"] < 0.999 - assert checked["follow_up"] == [ - { - "mode": "partial", - "job_id": "drift_check_recalibrate", - "target_qubits": ["q0"], +class TestAFullCalibration: + """The whole path from a job dict to a device file, and coherence measured but not tuned.""" + + def test_a_full_calibration_walks_the_graph_and_writes_the_device_back( + self, tmp_path + ): + """The whole path, from a job dict to a device file the QPU driver can read.""" + device_path = tmp_path / "quantify.device.yml" + tuner = SimulatedTuner(device_config_path=device_path) + config = write_calibration_config(tmp_path) + + result = run_job({"mode": "full", "job_id": "job-1"}, tuner, config) + + assert "error" not in result, result + report = result["report"] + assert report["status"] == "success", report["errors"] + assert report["mode"] == "full" + assert {r["routine_name"] for r in report["routine_results"]} == set(SIMULATED) + + # The device started 3 MHz off the true line, and the calibration moved it on. + true_f01 = tuner.simulator.f01 * GHZ + element = tuner.device.get_element("q0") + assert read_path(element, "clock_freqs.f01") == pytest.approx(true_f01, abs=2e5) + assert read_path(element, "rxy.amp180") == pytest.approx(0.2, rel=0.05) + + # And it reached the file every later job is compiled against, which is the + # only reason any of the above matters. + written = yaml.safe_load(device_path.read_text()) + assert written["q0"]["clock_freqs"]["f01"] == pytest.approx(true_f01, abs=2e5) + assert written["q0"]["rxy"]["amp180"] == pytest.approx(0.2, rel=0.05) + + def test_a_full_calibration_measures_coherence_it_does_not_tune(self, tmp_path): + """T1 and T2 write nothing, so their value is only in the report.""" + tuner = SimulatedTuner() + config = write_calibration_config(tmp_path) + + report = run_job({"mode": "full", "job_id": "job-1"}, tuner, config)["report"] + measured = { + r["routine_name"]: r["parameters"] + for r in report["routine_results"] + if r["routine_name"] in ("t1", "t2_echo") } - ] - - recalibrated = run_job(checked["follow_up"][0], tuner, config) - assert "error" not in recalibrated, recalibrated - assert recalibrated["report"]["mode"] == "partial" - assert recalibrated["report"]["status"] == "success", recalibrated["report"][ - "errors" - ] - - -def test_a_healthy_chip_queues_nothing(tmp_path): - """The control: the follow-up must come from the measurement, not from the mode. - A gate error of 0.04% is a good transmon rather than a perfect one, so this - also pins down that the measured fidelity tracks the injected error instead - of saturating somewhere below the threshold — which is exactly what it used - to do, and would have made the test above pass for the wrong reason. - """ - tuner = SimulatedTuner(gate_error=0.0004) - config = write_calibration_config(tmp_path) - - checked = run_job( - { + assert measured["t1"]["t1"] == pytest.approx( + tuner.simulator.t1_ns * 1e-9, rel=0.05 + ) + assert measured["t2_echo"]["t2"] == pytest.approx( + tuner.simulator.t2_ns * 1e-9, rel=0.1 + ) + + +class TestTheDriftCheckAndWhatItQueues: + """A fidelity check that benchmarks without moving the calibration, and what it queues for the worker to run.""" + + def test_a_fidelity_check_benchmarks_without_moving_the_calibration(self, tmp_path): + tuner = SimulatedTuner() + config = write_calibration_config(tmp_path) + element = tuner.device.get_element("q0") + before = ( + read_path(element, "clock_freqs.f01"), + read_path(element, "rxy.amp180"), + ) + + report = run_job( + {"mode": "fidelity_check", "job_id": "drift_check"}, tuner, config + )["report"] + + assert report["mode"] == "fidelity_check" + assert [r["routine_name"] for r in report["routine_results"]] == ["rb"] + assert [b["protocol"] for b in report["benchmarks"]] == ["rb"] + assert 0.9 < report["benchmarks"][0]["fidelity"] <= 1.0 + + after = ( + read_path(element, "clock_freqs.f01"), + read_path(element, "rxy.amp180"), + ) + assert after == before, "a fidelity check measures; it does not calibrate" + + def test_drift_queues_a_follow_up_the_worker_can_actually_run(self, tmp_path): + """The join that matters most: what the drift check emits is a runnable job. + + A follow-up whose shape the worker rejects would leave a chip that reported + drift and then did nothing about it — and nothing in a unit test of either + side would say so, because each side is correct on its own. + """ + tuner = SimulatedTuner( + gate_error=0.02, device_config_path=tmp_path / "device.yml" + ) + config = write_calibration_config(tmp_path) + check = { "mode": "fidelity_check", "job_id": "drift_check", "fidelity_threshold": 0.999, - }, - tuner, - config, - ) - assert checked["report"]["benchmarks"][0]["fidelity"] > 0.999 - assert "follow_up" not in checked - - -# --- partial recalibration ----------------------------------------------------- - - -def test_a_partial_recalibration_leaves_the_other_qubits_alone(tmp_path): - """Narrowing the targets is most of why a partial run is cheaper than a full one.""" - tuner = SimulatedTuner(qubits=("q0", "q1")) - config = write_calibration_config(tmp_path, qubits=("q0", "q1")) - - report = run_job( - {"mode": "partial", "job_id": "job-2", "target_qubits": ["q0"]}, tuner, config - )["report"] - - assert report["status"] == "success", report["errors"] - assert {r["target"] for r in report["routine_results"]} == {"q0"} - - untouched = tuner.device.get_element("q1") - assert read_path(untouched, "rxy.amp180") == 0.18 - assert read_path(untouched, "clock_freqs.f01") == pytest.approx( - tuner.simulator.f01 * GHZ + 3e6 - ) - - -def test_a_partial_recalibration_with_no_targets_is_refused(tmp_path): - """Running a full calibration instead would be hours nobody asked for.""" - tuner = SimulatedTuner() - config = write_calibration_config(tmp_path) - - result = run_job({"mode": "partial", "job_id": "job-3"}, tuner, config) - - assert "needs target_qubits" in result["error"] - assert "report" not in result - - -def test_an_unknown_mode_is_refused(tmp_path): - tuner = SimulatedTuner() - config = write_calibration_config(tmp_path) - - result = run_job( - {"mode": "recalibrate-everything", "job_id": "job-4"}, tuner, config - ) - - assert "unknown calibration mode" in result["error"] - - -# --- the write-back is a trust boundary ---------------------------------------- - - -def test_a_calibration_that_fails_outright_leaves_the_device_file_alone(tmp_path): - """RFC 0004 §10: a bad write-back does not fail the calibration, it fails every job after it. - - ``allxy`` has no simulated acquisition, so enabling it alone makes every - routine in the walk fail — which is the case where the in-memory device may - hold half-applied parameters and must not reach the file. - """ - device_path = tmp_path / "quantify.device.yml" - device_path.write_text("q0: {this is the good config}\n") - tuner = SimulatedTuner(device_config_path=device_path) - config = write_calibration_config(tmp_path, enabled=("allxy",)) - - report = run_job({"mode": "full", "job_id": "job-5"}, tuner, config)["report"] + "fidelity_2q_threshold": 0.99, + } - assert report["status"] == "failed" - assert report["routine_results"] == [] - assert device_path.read_text() == "q0: {this is the good config}\n" - assert not device_path.with_suffix(".yml.prev").exists() + checked = run_job(check, tuner, config) + assert checked["report"]["benchmarks"][0]["fidelity"] < 0.999 + assert checked["follow_up"] == [ + { + "mode": "partial", + "job_id": "drift_check_recalibrate", + "target_qubits": ["q0"], + } + ] + + recalibrated = run_job(checked["follow_up"][0], tuner, config) + assert "error" not in recalibrated, recalibrated + assert recalibrated["report"]["mode"] == "partial" + assert recalibrated["report"]["status"] == "success", recalibrated["report"][ + "errors" + ] + + def test_a_healthy_chip_queues_nothing(self, tmp_path): + """The control: the follow-up must come from the measurement, not from the mode. + + A gate error of 0.04% is a good transmon rather than a perfect one, so this + also pins down that the measured fidelity tracks the injected error instead + of saturating somewhere below the threshold — which is exactly what it used + to do, and would have made the test above pass for the wrong reason. + """ + tuner = SimulatedTuner(gate_error=0.0004) + config = write_calibration_config(tmp_path) + + checked = run_job( + { + "mode": "fidelity_check", + "job_id": "drift_check", + "fidelity_threshold": 0.999, + }, + tuner, + config, + ) + assert checked["report"]["benchmarks"][0]["fidelity"] > 0.999 + assert "follow_up" not in checked + + +class TestPartialRecalibration: + """Narrowing the targets, which is most of why a partial run is cheaper than a full one.""" + + def test_a_partial_recalibration_leaves_the_other_qubits_alone(self, tmp_path): + """Narrowing the targets is most of why a partial run is cheaper than a full one.""" + tuner = SimulatedTuner(qubits=("q0", "q1")) + config = write_calibration_config(tmp_path, qubits=("q0", "q1")) + + report = run_job( + {"mode": "partial", "job_id": "job-2", "target_qubits": ["q0"]}, + tuner, + config, + )["report"] + + assert report["status"] == "success", report["errors"] + assert {r["target"] for r in report["routine_results"]} == {"q0"} + + untouched = tuner.device.get_element("q1") + assert read_path(untouched, "rxy.amp180") == 0.18 + assert read_path(untouched, "clock_freqs.f01") == pytest.approx( + tuner.simulator.f01 * GHZ + 3e6 + ) + + def test_a_partial_recalibration_with_no_targets_is_refused(self, tmp_path): + """Running a full calibration instead would be hours nobody asked for.""" + tuner = SimulatedTuner() + config = write_calibration_config(tmp_path) + + result = run_job({"mode": "partial", "job_id": "job-3"}, tuner, config) + + assert "needs target_qubits" in result["error"] + assert "report" not in result + + def test_an_unknown_mode_is_refused(self, tmp_path): + tuner = SimulatedTuner() + config = write_calibration_config(tmp_path) + + result = run_job( + {"mode": "recalibrate-everything", "job_id": "job-4"}, tuner, config + ) + + assert "unknown calibration mode" in result["error"] + + +class TestTheWriteBackIsATrustBoundary: + """RFC 0004 §10: a bad write-back does not fail the calibration, it fails every job after it.""" + + def test_a_calibration_that_fails_outright_leaves_the_device_file_alone( + self, tmp_path + ): + """RFC 0004 §10: a bad write-back does not fail the calibration, it fails every job after it. + + ``allxy`` has no simulated acquisition, so enabling it alone makes every + routine in the walk fail — which is the case where the in-memory device may + hold half-applied parameters and must not reach the file. + """ + device_path = tmp_path / "quantify.device.yml" + device_path.write_text("q0: {this is the good config}\n") + tuner = SimulatedTuner(device_config_path=device_path) + config = write_calibration_config(tmp_path, enabled=("allxy",)) + + report = run_job({"mode": "full", "job_id": "job-5"}, tuner, config)["report"] + + assert report["status"] == "failed" + assert report["routine_results"] == [] + assert device_path.read_text() == "q0: {this is the good config}\n" + assert not device_path.with_suffix(".yml.prev").exists() diff --git a/qpi-driver/py/tests/test_calibration_loop.py b/qpi-driver/py/tests/test_calibration_loop.py index 9e3ea2d9..3e7dea85 100644 --- a/qpi-driver/py/tests/test_calibration_loop.py +++ b/qpi-driver/py/tests/test_calibration_loop.py @@ -209,9 +209,6 @@ def counts_from(executor, qasm: str, shots: int = 400) -> dict: return executor.process_result(dataset, "loop-job")["counts"] -# --- the calibration reaching a driver that is already running ------------------ - - def test_a_calibration_reaches_a_running_executor_without_a_restart( calibrated_device, tmp_path ): @@ -267,95 +264,85 @@ def test_a_calibration_reaches_a_running_executor_without_a_restart( close_instruments(scheduler) -# --- what the calibration found ------------------------------------------------ - - -def test_the_calibration_finds_the_simulated_chip(calibrated_device): - """Before trusting any circuit, check the numbers the calibration wrote.""" - device, simulator, scheduler = calibrated_device - written = yaml.safe_load(device.read_text())["q0"] - - # Spectroscopy then Ramsey: the fixture starts 214 MHz out, and what is - # written has to be good enough to drive with, not merely in the right area. - assert written["clock_freqs"]["f01"] == pytest.approx(simulator.f01 * GHZ, abs=1e6) - # The instrument's drive strength puts a pi rotation at 0.2, and nothing - # told the tuner that — Rabi had to find it. - assert written["rxy"]["amp180"] == pytest.approx(0.2, rel=0.05) - - # And the power spectroscopy chose for itself. Asserting it is non-zero rather - # than a value: which power reads best is a property of this chip and this - # sweep, and pinning it would only record what the fit happened to return. What - # matters is that the sweep is no longer a hand-set constant — the fixture ships - # 0, and a config kept working by a hardcoded 3% is how f01 came back 62 MHz out. - assert written["spec"]["amplitude"] > 0.0 - - -def test_the_executor_loads_exactly_what_the_tuner_wrote(calibrated_device): - device, simulator, scheduler = calibrated_device - written = yaml.safe_load(device.read_text())["q0"] +class TestWhatTheCalibrationFound: + """What the calibration actually wrote, checked before any circuit runs against it.""" - close_instruments(scheduler) - executor = resolve_executor( - scheduler, - is_simulated=True, - simulator=simulator, - quantify_hardware_config=FIXTURES / "quantify.hardware.json", - quantify_device_config=device, - ) - element = executor._device.get_element("q0") - assert read_path(element, "rxy.amp180") == written["rxy"]["amp180"] - assert read_path(element, "clock_freqs.f01") == written["clock_freqs"]["f01"] - - -# --- circuits against the calibration ------------------------------------------ + def test_the_calibration_finds_the_simulated_chip(self, calibrated_device): + """Before trusting any circuit, check the numbers the calibration wrote.""" + device, simulator, scheduler = calibrated_device + written = yaml.safe_load(device.read_text())["q0"] + # Spectroscopy then Ramsey: the fixture starts 214 MHz out, and what is + # written has to be good enough to drive with, not merely in the right area. + assert written["clock_freqs"]["f01"] == pytest.approx( + simulator.f01 * GHZ, abs=1e6 + ) + # The instrument's drive strength puts a pi rotation at 0.2, and nothing + # told the tuner that — Rabi had to find it. + assert written["rxy"]["amp180"] == pytest.approx(0.2, rel=0.05) -def test_an_x_gate_lands_in_the_excited_state(calibrated_device): - """The pi pulse the tuner calibrated, played by the executor. + # And the power spectroscopy chose for itself. Asserting it is non-zero rather + # than a value: which power reads best is a property of this chip and this + # sweep, and pinning it would only record what the fit happened to return. What + # matters is that the sweep is no longer a hand-set constant — the fixture ships + # 0, and a config kept working by a hardcoded 3% is how f01 came back 62 MHz out. + assert written["spec"]["amplitude"] > 0.0 - Not exactly 100%: the qubit relaxes during the readout integration, which is - what a real chip does too. - """ - counts = run(*calibrated_device, circuit("x q[0];\n")) - assert counts["1"] / sum(counts.values()) > 0.9 + def test_the_executor_loads_exactly_what_the_tuner_wrote(self, calibrated_device): + device, simulator, scheduler = calibrated_device + written = yaml.safe_load(device.read_text())["q0"] + close_instruments(scheduler) + executor = resolve_executor( + scheduler, + is_simulated=True, + simulator=simulator, + quantify_hardware_config=FIXTURES / "quantify.hardware.json", + quantify_device_config=device, + ) + element = executor._device.get_element("q0") + assert read_path(element, "rxy.amp180") == written["rxy"]["amp180"] + assert read_path(element, "clock_freqs.f01") == written["clock_freqs"]["f01"] -def test_two_x_gates_return_to_the_ground_state(calibrated_device): - """The control for the test above: a miscalibrated pi is still a fixed - rotation, so it would fail *here* rather than passing both.""" - counts = run(*calibrated_device, circuit("x q[0];\nx q[0];\n")) - assert counts["0"] / sum(counts.values()) > 0.95 +class TestCircuitsAgainstTheCalibration: + """Circuits run against a calibrated chip, and what an uncalibrated one gets wrong.""" -def test_a_hadamard_is_an_even_superposition(calibrated_device): - counts = run(*calibrated_device, circuit("h q[0];\n"), shots=800) - assert 0.4 < counts["1"] / sum(counts.values()) < 0.6 + def test_an_x_gate_lands_in_the_excited_state(self, calibrated_device): + """The pi pulse the tuner calibrated, played by the executor. + Not exactly 100%: the qubit relaxes during the readout integration, which is + what a real chip does too. + """ + counts = run(*calibrated_device, circuit("x q[0];\n")) + assert counts["1"] / sum(counts.values()) > 0.9 -def test_an_uncalibrated_chip_gets_the_answer_wrong(scheduler, tmp_path): - """The whole loop, negated — this is what makes the tests above mean something. + def test_two_x_gates_return_to_the_ground_state(self, calibrated_device): + """The control for the test above: a miscalibrated pi is still a fixed + rotation, so it would fail *here* rather than passing both.""" + counts = run(*calibrated_device, circuit("x q[0];\nx q[0];\n")) + assert counts["0"] / sum(counts.values()) > 0.95 - The same X gate on the *uncalibrated* fixture device, whose f01 is 214 MHz - from where the qubit actually is. The pulse compiles, runs and returns - counts; they are simply wrong. If calibration were not load-bearing, this - would pass too. - """ - simulator = TransmonSimulator() - device = tmp_path / "quantify.device.yml" - shutil.copy(FIXTURES / "quantify.device.yml", device) + def test_a_hadamard_is_an_even_superposition(self, calibrated_device): + counts = run(*calibrated_device, circuit("h q[0];\n"), shots=800) + assert 0.4 < counts["1"] / sum(counts.values()) < 0.6 - counts = run(device, simulator, scheduler, circuit("x q[0];\n")) - assert counts["1"] / sum(counts.values()) < 0.1, ( - "an X gate 214 MHz off resonance should not excite the qubit" - ) + def test_an_uncalibrated_chip_gets_the_answer_wrong(self, scheduler, tmp_path): + """The whole loop, negated — this is what makes the tests above mean something. + The same X gate on the *uncalibrated* fixture device, whose f01 is 214 MHz + from where the qubit actually is. The pulse compiles, runs and returns + counts; they are simply wrong. If calibration were not load-bearing, this + would pass too. + """ + simulator = TransmonSimulator() + device = tmp_path / "quantify.device.yml" + shutil.copy(FIXTURES / "quantify.device.yml", device) -# --- two qubits, through the coordinator ---------------------------------------- -# -# Everything above is one qubit at a time. A CZ is the first gate the executor can -# play that needs two held in one state, and the first whose result cannot be -# faked by a pair of independent simulations: entanglement lives between qubits, -# not in either of them. + counts = run(device, simulator, scheduler, circuit("x q[0];\n")) + assert counts["1"] / sum(counts.values()) < 0.1, ( + "an X gate 214 MHz off resonance should not excite the qubit" + ) @pytest.fixture(scope="module") @@ -389,268 +376,9 @@ def two_qubit_device( return device, simulator, scheduler -def test_the_parametric_cz_is_parametrised(two_qubit_device, tmp_path): - """The exchange rate per unit drive, recovered from the simulator's own constant. - - A parametric CZ is resonant in *frequency* and linear in *amplitude*, so once - `cz_spectroscopy` has placed the drive there is no chevron left — the population - just oscillates in duration, faster with drive. Measuring that slope is what makes - the gate predictable, and `PARAMETRIC_RATE_MHZ` is the number it should return. - - Asserted against the simulator's constant rather than a literal, so the test says - "the routine measures the chip" rather than "the routine returns 4.4". - """ - from qpi_driver.simulation.coupled import PARAMETRIC_RATE_MHZ - - shared, simulator, scheduler = two_qubit_device - device = tmp_path / "quantify.device.yml" - device.write_bytes(shared.read_bytes()) - # On the transition, which is what `cz_spectroscopy` would have written. - config = yaml.safe_load(device.read_text()) - config["q1_q2"]["clock_freqs"]["cz"] = float( - config["q1_q2"]["clock_freqs"]["sideband_gap"] - ) - device.write_text(yaml.safe_dump(config)) - - close_instruments(scheduler) - tuner = tuner_for( - scheduler, - name=f"czparam_{scheduler}", - quantify_hardware_config=FIXTURES / "quantify.hardware.json", - quantify_device_config=device, - is_simulated=True, - simulator=simulator, - ) - report = tuner.calibrate( - CalibrationConfig( - target_qubits=["q1", "q2"], - target_edges=["q1_q2"], - routines={ - name: RoutineConfig(enabled=name == "cz_parametrization", params={}) - for name in routine_names() - }, - ) - ) - tuner.close() - close_instruments(scheduler) - - assert report.status == "success", report.errors - params = next( - result.parameters - for result in report.routine_results - if result.routine_name == "cz_parametrization" - ) - # The simulator's rate is in MHz per unit amplitude; the routine reports Hz. - assert params["exchange_rate_hz_per_unit"] == pytest.approx( - PARAMETRIC_RATE_MHZ * 1e6, rel=0.15 - ), f"measured {params['exchange_rate_hz_per_unit'] / 1e6:.3f} MHz per unit" - - written = yaml.safe_load(device.read_text())["q1_q2"]["cz"] - duration_ns = float(written["square_duration"]) * 1e9 - assert duration_ns == pytest.approx(round(duration_ns), abs=1e-6), ( - f"a CZ duration must be a whole number of nanoseconds, got {duration_ns}" - ) - - -def test_the_graph_completes_on_a_plain_transmon_chip(scheduler, tmp_path): - """A config using `BasicTransmonElement` calibrates, minus what it cannot hold. - - This is the shape of a device file written before any of RFC 0005 existed, and it - is what a real chip is most likely to be running. Every node that needs a - `CalibratedTransmon` submodule — the EF chain, the two readout operating points — - has to *decline* rather than fail: six red entries in a report for parameters the - element was never going to have is indistinguishable from six broken routines, and - it is the report a chip owner would see first. - - What must still run is everything a plain transmon can do, and it must come back - `success`. - """ - simulator = TransmonSimulator() - device = tmp_path / "quantify.device.yml" - config = yaml.safe_load((FIXTURES / "quantify.device.yml").read_text()) - for qubit in ("q0", "q1", "q2"): - # Back to the stock element, and drop what only the richer one can hold. - config[qubit]["element_type"]["path"] = ( - "quantify_scheduler.device_under_test.transmon_element.BasicTransmonElement" - ) - for submodule in ("spec", "r12", "measure_2state", "measure_3state"): - config[qubit].pop(submodule, None) - config[qubit]["clock_freqs"]["f01"] = float(simulator.f01 * GHZ) - config[qubit]["rxy"]["amp180"] = 0.2 - device.write_text(yaml.safe_dump(config, sort_keys=False)) - - close_instruments(scheduler) - tuner = tuner_for( - scheduler, - name=f"plain_{scheduler}", - quantify_hardware_config=FIXTURES / "quantify.hardware.json", - quantify_device_config=device, - is_simulated=True, - simulator=simulator, - ) - report = tuner.calibrate( - CalibrationConfig( - target_qubits=["q0"], - routines={ - name: RoutineConfig( - enabled=name in ("resonator_spectroscopy", "rabi", "rabi_12"), - params=_sweep(name), - ) - for name in routine_names() - }, - ) - ) - tuner.close() - close_instruments(scheduler) - - assert report.status == "success", report.errors - ran = {result.routine_name for result in report.routine_results} - assert "resonator_spectroscopy" in ran and "rabi" in ran - # And the one that cannot: declined, not failed. - assert "rabi_12" not in ran - assert report.errors == [] - - -def test_the_coupler_crossing_is_found_and_a_parking_current_written( - two_qubit_device, tmp_path -): - """The one routine a single schedule cannot express, end to end. - - `coupler_anticrossing` sets a DC current, runs a spectroscopy, reads the qubit - back, and repeats — a loop no schedule holds, which is why it overrides `measure`. - What this asserts is that the loop closes: the crossing it finds is where the - simulated coupler actually crosses the qubit, and the current it writes is the - stated fraction of it. - - It also asserts the coupler is put *back*. A sweep that left the chip parked at - whatever current it tried last would corrupt every routine after it, and that is - the one failure worse than not measuring at all. - """ - from qpi_driver.simulation.coupled import TunableCoupler - - shared, simulator, scheduler = two_qubit_device - # Its own copy of the device, because this routine *writes* a parking current and - # `two_qubit_device` is module-scoped. Writing into it left the coupler parked at - # 1.9 mA for every test after this one, which pushes both qubits and breaks the CZ - # — a fixture mutated by one test and read by the next. - device = tmp_path / "quantify.device.yml" - device.write_bytes(shared.read_bytes()) - coupler = TunableCoupler() - expected = np.sqrt( - (coupler.frequency_ghz - simulator.f01) / coupler.curvature_ghz_per_a2 - ) - - close_instruments(scheduler) - tuner = tuner_for( - scheduler, - name=f"anticross_{scheduler}", - quantify_hardware_config=FIXTURES / "quantify.hardware.json", - quantify_device_config=device, - is_simulated=True, - simulator=simulator, - ) - report = tuner.calibrate( - CalibrationConfig( - target_qubits=["q1", "q2"], - target_edges=["q1_q2"], - routines={ - name: RoutineConfig( - enabled=name == "coupler_anticrossing", - params={"points": 9, "span": 300e6}, - ) - for name in routine_names() - }, - ) - ) - parked = tuner.bias.applied.get("q1_q2") - tuner.close() - close_instruments(scheduler) - - assert report.status == "success", report.errors - measured = { - result.target: result.parameters - for result in report.routine_results - if result.routine_name == "coupler_anticrossing" - } - assert measured, "coupler_anticrossing reported nothing" - params = measured["q1_q2"] - - # Within one step of the current sweep, which is the resolution it had. - assert params["crossing_current"] == pytest.approx(expected, abs=0.3e-3), ( - f"the crossing was placed at {params['crossing_current'] * 1e3:.3f} mA " - f"against {expected * 1e3:.3f} mA in the model" - ) - assert params["parking_current"] == pytest.approx(params["crossing_current"] * 0.6) - written = float( - yaml.safe_load(device.read_text())["q1_q2"]["bias"]["parking_current"] - ) - assert written == pytest.approx(params["parking_current"]) - # And the coupler was returned to where it started, not left mid-sweep. - assert parked == pytest.approx(0.0) - - -def test_a_mistuned_parametric_coupler_is_found_and_corrected(two_qubit_device): - """`cz_spectroscopy` moves the CZ drive onto the transition it means to bridge. - - The fixture declares them apart on purpose: ``clock_freqs.cz`` 50 MHz off the - ``clock_freqs.sideband_gap`` the simulator honours as the real resonance. That is - a coupler whose gate compiles, plays and does nothing — amplitude sets how fast - the exchange runs, frequency sets whether it runs at all — and the only way to - see it is to sweep. - - Its own test rather than a line in the full-DAG one: giving that fixture the - coupler edge means calibrating q2 as well, which is a wider change than this - routine needs to be covered. - """ - device, simulator, scheduler = two_qubit_device - # `float` because YAML 1.1 reads `3.9e9` as a *string* — it wants a decimal point - # or a signed exponent — and the device loader's own `_to_num` is what converts it - # on the way in. The fixture writes every clock that way, so reading one back raw - # gives text. - declared = { - name: float(value) - for name, value in yaml.safe_load(device.read_text())["q1_q2"][ - "clock_freqs" - ].items() - } - assert declared["cz"] != declared["sideband_gap"], ( - "the fixture is meant to ship a mistuned coupler, or this test proves nothing" - ) - - close_instruments(scheduler) - tuner = tuner_for( - scheduler, - name=f"cz_spec_{scheduler}", - quantify_hardware_config=FIXTURES / "quantify.hardware.json", - quantify_device_config=device, - is_simulated=True, - simulator=simulator, - ) - report = tuner.calibrate( - CalibrationConfig( - target_qubits=["q1", "q2"], - target_edges=["q1_q2"], - routines={ - name: RoutineConfig( - enabled=name == "cz_spectroscopy", - params={"span": 300e6, "points": 61}, - ) - for name in routine_names() - }, - ) - ) - tuner.close() - close_instruments(scheduler) - - assert report.status == "success", report.errors - written = float(yaml.safe_load(device.read_text())["q1_q2"]["clock_freqs"]["cz"]) - assert written == pytest.approx(declared["sideband_gap"], abs=10e6), ( - f"the CZ drive is still at {written / 1e9:.4f} GHz against a sideband gap of " - f"{declared['sideband_gap'] / 1e9:.4f} GHz" - ) +TWO_QUBIT_HEAD = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[2] q;\nbit[2] c;\n' -TWO_QUBIT_HEAD = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[2] q;\nbit[2] c;\n' TWO_QUBIT_TAIL = "c[0] = measure q[0];\nc[1] = measure q[1];\n" @@ -698,130 +426,392 @@ def correlation(counts: dict) -> float: return abs((both - first * second) / np.sqrt(spread)) -def test_a_cz_entangles_two_qubits(two_qubit_device): - """H, CZ, H is a CNOT, and on a superposition that is a Bell state. - - Before this the coordinator raised on a flux pulse. The claim now is - stronger than "it runs": the two qubits come out correlated, which no pair - of independently simulated qubits can be. +class TestTwoQubitsThroughTheCoordinator: + """Everything above is one qubit at a time. A CZ is the first gate the executor can + play that needs two held in one state, and the first whose result cannot be + faked by a pair of independent simulations: entanglement lives between qubits, + not in either of them. """ - counts = run_pair(*two_qubit_device, "h q[0];\nh q[1];\ncz q[0], q[1];\nh q[1];\n") - total = sum(counts.values()) - assert correlation(counts) > 0.85, ( - f"the pair should be nearly perfectly correlated, got {counts}" - ) - # And each qubit alone is still a coin toss — an entangled pair carries its - # information between the qubits, not in either marginal. - q0_excited = (counts.get("01", 0) + counts.get("11", 0)) / total - assert 0.35 < q0_excited < 0.65, f"q0's marginal should be ~0.5, got {q0_excited}" + def test_the_parametric_cz_is_parametrised(self, two_qubit_device, tmp_path): + """The exchange rate per unit drive, recovered from the simulator's own constant. + + A parametric CZ is resonant in *frequency* and linear in *amplitude*, so once + `cz_spectroscopy` has placed the drive there is no chevron left — the population + just oscillates in duration, faster with drive. Measuring that slope is what makes + the gate predictable, and `PARAMETRIC_RATE_MHZ` is the number it should return. + + Asserted against the simulator's constant rather than a literal, so the test says + "the routine measures the chip" rather than "the routine returns 4.4". + """ + from qpi_driver.simulation.coupled import PARAMETRIC_RATE_MHZ + + shared, simulator, scheduler = two_qubit_device + device = tmp_path / "quantify.device.yml" + device.write_bytes(shared.read_bytes()) + # On the transition, which is what `cz_spectroscopy` would have written. + config = yaml.safe_load(device.read_text()) + config["q1_q2"]["clock_freqs"]["cz"] = float( + config["q1_q2"]["clock_freqs"]["sideband_gap"] + ) + device.write_text(yaml.safe_dump(config)) + close_instruments(scheduler) + tuner = tuner_for( + scheduler, + name=f"czparam_{scheduler}", + quantify_hardware_config=FIXTURES / "quantify.hardware.json", + quantify_device_config=device, + is_simulated=True, + simulator=simulator, + ) + report = tuner.calibrate( + CalibrationConfig( + target_qubits=["q1", "q2"], + target_edges=["q1_q2"], + routines={ + name: RoutineConfig(enabled=name == "cz_parametrization", params={}) + for name in routine_names() + }, + ) + ) + tuner.close() + close_instruments(scheduler) -def test_a_cz_leaves_basis_states_alone(two_qubit_device): - """The population half of the gate: |11> must come back, not stay in |02>. + assert report.status == "success", report.errors + params = next( + result.parameters + for result in report.routine_results + if result.routine_name == "cz_parametrization" + ) + # The simulator's rate is in MHz per unit amplitude; the routine reports Hz. + assert params["exchange_rate_hz_per_unit"] == pytest.approx( + PARAMETRIC_RATE_MHZ * 1e6, rel=0.15 + ), f"measured {params['exchange_rate_hz_per_unit'] / 1e6:.3f} MHz per unit" + + written = yaml.safe_load(device.read_text())["q1_q2"]["cz"] + duration_ns = float(written["square_duration"]) * 1e9 + assert duration_ns == pytest.approx(round(duration_ns), abs=1e-6), ( + f"a CZ duration must be a whole number of nanoseconds, got {duration_ns}" + ) - A CZ works by driving |11> to |02> and back. Stopping half way is a - perfectly good population transfer and a completely broken gate, and this is - what tells the two apart. - """ - counts = run_pair(*two_qubit_device, "x q[0];\nx q[1];\ncz q[0], q[1];\n") - total = sum(counts.values()) - assert counts.get("11", 0) / total > 0.85, ( - f"|11> should survive a full exchange round trip, got {counts}" - ) + def test_the_graph_completes_on_a_plain_transmon_chip(self, scheduler, tmp_path): + """A config using `BasicTransmonElement` calibrates, minus what it cannot hold. + + This is the shape of a device file written before any of RFC 0005 existed, and it + is what a real chip is most likely to be running. Every node that needs a + `CalibratedTransmon` submodule — the EF chain, the two readout operating points — + has to *decline* rather than fail: six red entries in a report for parameters the + element was never going to have is indistinguishable from six broken routines, and + it is the report a chip owner would see first. + + What must still run is everything a plain transmon can do, and it must come back + `success`. + """ + simulator = TransmonSimulator() + device = tmp_path / "quantify.device.yml" + config = yaml.safe_load((FIXTURES / "quantify.device.yml").read_text()) + for qubit in ("q0", "q1", "q2"): + # Back to the stock element, and drop what only the richer one can hold. + config[qubit]["element_type"]["path"] = ( + "quantify_scheduler.device_under_test.transmon_element.BasicTransmonElement" + ) + for submodule in ("spec", "r12", "measure_2state", "measure_3state"): + config[qubit].pop(submodule, None) + config[qubit]["clock_freqs"]["f01"] = float(simulator.f01 * GHZ) + config[qubit]["rxy"]["amp180"] = 0.2 + device.write_text(yaml.safe_dump(config, sort_keys=False)) + close_instruments(scheduler) + tuner = tuner_for( + scheduler, + name=f"plain_{scheduler}", + quantify_hardware_config=FIXTURES / "quantify.hardware.json", + quantify_device_config=device, + is_simulated=True, + simulator=simulator, + ) + report = tuner.calibrate( + CalibrationConfig( + target_qubits=["q0"], + routines={ + name: RoutineConfig( + enabled=name in ("resonator_spectroscopy", "rabi", "rabi_12"), + params=_sweep(name), + ) + for name in routine_names() + }, + ) + ) + tuner.close() + close_instruments(scheduler) -def test_a_mistimed_cz_does_not_entangle(two_qubit_device): - """The control that makes the test above mean something. + assert report.status == "success", report.errors + ran = {result.routine_name for result in report.routine_results} + assert "resonator_spectroscopy" in ran and "rabi" in ran + # And the one that cannot: declined, not failed. + assert "rabi_12" not in ran + assert report.errors == [] - Half the duration is half a round trip: population sits in |02> instead of - returning, so there is no conditional phase and no Bell state. If the CZ - were being applied as a nominal gate rather than integrated, this would pass - identically to the real one. - """ - device, simulator, scheduler = two_qubit_device - broken = device.parent / "mistimed.device.yml" - config = yaml.safe_load(device.read_text()) - config["q0_q1"]["cz"]["square_duration"] = ( - round(config["q0_q1"]["cz"]["square_duration"] * 1e9 / 2) * 1e-9 - ) - broken.write_text(yaml.safe_dump(config)) + def test_the_coupler_crossing_is_found_and_a_parking_current_written( + self, two_qubit_device, tmp_path + ): + """The one routine a single schedule cannot express, end to end. + + `coupler_anticrossing` sets a DC current, runs a spectroscopy, reads the qubit + back, and repeats — a loop no schedule holds, which is why it overrides `measure`. + What this asserts is that the loop closes: the crossing it finds is where the + simulated coupler actually crosses the qubit, and the current it writes is the + stated fraction of it. + + It also asserts the coupler is put *back*. A sweep that left the chip parked at + whatever current it tried last would corrupt every routine after it, and that is + the one failure worse than not measuring at all. + """ + from qpi_driver.simulation.coupled import TunableCoupler + + shared, simulator, scheduler = two_qubit_device + # Its own copy of the device, because this routine *writes* a parking current and + # `two_qubit_device` is module-scoped. Writing into it left the coupler parked at + # 1.9 mA for every test after this one, which pushes both qubits and breaks the CZ + # — a fixture mutated by one test and read by the next. + device = tmp_path / "quantify.device.yml" + device.write_bytes(shared.read_bytes()) + coupler = TunableCoupler() + expected = np.sqrt( + (coupler.frequency_ghz - simulator.f01) / coupler.curvature_ghz_per_a2 + ) - counts = run_pair( - broken, simulator, scheduler, "h q[0];\nh q[1];\ncz q[0], q[1];\nh q[1];\n" - ) - assert correlation(counts) < 0.5, ( - f"half a CZ should not produce an entangled pair, got {counts}" - ) + close_instruments(scheduler) + tuner = tuner_for( + scheduler, + name=f"anticross_{scheduler}", + quantify_hardware_config=FIXTURES / "quantify.hardware.json", + quantify_device_config=device, + is_simulated=True, + simulator=simulator, + ) + report = tuner.calibrate( + CalibrationConfig( + target_qubits=["q1", "q2"], + target_edges=["q1_q2"], + routines={ + name: RoutineConfig( + enabled=name == "coupler_anticrossing", + params={"points": 9, "span": 300e6}, + ) + for name in routine_names() + }, + ) + ) + parked = tuner.bias.applied.get("q1_q2") + tuner.close() + close_instruments(scheduler) + assert report.status == "success", report.errors + measured = { + result.target: result.parameters + for result in report.routine_results + if result.routine_name == "coupler_anticrossing" + } + assert measured, "coupler_anticrossing reported nothing" + params = measured["q1_q2"] -# --- virtual Z ------------------------------------------------------------------ + # Within one step of the current sweep, which is the resolution it had. + assert params["crossing_current"] == pytest.approx(expected, abs=0.3e-3), ( + f"the crossing was placed at {params['crossing_current'] * 1e3:.3f} mA " + f"against {expected * 1e3:.3f} mA in the model" + ) + assert params["parking_current"] == pytest.approx( + params["crossing_current"] * 0.6 + ) + written = float( + yaml.safe_load(device.read_text())["q1_q2"]["bias"]["parking_current"] + ) + assert written == pytest.approx(params["parking_current"]) + # And the coupler was returned to where it started, not left mid-sweep. + assert parked == pytest.approx(0.0) + def test_a_mistuned_parametric_coupler_is_found_and_corrected( + self, two_qubit_device + ): + """`cz_spectroscopy` moves the CZ drive onto the transition it means to bridge. + + The fixture declares them apart on purpose: ``clock_freqs.cz`` 50 MHz off the + ``clock_freqs.sideband_gap`` the simulator honours as the real resonance. That is + a coupler whose gate compiles, plays and does nothing — amplitude sets how fast + the exchange runs, frequency sets whether it runs at all — and the only way to + see it is to sweep. + + Its own test rather than a line in the full-DAG one: giving that fixture the + coupler edge means calibrating q2 as well, which is a wider change than this + routine needs to be covered. + """ + device, simulator, scheduler = two_qubit_device + # `float` because YAML 1.1 reads `3.9e9` as a *string* — it wants a decimal point + # or a signed exponent — and the device loader's own `_to_num` is what converts it + # on the way in. The fixture writes every clock that way, so reading one back raw + # gives text. + declared = { + name: float(value) + for name, value in yaml.safe_load(device.read_text())["q1_q2"][ + "clock_freqs" + ].items() + } + assert declared["cz"] != declared["sideband_gap"], ( + "the fixture is meant to ship a mistuned coupler, or this test proves nothing" + ) -def test_a_virtual_z_actually_rotates_the_frame(calibrated_device): - """`rz` plays no pulse, and must still change the answer. + close_instruments(scheduler) + tuner = tuner_for( + scheduler, + name=f"cz_spec_{scheduler}", + quantify_hardware_config=FIXTURES / "quantify.hardware.json", + quantify_device_config=device, + is_simulated=True, + simulator=simulator, + ) + report = tuner.calibrate( + CalibrationConfig( + target_qubits=["q1", "q2"], + target_edges=["q1_q2"], + routines={ + name: RoutineConfig( + enabled=name == "cz_spectroscopy", + params={"span": 300e6, "points": 61}, + ) + for name in routine_names() + }, + ) + ) + tuner.close() + close_instruments(scheduler) - A `z`, `s`, `t` or `rz` compiles to a `ShiftClockPhase`: nothing is played, - the frame the *next* drive is referenced to simply moves. A simulator that - walks pulses and ignores frame shifts therefore runs every one of them as an - identity, silently — and `h; h` and `h; rz(pi); h` come out the same. + assert report.status == "success", report.errors + written = float( + yaml.safe_load(device.read_text())["q1_q2"]["clock_freqs"]["cz"] + ) + assert written == pytest.approx(declared["sideband_gap"], abs=10e6), ( + f"the CZ drive is still at {written / 1e9:.4f} GHz against a sideband gap of " + f"{declared['sideband_gap'] / 1e9:.4f} GHz" + ) - The delays are not padding: the compiler refuses two phase updates closer - than 4 ns apart, which is a real constraint of the hardware's NCO. - """ - device, simulator, scheduler = calibrated_device - gap = "delay[8ns] q[0];\n" + def test_a_cz_entangles_two_qubits(self, two_qubit_device): + """H, CZ, H is a CNOT, and on a superposition that is a Bell state. - without = run( - device, - simulator, - scheduler, - circuit("h q[0];\n" + gap + gap + "h q[0];\n"), - shots=600, - ) - with_z = run( - device, - simulator, - scheduler, - circuit("h q[0];\n" + gap + "rz(pi) q[0];\n" + gap + "h q[0];\n"), - shots=600, - ) + Before this the coordinator raised on a flux pulse. The claim now is + stronger than "it runs": the two qubits come out correlated, which no pair + of independently simulated qubits can be. + """ + counts = run_pair( + *two_qubit_device, "h q[0];\nh q[1];\ncz q[0], q[1];\nh q[1];\n" + ) + total = sum(counts.values()) - assert without["0"] / sum(without.values()) > 0.9, ( - f"h then h should return to |0>, got {without}" - ) - # H Z H = X, so the same circuit with a virtual Z between the two Hadamards - # must land in |1> instead. - assert with_z["1"] / sum(with_z.values()) > 0.9, ( - f"h, rz(pi), h should land in |1>, got {with_z}" - ) + assert correlation(counts) > 0.85, ( + f"the pair should be nearly perfectly correlated, got {counts}" + ) + # And each qubit alone is still a coin toss — an entangled pair carries its + # information between the qubits, not in either marginal. + q0_excited = (counts.get("01", 0) + counts.get("11", 0)) / total + assert 0.35 < q0_excited < 0.65, ( + f"q0's marginal should be ~0.5, got {q0_excited}" + ) + + def test_a_cz_leaves_basis_states_alone(self, two_qubit_device): + """The population half of the gate: |11> must come back, not stay in |02>. + + A CZ works by driving |11> to |02> and back. Stopping half way is a + perfectly good population transfer and a completely broken gate, and this is + what tells the two apart. + """ + counts = run_pair(*two_qubit_device, "x q[0];\nx q[1];\ncz q[0], q[1];\n") + total = sum(counts.values()) + assert counts.get("11", 0) / total > 0.85, ( + f"|11> should survive a full exchange round trip, got {counts}" + ) + def test_a_mistimed_cz_does_not_entangle(self, two_qubit_device): + """The control that makes the test above mean something. + + Half the duration is half a round trip: population sits in |02> instead of + returning, so there is no conditional phase and no Bell state. If the CZ + were being applied as a nominal gate rather than integrated, this would pass + identically to the real one. + """ + device, simulator, scheduler = two_qubit_device + broken = device.parent / "mistimed.device.yml" + config = yaml.safe_load(device.read_text()) + config["q0_q1"]["cz"]["square_duration"] = ( + round(config["q0_q1"]["cz"]["square_duration"] * 1e9 / 2) * 1e-9 + ) + broken.write_text(yaml.safe_dump(config)) -def test_two_virtual_z_gates_compose(calibrated_device): - """S·S = Z, which only holds if the shifts accumulate rather than replace.""" - device, simulator, scheduler = calibrated_device - gap = "delay[8ns] q[0];\n" - counts = run( - device, - simulator, - scheduler, - circuit( - "h q[0];\n" + gap + "s q[0];\n" + gap + "s q[0];\n" + gap + "h q[0];\n" - ), - shots=600, - ) - assert counts["1"] / sum(counts.values()) > 0.9, ( - f"two S gates should compose to a Z, got {counts}" - ) + counts = run_pair( + broken, simulator, scheduler, "h q[0];\nh q[1];\ncz q[0], q[1];\nh q[1];\n" + ) + assert correlation(counts) < 0.5, ( + f"half a CZ should not produce an entangled pair, got {counts}" + ) + + +class TestVirtualZ: + """A virtual Z gate's effect on the frame, and that two of them compose.""" + def test_a_virtual_z_actually_rotates_the_frame(self, calibrated_device): + """`rz` plays no pulse, and must still change the answer. -# --- the three measurement levels ----------------------------------------------- -# -# A job asks for one of three acquisition protocols, and they do not differ only -# in how much averaging they do — they return different *kinds* of thing. A -# simulator that answers all three with integrated IQ lets a level-0 job produce -# a one-sample waveform and a level-2 job quietly take the software -# discrimination path instead of the hardware one production runs on. + A `z`, `s`, `t` or `rz` compiles to a `ShiftClockPhase`: nothing is played, + the frame the *next* drive is referenced to simply moves. A simulator that + walks pulses and ignores frame shifts therefore runs every one of them as an + identity, silently — and `h; h` and `h; rz(pi); h` come out the same. + + The delays are not padding: the compiler refuses two phase updates closer + than 4 ns apart, which is a real constraint of the hardware's NCO. + """ + device, simulator, scheduler = calibrated_device + gap = "delay[8ns] q[0];\n" + + without = run( + device, + simulator, + scheduler, + circuit("h q[0];\n" + gap + gap + "h q[0];\n"), + shots=600, + ) + with_z = run( + device, + simulator, + scheduler, + circuit("h q[0];\n" + gap + "rz(pi) q[0];\n" + gap + "h q[0];\n"), + shots=600, + ) + + assert without["0"] / sum(without.values()) > 0.9, ( + f"h then h should return to |0>, got {without}" + ) + # H Z H = X, so the same circuit with a virtual Z between the two Hadamards + # must land in |1> instead. + assert with_z["1"] / sum(with_z.values()) > 0.9, ( + f"h, rz(pi), h should land in |1>, got {with_z}" + ) + + def test_two_virtual_z_gates_compose(self, calibrated_device): + """S·S = Z, which only holds if the shifts accumulate rather than replace.""" + device, simulator, scheduler = calibrated_device + gap = "delay[8ns] q[0];\n" + counts = run( + device, + simulator, + scheduler, + circuit( + "h q[0];\n" + gap + "s q[0];\n" + gap + "s q[0];\n" + gap + "h q[0];\n" + ), + shots=600, + ) + assert counts["1"] / sum(counts.values()) > 0.9, ( + f"two S gates should compose to a Z, got {counts}" + ) def run_at( @@ -855,139 +845,142 @@ def run_at( return executor.process_result(dataset, "loop-job") -def test_meas_level_0_returns_a_raw_trace(calibrated_device): - """A Trace is a time series, and it has to ring up rather than just appear. - - The integration window is 1 µs and the digitiser samples at 1 GSa/s, so a - correct trace is a thousand points. One point is what this used to give. +class TestTheThreeMeasurementLevels: + """A job asks for one of three acquisition protocols, and they do not differ only + in how much averaging they do — they return different *kinds* of thing. A + simulator that answers all three with integrated IQ lets a level-0 job produce + a one-sample waveform and a level-2 job quietly take the software + discrimination path instead of the hardware one production runs on. """ - result = run_at(*calibrated_device, circuit("x q[0];\n"), meas_level=0) - memory = np.asarray(result["memory"]) - - assert memory.shape == (1, 1000, 2), ( - f"expected one 1 us trace sampled per ns, as [I, Q] pairs, got {memory.shape}" - ) - trace = memory[0, :, 0] + 1j * memory[0, :, 1] - # The resonator fills over its own linewidth, so the start of the window is - # nearer the origin than the end. Averaging over many samples beats the - # per-sample noise, which is what makes this comparison meaningful. - assert abs(np.mean(trace[:20])) < abs(np.mean(trace[-200:])), ( - "the trace should ring up, not start settled" - ) + def test_meas_level_0_returns_a_raw_trace(self, calibrated_device): + """A Trace is a time series, and it has to ring up rather than just appear. -def test_meas_level_1_returns_integrated_iq(calibrated_device): - """One IQ point per shot, and the excited state lands on the excited blob.""" - excited = run_at(*calibrated_device, circuit("x q[0];\n"), meas_level=1, shots=200) - ground = run_at(*calibrated_device, circuit(""), meas_level=1, shots=200) - - excited_memory = np.asarray(excited["memory"]) - assert excited_memory.shape == (200, 1, 2), ( - f"expected one IQ pair per shot, got {excited_memory.shape}" - ) + The integration window is 1 µs and the digitiser samples at 1 GSa/s, so a + correct trace is a thousand points. One point is what this used to give. + """ + result = run_at(*calibrated_device, circuit("x q[0];\n"), meas_level=0) + memory = np.asarray(result["memory"]) - # The two clouds have to be separable, or no discriminator could work — but not - # separable *along I*, which is what this used to require. Where the clouds land - # is the amplifier chain's business: each level sits at its own resonance and so - # returns its own complex response, and the chain then rotates the pair by - # whatever the cabling happens to be. Demanding a particular axis was demanding - # that `acq_rotation` be zero, which is the thing `readout_discrimination` exists - # to measure. - ground_memory = np.asarray(ground["memory"]) - excited_iq = complex(*np.mean(excited_memory[:, 0, :], axis=0)) - ground_iq = complex(*np.mean(ground_memory[:, 0, :], axis=0)) - assert abs(excited_iq - ground_iq) > 1.0, ( - f"the clouds should separate: |0> at {ground_iq}, |1> at {excited_iq}" - ) + assert memory.shape == (1, 1000, 2), ( + f"expected one 1 us trace sampled per ns, as [I, Q] pairs, got {memory.shape}" + ) + trace = memory[0, :, 0] + 1j * memory[0, :, 1] + # The resonator fills over its own linewidth, so the start of the window is + # nearer the origin than the end. Averaging over many samples beats the + # per-sample noise, which is what makes this comparison meaningful. + assert abs(np.mean(trace[:20])) < abs(np.mean(trace[-200:])), ( + "the trace should ring up, not start settled" + ) + def test_meas_level_1_returns_integrated_iq(self, calibrated_device): + """One IQ point per shot, and the excited state lands on the excited blob.""" + excited = run_at( + *calibrated_device, circuit("x q[0];\n"), meas_level=1, shots=200 + ) + ground = run_at(*calibrated_device, circuit(""), meas_level=1, shots=200) -def test_meas_level_1_averaged_collapses_the_shots(calibrated_device): - result = run_at( - *calibrated_device, - circuit("x q[0];\n"), - meas_level=1, - meas_return="avg", - shots=200, - ) - assert np.asarray(result["memory"]).shape == (1, 1, 2), ( - "an averaged level-1 acquisition is one point, however many shots ran" - ) + excited_memory = np.asarray(excited["memory"]) + assert excited_memory.shape == (200, 1, 2), ( + f"expected one IQ pair per shot, got {excited_memory.shape}" + ) + # The two clouds have to be separable, or no discriminator could work — but not + # separable *along I*, which is what this used to require. Where the clouds land + # is the amplifier chain's business: each level sits at its own resonance and so + # returns its own complex response, and the chain then rotates the pair by + # whatever the cabling happens to be. Demanding a particular axis was demanding + # that `acq_rotation` be zero, which is the thing `readout_discrimination` exists + # to measure. + ground_memory = np.asarray(ground["memory"]) + excited_iq = complex(*np.mean(excited_memory[:, 0, :], axis=0)) + ground_iq = complex(*np.mean(ground_memory[:, 0, :], axis=0)) + assert abs(excited_iq - ground_iq) > 1.0, ( + f"the clouds should separate: |0> at {ground_iq}, |1> at {excited_iq}" + ) -def test_meas_level_2_is_discriminated_on_the_instrument(calibrated_device): - """Counts, and by the hardware's own rule rather than in software. + def test_meas_level_1_averaged_collapses_the_shots(self, calibrated_device): + result = run_at( + *calibrated_device, + circuit("x q[0];\n"), + meas_level=1, + meas_return="avg", + shots=200, + ) + assert np.asarray(result["memory"]).shape == (1, 1, 2), ( + "an averaged level-1 acquisition is one point, however many shots ran" + ) - `ThresholdedAcquisition` discriminates on the module and returns 0 or 1. A - simulator that hands back IQ here still produces the right counts — the - executor falls through to discriminating in software — so the path a real - job takes would never be exercised. - """ - result = run_at(*calibrated_device, circuit("x q[0];\n"), meas_level=2, shots=200) - counts = result["counts"] - assert counts["1"] / sum(counts.values()) > 0.9, ( - f"an X gate should read 1: {counts}" - ) + def test_meas_level_2_is_discriminated_on_the_instrument(self, calibrated_device): + """Counts, and by the hardware's own rule rather than in software. + `ThresholdedAcquisition` discriminates on the module and returns 0 or 1. A + simulator that hands back IQ here still produces the right counts — the + executor falls through to discriminating in software — so the path a real + job takes would never be exercised. + """ + result = run_at( + *calibrated_device, circuit("x q[0];\n"), meas_level=2, shots=200 + ) + counts = result["counts"] + assert counts["1"] / sum(counts.values()) > 0.9, ( + f"an X gate should read 1: {counts}" + ) -def test_every_measurement_level_agrees_about_the_same_circuit(calibrated_device): - """The three levels are three views of one experiment, not three experiments. + def test_every_measurement_level_agrees_about_the_same_circuit( + self, calibrated_device + ): + """The three levels are three views of one experiment, not three experiments. - Level 2 says the qubit is excited; level 1's IQ has to sit on the excited - blob and level 0's settled trace has to point the same way. A level that - disagrees is reporting a different circuit than the one that ran. - """ - device, simulator, scheduler = calibrated_device - qasm = circuit("x q[0];\n") - - counts = run_at(device, simulator, scheduler, qasm, meas_level=2, shots=200)[ - "counts" - ] - iq = np.asarray( - run_at(device, simulator, scheduler, qasm, meas_level=1, shots=200)["memory"] - ) - memory = np.asarray( - run_at(device, simulator, scheduler, qasm, meas_level=0)["memory"] - ) + Level 2 says the qubit is excited; level 1's IQ has to sit on the excited + blob and level 0's settled trace has to point the same way. A level that + disagrees is reporting a different circuit than the one that ran. + """ + device, simulator, scheduler = calibrated_device + qasm = circuit("x q[0];\n") - # Against the *same circuit with no gate*, rather than against a sign. Which side - # of the origin an excited qubit lands on is the readout chain's business, so the - # question that has content is whether all three levels move the same way when the - # gate is added. - reference_iq = np.asarray( - run_at(device, simulator, scheduler, circuit(""), meas_level=1, shots=200)[ - "memory" + counts = run_at(device, simulator, scheduler, qasm, meas_level=2, shots=200)[ + "counts" ] - ) - reference_trace = np.asarray( - run_at(device, simulator, scheduler, circuit(""), meas_level=0)["memory"] - ) - - excited_fraction = counts["1"] / sum(counts.values()) - moved_iq = abs( - complex(*np.mean(iq[:, 0, :], axis=0)) - - complex(*np.mean(reference_iq[:, 0, :], axis=0)) - ) - moved_trace = abs( - complex(*np.mean(memory[0, -200:, :], axis=0)) - - complex(*np.mean(reference_trace[0, -200:, :], axis=0)) - ) + iq = np.asarray( + run_at(device, simulator, scheduler, qasm, meas_level=1, shots=200)[ + "memory" + ] + ) + memory = np.asarray( + run_at(device, simulator, scheduler, qasm, meas_level=0)["memory"] + ) - assert excited_fraction > 0.9 - assert moved_iq > 1.0, ( - f"level 1 should move off the |0> cloud, moved {moved_iq:.2f}" - ) - assert moved_trace > 1.0, ( - f"level 0's settled trace should move with it, moved {moved_trace:.2f}" - ) + # Against the *same circuit with no gate*, rather than against a sign. Which side + # of the origin an excited qubit lands on is the readout chain's business, so the + # question that has content is whether all three levels move the same way when the + # gate is added. + reference_iq = np.asarray( + run_at(device, simulator, scheduler, circuit(""), meas_level=1, shots=200)[ + "memory" + ] + ) + reference_trace = np.asarray( + run_at(device, simulator, scheduler, circuit(""), meas_level=0)["memory"] + ) + excited_fraction = counts["1"] / sum(counts.values()) + moved_iq = abs( + complex(*np.mean(iq[:, 0, :], axis=0)) + - complex(*np.mean(reference_iq[:, 0, :], axis=0)) + ) + moved_trace = abs( + complex(*np.mean(memory[0, -200:, :], axis=0)) + - complex(*np.mean(reference_trace[0, -200:, :], axis=0)) + ) -# --- the flux-tunable coupler --------------------------------------------------- -# -# The tests above drive `q0_q1`, a stock `CompositeSquareEdge` whose flux pulse -# goes on a *qubit's* port. `q1_q2` is a `FluxTunableCoupler`: the pulse goes on -# the coupler's own port, `q1_q2:fl`, and the CZ is lowered into a fresh -# subschedule. That is a different path through both the compiler and the -# simulator, and it is the one a working chip's device config actually uses. + assert excited_fraction > 0.9 + assert moved_iq > 1.0, ( + f"level 1 should move off the |0> cloud, moved {moved_iq:.2f}" + ) + assert moved_trace > 1.0, ( + f"level 0's settled trace should move with it, moved {moved_trace:.2f}" + ) @pytest.fixture(scope="module") @@ -1070,203 +1063,200 @@ def without_corrections(device: Path, tmp_path: Path) -> Path: return other -def test_the_coupler_carries_its_parking_current(coupler_device): - """The bias is calibrated state and has to survive the device file. - - It is not scheduled — a seconds-scale DC current is not a pulse — but it is - as much a part of the CZ's calibration as the pulse amplitude, so it belongs - in the config the tuner writes and the executor reads. - """ - device, simulator, scheduler = coupler_device - close_instruments(scheduler) - executor = resolve_executor( - scheduler, - is_simulated=True, - simulator=simulator, - quantify_hardware_config=FIXTURES / "quantify.hardware.json", - quantify_device_config=device, - ) - edge = executor._device.get_edge("q1_q2") - - settings = bias_settings(edge) - assert settings["parking_current"] == pytest.approx(0.0009) - assert settings["source"] == "spi" - - -def test_the_parking_current_is_bounded(coupler_device): - """Outside the validated range is refused rather than driven. - - An S4g will happily source more than the couplers are characterised for, - and a current that far out is a fat-fingered config rather than an - operating point. - """ - device, simulator, scheduler = coupler_device - close_instruments(scheduler) - executor = resolve_executor( - scheduler, - is_simulated=True, - simulator=simulator, - quantify_hardware_config=FIXTURES / "quantify.hardware.json", - quantify_device_config=device, - ) - edge = executor._device.get_edge("q1_q2") - - # Both schedulers validate, and both reject the same way — but one takes the - # value through a call and the other through an assignment. - with pytest.raises(ValueError): - set_bias(edge, "parking_current", 5e-3) - # And a normal operating value is accepted. - set_bias(edge, "parking_current", 1.1e-3) - - -def test_a_cz_over_the_coupler_edge_completes_its_exchange(coupler_device): - """|11> survives a CZ played through the coupler's own port. - - The partner cannot be read off the gate here — the lowered subschedule has - lost it — so the simulator takes the pair from the port name, which is what - `q1_q2:fl` is for. Getting that wrong applies the coupling between the wrong - qubits, which is a working-looking gate on the wrong pair. - """ - device, simulator, scheduler = coupler_device - qasm = ( - 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[3] q;\nbit[2] c;\n' - "x q[1];\nx q[2];\ncz q[1], q[2];\n" - "c[0] = measure q[1];\nc[1] = measure q[2];\n" - ) - counts = run(device, simulator, scheduler, qasm, shots=300) - assert counts.get("11", 0) / sum(counts.values()) > 0.9, ( - f"|11> should come back after a full exchange round trip, got {counts}" - ) - - -def test_the_coupler_makes_the_bell_state_it_should(coupler_device): - """|00> + |11>, not merely *some* maximally entangled pair. - - Getting the specific state — rather than settling for "correlated up to a - local rotation" — is what says the two phase corrections were applied, with - the right sign, to the right qubits. A wrong sign or a swap still produces - a perfectly entangled pair; it just produces |01> + |10> instead. - """ - counts = run(*coupler_device, bell_circuit(), shots=400) - total = sum(counts.values()) - aligned = (counts.get("00", 0) + counts.get("11", 0)) / total - - assert aligned > 0.9, f"expected |00> + |11>, got {counts}" - assert abs(counts.get("00", 0) - counts.get("11", 0)) / total < 0.15, ( - f"the two outcomes should be roughly equal, got {counts}" - ) - - -def test_without_its_phase_corrections_the_bell_state_is_wrong( - coupler_device, tmp_path -): - """The control that makes the test above mean something. - - The coupler Stark-shifts both qubits while it drives them, and the phase - that leaves is single-qubit rather than conditional. Zero the two - corrections and the CZ is still a perfectly good conditional-phase gate — - the Bell state it builds is simply not the one asked for. +class TestTheFluxTunableCoupler: + """The tests above drive `q0_q1`, a stock `CompositeSquareEdge` whose flux pulse + goes on a *qubit's* port. `q1_q2` is a `FluxTunableCoupler`: the pulse goes on + the coupler's own port, `q1_q2:fl`, and the CZ is lowered into a fresh + subschedule. That is a different path through both the compiler and the + simulator, and it is the one a working chip's device config actually uses. """ - device, simulator, scheduler = coupler_device - counts = run( - without_corrections(device, tmp_path), simulator, scheduler, bell_circuit(), 400 - ) - aligned = (counts.get("00", 0) + counts.get("11", 0)) / sum(counts.values()) - assert aligned < 0.8, ( - f"uncorrected single-qubit phases should spoil |00> + |11>, got {counts}" - ) + def test_the_coupler_carries_its_parking_current(self, coupler_device): + """The bias is calibrated state and has to survive the device file. -def test_a_coupler_driven_off_resonance_does_nothing(coupler_device, tmp_path): - """Frequency is the resonance condition, not a carrier detail. - - A parametric CZ works because a sideband of the coupler's modulation - bridges |11>-|02>. Move the drive off that transition and the same - amplitude for the same duration compiles, plays, and performs no gate — - which is the failure a model with a fixed carrier could not show. - """ - device, simulator, scheduler = coupler_device - detuned = tmp_path / "detuned.device.yml" - config = yaml.safe_load(device.read_text()) - config["q1_q2"]["clock_freqs"]["cz"] += 50e6 # 50 MHz off - detuned.write_text(yaml.safe_dump(config)) - - counts = run(detuned, simulator, scheduler, bell_circuit(), shots=400) - aligned = (counts.get("00", 0) + counts.get("11", 0)) / sum(counts.values()) - assert aligned < 0.8, ( - f"a drive 50 MHz off the sideband should not make a Bell pair, got {counts}" - ) + It is not scheduled — a seconds-scale DC current is not a pulse — but it is + as much a part of the CZ's calibration as the pulse amplitude, so it belongs + in the config the tuner writes and the executor reads. + """ + device, simulator, scheduler = coupler_device + close_instruments(scheduler) + executor = resolve_executor( + scheduler, + is_simulated=True, + simulator=simulator, + quantify_hardware_config=FIXTURES / "quantify.hardware.json", + quantify_device_config=device, + ) + edge = executor._device.get_edge("q1_q2") + settings = bias_settings(edge) + assert settings["parking_current"] == pytest.approx(0.0009) + assert settings["source"] == "spi" -def test_a_raw_trace_over_two_qubits_takes_one_run_each(calibrated_device): - """A Qblox module scopes one sequencer, so two traces need two runs. + def test_the_parking_current_is_bounded(self, coupler_device): + """Outside the validated range is refused rather than driven. - Asking both qubits for a raw trace in one schedule does not compile — - "Only one sequencer per device can trigger raw trace capture" — which used - to make `meas_level=0` simply unavailable for any circuit measuring more - than one qubit. The executor now runs the circuit once per measured qubit - and captures one trace each time, which is what the instrument allows and - what a lab does by hand. - """ - device, simulator, scheduler = calibrated_device - qasm = ( - 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[2] q;\nbit[2] c;\n' - "x q[0];\nc[0] = measure q[0];\nc[1] = measure q[1];\n" - ) - memory = np.asarray( - run_at(device, simulator, scheduler, qasm, meas_level=0)["memory"] - ) + An S4g will happily source more than the couplers are characterised for, + and a current that far out is a fat-fingered config rather than an + operating point. + """ + device, simulator, scheduler = coupler_device + close_instruments(scheduler) + executor = resolve_executor( + scheduler, + is_simulated=True, + simulator=simulator, + quantify_hardware_config=FIXTURES / "quantify.hardware.json", + quantify_device_config=device, + ) + edge = executor._device.get_edge("q1_q2") + + # Both schedulers validate, and both reject the same way — but one takes the + # value through a call and the other through an assignment. + with pytest.raises(ValueError): + set_bias(edge, "parking_current", 5e-3) + # And a normal operating value is accepted. + set_bias(edge, "parking_current", 1.1e-3) + + def test_a_cz_over_the_coupler_edge_completes_its_exchange(self, coupler_device): + """|11> survives a CZ played through the coupler's own port. + + The partner cannot be read off the gate here — the lowered subschedule has + lost it — so the simulator takes the pair from the port name, which is what + `q1_q2:fl` is for. Getting that wrong applies the coupling between the wrong + qubits, which is a working-looking gate on the wrong pair. + """ + device, simulator, scheduler = coupler_device + qasm = ( + 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[3] q;\nbit[2] c;\n' + "x q[1];\nx q[2];\ncz q[1], q[2];\n" + "c[0] = measure q[1];\nc[1] = measure q[2];\n" + ) + counts = run(device, simulator, scheduler, qasm, shots=300) + assert counts.get("11", 0) / sum(counts.values()) > 0.9, ( + f"|11> should come back after a full exchange round trip, got {counts}" + ) - assert memory.shape == (2, 1000, 2), ( - f"expected a 1 us trace per qubit as [I, Q] pairs, got {memory.shape}" - ) - # The two qubits are in different states, and their traces have to say so — - # otherwise the second run measured the first qubit again. - excited = complex(*np.mean(memory[0, -200:, :], axis=0)) - ground = complex(*np.mean(memory[1, -200:, :], axis=0)) - assert abs(excited - ground) > 1.0, ( - f"q0 was excited and q1 was not, but their traces settle at the same place: " - f"{excited} and {ground}" - ) + def test_the_coupler_makes_the_bell_state_it_should(self, coupler_device): + """|00> + |11>, not merely *some* maximally entangled pair. + + Getting the specific state — rather than settling for "correlated up to a + local rotation" — is what says the two phase corrections were applied, with + the right sign, to the right qubits. A wrong sign or a swap still produces + a perfectly entangled pair; it just produces |01> + |10> instead. + """ + counts = run(*coupler_device, bell_circuit(), shots=400) + total = sum(counts.values()) + aligned = (counts.get("00", 0) + counts.get("11", 0)) / total + + assert aligned > 0.9, f"expected |00> + |11>, got {counts}" + assert abs(counts.get("00", 0) - counts.get("11", 0)) / total < 0.15, ( + f"the two outcomes should be roughly equal, got {counts}" + ) + def test_without_its_phase_corrections_the_bell_state_is_wrong( + self, coupler_device, tmp_path + ): + """The control that makes the test above mean something. + + The coupler Stark-shifts both qubits while it drives them, and the phase + that leaves is single-qubit rather than conditional. Zero the two + corrections and the CZ is still a perfectly good conditional-phase gate — + the Bell state it builds is simply not the one asked for. + """ + device, simulator, scheduler = coupler_device + counts = run( + without_corrections(device, tmp_path), + simulator, + scheduler, + bell_circuit(), + 400, + ) + aligned = (counts.get("00", 0) + counts.get("11", 0)) / sum(counts.values()) + assert aligned < 0.8, ( + f"uncorrected single-qubit phases should spoil |00> + |11>, got {counts}" + ) -def test_a_coupler_that_declares_its_own_gap_is_believed(coupler_device, tmp_path): - """The device's `sideband_gap` overrides the simulator's chosen constant. + def test_a_coupler_driven_off_resonance_does_nothing( + self, coupler_device, tmp_path + ): + """Frequency is the resonance condition, not a carrier detail. + + A parametric CZ works because a sideband of the coupler's modulation + bridges |11>-|02>. Move the drive off that transition and the same + amplitude for the same duration compiles, plays, and performs no gate — + which is the failure a model with a fixed carrier could not show. + """ + device, simulator, scheduler = coupler_device + detuned = tmp_path / "detuned.device.yml" + config = yaml.safe_load(device.read_text()) + config["q1_q2"]["clock_freqs"]["cz"] += 50e6 # 50 MHz off + detuned.write_text(yaml.safe_dump(config)) + + counts = run(detuned, simulator, scheduler, bell_circuit(), shots=400) + aligned = (counts.get("00", 0) + counts.get("11", 0)) / sum(counts.values()) + assert aligned < 0.8, ( + f"a drive 50 MHz off the sideband should not make a Bell pair, got {counts}" + ) - `SIDEBAND_GAP_GHZ` exists because this project has no coupler to measure. A - chip that *has* measured its own gap should not be simulated against a - guess, so the edge can declare it — and declaring a different one has to - change the answer, or the field is decoration. + def test_a_raw_trace_over_two_qubits_takes_one_run_each(self, calibrated_device): + """A Qblox module scopes one sequencer, so two traces need two runs. + + Asking both qubits for a raw trace in one schedule does not compile — + "Only one sequencer per device can trigger raw trace capture" — which used + to make `meas_level=0` simply unavailable for any circuit measuring more + than one qubit. The executor now runs the circuit once per measured qubit + and captures one trace each time, which is what the instrument allows and + what a lab does by hand. + """ + device, simulator, scheduler = calibrated_device + qasm = ( + 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[2] q;\nbit[2] c;\n' + "x q[0];\nc[0] = measure q[0];\nc[1] = measure q[1];\n" + ) + memory = np.asarray( + run_at(device, simulator, scheduler, qasm, meas_level=0)["memory"] + ) - Moving the gap without moving the drive detunes the gate, so the Bell state - it built stops being one. - """ - device, simulator, scheduler = coupler_device - moved = tmp_path / "moved-gap.device.yml" - config = yaml.safe_load(device.read_text()) - drive = float(config["q1_q2"]["clock_freqs"]["cz"]) - # 50 MHz away from where this edge's drive is played. - config["q1_q2"]["clock_freqs"]["sideband_gap"] = drive + 50e6 - moved.write_text(yaml.safe_dump(config)) - - counts = run(moved, simulator, scheduler, bell_circuit(), shots=400) - aligned = (counts.get("00", 0) + counts.get("11", 0)) / sum(counts.values()) - assert aligned < 0.8, ( - f"a declared gap 50 MHz off the drive should spoil the gate, got {counts}" - ) + assert memory.shape == (2, 1000, 2), ( + f"expected a 1 us trace per qubit as [I, Q] pairs, got {memory.shape}" + ) + # The two qubits are in different states, and their traces have to say so — + # otherwise the second run measured the first qubit again. + excited = complex(*np.mean(memory[0, -200:, :], axis=0)) + ground = complex(*np.mean(memory[1, -200:, :], axis=0)) + assert abs(excited - ground) > 1.0, ( + f"q0 was excited and q1 was not, but their traces settle at the same place: " + f"{excited} and {ground}" + ) + def test_a_coupler_that_declares_its_own_gap_is_believed( + self, coupler_device, tmp_path + ): + """The device's `sideband_gap` overrides the simulator's chosen constant. + + `SIDEBAND_GAP_GHZ` exists because this project has no coupler to measure. A + chip that *has* measured its own gap should not be simulated against a + guess, so the edge can declare it — and declaring a different one has to + change the answer, or the field is decoration. + + Moving the gap without moving the drive detunes the gate, so the Bell state + it built stops being one. + """ + device, simulator, scheduler = coupler_device + moved = tmp_path / "moved-gap.device.yml" + config = yaml.safe_load(device.read_text()) + drive = float(config["q1_q2"]["clock_freqs"]["cz"]) + # 50 MHz away from where this edge's drive is played. + config["q1_q2"]["clock_freqs"]["sideband_gap"] = drive + 50e6 + moved.write_text(yaml.safe_dump(config)) + + counts = run(moved, simulator, scheduler, bell_circuit(), shots=400) + aligned = (counts.get("00", 0) + counts.get("11", 0)) / sum(counts.values()) + assert aligned < 0.8, ( + f"a declared gap 50 MHz off the drive should spoil the gate, got {counts}" + ) -# --- the whole DAG, through the real stack --------------------------------------- -# -# The tests above calibrate three routines. That was enough to prove the loop -# closes, and not enough to prove much else: `cz_chevron` wrote `cz.amp`, a name -# no device has, and nothing noticed for as long as no test ran it against a real -# `QuantumDevice`. Tier 2 compiles a schedule without calling `apply`; tier 3's -# fake device was given the names the routine used. Both agreed with the code. -# -# This runs *every* routine the simulated chip can answer, through the shipped -# tuner, over a real device loaded from YAML, and requires the report to come back -# `success`. Nothing here is a double except the instrument. #: Where this chip's readout resonators actually are, in GHz — four megahertz from #: the 6.000/6.010/6.020 the fixture claims. @@ -1278,6 +1268,7 @@ def test_a_coupler_that_declares_its_own_gap_is_believed(coupler_device, tmp_pat #: move the number for the readout to work. TRUE_RESONATORS = {"q0": 6.004, "q1": 6.014, "q2": 6.024} + #: Different cables to different qubits, which is what a chip has and what the #: simulator's single ``readout_phase_deg`` does not give it. #: @@ -1288,6 +1279,7 @@ def test_a_coupler_that_declares_its_own_gap_is_believed(coupler_device, tmp_pat #: invisible when they agree, which is how the executors came to do exactly that. TRUE_READOUT_PHASES = {"q0": 35.0, "q1": 155.0, "q2": 265.0} + #: Sweeps sized for the simulated chip. Every one of these is a property of the #: simulator's own parameters — T1 of 30 us wants a sweep several times that, and #: a sweep shorter than the decay cannot measure it. @@ -1396,641 +1388,655 @@ def fully_calibrated(scheduler, tmp_path_factory): return report, device, simulator, scheduler -def test_the_whole_dag_completes_against_the_simulator(fully_calibrated): - """Every routine, through the shipped stack, with no failures and none skipped. - - This is the test that would have caught `cz.amp`, `cz.phase_correction`, the - DRAG units and the qblox write-back — each of which was a routine that - measured correctly and then failed, or silently declined, to write. - - The equality is the point rather than the success: a routine quietly excluded - from the run is indistinguishable from one that works. - """ - report, device, _simulator, _scheduler = fully_calibrated - - assert report.status == "success", report.errors - assert report.errors == [] - - ran = {result.routine_name for result in report.routine_results} - # Every routine that *applies* — which is not quite every routine. A parametric - # coupler has a CZ drive frequency to find and a DC-flux edge does not, so - # `cz_spectroscopy` declines `q0_q1` rather than failing on it. Computing the - # expectation the way the DAG does keeps this a statement about routines being - # silently dropped rather than a list to edit whenever one is added. - from qpi_driver.tuners.routines import all_routines - - close_instruments(_scheduler) - executor = resolve_executor( - _scheduler, - is_simulated=True, - simulator=_simulator, - quantify_hardware_config=FIXTURES / "quantify.hardware.json", - quantify_device_config=device, - ) - loaded = executor._device - expected = { - routine.name - for routine in all_routines() - if routine.targets == "qubits" - or any(routine.applies_to(loaded, edge) for edge in ("q0_q1",)) - } - assert ran == expected, f"did not run {sorted(expected - ran)}" - - -def test_the_dispersive_shift_is_measured_and_not_assumed(fully_calibrated): - """`resonator_spectroscopy_excited` recovers chi, which nothing else measures. - - A characterisation node writes nothing, so the only way it can be wrong is - quietly: it would keep reporting a number and the calibration would keep - succeeding. This is the assertion that makes it a measurement — the whole readout - rests on the two states pulling the resonance to different places, and this is the - node that says by how much. - - Against the simulator's own chi rather than a constant, and loosely: the routine - fits a Lorentzian at the *configured* readout power, where punchout has already - collapsed part of the pull, so it measures what the readout actually sees rather - than the zero-power value. - """ - report, _device, simulator, _scheduler = fully_calibrated - measured = { - result.target: result.parameters["dispersive_shift"] - for result in report.routine_results - if result.routine_name == "resonator_spectroscopy_excited" - } - assert measured, "resonator_spectroscopy_excited reported nothing" - - for qubit, shift in measured.items(): - # Negative: |1> pulls the resonance below where |0> leaves it. - assert shift < 0, f"{qubit} reported a positive dispersive shift, {shift}" - full = simulator.resonator(qubit).dispersive_shift_ghz * GHZ - assert abs(shift) < full, ( - f"{qubit}'s measured pull {abs(shift) / 1e6:.3f} MHz exceeds the " - f"{full / 1e6:.3f} MHz the model has to give, at any power" - ) - - -def test_the_ef_pi_pulse_is_measured_through_its_own_clock(fully_calibrated): - """`rabi_12` drives ``.12`` with a raw pulse and recovers the drive strength. - - Neither scheduler has an EF gate — the device config's operations are built for - ``rxy`` on ``.01`` — so this routine assembles the pulse itself: its own clock, - its own port, its own envelope. What the assertion covers is that whole path, - since a pulse that went to the wrong clock would drive nothing and the fit would - have no oscillation to find. - - And it comes out at ``amp180 / sqrt(2)``, which is the ladder's own matrix element - rather than anything the routine was told. This used to assert *equality* with - ``rxy.amp180``, because the EF drive was a two-level subspace with the 1-2 element - folded into its operator; driving the real ladder in the frame the rest of the - simulator uses puts the factor back. The remaining few per cent is relaxation - during the pulse, which pushes the fitted pi slightly high. - """ - _report, device, _simulator, _scheduler = fully_calibrated - written = yaml.safe_load(device.read_text()) - - for qubit in ("q0", "q1"): - ef = written[qubit]["r12"]["ef_amp180"] - assert ef > 0, f"{qubit} has no EF pi pulse" - ladder = written[qubit]["rxy"]["amp180"] / np.sqrt(2) - assert ef == pytest.approx(ladder, rel=0.10), ( - f"{qubit}'s EF pi is {ef:.4f}, against {ladder:.4f} for a sqrt(2) ladder" - ) - - #: What each backend's DRAG sweep spans, in its own units — see `drag_span`. Needed #: here because a fitted coefficient only means something against the range it came #: from: the same physical pulse is 0.04 to quantify and 1e-10 to qblox. DRAG_SPANS = {"quantify": 0.2, "qblox": 5e-10} -def test_the_ef_drag_coefficient_is_measured_and_negative(fully_calibrated): - """`drag_12` finds a real interior optimum, and on the far side of zero from 0-1. - - DRAG cancels the leakage a pulse drives into its *neighbouring* level. For an 0-1 - pulse that neighbour is ``|2>``, above it; for an EF pulse it is ``|0>``, below. - The sign follows, and it is the assertion that a flat fit could not pass by luck. +class TestTheWholeDagThroughTheRealStack: + """The tests above calibrate three routines. That was enough to prove the loop + closes, and not enough to prove much else: `cz_chevron` wrote `cz.amp`, a name + no device has, and nothing noticed for as long as no test ran it against a real + `QuantumDevice`. Tier 2 compiles a schedule without calling `apply`; tier 3's + fake device was given the names the routine used. Both agreed with the code. - Interior, too: a coefficient railed at the edge of its sweep means the optimum is - outside and the fitted line is extrapolating, while one at zero means the sweep - found nothing — which is exactly what this routine would have done before the EF - drive gained an envelope and a neighbour to leak into. + This runs *every* routine the simulated chip can answer, through the shipped + tuner, over a real device loaded from YAML, and requires the report to come back + `success`. Nothing here is a double except the instrument. """ - report, _device, _simulator, scheduler = fully_calibrated - span = DRAG_SPANS[scheduler] - measured = { - result.target: result.parameters["ef_motzoi"] - for result in report.routine_results - if result.routine_name == "drag_12" - } - assert measured, "drag_12 reported nothing" - for qubit, drag in measured.items(): - assert drag < 0, ( - f"{qubit}'s EF DRAG coefficient is {drag:+.5g}: the leakage an EF pulse " - f"drives is into |0>, below it, so the correction should be negative" - ) - assert 0.02 * span < abs(drag) < 0.8 * span, ( - f"{qubit}'s EF DRAG coefficient is {drag:+.5g} against a span of {span:g} " - f"— too close to zero to be a measurement, or railed at the edge" + def test_the_whole_dag_completes_against_the_simulator(self, fully_calibrated): + """Every routine, through the shipped stack, with no failures and none skipped. + + This is the test that would have caught `cz.amp`, `cz.phase_correction`, the + DRAG units and the qblox write-back — each of which was a routine that + measured correctly and then failed, or silently declined, to write. + + The equality is the point rather than the success: a routine quietly excluded + from the run is indistinguishable from one that works. + """ + report, device, _simulator, _scheduler = fully_calibrated + + assert report.status == "success", report.errors + assert report.errors == [] + + ran = {result.routine_name for result in report.routine_results} + # Every routine that *applies* — which is not quite every routine. A parametric + # coupler has a CZ drive frequency to find and a DC-flux edge does not, so + # `cz_spectroscopy` declines `q0_q1` rather than failing on it. Computing the + # expectation the way the DAG does keeps this a statement about routines being + # silently dropped rather than a list to edit whenever one is added. + from qpi_driver.tuners.routines import all_routines + + close_instruments(_scheduler) + executor = resolve_executor( + _scheduler, + is_simulated=True, + simulator=_simulator, + quantify_hardware_config=FIXTURES / "quantify.hardware.json", + quantify_device_config=device, ) - - -def test_ramsey_refines_f12_past_what_spectroscopy_can_reach(fully_calibrated): - """Spectroscopy finds the transition; Ramsey measures it. Same split as 0-1. - - `f12_spectroscopy` drives a 20 ns pulse, so its line is Fourier-limited to tens of - megahertz and it lands within a few. This asserts the written f12 is inside a - megahertz of the simulator's own, which the spectroscopy alone does not reach. - - It also reports a T2* for the 1-2 coherence, which nothing else measures — and it - could not be written at all until the EF drive shared a frame with the idles - between its pulses. - """ - report, device, simulator, _scheduler = fully_calibrated - written = yaml.safe_load(device.read_text()) - true_f12 = (simulator.f01 + simulator.anharmonicity) * GHZ - - measured = { - result.target: result.parameters - for result in report.routine_results - if result.routine_name == "ramsey_12" - } - assert measured, "ramsey_12 reported nothing" - - for qubit, params in measured.items(): - assert written[qubit]["clock_freqs"]["f12"] == pytest.approx( - true_f12, abs=1e6 - ), f"{qubit}'s f12 is {written[qubit]['clock_freqs']['f12'] / 1e9:.6f} GHz" - # Against the simulator's own dephasing rather than a number picked to pass. - # The 1-2 coherence should be the same order as the 0-1 one and shorter, since - # |2> relaxes faster; what this rules out is a runaway fit — 1.4 seconds, from - # a sweep too short to contain the decay — and a collapse to nothing. - coherence = simulator.t2_ns * 1e-9 - assert 0.2 * coherence < params["t2_star"] < 1.5 * coherence, ( - f"{qubit}'s 1-2 T2* is {params['t2_star'] * 1e6:.2f} us against a 0-1 " - f"coherence of {coherence * 1e6:.2f} us" - ) - - -def test_the_dispersive_ladder_is_evenly_spaced(fully_calibrated): - """Two sweeps, two ways to the same chi — and agreeing is the measurement. - - The pull is supposed to go as ``chi(1 - 2n)``: ``|0>`` at ``+chi``, ``|1>`` at - ``-chi``, ``|2>`` at ``-3chi``. So the excited-state sweep sees two shifts of gap - and the second-excited sweep sees four, and dividing each by its own factor has to - give the same number. Nothing else in the graph checks the *spacing* rather than - the size. - - It matters because three-state readout rests on it. A ladder that bunched up would - leave `three_state_operating_point` optimising against a chip whose levels cannot - be separated however it is tuned, and it would look like a tuning failure rather - than a model one. - """ - report, _device, _simulator, _scheduler = fully_calibrated - shifts = {} - for name in ( - "resonator_spectroscopy_excited", - "resonator_spectroscopy_second_excited", - ): - shifts[name] = { + loaded = executor._device + expected = { + routine.name + for routine in all_routines() + if routine.targets == "qubits" + or any(routine.applies_to(loaded, edge) for edge in ("q0_q1",)) + } + assert ran == expected, f"did not run {sorted(expected - ran)}" + + def test_the_dispersive_shift_is_measured_and_not_assumed(self, fully_calibrated): + """`resonator_spectroscopy_excited` recovers chi, which nothing else measures. + + A characterisation node writes nothing, so the only way it can be wrong is + quietly: it would keep reporting a number and the calibration would keep + succeeding. This is the assertion that makes it a measurement — the whole readout + rests on the two states pulling the resonance to different places, and this is the + node that says by how much. + + Against the simulator's own chi rather than a constant, and loosely: the routine + fits a Lorentzian at the *configured* readout power, where punchout has already + collapsed part of the pull, so it measures what the readout actually sees rather + than the zero-power value. + """ + report, _device, simulator, _scheduler = fully_calibrated + measured = { result.target: result.parameters["dispersive_shift"] for result in report.routine_results - if result.routine_name == name + if result.routine_name == "resonator_spectroscopy_excited" } - assert shifts[name], f"{name} reported nothing" + assert measured, "resonator_spectroscopy_excited reported nothing" + + for qubit, shift in measured.items(): + # Negative: |1> pulls the resonance below where |0> leaves it. + assert shift < 0, f"{qubit} reported a positive dispersive shift, {shift}" + full = simulator.resonator(qubit).dispersive_shift_ghz * GHZ + assert abs(shift) < full, ( + f"{qubit}'s measured pull {abs(shift) / 1e6:.3f} MHz exceeds the " + f"{full / 1e6:.3f} MHz the model has to give, at any power" + ) - for qubit in shifts["resonator_spectroscopy_excited"]: - first = shifts["resonator_spectroscopy_excited"][qubit] - second = shifts["resonator_spectroscopy_second_excited"][qubit] - assert first < 0 and second < 0, f"{qubit}: {first}, {second}" - # Loosely, because each is a Lorentzian centre fitted at a different readout - # contrast — |2> returns less signal than |1> does, so its sweep is noisier. - # What a bunched ladder would do is miss by a factor, not by a fifth. - assert second == pytest.approx(first, rel=0.25), ( - f"{qubit}'s dispersive ladder is not evenly spaced: chi from |1> is " - f"{first / 1e6:.3f} MHz, from |2> it is {second / 1e6:.3f} MHz" - ) + def test_the_ef_pi_pulse_is_measured_through_its_own_clock(self, fully_calibrated): + """`rabi_12` drives ``.12`` with a raw pulse and recovers the drive strength. + + Neither scheduler has an EF gate — the device config's operations are built for + ``rxy`` on ``.01`` — so this routine assembles the pulse itself: its own clock, + its own port, its own envelope. What the assertion covers is that whole path, + since a pulse that went to the wrong clock would drive nothing and the fit would + have no oscillation to find. + + And it comes out at ``amp180 / sqrt(2)``, which is the ladder's own matrix element + rather than anything the routine was told. This used to assert *equality* with + ``rxy.amp180``, because the EF drive was a two-level subspace with the 1-2 element + folded into its operator; driving the real ladder in the frame the rest of the + simulator uses puts the factor back. The remaining few per cent is relaxation + during the pulse, which pushes the fitted pi slightly high. + """ + _report, device, _simulator, _scheduler = fully_calibrated + written = yaml.safe_load(device.read_text()) + + for qubit in ("q0", "q1"): + ef = written[qubit]["r12"]["ef_amp180"] + assert ef > 0, f"{qubit} has no EF pi pulse" + ladder = written[qubit]["rxy"]["amp180"] / np.sqrt(2) + assert ef == pytest.approx(ladder, rel=0.10), ( + f"{qubit}'s EF pi is {ef:.4f}, against {ladder:.4f} for a sqrt(2) ladder" + ) + def test_the_ef_drag_coefficient_is_measured_and_negative(self, fully_calibrated): + """`drag_12` finds a real interior optimum, and on the far side of zero from 0-1. + + DRAG cancels the leakage a pulse drives into its *neighbouring* level. For an 0-1 + pulse that neighbour is ``|2>``, above it; for an EF pulse it is ``|0>``, below. + The sign follows, and it is the assertion that a flat fit could not pass by luck. + + Interior, too: a coefficient railed at the edge of its sweep means the optimum is + outside and the fitted line is extrapolating, while one at zero means the sweep + found nothing — which is exactly what this routine would have done before the EF + drive gained an envelope and a neighbour to leak into. + """ + report, _device, _simulator, scheduler = fully_calibrated + span = DRAG_SPANS[scheduler] + measured = { + result.target: result.parameters["ef_motzoi"] + for result in report.routine_results + if result.routine_name == "drag_12" + } + assert measured, "drag_12 reported nothing" -def test_the_ef_pi_pulse_is_refined_to_a_small_residual_error(fully_calibrated): - """`fine_amplitude_12` amplifies what is left of `rabi_12`'s error and removes it. + for qubit, drag in measured.items(): + assert drag < 0, ( + f"{qubit}'s EF DRAG coefficient is {drag:+.5g}: the leakage an EF pulse " + f"drives is into |0>, below it, so the correction should be negative" + ) + assert 0.02 * span < abs(drag) < 0.8 * span, ( + f"{qubit}'s EF DRAG coefficient is {drag:+.5g} against a span of {span:g} " + f"— too close to zero to be a measurement, or railed at the edge" + ) - A single pi pulse is second order in its own error, so `rabi_12` fitting a whole - oscillation cannot see a few per cent of over-rotation. Playing the pulse n times - turns that into n times the error against a readout noise that does not grow, - which is the only way the residual becomes visible. + def test_ramsey_refines_f12_past_what_spectroscopy_can_reach( + self, fully_calibrated + ): + """Spectroscopy finds the transition; Ramsey measures it. Same split as 0-1. + + `f12_spectroscopy` drives a 20 ns pulse, so its line is Fourier-limited to tens of + megahertz and it lands within a few. This asserts the written f12 is inside a + megahertz of the simulator's own, which the spectroscopy alone does not reach. + + It also reports a T2* for the 1-2 coherence, which nothing else measures — and it + could not be written at all until the EF drive shared a frame with the idles + between its pulses. + """ + report, device, simulator, _scheduler = fully_calibrated + written = yaml.safe_load(device.read_text()) + true_f12 = (simulator.f01 + simulator.anharmonicity) * GHZ + + measured = { + result.target: result.parameters + for result in report.routine_results + if result.routine_name == "ramsey_12" + } + assert measured, "ramsey_12 reported nothing" + + for qubit, params in measured.items(): + assert written[qubit]["clock_freqs"]["f12"] == pytest.approx( + true_f12, abs=1e6 + ), f"{qubit}'s f12 is {written[qubit]['clock_freqs']['f12'] / 1e9:.6f} GHz" + # Against the simulator's own dephasing rather than a number picked to pass. + # The 1-2 coherence should be the same order as the 0-1 one and shorter, since + # |2> relaxes faster; what this rules out is a runaway fit — 1.4 seconds, from + # a sweep too short to contain the decay — and a collapse to nothing. + coherence = simulator.t2_ns * 1e-9 + assert 0.2 * coherence < params["t2_star"] < 1.5 * coherence, ( + f"{qubit}'s 1-2 T2* is {params['t2_star'] * 1e6:.2f} us against a 0-1 " + f"coherence of {coherence * 1e6:.2f} us" + ) - What it needed was the three-state readout point: its two reference states are - ``|1>`` and ``|2>``, and at a 0-1 readout those are all but on top of each other. - """ - report, _device, _simulator, _scheduler = fully_calibrated - measured = { - result.target: result.parameters - for result in report.routine_results - if result.routine_name == "fine_amplitude_12" - } - assert measured, "fine_amplitude_12 reported nothing" + def test_the_dispersive_ladder_is_evenly_spaced(self, fully_calibrated): + """Two sweeps, two ways to the same chi — and agreeing is the measurement. + + The pull is supposed to go as ``chi(1 - 2n)``: ``|0>`` at ``+chi``, ``|1>`` at + ``-chi``, ``|2>`` at ``-3chi``. So the excited-state sweep sees two shifts of gap + and the second-excited sweep sees four, and dividing each by its own factor has to + give the same number. Nothing else in the graph checks the *spacing* rather than + the size. + + It matters because three-state readout rests on it. A ladder that bunched up would + leave `three_state_operating_point` optimising against a chip whose levels cannot + be separated however it is tuned, and it would look like a tuning failure rather + than a model one. + """ + report, _device, _simulator, _scheduler = fully_calibrated + shifts = {} + for name in ( + "resonator_spectroscopy_excited", + "resonator_spectroscopy_second_excited", + ): + shifts[name] = { + result.target: result.parameters["dispersive_shift"] + for result in report.routine_results + if result.routine_name == name + } + assert shifts[name], f"{name} reported nothing" + + for qubit in shifts["resonator_spectroscopy_excited"]: + first = shifts["resonator_spectroscopy_excited"][qubit] + second = shifts["resonator_spectroscopy_second_excited"][qubit] + assert first < 0 and second < 0, f"{qubit}: {first}, {second}" + # Loosely, because each is a Lorentzian centre fitted at a different readout + # contrast — |2> returns less signal than |1> does, so its sweep is noisier. + # What a bunched ladder would do is miss by a factor, not by a fifth. + assert second == pytest.approx(first, rel=0.25), ( + f"{qubit}'s dispersive ladder is not evenly spaced: chi from |1> is " + f"{first / 1e6:.3f} MHz, from |2> it is {second / 1e6:.3f} MHz" + ) - for qubit, params in measured.items(): - assert abs(params["error_per_pulse"]) < 0.05, ( - f"{qubit}'s EF pi pulse is still {params['error_per_pulse']:.4f} rad per " - f"pulse out after refinement: {params}" - ) + def test_the_ef_pi_pulse_is_refined_to_a_small_residual_error( + self, fully_calibrated + ): + """`fine_amplitude_12` amplifies what is left of `rabi_12`'s error and removes it. + + A single pi pulse is second order in its own error, so `rabi_12` fitting a whole + oscillation cannot see a few per cent of over-rotation. Playing the pulse n times + turns that into n times the error against a readout noise that does not grow, + which is the only way the residual becomes visible. + + What it needed was the three-state readout point: its two reference states are + ``|1>`` and ``|2>``, and at a 0-1 readout those are all but on top of each other. + """ + report, _device, _simulator, _scheduler = fully_calibrated + measured = { + result.target: result.parameters + for result in report.routine_results + if result.routine_name == "fine_amplitude_12" + } + assert measured, "fine_amplitude_12 reported nothing" + for qubit, params in measured.items(): + assert abs(params["error_per_pulse"]) < 0.05, ( + f"{qubit}'s EF pi pulse is still {params['error_per_pulse']:.4f} rad per " + f"pulse out after refinement: {params}" + ) -def test_leakage_into_the_third_level_is_measured(fully_calibrated): - """The number the whole EF chain exists to produce. + def test_leakage_into_the_third_level_is_measured(self, fully_calibrated): + """The number the whole EF chain exists to produce. + + A two-state readout does not lose a leaked shot — it reports it as ``|0>`` or + ``|1>``, so leakage arrives as an answer and every fidelity built on it is quietly + optimistic. This is the only node that can see it, and it can only see it because + the simulated acquisition now reports a third level: before that, a ``|2>``-prepared + shot came back on ``|0>``'s cloud and the classifier refused to fit. + + Asserting the classifier works rather than pinning a leakage figure. How much a + given X pulse leaks is a property of this chip and this pulse; what would make the + node worthless is three states it cannot tell apart. + """ + report, _device, _simulator, _scheduler = fully_calibrated + measured = { + result.target: result.parameters + for result in report.routine_results + if result.routine_name == "three_state_discrimination" + } + assert measured, "three_state_discrimination reported nothing" - A two-state readout does not lose a leaked shot — it reports it as ``|0>`` or - ``|1>``, so leakage arrives as an answer and every fidelity built on it is quietly - optimistic. This is the only node that can see it, and it can only see it because - the simulated acquisition now reports a third level: before that, a ``|2>``-prepared - shot came back on ``|0>``'s cloud and the classifier refused to fit. + for qubit, params in measured.items(): + assert params["assignment_fidelity"] > 0.9, ( + f"{qubit}'s three states are not being told apart: {params}" + ) + # A leakage figure at all, and a physical one. It is a fraction of shots, so + # anything outside [0, 1] would be a counting mistake rather than a chip. + assert 0.0 <= params["leakage"] <= 1.0 - Asserting the classifier works rather than pinning a leakage figure. How much a - given X pulse leaks is a property of this chip and this pulse; what would make the - node worthless is three states it cannot tell apart. - """ - report, _device, _simulator, _scheduler = fully_calibrated - measured = { - result.target: result.parameters - for result in report.routine_results - if result.routine_name == "three_state_discrimination" - } - assert measured, "three_state_discrimination reported nothing" + def test_the_three_state_readout_point_is_not_the_two_state_one( + self, fully_calibrated + ): + """Two points because one setting cannot be best at both, measured. + + At the two-state point ``|1>`` and ``|2>`` both return almost nothing and collapse + together — 2.95 sigma apart on this chip, against 39 where the three-state sweep + puts them. A run where the two points coincided would mean the sweep found nothing + to prefer, and the extra submodule would be carrying a duplicate. + """ + _report, device, _simulator, _scheduler = fully_calibrated + written = yaml.safe_load(device.read_text()) + + for qubit in ("q0", "q1"): + three = written[qubit]["measure_3state"] + two = written[qubit]["measure_2state"] + assert three["frequency"] > 0 and three["pulse_amp"] > 0 + + assert (three["frequency"], three["pulse_amp"]) != ( + two["frequency"], + two["pulse_amp"], + ), f"{qubit}'s three-state readout landed exactly on its two-state one" + + def test_the_discriminated_readout_point_is_calibrated_and_sane( + self, fully_calibrated + ): + """The two readouts want different things, and the graph now says so separately. + + `resonator_spectroscopy` and `resonator_punchout` find where the most signal comes + back, which is what every routine reducing an acquisition to a magnitude needs. + `readout_operating_point` finds where the two states look least alike, which is + what a discriminator needs, and it is not the same place — most of the complex + separation off resonance is phase, and a magnitude sees none of it. + + On this chip the two differ mostly in *power*: the frequency optimum sits only + about 200 kHz off the resonance, a tenth of a linewidth, while the amplitude has a + genuine interior optimum because signal grows linearly with drive and + punch-through only bends it. The frequency is swept alongside not because it moves + far but because it cannot be chosen separately — the resonance walks with power. + """ + _report, device, _simulator, _scheduler = fully_calibrated + written = yaml.safe_load(device.read_text()) + + for qubit in ("q0", "q1"): + point = written[qubit]["measure_2state"] + assert point["frequency"] > 0, f"{qubit} has no discriminated readout point" + assert point["pulse_amp"] > 0 + + # Inside the bracket it swept, which is what says the value came from the + # sweep rather than from a default left in place. Deliberately *not* that it + # differs from punchout's power: the grid is coarse and its optimum can land + # on punchout's answer, which is the sweep agreeing rather than failing. Two + # earlier versions of this test demanded a difference the chip does not + # guarantee, and both failed on a routine that was working. + punchout = written[qubit]["measure"]["pulse_amp"] + assert 0.5 * punchout <= point["pulse_amp"] <= 1.5 * punchout + + # A refinement rather than a second opinion: the optimum sits about 200 kHz + # off the resonance here, a tenth of a linewidth, so what this rules out is + # the sweep wandering rather than choosing. + offset = abs(point["frequency"] - written[qubit]["clock_freqs"]["readout"]) + assert offset < 2e6, ( + f"{qubit}'s discriminated readout sits {offset / 1e3:.1f} kHz from the " + f"calibration one, further than a linewidth — that is not a refinement" + ) - for qubit, params in measured.items(): - assert params["assignment_fidelity"] > 0.9, ( - f"{qubit}'s three states are not being told apart: {params}" + def test_each_qubit_is_discriminated_against_its_own_line(self, fully_calibrated): + """Two qubits, two readout chains, two rotations — and X on one of them. + + The executors used to take the rotation and threshold from whichever element had + them first and apply that pair to every qubit in the circuit. Nothing caught it + because nothing measured those numbers: an uncalibrated chip carries zero + everywhere, and one qubit's zero is as good as another's. Once + `readout_discrimination` puts a real line on each qubit, q0's applied to q1 reads + q1's shots against a boundary drawn somewhere else. + + ``x q[0]`` and nothing on q[1], so the two qubits must come back *different*. A + collapsed discriminator does not merely lose accuracy here, it assigns q1 with a + threshold placed for q0's cloud positions. + """ + _report, device, simulator, scheduler = fully_calibrated + written = yaml.safe_load(device.read_text()) + + # From `measure_2state`, where the discriminator now lives: it is fitted at the + # operating point discriminated shots are actually taken at, which is not the one + # the calibration routines read on. The fixture's hand-set `measure` pair stays + # put as the fallback for an element with no `measure_2state`. + lines = { + qubit: ( + written[qubit]["measure_2state"]["acq_rotation"], + written[qubit]["measure_2state"]["acq_threshold"], + ) + for qubit in ("q0", "q1") + } + # Far apart, not merely unequal. An earlier version of this guard asserted only + # that the two differ, and passed on the last digits of shot noise while the + # qubits were physically identical — so it would have gone on passing with the + # collapse reinstated. `TRUE_READOUT_PHASES` is what makes them really differ. + apart = abs(lines["q0"][0] - lines["q1"][0]) % 360.0 + assert 30.0 < apart < 330.0, ( + f"the two readout lines are {apart:.2f} degrees apart, which is too close for " + f"this test to tell a per-qubit discriminator from a collapsed one: {lines}" ) - # A leakage figure at all, and a physical one. It is a fraction of shots, so - # anything outside [0, 1] would be a counting mistake rather than a chip. - assert 0.0 <= params["leakage"] <= 1.0 - -def test_the_three_state_readout_point_is_not_the_two_state_one(fully_calibrated): - """Two points because one setting cannot be best at both, measured. + counts = run_pair(device, simulator, scheduler, "x q[0];\n", shots=400) + total = sum(counts.values()) or 1 + # Bit order is little-endian: c[1]c[0], so q0 is the right-hand character. + excited = sum(value for key, value in counts.items() if key[1] == "1") / total + ground = sum(value for key, value in counts.items() if key[0] == "1") / total - At the two-state point ``|1>`` and ``|2>`` both return almost nothing and collapse - together — 2.95 sigma apart on this chip, against 39 where the three-state sweep - puts them. A run where the two points coincided would mean the sweep found nothing - to prefer, and the extra submodule would be carrying a duplicate. - """ - _report, device, _simulator, _scheduler = fully_calibrated - written = yaml.safe_load(device.read_text()) - - for qubit in ("q0", "q1"): - three = written[qubit]["measure_3state"] - two = written[qubit]["measure_2state"] - assert three["frequency"] > 0 and three["pulse_amp"] > 0 - - assert (three["frequency"], three["pulse_amp"]) != ( - two["frequency"], - two["pulse_amp"], - ), f"{qubit}'s three-state readout landed exactly on its two-state one" - - -def test_the_discriminated_readout_point_is_calibrated_and_sane(fully_calibrated): - """The two readouts want different things, and the graph now says so separately. - - `resonator_spectroscopy` and `resonator_punchout` find where the most signal comes - back, which is what every routine reducing an acquisition to a magnitude needs. - `readout_operating_point` finds where the two states look least alike, which is - what a discriminator needs, and it is not the same place — most of the complex - separation off resonance is phase, and a magnitude sees none of it. - - On this chip the two differ mostly in *power*: the frequency optimum sits only - about 200 kHz off the resonance, a tenth of a linewidth, while the amplitude has a - genuine interior optimum because signal grows linearly with drive and - punch-through only bends it. The frequency is swept alongside not because it moves - far but because it cannot be chosen separately — the resonance walks with power. - """ - _report, device, _simulator, _scheduler = fully_calibrated - written = yaml.safe_load(device.read_text()) - - for qubit in ("q0", "q1"): - point = written[qubit]["measure_2state"] - assert point["frequency"] > 0, f"{qubit} has no discriminated readout point" - assert point["pulse_amp"] > 0 - - # Inside the bracket it swept, which is what says the value came from the - # sweep rather than from a default left in place. Deliberately *not* that it - # differs from punchout's power: the grid is coarse and its optimum can land - # on punchout's answer, which is the sweep agreeing rather than failing. Two - # earlier versions of this test demanded a difference the chip does not - # guarantee, and both failed on a routine that was working. - punchout = written[qubit]["measure"]["pulse_amp"] - assert 0.5 * punchout <= point["pulse_amp"] <= 1.5 * punchout - - # A refinement rather than a second opinion: the optimum sits about 200 kHz - # off the resonance here, a tenth of a linewidth, so what this rules out is - # the sweep wandering rather than choosing. - offset = abs(point["frequency"] - written[qubit]["clock_freqs"]["readout"]) - assert offset < 2e6, ( - f"{qubit}'s discriminated readout sits {offset / 1e3:.1f} kHz from the " - f"calibration one, further than a linewidth — that is not a refinement" - ) - - -def test_each_qubit_is_discriminated_against_its_own_line(fully_calibrated): - """Two qubits, two readout chains, two rotations — and X on one of them. - - The executors used to take the rotation and threshold from whichever element had - them first and apply that pair to every qubit in the circuit. Nothing caught it - because nothing measured those numbers: an uncalibrated chip carries zero - everywhere, and one qubit's zero is as good as another's. Once - `readout_discrimination` puts a real line on each qubit, q0's applied to q1 reads - q1's shots against a boundary drawn somewhere else. - - ``x q[0]`` and nothing on q[1], so the two qubits must come back *different*. A - collapsed discriminator does not merely lose accuracy here, it assigns q1 with a - threshold placed for q0's cloud positions. - """ - _report, device, simulator, scheduler = fully_calibrated - written = yaml.safe_load(device.read_text()) - - # From `measure_2state`, where the discriminator now lives: it is fitted at the - # operating point discriminated shots are actually taken at, which is not the one - # the calibration routines read on. The fixture's hand-set `measure` pair stays - # put as the fallback for an element with no `measure_2state`. - lines = { - qubit: ( - written[qubit]["measure_2state"]["acq_rotation"], - written[qubit]["measure_2state"]["acq_threshold"], - ) - for qubit in ("q0", "q1") - } - # Far apart, not merely unequal. An earlier version of this guard asserted only - # that the two differ, and passed on the last digits of shot noise while the - # qubits were physically identical — so it would have gone on passing with the - # collapse reinstated. `TRUE_READOUT_PHASES` is what makes them really differ. - apart = abs(lines["q0"][0] - lines["q1"][0]) % 360.0 - assert 30.0 < apart < 330.0, ( - f"the two readout lines are {apart:.2f} degrees apart, which is too close for " - f"this test to tell a per-qubit discriminator from a collapsed one: {lines}" - ) - - counts = run_pair(device, simulator, scheduler, "x q[0];\n", shots=400) - total = sum(counts.values()) or 1 - # Bit order is little-endian: c[1]c[0], so q0 is the right-hand character. - excited = sum(value for key, value in counts.items() if key[1] == "1") / total - ground = sum(value for key, value in counts.items() if key[0] == "1") / total + assert excited > 0.9, f"q0 was driven to |1> and should read 1: {counts}" + assert ground < 0.1, f"q1 was left alone and should read 0: {counts}" - assert excited > 0.9, f"q0 was driven to |1> and should read 1: {counts}" - assert ground < 0.1, f"q1 was left alone and should read 0: {counts}" + def test_the_two_qubit_gate_is_written_and_playable(self, fully_calibrated): + """The CZ the chevron found reached the file, on the hardware's time grid. + A duration refined between setpoints is sub-nanosecond, which the instrument + cannot play — and writing it made every *later* schedule containing this CZ + fail to compile, some distance from the routine that caused it. + """ + from qpi_driver.simulation.coupled import CoupledTransmons -def test_the_two_qubit_gate_is_written_and_playable(fully_calibrated): - """The CZ the chevron found reached the file, on the hardware's time grid. + _report, device, _simulator, _scheduler = fully_calibrated + written = yaml.safe_load(device.read_text())["q0_q1"]["cz"] - A duration refined between setpoints is sub-nanosecond, which the instrument - cannot play — and writing it made every *later* schedule containing this CZ - fail to compile, some distance from the routine that caused it. - """ - from qpi_driver.simulation.coupled import CoupledTransmons - - _report, device, _simulator, _scheduler = fully_calibrated - written = yaml.safe_load(device.read_text())["q0_q1"]["cz"] - - assert written["square_amp"] == pytest.approx(0.376, abs=0.01) - duration_ns = written["square_duration"] * 1e9 - assert duration_ns == pytest.approx(round(duration_ns), abs=1e-6), ( - f"the CZ duration must be a whole number of nanoseconds, got {duration_ns}" - ) - # A *full* round trip, which is the assertion this test used to be missing. - # Half of one is complete population transfer into |02>: a perfectly good gate, - # measured just as confidently, and not a CZ. The chevron wrote 55 ns against a - # round trip of 110 for as long as nothing checked the number itself. - assert duration_ns == pytest.approx(CoupledTransmons().cz_duration_ns, rel=0.05), ( - f"the CZ should be one |11>-|02>-|11> round trip, got {duration_ns:.1f} ns" - ) - - corrections = [ - value for name, value in written.items() if name.endswith("phase_correction") - ] - assert len(corrections) == 2, f"expected two virtual-Z corrections in {written}" - assert any(value != 0 for value in corrections), ( - "the conditional-phase routine measured its corrections and wrote none" - ) - - -def test_the_readout_lands_on_the_resonance_at_the_power_it_chose(fully_calibrated): - """Both halves of the readout operating point, and consistent with each other. - - The strong claim is the pairing. The resonance moves with readout power, so a - frequency and a power are not two independent numbers — the frequency is only - right *at* that power. Punchout picks the power, so punchout has to supply the - frequency too; leaving `resonator_spectroscopy`'s answer in place pointed the - readout half a linewidth off and cost a fifth of the contrast everywhere - downstream, which no assertion about either number alone would catch. - - Checked against the model rather than a constant, to a tenth of a linewidth. - """ - _report, device, simulator, _scheduler = fully_calibrated - config = yaml.safe_load(device.read_text()) - declared = yaml.safe_load((FIXTURES / "quantify.device.yml").read_text()) - - for qubit in ("q0", "q1"): - written = config[qubit]["clock_freqs"]["readout"] - power = config[qubit]["measure"]["pulse_amp"] - resonator = simulator.resonator(qubit) - expected = resonator.resonance_ghz(power) * GHZ - - assert written == pytest.approx(expected, abs=200e3), ( - f"{qubit}'s readout is at {written / 1e6:.3f} MHz but its resonance at " - f"power {power} is {expected / 1e6:.3f} MHz" + assert written["square_amp"] == pytest.approx(0.376, abs=0.01) + duration_ns = written["square_duration"] * 1e9 + assert duration_ns == pytest.approx(round(duration_ns), abs=1e-6), ( + f"the CZ duration must be a whole number of nanoseconds, got {duration_ns}" + ) + # A *full* round trip, which is the assertion this test used to be missing. + # Half of one is complete population transfer into |02>: a perfectly good gate, + # measured just as confidently, and not a CZ. The chevron wrote 55 ns against a + # round trip of 110 for as long as nothing checked the number itself. + assert duration_ns == pytest.approx( + CoupledTransmons().cz_duration_ns, rel=0.05 + ), f"the CZ should be one |11>-|02>-|11> round trip, got {duration_ns:.1f} ns" + + corrections = [ + value + for name, value in written.items() + if name.endswith("phase_correction") + ] + assert len(corrections) == 2, f"expected two virtual-Z corrections in {written}" + assert any(value != 0 for value in corrections), ( + "the conditional-phase routine measured its corrections and wrote none" ) - # And it moved, rather than the fixture having been right all along. - was = float(declared[qubit]["clock_freqs"]["readout"]) - assert abs(written - was) > 2e6, f"{qubit}'s readout never moved off {was}" - - -def test_the_acquisition_window_opens_when_the_signal_arrives(fully_calibrated): - """`time_of_flight` measured the wiring's delay, which nothing used to. - - A working chip's config carries 200 ns here with nothing having measured it. The - simulated chip's delay is 148 ns, and the routine has to find that by opening the - window *with* the readout pulse so the dead time lands inside the trace — which - is the whole trick, since leaving the configured delay in place would hide - exactly the quantity being measured. - Tight tolerance on purpose: the arrival and the ring-up are recovered from one - straight line, and a level crossing — the obvious alternative — is biased late by - a quarter of the ring-up, some 20 ns here. Ten nanoseconds would pass with that - bug present; two does not. - """ - _report, device, simulator, _scheduler = fully_calibrated - config = yaml.safe_load(device.read_text()) + def test_the_readout_lands_on_the_resonance_at_the_power_it_chose( + self, fully_calibrated + ): + """Both halves of the readout operating point, and consistent with each other. + + The strong claim is the pairing. The resonance moves with readout power, so a + frequency and a power are not two independent numbers — the frequency is only + right *at* that power. Punchout picks the power, so punchout has to supply the + frequency too; leaving `resonator_spectroscopy`'s answer in place pointed the + readout half a linewidth off and cost a fifth of the contrast everywhere + downstream, which no assertion about either number alone would catch. + + Checked against the model rather than a constant, to a tenth of a linewidth. + """ + _report, device, simulator, _scheduler = fully_calibrated + config = yaml.safe_load(device.read_text()) + declared = yaml.safe_load((FIXTURES / "quantify.device.yml").read_text()) + + for qubit in ("q0", "q1"): + written = config[qubit]["clock_freqs"]["readout"] + power = config[qubit]["measure"]["pulse_amp"] + resonator = simulator.resonator(qubit) + expected = resonator.resonance_ghz(power) * GHZ + + assert written == pytest.approx(expected, abs=200e3), ( + f"{qubit}'s readout is at {written / 1e6:.3f} MHz but its resonance at " + f"power {power} is {expected / 1e6:.3f} MHz" + ) + # And it moved, rather than the fixture having been right all along. + was = float(declared[qubit]["clock_freqs"]["readout"]) + assert abs(written - was) > 2e6, f"{qubit}'s readout never moved off {was}" - for qubit in ("q0", "q1"): - written = config[qubit]["measure"]["acq_delay"] - assert written * 1e9 == pytest.approx(simulator.time_of_flight_ns, abs=2.0), ( - f"{qubit}'s acq_delay is {written * 1e9:.1f} ns against a true flight of " - f"{simulator.time_of_flight_ns} ns" - ) - # And on the instrument's 1 ns grid. The fit reports an arrival to a fraction - # of a sample, and `acq_delay` shifts the acquisition: writing 149.327 ns - # compiled here and then broke punchout, qubit spectroscopy, Rabi, Ramsey, T1 - # and flux spectroscopy, none of which is the routine at fault. Same shape as - # the CZ duration below. - assert written * 1e9 == pytest.approx(round(written * 1e9), abs=1e-6), ( - f"acq_delay must be a whole number of nanoseconds, got {written * 1e9}" - ) + def test_the_acquisition_window_opens_when_the_signal_arrives( + self, fully_calibrated + ): + """`time_of_flight` measured the wiring's delay, which nothing used to. + + A working chip's config carries 200 ns here with nothing having measured it. The + simulated chip's delay is 148 ns, and the routine has to find that by opening the + window *with* the readout pulse so the dead time lands inside the trace — which + is the whole trick, since leaving the configured delay in place would hide + exactly the quantity being measured. + + Tight tolerance on purpose: the arrival and the ring-up are recovered from one + straight line, and a level crossing — the obvious alternative — is biased late by + a quarter of the ring-up, some 20 ns here. Ten nanoseconds would pass with that + bug present; two does not. + """ + _report, device, simulator, _scheduler = fully_calibrated + config = yaml.safe_load(device.read_text()) + + for qubit in ("q0", "q1"): + written = config[qubit]["measure"]["acq_delay"] + assert written * 1e9 == pytest.approx( + simulator.time_of_flight_ns, abs=2.0 + ), ( + f"{qubit}'s acq_delay is {written * 1e9:.1f} ns against a true flight of " + f"{simulator.time_of_flight_ns} ns" + ) + # And on the instrument's 1 ns grid. The fit reports an arrival to a fraction + # of a sample, and `acq_delay` shifts the acquisition: writing 149.327 ns + # compiled here and then broke punchout, qubit spectroscopy, Rabi, Ramsey, T1 + # and flux spectroscopy, none of which is the routine at fault. Same shape as + # the CZ duration below. + assert written * 1e9 == pytest.approx(round(written * 1e9), abs=1e-6), ( + f"acq_delay must be a whole number of nanoseconds, got {written * 1e9}" + ) + def test_resonator_relaxation_measures_the_linewidth_and_writes_nothing( + self, + fully_calibrated, + ): + """A characterisation, deliberately — see the routine for why. + + The tempting parameter is `measure.integration_time`, and the ring-up is only a + floor on it: the optimum trades signal-to-noise against relaxation during the + window, which nothing here measures yet. So the routine reports the linewidth — + which nothing else does, and which `resonator_spectroscopy`'s check currently + approximates with a constant — and leaves the window alone. + """ + report, device, simulator, _scheduler = fully_calibrated + results = [ + r + for r in report.routine_results + if r.routine_name == "resonator_relaxation" + ] + assert results, "resonator_relaxation did not run" -def test_resonator_relaxation_measures_the_linewidth_and_writes_nothing( - fully_calibrated, -): - """A characterisation, deliberately — see the routine for why. + for result in results: + assert result.parameters["linewidth"] == pytest.approx( + simulator.readout_linewidth_ghz * GHZ, rel=0.1 + ) - The tempting parameter is `measure.integration_time`, and the ring-up is only a - floor on it: the optimum trades signal-to-noise against relaxation during the - window, which nothing here measures yet. So the routine reports the linewidth — - which nothing else does, and which `resonator_spectroscopy`'s check currently - approximates with a constant — and leaves the window alone. - """ - report, device, simulator, _scheduler = fully_calibrated - results = [ - r for r in report.routine_results if r.routine_name == "resonator_relaxation" - ] - assert results, "resonator_relaxation did not run" + # And the window is untouched, at whatever the fixture set. + declared = yaml.safe_load((FIXTURES / "quantify.device.yml").read_text()) + written = yaml.safe_load(device.read_text()) + for qubit in ("q0", "q1"): + assert written[qubit]["measure"]["integration_time"] == pytest.approx( + float(declared[qubit]["measure"]["integration_time"]) + ) - for result in results: - assert result.parameters["linewidth"] == pytest.approx( - simulator.readout_linewidth_ghz * GHZ, rel=0.1 + def test_the_discriminator_is_measured_rather_than_defaulted( + self, fully_calibrated + ): + """The line that assigns every meas_level=2 shot, and nothing used to produce it. + + Two clouds land wherever the amplifier chain puts them — each qubit level sits at + its own resonance and so returns its own complex response, and the chain then + rotates the pair. On the simulated chip both land with *positive* real parts, so at + the defaults of `acq_rotation=0` and `acq_threshold=0` every shot reads ``|1>``. + A working chip's device config does not carry either field at all. + + So the assertion is the fidelity: the fitted rule has to assign shots correctly, + which the defaults cannot. And the rotation has to be in ``[0, 360)``, because the + instrument refuses anything else — in every schedule *after* the one that wrote it. + """ + report, device, _simulator, _scheduler = fully_calibrated + written = yaml.safe_load(device.read_text()) + + for qubit in ("q0", "q1"): + measure = written[qubit]["measure"] + assert 0.0 <= measure["acq_rotation"] < 360.0, ( + f"{qubit}'s acq_rotation is {measure['acq_rotation']}, which the " + "instrument will refuse" + ) + # Not the defaults, or the routine measured nothing. + assert measure["acq_rotation"] != 0.0 + assert measure["acq_threshold"] != 0.0 + + fidelities = [ + r.parameters["assignment_fidelity"] + for r in report.routine_results + if r.routine_name == "readout_discrimination" + ] + assert fidelities, "readout_discrimination did not run" + assert min(fidelities) > 0.95, f"poor readout assignment: {fidelities}" + + def test_f12_was_measured_rather_than_inherited(self, fully_calibrated): + """The |1>-|2> transition, which the element carried and nothing produced. + + The fixture pins `clock_freqs.f12` at 4.8 GHz and the simulated transmon's is at + 4.931 — 131 MHz out, unnoticed for as long as nothing read the field. It is the + input to three-state readout and it sets where |02> sits for a CZ, so a wrong value + is not harmless, only silent. + + Driving it needs a |1> to start from, which is why the routine depends on `rabi` + and why this cannot be part of the readout bring-up. + """ + report, device, simulator, _scheduler = fully_calibrated + written = yaml.safe_load(device.read_text()) + declared = yaml.safe_load((FIXTURES / "quantify.device.yml").read_text()) + true_f12 = (simulator.f01 + simulator.anharmonicity) * GHZ + + for qubit in ("q0", "q1"): + found = written[qubit]["clock_freqs"]["f12"] + assert found == pytest.approx(true_f12, abs=5e6), ( + f"{qubit}'s f12 is {found / 1e9:.4f} GHz against a true " + f"{true_f12 / 1e9:.4f}" + ) + was = float(declared[qubit]["clock_freqs"]["f12"]) + assert abs(found - was) > 50e6, f"{qubit}'s f12 never moved off {was}" + + # And the anharmonicity it implies is a transmon's, which is the sanity check on + # the whole measurement: a fit that found the 0-1 line again would report zero. + anharmonicities = [ + r.parameters["anharmonicity"] + for r in report.routine_results + if r.routine_name == "f12_spectroscopy" + ] + assert anharmonicities + for value in anharmonicities: + assert -400e6 < value < -150e6, f"not a transmon anharmonicity: {value}" + + def test_punchout_wrote_a_readout_power_inside_its_sweep(self, fully_calibrated): + """The last power still in the dressed regime, which is a real choice here. + + Punchout only means something because pushing more photons in *costs* the thing + readout measures: past the crossover the dispersive pull washes out and the + resonance walks to bare. A simulator where power did nothing would let this + routine write the top of its sweep and look just as successful. + """ + _report, device, _simulator, _scheduler = fully_calibrated + amplitudes = FULL_DAG_SWEEPS["resonator_punchout"]["amplitudes"] + written = yaml.safe_load(device.read_text())["q0"]["measure"]["pulse_amp"] + + assert any(abs(written - amplitude) < 1e-9 for amplitude in amplitudes), ( + f"the readout power should be one of the swept amplitudes, got {written}" ) - - # And the window is untouched, at whatever the fixture set. - declared = yaml.safe_load((FIXTURES / "quantify.device.yml").read_text()) - written = yaml.safe_load(device.read_text()) - for qubit in ("q0", "q1"): - assert written[qubit]["measure"]["integration_time"] == pytest.approx( - float(declared[qubit]["measure"]["integration_time"]) + assert written < max(amplitudes), ( + "punchout picked the highest power it tried, which is what it would do if " + "power had no effect on the resonator" ) + def test_drag_wrote_the_optimum_its_scheduler_spells(self, fully_calibrated): + """The Motzoi optimum, in whichever units and under whichever name. + + The two schedulers disagree twice over: quantify's `rxy.motzoi` is a + dimensionless ratio and qblox's `rxy.beta` is that ratio times the pulse sigma, + in seconds. So the same chip has two right answers differing by 2.5 ns, and a + routine with one hardcoded name and one hardcoded sweep gets both wrong. + + The physical value is about ``-1/(2*alpha)``, which for this transmon's -280 MHz + is a ratio near 0.11. + """ + _report, device, _simulator, scheduler = fully_calibrated + written = yaml.safe_load(device.read_text())["q0"]["rxy"] + + name = "beta" if scheduler == "qblox" else "motzoi" + assert name in written, f"drag wrote nothing readable: {written}" + # Back to the dimensionless ratio, so one number covers both schedulers. + sigma = 20e-9 / 8 # the default 20 ns gate, cut at four sigma + ratio = written[name] / (sigma if scheduler == "qblox" else 1.0) + assert ratio == pytest.approx(0.11, abs=0.03), ( + f"expected a DRAG ratio near 0.11, got {ratio} from {name}={written[name]}" + ) -def test_the_discriminator_is_measured_rather_than_defaulted(fully_calibrated): - """The line that assigns every meas_level=2 shot, and nothing used to produce it. - - Two clouds land wherever the amplifier chain puts them — each qubit level sits at - its own resonance and so returns its own complex response, and the chain then - rotates the pair. On the simulated chip both land with *positive* real parts, so at - the defaults of `acq_rotation=0` and `acq_threshold=0` every shot reads ``|1>``. - A working chip's device config does not carry either field at all. - - So the assertion is the fidelity: the fitted rule has to assign shots correctly, - which the defaults cannot. And the rotation has to be in ``[0, 360)``, because the - instrument refuses anything else — in every schedule *after* the one that wrote it. - """ - report, device, _simulator, _scheduler = fully_calibrated - written = yaml.safe_load(device.read_text()) - - for qubit in ("q0", "q1"): - measure = written[qubit]["measure"] - assert 0.0 <= measure["acq_rotation"] < 360.0, ( - f"{qubit}'s acq_rotation is {measure['acq_rotation']}, which the " - "instrument will refuse" - ) - # Not the defaults, or the routine measured nothing. - assert measure["acq_rotation"] != 0.0 - assert measure["acq_threshold"] != 0.0 - - fidelities = [ - r.parameters["assignment_fidelity"] - for r in report.routine_results - if r.routine_name == "readout_discrimination" - ] - assert fidelities, "readout_discrimination did not run" - assert min(fidelities) > 0.95, f"poor readout assignment: {fidelities}" - - -def test_f12_was_measured_rather_than_inherited(fully_calibrated): - """The |1>-|2> transition, which the element carried and nothing produced. - - The fixture pins `clock_freqs.f12` at 4.8 GHz and the simulated transmon's is at - 4.931 — 131 MHz out, unnoticed for as long as nothing read the field. It is the - input to three-state readout and it sets where |02> sits for a CZ, so a wrong value - is not harmless, only silent. - - Driving it needs a |1> to start from, which is why the routine depends on `rabi` - and why this cannot be part of the readout bring-up. - """ - report, device, simulator, _scheduler = fully_calibrated - written = yaml.safe_load(device.read_text()) - declared = yaml.safe_load((FIXTURES / "quantify.device.yml").read_text()) - true_f12 = (simulator.f01 + simulator.anharmonicity) * GHZ - - for qubit in ("q0", "q1"): - found = written[qubit]["clock_freqs"]["f12"] - assert found == pytest.approx(true_f12, abs=5e6), ( - f"{qubit}'s f12 is {found / 1e9:.4f} GHz against a true " - f"{true_f12 / 1e9:.4f}" - ) - was = float(declared[qubit]["clock_freqs"]["f12"]) - assert abs(found - was) > 50e6, f"{qubit}'s f12 never moved off {was}" - - # And the anharmonicity it implies is a transmon's, which is the sanity check on - # the whole measurement: a fit that found the 0-1 line again would report zero. - anharmonicities = [ - r.parameters["anharmonicity"] - for r in report.routine_results - if r.routine_name == "f12_spectroscopy" - ] - assert anharmonicities - for value in anharmonicities: - assert -400e6 < value < -150e6, f"not a transmon anharmonicity: {value}" - - -def test_punchout_wrote_a_readout_power_inside_its_sweep(fully_calibrated): - """The last power still in the dressed regime, which is a real choice here. - - Punchout only means something because pushing more photons in *costs* the thing - readout measures: past the crossover the dispersive pull washes out and the - resonance walks to bare. A simulator where power did nothing would let this - routine write the top of its sweep and look just as successful. - """ - _report, device, _simulator, _scheduler = fully_calibrated - amplitudes = FULL_DAG_SWEEPS["resonator_punchout"]["amplitudes"] - written = yaml.safe_load(device.read_text())["q0"]["measure"]["pulse_amp"] - - assert any(abs(written - amplitude) < 1e-9 for amplitude in amplitudes), ( - f"the readout power should be one of the swept amplitudes, got {written}" - ) - assert written < max(amplitudes), ( - "punchout picked the highest power it tried, which is what it would do if " - "power had no effect on the resonator" - ) - - -def test_drag_wrote_the_optimum_its_scheduler_spells(fully_calibrated): - """The Motzoi optimum, in whichever units and under whichever name. - - The two schedulers disagree twice over: quantify's `rxy.motzoi` is a - dimensionless ratio and qblox's `rxy.beta` is that ratio times the pulse sigma, - in seconds. So the same chip has two right answers differing by 2.5 ns, and a - routine with one hardcoded name and one hardcoded sweep gets both wrong. - - The physical value is about ``-1/(2*alpha)``, which for this transmon's -280 MHz - is a ratio near 0.11. - """ - _report, device, _simulator, scheduler = fully_calibrated - written = yaml.safe_load(device.read_text())["q0"]["rxy"] - - name = "beta" if scheduler == "qblox" else "motzoi" - assert name in written, f"drag wrote nothing readable: {written}" - # Back to the dimensionless ratio, so one number covers both schedulers. - sigma = 20e-9 / 8 # the default 20 ns gate, cut at four sigma - ratio = written[name] / (sigma if scheduler == "qblox" else 1.0) - assert ratio == pytest.approx(0.11, abs=0.03), ( - f"expected a DRAG ratio near 0.11, got {ratio} from {name}={written[name]}" - ) - - -def test_circuits_run_against_what_the_whole_dag_wrote(fully_calibrated): - """The point of calibrating: the file it produced drives the chip.""" - _report, device, simulator, scheduler = fully_calibrated + def test_circuits_run_against_what_the_whole_dag_wrote(self, fully_calibrated): + """The point of calibrating: the file it produced drives the chip.""" + _report, device, simulator, scheduler = fully_calibrated - counts = run(device, simulator, scheduler, circuit("x q[0];\n"), shots=400) - assert counts["1"] / sum(counts.values()) > 0.9, ( - f"an X gate should land in |1> after a full calibration, got {counts}" - ) + counts = run(device, simulator, scheduler, circuit("x q[0];\n"), shots=400) + assert counts["1"] / sum(counts.values()) > 0.9, ( + f"an X gate should land in |1> after a full calibration, got {counts}" + ) diff --git a/qpi-driver/py/tests/test_device_reload.py b/qpi-driver/py/tests/test_device_reload.py index 808a9e19..5d51c891 100644 --- a/qpi-driver/py/tests/test_device_reload.py +++ b/qpi-driver/py/tests/test_device_reload.py @@ -12,7 +12,7 @@ from qpi_driver.executors.base import CircuitPayload, JobPayload -from .utils import load_json_fixture, load_yaml_fixture +from .utils.io import load_json_fixture, load_yaml_fixture _HARDWARE = load_json_fixture("quantify.hardware.json") _DEVICE = load_yaml_fixture("quantify.device.yml") diff --git a/qpi-driver/py/tests/test_discovery.py b/qpi-driver/py/tests/test_discovery.py index d8cc5e71..34d8afdb 100644 --- a/qpi-driver/py/tests/test_discovery.py +++ b/qpi-driver/py/tests/test_discovery.py @@ -141,14 +141,12 @@ def test_import_object_keeps_filesystem_paths_out_of_the_message(): readable by anyone who can read logs. """ with pytest.raises(ValueError) as excinfo: - import_object("tests.fixtures.half_imported_device:Anything") + import_object("tests.utils.half_imported_device:Anything") message = str(excinfo.value) assert "ThisNameDoesNotExist" in message # the useful half is kept assert ".py" not in message - assert "/" not in message.replace( - "tests.fixtures.half_imported_device:Anything", "" - ) + assert "/" not in message.replace("tests.utils.half_imported_device:Anything", "") def test_import_object_reports_a_missing_attribute(): diff --git a/qpi-driver/py/tests/test_docs.py b/qpi-driver/py/tests/test_docs.py index 71f4e50a..7521e1fb 100644 --- a/qpi-driver/py/tests/test_docs.py +++ b/qpi-driver/py/tests/test_docs.py @@ -52,11 +52,8 @@ def _output(result) -> str: return text -# --------------------------------------------------------------------------- -# The four error transcripts in docs/driver/operations.md -# --------------------------------------------------------------------------- - -# Each documented message, with the invocation that has to produce it. The message +# The four error transcripts in docs/driver/operations.md, each documented message +# with the invocation that has to produce it. The message # is not written here — it is read out of the document by its prefix, so the document # is the source and this is only the command that proves it. TRANSCRIPT_CASES = [ @@ -152,10 +149,7 @@ def test_the_documented_error_is_the_error_the_cli_prints(prefix, argv): ) -# --------------------------------------------------------------------------- -# The CLI reference block in qpi-driver/py/README.md -# --------------------------------------------------------------------------- - +# The CLI reference block in qpi-driver/py/README.md. FLAG_RE = re.compile(r"(? np.ndarray: return RNG.normal(0.0, scale, size) -# --- oscillatory fits --------------------------------------------------------- - - -def test_rabi_recovers_the_pi_pulse_amplitude(): - amp180 = 0.21 - amplitudes = np.linspace(0.0, 0.5, 81) - # One full period spans 2*amp180, so the frequency is 1/(2*amp180). - signal = 0.5 * np.cos(2 * np.pi * amplitudes / (2 * amp180)) + 0.5 - fitted = fit_rabi(amplitudes, signal + _noise(len(amplitudes))) - assert fitted["amp180"] == pytest.approx(amp180, rel=0.05) - - -def test_rabi_refuses_a_fit_outside_the_swept_range(): - """A pi pulse the sweep never reached is an extrapolation, not a measurement.""" - amplitudes = np.linspace(0.0, 0.02, 41) - signal = 0.5 * np.cos(2 * np.pi * amplitudes / (2 * 5.0)) + 0.5 - with pytest.raises(FitError, match="outside the swept range"): - fit_rabi(amplitudes, signal) - - -def test_ramsey_recovers_the_detuning_and_t2_star(): - detuning, artificial, t2 = 0.3e6, 1e6, 8e-6 - delays = np.linspace(4e-9, 20e-6, 121) - signal = decaying_cosine(delays, 0.5, detuning + artificial, 0.0, t2, 0.5) - fitted = fit_ramsey(delays, signal + _noise(len(delays), 0.005), artificial) - assert fitted["detuning"] == pytest.approx(detuning, abs=3e4) - assert fitted["t2_star"] == pytest.approx(t2, rel=0.15) - - -def test_drag_finds_the_zero_crossing(): - betas = np.linspace(-1.0, 1.0, 41) - signal = 2.5 * (betas - 0.32) - fitted = fit_drag(betas, signal + _noise(len(betas), 0.002)) - assert fitted["motzoi"] == pytest.approx(0.32, abs=0.02) - - -def test_drag_refuses_a_flat_sweep(): - betas = np.linspace(-1.0, 1.0, 21) - with pytest.raises(FitError, match="flat in beta"): - fit_drag(betas, np.full(21, 0.5)) - - def _fine_amplitude_signal(counts: np.ndarray, delta: float) -> np.ndarray: """``P(n) = ½(1 + (-1)ⁿ·sin(nδ))`` — a π/2 pre-rotation then n π pulses.""" return 0.5 * (1 + np.power(-1.0, counts) * np.sin(counts * delta)) -def test_fine_amplitude_corrects_a_known_over_rotation(): - delta = 0.01 # radians per pulse of over-rotation - counts = np.arange(1, 41, dtype=float) - signal = _fine_amplitude_signal(counts, delta) - fitted = fit_fine_amplitude( - counts, signal + _noise(len(counts), 0.001), 0.2, ground=0.0, excited=1.0 - ) - - assert fitted["error_per_pulse"] == pytest.approx(delta, abs=0.002) - assert fitted["amp180"] < 0.2 # over-rotating, so the amplitude comes down - - -def test_fine_amplitude_recovers_the_sign_of_an_under_rotation(): - """The π/2 pre-rotation is what makes this distinguishable from over-rotation.""" - counts = np.arange(1, 41, dtype=float) - fitted = fit_fine_amplitude( - counts, _fine_amplitude_signal(counts, -0.01), 0.2, ground=0.0, excited=1.0 - ) - - assert fitted["error_per_pulse"] == pytest.approx(-0.01, abs=0.002) - assert fitted["amp180"] > 0.2 # under-rotating, so the amplitude goes up - - -def test_fine_amplitude_is_scaled_by_the_calibration_points_not_the_sweep(): - """Normalising against the observed range inflates the error by the contrast reached.""" - counts = np.arange(1, 41, dtype=float) - # Half the contrast: |0> reads 0.25 and |1> reads 0.75. - signal = 0.25 + 0.5 * _fine_amplitude_signal(counts, 0.01) - fitted = fit_fine_amplitude(counts, signal, 0.2, ground=0.25, excited=0.75) - assert fitted["error_per_pulse"] == pytest.approx(0.01, abs=0.002) - - -def test_fine_amplitude_refuses_indistinguishable_calibration_points(): - counts = np.arange(1, 21, dtype=float) - with pytest.raises(FitError, match="indistinguishable"): - fit_fine_amplitude(counts, np.ones(20) * 0.5, 0.2, ground=0.5, excited=0.5) - - -def test_fine_amplitude_refuses_fractional_repetition_counts(): - with pytest.raises(FitError, match="whole pulse repetitions"): - fit_fine_amplitude( - np.array([1.5, 2.5, 3.5, 4.5]), - np.array([0.1, 0.9, 0.2, 0.8]), - 0.2, - ground=0.0, - excited=1.0, +class TestOscillatoryFits: + """Rabi, Ramsey, DRAG, and fine-amplitude — fits over a sinusoid or a swept phase.""" + + def test_rabi_recovers_the_pi_pulse_amplitude(self): + amp180 = 0.21 + amplitudes = np.linspace(0.0, 0.5, 81) + # One full period spans 2*amp180, so the frequency is 1/(2*amp180). + signal = 0.5 * np.cos(2 * np.pi * amplitudes / (2 * amp180)) + 0.5 + fitted = fit_rabi(amplitudes, signal + _noise(len(amplitudes))) + assert fitted["amp180"] == pytest.approx(amp180, rel=0.05) + + def test_rabi_refuses_a_fit_outside_the_swept_range(self): + """A pi pulse the sweep never reached is an extrapolation, not a measurement.""" + amplitudes = np.linspace(0.0, 0.02, 41) + signal = 0.5 * np.cos(2 * np.pi * amplitudes / (2 * 5.0)) + 0.5 + with pytest.raises(FitError, match="outside the swept range"): + fit_rabi(amplitudes, signal) + + def test_ramsey_recovers_the_detuning_and_t2_star(self): + detuning, artificial, t2 = 0.3e6, 1e6, 8e-6 + delays = np.linspace(4e-9, 20e-6, 121) + signal = decaying_cosine(delays, 0.5, detuning + artificial, 0.0, t2, 0.5) + fitted = fit_ramsey(delays, signal + _noise(len(delays), 0.005), artificial) + assert fitted["detuning"] == pytest.approx(detuning, abs=3e4) + assert fitted["t2_star"] == pytest.approx(t2, rel=0.15) + + def test_drag_finds_the_zero_crossing(self): + betas = np.linspace(-1.0, 1.0, 41) + signal = 2.5 * (betas - 0.32) + fitted = fit_drag(betas, signal + _noise(len(betas), 0.002)) + assert fitted["motzoi"] == pytest.approx(0.32, abs=0.02) + + def test_drag_refuses_a_flat_sweep(self): + betas = np.linspace(-1.0, 1.0, 21) + with pytest.raises(FitError, match="flat in beta"): + fit_drag(betas, np.full(21, 0.5)) + + def test_fine_amplitude_corrects_a_known_over_rotation(self): + delta = 0.01 # radians per pulse of over-rotation + counts = np.arange(1, 41, dtype=float) + signal = _fine_amplitude_signal(counts, delta) + fitted = fit_fine_amplitude( + counts, signal + _noise(len(counts), 0.001), 0.2, ground=0.0, excited=1.0 ) + assert fitted["error_per_pulse"] == pytest.approx(delta, abs=0.002) + assert fitted["amp180"] < 0.2 # over-rotating, so the amplitude comes down -# --- exponential fits --------------------------------------------------------- - - -def test_t1_recovers_the_relaxation_time(): - t1 = 45e-6 - delays = np.linspace(0.0, 200e-6, 81) - signal = exponential_decay(delays, 1.0, t1, 0.05) - fitted = fit_t1(delays, signal + _noise(len(delays), 0.005)) - assert fitted["t1"] == pytest.approx(t1, rel=0.05) - - -def test_t2_recovers_the_dephasing_time(): - t2 = 30e-6 - delays = np.linspace(0.0, 150e-6, 81) - signal = exponential_decay(delays, 1.0, t2, 0.05) - fitted = fit_t2(delays, signal + _noise(len(delays), 0.005)) - assert fitted["t2"] == pytest.approx(t2, rel=0.05) - - -def test_a_coherence_time_far_beyond_the_window_is_refused(): - """Extrapolating T1 from a window that never sees the decay is not a measurement.""" - delays = np.linspace(0.0, 1e-9, 41) - with pytest.raises(FitError): - fit_t1(delays, np.linspace(1.0, 0.999999, 41)) - - -def test_rb_recovers_a_known_fidelity(): - decay = 0.995 - depths = np.array([1, 2, 4, 8, 16, 32, 64, 128], dtype=float) - survival = 0.5 * decay**depths + 0.5 - fitted = fit_rb_decay(depths, survival + _noise(len(depths), 0.002)) - expected = 1.0 - (1.0 - decay) * (2 - 1) / 2 - assert fitted["fidelity"] == pytest.approx(expected, abs=0.002) - assert fitted["error_per_gate"] == pytest.approx(1 - expected, abs=0.002) - - -def test_rb_recovers_the_same_fidelity_from_a_rescaled_signal(): - """The fit must not care about the readout's scale and offset. - - This is what the `rb` routine hands it: the acquisition rescaled to span - [0, 1], from a decay that has not reached its asymptote by the deepest - sequence. Bounding the model's amplitude used to make that case come out at - 0.988 whatever the truth was, so every chip better than about 3% error per - Clifford measured the same — permanently below the default drift threshold. - """ - decay = 0.999 - depths = np.array([1, 2, 4, 8, 16, 32, 64], dtype=float) - survival = 0.5 * decay**depths + 0.5 - rescaled = (survival - survival.min()) / (survival.max() - survival.min()) - - assert fit_rb_decay(depths, rescaled)["decay_rate"] == pytest.approx( - fit_rb_decay(depths, survival)["decay_rate"], abs=1e-4 - ) - assert fit_rb_decay(depths, rescaled)["fidelity"] == pytest.approx( - 1.0 - (1.0 - decay) / 2, abs=1e-4 - ) - - -def test_rb_uses_the_right_dimension_for_two_qubits(): - """d = 2^n, so a two-qubit decay maps to a worse fidelity than a one-qubit one.""" - decay = 0.99 - depths = np.array([1, 2, 4, 8, 16, 32], dtype=float) - survival = 0.5 * decay**depths + 0.5 - one = fit_rb_decay(depths, survival, n_qubits=1) - two = fit_rb_decay(depths, survival, n_qubits=2) - assert two["error_per_gate"] > one["error_per_gate"] - assert two["error_per_gate"] == pytest.approx((1 - decay) * 3 / 4, abs=0.002) - - -def test_rb_refuses_data_it_cannot_fit(): - depths = np.array([1, 2, 4, 8], dtype=float) - with pytest.raises(FitError): - fit_rb_decay(depths, np.array([np.nan, np.nan, np.nan, np.nan])) - - -# --- lorentzian fits ---------------------------------------------------------- - + def test_fine_amplitude_recovers_the_sign_of_an_under_rotation(self): + """The π/2 pre-rotation is what makes this distinguishable from over-rotation.""" + counts = np.arange(1, 41, dtype=float) + fitted = fit_fine_amplitude( + counts, _fine_amplitude_signal(counts, -0.01), 0.2, ground=0.0, excited=1.0 + ) -def test_resonator_spectroscopy_finds_a_peak(): - centre = 6.12e9 - frequencies = np.linspace(6.0e9, 6.3e9, 201) - signal = lorentzian(frequencies, 1.0, centre, 2e6, 0.1) - fitted = fit_resonator_spectroscopy(frequencies, signal + _noise(201, 0.005)) - assert fitted["readout_frequency"] == pytest.approx(centre, abs=1e5) - assert fitted["linewidth"] == pytest.approx(2e6, rel=0.2) + assert fitted["error_per_pulse"] == pytest.approx(-0.01, abs=0.002) + assert fitted["amp180"] > 0.2 # under-rotating, so the amplitude goes up + + def test_fine_amplitude_is_scaled_by_the_calibration_points_not_the_sweep(self): + """Normalising against the observed range inflates the error by the contrast reached.""" + counts = np.arange(1, 41, dtype=float) + # Half the contrast: |0> reads 0.25 and |1> reads 0.75. + signal = 0.25 + 0.5 * _fine_amplitude_signal(counts, 0.01) + fitted = fit_fine_amplitude(counts, signal, 0.2, ground=0.25, excited=0.75) + assert fitted["error_per_pulse"] == pytest.approx(0.01, abs=0.002) + + def test_fine_amplitude_refuses_indistinguishable_calibration_points(self): + counts = np.arange(1, 21, dtype=float) + with pytest.raises(FitError, match="indistinguishable"): + fit_fine_amplitude(counts, np.ones(20) * 0.5, 0.2, ground=0.5, excited=0.5) + + def test_fine_amplitude_refuses_fractional_repetition_counts(self): + with pytest.raises(FitError, match="whole pulse repetitions"): + fit_fine_amplitude( + np.array([1.5, 2.5, 3.5, 4.5]), + np.array([0.1, 0.9, 0.2, 0.8]), + 0.2, + ground=0.0, + excited=1.0, + ) + + +class TestExponentialFits: + """T1, RB decay, and what each refuses: a coherence time past the window, data neither can fit.""" + + def test_t1_recovers_the_relaxation_time(self): + t1 = 45e-6 + delays = np.linspace(0.0, 200e-6, 81) + signal = exponential_decay(delays, 1.0, t1, 0.05) + fitted = fit_t1(delays, signal + _noise(len(delays), 0.005)) + assert fitted["t1"] == pytest.approx(t1, rel=0.05) + + def test_t2_recovers_the_dephasing_time(self): + t2 = 30e-6 + delays = np.linspace(0.0, 150e-6, 81) + signal = exponential_decay(delays, 1.0, t2, 0.05) + fitted = fit_t2(delays, signal + _noise(len(delays), 0.005)) + assert fitted["t2"] == pytest.approx(t2, rel=0.05) + + def test_a_coherence_time_far_beyond_the_window_is_refused(self): + """Extrapolating T1 from a window that never sees the decay is not a measurement.""" + delays = np.linspace(0.0, 1e-9, 41) + with pytest.raises(FitError): + fit_t1(delays, np.linspace(1.0, 0.999999, 41)) + + def test_rb_recovers_a_known_fidelity(self): + decay = 0.995 + depths = np.array([1, 2, 4, 8, 16, 32, 64, 128], dtype=float) + survival = 0.5 * decay**depths + 0.5 + fitted = fit_rb_decay(depths, survival + _noise(len(depths), 0.002)) + expected = 1.0 - (1.0 - decay) * (2 - 1) / 2 + assert fitted["fidelity"] == pytest.approx(expected, abs=0.002) + assert fitted["error_per_gate"] == pytest.approx(1 - expected, abs=0.002) + + def test_rb_recovers_the_same_fidelity_from_a_rescaled_signal(self): + """The fit must not care about the readout's scale and offset. + + This is what the `rb` routine hands it: the acquisition rescaled to span + [0, 1], from a decay that has not reached its asymptote by the deepest + sequence. Bounding the model's amplitude used to make that case come out at + 0.988 whatever the truth was, so every chip better than about 3% error per + Clifford measured the same — permanently below the default drift threshold. + """ + decay = 0.999 + depths = np.array([1, 2, 4, 8, 16, 32, 64], dtype=float) + survival = 0.5 * decay**depths + 0.5 + rescaled = (survival - survival.min()) / (survival.max() - survival.min()) + + assert fit_rb_decay(depths, rescaled)["decay_rate"] == pytest.approx( + fit_rb_decay(depths, survival)["decay_rate"], abs=1e-4 + ) + assert fit_rb_decay(depths, rescaled)["fidelity"] == pytest.approx( + 1.0 - (1.0 - decay) / 2, abs=1e-4 + ) + def test_rb_uses_the_right_dimension_for_two_qubits(self): + """d = 2^n, so a two-qubit decay maps to a worse fidelity than a one-qubit one.""" + decay = 0.99 + depths = np.array([1, 2, 4, 8, 16, 32], dtype=float) + survival = 0.5 * decay**depths + 0.5 + one = fit_rb_decay(depths, survival, n_qubits=1) + two = fit_rb_decay(depths, survival, n_qubits=2) + assert two["error_per_gate"] > one["error_per_gate"] + assert two["error_per_gate"] == pytest.approx((1 - decay) * 3 / 4, abs=0.002) -def test_qubit_spectroscopy_finds_a_dip(): - """Which way the feature points depends on the acquisition, so both must fit.""" - centre = 5.03e9 - frequencies = np.linspace(4.9e9, 5.2e9, 201) - signal = lorentzian(frequencies, -1.0, centre, 3e6, 1.0) - fitted = fit_qubit_spectroscopy(frequencies, signal + _noise(201, 0.005)) - assert fitted["clock_freq_01"] == pytest.approx(centre, abs=2e5) + def test_rb_refuses_data_it_cannot_fit(self): + depths = np.array([1, 2, 4, 8], dtype=float) + with pytest.raises(FitError): + fit_rb_decay(depths, np.array([np.nan, np.nan, np.nan, np.nan])) def _power_sweep( @@ -245,77 +209,89 @@ def _power_sweep( ) -def test_spectroscopy_power_prefers_signal_to_noise_over_depth(): - """The deepest row is the most broadened one, so depth is the wrong criterion.""" - centre = 5.03e9 - frequencies = np.linspace(4.9e9, 5.2e9, 201) - powers = np.array([0.01, 0.02, 0.04]) - # Depth climbs with power, but so does width: the last row is twice as deep and - # nine times as broad. Choosing on depth takes it; choosing on contrast-over- - # scatter takes the middle one, which is what resolves the line. - signal = _power_sweep(frequencies, centre, [(3e6, 0.4), (4e6, 1.0), (36e6, 2.0)]) - fitted = fit_spectroscopy_power(powers, frequencies, signal) - assert fitted["drive_amplitude"] == pytest.approx(0.02) - assert fitted["clock_freq_01"] == pytest.approx(centre, abs=5e5) - - -def test_spectroscopy_power_ignores_a_line_the_sweep_could_not_resolve(): - """A noise-fitted spike is the *narrowest* row, so it must not set the reference. - - Left in, it becomes the linewidth every real row is judged against, and a sweep - that plainly found the qubit returns nothing usable. - """ - centre = 5.03e9 - frequencies = np.linspace(4.9e9, 5.2e9, 61) # 5 MHz steps - powers = np.array([0.005, 0.02]) - signal = _power_sweep(frequencies, centre, [(2e5, 0.05), (12e6, 1.0)]) - fitted = fit_spectroscopy_power(powers, frequencies, signal) - assert fitted["drive_amplitude"] == pytest.approx(0.02) - assert fitted["linewidth"] > 5e6 - - -def test_spectroscopy_power_refuses_a_sweep_with_no_resolved_line(): - frequencies = np.linspace(4.9e9, 5.2e9, 61) - powers = np.array([0.005, 0.01]) - signal = np.vstack([_noise(61, 0.01) + 1.0 for _ in powers]) - with pytest.raises(FitError, match="no drive power"): - fit_spectroscopy_power(powers, frequencies, signal) - - -def test_a_spectroscopy_centre_outside_the_scan_is_refused(): - frequencies = np.linspace(6.0e9, 6.1e9, 51) - with pytest.raises(FitError): - fit_resonator_spectroscopy(frequencies, np.linspace(0.0, 1.0, 51)) - - -def test_punchout_picks_the_last_dressed_power(): - powers = np.array([0.01, 0.05, 0.1, 0.2, 0.4]) - frequencies = np.array([6.0e9, 6.0e9, 6.0e9, 6.05e9, 6.1e9]) - fitted = fit_punchout(powers, frequencies) - assert fitted["readout_power"] == pytest.approx(0.1) - assert fitted["dressed_frequency"] == 6.0e9 - assert fitted["bare_frequency"] == 6.1e9 - - -def test_punchout_reports_the_frequency_at_the_power_it_chose(): - """Not the low-power one. Choosing a power moves the resonance to a new place, - and the caller has to drive it there rather than where it used to be.""" - powers = np.array([0.01, 0.1, 0.2, 0.4]) - frequencies = np.array([6.000e9, 6.002e9, 6.020e9, 6.040e9]) - fitted = fit_punchout(powers, frequencies) - - assert fitted["readout_power"] == pytest.approx(0.1) - assert fitted["readout_frequency"] == pytest.approx(6.002e9) - assert fitted["readout_frequency"] != fitted["dressed_frequency"] - - -def test_punchout_refuses_a_sweep_with_no_shift(): - powers = np.array([0.01, 0.05, 0.1, 0.2]) - with pytest.raises(FitError, match="no resonator shift"): - fit_punchout(powers, np.full(4, 6.0e9)) - - -# --- the readout chain's timing, off one raw trace ------------------------------ +class TestLorentzianFits: + """Resonator and qubit spectroscopy, and punchout's power-dependent dressed shift.""" + + def test_resonator_spectroscopy_finds_a_peak(self): + centre = 6.12e9 + frequencies = np.linspace(6.0e9, 6.3e9, 201) + signal = lorentzian(frequencies, 1.0, centre, 2e6, 0.1) + fitted = fit_resonator_spectroscopy(frequencies, signal + _noise(201, 0.005)) + assert fitted["readout_frequency"] == pytest.approx(centre, abs=1e5) + assert fitted["linewidth"] == pytest.approx(2e6, rel=0.2) + + def test_qubit_spectroscopy_finds_a_dip(self): + """Which way the feature points depends on the acquisition, so both must fit.""" + centre = 5.03e9 + frequencies = np.linspace(4.9e9, 5.2e9, 201) + signal = lorentzian(frequencies, -1.0, centre, 3e6, 1.0) + fitted = fit_qubit_spectroscopy(frequencies, signal + _noise(201, 0.005)) + assert fitted["clock_freq_01"] == pytest.approx(centre, abs=2e5) + + def test_spectroscopy_power_prefers_signal_to_noise_over_depth(self): + """The deepest row is the most broadened one, so depth is the wrong criterion.""" + centre = 5.03e9 + frequencies = np.linspace(4.9e9, 5.2e9, 201) + powers = np.array([0.01, 0.02, 0.04]) + # Depth climbs with power, but so does width: the last row is twice as deep and + # nine times as broad. Choosing on depth takes it; choosing on contrast-over- + # scatter takes the middle one, which is what resolves the line. + signal = _power_sweep( + frequencies, centre, [(3e6, 0.4), (4e6, 1.0), (36e6, 2.0)] + ) + fitted = fit_spectroscopy_power(powers, frequencies, signal) + assert fitted["drive_amplitude"] == pytest.approx(0.02) + assert fitted["clock_freq_01"] == pytest.approx(centre, abs=5e5) + + def test_spectroscopy_power_ignores_a_line_the_sweep_could_not_resolve(self): + """A noise-fitted spike is the *narrowest* row, so it must not set the reference. + + Left in, it becomes the linewidth every real row is judged against, and a sweep + that plainly found the qubit returns nothing usable. + """ + centre = 5.03e9 + frequencies = np.linspace(4.9e9, 5.2e9, 61) # 5 MHz steps + powers = np.array([0.005, 0.02]) + signal = _power_sweep(frequencies, centre, [(2e5, 0.05), (12e6, 1.0)]) + fitted = fit_spectroscopy_power(powers, frequencies, signal) + assert fitted["drive_amplitude"] == pytest.approx(0.02) + assert fitted["linewidth"] > 5e6 + + def test_spectroscopy_power_refuses_a_sweep_with_no_resolved_line(self): + frequencies = np.linspace(4.9e9, 5.2e9, 61) + powers = np.array([0.005, 0.01]) + signal = np.vstack([_noise(61, 0.01) + 1.0 for _ in powers]) + with pytest.raises(FitError, match="no drive power"): + fit_spectroscopy_power(powers, frequencies, signal) + + def test_a_spectroscopy_centre_outside_the_scan_is_refused(self): + frequencies = np.linspace(6.0e9, 6.1e9, 51) + with pytest.raises(FitError): + fit_resonator_spectroscopy(frequencies, np.linspace(0.0, 1.0, 51)) + + def test_punchout_picks_the_last_dressed_power(self): + powers = np.array([0.01, 0.05, 0.1, 0.2, 0.4]) + frequencies = np.array([6.0e9, 6.0e9, 6.0e9, 6.05e9, 6.1e9]) + fitted = fit_punchout(powers, frequencies) + assert fitted["readout_power"] == pytest.approx(0.1) + assert fitted["dressed_frequency"] == 6.0e9 + assert fitted["bare_frequency"] == 6.1e9 + + def test_punchout_reports_the_frequency_at_the_power_it_chose(self): + """Not the low-power one. Choosing a power moves the resonance to a new place, + and the caller has to drive it there rather than where it used to be.""" + powers = np.array([0.01, 0.1, 0.2, 0.4]) + frequencies = np.array([6.000e9, 6.002e9, 6.020e9, 6.040e9]) + fitted = fit_punchout(powers, frequencies) + + assert fitted["readout_power"] == pytest.approx(0.1) + assert fitted["readout_frequency"] == pytest.approx(6.002e9) + assert fitted["readout_frequency"] != fitted["dressed_frequency"] + + def test_punchout_refuses_a_sweep_with_no_shift(self): + powers = np.array([0.01, 0.05, 0.1, 0.2]) + with pytest.raises(FitError, match="no resonator shift"): + fit_punchout(powers, np.full(4, 6.0e9)) def _readout_trace( @@ -340,52 +316,49 @@ def _readout_trace( ) -@pytest.mark.parametrize("arrival", [0.0, 50.0, 148.0, 300.0]) -def test_readout_timing_recovers_the_arrival_and_the_ring_up(arrival): - """Both together, because neither is measurable alone. - - A level crossing finds the arrival biased late by the ring-up, and fitting the - ring-up needs to know where it started. One straight line through - ``ln(1 - rise)`` gives both, consistent by construction. - """ - fitted = fit_readout_timing(_readout_trace(arrival), sampling_rate=1e9) - - assert fitted["time_of_flight"] * 1e9 == pytest.approx(arrival, abs=8.0) - assert fitted["ring_up_time"] * 1e9 == pytest.approx(79.58, rel=0.05) - # The linewidth is what nothing else measures, and it is the same number. - assert fitted["linewidth"] == pytest.approx(2e6, rel=0.05) +class TestTheReadoutChainsTimingOffOneRawTrace: + """Recovering a raw trace's arrival time and ring-up constant, and what a signal-free or already-integrated trace refuses.""" + @pytest.mark.parametrize("arrival", [0.0, 50.0, 148.0, 300.0]) + def test_readout_timing_recovers_the_arrival_and_the_ring_up(self, arrival): + """Both together, because neither is measurable alone. -def test_readout_timing_refuses_a_trace_with_no_signal(): - """A dead readout chain is not a mismeasured delay, and must not report one.""" - noise = np.random.default_rng(0).normal(0, 0.01, 500) + 0j - with pytest.raises(FitError, match="never rises out of its own noise"): - fit_readout_timing(noise, sampling_rate=1e9) + A level crossing finds the arrival biased late by the ring-up, and fitting the + ring-up needs to know where it started. One straight line through + ``ln(1 - rise)`` gives both, consistent by construction. + """ + fitted = fit_readout_timing(_readout_trace(arrival), sampling_rate=1e9) + assert fitted["time_of_flight"] * 1e9 == pytest.approx(arrival, abs=8.0) + assert fitted["ring_up_time"] * 1e9 == pytest.approx(79.58, rel=0.05) + # The linewidth is what nothing else measures, and it is the same number. + assert fitted["linewidth"] == pytest.approx(2e6, rel=0.05) -def test_readout_timing_refuses_an_integrated_acquisition(): - """One point per acquisition is the wrong protocol, not a short trace.""" - with pytest.raises(FitError, match="needs a trace"): - fit_readout_timing(np.array([1 + 1j, 2 + 2j]), sampling_rate=1e9) + def test_readout_timing_refuses_a_trace_with_no_signal(self): + """A dead readout chain is not a mismeasured delay, and must not report one.""" + noise = np.random.default_rng(0).normal(0, 0.01, 500) + 0j + with pytest.raises(FitError, match="never rises out of its own noise"): + fit_readout_timing(noise, sampling_rate=1e9) + def test_readout_timing_refuses_an_integrated_acquisition(self): + """One point per acquisition is the wrong protocol, not a short trace.""" + with pytest.raises(FitError, match="needs a trace"): + fit_readout_timing(np.array([1 + 1j, 2 + 2j]), sampling_rate=1e9) -def test_readout_timing_reads_the_baseline_from_the_front_not_a_percentile(): - """With the delay already right there is no dead time to take a floor from. - - A low percentile over the whole trace lands part-way up the ring-up in that - case, reporting a floor about half the swing too high — which moved the - recovered onset by 50 ns on a true zero. The front of the trace is the baseline - whether or not the window waited first. - """ - fitted = fit_readout_timing(_readout_trace(0.0), sampling_rate=1e9) - assert fitted["time_of_flight"] * 1e9 < 10.0 - # Not near zero — with no dead time the lead window unavoidably averages in the - # first few per cent of the rise. Far below the settled level is what matters, - # and is what a percentile over the whole trace would not give. - assert fitted["noise_floor"] < 0.1 * fitted["settled_amplitude"] - + def test_readout_timing_reads_the_baseline_from_the_front_not_a_percentile(self): + """With the delay already right there is no dead time to take a floor from. -# --- the readout discriminator --------------------------------------------------- + A low percentile over the whole trace lands part-way up the ring-up in that + case, reporting a floor about half the swing too high — which moved the + recovered onset by 50 ns on a true zero. The front of the trace is the baseline + whether or not the window waited first. + """ + fitted = fit_readout_timing(_readout_trace(0.0), sampling_rate=1e9) + assert fitted["time_of_flight"] * 1e9 < 10.0 + # Not near zero — with no dead time the lead window unavoidably averages in the + # first few per cent of the rise. Far below the settled level is what matters, + # and is what a percentile over the whole trace would not give. + assert fitted["noise_floor"] < 0.1 * fitted["settled_amplitude"] def _clouds(rotation_deg: float, separation: float = 3.0, shots: int = 800): @@ -401,57 +374,54 @@ def noise(): return ground + noise(), excited + noise() -@pytest.mark.parametrize("rotation", [0.0, 35.0, 120.0, -153.7, 206.3, -20.0]) -def test_discrimination_returns_a_rotation_the_instrument_accepts(rotation): - """In [0, 360), because the hardware says so and says it late. - - `np.angle` returns (-180, 180], so half of all readout chains produce a negative - rotation — which the compiler refuses with "the hardware requires it to be between - 0 and 360", in *every schedule after* the one that wrote it rather than in the - routine at fault. - """ - fitted = fit_readout_discrimination(*_clouds(rotation)) - assert 0.0 <= fitted["acq_rotation"] < 360.0 - - -@pytest.mark.parametrize("rotation", [0.0, 35.0, 120.0, -153.7, 250.0]) -def test_discrimination_separates_the_clouds_at_any_chain_rotation(rotation): - """The rotation is what makes one real threshold enough. - - Whatever angle the chain leaves the clouds at, rotating by the direction between - their centres puts all the state information in the real part — so the fitted rule - assigns nearly every shot correctly. - """ - ground, excited = _clouds(rotation) - fitted = fit_readout_discrimination(ground, excited) - - turn = np.exp(-1j * np.deg2rad(fitted["acq_rotation"])) - assigned_ground = np.real(ground * turn) >= fitted["acq_threshold"] - assigned_excited = np.real(excited * turn) >= fitted["acq_threshold"] - assert assigned_ground.mean() < 0.01 - assert assigned_excited.mean() > 0.99 - assert fitted["assignment_fidelity"] > 0.98 - - -def test_discrimination_refuses_clouds_it_cannot_separate(): - """A readout that does not resolve the qubit has no discriminator. - - Fitting one anyway would put a confident, meaningless threshold on the device, and - every count afterwards would be a coin toss with no symptom. - """ - rng = np.random.default_rng(3) - same = rng.normal(0, 0.25, 500) + 1j * rng.normal(0, 0.25, 500) - other = rng.normal(0, 0.25, 500) + 1j * rng.normal(0, 0.25, 500) - with pytest.raises(FitError, match="no separation at all"): - fit_readout_discrimination(same, other) - - -def test_discrimination_needs_single_shots(): - with pytest.raises(FitError, match="needs single shots"): - fit_readout_discrimination(np.array([1 + 1j]), np.array([2 + 2j])) - - -# --- two-qubit fits ----------------------------------------------------------- +class TestTheReadoutDiscriminator: + """The rotation and threshold that separate two IQ clouds, at any chain rotation, and what an inseparable pair refuses.""" + + @pytest.mark.parametrize("rotation", [0.0, 35.0, 120.0, -153.7, 206.3, -20.0]) + def test_discrimination_returns_a_rotation_the_instrument_accepts(self, rotation): + """In [0, 360), because the hardware says so and says it late. + + `np.angle` returns (-180, 180], so half of all readout chains produce a negative + rotation — which the compiler refuses with "the hardware requires it to be between + 0 and 360", in *every schedule after* the one that wrote it rather than in the + routine at fault. + """ + fitted = fit_readout_discrimination(*_clouds(rotation)) + assert 0.0 <= fitted["acq_rotation"] < 360.0 + + @pytest.mark.parametrize("rotation", [0.0, 35.0, 120.0, -153.7, 250.0]) + def test_discrimination_separates_the_clouds_at_any_chain_rotation(self, rotation): + """The rotation is what makes one real threshold enough. + + Whatever angle the chain leaves the clouds at, rotating by the direction between + their centres puts all the state information in the real part — so the fitted rule + assigns nearly every shot correctly. + """ + ground, excited = _clouds(rotation) + fitted = fit_readout_discrimination(ground, excited) + + turn = np.exp(-1j * np.deg2rad(fitted["acq_rotation"])) + assigned_ground = np.real(ground * turn) >= fitted["acq_threshold"] + assigned_excited = np.real(excited * turn) >= fitted["acq_threshold"] + assert assigned_ground.mean() < 0.01 + assert assigned_excited.mean() > 0.99 + assert fitted["assignment_fidelity"] > 0.98 + + def test_discrimination_refuses_clouds_it_cannot_separate(self): + """A readout that does not resolve the qubit has no discriminator. + + Fitting one anyway would put a confident, meaningless threshold on the device, and + every count afterwards would be a coin toss with no symptom. + """ + rng = np.random.default_rng(3) + same = rng.normal(0, 0.25, 500) + 1j * rng.normal(0, 0.25, 500) + other = rng.normal(0, 0.25, 500) + 1j * rng.normal(0, 0.25, 500) + with pytest.raises(FitError, match="no separation at all"): + fit_readout_discrimination(same, other) + + def test_discrimination_needs_single_shots(self): + with pytest.raises(FitError, match="needs single shots"): + fit_readout_discrimination(np.array([1 + 1j]), np.array([2 + 2j])) def _chevron_grid( @@ -486,140 +456,130 @@ def _chevron_grid( return grid -@pytest.mark.parametrize("swing", [1.0, -1.0], ids=["falls", "rises"]) -def test_chevron_locates_the_operating_point(swing): - """And does so whichever way the readout's magnitude moves. - - The polarity is not knowable from a chevron: `resonator_spectroscopy` leaves the - readout on the ground-state resonance, where an *excited* qubit reflects less, so - an emptying control can read either up or down depending on the chain. Hunting an - extreme picked the wrong one and returned half the round trip — a complete - population swap, which is a perfectly good gate and not a CZ. - """ - amplitudes = np.linspace(0.3, 0.5, 5) - durations = np.linspace(10e-9, 200e-9, 20) - grid = _chevron_grid(durations, rows=5, resonant=2, swing=swing) - - fitted = fit_chevron(amplitudes, durations, grid.reshape(-1)) - assert fitted["cz_amplitude"] == pytest.approx(amplitudes[2], abs=0.03) - assert fitted["cz_duration"] == pytest.approx(110e-9, rel=0.05) - - -@pytest.mark.parametrize("swing", [1.0, -1.0], ids=["falls", "rises"]) -def test_chevron_reads_past_a_wiggle_in_the_far_side(swing): - """The round trip, not a bump on the way through it. - - The exchange beats against the transitions the flux pulse sits near, so the far - side of the round trip is not smooth. A turning-point walk stops on that bump, - still deep in ``|02>`` and at half the right duration. - """ - amplitudes = np.linspace(0.3, 0.5, 5) - durations = np.linspace(10e-9, 200e-9, 20) - grid = _chevron_grid( - durations, rows=5, resonant=2, swing=swing, ripple=0.05 * swing - ) - - fitted = fit_chevron(amplitudes, durations, grid.reshape(-1)) - assert fitted["cz_duration"] == pytest.approx(110e-9, rel=0.05), ( - "half of this is a swap, and it is what a turning-point walk returned" - ) - - -def test_chevron_needs_a_full_grid(): - with pytest.raises(FitError, match="needs 9 points"): - fit_chevron(np.arange(3.0), np.arange(3.0), np.zeros(4)) - - -@pytest.mark.parametrize("conditional", [180.0, 150.0, 95.0, 220.0]) -def test_conditional_phase_recovers_the_offset_between_two_fringes(conditional): - phases = np.linspace(0.0, 360.0, 73) - ground = 0.5 + 0.4 * np.cos(np.deg2rad(phases)) - excited = 0.5 + 0.4 * np.cos(np.deg2rad(phases - conditional)) - - fitted = fit_conditional_phase(phases, ground, excited) - - assert fitted["conditional_phase"] == pytest.approx(conditional, abs=0.5) - assert fitted["phase_correction"] == pytest.approx(180.0 - conditional, abs=0.5) - - -def test_conditional_phase_ignores_a_phase_common_to_both_fringes(): - """The control's dynamical phase over the flux pulse must not reach the answer. - - It is common to both fringes and can be tens of turns; only the offset - between them is the gate. - """ - phases = np.linspace(0.0, 360.0, 73) - common = 117.0 - ground = 0.5 + 0.4 * np.cos(np.deg2rad(phases - common)) - excited = 0.5 + 0.4 * np.cos(np.deg2rad(phases - common - 150.0)) - - fitted = fit_conditional_phase(phases, ground, excited) - - assert fitted["conditional_phase"] == pytest.approx(150.0, abs=0.5) - - -def test_conditional_phase_refuses_a_flat_fringe(): - phases = np.linspace(0.0, 360.0, 21) - with pytest.raises(FitError, match="flat"): - fit_conditional_phase(phases, np.full(21, 1.0), np.full(21, 1.0)) - +class TestTwoQubitFits: + """The CZ chevron's operating point, and the conditional-phase fringe's offset.""" + + @pytest.mark.parametrize("swing", [1.0, -1.0], ids=["falls", "rises"]) + def test_chevron_locates_the_operating_point(self, swing): + """And does so whichever way the readout's magnitude moves. + + The polarity is not knowable from a chevron: `resonator_spectroscopy` leaves the + readout on the ground-state resonance, where an *excited* qubit reflects less, so + an emptying control can read either up or down depending on the chain. Hunting an + extreme picked the wrong one and returned half the round trip — a complete + population swap, which is a perfectly good gate and not a CZ. + """ + amplitudes = np.linspace(0.3, 0.5, 5) + durations = np.linspace(10e-9, 200e-9, 20) + grid = _chevron_grid(durations, rows=5, resonant=2, swing=swing) + + fitted = fit_chevron(amplitudes, durations, grid.reshape(-1)) + assert fitted["cz_amplitude"] == pytest.approx(amplitudes[2], abs=0.03) + assert fitted["cz_duration"] == pytest.approx(110e-9, rel=0.05) + + @pytest.mark.parametrize("swing", [1.0, -1.0], ids=["falls", "rises"]) + def test_chevron_reads_past_a_wiggle_in_the_far_side(self, swing): + """The round trip, not a bump on the way through it. + + The exchange beats against the transitions the flux pulse sits near, so the far + side of the round trip is not smooth. A turning-point walk stops on that bump, + still deep in ``|02>`` and at half the right duration. + """ + amplitudes = np.linspace(0.3, 0.5, 5) + durations = np.linspace(10e-9, 200e-9, 20) + grid = _chevron_grid( + durations, rows=5, resonant=2, swing=swing, ripple=0.05 * swing + ) -# --- the shared machinery ----------------------------------------------------- + fitted = fit_chevron(amplitudes, durations, grid.reshape(-1)) + assert fitted["cz_duration"] == pytest.approx(110e-9, rel=0.05), ( + "half of this is a swap, and it is what a turning-point walk returned" + ) + def test_chevron_needs_a_full_grid(self): + with pytest.raises(FitError, match="needs 9 points"): + fit_chevron(np.arange(3.0), np.arange(3.0), np.zeros(4)) -def test_signal_of_reads_an_xarray_dataset(): - dataset = xr.Dataset({"y": ("x", [1.0, 2.0, 3.0])}) - assert list(signal_of(dataset)) == [1.0, 2.0, 3.0] + @pytest.mark.parametrize("conditional", [180.0, 150.0, 95.0, 220.0]) + def test_conditional_phase_recovers_the_offset_between_two_fringes( + self, conditional + ): + phases = np.linspace(0.0, 360.0, 73) + ground = 0.5 + 0.4 * np.cos(np.deg2rad(phases)) + excited = 0.5 + 0.4 * np.cos(np.deg2rad(phases - conditional)) + fitted = fit_conditional_phase(phases, ground, excited) -def test_signal_of_takes_the_magnitude_of_complex_iq(): - dataset = xr.Dataset({"y": ("x", np.array([3 + 4j, 0 + 1j]))}) - assert list(signal_of(dataset)) == [5.0, 1.0] + assert fitted["conditional_phase"] == pytest.approx(conditional, abs=0.5) + assert fitted["phase_correction"] == pytest.approx(180.0 - conditional, abs=0.5) + def test_conditional_phase_ignores_a_phase_common_to_both_fringes(self): + """The control's dynamical phase over the flux pulse must not reach the answer. -def test_signal_of_refuses_an_empty_acquisition(): - with pytest.raises(FitError, match="no data points"): - signal_of(np.array([])) + It is common to both fringes and can be tens of turns; only the offset + between them is the gate. + """ + phases = np.linspace(0.0, 360.0, 73) + common = 117.0 + ground = 0.5 + 0.4 * np.cos(np.deg2rad(phases - common)) + excited = 0.5 + 0.4 * np.cos(np.deg2rad(phases - common - 150.0)) + fitted = fit_conditional_phase(phases, ground, excited) -def test_signal_of_refuses_an_all_nan_acquisition(): - """The dummy cluster returns NaN; that must fail loudly, not fit to zero.""" - with pytest.raises(FitError, match="no finite data points"): - signal_of(np.array([np.nan, np.nan])) + assert fitted["conditional_phase"] == pytest.approx(150.0, abs=0.5) + def test_conditional_phase_refuses_a_flat_fringe(self): + phases = np.linspace(0.0, 360.0, 21) + with pytest.raises(FitError, match="flat"): + fit_conditional_phase(phases, np.full(21, 1.0), np.full(21, 1.0)) -def test_signal_of_refuses_a_dataset_with_no_variables(): - with pytest.raises(FitError, match="no data variables"): - signal_of(xr.Dataset()) +class TestTheSharedMachinery: + """signal_of, align, require_in_range, and require_positive — the machinery every fit above is built on.""" -def test_signal_of_repairs_a_few_nans_around_finite_data(): - values = signal_of(np.array([1.0, np.nan, 3.0, 4.0])) - assert np.all(np.isfinite(values)) + def test_signal_of_reads_an_xarray_dataset(self): + dataset = xr.Dataset({"y": ("x", [1.0, 2.0, 3.0])}) + assert list(signal_of(dataset)) == [1.0, 2.0, 3.0] + def test_signal_of_takes_the_magnitude_of_complex_iq(self): + dataset = xr.Dataset({"y": ("x", np.array([3 + 4j, 0 + 1j]))}) + assert list(signal_of(dataset)) == [5.0, 1.0] -def test_align_trims_to_the_shorter_axis(): - x, y = align(np.arange(10.0), np.arange(6.0), what="test") - assert len(x) == len(y) == 6 + def test_signal_of_refuses_an_empty_acquisition(self): + with pytest.raises(FitError, match="no data points"): + signal_of(np.array([])) + def test_signal_of_refuses_an_all_nan_acquisition(self): + """The dummy cluster returns NaN; that must fail loudly, not fit to zero.""" + with pytest.raises(FitError, match="no finite data points"): + signal_of(np.array([np.nan, np.nan])) -def test_align_refuses_too_few_points(): - with pytest.raises(FitError, match="at least 4 points"): - align(np.arange(2.0), np.arange(2.0), what="test") + def test_signal_of_refuses_a_dataset_with_no_variables(self): + with pytest.raises(FitError, match="no data variables"): + signal_of(xr.Dataset()) + def test_signal_of_repairs_a_few_nans_around_finite_data(self): + values = signal_of(np.array([1.0, np.nan, 3.0, 4.0])) + assert np.all(np.isfinite(values)) -def test_require_in_range_accepts_a_reversed_window(): - assert require_in_range(5.0, 10.0, 0.0, what="x") == 5.0 + def test_align_trims_to_the_shorter_axis(self): + x, y = align(np.arange(10.0), np.arange(6.0), what="test") + assert len(x) == len(y) == 6 + def test_align_refuses_too_few_points(self): + with pytest.raises(FitError, match="at least 4 points"): + align(np.arange(2.0), np.arange(2.0), what="test") -def test_require_in_range_refuses_a_non_finite_value(): - with pytest.raises(FitError, match="not finite"): - require_in_range(float("nan"), 0.0, 1.0, what="x") + def test_require_in_range_accepts_a_reversed_window(self): + assert require_in_range(5.0, 10.0, 0.0, what="x") == 5.0 + def test_require_in_range_refuses_a_non_finite_value(self): + with pytest.raises(FitError, match="not finite"): + require_in_range(float("nan"), 0.0, 1.0, what="x") -def test_require_positive_refuses_zero_and_negatives(): - assert require_positive(1.0, what="x") == 1.0 - with pytest.raises(FitError, match="not physical"): - require_positive(0.0, what="x") - with pytest.raises(FitError, match="not physical"): - require_positive(-1.0, what="x") + def test_require_positive_refuses_zero_and_negatives(self): + assert require_positive(1.0, what="x") == 1.0 + with pytest.raises(FitError, match="not physical"): + require_positive(0.0, what="x") + with pytest.raises(FitError, match="not physical"): + require_positive(-1.0, what="x") diff --git a/qpi-driver/py/tests/test_persistence.py b/qpi-driver/py/tests/test_persistence.py index a6084283..a2ac2a81 100644 --- a/qpi-driver/py/tests/test_persistence.py +++ b/qpi-driver/py/tests/test_persistence.py @@ -85,224 +85,215 @@ def _device() -> FakeDevice: ) -# --- serialisation ------------------------------------------------------------ - - -def test_an_element_is_written_with_the_class_the_loader_rebuilds_it_from(): - config = serialise_device(_device()) - element_type = config["q0"][ELEMENT_TYPE_PROP] - - assert element_type["path"].endswith("FakeElement") - assert element_type["args"] == ["q0"] - assert config["q0"]["rxy"] == {"amp180": 0.12, "motzoi": 0.0} - - -def test_an_edge_is_written_with_the_two_elements_it_joins(): - """By keyword, not position. - - An edge's own name is not what its class is constructed from — the pair it - joins is. Named rather than positional because qblox's edges are pydantic - models and pydantic takes no positional arguments at all, so a file written - with them parses and then fails to instantiate. - """ - config = serialise_device(_device()) - element_type = config["q0_q1"][ELEMENT_TYPE_PROP] - - assert element_type["args"] == [] - assert element_type["kwargs"] == { - "parent_element_name": "q0", - "child_element_name": "q1", - } - - -def test_an_element_is_still_written_with_its_own_name(): - """Only edges move to keywords; a qubit is constructed from its name.""" - config = serialise_device(_device()) - assert config["q0"][ELEMENT_TYPE_PROP]["args"] == ["q0"] - assert config["q0"][ELEMENT_TYPE_PROP]["kwargs"] == {} - - -def test_structural_fields_are_not_written_as_calibration(): - """`edge_type` and the endpoints are already in `element_type`. - - qblox's elements are pydantic models carrying both as ordinary fields, so - they come back looking like calibration. Writing them out makes the loader - try to *assign* them to a constructed object, which its discriminated fields - refuse — and the file it just wrote no longer loads. - """ - config = serialise_device(_device()) - for name, element in config.items(): - for structural in ("edge_type", "parent_element_name", "child_element_name"): - assert structural not in element, ( - f"{name} wrote {structural} as a calibration parameter" - ) - - -def test_an_unreadable_parameter_is_skipped_not_stringified(): - """str() of a live object round-trips into a numeric parameter as a string.""" - device = FakeDevice( - elements={ - "q0": FakeElement( - "q0", submodules={"rxy": FakeSubmodule(amp180=0.1)}, parameters={} - ) +class TestSerialisation: + """Turning a device's elements and edges into the plain dict a YAML dump can hold.""" + + def test_an_element_is_written_with_the_class_the_loader_rebuilds_it_from(self): + config = serialise_device(_device()) + element_type = config["q0"][ELEMENT_TYPE_PROP] + + assert element_type["path"].endswith("FakeElement") + assert element_type["args"] == ["q0"] + assert config["q0"]["rxy"] == {"amp180": 0.12, "motzoi": 0.0} + + def test_an_edge_is_written_with_the_two_elements_it_joins(self): + """By keyword, not position. + + An edge's own name is not what its class is constructed from — the pair it + joins is. Named rather than positional because qblox's edges are pydantic + models and pydantic takes no positional arguments at all, so a file written + with them parses and then fails to instantiate. + """ + config = serialise_device(_device()) + element_type = config["q0_q1"][ELEMENT_TYPE_PROP] + + assert element_type["args"] == [] + assert element_type["kwargs"] == { + "parent_element_name": "q0", + "child_element_name": "q1", } - ) - device._elements["q0"].submodules["rxy"].parameters["broken"] = Unreadable() - - config = serialise_device(device) - assert config["q0"]["rxy"] == {"amp180": 0.1} - - -def test_an_object_valued_parameter_is_skipped(): - device = FakeDevice( - elements={ - "q0": FakeElement("q0", submodules={"rxy": FakeSubmodule(amp180=0.1)}) - } - ) - device._elements["q0"].submodules["rxy"].parameters["obj"] = FakeParameter(object()) - - assert "obj" not in serialise_device(device)["q0"]["rxy"] - - -def test_the_identity_parameter_is_not_calibration(): - device = FakeDevice( - elements={"q0": FakeElement("q0", parameters={"IDN": FakeParameter("acme")})} - ) - assert "IDN" not in serialise_device(device)["q0"] - - -def test_lists_of_scalars_survive(): - device = FakeDevice( - elements={"q0": FakeElement("q0", submodules={"p": FakeSubmodule(ports=["a"])})} - ) - assert serialise_device(device)["q0"]["p"]["ports"] == ["a"] - - -# --- writing ------------------------------------------------------------------ - - -def test_a_write_produces_plain_safe_yaml(tmp_path): - """yaml.dump of live objects emits tags the loader's safe_load refuses.""" - path = tmp_path / "device.yml" - save_device_config(_device(), path) - text = path.read_text() - assert "!!python" not in text - assert yaml.safe_load(text)["q0"]["rxy"]["amp180"] == 0.12 - - -def test_the_previous_file_is_kept(tmp_path): - path = tmp_path / "device.yml" - path.write_text("q0: {}\n") - save_device_config(_device(), path) - - backup = path.with_suffix(path.suffix + BACKUP_SUFFIX) - assert backup.exists() - assert backup.read_text() == "q0: {}\n" - - -def test_the_backup_can_be_restored(tmp_path): - path = tmp_path / "device.yml" - path.write_text("original\n") - save_device_config(_device(), path) - restore_backup(path) - - assert path.read_text() == "original\n" - - -def test_restoring_without_a_backup_is_an_error(tmp_path): - with pytest.raises(PersistenceError, match="no backup"): - restore_backup(tmp_path / "device.yml") - - -def test_the_backup_can_be_declined(tmp_path): - path = tmp_path / "device.yml" - path.write_text("original\n") - save_device_config(_device(), path, keep_backup=False) - - assert not path.with_suffix(path.suffix + BACKUP_SUFFIX).exists() - - -def test_an_empty_device_is_refused(tmp_path): - """Writing an empty config would leave the process driver with no device.""" - path = tmp_path / "device.yml" - path.write_text("original\n") - - with pytest.raises(PersistenceError, match="empty device config"): - save_device_config(FakeDevice(elements={}), path) - assert path.read_text() == "original\n" - - -def test_a_failed_write_leaves_the_original_untouched_and_no_litter( - tmp_path, monkeypatch -): - """Nothing is replaced until the candidate has passed verification.""" - path = tmp_path / "device.yml" - path.write_text("original\n") - - monkeypatch.setattr( - "qpi_driver.tuners.utils.persistence._verify_loads", - _raise_persistence_error, - ) - with pytest.raises(PersistenceError): - save_device_config(_device(), path) - - assert path.read_text() == "original\n" - assert list(tmp_path.glob("*.tmp")) == [] + def test_an_element_is_still_written_with_its_own_name(self): + """Only edges move to keywords; a qubit is constructed from its name.""" + config = serialise_device(_device()) + assert config["q0"][ELEMENT_TYPE_PROP]["args"] == ["q0"] + assert config["q0"][ELEMENT_TYPE_PROP]["kwargs"] == {} + + def test_structural_fields_are_not_written_as_calibration(self): + """`edge_type` and the endpoints are already in `element_type`. + + qblox's elements are pydantic models carrying both as ordinary fields, so + they come back looking like calibration. Writing them out makes the loader + try to *assign* them to a constructed object, which its discriminated fields + refuse — and the file it just wrote no longer loads. + """ + config = serialise_device(_device()) + for name, element in config.items(): + for structural in ( + "edge_type", + "parent_element_name", + "child_element_name", + ): + assert structural not in element, ( + f"{name} wrote {structural} as a calibration parameter" + ) + + def test_an_unreadable_parameter_is_skipped_not_stringified(self): + """str() of a live object round-trips into a numeric parameter as a string.""" + device = FakeDevice( + elements={ + "q0": FakeElement( + "q0", submodules={"rxy": FakeSubmodule(amp180=0.1)}, parameters={} + ) + } + ) + device._elements["q0"].submodules["rxy"].parameters["broken"] = Unreadable() + + config = serialise_device(device) + assert config["q0"]["rxy"] == {"amp180": 0.1} + + def test_an_object_valued_parameter_is_skipped(self): + device = FakeDevice( + elements={ + "q0": FakeElement("q0", submodules={"rxy": FakeSubmodule(amp180=0.1)}) + } + ) + device._elements["q0"].submodules["rxy"].parameters["obj"] = FakeParameter( + object() + ) + + assert "obj" not in serialise_device(device)["q0"]["rxy"] + + def test_the_identity_parameter_is_not_calibration(self): + device = FakeDevice( + elements={ + "q0": FakeElement("q0", parameters={"IDN": FakeParameter("acme")}) + } + ) + assert "IDN" not in serialise_device(device)["q0"] + + def test_lists_of_scalars_survive(self): + device = FakeDevice( + elements={ + "q0": FakeElement("q0", submodules={"p": FakeSubmodule(ports=["a"])}) + } + ) + assert serialise_device(device)["q0"]["p"]["ports"] == ["a"] def _raise_persistence_error(*_args, **_kwargs): raise PersistenceError("verification failed") -def test_verification_rejects_an_element_with_no_type(tmp_path): - candidate = tmp_path / "candidate.yml" - candidate.write_text("q0:\n rxy:\n amp180: 0.1\n") - - with pytest.raises(PersistenceError, match=ELEMENT_TYPE_PROP): - _verify_loads(candidate, {"q0": {"rxy": {"amp180": 0.1}}}) - - -def test_verification_rejects_an_unusable_class_path(tmp_path): - candidate = tmp_path / "candidate.yml" - expected = {"q0": {ELEMENT_TYPE_PROP: {"path": "nodots", "args": ["q0"]}}} - candidate.write_text(yaml.safe_dump(expected)) - - with pytest.raises(PersistenceError, match="unusable"): - _verify_loads(candidate, expected) - - -def test_verification_rejects_a_file_that_reads_back_differently(tmp_path): - candidate = tmp_path / "candidate.yml" - candidate.write_text("q0: {}\n") - - with pytest.raises(PersistenceError, match="does not read back as written"): - _verify_loads(candidate, {"q0": {"rxy": {"amp180": 0.1}}}) +class TestWriting: + """Writing a device config to disk: the backup it keeps, and the checks a write cannot skip.""" + def test_a_write_produces_plain_safe_yaml(self, tmp_path): + """yaml.dump of live objects emits tags the loader's safe_load refuses.""" + path = tmp_path / "device.yml" + save_device_config(_device(), path) -def test_verification_rejects_a_file_that_is_not_safe_yaml(tmp_path): - """yaml.dump of live objects emits exactly this, and safe_load refuses it.""" - candidate = tmp_path / "candidate.yml" - candidate.write_text("q0: !!python/object/apply:os.system ['echo pwned']\n") - - with pytest.raises(PersistenceError, match="does not parse as safe YAML"): - _verify_loads(candidate, {"q0": {}}) - + text = path.read_text() + assert "!!python" not in text + assert yaml.safe_load(text)["q0"]["rxy"]["amp180"] == 0.12 -def test_nan_is_not_treated_as_corruption(): - """An uncalibrated qcodes parameter reads as NaN, and NaN never equals itself.""" - assert _equivalent({"a": float("nan")}, {"a": float("nan")}) - assert _equivalent([1.0, float("nan")], [1.0, float("nan")]) - assert not _equivalent({"a": 1.0}, {"a": 2.0}) - assert not _equivalent({"a": 1.0}, {"b": 1.0}) - assert not _equivalent([1.0], [1.0, 2.0]) + def test_the_previous_file_is_kept(self, tmp_path): + path = tmp_path / "device.yml" + path.write_text("q0: {}\n") + save_device_config(_device(), path) + backup = path.with_suffix(path.suffix + BACKUP_SUFFIX) + assert backup.exists() + assert backup.read_text() == "q0: {}\n" -def test_a_device_with_nan_parameters_still_writes(tmp_path): - device = FakeDevice( - elements={ - "q0": FakeElement("q0", submodules={"p": FakeSubmodule(x=float("nan"))}) - } - ) - save_device_config(device, tmp_path / "device.yml") - assert (tmp_path / "device.yml").exists() + def test_the_backup_can_be_restored(self, tmp_path): + path = tmp_path / "device.yml" + path.write_text("original\n") + save_device_config(_device(), path) + restore_backup(path) + + assert path.read_text() == "original\n" + + def test_restoring_without_a_backup_is_an_error(self, tmp_path): + with pytest.raises(PersistenceError, match="no backup"): + restore_backup(tmp_path / "device.yml") + + def test_the_backup_can_be_declined(self, tmp_path): + path = tmp_path / "device.yml" + path.write_text("original\n") + save_device_config(_device(), path, keep_backup=False) + + assert not path.with_suffix(path.suffix + BACKUP_SUFFIX).exists() + + def test_an_empty_device_is_refused(self, tmp_path): + """Writing an empty config would leave the process driver with no device.""" + path = tmp_path / "device.yml" + path.write_text("original\n") + + with pytest.raises(PersistenceError, match="empty device config"): + save_device_config(FakeDevice(elements={}), path) + assert path.read_text() == "original\n" + + def test_a_failed_write_leaves_the_original_untouched_and_no_litter( + self, tmp_path, monkeypatch + ): + """Nothing is replaced until the candidate has passed verification.""" + path = tmp_path / "device.yml" + path.write_text("original\n") + + monkeypatch.setattr( + "qpi_driver.tuners.utils.persistence._verify_loads", + _raise_persistence_error, + ) + with pytest.raises(PersistenceError): + save_device_config(_device(), path) + + assert path.read_text() == "original\n" + assert list(tmp_path.glob("*.tmp")) == [] + + def test_verification_rejects_an_element_with_no_type(self, tmp_path): + candidate = tmp_path / "candidate.yml" + candidate.write_text("q0:\n rxy:\n amp180: 0.1\n") + + with pytest.raises(PersistenceError, match=ELEMENT_TYPE_PROP): + _verify_loads(candidate, {"q0": {"rxy": {"amp180": 0.1}}}) + + def test_verification_rejects_an_unusable_class_path(self, tmp_path): + candidate = tmp_path / "candidate.yml" + expected = {"q0": {ELEMENT_TYPE_PROP: {"path": "nodots", "args": ["q0"]}}} + candidate.write_text(yaml.safe_dump(expected)) + + with pytest.raises(PersistenceError, match="unusable"): + _verify_loads(candidate, expected) + + def test_verification_rejects_a_file_that_reads_back_differently(self, tmp_path): + candidate = tmp_path / "candidate.yml" + candidate.write_text("q0: {}\n") + + with pytest.raises(PersistenceError, match="does not read back as written"): + _verify_loads(candidate, {"q0": {"rxy": {"amp180": 0.1}}}) + + def test_verification_rejects_a_file_that_is_not_safe_yaml(self, tmp_path): + """yaml.dump of live objects emits exactly this, and safe_load refuses it.""" + candidate = tmp_path / "candidate.yml" + candidate.write_text("q0: !!python/object/apply:os.system ['echo pwned']\n") + + with pytest.raises(PersistenceError, match="does not parse as safe YAML"): + _verify_loads(candidate, {"q0": {}}) + + def test_nan_is_not_treated_as_corruption(self): + """An uncalibrated qcodes parameter reads as NaN, and NaN never equals itself.""" + assert _equivalent({"a": float("nan")}, {"a": float("nan")}) + assert _equivalent([1.0, float("nan")], [1.0, float("nan")]) + assert not _equivalent({"a": 1.0}, {"a": 2.0}) + assert not _equivalent({"a": 1.0}, {"b": 1.0}) + assert not _equivalent([1.0], [1.0, 2.0]) + + def test_a_device_with_nan_parameters_still_writes(self, tmp_path): + device = FakeDevice( + elements={ + "q0": FakeElement("q0", submodules={"p": FakeSubmodule(x=float("nan"))}) + } + ) + save_device_config(device, tmp_path / "device.yml") + assert (tmp_path / "device.yml").exists() diff --git a/qpi-driver/py/tests/test_physics_simulation.py b/qpi-driver/py/tests/test_physics_simulation.py index 50381bd2..28f8985a 100644 --- a/qpi-driver/py/tests/test_physics_simulation.py +++ b/qpi-driver/py/tests/test_physics_simulation.py @@ -30,7 +30,7 @@ pytest.importorskip("scqubits", reason="needs the [sim] extra") qutip = pytest.importorskip("qutip", reason="needs the [sim] extra") -from tests.fixtures.simulation import ( # noqa: E402 +from tests.utils.simulation import ( # noqa: E402 GHZ, StubBackend, TransmonSimulator, @@ -57,9 +57,6 @@ def routine(name: str): return next(r for r in all_routines() if r.name == name) -# --- the simulator itself ---------------------------------------------------- - - def test_the_simulated_transmon_is_a_plausible_one(simulator): """Check the model before trusting anything fitted from it. @@ -71,328 +68,311 @@ def test_the_simulated_transmon_is_a_plausible_one(simulator): ) -# --- the readout resonator ---------------------------------------------------- -# -# The two resonator routines are exercised end to end in `test_calibration_loop`, -# where they sweep a compiled schedule. What belongs here is the model underneath: -# these are the properties those routines are entitled to assume, and each one is -# a number a fit of the simulated data has to come back with. - - @pytest.fixture def resonator(simulator): return simulator.resonator("q0", configured_ghz=6.0) -def test_the_resonator_response_is_lorentzian_in_its_own_linewidth(resonator): - """Half power at half a linewidth away — which is what `kappa` *means*. - - `fit_resonator_spectroscopy` fits a Lorentzian and reports its width as the - linewidth, so if the model's response were some other lineshape the routine - would report a number that is not the parameter it names. +class TestTheReadoutResonator: + """The two resonator routines are exercised end to end in `test_calibration_loop`, + where they sweep a compiled schedule. What belongs here is the model underneath: + these are the properties those routines are entitled to assume, and each one is + a number a fit of the simulated data has to come back with. """ - centre = resonator.resonance_ghz(amplitude=0.0) - half = resonator.linewidth_ghz / 2.0 - - assert resonator.response(centre, 0.0) == pytest.approx(1.0) - assert resonator.response(centre + half, 0.0) == pytest.approx(0.5) - assert resonator.response(centre - half, 0.0) == pytest.approx(0.5) - # And far off it is gone, rather than merely smaller: a readout at the wrong - # frequency has to lose the signal, not attenuate it. - assert resonator.response(centre + 20 * half, 0.0) < 0.01 - - -def test_readout_power_walks_the_resonance_from_dressed_to_bare(resonator): - """Monotone, and bounded by one dispersive shift — which is punchout. - - Monotone because `fit_punchout` reads the crossing of the halfway point, and a - curve that wandered would give it several. Bounded because the pull it is - watching collapse is the qubit's, and there is only one of those to lose. - """ - amplitudes = np.linspace(0.0, 4.0, 40) - frequencies = np.array([resonator.resonance_ghz(a) for a in amplitudes]) - - assert np.all(np.diff(frequencies) <= 0), "the resonance should only walk down" - total = float(frequencies[0] - frequencies[-1]) - shift = resonator.dispersive_shift_ghz - assert 0.95 * shift < total < shift - # Half of it by the crossover amplitude, which is what names that parameter. - midpoint = resonator.resonance_ghz(resonator.punchout_amplitude) - assert midpoint == pytest.approx( - frequencies[0] - resonator.dispersive_shift_ghz / 2 - ) - -def test_the_readout_response_scales_with_the_power_it_is_driven_with(resonator): - """More power buys more signal, and punch-through is what it costs. - - Without the first half there is nothing to trade: a response that ignored the - drive amplitude would make readout power purely harmful, since the only thing - left for it to do is collapse the dispersive pull. Then the optimum power is - always the smallest one, and `readout_amplitude_two_state` has no question to - answer. - - The scaling lives in the coordinator's amplifier chain rather than here, so what - this asserts is the other half — that the pull really does collapse, and that - the two effects therefore pull opposite ways. - """ - strong, weak = 1.4, 0.2 - assert resonator.reflection( - resonator.resonance_ghz(strong, 0), strong, 0 - ) == pytest.approx(resonator.reflection(resonator.resonance_ghz(weak, 0), weak, 0)) - - def gap(amplitude: float) -> float: - return abs( - resonator.resonance_ghz(amplitude, 0) - - resonator.resonance_ghz(amplitude, 1) + def test_the_resonator_response_is_lorentzian_in_its_own_linewidth(self, resonator): + """Half power at half a linewidth away — which is what `kappa` *means*. + + `fit_resonator_spectroscopy` fits a Lorentzian and reports its width as the + linewidth, so if the model's response were some other lineshape the routine + would report a number that is not the parameter it names. + """ + centre = resonator.resonance_ghz(amplitude=0.0) + half = resonator.linewidth_ghz / 2.0 + + assert resonator.response(centre, 0.0) == pytest.approx(1.0) + assert resonator.response(centre + half, 0.0) == pytest.approx(0.5) + assert resonator.response(centre - half, 0.0) == pytest.approx(0.5) + # And far off it is gone, rather than merely smaller: a readout at the wrong + # frequency has to lose the signal, not attenuate it. + assert resonator.response(centre + 20 * half, 0.0) < 0.01 + + def test_readout_power_walks_the_resonance_from_dressed_to_bare(self, resonator): + """Monotone, and bounded by one dispersive shift — which is punchout. + + Monotone because `fit_punchout` reads the crossing of the halfway point, and a + curve that wandered would give it several. Bounded because the pull it is + watching collapse is the qubit's, and there is only one of those to lose. + """ + amplitudes = np.linspace(0.0, 4.0, 40) + frequencies = np.array([resonator.resonance_ghz(a) for a in amplitudes]) + + assert np.all(np.diff(frequencies) <= 0), "the resonance should only walk down" + total = float(frequencies[0] - frequencies[-1]) + shift = resonator.dispersive_shift_ghz + assert 0.95 * shift < total < shift + # Half of it by the crossover amplitude, which is what names that parameter. + midpoint = resonator.resonance_ghz(resonator.punchout_amplitude) + assert midpoint == pytest.approx( + frequencies[0] - resonator.dispersive_shift_ghz / 2 ) - assert gap(strong) < 0.5 * gap(weak), ( - "the states should be harder to tell apart at high power, or there is no " - "cost to turning it up" - ) - - -def test_a_chip_that_states_its_resonators_is_believed(simulator): - """Otherwise the resonator sits wherever the readout clock is configured. - - Both halves matter. The fallback is what lets `is_simulated` work against any - device file — a resonator where the config says means spectroscopy confirms it. - The override is what lets a test put one somewhere the config does not expect, - without which the routine cannot come out wrong. - """ - import dataclasses - - stated = dataclasses.replace(simulator, resonator_frequencies_ghz={"q0": 6.004}) - - assert stated.resonator("q0", configured_ghz=6.0).frequency_ghz == 6.004 - assert stated.resonator("q1", configured_ghz=6.01).frequency_ghz == 6.01 - + def test_the_readout_response_scales_with_the_power_it_is_driven_with( + self, resonator + ): + """More power buys more signal, and punch-through is what it costs. + + Without the first half there is nothing to trade: a response that ignored the + drive amplitude would make readout power purely harmful, since the only thing + left for it to do is collapse the dispersive pull. Then the optimum power is + always the smallest one, and `readout_amplitude_two_state` has no question to + answer. + + The scaling lives in the coordinator's amplifier chain rather than here, so what + this asserts is the other half — that the pull really does collapse, and that + the two effects therefore pull opposite ways. + """ + strong, weak = 1.4, 0.2 + assert resonator.reflection( + resonator.resonance_ghz(strong, 0), strong, 0 + ) == pytest.approx( + resonator.reflection(resonator.resonance_ghz(weak, 0), weak, 0) + ) -# --- spectroscopy ------------------------------------------------------------ + def gap(amplitude: float) -> float: + return abs( + resonator.resonance_ghz(amplitude, 0) + - resonator.resonance_ghz(amplitude, 1) + ) + assert gap(strong) < 0.5 * gap(weak), ( + "the states should be harder to tell apart at high power, or there is no " + "cost to turning it up" + ) -def test_qubit_spectroscopy_finds_the_transmons_real_f01(simulator): - """The line comes from a driven, damped steady state — not from a Lorentzian. + def test_a_chip_that_states_its_resonators_is_believed(self, simulator): + """Otherwise the resonator sits wherever the readout clock is configured. - 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. - """ - device = device_for(simulator) - spectroscopy = routine("qubit_spectroscopy") - config = RoutineConfig(params={"span": 30e6, "points": 61}) + Both halves matter. The fallback is what lets `is_simulated` work against any + device file — a resonator where the config says means spectroscopy confirms it. + The override is what lets a test put one somewhere the config does not expect, + without which the routine cannot come out wrong. + """ + import dataclasses - spectroscopy.build_schedule("q0", device, config, StubBackend()) - acquisition = simulator.qubit_spectroscopy( - spectroscopy._frequencies, spectroscopy._amplitudes - ) - fitted = spectroscopy.analyse(acquisition, "q0", device, config) - - 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._amplitudes - - # Applying it moves the device onto the true frequency. - spectroscopy.apply(device, "q0", fitted) - assert device.get_element("q0").clock_freqs.f01 == pytest.approx( - simulator.f01 * GHZ, abs=5e4 - ) + stated = dataclasses.replace(simulator, resonator_frequencies_ghz={"q0": 6.004}) + assert stated.resonator("q0", configured_ghz=6.0).frequency_ghz == 6.004 + assert stated.resonator("q1", configured_ghz=6.01).frequency_ghz == 6.01 -def test_scanning_the_wrong_window_cannot_invent_the_right_answer(simulator): - """A sweep that does not bracket the line gives a bounded wrong answer, or none. - This is the common mistake on hardware, and it is worth being precise about - what the routine guarantees. There is no direct check for "you scanned the - wrong range" — the routine cannot know where the qubit was supposed to be. - Two things bound the damage instead, and either is an acceptable outcome: +class TestSpectroscopy: + """Qubit spectroscopy against a simulator whose f01 is deliberately off, and what a scan of the wrong window finds instead.""" - - The fit refuses. Flat noise fits a spurious Lorentzian, but a spurious one - is narrow, and a line narrower than the sweep's own step size is one that - was never measured — which is what the resolution guard rejects. - - The answer is confined to the window actually scanned, so the routine can - 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. - """ - spectroscopy = routine("qubit_spectroscopy") - device = device_for(simulator) - offset = 500e6 # nowhere near the real line - config = RoutineConfig( - params={ - "centre_frequency": simulator.f01 * GHZ + offset, - "span": 20e6, - "points": 41, - } - ) + def test_qubit_spectroscopy_finds_the_transmons_real_f01(self, simulator): + """The line comes from a driven, damped steady state — not from a Lorentzian. - spectroscopy.build_schedule("q0", device, config, StubBackend()) - acquisition = simulator.qubit_spectroscopy(spectroscopy._frequencies) - scanned = spectroscopy._frequencies + 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. + """ + device = device_for(simulator) + spectroscopy = routine("qubit_spectroscopy") + config = RoutineConfig(params={"span": 30e6, "points": 61}) - try: + spectroscopy.build_schedule("q0", device, config, StubBackend()) + acquisition = simulator.qubit_spectroscopy( + spectroscopy._frequencies, spectroscopy._amplitudes + ) fitted = spectroscopy.analyse(acquisition, "q0", device, config) - except (FitError, RoutineError): - return # refusing outright is the other acceptable outcome - - assert min(scanned) <= fitted["clock_freq_01"] <= max(scanned), ( - "the fit escaped the window it measured" - ) - assert abs(fitted["clock_freq_01"] - simulator.f01 * GHZ) > offset / 2, ( - "a window 500 MHz away should not somehow land on the true f01" - ) - - -# --- time-domain routines ---------------------------------------------------- - - -def test_rabi_finds_the_pi_pulse_from_simulated_dynamics(simulator): - """The oscillation emerges from integrating the drive, not from a cosine.""" - 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) - - # 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(simulator): - """The decay comes from a collapse operator, not from an exponential.""" - 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) - - # 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 - # rather than merely satisfied. - assert fitted["t1"] == pytest.approx(simulator.t1_ns * 1e-9, rel=0.02) - - -def test_t2_echo_recovers_the_simulated_dephasing_time(simulator): - """A Hahn echo, evolved through both halves with the refocusing pulse between.""" - 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) - - assert fitted["t2"] == pytest.approx(simulator.t2_ns * 1e-9, rel=0.05) - - -def test_ramsey_measures_the_deliberate_detuning_and_reports_no_residual(simulator): - """The fringe is the artificial detuning; the qubit itself is on resonance. - - 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. - """ - ramsey = routine("ramsey") - device = device_for(simulator) - detuning = 1e6 - config = RoutineConfig( - params={ - "delays": list(np.linspace(4e-9, 6e-6, 61)), - "artificial_detuning": detuning, - } - ) - - ramsey.build_schedule("q0", device, config, StubBackend()) - acquisition = simulator.ramsey(ramsey._delays, detuning) - fitted = ramsey.analyse(acquisition, "q0", device, config) - - assert fitted["fringe_frequency"] == pytest.approx(detuning, rel=0.01) - # The qubit is on resonance, so the residual should be kHz, not a - # fraction of the deliberate detuning. - assert abs(fitted["detuning"]) < 5e3 + 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._amplitudes + # Applying it moves the device onto the true frequency. + spectroscopy.apply(device, "q0", fitted) + assert device.get_element("q0").clock_freqs.f01 == pytest.approx( + simulator.f01 * GHZ, abs=5e4 + ) -# --- randomized benchmarking -------------------------------------------------- + def test_scanning_the_wrong_window_cannot_invent_the_right_answer(self, simulator): + """A sweep that does not bracket the line gives a bounded wrong answer, or none. + + This is the common mistake on hardware, and it is worth being precise about + what the routine guarantees. There is no direct check for "you scanned the + wrong range" — the routine cannot know where the qubit was supposed to be. + Two things bound the damage instead, and either is an acceptable outcome: + + - The fit refuses. Flat noise fits a spurious Lorentzian, but a spurious one + is narrow, and a line narrower than the sweep's own step size is one that + was never measured — which is what the resolution guard rejects. + - The answer is confined to the window actually scanned, so the routine can + 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. + """ + spectroscopy = routine("qubit_spectroscopy") + device = device_for(simulator) + offset = 500e6 # nowhere near the real line + config = RoutineConfig( + params={ + "centre_frequency": simulator.f01 * GHZ + offset, + "span": 20e6, + "points": 41, + } + ) + spectroscopy.build_schedule("q0", device, config, StubBackend()) + acquisition = simulator.qubit_spectroscopy(spectroscopy._frequencies) + scanned = spectroscopy._frequencies -@pytest.mark.parametrize("error_per_gate", [0.004, 0.02]) -def test_rb_recovers_a_known_gate_error(simulator, error_per_gate): - """The whole RB stack against real unitaries. + try: + fitted = spectroscopy.analyse(acquisition, "q0", device, config) + except (FitError, RoutineError): + return # refusing outright is the other acceptable outcome - Sequences are composed from this package's own Clifford decomposition, - evolved as unitaries, closed with the computed recovery gate, and - depolarised by a known amount per Clifford. If the decomposition were wrong, - or the recovery gate did not invert the sequence, the survival probability - would not decay from 1 and the fit would not find the injected error. - """ - depths = [1, 2, 4, 8, 16, 32, 64] - # 60 sequences per depth, because resolving a sub-percent error needs the - # averaging — the simulator's shot noise falls as 1/sqrt(N), as it does on - # hardware, so this is the same trade an experimenter makes. - survival = simulator.randomized_benchmarking( - depths, circuits_per_depth=60, error_per_gate=error_per_gate - ) + assert min(scanned) <= fitted["clock_freq_01"] <= max(scanned), ( + "the fit escaped the window it measured" + ) + assert abs(fitted["clock_freq_01"] - simulator.f01 * GHZ) > offset / 2, ( + "a window 500 MHz away should not somehow land on the true f01" + ) - # It must actually decay: a broken recovery gate sits at chance from depth 1. - assert survival[0] > survival[-1] + 0.1 - fitted = fit_rb_decay(np.asarray(depths, dtype=float), survival) - # A depolarising channel of strength p per Clifford is an average gate error - # of p·(d−1)/d with d = 2. - assert fitted["error_per_gate"] == pytest.approx(error_per_gate * 0.5, rel=0.3) +class TestTimeDomainRoutines: + """Rabi, T1, T2 echo, and Ramsey, recovered from simulated dynamics rather than a closed-form fit target.""" + + def test_rabi_finds_the_pi_pulse_from_simulated_dynamics(self, simulator): + """The oscillation emerges from integrating the drive, not from a cosine.""" + 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) + + # 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.""" + 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) + + # 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 + # rather than merely satisfied. + assert fitted["t1"] == pytest.approx(simulator.t1_ns * 1e-9, rel=0.02) + + def test_t2_echo_recovers_the_simulated_dephasing_time(self, simulator): + """A Hahn echo, evolved through both halves with the refocusing pulse between.""" + 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) + + assert fitted["t2"] == pytest.approx(simulator.t2_ns * 1e-9, rel=0.05) + + def test_ramsey_measures_the_deliberate_detuning_and_reports_no_residual( + self, simulator + ): + """The fringe is the artificial detuning; the qubit itself is on resonance. + + 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. + """ + ramsey = routine("ramsey") + device = device_for(simulator) + detuning = 1e6 + config = RoutineConfig( + params={ + "delays": list(np.linspace(4e-9, 6e-6, 61)), + "artificial_detuning": detuning, + } + ) + ramsey.build_schedule("q0", device, config, StubBackend()) + acquisition = simulator.ramsey(ramsey._delays, detuning) + fitted = ramsey.analyse(acquisition, "q0", device, config) + + assert fitted["fringe_frequency"] == pytest.approx(detuning, rel=0.01) + # The qubit is on resonance, so the residual should be kHz, not a + # fraction of the deliberate detuning. + assert abs(fitted["detuning"]) < 5e3 + + +class TestRandomizedBenchmarking: + """The whole RB stack against real unitaries, composed from this package's own Clifford decomposition.""" + + @pytest.mark.parametrize("error_per_gate", [0.004, 0.02]) + def test_rb_recovers_a_known_gate_error(self, simulator, error_per_gate): + """The whole RB stack against real unitaries. + + Sequences are composed from this package's own Clifford decomposition, + evolved as unitaries, closed with the computed recovery gate, and + depolarised by a known amount per Clifford. If the decomposition were wrong, + or the recovery gate did not invert the sequence, the survival probability + would not decay from 1 and the fit would not find the injected error. + """ + depths = [1, 2, 4, 8, 16, 32, 64] + # 60 sequences per depth, because resolving a sub-percent error needs the + # averaging — the simulator's shot noise falls as 1/sqrt(N), as it does on + # hardware, so this is the same trade an experimenter makes. + survival = simulator.randomized_benchmarking( + depths, circuits_per_depth=60, error_per_gate=error_per_gate + ) -def test_rb_survival_collapses_to_chance_without_a_recovery_gate(simulator): - """The control for the test above. + # It must actually decay: a broken recovery gate sits at chance from depth 1. + assert survival[0] > survival[-1] + 0.1 - Without it, a fit returning a plausible number on flat data would look like a - pass. Dropping the recovery gate must destroy the return to |0>. - """ - import random + fitted = fit_rb_decay(np.asarray(depths, dtype=float), survival) + # A depolarising channel of strength p per Clifford is an average gate error + # of p·(d−1)/d with d = 2. + assert fitted["error_per_gate"] == pytest.approx(error_per_gate * 0.5, rel=0.3) - from qpi_driver.tuners.utils.clifford import ( - clifford_to_gates, - generate_clifford_sequence, - ) + def test_rb_survival_collapses_to_chance_without_a_recovery_gate(self, simulator): + """The control for the test above. - rng = random.Random(7) - ground = qutip.basis(2, 0) - survivals = [] - for depth in (1, 8, 64): - shots = [] - for _ in range(12): - # Deliberately no recovery gate appended. - sequence = generate_clifford_sequence(depth, rng) - state = ground * ground.dag() - for clifford in sequence: - for theta, phi in clifford_to_gates(clifford): - gate = _rxy_qobj(theta, phi) - state = gate * state * gate.dag() - shots.append(float(np.real((state * ground * ground.dag()).tr()))) - survivals.append(float(np.mean(shots))) - - assert max(survivals) < 0.9, ( - "without a recovery gate the sequences should not return to the ground " - f"state, but got {survivals}" - ) + Without it, a fit returning a plausible number on flat data would look like a + pass. Dropping the recovery gate must destroy the return to |0>. + """ + import random + from qpi_driver.tuners.utils.clifford import ( + clifford_to_gates, + generate_clifford_sequence, + ) -# --- two qubits --------------------------------------------------------------- -# -# A CZ is the first thing here that one transmon cannot have: it needs a joint -# register, because entanglement is not merely absent between two independent -# density matrices, it is unrepresentable. `qpi_driver.simulation.coupled` holds -# both in one 9-dimensional space and couples them. -# -# These tolerances are looser than the one-qubit ones above, and the reason is -# structural rather than numerical: the exchange coupling and the -# flux-to-detuning curve are numbers chosen to describe a plausible coupler, not -# constants of a Cooper-pair box. See the module docstring of `coupled`, and -# RFC 0004 §7. + rng = random.Random(7) + ground = qutip.basis(2, 0) + survivals = [] + for depth in (1, 8, 64): + shots = [] + for _ in range(12): + # Deliberately no recovery gate appended. + sequence = generate_clifford_sequence(depth, rng) + state = ground * ground.dag() + for clifford in sequence: + for theta, phi in clifford_to_gates(clifford): + gate = _rxy_qobj(theta, phi) + state = gate * state * gate.dag() + shots.append(float(np.real((state * ground * ground.dag()).tr()))) + survivals.append(float(np.mean(shots))) + + assert max(survivals) < 0.9, ( + "without a recovery gate the sequences should not return to the ground " + f"state, but got {survivals}" + ) @pytest.fixture(scope="module") @@ -413,7 +393,7 @@ def chevron_config(low, high, amplitude_points, duration_points): def two_qubit_tuner(coupled): - from tests.fixtures.simulation import SimulatedTuner + from tests.utils.simulation import SimulatedTuner return SimulatedTuner(qubits=("q0", "q1"), edges=("q0_q1",), coupled=coupled) @@ -427,157 +407,178 @@ def angle_apart(left: float, right: float) -> float: return abs((left - right + 180.0) % 360.0 - 180.0) -def test_the_coupled_pair_produces_a_cz_and_not_merely_a_rotation(coupled): - """The conditional phase is 180° because of the avoided crossing, not by fiat.""" - assert angle_apart(coupled.cz_conditional_phase(), 180.0) < 2.0 - - -def test_a_bell_state_comes_out_entangled(coupled): - from qpi_driver.simulation.coupled import ( - computational_block, - concurrence, - leakage, - ) - - density = coupled.bell_state() - block = computational_block(density) - populations = np.real(np.diag(block)) - - # (|00> + |11>)/sqrt(2): half in |00>, half in |11>, nothing in between. - assert populations[0] == pytest.approx(0.5, abs=0.05) - assert populations[3] == pytest.approx(0.5, abs=0.05) - assert populations[1] < 0.02 and populations[2] < 0.02 - assert concurrence(density) > 0.9, ( - "the pair should be very nearly maximally entangled" - ) - assert leakage(density) < 0.01, "a calibrated CZ should not leave |2> populated" - - -def test_a_cz_needs_the_coupling_to_exist(coupled): - """With the qubits uncoupled the same pulse is a pair of single-qubit phases. - - The guard against a simulator that would produce a plausible CZ out of - bookkeeping: switch off the one term that makes the gate possible and the - conditional phase must collapse. - """ - import dataclasses - - uncoupled = dataclasses.replace(coupled, g_mhz=0.0) - # The same pulse, not the same gate: with g=0 there is no exchange and so no - # round-trip duration to ask for. Holding the pulse fixed is what isolates - # the coupling as the cause. - phase = uncoupled.cz_conditional_phase(duration_ns=coupled.cz_duration_ns) - assert angle_apart(phase, 0.0) < 1.0 - - -def test_cz_chevron_finds_the_avoided_crossing(coupled): - 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) - fit = routine_.analyse(tuner.backend.run(schedule), "q0_q1", tuner.device, config) - - assert fit["cz_amplitude"] == pytest.approx(coupled.resonant_amplitude, rel=0.01) - assert fit["cz_duration"] / 1e-9 == pytest.approx(coupled.cz_duration_ns, rel=0.02) - +class TestTwoQubits: + """A CZ is the first thing here that one transmon cannot have: it needs a joint + register, because entanglement is not merely absent between two independent + density matrices, it is unrepresentable. `qpi_driver.simulation.coupled` holds + both in one 9-dimensional space and couples them. -def test_cz_chevron_refuses_a_sweep_that_stepped_over_the_crossing(coupled): - """The default amplitude range is far too coarse to resolve a few-MHz crossing. - - One step of the default grid moves the control by tens of MHz while the - avoided crossing is about 4.5 MHz wide, so the surface comes back flat to - 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. + These tolerances are looser than the one-qubit ones above, and the reason is + structural rather than numerical: the exchange coupling and the + flux-to-detuning curve are numbers chosen to describe a plausible coupler, not + constants of a Cooper-pair box. See the module docstring of `coupled`, and + RFC 0004 §7. """ - 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) - with pytest.raises(FitError, match="no flux amplitude drove"): - routine_.analyse(tuner.backend.run(schedule), "q0_q1", tuner.device, config) - - -def test_conditional_phase_recovers_the_gates_real_phase(coupled): - 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) - fit = routine_.analyse(tuner.backend.run(schedule), "q0_q1", tuner.device, config) + def test_the_coupled_pair_produces_a_cz_and_not_merely_a_rotation(self, coupled): + """The conditional phase is 180° because of the avoided crossing, not by fiat.""" + assert angle_apart(coupled.cz_conditional_phase(), 180.0) < 2.0 - assert angle_apart(fit["conditional_phase"], coupled.cz_conditional_phase()) < 2.0 - # A CZ this good needs almost no correcting, and the correction is the - # complement of the phase — in degrees, which is what Rxy takes. - assert fit["phase_correction"] == pytest.approx( - 180.0 - coupled.cz_conditional_phase(), abs=2.0 - ) - - -def test_conditional_phase_measures_a_gate_that_is_wrong(coupled): - """A miscalibrated CZ must be reported as needing exactly its own error back. + def test_a_bell_state_comes_out_entangled(self, coupled): + from qpi_driver.simulation.coupled import ( + computational_block, + concurrence, + leakage, + ) - The one-qubit tier found that a fit which cannot measure a *good* chip - 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. - """ - import dataclasses + density = coupled.bell_state() + block = computational_block(density) + populations = np.real(np.diag(block)) - detuned = dataclasses.replace(coupled, conditional_phase_offset_deg=40.0) - tuner = two_qubit_tuner(detuned) - config = RoutineConfig( - params={"phases": list(np.linspace(0.0, 360.0, 25)), "shots": 512} - ) - routine_ = routine("conditional_phase") + # (|00> + |11>)/sqrt(2): half in |00>, half in |11>, nothing in between. + assert populations[0] == pytest.approx(0.5, abs=0.05) + assert populations[3] == pytest.approx(0.5, abs=0.05) + assert populations[1] < 0.02 and populations[2] < 0.02 + assert concurrence(density) > 0.9, ( + "the pair should be very nearly maximally entangled" + ) + assert leakage(density) < 0.01, "a calibrated CZ should not leave |2> populated" + + def test_a_cz_needs_the_coupling_to_exist(self, coupled): + """With the qubits uncoupled the same pulse is a pair of single-qubit phases. + + The guard against a simulator that would produce a plausible CZ out of + bookkeeping: switch off the one term that makes the gate possible and the + conditional phase must collapse. + """ + import dataclasses + + uncoupled = dataclasses.replace(coupled, g_mhz=0.0) + # The same pulse, not the same gate: with g=0 there is no exchange and so no + # round-trip duration to ask for. Holding the pulse fixed is what isolates + # the coupling as the cause. + phase = uncoupled.cz_conditional_phase(duration_ns=coupled.cz_duration_ns) + assert angle_apart(phase, 0.0) < 1.0 + + def test_cz_chevron_finds_the_avoided_crossing(self, coupled): + 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) + fit = routine_.analyse( + tuner.backend.run(schedule), "q0_q1", tuner.device, config + ) - schedule = routine_.build_schedule("q0_q1", tuner.device, config, tuner.backend) - fit = routine_.analyse(tuner.backend.run(schedule), "q0_q1", tuner.device, config) + assert fit["cz_amplitude"] == pytest.approx( + coupled.resonant_amplitude, rel=0.01 + ) + assert fit["cz_duration"] / 1e-9 == pytest.approx( + coupled.cz_duration_ns, rel=0.02 + ) - assert angle_apart(fit["conditional_phase"], detuned.cz_conditional_phase()) < 2.0 - assert fit["phase_correction"] == pytest.approx(-40.0, abs=3.0) + def test_cz_chevron_refuses_a_sweep_that_stepped_over_the_crossing(self, coupled): + """The default amplitude range is far too coarse to resolve a few-MHz crossing. + + One step of the default grid moves the control by tens of MHz while the + avoided crossing is about 4.5 MHz wide, so the surface comes back flat to + 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. + """ + 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) + with pytest.raises(FitError, match="no flux amplitude drove"): + routine_.analyse(tuner.backend.run(schedule), "q0_q1", tuner.device, config) + + def test_conditional_phase_recovers_the_gates_real_phase(self, coupled): + 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) + fit = routine_.analyse( + tuner.backend.run(schedule), "q0_q1", tuner.device, config + ) -def test_the_conditional_phase_survives_the_controls_own_dynamical_phase(coupled): - """The single-qubit phase over a flux pulse is huge, and must cancel. + assert ( + angle_apart(fit["conditional_phase"], coupled.cz_conditional_phase()) < 2.0 + ) + # A CZ this good needs almost no correcting, and the correction is the + # complement of the phase — in degrees, which is what Rxy takes. + assert fit["phase_correction"] == pytest.approx( + 180.0 - coupled.cz_conditional_phase(), abs=2.0 + ) - Detuning the control by ~280 MHz for ~110 ns winds its phase through tens of - turns. That phase is common to both fringes and carries no information about - the coupling, so an analysis that does not cancel it reads the winding - instead of the gate. Lengthening the pulse changes the winding a great deal - and the conditional phase hardly at all, which is the discriminating test. - """ - import dataclasses + def test_conditional_phase_measures_a_gate_that_is_wrong(self, coupled): + """A miscalibrated CZ must be reported as needing exactly its own error back. - from qpi_driver.simulation.coupled import CoupledTransmons + The one-qubit tier found that a fit which cannot measure a *good* chip + 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. + """ + import dataclasses - phases = np.linspace(0.0, 360.0, 25) - longer: CoupledTransmons = dataclasses.replace(coupled) + detuned = dataclasses.replace(coupled, conditional_phase_offset_deg=40.0) + tuner = two_qubit_tuner(detuned) + config = RoutineConfig( + params={"phases": list(np.linspace(0.0, 360.0, 25)), "shots": 512} + ) + routine_ = routine("conditional_phase") - measured = [] - for duration in (coupled.cz_duration_ns, coupled.cz_duration_ns * 3): - fringes = longer.conditional_phase(phases, duration_ns=duration, averages=512) - from qpi_driver.tuners.fitting import fit_conditional_phase + schedule = routine_.build_schedule("q0_q1", tuner.device, config, tuner.backend) + fit = routine_.analyse( + tuner.backend.run(schedule), "q0_q1", tuner.device, config + ) - fit = fit_conditional_phase(phases, fringes[:25], fringes[25:]) - measured.append(fit["conditional_phase"]) assert ( - angle_apart( - fit["conditional_phase"], - longer.cz_conditional_phase(duration_ns=duration), - ) - < 3.0 + angle_apart(fit["conditional_phase"], detuned.cz_conditional_phase()) < 2.0 ) + assert fit["phase_correction"] == pytest.approx(-40.0, abs=3.0) + + def test_the_conditional_phase_survives_the_controls_own_dynamical_phase( + self, coupled + ): + """The single-qubit phase over a flux pulse is huge, and must cancel. + + Detuning the control by ~280 MHz for ~110 ns winds its phase through tens of + turns. That phase is common to both fringes and carries no information about + the coupling, so an analysis that does not cancel it reads the winding + instead of the gate. Lengthening the pulse changes the winding a great deal + and the conditional phase hardly at all, which is the discriminating test. + """ + import dataclasses + + from qpi_driver.simulation.coupled import CoupledTransmons + + phases = np.linspace(0.0, 360.0, 25) + longer: CoupledTransmons = dataclasses.replace(coupled) + + measured = [] + for duration in (coupled.cz_duration_ns, coupled.cz_duration_ns * 3): + fringes = longer.conditional_phase( + phases, duration_ns=duration, averages=512 + ) + from qpi_driver.tuners.fitting import fit_conditional_phase + + fit = fit_conditional_phase(phases, fringes[:25], fringes[25:]) + measured.append(fit["conditional_phase"]) + assert ( + angle_apart( + fit["conditional_phase"], + longer.cz_conditional_phase(duration_ns=duration), + ) + < 3.0 + ) - # Three round trips is three times the winding, and an odd multiple of a - # half-exchange either way — so a reader of the winding could not land near - # the gate's phase twice. - assert angle_apart(measured[0], 180.0) < 5.0 - - -# --- the acquisition, which is where the third level was being lost ------------ + # Three round trips is three times the winding, and an odd multiple of a + # half-exchange either way — so a reader of the winding could not land near + # the gate's phase twice. + assert angle_apart(measured[0], 180.0) < 5.0 def _acquisition(populations, clouds, **kwargs): @@ -602,74 +603,73 @@ def _coordinator(simulator, shots=4000): return coordinator -def test_a_shot_in_the_second_excited_state_lands_on_its_own_cloud(simulator): - """The acquisition used to carry one excited fraction, so it could not. - - A `|2>` population was reported as one of the other two — measured, and by the - widest possible margin: the dynamics put 98.5% of the population in `|2>` and the - acquisition returned `|0>`'s cloud. The clouds were already derived per level; - the sampler was not, and that is what made three-state readout unmeasurable while - the physics underneath was already right. - """ - clouds = (5 + 0j, 1 + 0j, 0 + 3j) - coordinator = _coordinator(simulator) - - for level, expected in enumerate(clouds): - shots = coordinator._single_shots( - _acquisition([1.0 if n == level else 0.0 for n in range(3)], clouds) - ) - assert complex(shots.mean()) == pytest.approx(expected, abs=0.05), ( - f"a shot prepared in |{level}> did not land on |{level}>'s cloud" - ) - - -def test_a_mixed_state_lands_shots_on_every_cloud_it_populates(simulator): - """And in the right proportions, which is what a leakage number is made of.""" - clouds = (5 + 0j, 1 + 0j, 0 + 3j) - coordinator = _coordinator(simulator, shots=8000) - shots = coordinator._single_shots(_acquisition([0.2, 0.5, 0.3], clouds)) - - nearest = np.argmin( - np.abs(np.asarray(shots)[:, None] - np.asarray(clouds)[None, :]), axis=1 - ) - fractions = [float(np.mean(nearest == level)) for level in range(3)] - assert fractions == pytest.approx([0.2, 0.5, 0.3], abs=0.03) - - -def test_an_averaged_acquisition_weights_every_level_it_populates(simulator): - """`|2>` used to be folded into `1 - P(excited)` and so read as `|0>`. +class TestTheAcquisitionWhichIsWhereTheThirdLevelWasBeingLost: + """The acquisition path for a chip with a real second excited state, and the shot noise that lands on each cloud it populates.""" - That is what made `f12_spectroscopy` look easy: transferring population into - `|2>` swung the averaged point across the whole readout axis, because `|2>` was - being reported at the ground cloud. Weighted correctly the same transfer is a - much smaller move, which is the honest contrast an EF measurement has. - """ - clouds = (5 + 0j, 1 + 0j, 0 + 3j) - coordinator = _coordinator(simulator, shots=100_000) - centre = coordinator._averaged(_acquisition([0.2, 0.5, 0.3], clouds)) + def test_a_shot_in_the_second_excited_state_lands_on_its_own_cloud(self, simulator): + """The acquisition used to carry one excited fraction, so it could not. - assert centre == pytest.approx(0.2 * 5 + 0.5 * 1 + 0.3 * 3j, abs=0.05) + A `|2>` population was reported as one of the other two — measured, and by the + widest possible margin: the dynamics put 98.5% of the population in `|2>` and the + acquisition returned `|0>`'s cloud. The clouds were already derived per level; + the sampler was not, and that is what made three-state readout unmeasurable while + the physics underneath was already right. + """ + clouds = (5 + 0j, 1 + 0j, 0 + 3j) + coordinator = _coordinator(simulator) + for level, expected in enumerate(clouds): + shots = coordinator._single_shots( + _acquisition([1.0 if n == level else 0.0 for n in range(3)], clouds) + ) + assert complex(shots.mean()) == pytest.approx(expected, abs=0.05), ( + f"a shot prepared in |{level}> did not land on |{level}>'s cloud" + ) -def test_the_draw_is_unchanged_for_a_chip_with_no_second_excited_state(simulator): - """The stream had to survive this change, or every number measured against it moves. - - Pi pulse amplitudes, coherence times and gate fidelities were all measured against - `rng.random(n) < P(excited)`. Drawing a level from a cumulative distribution - ordered `|1>` first, then `|0>`, consumes the same uniforms and makes the same - decision — so a test that fails afterwards is reporting physics rather than a - reshuffled random stream. - """ - populations = [0.3, 0.7, 0.0] - drawn = _coordinator(simulator)._draw_levels(_acquisition(populations, ())) - - expected = (np.random.default_rng(20260731).random(4000) < populations[1]).astype( - int - ) - assert np.array_equal(drawn, expected) - + def test_a_mixed_state_lands_shots_on_every_cloud_it_populates(self, simulator): + """And in the right proportions, which is what a leakage number is made of.""" + clouds = (5 + 0j, 1 + 0j, 0 + 3j) + coordinator = _coordinator(simulator, shots=8000) + shots = coordinator._single_shots(_acquisition([0.2, 0.5, 0.3], clouds)) -# --- the tunable coupler ------------------------------------------------------- + nearest = np.argmin( + np.abs(np.asarray(shots)[:, None] - np.asarray(clouds)[None, :]), axis=1 + ) + fractions = [float(np.mean(nearest == level)) for level in range(3)] + assert fractions == pytest.approx([0.2, 0.5, 0.3], abs=0.03) + + def test_an_averaged_acquisition_weights_every_level_it_populates(self, simulator): + """`|2>` used to be folded into `1 - P(excited)` and so read as `|0>`. + + That is what made `f12_spectroscopy` look easy: transferring population into + `|2>` swung the averaged point across the whole readout axis, because `|2>` was + being reported at the ground cloud. Weighted correctly the same transfer is a + much smaller move, which is the honest contrast an EF measurement has. + """ + clouds = (5 + 0j, 1 + 0j, 0 + 3j) + coordinator = _coordinator(simulator, shots=100_000) + centre = coordinator._averaged(_acquisition([0.2, 0.5, 0.3], clouds)) + + assert centre == pytest.approx(0.2 * 5 + 0.5 * 1 + 0.3 * 3j, abs=0.05) + + def test_the_draw_is_unchanged_for_a_chip_with_no_second_excited_state( + self, simulator + ): + """The stream had to survive this change, or every number measured against it moves. + + Pi pulse amplitudes, coherence times and gate fidelities were all measured against + `rng.random(n) < P(excited)`. Drawing a level from a cumulative distribution + ordered `|1>` first, then `|0>`, consumes the same uniforms and makes the same + decision — so a test that fails afterwards is reporting physics rather than a + reshuffled random stream. + """ + populations = [0.3, 0.7, 0.0] + drawn = _coordinator(simulator)._draw_levels(_acquisition(populations, ())) + + expected = ( + np.random.default_rng(20260731).random(4000) < populations[1] + ).astype(int) + assert np.array_equal(drawn, expected) @pytest.fixture @@ -679,87 +679,88 @@ def coupler(): return TunableCoupler() -def test_the_coupler_tunes_down_from_a_sweet_spot(coupler): - """Quadratically, and only downward — which is what a flux sweet spot means. - - The same shape `FLUX_CURVATURE_GHZ` gives a qubit, for the same reason: at the - sweet spot the first derivative of frequency with flux vanishes, so the leading - behaviour is second order and the sign cannot change. - """ - currents = np.linspace(0.0, 4e-3, 40) - frequencies = np.array([coupler.frequency_at(i) for i in currents]) - - assert frequencies[0] == coupler.frequency_ghz - assert np.all(np.diff(frequencies) < 0), "the coupler should only tune down" - # Symmetric in the sign of the current, because it is quadratic in it. - assert coupler.frequency_at(-2e-3) == pytest.approx(coupler.frequency_at(2e-3)) - - -def test_an_unbiased_coupler_pushes_a_qubit_nowhere(coupler, simulator): - """The push is measured from zero bias, and that is not a convenience. - - A qubit beside a coupler is always repelled; what a *bias* changes is by how - much. Defining the shift as the difference from the unbiased push keeps the - simulator's `f01` meaning what it always meant, so every expectation measured - before this model existed still holds for a chip whose couplers are unparked. - """ - assert coupler.push_ghz(simulator.f01, 0.0) == 0.0 - - -def test_the_push_runs_away_and_changes_sign_across_the_crossing(coupler, simulator): - """The anticrossing, which is the landmark `coupler_anticrossing` looks for. - - Level repulsion pushes a qubit *away* from the coupler, so a coupler above it - presses it down and a coupler below it lifts it up. Tuning through the qubit - therefore flips the sign, and the magnitude diverges on the way — that pair of - facts is the whole signature, and neither alone would identify a crossing. - """ - crossing = np.sqrt( - (coupler.frequency_ghz - simulator.f01) / coupler.curvature_ghz_per_a2 - ) - below = coupler.push_ghz(simulator.f01, crossing * 0.97) - above = coupler.push_ghz(simulator.f01, crossing * 1.02) - far = coupler.push_ghz(simulator.f01, crossing * 0.5) - - assert below < 0 and above > 0, "the push must change sign across the crossing" - assert abs(below) > 10 * abs(far), "and run away as the crossing is approached" - - -def test_a_parked_coupler_moves_the_qubit_the_simulator_reports(simulator): - """The plumbing, not the model: a current in the device config reaches the qubit. - - `bias.parking_current` has been carried, validated and applied since RFC 0004, - and nothing in the simulator responded to it — so a routine could write any - current at all and no measurement would contradict it. This is what makes the - bias measurable rather than merely stored. - """ - from qpi_driver.simulation.coordinator import SimulatedCoordinator - - unparked = SimulatedCoordinator(simulator) - parked = SimulatedCoordinator(simulator, parking_currents={"q1_q2": 2.5e-3}) - - assert unparked._qubit_frequency_hz("q1") == pytest.approx(simulator.f01 * GHZ) - # q1 is on the edge, so it moves; q0 is not, so it does not. - assert parked._qubit_frequency_hz("q1") != pytest.approx(simulator.f01 * GHZ) - assert parked._qubit_frequency_hz("q0") == pytest.approx(simulator.f01 * GHZ) - - -def test_a_missing_sim_extra_says_what_to_install(monkeypatch): - """Otherwise the first sign is a ModuleNotFoundError from inside a fit.""" - import importlib.util - - from qpi_driver.simulation import require_simulation_deps - - real = importlib.util.find_spec - monkeypatch.setattr( - importlib.util, - "find_spec", - lambda name, *a, **k: None if name == "scqubits" else real(name, *a, **k), - ) +class TestTheTunableCoupler: + """The tunable coupler's parking spot, and how a bias pushes a qubit's frequency around it.""" + + def test_the_coupler_tunes_down_from_a_sweet_spot(self, coupler): + """Quadratically, and only downward — which is what a flux sweet spot means. + + The same shape `FLUX_CURVATURE_GHZ` gives a qubit, for the same reason: at the + sweet spot the first derivative of frequency with flux vanishes, so the leading + behaviour is second order and the sign cannot change. + """ + currents = np.linspace(0.0, 4e-3, 40) + frequencies = np.array([coupler.frequency_at(i) for i in currents]) + + assert frequencies[0] == coupler.frequency_ghz + assert np.all(np.diff(frequencies) < 0), "the coupler should only tune down" + # Symmetric in the sign of the current, because it is quadratic in it. + assert coupler.frequency_at(-2e-3) == pytest.approx(coupler.frequency_at(2e-3)) + + def test_an_unbiased_coupler_pushes_a_qubit_nowhere(self, coupler, simulator): + """The push is measured from zero bias, and that is not a convenience. + + A qubit beside a coupler is always repelled; what a *bias* changes is by how + much. Defining the shift as the difference from the unbiased push keeps the + simulator's `f01` meaning what it always meant, so every expectation measured + before this model existed still holds for a chip whose couplers are unparked. + """ + assert coupler.push_ghz(simulator.f01, 0.0) == 0.0 + + def test_the_push_runs_away_and_changes_sign_across_the_crossing( + self, coupler, simulator + ): + """The anticrossing, which is the landmark `coupler_anticrossing` looks for. + + Level repulsion pushes a qubit *away* from the coupler, so a coupler above it + presses it down and a coupler below it lifts it up. Tuning through the qubit + therefore flips the sign, and the magnitude diverges on the way — that pair of + facts is the whole signature, and neither alone would identify a crossing. + """ + crossing = np.sqrt( + (coupler.frequency_ghz - simulator.f01) / coupler.curvature_ghz_per_a2 + ) + below = coupler.push_ghz(simulator.f01, crossing * 0.97) + above = coupler.push_ghz(simulator.f01, crossing * 1.02) + far = coupler.push_ghz(simulator.f01, crossing * 0.5) + + assert below < 0 and above > 0, "the push must change sign across the crossing" + assert abs(below) > 10 * abs(far), "and run away as the crossing is approached" + + def test_a_parked_coupler_moves_the_qubit_the_simulator_reports(self, simulator): + """The plumbing, not the model: a current in the device config reaches the qubit. + + `bias.parking_current` has been carried, validated and applied since RFC 0004, + and nothing in the simulator responded to it — so a routine could write any + current at all and no measurement would contradict it. This is what makes the + bias measurable rather than merely stored. + """ + from qpi_driver.simulation.coordinator import SimulatedCoordinator + + unparked = SimulatedCoordinator(simulator) + parked = SimulatedCoordinator(simulator, parking_currents={"q1_q2": 2.5e-3}) + + assert unparked._qubit_frequency_hz("q1") == pytest.approx(simulator.f01 * GHZ) + # q1 is on the edge, so it moves; q0 is not, so it does not. + assert parked._qubit_frequency_hz("q1") != pytest.approx(simulator.f01 * GHZ) + assert parked._qubit_frequency_hz("q0") == pytest.approx(simulator.f01 * GHZ) + + def test_a_missing_sim_extra_says_what_to_install(self, monkeypatch): + """Otherwise the first sign is a ModuleNotFoundError from inside a fit.""" + import importlib.util + + from qpi_driver.simulation import require_simulation_deps + + real = importlib.util.find_spec + monkeypatch.setattr( + importlib.util, + "find_spec", + lambda name, *a, **k: None if name == "scqubits" else real(name, *a, **k), + ) - with pytest.raises(ImportError) as excinfo: - require_simulation_deps() + with pytest.raises(ImportError) as excinfo: + require_simulation_deps() - message = str(excinfo.value) - assert "scqubits" in message - assert "qpi-driver[sim]" in message, "it has to name the extra, not the package" + message = str(excinfo.value) + assert "scqubits" in message + assert "qpi-driver[sim]" in message, "it has to name the extra, not the package" diff --git a/qpi-driver/py/tests/test_qblox.py b/qpi-driver/py/tests/test_qblox.py index 72660057..bc1dfc22 100644 --- a/qpi-driver/py/tests/test_qblox.py +++ b/qpi-driver/py/tests/test_qblox.py @@ -4,12 +4,8 @@ from qpi_driver.executors import resolve_executor from qpi_driver.executors.base import CircuitPayload, JobPayload -from .utils import ( - GATE_CONVERSION_CASES, - assert_gate_conversion_matches_qiskit, - load_json_fixture, - load_yaml_fixture, -) +from .utils.circuits import GATE_CONVERSION_CASES, assert_gate_conversion_matches_qiskit +from .utils.io import load_json_fixture, load_yaml_fixture _QUANTIFY_HARDWARE_CONFIG: dict = load_json_fixture("quantify.hardware.json") _QUANTIFY_DEVICE_CONFIG: dict = load_yaml_fixture("quantify.device.yml") diff --git a/qpi-driver/py/tests/test_quantify.py b/qpi-driver/py/tests/test_quantify.py index 09bc8ea8..f8127480 100644 --- a/qpi-driver/py/tests/test_quantify.py +++ b/qpi-driver/py/tests/test_quantify.py @@ -5,12 +5,8 @@ from qpi_driver.executors import resolve_executor from qpi_driver.executors.base import CircuitPayload, JobPayload -from .utils import ( - GATE_CONVERSION_CASES, - assert_gate_conversion_matches_qiskit, - load_json_fixture, - load_yaml_fixture, -) +from .utils.circuits import GATE_CONVERSION_CASES, assert_gate_conversion_matches_qiskit +from .utils.io import load_json_fixture, load_yaml_fixture _QUANTIFY_HARDWARE_CONFIG: dict = load_json_fixture("quantify.hardware.json") _QUANTIFY_DEVICE_CONFIG: dict = load_yaml_fixture("quantify.device.yml") diff --git a/qpi-driver/py/tests/utils/__init__.py b/qpi-driver/py/tests/utils/__init__.py new file mode 100644 index 00000000..310ca8c6 --- /dev/null +++ b/qpi-driver/py/tests/utils/__init__.py @@ -0,0 +1 @@ +"""Helpers shared across tests, grouped by what they do.""" diff --git a/qpi-driver/py/tests/utils.py b/qpi-driver/py/tests/utils/circuits.py similarity index 89% rename from qpi-driver/py/tests/utils.py rename to qpi-driver/py/tests/utils/circuits.py index 2d4fd8f3..9edd801b 100644 --- a/qpi-driver/py/tests/utils.py +++ b/qpi-driver/py/tests/utils/circuits.py @@ -1,27 +1,37 @@ -import json -from pathlib import Path +"""Comparing a gate-conversion function's output against qiskit's own unitary.""" + from typing import Any, Callable, Iterable, Sequence import numpy as np -import yaml from qiskit import QuantumCircuit from qiskit.quantum_info import Operator -_FIXTURES_PATH = Path(__file__).parent / "fixtures" - - -def load_json_fixture(fixture_relative_path: str) -> Any: - """Load a JSON fixture from the tests/fixtures directory.""" - full_path = _FIXTURES_PATH / fixture_relative_path - with open(full_path, "r") as f: - return json.load(f) - - -def load_yaml_fixture(fixture_relative_path: str) -> Any: - """Load a YAML fixture from the tests/fixtures directory.""" - full_path = _FIXTURES_PATH / fixture_relative_path - with open(full_path, "r") as f: - return yaml.safe_load(f) +# Every unitary gate branch handled by to_qblox_gates/to_quantify_gates, paired +# with a function that applies it to a fresh circuit. Reset/Measure/Delay/Barrier +# are excluded since they aren't fixed unitaries. +GATE_CONVERSION_CASES: list[tuple[str, int, Callable[[QuantumCircuit], None]]] = [ + ("x", 1, lambda qc: qc.x(0)), + ("y", 1, lambda qc: qc.y(0)), + ("z", 1, lambda qc: qc.z(0)), + ("h", 1, lambda qc: qc.h(0)), + ("s", 1, lambda qc: qc.s(0)), + ("sdg", 1, lambda qc: qc.sdg(0)), + ("t", 1, lambda qc: qc.t(0)), + ("tdg", 1, lambda qc: qc.tdg(0)), + ("sx", 1, lambda qc: qc.sx(0)), + ("sxdg", 1, lambda qc: qc.sxdg(0)), + ("rx", 1, lambda qc: qc.rx(0.37, 0)), + ("ry", 1, lambda qc: qc.ry(0.51, 0)), + ("rz", 1, lambda qc: qc.rz(0.63, 0)), + ("p", 1, lambda qc: qc.p(0.63, 0)), + ("u", 1, lambda qc: qc.u(0.3, 0.4, 0.5, 0)), + ("cx", 2, lambda qc: qc.cx(0, 1)), + ("cz", 2, lambda qc: qc.cz(0, 1)), + ("swap", 2, lambda qc: qc.swap(0, 1)), + ("crz", 2, lambda qc: qc.crz(0.63, 0, 1)), + ("cp", 2, lambda qc: qc.cp(0.63, 0, 1)), + ("ccx", 3, lambda qc: qc.ccx(0, 1, 2)), +] def operations_to_unitary( @@ -66,34 +76,6 @@ def qiskit_unitary(gate: Any) -> np.ndarray: return Operator(gate).reverse_qargs().data -# Every unitary gate branch handled by to_qblox_gates/to_quantify_gates, paired -# with a function that applies it to a fresh circuit. Reset/Measure/Delay/Barrier -# are excluded since they aren't fixed unitaries. -GATE_CONVERSION_CASES: list[tuple[str, int, Callable[[QuantumCircuit], None]]] = [ - ("x", 1, lambda qc: qc.x(0)), - ("y", 1, lambda qc: qc.y(0)), - ("z", 1, lambda qc: qc.z(0)), - ("h", 1, lambda qc: qc.h(0)), - ("s", 1, lambda qc: qc.s(0)), - ("sdg", 1, lambda qc: qc.sdg(0)), - ("t", 1, lambda qc: qc.t(0)), - ("tdg", 1, lambda qc: qc.tdg(0)), - ("sx", 1, lambda qc: qc.sx(0)), - ("sxdg", 1, lambda qc: qc.sxdg(0)), - ("rx", 1, lambda qc: qc.rx(0.37, 0)), - ("ry", 1, lambda qc: qc.ry(0.51, 0)), - ("rz", 1, lambda qc: qc.rz(0.63, 0)), - ("p", 1, lambda qc: qc.p(0.63, 0)), - ("u", 1, lambda qc: qc.u(0.3, 0.4, 0.5, 0)), - ("cx", 2, lambda qc: qc.cx(0, 1)), - ("cz", 2, lambda qc: qc.cz(0, 1)), - ("swap", 2, lambda qc: qc.swap(0, 1)), - ("crz", 2, lambda qc: qc.crz(0.63, 0, 1)), - ("cp", 2, lambda qc: qc.cp(0.63, 0, 1)), - ("ccx", 3, lambda qc: qc.ccx(0, 1, 2)), -] - - def assert_gate_conversion_matches_qiskit( to_gates_fn: Callable[..., Iterable[Any]], num_qubits: int, diff --git a/qpi-driver/py/tests/fixtures/half_imported_device.py b/qpi-driver/py/tests/utils/half_imported_device.py similarity index 100% rename from qpi-driver/py/tests/fixtures/half_imported_device.py rename to qpi-driver/py/tests/utils/half_imported_device.py diff --git a/qpi-driver/py/tests/utils/io.py b/qpi-driver/py/tests/utils/io.py new file mode 100644 index 00000000..7d2638aa --- /dev/null +++ b/qpi-driver/py/tests/utils/io.py @@ -0,0 +1,23 @@ +"""Loading fixture data files from tests/fixtures.""" + +import json +from pathlib import Path +from typing import Any + +import yaml + +_FIXTURES_PATH = Path(__file__).resolve().parent.parent / "fixtures" + + +def load_json_fixture(fixture_relative_path: str) -> Any: + """Load a JSON fixture from the tests/fixtures directory.""" + full_path = _FIXTURES_PATH / fixture_relative_path + with open(full_path, "r") as f: + return json.load(f) + + +def load_yaml_fixture(fixture_relative_path: str) -> Any: + """Load a YAML fixture from the tests/fixtures directory.""" + full_path = _FIXTURES_PATH / fixture_relative_path + with open(full_path, "r") as f: + return yaml.safe_load(f) diff --git a/qpi-driver/py/tests/fixtures/simulation.py b/qpi-driver/py/tests/utils/simulation.py similarity index 99% rename from qpi-driver/py/tests/fixtures/simulation.py rename to qpi-driver/py/tests/utils/simulation.py index 5b1170d3..006dd245 100644 --- a/qpi-driver/py/tests/fixtures/simulation.py +++ b/qpi-driver/py/tests/utils/simulation.py @@ -151,9 +151,6 @@ def device_for( ) -# --- the scheduler seam ------------------------------------------------------ - - class _Operation: """One operation as recorded on a schedule. @@ -291,8 +288,6 @@ def run(self, schedule: _Schedule) -> xr.Dataset: values = np.asarray(acquire(self, schedule), dtype=float) return xr.Dataset({"y0": ("acq_index", values)}) - # --- reading the sweep back off a schedule -------------------------------- - @staticmethod def _of_kind(schedule: _Schedule, kind: str) -> list[_Operation]: return [op for op in schedule.operations if op.kind == kind] diff --git a/scripts/_check_docs/__init__.py b/scripts/_check_docs/__init__.py new file mode 100644 index 00000000..e785449b --- /dev/null +++ b/scripts/_check_docs/__init__.py @@ -0,0 +1 @@ +"""Internals for ``scripts/check_docs.py``, one module per check it runs.""" diff --git a/scripts/_check_docs/cli_flags.py b/scripts/_check_docs/cli_flags.py new file mode 100644 index 00000000..941f03e2 --- /dev/null +++ b/scripts/_check_docs/cli_flags.py @@ -0,0 +1,101 @@ +"""Check 3: every ``qpi-driver`` flag a document names is one the CLI accepts.""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +from _check_docs.document import ROOT, Line, Report + +FLAG_RE = re.compile(r"(? set[str] | None: + """The long flags this SDK's CLI accepts, from its own --help. + + None when the CLI cannot be run here: an absent toolchain should skip the check + with a warning, not fail the build. COLUMNS is set wide because typer wraps + --help to the terminal, and a flag broken across two lines is a flag this would + not find. + """ + argv, cwd = CLI_INVOCATIONS[sdk] + env = { + **os.environ, + "COLUMNS": "400", + "TERM": "dumb", + "NO_COLOR": "1", + "PYTHONPATH": str(CLI_INVOCATIONS["py"][1]), + } + found: set[str] = set() + for subcommand in SUBCOMMANDS: + command = argv + ([subcommand] if subcommand else []) + ["--help"] + try: + out = subprocess.run( + command, cwd=cwd, capture_output=True, text=True, env=env + ) + except FileNotFoundError: + return None + if out.returncode != 0: + return None + found |= set(FLAG_RE.findall(out.stdout + out.stderr)) + + # A CLI that ran but told us almost nothing is not a CLI whose flags we can + # check against — it is a stub. `qpi_driver.cli` is importable from the + # source tree whether or not the `cli` extra is installed (PYTHONPATH above + # guarantees it), and without typer it prints a near-empty help and exits 0. + # Believing that would report every documented flag as removed, which is a + # wall of false failures rather than the one true statement "the CLI could + # not be run here". + if len(found) < MINIMUM_PLAUSIBLE_FLAGS: + return None + return found + + +def check_cli_flags( + path: Path, lines: list[Line], report: Report, accepted: set[str] +) -> None: + """Flags are attributed to the command they follow. + + A line is only checked from the last `qpi-driver` token onwards, so + `uv tool install --with mylab-devices "qpi-driver[cli]"` does not have `--with` + read as a driver flag. A command continued with a trailing backslash keeps the + attribution across the continuation lines. + """ + continuing = False + for line in lines: + code = line.code + if DRIVER_COMMAND_RE.search(code): + for match in DRIVER_COMMAND_RE.finditer(code): + segment = code[match.end() :] # the last occurrence wins + continuing = True + elif continuing: + segment = code + else: + continue + + for flag in FLAG_RE.findall(segment): + report.checked += 1 + if flag not in accepted: + report.fail( + path, + line.number, + f"`{flag}` is not a flag this SDK's qpi-driver accepts — it was " + f"renamed or removed. The CLI's own --help is the list.", + ) + continuing = code.rstrip().endswith("\\") diff --git a/scripts/_check_docs/document.py b/scripts/_check_docs/document.py new file mode 100644 index 00000000..e741cf24 --- /dev/null +++ b/scripts/_check_docs/document.py @@ -0,0 +1,96 @@ +"""Reading a document: its lines, its code spans, and where a failure is reported.""" + +import re +from dataclasses import dataclass, field +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +SKIP_MARKER = "" + +# A record of the past, not instructions for the present: a changelog quotes +# commands and paths that no longer work on purpose. +EXCLUDED = {"CHANGELOG.md"} + +INLINE_CODE_RE = re.compile(r"`([^`]+)`") + +# A shell comment is prose that happens to be inside a fence — "# the make targets it +# names" is not a command. Only a `#` that starts a token counts, so the `#` in +# `--device ./dist/my-device.js#MyExport` is left where it is. +SHELL_COMMENT_RE = re.compile(r"(?:(?<=\s)|^)#.*$") + + +@dataclass(frozen=True) +class Line: + """One line of a document, with what a check needs to know about where it is. + + ``code`` is the part of the line inside a fenced block or inline backticks — + empty for prose. Every check runs on ``code`` rather than the raw line, because + "we make the operation a value" is prose about the word *make*, not a promise + about a Makefile target. + """ + + number: int + raw: str + code: str + + +def read_lines(path: Path) -> list[Line]: + """The document's lines, skip-marked fences dropped, code spans extracted.""" + lines: list[Line] = [] + skip_next_fence = False + in_fence = False + in_skipped_fence = False + + for number, raw in enumerate(path.read_text().splitlines(), start=1): + stripped = raw.strip() + if stripped == SKIP_MARKER: + skip_next_fence = True + continue + if stripped.startswith("```"): + if in_skipped_fence: + in_skipped_fence, in_fence = False, False + elif in_fence: + in_fence = False + elif skip_next_fence: + in_skipped_fence, skip_next_fence = True, True + else: + in_fence = True + continue + if in_skipped_fence: + continue + code = raw if in_fence else " ".join(INLINE_CODE_RE.findall(raw)) + lines.append(Line(number, raw, SHELL_COMMENT_RE.sub("", code))) + return lines + + +def find_documents() -> list[Path]: + candidates = [ + *ROOT.glob("*.md"), + *ROOT.glob("docs/**/*.md"), + *ROOT.glob("qpi-driver/*/README.md"), + *ROOT.glob("qpi-driver/py/examples/*/README.md"), + *ROOT.glob("qpi-client/*/README.md"), + # Named individually because the symlink under docs/ that publishes each of + # these is skipped below: a glob that reaches only the symlink checks nothing. + # The dashboard README sat unchecked long enough to still be the Vite scaffold. + ROOT / "qpi-driver/py/qpi_driver/tuners/README.md", + ROOT / "qpi-ui/internal/dashboard/README.md", + ] + seen: dict[Path, Path] = {} + for path in candidates: + if path.is_symlink() or str(path.relative_to(ROOT)) in EXCLUDED: + continue + seen[path.resolve()] = path + return sorted(seen.values()) + + +@dataclass +class Report: + """Collected rather than raised, so one run names every problem.""" + + failures: list[str] = field(default_factory=list) + checked: int = 0 + + def fail(self, path: Path, number: int, message: str) -> None: + self.failures.append(f"{path.relative_to(ROOT)}:{number}: {message}") diff --git a/scripts/_check_docs/make_targets.py b/scripts/_check_docs/make_targets.py new file mode 100644 index 00000000..9da75ef6 --- /dev/null +++ b/scripts/_check_docs/make_targets.py @@ -0,0 +1,46 @@ +"""Check 1: every ``make`` target a document names exists.""" + +import re +import subprocess +from pathlib import Path + +from _check_docs.document import ROOT, Line, Report + +MAKE_TARGET_RE = re.compile(r"\bmake\s+([a-z][a-z0-9_-]*)") + + +def read_makefile_targets() -> set[str]: + """Every target the Makefile defines, read from `make`'s own database. + + Not a regex over the file: a `.PHONY` entry looks like a declaration and defines + nothing, which is exactly the bug this check exists for. + """ + out = subprocess.run( + ["make", "-qp", "-f", str(ROOT / "Makefile")], + cwd=ROOT, + capture_output=True, + text=True, + ).stdout + return { + match.group(1) + for match in ( + re.match(r"^([a-zA-Z][a-zA-Z0-9_.-]*):(?!=)", line) + for line in out.splitlines() + ) + if match + } + + +def check_make_targets( + path: Path, lines: list[Line], report: Report, known: set[str] +) -> None: + for line in lines: + for target in MAKE_TARGET_RE.findall(line.code): + report.checked += 1 + if target not in known: + report.fail( + path, + line.number, + f"`make {target}` is not a Makefile target — name the one that " + f"exists, or add it. A .PHONY entry alone is not a target.", + ) diff --git a/scripts/_check_docs/paths.py b/scripts/_check_docs/paths.py new file mode 100644 index 00000000..f86a0a87 --- /dev/null +++ b/scripts/_check_docs/paths.py @@ -0,0 +1,52 @@ +"""Check 2: every repository path a document names exists.""" + +import re +from pathlib import Path + +from _check_docs.document import ROOT, Line, Report + +PIP_TARGET_RE = re.compile(r"""(?:pip|uv pip) install\s+["']?(\.[^"'\s\[]*)""") +O_PATH_RE = re.compile(r"-o\s+\w+=([\w./-]*\.(?:json|ya?ml|pem|toml|py))") +LINK_RE = re.compile(r"\]\((?!https?:|#|mailto:)([^)\s]+)\)") + +# A path is ours to check when it names a top-level directory of this repository. +# Anything else is a path on the reader's machine (`/var/qpi-driver/…`, `./data`). +REPO_DIRS = tuple( + p.name for p in ROOT.iterdir() if p.is_dir() and not p.name.startswith(".") +) + + +def check_paths(path: Path, lines: list[Line], report: Report) -> None: + """Paths resolve from the document's own directory — a reader who `cd`s to the + example and runs `pip install .` means that example, not the repository root.""" + for line in lines: + for raw in PIP_TARGET_RE.findall(line.code): + report.checked += 1 + target = (path.parent / raw).resolve() + if not target.is_dir(): + report.fail(path, line.number, f"`pip install {raw}`: not a directory") + elif not (target / "pyproject.toml").exists(): + report.fail( + path, + line.number, + f"`pip install {raw}`: {raw} has no pyproject.toml, so there is " + f"nothing there to install", + ) + + for raw in O_PATH_RE.findall(line.code): + if not raw.lstrip("./").startswith(REPO_DIRS): + continue # a path on the reader's machine, not ours + report.checked += 1 + if not (ROOT / raw.lstrip("./")).exists(): + report.fail( + path, line.number, f"{raw} does not exist in this repository" + ) + + for line in lines: # links are prose, so read from the raw line + for raw in LINK_RE.findall(line.raw): + target = raw.split("#", 1)[0] + if not target: + continue + report.checked += 1 + if not (path.parent / target).resolve().exists(): + report.fail(path, line.number, f"link target {raw} does not exist") diff --git a/scripts/check_docs.py b/scripts/check_docs.py index ada36ba4..d09fc79e 100644 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -6,14 +6,17 @@ 1. **Every ``make`` target a document names exists.** A `make` line in a runbook is a promise; ``make test-e2e-driver-framework`` sat in ``docs/driver/operations.md`` - for a release, existing only in the Makefile's ``.PHONY`` list. + for a release, existing only in the Makefile's ``.PHONY`` list. See + :mod:`_check_docs.make_targets`. 2. **Every repository path a document names exists.** Both halves of ``pip install ./qpi-driver[cli]`` and of ``-o quantify_device_config=quantify.device.example.json`` were wrong at once — a - directory with no ``pyproject.toml`` and a file that has never existed. + directory with no ``pyproject.toml`` and a file that has never existed. See + :mod:`_check_docs.paths`. 3. **Every ``qpi-driver`` flag a document names is one the CLI accepts.** The flags come from each SDK's own ``--help``, so a removed flag cannot linger in a README, - and each document is checked against the SDK it belongs to. + and each document is checked against the SDK it belongs to. See + :mod:`_check_docs.cli_flags`. The runnable half — the snippets, the error transcripts, the installed example — is the rest of ``make test-docs``. @@ -31,19 +34,12 @@ from __future__ import annotations -import os -import re -import subprocess import sys -from dataclasses import dataclass, field -from pathlib import Path -ROOT = Path(__file__).resolve().parent.parent - -SKIP_MARKER = "" - -# A record of the past, not instructions for the present. See the module docstring. -EXCLUDED = {"CHANGELOG.md"} +from _check_docs.cli_flags import check_cli_flags, read_cli_flags +from _check_docs.document import ROOT, Report, find_documents, read_lines +from _check_docs.make_targets import check_make_targets, read_makefile_targets +from _check_docs.paths import check_paths # The SDK whose CLI each document's `qpi-driver` commands belong to. A document not # listed here has no CLI of its own and is checked for make targets and paths only. @@ -57,300 +53,15 @@ } -# --------------------------------------------------------------------------- -# Reading a document -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class Line: - """One line of a document, with what a check needs to know about where it is. - - ``code`` is the part of the line inside a fenced block or inline backticks — - empty for prose. Every check runs on ``code`` rather than the raw line, because - "we make the operation a value" is prose about the word *make*, not a promise - about a Makefile target. - """ - - number: int - raw: str - code: str - - -INLINE_CODE_RE = re.compile(r"`([^`]+)`") - -# A shell comment is prose that happens to be inside a fence — "# the make targets it -# names" is not a command. Only a `#` that starts a token counts, so the `#` in -# `--device ./dist/my-device.js#MyExport` is left where it is. -SHELL_COMMENT_RE = re.compile(r"(?:(?<=\s)|^)#.*$") - - -def read(path: Path) -> list[Line]: - """The document's lines, skip-marked fences dropped, code spans extracted.""" - lines: list[Line] = [] - skip_next_fence = False - in_fence = False - in_skipped_fence = False - - for number, raw in enumerate(path.read_text().splitlines(), start=1): - stripped = raw.strip() - if stripped == SKIP_MARKER: - skip_next_fence = True - continue - if stripped.startswith("```"): - if in_skipped_fence: - in_skipped_fence, in_fence = False, False - elif in_fence: - in_fence = False - elif skip_next_fence: - in_skipped_fence, skip_next_fence = True, True - else: - in_fence = True - continue - if in_skipped_fence: - continue - code = raw if in_fence else " ".join(INLINE_CODE_RE.findall(raw)) - lines.append(Line(number, raw, SHELL_COMMENT_RE.sub("", code))) - return lines - - -def documents() -> list[Path]: - candidates = [ - *ROOT.glob("*.md"), - *ROOT.glob("docs/**/*.md"), - *ROOT.glob("qpi-driver/*/README.md"), - *ROOT.glob("qpi-driver/py/examples/*/README.md"), - *ROOT.glob("qpi-client/*/README.md"), - # Named individually because the symlink under docs/ that publishes each of - # these is skipped below: a glob that reaches only the symlink checks nothing. - # The dashboard README sat unchecked long enough to still be the Vite scaffold. - ROOT / "qpi-driver/py/qpi_driver/tuners/README.md", - ROOT / "qpi-ui/internal/dashboard/README.md", - ] - seen: dict[Path, Path] = {} - for path in candidates: - if path.is_symlink() or str(path.relative_to(ROOT)) in EXCLUDED: - continue - seen[path.resolve()] = path - return sorted(seen.values()) - - -@dataclass -class Report: - """Collected rather than raised, so one run names every problem.""" - - failures: list[str] = field(default_factory=list) - checked: int = 0 - - def fail(self, path: Path, number: int, message: str) -> None: - self.failures.append(f"{path.relative_to(ROOT)}:{number}: {message}") - - -# --------------------------------------------------------------------------- -# 1. make targets -# --------------------------------------------------------------------------- - -MAKE_TARGET_RE = re.compile(r"\bmake\s+([a-z][a-z0-9_-]*)") - - -def makefile_targets() -> set[str]: - """Every target the Makefile defines, read from `make`'s own database. - - Not a regex over the file: a `.PHONY` entry looks like a declaration and defines - nothing, which is exactly the bug this check exists for. - """ - out = subprocess.run( - ["make", "-qp", "-f", str(ROOT / "Makefile")], - cwd=ROOT, - capture_output=True, - text=True, - ).stdout - return { - match.group(1) - for match in ( - re.match(r"^([a-zA-Z][a-zA-Z0-9_.-]*):(?!=)", line) - for line in out.splitlines() - ) - if match - } - - -def check_make_targets( - path: Path, lines: list[Line], report: Report, known: set[str] -) -> None: - for line in lines: - for target in MAKE_TARGET_RE.findall(line.code): - report.checked += 1 - if target not in known: - report.fail( - path, - line.number, - f"`make {target}` is not a Makefile target — name the one that " - f"exists, or add it. A .PHONY entry alone is not a target.", - ) - - -# --------------------------------------------------------------------------- -# 2. repository paths -# --------------------------------------------------------------------------- - -PIP_TARGET_RE = re.compile(r"""(?:pip|uv pip) install\s+["']?(\.[^"'\s\[]*)""") -O_PATH_RE = re.compile(r"-o\s+\w+=([\w./-]*\.(?:json|ya?ml|pem|toml|py))") -LINK_RE = re.compile(r"\]\((?!https?:|#|mailto:)([^)\s]+)\)") - -# A path is ours to check when it names a top-level directory of this repository. -# Anything else is a path on the reader's machine (`/var/qpi-driver/…`, `./data`). -REPO_DIRS = tuple( - p.name for p in ROOT.iterdir() if p.is_dir() and not p.name.startswith(".") -) - - -def check_paths(path: Path, lines: list[Line], report: Report) -> None: - """Paths resolve from the document's own directory — a reader who `cd`s to the - example and runs `pip install .` means that example, not the repository root.""" - for line in lines: - for raw in PIP_TARGET_RE.findall(line.code): - report.checked += 1 - target = (path.parent / raw).resolve() - if not target.is_dir(): - report.fail(path, line.number, f"`pip install {raw}`: not a directory") - elif not (target / "pyproject.toml").exists(): - report.fail( - path, - line.number, - f"`pip install {raw}`: {raw} has no pyproject.toml, so there is " - f"nothing there to install", - ) - - for raw in O_PATH_RE.findall(line.code): - if not raw.lstrip("./").startswith(REPO_DIRS): - continue # a path on the reader's machine, not ours - report.checked += 1 - if not (ROOT / raw.lstrip("./")).exists(): - report.fail( - path, line.number, f"{raw} does not exist in this repository" - ) - - for line in lines: # links are prose, so read from the raw line - for raw in LINK_RE.findall(line.raw): - target = raw.split("#", 1)[0] - if not target: - continue - report.checked += 1 - if not (path.parent / target).resolve().exists(): - report.fail(path, line.number, f"link target {raw} does not exist") - - -# --------------------------------------------------------------------------- -# 3. qpi-driver CLI flags -# --------------------------------------------------------------------------- - -FLAG_RE = re.compile(r"(? set[str] | None: - """The long flags this SDK's CLI accepts, from its own --help. - - None when the CLI cannot be run here: an absent toolchain should skip the check - with a warning, not fail the build. COLUMNS is set wide because typer wraps - --help to the terminal, and a flag broken across two lines is a flag this would - not find. - """ - argv, cwd = CLI_INVOCATIONS[sdk] - env = { - **os.environ, - "COLUMNS": "400", - "TERM": "dumb", - "NO_COLOR": "1", - "PYTHONPATH": str(CLI_INVOCATIONS["py"][1]), - } - found: set[str] = set() - for subcommand in SUBCOMMANDS: - command = argv + ([subcommand] if subcommand else []) + ["--help"] - try: - out = subprocess.run( - command, cwd=cwd, capture_output=True, text=True, env=env - ) - except FileNotFoundError: - return None - if out.returncode != 0: - return None - found |= set(FLAG_RE.findall(out.stdout + out.stderr)) - - # A CLI that ran but told us almost nothing is not a CLI whose flags we can - # check against — it is a stub. `qpi_driver.cli` is importable from the - # source tree whether or not the `cli` extra is installed (PYTHONPATH above - # guarantees it), and without typer it prints a near-empty help and exits 0. - # Believing that would report every documented flag as removed, which is a - # wall of false failures rather than the one true statement "the CLI could - # not be run here". - if len(found) < MINIMUM_PLAUSIBLE_FLAGS: - return None - return found - - -def check_cli_flags( - path: Path, lines: list[Line], report: Report, accepted: set[str] -) -> None: - """Flags are attributed to the command they follow. - - A line is only checked from the last `qpi-driver` token onwards, so - `uv tool install --with mylab-devices "qpi-driver[cli]"` does not have `--with` - read as a driver flag. A command continued with a trailing backslash keeps the - attribution across the continuation lines. - """ - continuing = False - for line in lines: - code = line.code - if DRIVER_COMMAND_RE.search(code): - segment = code[DRIVER_COMMAND_RE.search(code).end() :] - for match in DRIVER_COMMAND_RE.finditer(code): - segment = code[match.end() :] # the last occurrence wins - continuing = True - elif continuing: - segment = code - else: - continue - - for flag in FLAG_RE.findall(segment): - report.checked += 1 - if flag not in accepted: - report.fail( - path, - line.number, - f"`{flag}` is not a flag this SDK's qpi-driver accepts — it was " - f"renamed or removed. The CLI's own --help is the list.", - ) - continuing = code.rstrip().endswith("\\") - - -# --------------------------------------------------------------------------- - - def main() -> int: - known_targets = makefile_targets() + known_targets = read_makefile_targets() flags_cache: dict[str, set[str] | None] = {} report = Report() - docs = documents() + docs = find_documents() for path in docs: relative = str(path.relative_to(ROOT)) - lines = read(path) + lines = read_lines(path) check_make_targets(path, lines, report, known_targets) check_paths(path, lines, report) @@ -359,7 +70,7 @@ def main() -> int: if sdk is None: continue if sdk not in flags_cache: - flags_cache[sdk] = cli_flags(sdk) + flags_cache[sdk] = read_cli_flags(sdk) if flags_cache[sdk] is None: print( f"[check-docs] ⚠ skipping CLI flag checks for the {sdk} SDK: its "