From f77e2ea443e687cf29c95c537446e1df18c20238 Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Wed, 5 Aug 2026 13:39:01 +0200 Subject: [PATCH 1/5] refactor(scripts): split check_docs into one module per check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_docs.py carried its three checks in one 387-line file, separated by banner comments — the kind future contributors read past. Each check is now its own module under scripts/_check_docs, named after what it checks, and check_docs.py is the entry point that walks the documents and calls them. The module docstring still lists the three, now pointing at the module each lives in. `make test-docs-static` invokes it the same way and reports the same 151 claims across 20 documents. document.py holds what all three need: the Line/Report types, reading a document's code spans, and finding the documents. Its EXCLUDED comment said "see the module docstring" for why CHANGELOG.md is skipped, which was true in the old single file and dangling here, so it states the reason itself. Four functions renamed while they moved, since they are the ones the split made awkward: cli_flags() shadowed the module it now lives in, and makefile_targets(), documents() and a bare read() were nouns for things that go and fetch. They are read_cli_flags, read_makefile_targets, find_documents and read_lines. check_cli_flags opened with an assignment that the loop on the next line always overwrote. Dropped; finditer yields at least one match wherever the search that guards the branch matched. scripts/ had no importable package before this, so nothing ever left a __pycache__ there and no .gitignore covered it — the SDKs ignore bytecode in their own. A root rule now catches the rest of the tree. --- .gitignore | 5 + scripts/_check_docs/__init__.py | 1 + scripts/_check_docs/cli_flags.py | 101 +++++++++ scripts/_check_docs/document.py | 96 +++++++++ scripts/_check_docs/make_targets.py | 46 ++++ scripts/_check_docs/paths.py | 52 +++++ scripts/check_docs.py | 317 ++-------------------------- 7 files changed, 315 insertions(+), 303 deletions(-) create mode 100644 scripts/_check_docs/__init__.py create mode 100644 scripts/_check_docs/cli_flags.py create mode 100644 scripts/_check_docs/document.py create mode 100644 scripts/_check_docs/make_targets.py create mode 100644 scripts/_check_docs/paths.py 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/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 " From 179b16653afdec2e09204bd21256badb21b5210f Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Wed, 5 Aug 2026 13:39:18 +0200 Subject: [PATCH 2/5] style(qpi-client): drop the section banners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.py and provider.py divided their classes with banner comments — "-- public API ---", "-- execution ---", and twelve more. Every method under them already has a docstring saying what it does, so the banners were labelling groups that named themselves, and they are the first thing to go stale when a method moves. Fourteen removed, nothing else touched. --- qpi-client/py/qpi_client/client.py | 20 -------------------- qpi-client/py/qpi_client/provider.py | 17 ----------------- 2 files changed, 37 deletions(-) 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, From 1d70fe091dc3bdad721d4579c31cb8ad9dfe0727 Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Wed, 5 Aug 2026 13:39:19 +0200 Subject: [PATCH 3/5] style(e2e): drop a section banner, and lift a constant to the top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "New E2E tests for recently added features" sat above twenty test functions, which is every test in the second half of the file — it dated them rather than grouping them, and the features stopped being recent some releases ago. _BLUEFORS_CHANNELS was defined fifteen hundred lines in, immediately above the first helper that reads it. It joins the other module constants at the top. The file is not ruff-formatted and this does not change that; reformatting it would bury two lines of intent in six hundred lines of rewrapping. --- e2e/verify.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) 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 From d93cf0fdec362ede9d5a3fc7ee1a931d228ccca5 Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Wed, 5 Aug 2026 13:39:37 +0200 Subject: [PATCH 4/5] refactor(qpi-driver): read a compiled schedule from its own module, and lift constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four functions that paper over quantify-scheduler and qblox-scheduler disagreeing about a compiled schedule sat at the bottom of coordinator.py under a banner comment carrying the whole explanation — which of the two spells pulse_info as a list, where a subschedule keeps its operations, whether a drive amplitude is G_amp or amplitude. That is a module's worth of one subject, so it is simulation/_compiled_schedule.py now and the banner's prose is its docstring. Renamed on the way out, because the names only read inside the file that defined them: infos() said nothing about returning pulse or acquisition entries, and operations_of/amplitude_of were nouns. They are read_info_entries, read_operations, read_amplitude, and is_subschedule keeps its name. Constants moved to the top of their modules, out from between the functions that use them: MAX_ENTANGLED and the four drive-step and propagator-cache bounds in coordinator.py, MAX_BROADENING in lorentzian.py, _DEPARTED/_RETURNED in chevron.py, GRID_NS in tuners/base/routines.py, BIAS_KEYS in coupler_bias.py. Every comment above them moved too. The remaining banners in transmon.py, coupled.py, agent.py, dag.py and coordinator.py are gone. One was load-bearing — routines.py explained what a check is and why the two methods mirror build_schedule/analyse — so that went into build_check_schedule's docstring rather than the bin. Constants left where they are: DEVICE_SPEC in bluefors_gen1.py, DEVICE_SPECS in qpu.py and calibrate.py, DeviceBuilder and _DEVICES in registry.py. Each is built from a function or class defined above it and cannot move over its own dependency. --- .../executors/utils/coupler_bias.py | 23 ++-- .../simulation/_compiled_schedule.py | 56 ++++++++ qpi-driver/py/qpi_driver/simulation/agent.py | 4 - .../py/qpi_driver/simulation/coordinator.py | 130 +++++------------- .../py/qpi_driver/simulation/coupled.py | 8 -- .../py/qpi_driver/simulation/transmon.py | 8 -- qpi-driver/py/qpi_driver/tuners/base/dag.py | 2 - .../py/qpi_driver/tuners/base/routines.py | 39 +++--- .../py/qpi_driver/tuners/fitting/chevron.py | 15 +- .../qpi_driver/tuners/fitting/lorentzian.py | 11 +- 10 files changed, 134 insertions(+), 162 deletions(-) create mode 100644 qpi-driver/py/qpi_driver/simulation/_compiled_schedule.py 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]: From a3d814523a8397bdec76476cde0eacd4a4b1ddf3 Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Wed, 5 Aug 2026 13:39:53 +0200 Subject: [PATCH 5/5] refactor(qpi-driver): group shared test helpers, and give test groups real names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/utils.py held fixture loading and qiskit gate-conversion assertions in one file, and tests/fixtures/ held two Python modules beside its data files. Helpers are now a tests/utils package split by subject: io.py loads the fixture data, circuits.py compares a converted gate against qiskit's own unitary, simulation.py holds the transmon test doubles, half_imported_device.py is still the module that fails to import. fixtures/ is data files only, and says so. The nine test modules that divided their tests with banner comments now use test classes. Each banner's title became the class name and its prose the class docstring, so what was a comment is now something pytest prints and a reader can find — TestTheWholeDagThroughTheRealStack rather than "--- the whole DAG, through the real stack ---". Helpers and constants that sat between the tests of a section move above the class they serve. Where a banner only restated the single docstring below it, it is simply gone. No test changed behaviour: pytest collects the same 739 items before and after, identical once class names are stripped from the node IDs, and the suite is 615 passed / 124 skipped either way. The skips are the second scheduler's parametrisation, as before. test_discovery.py's dotted paths for the moved module are four characters shorter, which let ruff rejoin a wrapped assertion. --- qpi-driver/py/tests/fixtures/__init__.py | 6 - qpi-driver/py/tests/test_calibrate_driver.py | 951 +++--- .../py/tests/test_calibration_config.py | 130 +- qpi-driver/py/tests/test_calibration_dag.py | 782 +++-- qpi-driver/py/tests/test_calibration_e2e.py | 390 +-- qpi-driver/py/tests/test_calibration_loop.py | 2678 +++++++++-------- qpi-driver/py/tests/test_device_reload.py | 2 +- qpi-driver/py/tests/test_discovery.py | 6 +- qpi-driver/py/tests/test_docs.py | 12 +- qpi-driver/py/tests/test_fitting.py | 868 +++--- qpi-driver/py/tests/test_persistence.py | 407 ++- .../py/tests/test_physics_simulation.py | 1139 +++---- qpi-driver/py/tests/test_qblox.py | 8 +- qpi-driver/py/tests/test_quantify.py | 8 +- qpi-driver/py/tests/utils/__init__.py | 1 + .../py/tests/{utils.py => utils/circuits.py} | 74 +- .../half_imported_device.py | 0 qpi-driver/py/tests/utils/io.py | 23 + .../tests/{fixtures => utils}/simulation.py | 5 - 19 files changed, 3693 insertions(+), 3797 deletions(-) delete mode 100644 qpi-driver/py/tests/fixtures/__init__.py create mode 100644 qpi-driver/py/tests/utils/__init__.py rename qpi-driver/py/tests/{utils.py => utils/circuits.py} (89%) rename qpi-driver/py/tests/{fixtures => utils}/half_imported_device.py (100%) create mode 100644 qpi-driver/py/tests/utils/io.py rename qpi-driver/py/tests/{fixtures => utils}/simulation.py (99%) 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]