diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1da652902..93b0708ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,44 @@ jobs: permissions: contents: read + docs-check: + name: 📚 Documentation + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + - name: Check documentation + run: uvx nox --non-interactive -s docs-check + + docs-execute: + name: 📊 Documentation examples + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + - name: Execute documentation examples + run: uvx nox --non-interactive -s docs-execute + - name: Save executed examples and rendered documentation + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: documentation-examples + path: | + docs/_outputs + docs/_build/executed + if-no-files-found: error + retention-days: 14 + build-sdist: name: 🚀 CD needs: change-detection @@ -90,6 +128,8 @@ jobs: - python-tests - python-coverage - python-linter + - docs-check + - docs-execute - build-sdist - build-wheel runs-on: ubuntu-slim diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 82f4ec78f..e0c01e93d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,6 +8,10 @@ # prek install # +# Saved notebooks are generated from the checked Markdown cells. Formatting their +# copies would invalidate the output manifest and repeat checks of the same code. +exclude: ^docs/_outputs/.*\.ipynb$ + ci: autoupdate_commit_msg: "⬆️🪝 update pre-commit hooks" autoupdate_schedule: quarterly diff --git a/.readthedocs.yaml b/.readthedocs.yaml index cb4b694bd..366ba69d9 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,20 +1,26 @@ version: 2 -formats: - - htmlzip - sphinx: configuration: docs/conf.py + fail_on_warning: true build: os: ubuntu-24.04 tools: python: "3.14" + jobs: + install: + - uv pip install --python "$READTHEDOCS_VIRTUALENV_PATH/bin/python" --group docs --exact -e . + build: + html: + # Render saved notebook outputs; execute and validate examples in GitHub Actions. + - >- + "$READTHEDOCS_VIRTUALENV_PATH/bin/python" -u -m sphinx + -j 1 -n -T -W --keep-going -b html -d docs/_build/doctrees + docs "$READTHEDOCS_OUTPUT/html" python: install: - method: uv - command: sync - groups: - - docs - extras: all + command: pip + path: . diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index a005cf7a2..6794a0659 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -1,3 +1,3 @@ ```{include} ../UPGRADING.md - +:relative-docs: docs/ ``` diff --git a/docs/_ext/yaqs_api.py b/docs/_ext/yaqs_api.py new file mode 100644 index 000000000..98af5e0ce --- /dev/null +++ b/docs/_ext/yaqs_api.py @@ -0,0 +1,221 @@ +# Copyright (c) 2025 - 2026 Chair for Design Automation, TUM +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Keep API re-exports and type references linked to their defining objects.""" + +from __future__ import annotations + +import builtins +import shutil +from html import escape +from pathlib import Path +from typing import TYPE_CHECKING + +from docutils import nodes +from sphinx import addnodes +from sphinx.domains.python import ObjectEntry, PythonDomain + +if TYPE_CHECKING: + from collections.abc import Iterator + + from autoapi._objects import PythonObject + from sphinx.application import Sphinx + from sphinx_markdown_builder.translator import MarkdownTranslator + + +_EXTERNAL_ALIASES = { + "NDArray": "numpy.typing.NDArray", + "ArrayLike": "numpy.typing.ArrayLike", + "QuantumCircuit": "qiskit.circuit.QuantumCircuit", + "qiskit.QuantumCircuit": "qiskit.circuit.QuantumCircuit", + "DAGCircuit": "qiskit.dagcircuit.DAGCircuit", + "DAGOpNode": "qiskit.dagcircuit.DAGOpNode", + "Parameter": "qiskit.circuit.Parameter", + "scipy.linalg.LinAlgError": "numpy.linalg.LinAlgError", +} + + +class _APIPythonDomain(PythonDomain): + """Publish public import aliases alongside their canonical API entries.""" + + def get_objects(self) -> Iterator[tuple[str, str, str, str, str, int]]: + """Include import aliases, even when a module has the same name. + + Yields: + Python inventory entries. + """ + yield from super().get_objects() + for name, entry in self.data.get("yaqs_public_aliases", {}).items(): + if name not in self.objects or self.objects[name].objtype != entry.objtype: + yield name, name, entry.objtype, entry.docname, entry.node_id, -1 + + +def _canonical_name(objects: dict[str, PythonObject], name: str) -> str: + """Follow a re-export, including a method or attribute below that export. + + Returns: + The object's name at its defining module. + """ + visited = set() + while True: + parts = name.split(".") + for end in range(len(parts), 0, -1): + obj = objects.get(".".join(parts[:end])) + if obj is not None and obj.imported: + if end < len(parts) and obj.type not in {"class", "exception", "module", "package"}: + continue + if obj.id in visited: + return name + visited.add(obj.id) + original = obj.obj["original_path"] + suffix = ".".join(parts[end:]) + name = original + (f".{suffix}" if suffix else "") + break + else: + return name + + +def _public_imports( + app: Sphinx, + what: str, + name: str, + obj: PythonObject, + skip: bool, # ruff: ignore[boolean-type-hint-positional-argument] - Sphinx calls event handlers positionally. + options: list[str], +) -> None: + """List explicit public re-exports without repeating their documentation.""" + del app, name, options + if skip or what not in {"module", "package"}: + return + exports = obj.obj.get("all") or [] + links = [ + f"* :py:obj:`{child.name} <{child.obj['original_path']}>`" + for child in obj.children + if child.imported and child.name in exports + ] + if links: + obj.docstring += "\n\n.. rubric:: Public imports\n\n" + "\n".join(links) + "\n" + + +def _resolve_api_references(app: Sphinx, doctree: nodes.document) -> None: + """Qualify local aliases and use inventory roles for external types.""" + objects = getattr(app.env, "autoapi_all_objects", {}) + inventory = getattr(app.env, "intersphinx_inventory", {}) + for node in doctree.findall(addnodes.pending_xref): + if node.get("refdomain") != "py": + continue + target = node["reftarget"] + if node.get("reftype") in {"class", "obj"} and isinstance(getattr(builtins, target, None), type): + # Avoid resolving builtin ``type`` or ``float`` to a class member. + node["reftarget"] = f"python:{target}" + continue + module = node.get("py:module", "") + classname = node.get("py:class", "") + candidates = [target, f"{module}.{classname}.{target}", f"{module}.{target}"] + if target.startswith("."): + candidates.insert(0, module + target) + # Resolve within the declared scope before searching globally. The + # imported object remains in AutoAPI's model even when it is not rendered. + for candidate in candidates: + if candidate in objects: + target = _canonical_name(objects, candidate) if node.get("reftype") != "mod" else candidate + break + else: + if "." not in target: + matches = { + name + for name, obj in objects.items() + if name.endswith(f".{target}") and obj.display and not obj.imported + } + if len(matches) == 1: + target = matches.pop() + + target = _EXTERNAL_ALIASES.get(target, target) + if target.startswith("np."): + target = "numpy." + target[3:] + obj = objects.get(target) + if ( + obj is not None and not obj.display and not obj.imported + ) or target == "multiprocessing.context.BaseContext": + # Keep known internal types readable without linking to deliberately + # omitted API entries. Python has no inventory entry for BaseContext. + # Unknown YAQS names still pass through to Sphinx's strict check. + node.replace_self(nodes.literal("", node.astext())) + continue + node["reftarget"] = target + if node.get("reftype") == "class" and any( + target in inventory.get(role, {}) for role in ("py:data", "py:attribute", "py:type") + ): + # NumPy documents NDArray as data and scalar dtypes as attributes. + # Generic object roles retain the exact type and use those entries. + node["reftype"] = "obj" + + +def _register_public_aliases(app: Sphinx, env: object) -> None: + """Keep public re-export names available to downstream intersphinx users.""" + del env + objects = getattr(app.env, "autoapi_all_objects", {}) + domain = app.env.domains["py"] + canonical_entries = dict(domain.objects) + aliases = {} + for name, obj in objects.items(): + if not obj.imported or obj.obj.get("hide"): + continue + canonical = _canonical_name(objects, name) + for target, entry in canonical_entries.items(): + if target == canonical or target.startswith(f"{canonical}."): + alias = name + target[len(canonical) :] + aliases[alias] = ObjectEntry(entry.docname, entry.node_id, entry.objtype, aliased=True) + domain.data["yaqs_public_aliases"] = aliases + + +def _markdown_images(app: Sphinx, doctree: nodes.document, docname: str) -> None: + """Keep local image links valid when sphinx-llm merges Markdown outputs.""" + if app.builder.name not in {"llms-markdown", "markdown"}: + return + output = Path(app.outdir) + if app.tags.has("sphinx_llm_markdown"): + output = output.parent + for node in doctree.findall(nodes.image): + image = app.env.images.get(node["uri"]) + if image is None: + continue + destination = output / "_images" / image[1] + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(Path(app.srcdir) / node["uri"], destination) + node["uri"] = "../" * docname.count("/") + f"_images/{image[1]}" + + +def _visit_markdown_abbreviation(translator: MarkdownTranslator, node: nodes.abbreviation) -> None: + """Keep abbreviation explanations and signature separators in Markdown.""" + translator.add(f'') + + +def _depart_markdown_abbreviation(translator: MarkdownTranslator, node: nodes.abbreviation) -> None: + """Close an abbreviation after the translator has rendered its text.""" + del node + translator.add("") + + +def setup(app: Sphinx) -> dict[str, bool]: + """Install API presentation hooks. + + Returns: + Parallel build support declarations. + """ + if "autoapi-skip-member" in app.events.events: + app.connect("autoapi-skip-member", _public_imports) + app.add_domain(_APIPythonDomain, override=True) + app.add_node( + nodes.abbreviation, + override=True, + markdown=(_visit_markdown_abbreviation, _depart_markdown_abbreviation), + ) + app.connect("doctree-read", _resolve_api_references) + app.connect("env-updated", _register_public_aliases) + app.connect("doctree-resolved", _markdown_images) + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs/_ext/yaqs_examples.py b/docs/_ext/yaqs_examples.py new file mode 100644 index 000000000..88933ebd9 --- /dev/null +++ b/docs/_ext/yaqs_examples.py @@ -0,0 +1,189 @@ +# Copyright (c) 2025 - 2026 Chair for Design Automation, TUM +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Publish saved notebook outputs and regenerate them in a separate build.""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +from importlib.metadata import version +from pathlib import Path +from typing import TYPE_CHECKING + +import nbformat +from jupyter_cache import get_cache +from jupyter_cache.base import CacheBundleIn +from myst_nb.core.read import is_myst_markdown_notebook, read_myst_markdown_notebook +from sphinx.errors import SphinxError + +if TYPE_CHECKING: + from nbformat import NotebookNode + from sphinx.application import Sphinx + + +def _notebooks(app: Sphinx) -> dict[Path, NotebookNode]: + """Read notebook sources, excluding generated documentation. + + Returns: + Source paths relative to the documentation directory and their notebooks. + """ + source = Path(app.srcdir) + notebooks = {} + for path in sorted(source.rglob("*")): + relative = path.relative_to(source) + if relative.parts[0] in {"_build", "_outputs"}: + continue + if path.suffix == ".ipynb": + notebooks[relative] = nbformat.read(path, as_version=4) + elif path.suffix == ".md": + text = path.read_text(encoding="utf-8") + if is_myst_markdown_notebook(text): + notebooks[relative] = read_myst_markdown_notebook( + text, config=app.env.myst_config, add_source_map=True, path=path + ) + return notebooks + + +def _runtime_digest(root: Path) -> str: + """Hash package code and dependency inputs that can change example results. + + Returns: + A digest independent of paths, timestamps, and generated version strings. + """ + digest = hashlib.sha256() + paths = [*sorted((root / "src").rglob("*.py")), root / "pyproject.toml", root / "uv.lock"] + for path in paths: + if path.is_file() and path.name != "_version.py": + digest.update(path.relative_to(root).as_posix().encode()) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def _input_digest(notebook: NotebookNode) -> str: + """Hash executable cells and settings, allowing prose-only edits. + + Returns: + The digest of inputs used to produce notebook outputs. + """ + cells = [ + {"source": cell.source, "metadata": {key: value for key, value in cell.metadata.items() if key != "source_map"}} + for cell in notebook.cells + if cell.cell_type == "code" + ] + metadata = {key: value for key, value in notebook.metadata.items() if key != "source_map"} + payload = json.dumps({"metadata": metadata, "cells": cells}, sort_keys=True).encode() + return hashlib.sha256(payload).hexdigest() + + +def _prepare_outputs(app: Sphinx) -> None: + """Require current saved outputs before publishing, then seed MyST's cache. + + Raises: + SphinxError: Saved outputs are missing, stale, or cannot be used safely. + """ + notebooks = _notebooks(app) + if app.env.mystnb_config.execution_mode != "cache" or any( + notebook.metadata.get("mystnb", {}).get("execution_mode", "cache") != "cache" for notebook in notebooks.values() + ): + msg = "Documentation requires nb_execution_mode='cache' to publish saved outputs safely." + raise SphinxError(msg) + if app.config.yaqs_generate_examples: + return + output = Path(app.srcdir) / "_outputs" + instruction = "Regenerate example outputs with: uvx nox --non-interactive -s docs-execute" + try: + manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + msg = f"Missing or invalid example output manifest. {instruction}" + raise SphinxError(msg) from error + if manifest.get("runtime") != _runtime_digest(Path(app.srcdir).parent): + msg = f"Example outputs predate changes to package code or dependencies. {instruction}" + raise SphinxError(msg) + cache = get_cache(app.env.mystnb_config.execution_cache_path) + for relative, notebook in notebooks.items(): + entry = manifest.get("notebooks", {}).get(relative.as_posix()) + saved = output / relative.with_suffix(".ipynb") + if entry is None or entry["inputs"] != _input_digest(notebook): + msg = f"Missing or stale example outputs for {relative}. {instruction}" + raise SphinxError(msg) + if not saved.is_file() or hashlib.sha256(saved.read_bytes()).hexdigest() != entry["sha256"]: + msg = f"Missing or changed saved notebook {saved}. {instruction}" + raise SphinxError(msg) + executed = nbformat.read(saved, as_version=4) + cache.cache_notebook_bundle(CacheBundleIn(executed, str(Path(app.srcdir) / relative)), overwrite=True) + try: + cache.match_cache_notebook(notebook) + except KeyError as error: + msg = f"Saved outputs do not match executable cells in {relative}. {instruction}" + raise SphinxError(msg) from error + + +def _output_dependencies(app: Sphinx, docname: str, source: list[str]) -> None: + """Refresh rendered pages when their saved outputs change.""" + del source + output = Path(app.srcdir) / "_outputs" / f"{docname}.ipynb" + if output.is_file(): + app.env.note_dependency(str(output)) + app.env.note_dependency(str(Path(app.srcdir) / "_outputs" / "manifest.json")) + + +def _save_outputs(app: Sphinx, exception: Exception | None) -> None: + """Export successfully executed notebooks and record their input provenance.""" + if ( + exception is not None + or app.statuscode + or not app.config.yaqs_generate_examples + or app.tags.has("sphinx_llm_markdown") + ): + return + output = Path(app.srcdir) / "_outputs" + output.mkdir(parents=True, exist_ok=True) + executed_dir = Path(app.env.mystnb_config.output_folder) + entries = {} + for relative, notebook in _notebooks(app).items(): + executed = nbformat.read(executed_dir / relative.with_suffix(".ipynb"), as_version=4) + for index, cell in enumerate(executed.cells): + cell.id = f"cell-{index}" + cell.metadata.pop("execution", None) + saved = output / relative.with_suffix(".ipynb") + saved.parent.mkdir(parents=True, exist_ok=True) + nbformat.write(executed, saved) + entries[relative.as_posix()] = { + "inputs": _input_digest(notebook), + "sha256": hashlib.sha256(saved.read_bytes()).hexdigest(), + } + retained = {output / Path(name).with_suffix(".ipynb") for name in entries} + for saved in output.rglob("*.ipynb"): + if saved not in retained: + saved.unlink() + manifest = { + "runtime": _runtime_digest(Path(app.srcdir).parent), + "environment": { + "python": platform.python_version(), + **{name: version(name) for name in ("mqt.yaqs", "numpy", "scipy", "qiskit", "myst-nb")}, + }, + "notebooks": entries, + } + (output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + +def setup(app: Sphinx) -> dict[str, bool]: + """Register saved-output publishing and generation hooks. + + Returns: + Parallel build support declarations. + """ + app.add_config_value("yaqs_generate_examples", os.environ.get("YAQS_DOCS_EXECUTE") == "1", "env") + app.connect("builder-inited", _prepare_outputs, priority=600) + app.connect("source-read", _output_dependencies) + app.connect("build-finished", _save_outputs, priority=400) + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs/_outputs/README.md b/docs/_outputs/README.md new file mode 100644 index 000000000..b3956d0a9 --- /dev/null +++ b/docs/_outputs/README.md @@ -0,0 +1,33 @@ +# Saved example outputs + +The Markdown files in `docs/examples/` remain the guide sources. This directory +stores their executed notebooks so Read the Docs can publish code, figures, and +results without running simulations or installing PyTorch. + +After changing example code, package code, or dependencies, regenerate the +outputs: + +```console +uvx nox --non-interactive -s docs-execute +``` + +Review the rendered pages in `docs/_build/executed/` and commit the notebooks +and `manifest.json` with the source changes. Edit the Markdown sources rather +than the saved notebooks. Changes to prose do not require another execution. + +The usual documentation command renders the saved outputs: + +```console +uvx nox --non-interactive -s docs +``` + +Publishing fails if outputs are missing, changed, or stale. The manifest records +the executable cells, notebook settings, package source, dependency inputs, and +execution environment. Generated version strings do not invalidate outputs. +Examples that read additional data files must also include those files in the +input check in `docs/_ext/yaqs_examples.py`. + +GitHub Actions executes all examples in a separate job and uploads the notebooks +and rendered pages as the `documentation-examples` artifact. The job does not +commit changes. Download and review the artifact if you regenerate outputs in +CI. The documentation check validates the committed copies independently. diff --git a/docs/_outputs/examples/analog_simulation.ipynb b/docs/_outputs/examples/analog_simulation.ipynb new file mode 100644 index 000000000..6871fd059 --- /dev/null +++ b/docs/_outputs/examples/analog_simulation.ipynb @@ -0,0 +1,3873 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Noisy Analog Simulation\n", + "\n", + "Relaxation can remove an excitation before it travels across a spin chain. To\n", + "study this competition between transport and loss, we follow an excitation\n", + "through a **20-site XY chain** and compare its motion at several relaxation\n", + "rates. The occupation heatmaps show where the excitation travels, while the\n", + "total occupation tells us how much survives.\n", + "\n", + "This guide extends the analog example in {doc}`quickstart` using only the\n", + "standard YAQS installation. YAQS represents the state as a matrix product state\n", + "(MPS) and simulates noise with the tensor jump method (TJM), averaging\n", + "independent quantum-jump trajectories. Run the cells in order in a notebook. In\n", + "a script, put execution inside an `if __name__ == \"__main__\":` guard, as shown\n", + "in {doc}`simulator_initialization`.\n", + "\n", + "## 1. Build the Hamiltonian\n", + "\n", + "The XY model lets an excitation move between neighboring spins without changing\n", + "the total number of excitations. With open ends and the coefficients below, its\n", + "Hamiltonian is\n", + "\n", + "$$\n", + "H=-\\frac{1}{2}\\sum_{i=0}^{L-2}(X_iX_{i+1}+Y_iY_{i+1}).\n", + "$$" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import Hamiltonian\n", + "\n", + "length = 20\n", + "hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "Setting `Jz=0` removes the ZZ interaction from the Heisenberg model. The default\n", + "boundary condition is open, so sites 0 and 19 have one neighbor each. Because\n", + "the Hamiltonian conserves excitation, a noiseless run gives us a baseline\n", + "against which to measure relaxation.\n", + "\n", + "We use $\\hbar=1$ and set the excitation-hopping amplitude to one. Time is\n", + "measured in its inverse units. See {doc}`hamiltonians` for other built-in\n", + "models, custom terms, and boundary conditions.\n", + "\n", + "## 2. Prepare one localized excitation\n", + "\n", + "To see the excitation spread, start every spin in $|0\\rangle$ except the spin at\n", + "site 10, which starts in $|1\\rangle$. Each character of `basis_string` specifies\n", + "the state of the corresponding site, starting at site 0." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import State\n", + "\n", + "center = length // 2\n", + "basis = \"0\" * center + \"1\" + \"0\" * (length - center - 1)\n", + "state = State(length, initial=\"basis\", basis_string=basis)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "`State` uses an MPS by default. A single excitation keeps entanglement modest,\n", + "so this example is inexpensive compared with a general interacting state on 20\n", + "sites. For other initial states and representations, see\n", + "{doc}`state_initialization` and {doc}`representation_comparison`.\n", + "\n", + "## 3. Choose observables, times, and accuracy\n", + "\n", + "The spatial dynamics require one observable per site. We use $Z_i$, whose\n", + "expectation gives the occupation through\n", + "$\\langle n_i\\rangle=(1-\\langle Z_i\\rangle)/2$." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import AnalogSimParams, Observable\n", + "\n", + "params = AnalogSimParams(\n", + " observables=[Observable(\"z\", site) for site in range(length)],\n", + " elapsed_time=3.0,\n", + " dt=0.25,\n", + " num_traj=32,\n", + " preset=\"fast\",\n", + " random_seed=7,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "These settings sample 13 times from $t=0$ to $t=3$, long enough to see the\n", + "excitation spread away from the center. Time sampling is enabled by default, and\n", + "`elapsed_time` must be an integer multiple of `dt`.\n", + "\n", + "The `fast` preset sets numerical tolerances for a quick example. We override its\n", + "trajectory budget with `num_traj=32`, so each noisy run averages 32\n", + "trajectories; the noiseless calculation needs only one. Increasing this budget\n", + "reduces sampling error, while timestep and MPS truncation errors require\n", + "separate convergence checks.\n", + "\n", + "`random_seed` fixes the jump random streams for repeat runs with the same\n", + "configuration. It does not improve accuracy. See {doc}`simulation_parameters`\n", + "for presets, overrides, and convergence settings.\n", + "\n", + "## 4. Define relaxation\n", + "\n", + "Relaxation competes with the motion generated by the Hamiltonian. The `lowering`\n", + "channel turns $|1\\rangle$ into $|0\\rangle$ without adding new excitations. Give\n", + "every site the same relaxation rate $\\gamma$:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import NoiseModel\n", + "\n", + "relaxation_rate = 4.0\n", + "noise = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": relaxation_rate}\n", + " for site in range(length)\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "For analog evolution, `strength` is a Lindblad rate, with units of inverse time.\n", + "YAQS forms the jump operator $\\sqrt{\\gamma}\\,|0\\rangle\\langle1|$ internally;\n", + "supply $\\gamma$, not its square root.\n", + "\n", + "The corresponding lifetime is $1/\\gamma$. At $\\gamma=4$, loss occurs on a\n", + "shorter timescale than hopping between sites. For site-dependent rates, other\n", + "channels, custom operators, and distribution-valued strengths, see\n", + "{doc}`realistic_noise_models`.\n", + "\n", + "## 5. Run the noiseless and noisy cases\n", + "\n", + "Initialize the simulator separately, then pass the state, Hamiltonian, and\n", + "parameters to `run`. Omit the noise model for the noiseless baseline." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import Simulator\n", + "\n", + "simulator = Simulator(show_progress=False)\n", + "noiseless = simulator.run(state, hamiltonian, params)\n", + "noisy = simulator.run(state, hamiltonian, params, noise)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "Parallel execution remains enabled by default. `show_progress=False` keeps the\n", + "documentation quiet; omit it to see progress. Execution and worker options are\n", + "explained in {doc}`simulator_initialization`.\n", + "\n", + "## 6. Read the results\n", + "\n", + "`expectation_values` follows the order of the supplied observables. Here, row\n", + "$i$ contains $\\langle Z_i\\rangle$ and columns follow `times`. Convert the\n", + "expectations to an array of occupations:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(20, 13)\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "\n", + "times = np.asarray(noisy.times)\n", + "occupation = (1 - np.asarray(noisy.expectation_values)) / 2\n", + "print(occupation.shape)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "The shape is `(20, 13)`: sites by sampled times. At $t=0$, only site 10 has\n", + "occupation one. In noisy runs, each entry is a trajectory average, rather than a\n", + "single measurement outcome. `noisy.trajectories` retains the per-trajectory\n", + "observable data when you need to examine sampling fluctuations.\n", + "\n", + "## 7. Compare four relaxation strengths\n", + "\n", + "The relaxation lifetime determines how long the excitation can propagate before\n", + "loss. To compare that timescale with the transport dynamics, keep the model and\n", + "numerical settings fixed and add rates between the two runs above:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-13", + "metadata": {}, + "outputs": [], + "source": [ + "rates = [0.0, 0.5, 1.5, relaxation_rate]\n", + "results = {0.0: noiseless, relaxation_rate: noisy}\n", + "for rate in rates[1:-1]:\n", + " rate_noise = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": rate}\n", + " for site in range(length)\n", + " ])\n", + " results[rate] = simulator.run(state, hamiltonian, params, rate_noise)\n", + "\n", + "occupations = np.stack([\n", + " (1 - np.asarray(results[rate].expectation_values)) / 2\n", + " for rate in rates\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "The nonzero rates give lifetimes of $2$, $2/3$, and $1/4$ in the time units used\n", + "here. The heatmaps below compare how far the excitation spreads before its\n", + "occupation decays. All panels use one square-root color scale to keep small\n", + "occupations visible, with no normalization by the remaining excitation." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cell-15", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:34:59.410121\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib.colors import PowerNorm\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\",\n", + " \"font.serif\": [\"STIXGeneral\"],\n", + " \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 11,\n", + " \"axes.labelsize\": 11,\n", + " \"axes.linewidth\": 0.7,\n", + " \"xtick.labelsize\": 10,\n", + " \"ytick.labelsize\": 10,\n", + " \"xtick.direction\": \"in\",\n", + " \"ytick.direction\": \"in\",\n", + " \"xtick.top\": True,\n", + " \"ytick.right\": True,\n", + " \"legend.fontsize\": 9,\n", + " \"legend.frameon\": False,\n", + " \"lines.linewidth\": 1.6,\n", + " \"figure.constrained_layout.use\": True,\n", + " \"savefig.dpi\": 180,\n", + "})\n", + "fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.4), sharex=True, sharey=True)\n", + "for ax, values, rate, panel in zip(\n", + " axes.flat, occupations, rates, \"abcd\", strict=True,\n", + "):\n", + " image = ax.pcolormesh(\n", + " times, np.arange(length), values, shading=\"auto\", cmap=\"cividis\",\n", + " norm=PowerNorm(0.5, vmin=0, vmax=1), rasterized=True,\n", + " )\n", + " label = \"Noiseless\" if rate == 0 else rf\"$\\gamma={rate:g}$\"\n", + " ax.set_title(f\"({panel}) {label}\", loc=\"left\", fontsize=11)\n", + " ax.set(xticks=[0, 1, 2, 3], yticks=[0, 5, 10, 15, 19])\n", + "for ax in axes[-1]:\n", + " ax.set_xlabel(r\"Time $t$\")\n", + "for ax in axes[:, 0]:\n", + " ax.set_ylabel(r\"Site $i$\")\n", + "fig.colorbar(image, ax=axes.ravel().tolist(),\n", + " label=r\"Occupation $\\langle n_i\\rangle$\", ticks=[0, 0.1, 0.5, 1])\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-16", + "metadata": {}, + "source": [ + "**Excitation transport under uniform relaxation.** Without noise, the excitation\n", + "spreads outward and forms an interference pattern. Increasing the relaxation\n", + "rate suppresses occupation at later times and more distant sites. At $\\gamma=4$,\n", + "most of the excitation is lost before it travels far from the center.\n", + "\n", + "The fading pattern does not imply slower propagation. Conditioned on no jump,\n", + "the excitation follows the noiseless spatial dynamics because this Hamiltonian\n", + "conserves excitation and the loss rate is uniform. Site-dependent relaxation or\n", + "a different channel can change that profile. This time window mainly shows\n", + "outward propagation; longer runs can also show reflections from the open ends.\n", + "\n", + "## 8. Check the total excitation\n", + "\n", + "The heatmaps combine spreading with loss. Summing occupation over sites removes\n", + "the spatial information and isolates the survival probability. Since the initial\n", + "state has exactly one excitation and every site has the same relaxation rate,\n", + "the ensemble mean obeys\n", + "\n", + "$$\n", + "N(t)=\\sum_i\\langle n_i(t)\\rangle=e^{-\\gamma t}.\n", + "$$" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cell-17", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:34:59.516247\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "total_excitation = occupations.sum(axis=1)\n", + "fine_times = np.linspace(times[0], times[-1], 200)\n", + "colors = [\"0.2\", \"#0072B2\", \"#009E73\", \"#D55E00\"]\n", + "fig, ax = plt.subplots(figsize=(6.2, 3.2))\n", + "for rate, values, color in zip(rates, total_excitation, colors, strict=True):\n", + " ax.plot(times, values, \"o-\", color=color, markersize=4,\n", + " label=\"Noiseless\" if rate == 0 else rf\"$\\gamma={rate:g}$\")\n", + " ax.plot(fine_times, np.exp(-rate * fine_times), \"--\", color=color,\n", + " linewidth=1.1, alpha=0.75)\n", + "ax.set(xlabel=r\"Time $t$\", ylabel=r\"Total excitation $N(t)$\",\n", + " xlim=(0, 3), ylim=(0, 1.08), xticks=[0, 1, 2, 3])\n", + "ax.legend(ncols=4, loc=\"lower center\", bbox_to_anchor=(0.5, 1.02))\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-18", + "metadata": {}, + "source": [ + "**Excitation survival compared with the decay law.** Markers show the YAQS\n", + "estimates, and dashed lines show $e^{-\\gamma t}$. At $t=3$, the exact survival\n", + "probabilities are one without noise and about $0.22$, $0.011$, and\n", + "$6\\times10^{-6}$ for the three nonzero rates.\n", + "\n", + "Each noisy trajectory either retains its excitation or loses it to a jump. With\n", + "only 32 trajectories, the estimated survival fraction changes in steps and may\n", + "reach zero while the exact mean is still positive. Finite sampling can also make\n", + "curves for different rates cross. Increase `num_traj` to reduce these\n", + "fluctuations; the sampling error decreases as $1/\\sqrt{\\mathtt{num\\_traj}}$.\n", + "\n", + "Together, the two figures distinguish loss from redistribution along the chain.\n", + "Uniform relaxation reduces the chance of finding the excitation, while surviving\n", + "excitations retain the coherent transport pattern. This conclusion and the decay\n", + "law rely on uniform loss and excitation-conserving dynamics. The noiseless total\n", + "also checks conservation, but that check alone cannot establish the accuracy of\n", + "the spatial dynamics.\n", + "\n", + "## Accuracy and other options\n", + "\n", + "Before using a larger or more demanding model, check convergence separately:\n", + "\n", + "| Change | What to check |\n", + "| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |\n", + "| Increase `num_traj` | Whether noisy observables stabilize within sampling uncertainty. |\n", + "| Reduce `dt` at fixed duration | Whether the dynamics stabilize with a finer evolution step. |\n", + "| Use `balanced` or `accurate` | Whether tighter truncation and solver settings change the observables. Keep an explicit trajectory budget when comparing numerical settings. |\n", + "| Increase `length` or `elapsed_time` | Whether boundaries, entanglement growth, and runtime affect the question being studied. |\n", + "\n", + "The default MPS evolution uses TDVP updates. To use the BUG integrator, import\n", + "`EvolutionMode` from `mqt.yaqs` and set `evolution_mode=EvolutionMode.BUG` in\n", + "`AnalogSimParams`. For TDVP, `tdvp_sweeps` adds unitary substeps without\n", + "changing the noise timestep. See {doc}`simulation_parameters` for these advanced\n", + "choices.\n", + "\n", + "## Related topics\n", + "\n", + "- {doc}`realistic_noise_models` — other channels, custom jumps, and static\n", + " disorder\n", + "- {doc}`digital_analog_simulation` — combine analog evolution and digital\n", + " operations\n", + "- {doc}`hamiltonians` — built-in, custom, hardware, and time-dependent\n", + " Hamiltonians\n", + "- {doc}`representation_comparison` — MPS, statevector, and density matrix\n", + " backends\n", + "- {ref}`noise-scheduled-jumps` — deterministic jumps at specified times\n", + "- {doc}`ensemble_evolution` — unitary ensemble correlations" + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 180, + "number_source_lines": true + }, + "source_map": [ + 10, + 37, + 42, + 59, + 65, + 78, + 89, + 111, + 119, + 135, + 141, + 153, + 159, + 172, + 186, + 193, + 237, + 261, + 276 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/characterization.ipynb b/docs/_outputs/examples/characterization.ipynb new file mode 100644 index 000000000..2d9321bed --- /dev/null +++ b/docs/_outputs/examples/characterization.ipynb @@ -0,0 +1,2544 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Probing Environmental Memory\n", + "\n", + "A quantum system can leave information in its environment and encounter that\n", + "information again later. To study this memory, we control one probe qubit,\n", + "interrupt its evolution with a measurement and preparation, then ask whether its\n", + "future responses still depend on the past. The interruption removes the probe's\n", + "direct link to its earlier state while the environment keeps evolving.\n", + "\n", + "We extend the coupling sweep in {doc}`quickstart` to explain the probing\n", + "schedule, the response matrix, and its spectrum. We then test memory persistence\n", + "under repeated resets and add dephasing in a short process-tensor example. The\n", + "examples use the standard YAQS installation and Matplotlib for plotting. Run the\n", + "cells in order in a notebook; for a script, use the entry-point guard in\n", + "{doc}`simulator_initialization`.\n", + "\n", + "## 1. Define the probe and its environment\n", + "\n", + "Site 0 is our probe qubit. Sites 1 and 2 form an environment that we do not\n", + "control or measure directly. All three spins evolve under the transverse-field\n", + "Ising Hamiltonian\n", + "\n", + "$$\n", + "H=-J(Z_0Z_1+Z_1Z_2)-g(X_0+X_1+X_2).\n", + "$$\n", + "\n", + "We fix $g=1$, use $\\hbar=1$, and vary $J$. This changes both the\n", + "probe–environment coupling and the bond within the environment. At $J=0$ the\n", + "probe is isolated, giving a reference with no environmental memory. The default\n", + "initial state is $|000\\rangle$." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from mqt.yaqs import AnalogSimParams, Hamiltonian, MemoryCharacterizer\n", + "\n", + "length = 3\n", + "couplings = np.linspace(0, 1.5, 13)\n", + "params = AnalogSimParams(elapsed_time=0.5, dt=0.5, preset=\"fast\")\n", + "characterizer = MemoryCharacterizer(show_progress=False)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "The characterizer selects the state representation automatically: vectors for\n", + "small systems, and MPS for larger ones. Parallel execution remains enabled. The\n", + "documentation suppresses progress bars; omit `show_progress=False` to see them.\n", + "Memory characterization currently supports qubit Hamiltonians.\n", + "\n", + "(memory-theory)=\n", + "\n", + "## 2. Choose the probing schedule\n", + "\n", + "Use four interventions separated by evolution intervals of `dt=0.5`. YAQS also\n", + "evolves before the first intervention and after the last, giving five intervals\n", + "and a total duration of 2.5. With `cut=2`, the second intervention is the\n", + "**causal break**: measure a selected outcome and prepare a new probe state.\n", + "There is one control before the break and two controls after it.\n", + "\n", + "The default `intervention_style=\"haar\"` draws random single-qubit unitaries for\n", + "these controls. Past probes also choose the measurement at the break; future\n", + "probes choose its preparation. Keeping these choices separate lets us test\n", + "whether past settings affect the future through the environment." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cut=2: S_V=-0.0000, modes=1.000\n", + "Response matrix shape: (32, 8)\n" + ] + } + ], + "source": [ + "num_interventions = 4\n", + "cut = 2\n", + "anchor = characterizer.characterize(\n", + " Hamiltonian.ising(length, J=0.0, g=1.0),\n", + " params,\n", + " num_interventions=num_interventions,\n", + " cut=cut,\n", + " preset=\"quick\",\n", + " rng=np.random.default_rng(7),\n", + ")\n", + "\n", + "print(anchor.summary())\n", + "print(\"Response matrix shape:\", anchor.response_matrix(cut).shape)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "`preset=\"quick\"` selects eight past probes and eight future probes, giving 64\n", + "sequences. This preset sets the probe grid; the preset on `AnalogSimParams` sets\n", + "numerical accuracy. For Hamiltonian characterization, `dt` sets the interval\n", + "between interventions. `elapsed_time` does not set the full probing horizon.\n", + "Here it equals one interval so the simulation parameters have a valid time grid.\n", + "\n", + "For each past–future pair, YAQS retains the selected measurement outcome and\n", + "records the final probe's $(I,X,Y,Z)$ responses. It multiplies each conditional\n", + "response by the joint probability of the retained outcomes. The\n", + "**response matrix** $V$ places each history in a column and each future's four\n", + "response channels in consecutive rows. Its shape here is $(32,8)$, and its\n", + "identity rows contain the outcome probabilities. Keeping these probabilities\n", + "avoids treating a rare branch as though it occurred on every run.\n", + "\n", + "## 3. Sweep the coupling with the same probes\n", + "\n", + "Reuse `anchor` as `probe_set` so every coupling uses the same controls,\n", + "measurements, and preparations. Otherwise a change in the sampled probes could\n", + "be confused with a change in the environment." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "memories = [anchor]\n", + "for coupling in couplings[1:]:\n", + " memory = characterizer.characterize(\n", + " Hamiltonian.ising(length, J=float(coupling), g=1.0),\n", + " params,\n", + " num_interventions=num_interventions,\n", + " cut=cut,\n", + " probe_set=anchor,\n", + " )\n", + " memories.append(memory)\n", + "\n", + "mode_weights = []\n", + "for memory in memories:\n", + " spectrum = memory.singular_values(cut)\n", + " mode_weights.append(spectrum**2 / np.sum(spectrum**2))\n", + "entropies = np.array([memory.entropy(cut) for memory in memories])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "The singular values $s_k$ describe independent combinations of past settings and\n", + "future responses. Their normalized squared weights and entropy are\n", + "\n", + "$$\n", + "p_k=\\frac{s_k^2}{\\sum_j s_j^2},\\qquad\n", + "S_V=-\\sum_k p_k\\ln p_k.\n", + "$$\n", + "\n", + "`singular_values(cut)` returns the spectrum retained for this entropy, after\n", + "removing a numerical tail with relative squared weight at most $10^{-12}$.\n", + "`singular_values_full(cut)` returns every compact-SVD value. The entropy uses\n", + "natural logarithms, and `memory.modes(cut)` gives the effective mode number\n", + "$R=\\exp(S_V)$. One retained mode gives $S_V=0$ and $R=1$.\n", + "\n", + "## 4. Read the spectrum and entropy\n", + "\n", + "The spectrum shows how coupling redistributes the response among modes. The\n", + "entropy summarizes this spread, allowing us to compare the same probing\n", + "experiment across the coupling sweep." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:35:10.637477\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " Mode index\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 1\n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 1\n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " R\n", + " e\n", + " t\n", + " a\n", + " i\n", + " n\n", + " e\n", + " d\n", + "  \n", + " m\n", + " o\n", + " d\n", + " e\n", + "  \n", + " w\n", + " e\n", + " i\n", + " g\n", + " h\n", + " t\n", + "  \n", + " p\n", + " k\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a)\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " C\n", + " o\n", + " u\n", + " p\n", + " l\n", + " i\n", + " n\n", + " g\n", + "  \n", + " /\n", + " J\n", + " g\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.05\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.10\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.15\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.20\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.25\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.30\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " M\n", + " e\n", + " m\n", + " o\n", + " r\n", + " y\n", + "  \n", + " e\n", + " n\n", + " t\n", + " r\n", + " o\n", + " p\n", + " y\n", + "  \n", + "  \n", + " (\n", + " n\n", + " a\n", + " t\n", + " s\n", + " )\n", + " S\n", + " V\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b)\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " C\n", + " o\n", + " u\n", + " p\n", + " l\n", + " i\n", + " n\n", + " g\n", + "  \n", + " /\n", + " J\n", + " g\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib.colors import LinearSegmentedColormap, Normalize\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\",\n", + " \"font.serif\": [\"STIXGeneral\"],\n", + " \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 10,\n", + " \"axes.labelsize\": 11,\n", + " \"axes.linewidth\": 0.8,\n", + " \"xtick.direction\": \"in\",\n", + " \"ytick.direction\": \"in\",\n", + " \"svg.fonttype\": \"none\",\n", + "})\n", + "red_map = LinearSegmentedColormap.from_list(\"coupling_reds\", plt.colormaps[\"Reds\"](np.linspace(0.3, 0.95, 100)))\n", + "norm = Normalize(couplings[0], couplings[-1])\n", + "fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8), layout=\"constrained\")\n", + "for coupling, weights in zip(couplings, mode_weights, strict=True):\n", + " axes[0].semilogy(np.arange(1, len(weights) + 1), weights, \"o-\", color=red_map(norm(coupling)), lw=1.3, ms=3)\n", + "axes[0].set(xlabel=\"Mode index\", ylabel=r\"Retained mode weight $p_k$\", ylim=(1e-13, 2), xticks=[1, 2, 4, 6, 8])\n", + "fig.colorbar(plt.cm.ScalarMappable(norm=norm, cmap=red_map), ax=axes[0], label=r\"Coupling $J/g$\", ticks=[0, 0.5, 1, 1.5], fraction=0.05, pad=0.03)\n", + "axes[1].fill_between(couplings, entropies, color=red_map(0.35), alpha=0.3)\n", + "axes[1].plot(couplings, entropies, \"o-\", color=red_map(0.95), lw=2.2, ms=4, markerfacecolor=\"white\")\n", + "axes[1].set(xlabel=r\"Coupling $J/g$\", ylabel=r\"Memory entropy $S_V$ (nats)\", xlim=(-0.03, 1.53), ylim=(-0.01, 0.34), xticks=[0, 0.5, 1, 1.5])\n", + "for label, ax in zip((\"(a)\", \"(b)\"), axes, strict=True):\n", + " ax.text(0.02, 1.03, label, transform=ax.transAxes, va=\"bottom\", fontweight=\"bold\")\n", + " ax.spines[[\"top\", \"right\"]].set_visible(False)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "**Coupling creates additional response modes, with a peak inside this sweep.**\n", + "(a) Darker curves show larger $J$. The uncoupled probe has one retained mode;\n", + "coupled dynamics distribute weight among additional modes. (b) The entropy\n", + "reaches a maximum near $J/g=1.4$, then decreases. Stronger coupling does not\n", + "imply a larger entropy for a fixed schedule and probe grid.\n", + "\n", + "These weights describe the responses accessible through the chosen experiment.\n", + "They are not environment populations or Schmidt weights of a mixed state. A\n", + "small entropy means that a few modes dominate these responses; it does not prove\n", + "that every possible experiment would find the environment memoryless. Changing\n", + "the interval, temporal cut, or controls can reveal different memory. The\n", + "spectrum alone also does not certify that the memory is quantum rather than\n", + "classical.\n", + "\n", + "## Further experiments\n", + "\n", + "(reset-delay)=\n", + "\n", + "### How long does memory persist under resets?\n", + "\n", + "A causal break interrupts the probe once. Repeated resets ask whether the\n", + "selected histories remain distinguishable after a longer interruption. With\n", + "`delay=N`, YAQS measures the selected history outcome and prepares $|0\\rangle$,\n", + "inserts $N$ selected-zero measure–prepare resets, then measures zero once more\n", + "before the sampled future preparation. The environment evolves between all these\n", + "interventions." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "hamiltonian = Hamiltonian.ising(length, J=1.0, g=1.0)\n", + "delays = np.arange(7)\n", + "delay_memories = []\n", + "for delay in delays:\n", + " delay_memories.append(characterizer.characterize(\n", + " hamiltonian,\n", + " params,\n", + " num_interventions=num_interventions,\n", + " cut=cut,\n", + " delay=int(delay),\n", + " probe_set=anchor,\n", + " ))\n", + "delay_entropies = [memory.entropy(cut) for memory in delay_memories]" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "Every integer delay, including zero, uses separate boundary interventions. The\n", + "sequence has `num_interventions + delay + 1` interventions and one more\n", + "evolution interval. Omitting `delay` uses the standard single causal break, so\n", + "the ordinary result and the `delay=0` result have different schedules." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:35:14.540059\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " Selected-zero reset slots\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.05\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.10\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.15\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.20\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.25\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.30\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " M\n", + " e\n", + " m\n", + " o\n", + " r\n", + " y\n", + "  \n", + " e\n", + " n\n", + " t\n", + " r\n", + " o\n", + " p\n", + " y\n", + "  \n", + "  \n", + " (\n", + " n\n", + " a\n", + " t\n", + " s\n", + " )\n", + " S\n", + " V\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(4.5, 2.6), layout=\"constrained\")\n", + "ax.plot(delays, delay_entropies, \"o-\", color=\"#225c80\", lw=2, ms=5, markerfacecolor=\"white\")\n", + "ax.fill_between(delays, delay_entropies, color=\"#225c80\", alpha=0.12)\n", + "ax.set(xlabel=\"Selected-zero reset slots\", ylabel=r\"Memory entropy $S_V$ (nats)\", xticks=delays, ylim=(0, None))\n", + "ax.spines[[\"top\", \"right\"]].set_visible(False)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "**The conditioned memory varies nonmonotonically with reset delay.** The finite\n", + "spin environment continues to evolve and can return information to the probe.\n", + "These resets retain particular outcomes rather than averaging over every\n", + "measurement result. Their joint probability contributes to $V$, so this is a\n", + "conditioned persistence experiment. It does not establish an all-outcome memory\n", + "length. Explicit `delay` is supported for Hamiltonian characterization.\n", + "\n", + "### What changes when we add dephasing?\n", + "\n", + "The Hamiltonian `characterize` path does not accept a `NoiseModel`. To include\n", + "Markovian noise, first reconstruct a **dense process tensor**, which records the\n", + "response to interventions, then pass that tensor to `characterize`. We use two\n", + "spins and one causal break to keep this exhaustive reconstruction small. Two\n", + "evolution intervals of 0.5 surround the break; the probe and its one-spin\n", + "environment retain $J=g=1$." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-13", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Dephasing rate 0: cut=1: S_V=0.0320, modes=1.033\n", + "Dephasing rate 1: cut=1: S_V=0.0035, modes=1.004\n", + "Dephasing rate 4: cut=1: S_V=0.0002, modes=1.000\n" + ] + } + ], + "source": [ + "from mqt.yaqs import NoiseModel\n", + "\n", + "short_hamiltonian = Hamiltonian.ising(2, J=1.0, g=1.0)\n", + "noise_params = AnalogSimParams(elapsed_time=0.5, dt=0.025, preset=\"fast\", random_seed=7)\n", + "dephasing_rates = [0.0, 1.0, 4.0]\n", + "process_tensors = []\n", + "noise_memories = []\n", + "short_anchor = None\n", + "for rate in dephasing_rates:\n", + " noise = None if rate == 0 else NoiseModel([{\"name\": \"pauli_z\", \"sites\": [0], \"strength\": rate}])\n", + " process = characterizer.build_process_tensor(\n", + " short_hamiltonian,\n", + " noise_params,\n", + " timesteps=[0.5, 0.5],\n", + " return_type=\"dense\",\n", + " noise_model=noise,\n", + " num_trajectories=512,\n", + " )\n", + " memory = characterizer.characterize(\n", + " process,\n", + " cut=1,\n", + " preset=\"quick\",\n", + " probe_set=short_anchor,\n", + " rng=np.random.default_rng(7),\n", + " )\n", + " short_anchor = memory if short_anchor is None else short_anchor\n", + " process_tensors.append(process)\n", + " noise_memories.append(memory)\n", + "\n", + "for rate, memory in zip(dephasing_rates, noise_memories, strict=True):\n", + " print(f\"Dephasing rate {rate:g}: {memory.summary()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "Here `strength=rate` is a Lindblad rate, with jump operator\n", + "$L=\\sqrt{\\gamma}\\,Z_0$ and dissipator $\\gamma(Z_0\\rho Z_0-\\rho)$. `timesteps`\n", + "defines the two evolution intervals, while `noise_params.dt` sets the\n", + "integration step within them. Each of the 16 tomography sequences averages 512\n", + "trajectories for nonzero noise. The noiseless reconstruction uses one." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cell-15", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:35:17.887365\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " Mode index\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " −\n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " 0\n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " R\n", + " e\n", + " t\n", + " a\n", + " i\n", + " n\n", + " e\n", + " d\n", + "  \n", + " m\n", + " o\n", + " d\n", + " e\n", + "  \n", + " w\n", + " e\n", + " i\n", + " g\n", + " h\n", + " t\n", + "  \n", + " p\n", + " k\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a)\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " γ\n", + " g\n", + " /\n", + " =\n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " γ\n", + " g\n", + " /\n", + " =\n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " γ\n", + " g\n", + " /\n", + " =\n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " D\n", + " e\n", + " p\n", + " h\n", + " a\n", + " s\n", + " i\n", + " n\n", + " g\n", + "  \n", + " r\n", + " a\n", + " t\n", + " e\n", + "  \n", + " /\n", + " γ\n", + " g\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.01\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.02\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.03\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " M\n", + " e\n", + " m\n", + " o\n", + " r\n", + " y\n", + "  \n", + " e\n", + " n\n", + " t\n", + " r\n", + " o\n", + " p\n", + " y\n", + "  \n", + "  \n", + " (\n", + " n\n", + " a\n", + " t\n", + " s\n", + " )\n", + " S\n", + " V\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b)\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.6), layout=\"constrained\")\n", + "noise_colors = [\"#225c80\", \"#b78730\", \"#bb563b\"]\n", + "for rate, memory, color in zip(dephasing_rates, noise_memories, noise_colors, strict=True):\n", + " spectrum = memory.singular_values(1)\n", + " weights = spectrum**2 / np.sum(spectrum**2)\n", + " axes[0].semilogy(np.arange(1, len(weights) + 1), weights, \"o-\", color=color, ms=4, lw=1.6, label=rf\"$\\gamma/g={rate:g}$\")\n", + "axes[0].set(xlabel=\"Mode index\", ylabel=r\"Retained mode weight $p_k$\", xticks=[1, 2, 3, 4], ylim=(1e-8, 2))\n", + "axes[0].legend(frameon=False, fontsize=9, loc=\"lower left\")\n", + "axes[1].bar(np.arange(3), [memory.entropy(1) for memory in noise_memories], color=noise_colors, width=0.6)\n", + "axes[1].set(xlabel=r\"Dephasing rate $\\gamma/g$\", ylabel=r\"Memory entropy $S_V$ (nats)\", xticks=np.arange(3), xticklabels=[\"0\", \"1\", \"4\"])\n", + "for label, ax in zip((\"(a)\", \"(b)\"), axes, strict=True):\n", + " ax.text(0.02, 1.03, label, transform=ax.transAxes, va=\"bottom\", fontweight=\"bold\")\n", + " ax.spines[[\"top\", \"right\"]].set_visible(False)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-16", + "metadata": {}, + "source": [ + "**Dephasing reduces the weight outside the leading response mode.** (a) The\n", + "leading mode gains relative weight as the dephasing rate increases. (b) The\n", + "sampled entropy falls. These values describe the two-spin, one-break schedule\n", + "and should not be compared directly with the earlier four-intervention sweep.\n", + "Small spectral weights can reflect trajectory sampling error; they do not all\n", + "establish resolved physical modes. Increase the trajectory count and refine the\n", + "integration step before interpreting the smallest weights.\n", + "\n", + "Coupling, resets, and added dephasing answer different questions about the same\n", + "physical issue: which traces of earlier probe choices can affect later\n", + "responses? Keep the probes and schedule fixed when comparing models, and check\n", + "whether the observed spectrum persists as numerical and sampling accuracy\n", + "improve.\n", + "\n", + "## Further options\n", + "\n", + "### Probe coverage and representations\n", + "\n", + "`preset=\"balanced\"` uses a $32\\times32$ grid and `\"accurate\"` uses\n", + "$128\\times128$. Set `n_pasts` and `n_futures` to choose counts explicitly.\n", + "Larger grids test more controls; they are not extra trajectories of the same\n", + "experiment. Check probe coverage as well as evolution accuracy.\n", + "\n", + "Use `intervention_style=\"clifford\"` for random single-qubit Clifford controls,\n", + "or `\"measure_prepare\"` for rank-one measurement and preparation on every leg.\n", + "The default `\"haar\"` uses random unitaries away from the cut. The choice changes\n", + "what memory the experiment can resolve.\n", + "\n", + "Pass `cuts=[...]` or `cuts=\"all\"` to compare temporal cuts. Each cut needs its\n", + "own probe geometry, so `probe_set` reuse is restricted to a single cut.\n", + "`representation=\"auto\"` on `MemoryCharacterizer` selects vectors up to ten\n", + "qubits and MPS above that size. Set `\"vector\"` or `\"mps\"` explicitly when\n", + "needed. `initial_psi` replaces the default all-zero state for Hamiltonian\n", + "characterization. See {class}`~mqt.yaqs.MemoryCharacterizer` for execution and\n", + "accuracy options.\n", + "\n", + "### Inspecting response modes\n", + "\n", + "Use `memory.response_matrix(cut)` to inspect $V$. The full compact-SVD factors\n", + "from `left_singular_vectors`, `singular_values_full`, and\n", + "`right_singular_vectors` satisfy $V=U\\operatorname{diag}(s)W^\\dagger$. The\n", + "columns of $U$ describe future responses, while the columns of $W$ combine\n", + "histories. Directions paired with an unresolved tail are not resolved modes, and\n", + "vectors inside a degenerate singular subspace are not unique.\n", + "\n", + "### Process tensors and temporal entanglement\n", + "\n", + "`build_process_tensor` defaults to direct MPO construction for noiseless models.\n", + "Use `return_type=\"dense\"` for added noise, as above. Both constructions grow\n", + "with $16^k$ intervention sequences or histories, so reserve them for short\n", + "horizons. Direct construction retains all branches by default; `compress_every`\n", + "controls an accumulation batch, not the total number of histories.\n", + "\n", + "A process tensor also provides `compute_temporal_entropy(cut)`, `qmi`, and\n", + "`cmi`. These describe the multi-time process, while $S_V$ describes the sampled\n", + "probe responses. They are distinct quantities. Reuse the noiseless short-horizon\n", + "tensor to compute its temporal operator-Schmidt entropy:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cell-17", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Temporal entropy S_PT: 0.0762\n", + "Response entropy S_V: 0.0320\n" + ] + } + ], + "source": [ + "temporal = process_tensors[0].compute_temporal_entropy(1)\n", + "print(f\"Temporal entropy S_PT: {temporal['entropy']:.4f}\")\n", + "print(f\"Response entropy S_V: {noise_memories[0].entropy(1):.4f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-18", + "metadata": {}, + "source": [ + "Both entropies use natural logarithms. Their values differ because they describe\n", + "different objects. The MPO implementations of these diagnostics currently\n", + "densify the tensor. Dense storage alone takes 64 MiB at five intervention legs\n", + "and 1 GiB at six, before analysis workspace. Operational probing of an MPO\n", + "process tensor does not require this conversion.\n", + "\n", + "```{warning}\n", + "A finite `max_bond_dim` in direct process-tensor construction enables an\n", + "experimental approximation that can violate positivity and causal normalization.\n", + "Keep the supported default `None` for scientific references. Noisy tomography\n", + "also has finite-sample error; validate reconstructed responses before using them\n", + "as a reference.\n", + "```\n", + "\n", + "For predicting dynamics under new controls with a trained model, see\n", + "{doc}`memory_surrogate`. Surrogate characterization requires `initial_rho`, the\n", + "site-0 state after initial evolution and before the first intervention. Use the\n", + "reference process tensor's `initial_rho` when the surrogate was trained against\n", + "that tensor, and reuse the same `probe_set` for a direct comparison." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 42, + 51, + 73, + 87, + 109, + 126, + 148, + 180, + 209, + 223, + 230, + 238, + 256, + 288, + 296, + 312, + 372, + 376 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/circuit_observables.ipynb b/docs/_outputs/examples/circuit_observables.ipynb new file mode 100644 index 000000000..336d1b1bb --- /dev/null +++ b/docs/_outputs/examples/circuit_observables.ipynb @@ -0,0 +1,5196 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Observable-Based Circuit Simulation\n", + "\n", + "The excitation transport in {doc}`analog_simulation` can also be simulated with\n", + "a quantum circuit. We use the same **20-site XY chain**, localized excitation,\n", + "and relaxation rates, then replace continuous Hamiltonian evolution with short\n", + "sequences of exchange gates. Sampling observables between these sequences lets\n", + "us reconstruct the occupation heatmaps and compare digital and analog dynamics.\n", + "\n", + "The example uses the standard YAQS installation and public interfaces. Run the\n", + "cells in order in a notebook; in a script, use the entry-point guard in\n", + "{doc}`simulator_initialization`. For bitstring counts rather than expectation\n", + "values, see {doc}`circuit_shots`.\n", + "\n", + "## 1. Turn the XY Hamiltonian into gates\n", + "\n", + "The Hamiltonian exchanges excitations between neighboring sites,\n", + "\n", + "$$\n", + "H=-\\frac{1}{2}\\sum_{i=0}^{L-2}(X_iX_{i+1}+Y_iY_{i+1}).\n", + "$$\n", + "\n", + "As in the analog guide, we set the hopping amplitude to one and use $\\hbar=1$.\n", + "An exchange gate on sites $i$ and $i+1$ implements their contribution to the\n", + "evolution. Qiskit's `XXPlusYYGate(-2 * duration)` gives\n", + "$\\exp[+i\\,\\mathtt{duration}(XX+YY)/2]$, with the sign set by this Hamiltonian.\n", + "\n", + "Gates on overlapping bonds do not commute. We approximate a time step with half\n", + "a step on even bonds, a full step on odd bonds, then another half step on even\n", + "bonds. This symmetric Trotter formula approaches the Hamiltonian dynamics as the\n", + "step decreases. Each exchange gate preserves excitation number, including when\n", + "noise acts between gates." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from qiskit import QuantumCircuit\n", + "from qiskit.circuit.library import XXPlusYYGate\n", + "\n", + "\n", + "def xy_circuit(length, dt, steps):\n", + " \"\"\"Build a symmetric XY Trotter circuit and count gate exposures per step.\"\"\"\n", + " step_circuit = QuantumCircuit(length)\n", + " gate_exposures = np.zeros(length, dtype=int)\n", + " for parity, fraction in ((0, 0.5), (1, 1.0), (0, 0.5)):\n", + " for site in range(parity, length - 1, 2):\n", + " step_circuit.append(XXPlusYYGate(-2 * dt * fraction), [site, site + 1])\n", + " gate_exposures[[site, site + 1]] += 1\n", + "\n", + " circuit = QuantumCircuit(length)\n", + " for step in range(steps):\n", + " circuit.compose(step_circuit, inplace=True)\n", + " if step < steps - 1:\n", + " circuit.barrier(label=\"SAMPLE_OBSERVABLES\")\n", + " return circuit, gate_exposures\n", + "\n", + "\n", + "length = 20\n", + "dt = 0.25\n", + "steps = 12\n", + "circuit, gate_exposures = xy_circuit(length, dt, steps)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "The circuit represents evolution to $t=3$. Labelled barriers separate the\n", + "Trotter steps so YAQS can sample observables at the same 13 times as the analog\n", + "guide. `gate_exposures` counts each site's two-qubit gates in one step; we will\n", + "use these counts to match the relaxation rate.\n", + "\n", + "## 2. Prepare the state and observables\n", + "\n", + "Start with one excitation at site 10. Measuring $Z_i$ at every site gives the\n", + "occupation through $\\langle n_i\\rangle=(1-\\langle Z_i\\rangle)/2$." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import DigitalSimParams, NoiseModel, Observable, Simulator, State\n", + "\n", + "center = length // 2\n", + "basis = \"0\" * center + \"1\" + \"0\" * (length - center - 1)\n", + "state = State(length, initial=\"basis\", basis_string=basis)\n", + "observables = [Observable(\"z\", site) for site in range(length)]\n", + "params = DigitalSimParams(\n", + " observables=observables,\n", + " sample_layers=True,\n", + " num_traj=16,\n", + " preset=\"fast\",\n", + " random_seed=7,\n", + ")\n", + "sim = Simulator(show_progress=False)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "`sample_layers=True` records observables at the circuit start, at each barrier\n", + "labelled `SAMPLE_OBSERVABLES`, and after the final gates. Barrier labels are\n", + "case-insensitive; unlabelled barriers do not trigger sampling. These checkpoints\n", + "evaluate expectations without collapsing the state, unlike hardware mid-circuit\n", + "measurements.\n", + "\n", + "The noisy results average 16 trajectories, while a noiseless run uses one.\n", + "Parallel execution remains enabled. The documentation suppresses progress bars;\n", + "omit `show_progress=False` to see them. The preset controls numerical\n", + "tolerances, while `num_traj` controls sampling uncertainty.\n", + "\n", + "## 3. Match relaxation to circuit steps\n", + "\n", + "The analog model uses a uniform relaxation rate $\\gamma$ per unit of physical\n", + "time. Circuit noise instead acts for one unit of noise time after each gate on\n", + "two or more qubits, using only processes supported on that gate's qubits.\n", + "Single-qubit gates and idle sites receive no noise.\n", + "\n", + "Using the same numerical strength for every gate would make damping depend on\n", + "the number of gates rather than the represented duration. For a site involved in\n", + "$m_i$ gates per Trotter step, set its circuit strength to\n", + "\n", + "$$\n", + "\\mathtt{strength}_i=\\frac{\\gamma\\,\\Delta t}{m_i}.\n", + "$$\n", + "\n", + "Here the end sites encounter two gates per step and interior sites encounter\n", + "three. Dividing by these counts gives each site a total relaxation exposure of\n", + "$\\gamma\\Delta t$ per step. Noise is still interleaved with the gates, so the\n", + "finite-step evolution can differ from the continuous analog model. This rate\n", + "mapping describes a digital approximation to that model; hardware gate noise\n", + "should instead follow the device and its compiled operations." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "rates = [0.0, 0.5, 1.5, 4.0]\n", + "results = {}\n", + "for rate in rates:\n", + " noise = None if rate == 0 else NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site],\n", + " \"strength\": rate * dt / gate_exposures[site]}\n", + " for site in range(length)\n", + " ])\n", + " results[rate] = sim.run(state, circuit, params, noise)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "## 4. Read the sampled dynamics\n", + "\n", + "`expectation_values` contains one NumPy array per observable. The observable\n", + "order follows the supplied list, and each array follows checkpoint order.\n", + "Combine the arrays and convert $Z_i$ to occupation:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(4, 20, 13)\n" + ] + } + ], + "source": [ + "times = dt * np.arange(steps + 1)\n", + "occupations = np.stack([\n", + " (1 - np.asarray(results[rate].expectation_values).real) / 2\n", + " for rate in rates\n", + "])\n", + "print(occupations.shape)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "The shape is `(4, 20, 13)`: relaxation rates by sites by checkpoints. Digital\n", + "results can store complex values; `.real` selects the real expectation of these\n", + "Hermitian observables. `result.trajectories` retains the per-trajectory data.\n", + "\n", + "A standalone circuit has `result.times=None` because its gates do not define\n", + "physical durations. The `times` array above assigns physical times from our\n", + "Trotter construction. For final observables only, omit `sample_layers=True`;\n", + "each observable then has one sample. To collect counts too, supply `shots` and\n", + "YAQS distributes that total budget across the noisy observable trajectories.\n", + "\n", + "## 5. Reconstruct the transport heatmaps\n", + "\n", + "The following panels use the analog guide's time window, noise strengths, and\n", + "shared square-root color scale. They show absolute occupation, without\n", + "normalizing by the remaining excitation." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:36:06.132278\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib.colors import PowerNorm\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\",\n", + " \"font.serif\": [\"STIXGeneral\"],\n", + " \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 11,\n", + " \"axes.labelsize\": 11,\n", + " \"axes.linewidth\": 0.7,\n", + " \"xtick.labelsize\": 10,\n", + " \"ytick.labelsize\": 10,\n", + " \"xtick.direction\": \"in\",\n", + " \"ytick.direction\": \"in\",\n", + " \"xtick.top\": True,\n", + " \"ytick.right\": True,\n", + " \"legend.fontsize\": 9,\n", + " \"legend.frameon\": False,\n", + " \"lines.linewidth\": 1.6,\n", + " \"figure.constrained_layout.use\": True,\n", + " \"savefig.dpi\": 180,\n", + "})\n", + "fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.4), sharex=True, sharey=True)\n", + "for ax, values, rate, panel in zip(axes.flat, occupations, rates, \"abcd\", strict=True):\n", + " image = ax.pcolormesh(times, np.arange(length), values, shading=\"auto\",\n", + " cmap=\"cividis\", norm=PowerNorm(0.5, vmin=0, vmax=1),\n", + " rasterized=True)\n", + " label = \"Noiseless\" if rate == 0 else rf\"$\\gamma={rate:g}$\"\n", + " ax.set_title(f\"({panel}) {label}\", loc=\"left\", fontsize=11)\n", + " ax.set(xticks=[0, 1, 2, 3], yticks=[0, 5, 10, 15, 19])\n", + "for ax in axes[-1]:\n", + " ax.set_xlabel(r\"Represented time $t$\")\n", + "for ax in axes[:, 0]:\n", + " ax.set_ylabel(r\"Site $i$\")\n", + "fig.colorbar(image, ax=axes.ravel().tolist(),\n", + " label=r\"Occupation $\\langle n_i\\rangle$\", ticks=[0, 0.1, 0.5, 1])\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "**Digital reconstruction of excitation transport and loss.** Exchange gates\n", + "spread the initial excitation along the chain, while increasing relaxation\n", + "suppresses occupation at later times. The broad patterns reproduce the analog\n", + "example, with differences from Trotter splitting and finite trajectory sampling.\n", + "\n", + "The heatmaps alone do not tell us how close the circuit is to Hamiltonian\n", + "evolution. To separate the approximation in the gates from sampling noise, we\n", + "next compare the noiseless circuit with an analog reference and a finer circuit.\n", + "\n", + "## 6. Compare with analog evolution\n", + "\n", + "Run the same Hamiltonian without noise, then halve the circuit step while\n", + "keeping the total duration fixed. Select every second checkpoint of the finer\n", + "circuit to compare at the original sampling times." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import AnalogSimParams, Hamiltonian\n", + "\n", + "hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0)\n", + "analog_params = AnalogSimParams(\n", + " observables=observables, elapsed_time=steps * dt, dt=dt, preset=\"fast\",\n", + ")\n", + "analog = sim.run(state, hamiltonian, analog_params)\n", + "analog_occupation = (1 - np.asarray(analog.expectation_values)) / 2\n", + "\n", + "fine_circuit, _ = xy_circuit(length, dt / 2, steps * 2)\n", + "fine_result = sim.run(state, fine_circuit, params)\n", + "fine_occupation = (1 - np.asarray(fine_result.expectation_values).real[:, ::2]) / 2" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "The first panel compares final occupation profiles. The second sums occupation\n", + "over sites and compares noisy circuit estimates with the analog model's decay\n", + "law, $N(t)=e^{-\\gamma t}$. This law holds because the initial state contains one\n", + "excitation, the Hamiltonian conserves excitation, and relaxation is uniform." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-13", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:36:06.757573\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.2))\n", + "ax = axes[0]\n", + "for values, label, style, color in (\n", + " (analog_occupation, \"Analog\", \"-\", \"0.2\"),\n", + " (occupations[0], r\"Circuit $\\Delta t=0.25$\", \"o\", \"#0072B2\"),\n", + " (fine_occupation, r\"Circuit $\\Delta t=0.125$\", \"+\", \"#D55E00\"),\n", + "):\n", + " ax.plot(np.arange(length), values[:, -1], style, color=color,\n", + " label=label, markersize=4)\n", + "ax.set(xlabel=\"Site\", ylabel=r\"Final occupation $\\langle n_i\\rangle$\",\n", + " xticks=[0, 5, 10, 15, 19])\n", + "ax.set_title(\"(a) Noiseless transport\", loc=\"left\", fontsize=11)\n", + "ax.legend(loc=\"upper center\", fontsize=8)\n", + "\n", + "ax = axes[1]\n", + "colors = [\"0.2\", \"#0072B2\", \"#009E73\", \"#D55E00\"]\n", + "fine_times = np.linspace(0, times[-1], 200)\n", + "for rate, values, color in zip(rates, occupations, colors, strict=True):\n", + " ax.plot(times, values.sum(axis=0), \"o\", color=color, markersize=3,\n", + " label=\"Noiseless\" if rate == 0 else rf\"$\\gamma={rate:g}$\")\n", + " ax.plot(fine_times, np.exp(-rate * fine_times), \"--\", color=color, linewidth=1)\n", + "ax.set(xlabel=r\"Represented time $t$\", ylabel=r\"Total excitation $N(t)$\",\n", + " xlim=(0, 3), ylim=(0, 1.08), xticks=[0, 1, 2, 3])\n", + "ax.set_title(\"(b) Excitation survival\", loc=\"left\", fontsize=11)\n", + "ax.legend(ncols=2, loc=\"upper right\", bbox_to_anchor=(1, 0.9), fontsize=8)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "**Analog and digital transport compared at the same duration.** The final\n", + "noiseless profiles agree closely, and a smaller Trotter step reduces the\n", + "circuit's splitting error. Noisy survival estimates follow the uniform-loss\n", + "decay within the resolution of this 16-trajectory example; dashed lines show the\n", + "continuous-model reference.\n", + "\n", + "This comparison connects the two workflows through their physical model, rather\n", + "than equating a circuit's gate count with elapsed time. Check Trotter-step\n", + "convergence separately from MPS tolerances and trajectory uncertainty. For noisy\n", + "refinement, rebuild the circuit and rescale the strengths using the new step and\n", + "gate exposures. Finite-step noise splitting can also affect the spatial profile;\n", + "agreement of total excitation alone does not validate that profile.\n", + "\n", + "(circuit-qasm-inputs)=\n", + "\n", + "## 7. OpenQASM inputs\n", + "\n", + "Pass an OpenQASM 2 source string (or file path) directly to\n", + "{meth}`~mqt.yaqs.Simulator.run` instead of building a\n", + "{class}`qiskit.circuit.QuantumCircuit` in Python. Custom gate bodies declared in\n", + "the program are translated like any other Qiskit operation." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cell-15", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import DigitalSimParams\n", + "\n", + "qasm = \"\"\"\n", + "OPENQASM 2.0;\n", + "include \"qelib1.inc\";\n", + "\n", + "gate entangle a,b {\n", + " h a;\n", + " cx a,b;\n", + "}\n", + "\n", + "qreg q[2];\n", + "entangle q[0], q[1];\n", + "\"\"\"\n", + "\n", + "qasm_state = State(2, initial=\"zeros\")\n", + "qasm_result = sim.run(\n", + " qasm_state,\n", + " qasm,\n", + " DigitalSimParams(shots=128, max_bond_dim=4),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-16", + "metadata": {}, + "source": [ + "OpenQASM 3 requires `uv pip install mqt-yaqs[qasm3]`.\n", + "{class}`~mqt.yaqs.EquivalenceChecker` accepts the same path and string forms;\n", + "see {doc}`equivalence_checking`.\n", + "\n", + "## 8. Gate application modes\n", + "\n", + "`DigitalSimParams.gate_mode` selects how two-qubit gates are applied to the MPS.\n", + "The default `\"mpo\"` uses extended gate MPOs for long-range pairs; `\"tdvp\"` uses\n", + "a local TDVP window when an analytic generator is available. See\n", + "{doc}`simulation_parameters` for the available modes and\n", + "{ref}`circuit-custom-gates` for matrix-backed gates.\n", + "\n", + "Below, a long-range `cx` on qubits 0 and 2 is simulated noiselessly with both\n", + "modes:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cell-17", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'mpo': 0.0, 'tdvp': 0.0}\n" + ] + } + ], + "source": [ + "lr_qc = QuantumCircuit(3)\n", + "lr_qc.h(0)\n", + "lr_qc.cx(0, 2)\n", + "\n", + "lr_state = State(3, initial=\"zeros\")\n", + "z0_by_mode = {}\n", + "for mode in (\"mpo\", \"tdvp\"):\n", + " mode_params = DigitalSimParams(\n", + " observables=[Observable(\"z\", 0)],\n", + " num_traj=1,\n", + " gate_mode=mode,\n", + " max_bond_dim=8,\n", + " )\n", + " mode_result = sim.run(lr_state, lr_qc, mode_params)\n", + " z0_by_mode[mode] = float(np.real(mode_result.expectation_values[0][0]))\n", + "\n", + "print({mode: round(value, 4) for mode, value in z0_by_mode.items()})" + ] + }, + { + "cell_type": "markdown", + "id": "cell-18", + "metadata": {}, + "source": [ + "(circuit-custom-gates)=\n", + "\n", + "## 9. Supply custom gates\n", + "\n", + "Add a custom unitary to a Qiskit circuit with `UnitaryGate`. YAQS translates the\n", + "matrix automatically, so no gate registration is needed. This two-qubit example\n", + "applies a phase only to the $|11\\rangle$ component:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "cell-19", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0.9605305+0.j]\n" + ] + } + ], + "source": [ + "from qiskit.circuit.library import UnitaryGate\n", + "\n", + "custom_unitary = np.diag([1, 1, 1, np.exp(0.4j)])\n", + "custom_circuit = QuantumCircuit(2)\n", + "custom_circuit.h([0, 1])\n", + "custom_circuit.append(UnitaryGate(custom_unitary), [0, 1])\n", + "\n", + "custom_params = DigitalSimParams(observables=[Observable(\"x\", 0)])\n", + "custom_sim = Simulator(show_progress=False)\n", + "custom_result = custom_sim.run(State(2, initial=\"zeros\"), custom_circuit, custom_params)\n", + "print(custom_result.expectation_values[0])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-20", + "metadata": {}, + "source": [ + "The final expectation is $\\langle X_0\\rangle=(1+\\cos 0.4)/2$, about 0.9605. The\n", + "matrix uses Qiskit's qubit ordering; YAQS converts it to its internal gate\n", + "layout. The same input works with `shots`, noise, and sampling checkpoints under\n", + "the circuit rules described above.\n", + "\n", + "Custom gate bodies in {ref}`circuit-qasm-inputs` follow the same translation\n", + "path. Unknown unitary operations use a matrix fallback on up to eight qubits;\n", + "decompose larger operations first. A matrix-backed gate has no analytic\n", + "generator: TDVP gate modes use direct local updates for adjacent pairs and the\n", + "MPO path for separated sites or larger gates. Keep `gate_mode=\"mpo\"` unless you\n", + "need another method.\n", + "\n", + ":::{dropdown} Supported instructions and gate translation\n", + "\n", + "Bind symbolic Qiskit parameters before simulation. YAQS translates known gate\n", + "names through its gate library; other operations must provide a unitary matrix\n", + "through Qiskit's `to_matrix()` or `Operator`. This also supports gates defined\n", + "by a reusable Qiskit circuit or an OpenQASM gate body. Use a distinct name for a\n", + "custom operation, since a name matching a built-in alias selects that built-in\n", + "implementation.\n", + "\n", + "Terminal measurements are removed before simulation; request `shots` for\n", + "readout. Measurements followed by further operations on the measured qubits are\n", + "unsupported. `reset`, `delay`, `store`, classical conditions, and control-flow\n", + "instructions are also unsupported. Ordinary barriers do not change the state;\n", + "barriers labelled `SAMPLE_OBSERVABLES` mark sampling points.\n", + "\n", + "Digital gates require qubit target sites. Idle sites can have other local\n", + "dimensions, but `gate_mode=\"swaps\"` cannot route through a non-qubit site.\n", + "\n", + "Built-in `ccx`, `ccz`, and `cswap` gates translate without decomposition.\n", + "Simulation applies gates on three or more qubits through an MPO, except\n", + "supported product generators such as `ccx` and `ccz` in TDVP modes. The\n", + "`\"swaps\"` mode also uses the MPO path for these larger gates.\n", + "\n", + "`EquivalenceChecker` accepts the same unitary and OpenQASM inputs. Gates on more\n", + "than two qubits require its `\"matrix\"` backend; decompose them before using the\n", + "`\"mpo\"` backend. See {doc}`equivalence_checking` for backend choice and\n", + "measurement restrictions. Translation details and the supported alias list are\n", + "in {mod}`~mqt.yaqs.digital.utils.dag_utils`.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} Low-level gate objects\n", + "\n", + "Application code should supply Qiskit circuits. For code that works directly\n", + "with YAQS gate kernels, `GateLibrary.custom` constructs a matrix-backed\n", + "{class}`~mqt.yaqs.core.libraries.gate_library.BaseGate`:\n", + "\n", + "```python\n", + "from mqt.yaqs.core.libraries.gate_library import GateLibrary\n", + "\n", + "gate = GateLibrary.custom(np.eye(4, dtype=complex))\n", + "gate.name = \"my_gate\"\n", + "gate.set_sites(0, 1)\n", + "```\n", + "\n", + "This constructor checks that the matrix is square with dimension $2^n$. The\n", + "caller must supply a finite unitary. The resulting fields are:\n", + "\n", + "| Field | Meaning |\n", + "| ------------- | -------------------------------------------------------------- |\n", + "| `matrix` | Gate matrix in YAQS gate order. |\n", + "| `interaction` | Number of target qubits, inferred from the matrix size. |\n", + "| `sites` | Target sites in their declared order. |\n", + "| `tensor` | Gate tensor; `set_sites` reshapes gates on two or more qubits. |\n", + "| `generator` | Optional local factors for a product-form TDVP generator. |\n", + "| `name` | Gate identifier. |\n", + "\n", + "In a manually supplied gate matrix, the first tensor factor acts on the first\n", + "declared site. Qiskit translation handles its different matrix convention\n", + "automatically. Creating this object does not register a new Qiskit gate or make\n", + "it a valid operator argument to `Simulator.run`.\n", + "\n", + "Built-in gates subclass `BaseGate` and prepare tensors and optional generators\n", + "in `set_sites`. See {class}`~mqt.yaqs.core.libraries.gate_library.CX` and\n", + "{class}`~mqt.yaqs.core.libraries.gate_library.CCX` for examples.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} Product generators for digital TDVP\n", + "\n", + "A TDVP-capable gate has one $2\\times2$ generator factor per target site. The\n", + "factors define a product $G$ whose exponential at evolution time one must\n", + "reproduce the gate, $U=\\exp(-iG)$. For example, a ZZ phase rotation has\n", + "$G=(\\theta Z/2)\\otimes Z$:\n", + "\n", + "```python\n", + "from scipy.linalg import expm\n", + "\n", + "from mqt.yaqs.core.libraries.gate_library import GateLibrary\n", + "\n", + "theta = 0.3\n", + "pauli_z = np.diag([1.0, -1.0])\n", + "generator_factors = [theta * pauli_z / 2, pauli_z]\n", + "phase_unitary = expm(-1j * np.kron(*generator_factors))\n", + "phase_gate = GateLibrary.custom(phase_unitary)\n", + "phase_gate.set_sites(0, 2)\n", + "phase_gate.generator = generator_factors\n", + "```\n", + "\n", + "The factors follow the declared `sites` order. YAQS places identities between\n", + "separated factors when constructing the generator MPO. The caller must check\n", + "that the full generator is Hermitian and reproduces the unitary; YAQS does not\n", + "verify that relation. This low-level assignment does not change how a Qiskit\n", + "`UnitaryGate` is translated.\n", + "\n", + "Digital generator evolution requires `tdvp_mode=\"2site\"`. `tdvp_sweeps` divides\n", + "the total generator time of one into substeps. TDVP gate application remains\n", + "approximate and can miss required bond growth; see {doc}`simulation_parameters`\n", + "for accuracy limits. Single-qubit gates always use direct contraction. For\n", + "implementation details, see\n", + "{func}`~mqt.yaqs.digital.digital_tjm.construct_generator_mpo`.\n", + "\n", + ":::\n", + "\n", + "## Related topics\n", + "\n", + "- {doc}`digital_analog_simulation` — combine digital operations with analog\n", + " evolution in one program\n", + "- {doc}`circuit_shots` — computational-basis shot histograms with\n", + " {class}`~mqt.yaqs.DigitalSimParams`\n", + "- {doc}`realistic_noise_models` — log-normal and other distributed noise\n", + " strengths\n", + "- {doc}`equivalence_checking` — verify that two circuits implement the same\n", + " unitary\n", + "- {doc}`quickstart` — minimal analog, circuit, and equivalence-check workflows" + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 44, + 71, + 83, + 98, + 133, + 143, + 151, + 158, + 176, + 217, + 234, + 247, + 254, + 282, + 306, + 328, + 345, + 363, + 373, + 385 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/circuit_shots.ipynb b/docs/_outputs/examples/circuit_shots.ipynb new file mode 100644 index 000000000..127c923ea --- /dev/null +++ b/docs/_outputs/examples/circuit_shots.ipynb @@ -0,0 +1,3564 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Shot-Based Circuit Simulation\n", + "\n", + "Relaxation changes which bitstrings a quantum circuit produces. We study this\n", + "change by preparing a **16-qubit graph state** and sampling its readout at\n", + "several damping strengths. Grouping the outcomes by the number of excited qubits\n", + "makes the loss of excitation visible without plotting all $2^{16}$ bitstrings.\n", + "\n", + "This guide extends the circuit-readout example in {doc}`quickstart` using the\n", + "standard YAQS installation. YAQS evolves a matrix product state (MPS) through\n", + "the circuit and samples the final state in the computational basis. Run the\n", + "cells in order in a notebook. For a script, use the `if __name__ == \"__main__\":`\n", + "guard shown in {doc}`simulator_initialization`.\n", + "\n", + "## 1. Prepare the circuit and initial state\n", + "\n", + "Start from $|0\\rangle^{\\otimes16}$, apply a Hadamard gate to each qubit, then\n", + "entangle neighboring qubits with CZ gates. These gates change relative phases\n", + "without changing computational-basis probabilities, so the ideal readout has\n", + "equal probability for every bitstring." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "from qiskit import QuantumCircuit\n", + "\n", + "from mqt.yaqs import State\n", + "\n", + "num_qubits = 16\n", + "circuit = QuantumCircuit(num_qubits)\n", + "circuit.h(range(num_qubits))\n", + "for site in range(num_qubits - 1):\n", + " circuit.cz(site, site + 1)\n", + "circuit.measure_all()\n", + "\n", + "state = State(num_qubits, initial=\"zeros\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "`State` uses an MPS by default. `shots` requests final computational-basis\n", + "sampling, including when a circuit has no explicit measurement gates. YAQS\n", + "accepts the terminal measurements above; it does not support measurements\n", + "followed by further gates on the measured qubits or classical feedback.\n", + "\n", + "## 2. Set the sampling budget and damping\n", + "\n", + "Each shot produces one bitstring. We use 256 shots per run to keep the example\n", + "quick, with the `fast` preset controlling numerical tolerances." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import DigitalSimParams, NoiseModel\n", + "\n", + "params = DigitalSimParams(shots=256, preset=\"fast\", random_seed=7)\n", + "damping_rate = 1.5\n", + "noise = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": damping_rate}\n", + " for site in range(num_qubits)\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "The `lowering` channel relaxes $|1\\rangle$ toward $|0\\rangle$. In circuit\n", + "simulation, `strength` is a Lindblad rate per unit of gate noise time, rather\n", + "than a direct error probability. YAQS applies one unit of noise time after each\n", + "gate on two or more qubits, using only noise processes supported entirely on\n", + "that gate's qubits. Single-qubit gates and idle sites receive no noise.\n", + "\n", + "Here, each end qubit participates in one CZ gate, while each interior qubit\n", + "participates in two. The noise therefore acts during entangling operations,\n", + "rather than as a separate readout-error channel. Simulate a transpiled circuit\n", + "when its compiled gates should determine the noise opportunities.\n", + "\n", + "`shots` sets the total sample budget and must be supplied explicitly. In a noisy\n", + "shots-only run, YAQS uses one stochastic trajectory per shot. The noiseless run\n", + "evolves once and samples that final state repeatedly. Setting `num_traj` does\n", + "not change a shots-only budget. See {doc}`simulation_parameters` for presets and\n", + "the separate roles of shots and trajectories.\n", + "\n", + "## 3. Run the noiseless and noisy cases\n", + "\n", + "Use the same circuit, state, and parameters for both runs so that the comparison\n", + "isolates the added noise. Initialize the simulator separately, then omit the\n", + "noise model for the baseline." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import Simulator\n", + "\n", + "simulator = Simulator(show_progress=False)\n", + "ideal = simulator.run(state, circuit, params)\n", + "damped = simulator.run(state, circuit, params, noise)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "Parallel execution remains enabled by default. `show_progress=False` suppresses\n", + "bars in the documentation; omit it to see progress. The seed repeats the random\n", + "streams for jumps and sampled disorder for the same configuration, but does not\n", + "seed final readout sampling. Shot counts can therefore vary between runs.\n", + "\n", + "## 4. Read individual outcomes\n", + "\n", + "`Result.counts` maps integer outcomes to their counts. Site 0 is the\n", + "least-significant bit, so outcome 1 means that only site 0 is excited.\n", + "Formatting an outcome as binary places site 0 on the right." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0000000000000000: 137 shots, estimated probability 0.535\n", + "0000000000000001: 20 shots, estimated probability 0.078\n", + "1000000000000000: 19 shots, estimated probability 0.074\n", + "0000000000010000: 8 shots, estimated probability 0.031\n", + "0000010000000000: 7 shots, estimated probability 0.027\n" + ] + } + ], + "source": [ + "most_common = sorted(damped.counts.items(), key=lambda item: item[1], reverse=True)[:5]\n", + "for outcome, count in most_common:\n", + " bitstring = format(outcome, f\"0{num_qubits}b\")\n", + " print(f\"{bitstring}: {count} shots, estimated probability {count / params.shots:.3f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "The counts sum to 256. An outcome absent from the dictionary was not observed;\n", + "its underlying probability need not be zero. To read a specific qubit, use\n", + "`(outcome >> site) & 1`. Bitstring counts preserve spatial information that the\n", + "grouped histogram below discards.\n", + "\n", + "## 5. Compare damping strengths\n", + "\n", + "The quickstart compares a noiseless run with one damped run. Here we add two\n", + "rates to show how the distribution moves as damping increases, reusing the\n", + "baseline and strong-noise calculations above." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "rates = [0.0, 0.1, 0.5, damping_rate]\n", + "results = {0.0: ideal, damping_rate: damped}\n", + "for rate in rates[1:-1]:\n", + " rate_noise = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": rate}\n", + " for site in range(num_qubits)\n", + " ])\n", + " results[rate] = simulator.run(state, circuit, params, rate_noise)\n", + "\n", + "probabilities = np.zeros((len(rates), num_qubits + 1))\n", + "for row, rate in enumerate(rates):\n", + " for outcome, count in results[rate].counts.items():\n", + " probabilities[row, outcome.bit_count()] += count / params.shots" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "`outcome.bit_count()` gives the number of excited qubits, often called the\n", + "Hamming weight. Each row of `probabilities` has 17 bins, from zero to 16\n", + "excitations, and sums to one. Every outcome contributes, including the tails of\n", + "the distribution.\n", + "\n", + "The following histograms use shared axes. The gray outline repeats the sampled\n", + "noiseless baseline in the noisy panels so that the shift remains easy to\n", + "compare." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:36:44.061617\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\",\n", + " \"font.serif\": [\"STIXGeneral\"],\n", + " \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 11,\n", + " \"axes.labelsize\": 11,\n", + " \"axes.linewidth\": 0.7,\n", + " \"xtick.labelsize\": 10,\n", + " \"ytick.labelsize\": 10,\n", + " \"xtick.direction\": \"in\",\n", + " \"ytick.direction\": \"in\",\n", + " \"xtick.top\": True,\n", + " \"ytick.right\": True,\n", + " \"legend.fontsize\": 9,\n", + " \"legend.frameon\": False,\n", + " \"figure.constrained_layout.use\": True,\n", + " \"savefig.dpi\": 180,\n", + "})\n", + "excitation_number = np.arange(num_qubits + 1)\n", + "colors = [\"#0072B2\", \"#009E73\", \"#D55E00\", \"#CC79A7\"]\n", + "fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.6), sharex=True, sharey=True)\n", + "for ax, rate, probability, color, panel in zip(\n", + " axes.flat, rates, probabilities, colors, \"abcd\", strict=True,\n", + "):\n", + " if rate != 0:\n", + " ax.bar(excitation_number, probabilities[0], width=0.85,\n", + " facecolor=\"none\", edgecolor=\"0.55\", linewidth=0.9, label=\"Noiseless\")\n", + " ax.bar(excitation_number, probability, width=0.65, color=color,\n", + " edgecolor=\"white\", linewidth=0.4, alpha=0.9,\n", + " label=\"Noiseless\" if rate == 0 else \"Damped\")\n", + " label = \"Noiseless\" if rate == 0 else rf\"$\\gamma={rate:g}$\"\n", + " ax.set_title(f\"({panel}) {label}\", loc=\"left\", fontsize=11)\n", + " ax.set(xlim=(-0.7, num_qubits + 0.7), xticks=np.arange(0, num_qubits + 1, 4))\n", + " ax.legend(loc=\"upper right\")\n", + "for ax in axes[-1]:\n", + " ax.set_xlabel(\"Number of excited qubits\")\n", + "for ax in axes[:, 0]:\n", + " ax.set_ylabel(\"Measured probability\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "**Readout shifts toward fewer excitations as damping increases.** Each panel\n", + "contains 256 shots from the same 16-qubit circuit. The noiseless distribution is\n", + "centered near eight excitations, while strong damping concentrates probability\n", + "near zero. The baseline outlines use the same samples in all panels.\n", + "\n", + "Without noise, each qubit has excitation probability $1/2$, so the excitation\n", + "number follows a binomial distribution. The graph state's phases do not appear\n", + "in this measurement basis. A matching histogram alone therefore cannot verify\n", + "that the intended entangled state was prepared.\n", + "\n", + "With noise, the shift measures excitation loss during the CZ gates. Different\n", + "bitstrings can have the same excitation number, so use `counts` or site-resolved\n", + "observables when the spatial distribution matters. For a bin of probability $p$,\n", + "independent shots have sampling uncertainty of order\n", + "$\\sqrt{p(1-p)/\\mathtt{shots}}$. Increase `shots` to reduce these fluctuations;\n", + "use tighter presets separately to check numerical error.\n", + "\n", + "## Other measurement workflows\n", + "\n", + "To obtain expectation values instead of counts, supply `observables` on\n", + "`DigitalSimParams`; see {doc}`circuit_observables`. You can request both outputs\n", + "in one call. In a noisy combined run, `num_traj` sets the observable ensemble,\n", + "and YAQS distributes the total `shots` across those trajectories. Multiple shots\n", + "from one trajectory share its noise history, so their uncertainty differs from\n", + "independent one-shot trajectories.\n", + "\n", + "You can pass an OpenQASM source string or file path to `Simulator.run` in place\n", + "of a Qiskit circuit. OpenQASM 3 requires the `qasm3` extra. See\n", + "{doc}`circuit_observables` for an executable example, mid-circuit observable\n", + "checkpoints, and gate-application modes.\n", + "\n", + "## Related topics\n", + "\n", + "- {doc}`simulation_parameters` — sampling budgets and accuracy presets\n", + "- {doc}`realistic_noise_models` — other channels, custom operators, and disorder\n", + "- {ref}`circuit-custom-gates` — custom unitaries and gate translation\n", + "- {doc}`equivalence_checking` — compare circuit behavior" + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 180, + "number_source_lines": true + }, + "source_map": [ + 10, + 32, + 45, + 57, + 66, + 91, + 97, + 110, + 115, + 128, + 144, + 155, + 200 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/digital_analog_simulation.ipynb b/docs/_outputs/examples/digital_analog_simulation.ipynb new file mode 100644 index 000000000..dd208d2b1 --- /dev/null +++ b/docs/_outputs/examples/digital_analog_simulation.ipynb @@ -0,0 +1,11741 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Analog-Digital Simulation\n", + "\n", + "An excitation spreads through the XY chain in {doc}`analog_simulation`. Can\n", + "digital gates bring it back? Here we interrupt that same continuous Hamiltonian\n", + "evolution with a pattern of phase gates. The excitation refocuses at its\n", + "starting site, giving a direct way to see how relaxation and dephasing affect\n", + "the return.\n", + "\n", + "A `SimulationProgram` combines circuits and analog intervals in their execution\n", + "order. YAQS carries the evolving state through the whole program, including each\n", + "noisy trajectory. This guide uses the standard installation and Matplotlib for\n", + "plotting. Run the cells in order in a notebook; for a script, use the\n", + "entry-point guard in {doc}`simulator_initialization`.\n", + "\n", + "## 1. Prepare the chain with a circuit\n", + "\n", + "Use the same 20-site open XY chain and hopping amplitude as the analog and\n", + "{doc}`circuit_observables` guides,\n", + "\n", + "$$\n", + "H=-\\frac{1}{2}\\sum_{i=0}^{L-2}(X_iX_{i+1}+Y_iY_{i+1}).\n", + "$$\n", + "\n", + "Time is measured in inverse hopping units, with $\\hbar=1$. Start from an\n", + "all-zero MPS, then use a digital $X$ gate to prepare one excitation at site 10.\n", + "Site numbers match Qiskit's qubit indices." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import numpy as np\n", + "from qiskit import QuantumCircuit\n", + "\n", + "from mqt.yaqs import AnalogSimParams, DigitalSimParams, Hamiltonian, Observable, SimulationProgram, Simulator, State\n", + "\n", + "length = 20\n", + "center = length // 2\n", + "state = State(length, initial=\"zeros\")\n", + "hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0)\n", + "observables = [Observable(\"z\", site) for site in range(length)]\n", + "\n", + "preparation = QuantumCircuit(length)\n", + "preparation.x(center)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "Unlike the circuit guide, we will let YAQS evolve the Hamiltonian directly\n", + "between gates. We need no Trotter circuit to represent those intervals.\n", + "\n", + "## 2. Build the refocusing pulse\n", + "\n", + "After half the evolution, apply a $Z$ gate to every even site. The pulse changes\n", + "phases without changing site occupations at that instant. Every XY bond has\n", + "exactly one pulsed endpoint, so the combined pulse $P$ satisfies $PHP=-H$. The\n", + "subsequent evolution therefore unwinds the earlier spreading." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "phase_pulse = QuantumCircuit(length)\n", + "phase_pulse.z(range(0, length, 2))\n", + "\n", + "half_duration = 1.5\n", + "analog_params = AnalogSimParams(elapsed_time=half_duration, dt=0.25, order=2, preset=\"fast\")\n", + "digital_params = DigitalSimParams(sample_layers=True, preset=\"fast\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "The two analog intervals give a final time of $3$, matching the other transport\n", + "guides. `dt=0.25` samples seven times per interval. We use second-order TJM\n", + "evolution for the noisy comparison. `sample_layers=True` also records the\n", + "observables at each circuit's entry and exit.\n", + "\n", + "The phase pulse reverses this XY Hamiltonian because it changes the sign of\n", + "every exchange term. Added terms such as ZZ interactions generally do not\n", + "reverse under the same pulse; another Hamiltonian needs a separate check.\n", + "\n", + "## 3. Assemble and run the programs\n", + "\n", + "Each segment is an `(operator, params)` pair. A circuit selects digital\n", + "simulation, while a `Hamiltonian` selects analog evolution. Set the shared\n", + "observables and random seed on the program; leave those fields unset on the\n", + "segment parameters. We also set the trajectory budget on the program so it\n", + "applies to the complete sequence." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "free_program = SimulationProgram(\n", + " [\n", + " (preparation, digital_params),\n", + " (hamiltonian, analog_params),\n", + " (hamiltonian, analog_params),\n", + " ],\n", + " observables=observables, num_traj=32, random_seed=7,\n", + ")\n", + "echo_program = SimulationProgram(\n", + " [\n", + " (preparation, digital_params),\n", + " (hamiltonian, analog_params),\n", + " (phase_pulse, digital_params),\n", + " (hamiltonian, analog_params),\n", + " (phase_pulse, digital_params),\n", + " ],\n", + " observables=observables, num_traj=32, random_seed=7,\n", + ")\n", + "\n", + "simulator = Simulator(show_progress=False)\n", + "free_result = simulator.run(state, free_program)\n", + "echo_result = simulator.run(state, echo_program)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "The final phase pulse restores the original frame. With $U(\\tau)=\\exp(-iH\\tau)$,\n", + "the echo part obeys $PU(\\tau)PU(\\tau)=I$ in the noiseless limit. This last pulse\n", + "does not change the plotted occupations, but it also restores the phases of a\n", + "general initial state.\n", + "\n", + "YAQS preserves the input `state`, so both programs start from the same all-zero\n", + "state and apply the same preparation. Noiseless programs need only one\n", + "trajectory. Noisy programs average 32 complete trajectories, with parallel\n", + "execution enabled by default. The documentation suppresses progress bars; omit\n", + "`show_progress=False` to see them.\n", + "\n", + "## 4. Read the occupation heatmaps\n", + "\n", + "The outer result contains stitched `times` and `expectation_values`. Digital\n", + "gates are instantaneous on this timeline, so their samples share timestamps with\n", + "analog boundaries. Each entry in `segment_results` also contains the segment's\n", + "type, time offset, and local output.\n", + "\n", + "For heatmaps, collect the analog samples, add each segment's time offset, and\n", + "remove the repeated midpoint. The $Z$ pulse leaves occupation unchanged there,\n", + "so either adjacent analog sample gives the same value. The initial analog sample\n", + "already includes the digital preparation." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Return occupation: free=0.0244, echo=0.9999\n" + ] + } + ], + "source": [ + "def analog_occupations(result):\n", + " \"\"\"Extract analog times and site occupations from a program result.\"\"\"\n", + " segments = [segment for segment in result.segment_results if segment.segment_type == \"analog\"]\n", + " times = np.concatenate([segment.times + segment.time_offset for segment in segments])\n", + " values = np.concatenate([np.asarray(segment.expectation_values) for segment in segments], axis=1)\n", + " keep = np.r_[True, np.diff(times) > 0]\n", + " return times[keep], (1 - values[:, keep]) / 2\n", + "\n", + "\n", + "times, free_occupation = analog_occupations(free_result)\n", + "_, echo_occupation = analog_occupations(echo_result)\n", + "print(f\"Return occupation: free={free_occupation[center, -1]:.4f}, echo={echo_occupation[center, -1]:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "The occupation array has shape `(20, 13)`. Rows follow the observable list, and\n", + "columns follow the extracted times from $0$ to $3$." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:36:48.842221\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3.0\n", + " \n", + " \n", + " \n", + " Time\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 10\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 15\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 19\n", + " \n", + " \n", + " \n", + " Site\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) Free evolution\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3.0\n", + " \n", + " \n", + " \n", + " Time\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b) Digital refocusing\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.25\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.50\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " O\n", + " c\n", + " c\n", + " u\n", + " p\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " ⟨\n", + " ⟩\n", + " n\n", + " i\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib.colors import PowerNorm\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\", \"font.serif\": [\"STIXGeneral\"], \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 10, \"axes.labelsize\": 11, \"axes.linewidth\": 0.8,\n", + " \"xtick.direction\": \"in\", \"ytick.direction\": \"in\", \"svg.fonttype\": \"none\",\n", + " \"legend.frameon\": False,\n", + "})\n", + "occupation_norm = PowerNorm(gamma=0.5, vmin=0, vmax=1)\n", + "fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8), sharex=True, sharey=True, layout=\"constrained\")\n", + "for ax, occupation, title in zip(\n", + " axes, [free_occupation, echo_occupation], [\"(a) Free evolution\", \"(b) Digital refocusing\"], strict=True,\n", + "):\n", + " image = ax.pcolormesh(times, np.arange(length), occupation, shading=\"auto\", cmap=\"cividis\", norm=occupation_norm)\n", + " ax.set(xlabel=\"Time\", title=title, yticks=[0, 5, 10, 15, 19], xlim=(0, 3))\n", + "axes[0].set_ylabel(\"Site\")\n", + "axes[1].axvline(half_duration, color=\"white\", linestyle=\"--\", linewidth=1)\n", + "fig.colorbar(image, ax=axes, label=r\"Occupation $\\langle n_i\\rangle$\", ticks=[0, 0.25, 0.5, 1], shrink=0.9)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "**The phase pulse brings the spreading excitation back.** Both programs follow\n", + "the same dynamics until $t=1.5$. Free evolution continues to spread, while the\n", + "pulsed program refocuses at site 10 at $t=3$. The white dashed line marks the\n", + "midpoint pulse. Both panels use the same square-root color scale to retain weak\n", + "occupation without changing the normalization.\n", + "\n", + "## 5. Add relaxation and dephasing\n", + "\n", + "A pulse can reverse coherent spreading, but it cannot reverse an irreversible\n", + "noise process. Uniform relaxation removes the excitation. Local dephasing\n", + "preserves total excitation while disrupting the phases needed for refocusing.\n", + "Apply each noise model to the same echo program." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import NoiseModel\n", + "\n", + "relaxation_rate = 0.5\n", + "dephasing_rates = [0.05, 0.2]\n", + "noise_models = {\n", + " \"Relaxation\": NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": relaxation_rate}\n", + " for site in range(length)\n", + " ]),\n", + " **{\n", + " f\"Dephasing {rate:g}\": NoiseModel([\n", + " {\"name\": \"pauli_z\", \"sites\": [site], \"strength\": rate}\n", + " for site in range(length)\n", + " ])\n", + " for rate in dephasing_rates\n", + " },\n", + "}\n", + "echo_results = {\"No noise\": echo_result}\n", + "for label, noise in noise_models.items():\n", + " echo_results[label] = simulator.run(state, echo_program, noise_model=noise)\n", + "\n", + "occupations = {label: analog_occupations(result)[1] for label, result in echo_results.items()}" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "`strength` is a Lindblad rate in inverse time. The jump operators are\n", + "$\\sqrt{\\gamma_-}\\,|0\\rangle\\langle1|_i$ for relaxation and\n", + "$\\sqrt{\\gamma_z}\\,Z_i$ for dephasing. In this convention an isolated qubit's\n", + "off-diagonal density-matrix entries decay at rate $2\\gamma_z$.\n", + "\n", + "The run-level noise model is inherited by all segments. These one-qubit gates\n", + "receive no stochastic circuit noise in YAQS, so noise acts only during the\n", + "analog intervals here. The pulses are ideal and instantaneous. The same noisy\n", + "trajectory and random stream continue across the midpoint pulse.\n", + "\n", + "For a single excitation with uniform relaxation, the exact total population is\n", + "$N(t)=\\exp(-\\gamma_-t)$. Surviving trajectories still refocus, so the exact\n", + "final return occupation has the same value, about $0.223$ at $t=3$. Under\n", + "Pauli-Z dephasing, $N(t)=1$, but the return becomes weaker and occupation\n", + "remains away from the center. This comparison uses different noise channels;\n", + "their numerical rates do not represent equal physical error strengths." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-13", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:37:23.232357\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " Time\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 10\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 19\n", + " \n", + " \n", + " \n", + " Site\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) No noise\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " Time\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 10\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 19\n", + " \n", + " \n", + " \n", + " Site\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (\n", + " b\n", + " )\n", + "  \n", + " R\n", + " e\n", + " l\n", + " a\n", + " x\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + " ,\n", + "  \n", + " =\n", + " 0\n", + " .\n", + " 5\n", + " γ\n", + " −\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " Time\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 10\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 19\n", + " \n", + " \n", + " \n", + " Site\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (\n", + " c\n", + " )\n", + "  \n", + " D\n", + " e\n", + " p\n", + " h\n", + " a\n", + " s\n", + " i\n", + " n\n", + " g\n", + " ,\n", + "  \n", + " =\n", + " 0\n", + " .\n", + " 0\n", + " 5\n", + " γ\n", + " z\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " Time\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 10\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 19\n", + " \n", + " \n", + " \n", + " Site\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (\n", + " d\n", + " )\n", + "  \n", + " D\n", + " e\n", + " p\n", + " h\n", + " a\n", + " s\n", + " i\n", + " n\n", + " g\n", + " ,\n", + "  \n", + " =\n", + " 0\n", + " .\n", + " 2\n", + " γ\n", + " z\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " Time\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.25\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.50\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.75\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " ⟨\n", + " ⟩\n", + " n\n", + " 1\n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (e) Return to the center\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " Time\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " N\n", + " n\n", + " =\n", + " ∑\n", + " ⟨\n", + " ⟩\n", + " i\n", + " i\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (f) Surviving excitation\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " No noise\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Relaxation\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Dephasing 0.05\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Dephasing 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " e\n", + " −\n", + " γ\n", + " t\n", + " −\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.25\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.50\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " O\n", + " c\n", + " c\n", + " u\n", + " p\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " ⟨\n", + " ⟩\n", + " n\n", + " i\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "labels = list(echo_results)\n", + "colors = [\"0.15\", \"#D55E00\", \"#56B4E9\", \"#0072B2\"]\n", + "fig, axes = plt.subplots(3, 2, figsize=(7.2, 6.8), layout=\"constrained\")\n", + "titles = [\"(a) No noise\", r\"(b) Relaxation, $\\gamma_-=0.5$\",\n", + " r\"(c) Dephasing, $\\gamma_z=0.05$\", r\"(d) Dephasing, $\\gamma_z=0.2$\"]\n", + "for ax, label, title in zip(axes[:2].flat, labels, titles, strict=True):\n", + " image = ax.pcolormesh(times, np.arange(length), occupations[label], shading=\"auto\", cmap=\"cividis\", norm=occupation_norm)\n", + " ax.axvline(half_duration, color=\"white\", linestyle=\"--\", linewidth=1)\n", + " ax.set(xlabel=\"Time\", ylabel=\"Site\", title=title, yticks=[0, 10, 19], xlim=(0, 3))\n", + "fig.colorbar(image, ax=list(axes[:2].flat), label=r\"Occupation $\\langle n_i\\rangle$\",\n", + " ticks=[0, 0.25, 0.5, 1], shrink=0.8)\n", + "\n", + "for label, color in zip(labels, colors, strict=True):\n", + " occupation = occupations[label]\n", + " result = echo_results[label]\n", + " segments = [segment for segment in result.segment_results if segment.segment_type == \"analog\"]\n", + " raw_times = np.concatenate([segment.times + segment.time_offset for segment in segments])\n", + " keep = np.r_[True, np.diff(raw_times) > 0]\n", + " trajectories = (1 - np.concatenate([np.asarray(segment.trajectories) for segment in segments], axis=-1)) / 2\n", + " trajectories = trajectories[..., keep]\n", + " for ax, mean, samples in (\n", + " (axes[2, 0], occupation[center], trajectories[center]),\n", + " (axes[2, 1], occupation.sum(axis=0), trajectories.sum(axis=0)),\n", + " ):\n", + " ax.plot(times, mean, color=color, linewidth=1.7, label=label)\n", + " if samples.shape[0] > 1:\n", + " standard_error = samples.std(axis=0, ddof=1) / np.sqrt(samples.shape[0])\n", + " ax.fill_between(times, mean - standard_error, mean + standard_error, color=color, alpha=0.15)\n", + "axes[2, 1].plot(times, np.exp(-relaxation_rate * times), \":\", color=\"#D55E00\", linewidth=1.4, label=r\"$e^{-\\gamma_-t}$\")\n", + "for ax in axes[2]:\n", + " ax.axvline(half_duration, color=\"0.6\", linestyle=\"--\", linewidth=0.8)\n", + " ax.set(xlabel=\"Time\", xlim=(0, 3))\n", + "axes[2, 0].set(title=\"(e) Return to the center\", ylabel=r\"$\\langle n_{10}\\rangle$\")\n", + "axes[2, 1].set(title=\"(f) Surviving excitation\", ylabel=r\"$N=\\sum_i\\langle n_i\\rangle$\")\n", + "axes[2, 1].legend(fontsize=7, loc=\"lower left\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "**Loss and dephasing limit the return in different ways.** Relaxation reduces\n", + "the total population, while the dephased excitation survives in a broader\n", + "spatial distribution. Increasing dephasing weakens the echo over the rates\n", + "shown. The lower panels separate the occupation returning to site 10 from the\n", + "total excitation remaining in the chain. Shading shows one standard error\n", + "estimated from complete trajectories; the dotted line gives the exact relaxation\n", + "envelope. These bands describe sampling uncertainty, not timestep or MPS\n", + "truncation error. Increase `num_traj` to reduce sampling fluctuations, and check\n", + "numerical convergence before interpreting small differences.\n", + "\n", + "## Further options\n", + "\n", + "Programs require an MPS initial state. Segment parameters control local timing,\n", + "accuracy, gate mode, and digital `shots`. Observables, `random_seed`, and\n", + "`get_state` belong on the program. A noiseless program can retain its final\n", + "state with `get_state=True`; noisy programs do not return a single final MPS. A\n", + "program-level `num_traj` overrides segment budgets. If omitted, all segment\n", + "budgets must agree. Digital segments can target qubits in a heterogeneous MPS;\n", + "non-qubit sites remain spectators. For analog-only Hamiltonian changes, use\n", + "`Hamiltonian.piecewise` as described in {doc}`hamiltonians`.\n", + "\n", + "An optional third tuple entry overrides noise for one segment, for example\n", + "`(hamiltonian, analog_params, local_noise)`. `None` inherits run-level noise; an\n", + "empty `NoiseModel()` disables it for that segment. Digital operators may also be\n", + "OpenQASM strings or paths. Pass the pair list directly to `Simulator.run` with\n", + "program-wide keywords when a named program is unnecessary. The outer `counts`\n", + "contains the histogram from the last segment that sampled shots; inspect\n", + "`segment_results` for earlier histograms. Program execution does not support\n", + "`multi_time_observables`.\n", + "\n", + "For deterministic scheduled jumps, see {ref}`noise-scheduled-jumps`. Jump times\n", + "use the analog run's local clock and must follow its `dt` grid with `order=1`.\n", + "Consecutive compatible analog segments share that clock; a digital gate starts a\n", + "new analog run. Use a segment noise override to attach a schedule to one\n", + "interval. For device-specific noise strengths and distributions, see\n", + "{doc}`realistic_noise_models`." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 39, + 53, + 65, + 72, + 91, + 114, + 139, + 152, + 157, + 181, + 196, + 219, + 238, + 276 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/digital_twin.ipynb b/docs/_outputs/examples/digital_twin.ipynb new file mode 100644 index 000000000..a0b794a0c --- /dev/null +++ b/docs/_outputs/examples/digital_twin.ipynb @@ -0,0 +1,2206 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Building a Digital Twin\n", + "\n", + "Noise changes how excitations move through a quantum system. Measurements can\n", + "help us estimate that noise and build a model of the observed dynamics. Here we\n", + "learn relaxation and dephasing rates from measurements at the ends of a\n", + "four-spin chain, then use the fitted model to predict transport through its\n", + "unmeasured interior.\n", + "\n", + "This extends {doc}`quickstart` with data preparation, parameter bounds, and\n", + "validation. We generate synthetic observations so the underlying rates are\n", + "known, but pass only the observed traces to the fitter. The example uses the\n", + "standard YAQS installation and Matplotlib for plotting. Run the cells in order\n", + "in a notebook; for a script, use the entry-point guard in\n", + "{doc}`simulator_initialization`.\n", + "\n", + "## 1. Set up excitation transport\n", + "\n", + "The XY Hamiltonian exchanges excitations between neighboring spins,\n", + "\n", + "$$\n", + "H=-\\frac{1}{2}\\sum_{i=0}^{2}(X_iX_{i+1}+Y_iY_{i+1}).\n", + "$$\n", + "\n", + "We start with one excitation at site 0. As in {doc}`analog_simulation`, the\n", + "hopping amplitude is one and $\\hbar=1$. Measuring $Z_i$ gives the occupation\n", + "through $\\langle n_i\\rangle=(1-\\langle Z_i\\rangle)/2$." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseCharacterizer, NoiseModel, Observable, Simulator, State\n", + "\n", + "length = 4\n", + "state = State(length, initial=\"basis\", basis_string=\"1000\", representation=\"density_matrix\")\n", + "hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0)\n", + "observables = [Observable(\"z\", site) for site in range(length)]\n", + "params = AnalogSimParams(observables=observables, elapsed_time=8.0, dt=0.1, preset=\"fast\")\n", + "simulator = Simulator(show_progress=False)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "The density-matrix state gives deterministic Lindblad dynamics without\n", + "trajectory sampling error. We record all four sites to validate predictions\n", + "later; only the endpoints will enter the fit. The documentation suppresses\n", + "progress bars with `show_progress=False`; omit this argument to see progress.\n", + "\n", + "## 2. See how noise changes the dynamics\n", + "\n", + "A local relaxation channel at site 3 removes excitations after they reach the\n", + "far end of the chain. A dephasing channel at site 2 leaves the total excitation\n", + "number unchanged by itself, but changes the interference that drives transport.\n", + "The reference rates are $\\gamma_{\\mathrm{loss}}=0.35$ and $\\gamma_\\phi=0.12$." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "def transport_noise(scale):\n", + " \"\"\"Scale the reference relaxation and dephasing rates together.\"\"\"\n", + " return NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [3], \"strength\": 0.35 * scale},\n", + " {\"name\": \"pauli_z\", \"sites\": [2], \"strength\": 0.12 * scale},\n", + " ])\n", + "\n", + "\n", + "noise_scales = [0.0, 0.5, 1.0, 3.0]\n", + "reference_runs = {}\n", + "occupations = {}\n", + "for scale in noise_scales:\n", + " noise = None if scale == 0 else transport_noise(scale)\n", + " result = simulator.run(state, hamiltonian, params, noise)\n", + " reference_runs[scale] = result\n", + " occupations[scale] = (1 - np.asarray(result.expectation_values)) / 2\n", + "\n", + "times = reference_runs[1.0].times" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "`strength` is a Lindblad rate in inverse time. The jump operators are\n", + "$L_{\\mathrm{loss}}=\\sqrt{\\gamma_{\\mathrm{loss}}}\\,|0\\rangle\\langle1|_3$ and\n", + "$L_\\phi=\\sqrt{\\gamma_\\phi}\\,Z_2$. This convention gives the dephasing term\n", + "$\\gamma_\\phi(Z_2\\rho Z_2-\\rho)$. The dimensionless `scale` multiplies both\n", + "rates; it is not a per-gate error probability.\n", + "\n", + "The four heatmaps share their axes and a square-root color normalization, which\n", + "keeps weak occupation visible while retaining the full range from zero to one." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:37:27.458999\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " S\n", + " i\n", + " t\n", + " e\n", + "  \n", + " i\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) No noise\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b) Half the reference rates\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " S\n", + " i\n", + " t\n", + " e\n", + "  \n", + " i\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (c) Reference rates\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (d) Three times the reference rates\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.25\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.50\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " O\n", + " c\n", + " c\n", + " u\n", + " p\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " ⟨\n", + " ⟩\n", + " n\n", + " i\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib.colors import PowerNorm\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\",\n", + " \"font.serif\": [\"STIXGeneral\"],\n", + " \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 10,\n", + " \"axes.labelsize\": 11,\n", + " \"axes.linewidth\": 0.8,\n", + " \"xtick.direction\": \"in\",\n", + " \"ytick.direction\": \"in\",\n", + " \"svg.fonttype\": \"none\",\n", + "})\n", + "occupation_norm = PowerNorm(gamma=0.5, vmin=0, vmax=1)\n", + "fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.4), sharex=True, sharey=True, layout=\"constrained\")\n", + "titles = [\"(a) No noise\", \"(b) Half the reference rates\", \"(c) Reference rates\", \"(d) Three times the reference rates\"]\n", + "for ax, scale, title in zip(axes.flat, noise_scales, titles, strict=True):\n", + " image = ax.pcolormesh(times, np.arange(length), occupations[scale], shading=\"auto\", cmap=\"cividis\", norm=occupation_norm, rasterized=True)\n", + " ax.set(title=title, yticks=np.arange(length), xlim=(0, 8))\n", + "for ax in axes[-1]:\n", + " ax.set_xlabel(r\"Time $t$\")\n", + "for ax in axes[:, 0]:\n", + " ax.set_ylabel(r\"Site $i$\")\n", + "fig.colorbar(image, ax=axes, label=r\"Occupation $\\langle n_i\\rangle$\", ticks=[0, 0.25, 0.5, 1], shrink=0.92)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "**Noise changes transport and weakens the returning excitation.** Without noise,\n", + "the excitation reflects through the chain while its total population stays one.\n", + "Relaxation removes population, and dephasing changes its spatial distribution.\n", + "Both rates change together in this comparison, so the panels show their combined\n", + "effect. We will fit the reference-rate case and use the other panels only to\n", + "illustrate the physical changes.\n", + "\n", + "## 3. Select the observations and candidate channels\n", + "\n", + "Assume that only the endpoints can be measured. Select rows 0 and 3 of the\n", + "synthetic $Z$ traces, keeping their time samples unchanged." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Observation shape: (2, 81)\n" + ] + } + ], + "source": [ + "fitting_sites = [0, 3]\n", + "fitting_observables = [observables[site] for site in fitting_sites]\n", + "reference_z = np.asarray(reference_runs[1.0].expectation_values)\n", + "measured_z = reference_z[fitting_sites]\n", + "\n", + "print(\"Observation shape:\", measured_z.shape)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "`ref_expectations` must have shape `(n_observables, n_times)`. Here it is\n", + "`(2, 81)`: rows follow `fitting_observables`, and columns follow `params.times`,\n", + "including $t=0$. We fit $Z$ expectations, not occupations; convert measured\n", + "occupations with `measured_z = 1 - 2 * measured_occupations` when necessary.\n", + "Data from another time grid must first be aligned with the simulation grid.\n", + "\n", + "The candidate model specifies which channels exist and where they act. The\n", + "optimizer will change their strengths only. We start both rates at 0.2 and allow\n", + "each to range from zero to one." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "initial_guess = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [3], \"strength\": 0.2},\n", + " {\"name\": \"pauli_z\", \"sites\": [2], \"strength\": 0.2},\n", + "])\n", + "lower_bounds = np.zeros(2)\n", + "upper_bounds = np.ones(2)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "Bounds and fitted parameters follow the order of `initial_guess.processes`:\n", + "relaxation first, dephasing second. These bounds restrict the search; they are\n", + "not uncertainty intervals. The fit assumes the Hamiltonian, initial state,\n", + "channel types, and channel locations are known. It does not discover an\n", + "arbitrary noise model from the observations.\n", + "\n", + "## 4. Fit the rates\n", + "\n", + "`NoiseCharacterizer` repeatedly simulates candidate models and minimizes the\n", + "mean-squared difference from the supplied traces. Its default backend selection\n", + "uses deterministic Lindblad evolution for this four-spin problem." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Endpoint Z-trace RMSE: 0.0686 → 1.10e-04\n", + "Relaxation rate: 0.3499\n", + "Dephasing rate: 0.1199\n" + ] + } + ], + "source": [ + "characterizer = NoiseCharacterizer(show_progress=False)\n", + "fit = characterizer.characterize(\n", + " hamiltonian,\n", + " params,\n", + " init_state=state,\n", + " init_guess=initial_guess,\n", + " observables=fitting_observables,\n", + " ref_expectations=measured_z,\n", + " x_low=lower_bounds,\n", + " x_up=upper_bounds,\n", + " max_iter=40,\n", + " seed=7,\n", + ")\n", + "\n", + "print(f\"Endpoint Z-trace RMSE: {fit.sqrt_loss_before():.4f} → {fit.trajectory_rmse():.2e}\")\n", + "for name, rate in zip((\"Relaxation\", \"Dephasing\"), fit.best_parameters, strict=True):\n", + " print(f\"{name} rate: {rate:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "With two free parameters, YAQS uses the derivative-free CMA-ES optimizer.\n", + "`max_iter=40` limits its generations, and `seed` fixes the optimizer's random\n", + "search. This seed is separate from `AnalogSimParams.random_seed`, which controls\n", + "stochastic simulation. `NoiseCharacterizer` defaults to in-process execution;\n", + "set `parallel=True` to parallelize trajectories for vector or MPS forward\n", + "models.\n", + "\n", + "For observations $z_{o,t}$, the fitted objective is\n", + "\n", + "$$\n", + "J=\\frac{1}{N_{\\mathrm{obs}}N_t}\\sum_{o,t}\n", + "\\left(z_{o,t}^{\\mathrm{model}}-z_{o,t}^{\\mathrm{data}}\\right)^2.\n", + "$$\n", + "\n", + "`sqrt_loss_before()` reports the initial model's RMSE. `trajectory_rmse()`\n", + "reports the mismatch of the final fitted traces. `best_parameters` gives the\n", + "rates in process order, and `optimal_model` is the fitted `NoiseModel` ready for\n", + "simulation. The known synthetic rates let us check recovery, but a low training\n", + "error alone does not establish unique parameters.\n", + "\n", + "## 5. Predict the unmeasured interior\n", + "\n", + "Rerun the fitted model with all four observables. Sites 1 and 2 were withheld\n", + "from the optimization, so they test predictions beyond the fitted traces." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-13", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Withheld interior Z-trace RMSE: 7.10e-05\n" + ] + } + ], + "source": [ + "reconstructed = simulator.run(state, hamiltonian, params, fit.optimal_model)\n", + "fitted_z = np.asarray(reconstructed.expectation_values)\n", + "fitted_occupation = (1 - fitted_z) / 2\n", + "heldout_sites = [1, 2]\n", + "heldout_rmse = np.sqrt(np.mean((fitted_z[heldout_sites] - reference_z[heldout_sites]) ** 2))\n", + "\n", + "print(f\"Withheld interior Z-trace RMSE: {heldout_rmse:.2e}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "Compare the full dynamics on the same color scale, then inspect the withheld\n", + "sites as time traces. Reference markers are spaced out for readability; all 81\n", + "samples enter the error calculation." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cell-15", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:37:35.630924\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " S\n", + " i\n", + " t\n", + " e\n", + "  \n", + " i\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) Synthetic reference\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " S\n", + " i\n", + " t\n", + " e\n", + "  \n", + " i\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b) Fitted noise model\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " O\n", + " c\n", + " c\n", + " u\n", + " p\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " ⟨\n", + " ⟩\n", + " n\n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (c) Withheld site 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Fitted prediction\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Withheld reference\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " O\n", + " c\n", + " c\n", + " u\n", + " p\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " ⟨\n", + " ⟩\n", + " n\n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (d) Withheld site 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " O\n", + " c\n", + " c\n", + " u\n", + " p\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " ⟨\n", + " ⟩\n", + " n\n", + " i\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.6), layout=\"constrained\")\n", + "for ax, dynamics, title in zip(\n", + " axes[0],\n", + " (occupations[1.0], fitted_occupation),\n", + " (\"(a) Synthetic reference\", \"(b) Fitted noise model\"),\n", + " strict=True,\n", + "):\n", + " image = ax.pcolormesh(times, np.arange(length), dynamics, shading=\"auto\", cmap=\"cividis\", norm=occupation_norm, rasterized=True)\n", + " ax.set(title=title, xlabel=r\"Time $t$\", ylabel=r\"Site $i$\", yticks=np.arange(length), xlim=(0, 8))\n", + "fig.colorbar(image, ax=list(axes[0]), label=r\"Occupation $\\langle n_i\\rangle$\", ticks=[0, 0.5, 1])\n", + "for ax, site, label in zip(axes[1], heldout_sites, (\"(c)\", \"(d)\"), strict=True):\n", + " ax.plot(times, fitted_occupation[site], color=\"#225c80\", lw=1.8, label=\"Fitted prediction\")\n", + " ax.plot(times[::4], occupations[1.0][site, ::4], \"o\", color=\"#bb563b\", ms=3.5, markerfacecolor=\"white\", label=\"Withheld reference\")\n", + " ax.set(title=f\"{label} Withheld site {site}\", xlabel=r\"Time $t$\", ylabel=rf\"Occupation $\\langle n_{site}\\rangle$\", xlim=(0, 8), ylim=(0, 1))\n", + " ax.spines[[\"top\", \"right\"]].set_visible(False)\n", + "axes[1, 0].legend(frameon=False, fontsize=9, loc=\"upper right\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-16", + "metadata": {}, + "source": [ + "**Endpoint observations recover the transport through the interior in this\n", + "model.** The fitted heatmap reproduces the reference, including both withheld\n", + "sites. This supports the fitted model for the specified Hamiltonian, initial\n", + "state, and observation window. It does not certify the assumed channels or\n", + "establish accuracy for other preparations, controls, or longer times. In\n", + "experimental work, reserve independent measurements for this validation step.\n", + "\n", + "## Using measured data\n", + "\n", + "Replace `measured_z` with your measured expectation array and keep the same\n", + "observable and time ordering. Supply exactly one of `ref_expectations` and\n", + "`reference_model`. The latter generates reference traces internally and is a\n", + "shortcut for synthetic benchmarks; it is not needed when measurements are\n", + "already available.\n", + "\n", + "The example contains no measurement noise. Finite-shot data add uncertainty, and\n", + "calibration drift or an incorrect Hamiltonian can also affect the fit. The\n", + "current objective weights every observable and time sample equally; it does not\n", + "accept per-sample uncertainty weights or return confidence intervals for the\n", + "rates. Check residuals against measurement uncertainty and test withheld data\n", + "before interpreting small differences between fitted parameters.\n", + "\n", + "Sparse observations can leave several rate combinations indistinguishable. Use\n", + "more times, observables, or preparations to test identifiability. A successful\n", + "optimization shows that a candidate model fits the chosen data; it does not\n", + "prove that this model is unique or that the environment has no memory. For\n", + "memory-sensitive probing, see {doc}`characterization`.\n", + "\n", + "## Further options\n", + "\n", + "### Forward models and sampling\n", + "\n", + "`NoiseCharacterizer(representation=\"auto\")` uses density matrices up to eight\n", + "qubits, vectors up to ten, and MPS above that size by default. Choose\n", + "`\"density_matrix\"`, `\"vector\"`, or `\"mps\"` explicitly when needed. See\n", + "{doc}`representation_comparison` for the numerical trade-offs.\n", + "\n", + "Vector and MPS fits use trajectory-averaged MCWF and TJM simulations.\n", + "`sim_params.num_traj` controls their sampling budget; increasing it reduces\n", + "sampling error at greater cost. Refine the time step and numerical tolerances as\n", + "well as the trajectory count. A fixed `random_seed` makes the forward runs\n", + "repeatable but does not remove their sampling error. Recheck the fitted model\n", + "with more trajectories and independent seeds before drawing conclusions. For a\n", + "small system, deterministic fitting can also use stochastic or measured\n", + "reference data without making the candidate simulations stochastic.\n", + "\n", + "### Optimizer controls and results\n", + "\n", + "`sigma0` sets the initial CMA-ES search scale, and `popsize` sets the number of\n", + "candidates per generation. A larger search budget or several starting points can\n", + "help assess sensitivity to initialization. When there is only one free parameter\n", + "with finite bounds, YAQS uses a bounded scalar search; `max_iter` then limits\n", + "search evaluations rather than CMA-ES generations. Initial-model and final\n", + "fitted-trajectory evaluations are outside either limit.\n", + "\n", + "`fit.ref_traj`, `fit.fit_traj`, and `fit.times` retain the fitted-observable\n", + "comparison. `fit.loss_history` stores candidate losses; it excludes the initial\n", + "model's separately evaluated baseline. `fit.best_loss` is the best search\n", + "objective, while `fit.sqrt_loss_after()` gives its square root. On stochastic\n", + "backends, the final rerun can differ from the best sampled objective. Inspect\n", + "the traces as well as the optimizer's reported loss.\n", + "\n", + "See {class}`~mqt.yaqs.NoiseCharacterizer` for the full interface and\n", + "{doc}`realistic_noise_models` for supported one-site and two-site jump\n", + "processes." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 39, + 50, + 64, + 83, + 94, + 124, + 138, + 145, + 157, + 164, + 178, + 196, + 223, + 231, + 237, + 256 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/ensemble_evolution.ipynb b/docs/_outputs/examples/ensemble_evolution.ipynb new file mode 100644 index 000000000..bb6d3e3e3 --- /dev/null +++ b/docs/_outputs/examples/ensemble_evolution.ipynb @@ -0,0 +1,1737 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Ensemble Evolution\n", + "\n", + "A two-time correlation follows how a measurement at a later time relates to an\n", + "operator applied to the initial state. Averaging these correlations over several\n", + "initial states helps study spin dynamics and transport. This guide starts with\n", + "one state, compares it with a small ensemble, and then follows local\n", + "spin-current correlations in a periodic chain. All evolution is unitary.\n", + "\n", + "This guide uses the standard YAQS installation and Matplotlib. Run the cells in\n", + "order in a notebook. For a script, use the entry-point guard in\n", + "{doc}`simulator_initialization`.\n", + "\n", + "## 1. Follow one state in an open spin chain\n", + "\n", + "Use six spin-$1/2$ sites with nearest-neighbor XXZ interactions and a transverse\n", + "field. With $S^\\alpha=\\sigma^\\alpha/2$, the Hamiltonian is\n", + "\n", + "```{math}\n", + "H = \\sum_{r=0}^{L-2}\\left[\n", + "J_{xx}(S_r^x S_{r+1}^x+S_r^y S_{r+1}^y)\n", + "+\\Delta S_r^z S_{r+1}^z\\right]\n", + "+h_x\\sum_{r=0}^{L-1}S_r^x.\n", + "```\n", + "\n", + "`Hamiltonian.pauli` uses Pauli matrices, so two-spin coefficients include a\n", + "factor of $1/4$ and the field coefficient includes $1/2$. Set $J_{xx}=1$ and\n", + "$\\hbar=1$, so time is in units of $1/J_{xx}$." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from mqt.yaqs import AnalogSimParams, Hamiltonian, Observable, Simulator, State\n", + "\n", + "L = 6\n", + "Jxx = 1.0\n", + "delta = 0.7\n", + "h_x = 0.4\n", + "H_open = Hamiltonian.pauli(\n", + " length=L,\n", + " two_body=[(0.25 * Jxx, \"X\", \"X\"), (0.25 * Jxx, \"Y\", \"Y\"), (0.25 * delta, \"Z\", \"Z\")],\n", + " one_body=[(0.5 * h_x, \"X\")],\n", + " bc=\"open\",\n", + ")\n", + "mid = L // 2\n", + "psi0 = State(L, initial=\"haar-random\", pad=2)\n", + "sim = Simulator(show_progress=False)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "The `haar-random` preset builds a random MPS from Haar-random isometries. Here,\n", + "`pad=2` limits its initial bond dimension to two. This is not a uniformly\n", + "sampled vector from the full Hilbert space. State initialization is unseeded, so\n", + "rerunning the notebook changes the numerical curves. Save the initial MPS\n", + "tensors when you need to reproduce a particular ensemble.\n", + "\n", + "First measure the central site's Pauli $Z$ expectation. `Observable(\"z\", mid)`\n", + "represents $\\sigma^z_m$; divide its expectation by two for $S^z_m$." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "primer_params = AnalogSimParams(\n", + " observables=[Observable(\"z\", mid)],\n", + " elapsed_time=5.1,\n", + " dt=0.15,\n", + " max_bond_dim=64,\n", + " svd_threshold=1e-10,\n", + ")\n", + "result_primer = sim.run(psi0, H_open, primer_params)\n", + "times_primer = result_primer.times\n", + "zexp_primer = result_primer.expectation_values[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-4", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:37:39.680734\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " ⟨\n", + " (\n", + " )\n", + " ⟩\n", + " σ\n", + " t\n", + " m\n", + " z\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Local spin dynamics\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\", \"font.serif\": [\"STIXGeneral\"], \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 10, \"axes.labelsize\": 11, \"axes.linewidth\": 0.8,\n", + " \"xtick.direction\": \"in\", \"ytick.direction\": \"in\", \"svg.fonttype\": \"none\",\n", + " \"legend.frameon\": False,\n", + "})\n", + "fig, ax = plt.subplots(figsize=(5.4, 2.8), layout=\"constrained\")\n", + "ax.plot(times_primer, zexp_primer, color=\"#0072B2\", linewidth=1.8)\n", + "ax.axhline(0, color=\"0.7\", linewidth=0.6)\n", + "ax.set(xlabel=r\"Time $t$\", ylabel=r\"$\\langle\\sigma^z_m(t)\\rangle$\", xlim=(0, 5.1))\n", + "ax.set_title(\"Local spin dynamics\", loc=\"left\", fontsize=11)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-5", + "metadata": {}, + "source": [ + "The local expectation changes even though the whole chain evolves unitarily. A\n", + "single curve depends on its initial state. Two-time correlations let us ask how\n", + "a specified initial perturbation affects the later dynamics.\n", + "\n", + "## 2. Request two-time correlations\n", + "\n", + "For a state $|\\psi_0\\rangle$ and propagator $U(t)$, define\n", + "\n", + "```{math}\n", + "C_{AB}(t)=\\langle\\psi_0|U^\\dagger(t)\\,A\\,U(t)\\,B|\\psi_0\\rangle.\n", + "```\n", + "\n", + "Pass `(A, B)` in `multi_time_observables`: `B` acts at time zero and `A` is\n", + "measured at time $t$. Setting `A` and `B` equal gives an autocorrelation. The\n", + "product need not be Hermitian, so the result can be complex.\n", + "\n", + "The correlation backend takes a `list[State]` of MPS inputs. A list with one\n", + "state gives a single-state result. Reuse `psi0` to connect the correlation\n", + "calculation with the local dynamics above." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-6", + "metadata": {}, + "outputs": [], + "source": [ + "sz_mid = Observable(\"z\", mid)\n", + "sx_mid = Observable(\"x\", mid)\n", + "single_state_params = AnalogSimParams(\n", + " observables=[],\n", + " elapsed_time=5.1,\n", + " dt=0.15,\n", + " max_bond_dim=64,\n", + " svd_threshold=1e-10,\n", + " multi_time_observables=[(sz_mid, sz_mid), (sz_mid, sx_mid)],\n", + ")\n", + "result_single = sim.run([psi0], H_open, single_state_params)\n", + "t_single = result_single.multi_time_times\n", + "czz_single = result_single.multi_time_results[0]\n", + "czx_single = result_single.multi_time_results[1]" + ] + }, + { + "cell_type": "markdown", + "id": "cell-7", + "metadata": {}, + "source": [ + "`multi_time_results` has shape `(2, 35)`: one row per pair, in the supplied\n", + "order, and one column per sampled time. `multi_time_times` supplies the matching\n", + "time axis. The rows contain $C_{zz}$ and $C_{zx}$ for Pauli operators; divide by\n", + "four for spin-$1/2$ correlations. In particular, $C_{zz}(0)=1$.\n", + "\n", + "## 3. Average over initial states\n", + "\n", + "Pass several states to average the same correlations with equal weights. Keep\n", + "the original state as the first member and add three independently initialized\n", + "random MPS. Each state evolves separately, and the list length determines the\n", + "ensemble size; `num_traj` does not set it." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-8", + "metadata": {}, + "outputs": [], + "source": [ + "num_states = 4\n", + "ensemble_states = [psi0, *[State(L, initial=\"haar-random\", pad=2) for _ in range(num_states - 1)]]\n", + "ensemble_params = AnalogSimParams(\n", + " observables=[],\n", + " elapsed_time=5.1,\n", + " dt=0.15,\n", + " max_bond_dim=64,\n", + " svd_threshold=1e-10,\n", + " multi_time_observables=[(sz_mid, sz_mid), (sz_mid, sx_mid)],\n", + ")\n", + "result_ens = sim.run(ensemble_states, H_open, ensemble_params)\n", + "t_ens = result_ens.multi_time_times\n", + "czz_ens = result_ens.multi_time_results[0]\n", + "czx_ens = result_ens.multi_time_results[1]" + ] + }, + { + "cell_type": "markdown", + "id": "cell-9", + "metadata": {}, + "source": [ + "`multi_time_results` now contains the ensemble mean, with the same pair and time\n", + "axes. Ordinary `observables`, if supplied, also produce ensemble means in\n", + "`expectation_values` and individual member data in `trajectories`. Per-member\n", + "two-time correlations are not exposed in `Result`." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-10", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:37:41.661800\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " R\n", + " e\n", + " (\n", + " )\n", + " C\n", + " t\n", + " z\n", + " z\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) Autocorrelation\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " One state\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4-state mean\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " R\n", + " e\n", + " (\n", + " )\n", + " C\n", + " t\n", + " z\n", + " x\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b) Cross correlation\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " One state\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4-state mean\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.9), layout=\"constrained\")\n", + "for ax, single, average, label in zip(\n", + " axes, [czz_single, czx_single], [czz_ens, czx_ens], [\"zz\", \"zx\"], strict=True,\n", + "):\n", + " ax.plot(t_single, single.real, color=\"0.55\", linestyle=\"--\", linewidth=1.3, label=\"One state\")\n", + " ax.plot(t_ens, average.real, color=\"#0072B2\", linewidth=1.8, label=f\"{num_states}-state mean\")\n", + " ax.axhline(0, color=\"0.75\", linewidth=0.6)\n", + " ax.set(xlabel=r\"Time $t$\", ylabel=rf\"$\\mathrm{{Re}}\\,C_{{{label}}}(t)$\", xlim=(0, 5.1))\n", + " ax.legend(fontsize=8)\n", + "axes[0].set_title(\"(a) Autocorrelation\", loc=\"left\", fontsize=11)\n", + "axes[1].set_title(\"(b) Cross correlation\", loc=\"left\", fontsize=11)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-11", + "metadata": {}, + "source": [ + "The plots compare the real parts; the result retains both real and imaginary\n", + "components. Averaging changes the state-dependent fluctuations, but four states\n", + "do not establish a converged thermal average. This example demonstrates the\n", + "ensemble workflow. Dynamical quantum typicality uses suitable random-state\n", + "sampling to estimate traces; finite-temperature calculations also need thermal\n", + "weighting or filtering. The small random-MPS ensemble here supplies neither a\n", + "convergence study nor finite-temperature preparation.\n", + "\n", + "## 4. Compare local spin-current correlations\n", + "\n", + "A periodic XXZ chain lets us study how spin currents change with the interaction\n", + "strength. For each directed bond $(r,r+1)$, with site indices wrapped modulo\n", + "$L$, define\n", + "\n", + "```{math}\n", + "j_r=J_{xx}\\left(S_r^x S_{r+1}^y-S_r^y S_{r+1}^x\\right).\n", + "```\n", + "\n", + "Measure each bond's autocorrelation and average over bonds and initial states:\n", + "\n", + "```{math}\n", + "C_{\\mathrm{bond}}(t)=\\frac{1}{L}\\sum_r\\langle j_r(t)j_r(0)\\rangle_{\\mathrm{ensemble}}.\n", + "```\n", + "\n", + "The two-site matrix below follows the listed site order, including the periodic\n", + "bond `(L - 1, 0)`." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-12", + "metadata": {}, + "outputs": [], + "source": [ + "def spin_current_bond_matrix(j_coupling):\n", + " x = np.array([[0.0, 1.0], [1.0, 0.0]], dtype=complex)\n", + " y = np.array([[0.0, -1.0j], [1.0j, 0.0]], dtype=complex)\n", + " return 0.25 * j_coupling * (np.kron(x, y) - np.kron(y, x))\n", + "\n", + "\n", + "Ltr = 6\n", + "deltas = [0.1, 1.5]\n", + "states_transport = [State(Ltr, initial=\"haar-random\", pad=2) for _ in range(2)]\n", + "j_mat = spin_current_bond_matrix(Jxx)\n", + "bond_obs = [Observable(j_mat, sites=[r, (r + 1) % Ltr]) for r in range(Ltr)]\n", + "pairs_jj = [(obs, obs) for obs in bond_obs]\n", + "transport_curves = {}\n", + "transport_results = {}\n", + "for d in deltas:\n", + " h_periodic = Hamiltonian.pauli(\n", + " length=Ltr,\n", + " two_body=[(0.25 * Jxx, \"X\", \"X\"), (0.25 * Jxx, \"Y\", \"Y\"), (0.25 * d, \"Z\", \"Z\")],\n", + " one_body=[],\n", + " bc=\"periodic\",\n", + " )\n", + " transport_params = AnalogSimParams(\n", + " observables=[],\n", + " elapsed_time=3.0,\n", + " dt=0.1,\n", + " max_bond_dim=32,\n", + " svd_threshold=1e-10,\n", + " multi_time_observables=pairs_jj,\n", + " )\n", + " result_transport = sim.run(states_transport, h_periodic, transport_params)\n", + " t_transport = result_transport.multi_time_times\n", + " transport_results[d] = result_transport\n", + " transport_curves[d] = result_transport.multi_time_results.mean(axis=0)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-13", + "metadata": {}, + "source": [ + "Each `multi_time_results` array has shape `(6, 31)`, with one row for each bond.\n", + "The final mean over rows gives $C_{\\mathrm{bond}}$. Reusing the same initial\n", + "states for both interaction strengths keeps the ensemble fixed." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cell-14", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:37:44.657555\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.050\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.025\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.000\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.025\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.050\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.075\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.100\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.125\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " R\n", + " e\n", + " (\n", + " )\n", + " C\n", + " t\n", + " b\n", + " o\n", + " n\n", + " d\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Local spin-current autocorrelation\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Δ\n", + " =\n", + " 0\n", + " .\n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Δ\n", + " =\n", + " 1\n", + " .\n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(5.4, 2.9), layout=\"constrained\")\n", + "for d, color in zip(deltas, [\"#0072B2\", \"#D55E00\"], strict=True):\n", + " ax.plot(t_transport, transport_curves[d].real, color=color, linewidth=1.8, label=rf\"$\\Delta={d}$\")\n", + "ax.axhline(0, color=\"0.75\", linewidth=0.6)\n", + "ax.set(xlabel=r\"Time $t$\", ylabel=r\"$\\mathrm{Re}\\,C_{\\mathrm{bond}}(t)$\", xlim=(0, 3))\n", + "ax.set_title(\"Local spin-current autocorrelation\", loc=\"left\", fontsize=11)\n", + "ax.legend(fontsize=9)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-15", + "metadata": {}, + "source": [ + "The curves show how the bond-current correlation depends on the interaction\n", + "strength over this short window. They are not the full total-current\n", + "correlation. For $J=\\sum_r j_r$, the latter contains all cross-bond terms,\n", + "$C_{JJ}(t)=L^{-1}\\sum_{r,s}\\langle j_r(t)j_s(0)\\rangle$. Computing it requires\n", + "$L^2$ operator pairs instead of the $L$ pairs used here.\n", + "\n", + "These small-chain curves do not determine a diffusion constant or a Drude\n", + "weight. For the connection between typicality and current correlations, see\n", + "[Steinigeweg et al., Phys. Rev. Lett. **112**, 120601 (2014)](https://doi.org/10.1103/PhysRevLett.112.120601).\n", + "The broader transport setting is covered in\n", + "[Bertini et al., Rev. Mod. Phys. **93**, 025003 (2021)](https://doi.org/10.1103/RevModPhys.93.025003).\n", + "\n", + "## Scale the calculation\n", + "\n", + "Parallel execution is enabled by default for ensembles with several members. The\n", + "documentation suppresses progress with `show_progress=False`; omit that setting\n", + "to see progress. Increase the number of initial states to check sampling\n", + "convergence, and check timestep and bond-dimension convergence separately. The\n", + "random state's initial `pad` and the evolution's `max_bond_dim` serve different\n", + "purposes. Longer evolution can require larger bonds as entanglement grows.\n", + "\n", + "The list-of-state path requires MPS inputs and a static Hamiltonian. It returns\n", + "ensemble observables and correlations, rather than a final ensemble state.\n", + "\n", + "## Related guides\n", + "\n", + "- {doc}`analog_simulation` — single-state analog evolution and numerical\n", + " settings.\n", + "- {doc}`state_initialization` — random MPS, custom states, and list inputs.\n", + "- {doc}`simulator_initialization` — parallel workers and script execution." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 600, + "number_source_lines": true + }, + "source_map": [ + 10, + 40, + 58, + 69, + 82, + 100, + 122, + 137, + 151, + 166, + 173, + 187, + 216, + 250, + 256, + 266 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/equivalence_checking.ipynb b/docs/_outputs/examples/equivalence_checking.ipynb new file mode 100644 index 000000000..7725e09fa --- /dev/null +++ b/docs/_outputs/examples/equivalence_checking.ipynb @@ -0,0 +1,1233 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Circuit Verification\n", + "\n", + "A circuit must use the gates and connections that a quantum device supports.\n", + "Transpilation makes those changes, but the compiled circuit should still perform\n", + "the intended operation. We first verify a circuit compiled for a hardware\n", + "target, then introduce a rotation-angle bug and ask how hardware noise changes\n", + "its agreement with the original circuit.\n", + "\n", + "This extends the circuit comparison in {doc}`quickstart`. The four-qubit example\n", + "uses the standard YAQS installation and Matplotlib for plotting, without a\n", + "hardware account. Run the cells in order in a notebook; for a script, use the\n", + "entry-point guard in {doc}`simulator_initialization`.\n", + "\n", + "## 1. Compile for hardware constraints\n", + "\n", + "Our circuit entangles qubit 0 with every other qubit, then applies local\n", + "rotations. A device with a line of nearest-neighbor connections cannot execute\n", + "all three controlled-X gates directly. The transpiler must route the circuit and\n", + "express its rotations in the device's native gate set." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Original gates: {'ry': 4, 'cx': 3, 'h': 1}\n", + "Compiled gates: {'rz': 10, 'sx': 9, 'cx': 9}\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "from qiskit import QuantumCircuit, transpile\n", + "from qiskit.providers.fake_provider import GenericBackendV2\n", + "\n", + "num_qubits = 4\n", + "original = QuantumCircuit(num_qubits)\n", + "original.h(0)\n", + "for site in range(1, num_qubits):\n", + " original.cx(0, site)\n", + "for site in range(num_qubits):\n", + " original.ry(0.3 * (site + 1), site)\n", + "\n", + "connections = [[0, 1], [1, 0], [1, 2], [2, 1], [2, 3], [3, 2]]\n", + "backend = GenericBackendV2(\n", + " num_qubits,\n", + " basis_gates=[\"rz\", \"sx\", \"x\", \"cx\"],\n", + " coupling_map=connections,\n", + " noise_info=False,\n", + ")\n", + "compiled = transpile(\n", + " original,\n", + " backend=backend,\n", + " initial_layout=list(range(num_qubits)),\n", + " optimization_level=1,\n", + " seed_transpiler=7,\n", + ")\n", + "\n", + "print(\"Original gates:\", dict(original.count_ops()))\n", + "print(\"Compiled gates:\", dict(compiled.count_ops()))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "`GenericBackendV2` supplies an **offline hardware target**, not measured device\n", + "data. To compile for a real device, pass that device's Qiskit backend instead.\n", + "The native gates and connectivity then come from its target. YAQS does not\n", + "import the backend's calibration data into a noise model; we define the noise\n", + "separately below.\n", + "\n", + "## 2. Align the outputs and verify the compiled circuit\n", + "\n", + "Routing can leave logical outputs on different physical qubits. The checker\n", + "compares circuit wires directly, so we must account for this mapping before\n", + "interpreting a mismatch as a compiler bug. We fixed the initial placement to\n", + "`[0, 1, 2, 3]`; the remaining change is the final output permutation.\n", + "\n", + "Append that permutation to the **reference** circuit. The compiled circuit stays\n", + "as the device would execute it, including its routing gates. Qiskit's\n", + "`PermutationGate` lists the input wire for each output position, so we invert\n", + "the logical-to-physical map returned by `final_index_layout()`." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Logical output → physical qubit: [2, 0, 1, 3]\n", + "Equivalent: True\n", + "Overlap: 1.000000000000\n" + ] + } + ], + "source": [ + "from qiskit.circuit.library import PermutationGate\n", + "\n", + "from mqt.yaqs import EquivalenceChecker\n", + "\n", + "output_mapping = compiled.layout.final_index_layout()\n", + "reference = original.copy()\n", + "reference.append(PermutationGate(np.argsort(output_mapping).tolist()), range(num_qubits))\n", + "reference = reference.decompose(gates_to_decompose=[\"permutation\"])\n", + "\n", + "checker = EquivalenceChecker()\n", + "verified = checker.check(reference, compiled)\n", + "\n", + "print(\"Logical output → physical qubit:\", output_mapping)\n", + "print(\"Equivalent:\", verified[\"equivalent\"])\n", + "print(f\"Overlap: {verified['fidelity']:.12f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "For the reference unitary $U$ and compiled unitary $V$, the returned overlap is\n", + "\n", + "$$\n", + "a=\\frac{|\\operatorname{Tr}(UV^\\dagger)|}{2^n}.\n", + "$$\n", + "\n", + "An overlap of one means that the circuits agree on every input state, up to a\n", + "global phase. `equivalent` tests whether this value reaches the checker's\n", + "`fidelity` setting, which defaults to `1 - 1e-13`. The compiled circuit passes\n", + "this numerical check. This compares the full operation, rather than only the\n", + "output from one chosen input state.\n", + "\n", + "The default checker selects its backend automatically: dense matrices for at\n", + "most seven qubits, and a matrix product operator (MPO) for larger circuits. This\n", + "small example therefore uses the matrix backend.\n", + "\n", + "```{note}\n", + "The mapping above assumes the identity initial placement and equal circuit\n", + "widths. For another initial layout or a backend that adds ancillas, also align\n", + "the input wires and the circuit widths before checking. YAQS does not apply\n", + "Qiskit's transpilation layout automatically.\n", + "```\n", + "\n", + "## 3. Introduce a rotation-angle bug\n", + "\n", + "Suppose a compiler pass changes one native $R_z$ angle by $\\delta$. We copy the\n", + "compiled circuit and change its first `rz` instruction, leaving the routing and\n", + "all other gates intact." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Buggy circuit equivalent: False\n", + "Overlap with a π/2 angle error: 0.7071\n" + ] + } + ], + "source": [ + "def with_angle_error(circuit, delta):\n", + " \"\"\"Offset the first native Z rotation by delta radians.\"\"\"\n", + " changed = circuit.copy()\n", + " index = next(i for i, instruction in enumerate(changed.data) if instruction.operation.name == \"rz\")\n", + " instruction = changed.data[index]\n", + " rotation = instruction.operation.copy()\n", + " rotation.params[0] += delta\n", + " changed.data[index] = instruction.replace(operation=rotation)\n", + " return changed\n", + "\n", + "\n", + "angles = np.linspace(0, np.pi, 25)\n", + "angle_overlaps = [checker.check(reference, with_angle_error(compiled, delta))[\"fidelity\"] for delta in angles]\n", + "bug_angle = np.pi / 2\n", + "buggy = with_angle_error(compiled, bug_angle)\n", + "bug_result = checker.check(reference, buggy)\n", + "\n", + "print(\"Buggy circuit equivalent:\", bug_result[\"equivalent\"])\n", + "print(f\"Overlap with a π/2 angle error: {bug_result['fidelity']:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "For this single rotation error, the overlap is exactly $|\\cos(\\delta/2)|$ in\n", + "exact arithmetic. The other gates cancel inside the trace, so the formula does\n", + "not depend on where the faulty rotation occurs. A $\\pi/2$ error gives an overlap\n", + "of about 0.707 and fails the equivalence check. A smaller error also fails once\n", + "its overlap falls below the chosen tolerance.\n", + "\n", + "(equivalence-noise-model)=\n", + "\n", + "## 4. Add a hardware noise model\n", + "\n", + "A correctly compiled circuit can still deviate from the intended operation\n", + "because its gates are noisy. We model a Pauli error after each controlled-X\n", + "gate: on each participating qubit, apply $X$, $Y$, or $Z$ with probability $p/3$\n", + "each, and apply no error with probability $1-p$. Sweeping $p$ separates the\n", + "noiseless compiler check from the effect of executing the circuit on noisy\n", + "hardware." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import NoiseModel\n", + "\n", + "probabilities = np.array([0.0, 0.005, 0.015, 0.03, 0.06, 0.1])\n", + "implementations = {\"Correct compilation\": compiled, \"Rotation-angle bug\": buggy}\n", + "noise_results = {label: [] for label in implementations}\n", + "\n", + "for probability in probabilities:\n", + " noise = NoiseModel([\n", + " {\"name\": f\"pauli_{axis}\", \"sites\": [site], \"strength\": float(probability / 3)}\n", + " for site in range(num_qubits)\n", + " for axis in \"xyz\"\n", + " ])\n", + " for label, circuit in implementations.items():\n", + " result = checker.check(\n", + " reference,\n", + " circuit,\n", + " noise_model=noise,\n", + " num_traj=256,\n", + " random_seed=7,\n", + " )\n", + " noise_results[label].append(result)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "**Noise acts on the second circuit only.** Every supported two-qubit unitary is\n", + "a noise opportunity when it contains all sites of a process. Thus routing gates\n", + "also contribute noise. Single-qubit gates, barriers, measurements, and gates on\n", + "three or more qubits do not create noise opportunities.\n", + "\n", + "For the checker, `strength` is a\n", + "**dimensionless probability per eligible gate**, not a Lindblad rate or a\n", + "probability for the whole circuit. This differs from noise strengths in\n", + "{doc}`analog_simulation` and {doc}`circuit_observables`. Processes with the same\n", + "exact support are mutually exclusive, and their probabilities must sum to at\n", + "most one. Different supports are sampled independently, including overlapping\n", + "supports.\n", + "\n", + "Each trajectory gives a unitary $V_r$ with sampled Pauli errors. The noisy\n", + "result reports the square root of the estimated process fidelity,\n", + "\n", + "$$\n", + "\\mathtt{fidelity}=\\sqrt{\\frac{1}{N}\\sum_{r=1}^{N}\n", + "\\left|\\frac{\\operatorname{Tr}(UV_r^\\dagger)}{2^n}\\right|^2}.\n", + "$$\n", + "\n", + "This is the root-mean-square trajectory overlap, not the mean overlap or a\n", + "measurement success probability. `fidelity_error` estimates its Monte Carlo\n", + "standard error. Increasing `num_traj` reduces sampling uncertainty; it does not\n", + "make the assumed hardware model more accurate.\n", + "\n", + "## 5. Compare the bug and noise effects\n", + "\n", + "The left panel checks the controlled rotation error against its exact formula.\n", + "The right panel compares correct and faulty compilations under the same noise\n", + "model. Plotting code is folded so the verification workflow remains visible." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:37:58.048394\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " R\n", + " o\n", + " t\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + " -\n", + " a\n", + " n\n", + " g\n", + " l\n", + " e\n", + "  \n", + " e\n", + " r\n", + " r\n", + " o\n", + " r\n", + "  \n", + " /\n", + " δ\n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " Root process fidelity\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a)\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " |\n", + " c\n", + " o\n", + " s\n", + " (\n", + " /\n", + " 2\n", + " )\n", + " |\n", + " δ\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " YAQS\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " B\n", + " u\n", + " g\n", + " :\n", + "  \n", + " =\n", + " /\n", + " 2\n", + " δ\n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 10\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " P\n", + " a\n", + " u\n", + " l\n", + " i\n", + " -\n", + " e\n", + " r\n", + " r\n", + " o\n", + " r\n", + "  \n", + " p\n", + " r\n", + " o\n", + " b\n", + " a\n", + " b\n", + " i\n", + " l\n", + " i\n", + " t\n", + " y\n", + "  \n", + " p\n", + " e\n", + " r\n", + "  \n", + " g\n", + " a\n", + " t\n", + " e\n", + "  \n", + " q\n", + " u\n", + " b\n", + " i\n", + " t\n", + "  \n", + "  \n", + " (\n", + " %\n", + " )\n", + " p\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b)\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Correct compilation\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Rotation-angle bug\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\",\n", + " \"font.serif\": [\"STIXGeneral\"],\n", + " \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 10,\n", + " \"axes.labelsize\": 11,\n", + " \"axes.linewidth\": 0.8,\n", + " \"xtick.direction\": \"in\",\n", + " \"ytick.direction\": \"in\",\n", + " \"xtick.top\": True,\n", + " \"ytick.right\": True,\n", + " \"svg.fonttype\": \"none\",\n", + "})\n", + "%config InlineBackend.figure_formats = ['svg']\n", + "\n", + "colors = [\"#225c80\", \"#bb563b\"]\n", + "fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8), sharey=True, layout=\"constrained\")\n", + "fine_angles = np.linspace(0, np.pi, 200)\n", + "axes[0].plot(fine_angles / np.pi, np.abs(np.cos(fine_angles / 2)), color=\"0.55\", lw=1.7, label=r\"$|\\cos(\\delta/2)|$\")\n", + "axes[0].plot(angles / np.pi, angle_overlaps, \"o\", ms=3.5, color=colors[0], label=\"YAQS\")\n", + "axes[0].plot(0.5, bug_result[\"fidelity\"], \"D\", ms=5, color=colors[1], label=r\"Bug: $\\delta=\\pi/2$\")\n", + "axes[0].set(xlabel=r\"Rotation-angle error $\\delta/\\pi$\", ylabel=\"Root process fidelity\", xlim=(-0.03, 1.03))\n", + "axes[0].legend(frameon=False, fontsize=9, loc=\"lower left\")\n", + "\n", + "for (label, results), color in zip(noise_results.items(), colors, strict=True):\n", + " values = [result[\"fidelity\"] for result in results]\n", + " errors = [result[\"fidelity_error\"] for result in results]\n", + " axes[1].errorbar(100 * probabilities, values, yerr=errors, color=color, marker=\"o\", ms=4, lw=1.6, capsize=2.5, label=label)\n", + "axes[1].set(xlabel=r\"Pauli-error probability per gate qubit $p$ (%)\", xlim=(-0.3, 10.3))\n", + "axes[1].legend(frameon=False, fontsize=9, loc=\"lower left\")\n", + "for label, ax in zip((\"(a)\", \"(b)\"), axes, strict=True):\n", + " ax.text(0.02, 1.03, label, transform=ax.transAxes, va=\"bottom\", fontweight=\"bold\")\n", + " ax.set_ylim(-0.04, 1.06)\n", + " ax.spines[[\"top\", \"right\"]].set_visible(False)\n", + " ax.tick_params(top=False, right=False)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "**Compiler errors and hardware noise reduce agreement in different ways.** (a)\n", + "The noiseless overlap follows the single-angle error formula. (b) With no noise,\n", + "the correct compilation agrees with the reference, while the faulty circuit\n", + "starts below one. As the Pauli-error probability increases, both estimates fall\n", + "in this example. Error bars show one Monte Carlo standard error from 256\n", + "trajectories. Lines join sampled points.\n", + "\n", + "This comparison tells us whether compilation preserves the intended operation\n", + "and how a specified hardware model changes its process fidelity. It does not\n", + "identify an unknown bug or infer device noise from measurements. For learning\n", + "noise strengths from observed dynamics, see {doc}`digital_twin`.\n", + "\n", + "For noisy results, `equivalent` applies the same overlap threshold to the point\n", + "estimate without using its error bar. Treat this as a sampled comparison, not an\n", + "exact noisy-channel certificate. In particular, a zero reported error bar when\n", + "every sampled overlap is zero does not establish zero uncertainty.\n", + "\n", + "## Further options\n", + "\n", + "### Accuracy and backends\n", + "\n", + "Keep `representation=\"auto\"` for automatic selection, or choose `\"matrix\"` or\n", + "`\"mpo\"` explicitly. Dense matrix storage grows as $4^n$; MPO cost depends on the\n", + "operator's bond dimensions and can also grow rapidly. The MPO method is\n", + "described in {footcite:p}`sander2025_EquivalenceChecking`.\n", + "\n", + "The constructor's `fidelity` sets the decision threshold, while `threshold` sets\n", + "the MPO singular-value cutoff. These control different errors. For an\n", + "approximate comparison, choose a decision threshold that matches your purpose\n", + "and check that numerical truncation does not determine the answer. The returned\n", + "`representation` records which backend ran.\n", + "\n", + "Terminal measurements are ignored for unitary checks; mid-circuit measurements\n", + "are unsupported. Decompose gates on more than two qubits before using the MPO\n", + "backend. See {ref}`circuit-custom-gates` for supported gate translation.\n", + "\n", + "### Noise models and returned data\n", + "\n", + "The checker supports stochastic Pauli errors, including Pauli products. It\n", + "rejects dissipative channels such as relaxation; use the simulator for those\n", + "channels. See {doc}`realistic_noise_models` for model construction.\n", + "Distribution-valued strengths are drawn once per `check`, then held fixed across\n", + "its trajectories. The resolved same-support probabilities must still sum to at\n", + "most one.\n", + "\n", + "Pass `return_trajectories=True` to keep each trajectory result. Noisy results\n", + "have `matrix=None` and `mpo=None` because the ensemble is a channel. For MPO\n", + "checks, returned entropies and zero-padded Schmidt spectra are trajectory means,\n", + "not spectra of that channel. `fidelity_error` is `None` for a single trajectory.\n", + "\n", + "### Inputs and execution\n", + "\n", + "`check` accepts Qiskit circuits, OpenQASM file paths, and raw OpenQASM source\n", + "strings. OpenQASM 3 requires the optional `mqt-yaqs[qasm3]` extra. File paths\n", + "allow includes to resolve relative to the source file.\n", + "\n", + "Parallel execution is enabled by default. `max_workers` caps concurrency, and\n", + "`mp_context` controls the start method for noisy process pools. A nonnegative\n", + "`random_seed` makes sampled trajectories reproducible across worker scheduling.\n", + "See {class}`~mqt.yaqs.EquivalenceChecker` for the full settings and returned\n", + "fields.\n", + "\n", + "```{footbibliography}\n", + "```" + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 32, + 62, + 82, + 98, + 129, + 149, + 168, + 190, + 224, + 264 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/hamiltonians.ipynb b/docs/_outputs/examples/hamiltonians.ipynb new file mode 100644 index 000000000..feca6e456 --- /dev/null +++ b/docs/_outputs/examples/hamiltonians.ipynb @@ -0,0 +1,443 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Building Hamiltonians\n", + "\n", + "A Hamiltonian defines the energies and interactions in an analog simulation.\n", + "Build one with a named model, a sum of Pauli terms, or your own operator data,\n", + "then pass it to `Simulator.run`. Match its site count and local dimensions to\n", + "those of the initial `State`.\n", + "\n", + "## Choose a built-in model\n", + "\n", + "Most builders create a matrix product operator (MPO), which stores the operator\n", + "as a tensor network. Use the `Hamiltonian` methods directly when available; wrap\n", + "an MPO with `Hamiltonian.from_mpo` for the other models.\n", + "\n", + "| Model | Constructor | Site layout |\n", + "| ------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- |\n", + "| Transverse-field Ising | {meth}`~mqt.yaqs.Hamiltonian.ising` | Qubits. |\n", + "| Heisenberg or XY | {meth}`~mqt.yaqs.Hamiltonian.heisenberg` | Qubits. |\n", + "| On-site and nearest-neighbor Pauli terms | {meth}`~mqt.yaqs.Hamiltonian.pauli` | Qubits. |\n", + "| Indexed Pauli strings, including long-range terms | {meth}`~mqt.yaqs.MPO.from_pauli_sum` | Qubits; wrap the MPO. |\n", + "| 1D Fermi–Hubbard | {meth}`~mqt.yaqs.Hamiltonian.fermi_hubbard_1d` | Dimension-four sites, or a Jordan–Wigner qubit chain. |\n", + "| Bose–Hubbard | {meth}`~mqt.yaqs.MPO.bose_hubbard` | Truncated boson occupation; wrap the MPO. |\n", + "| Coupled transmons and resonators | {meth}`~mqt.yaqs.Hamiltonian.coupled_transmon` | Alternating transmon and resonator dimensions. |\n", + "| Trapped ion position grid | {meth}`~mqt.yaqs.MPO.trapped_ion` | One grid per ion, for one or two ions; wrap the MPO. |\n", + "\n", + "YAQS evolves with $\\exp(-itH)$, using $\\hbar=1$. Hamiltonian coefficients and\n", + "times must use consistent units. For energies in SI units, divide the operator\n", + "by $\\hbar$ before evolving with time in seconds.\n", + "\n", + "## Build a spin chain\n", + "\n", + "For an open chain, the Ising shortcut constructs\n", + "\n", + "$$\n", + "H_{\\mathrm{Ising}}=-J\\sum_{i=0}^{L-2}Z_iZ_{i+1}-g\\sum_{i=0}^{L-1}X_i.\n", + "$$" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import Hamiltonian, State\n", + "\n", + "length = 4\n", + "hamiltonian = Hamiltonian.ising(length, J=1.0, g=0.5)\n", + "state = State(length, initial=\"zeros\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "The Heisenberg shortcut uses the sign convention\n", + "\n", + "$$\n", + "H_{\\mathrm{Heisenberg}}=-\\sum_{i=0}^{L-2}\n", + "\\left(J_xX_iX_{i+1}+J_yY_iY_{i+1}+J_zZ_iZ_{i+1}\\right)\n", + "-h\\sum_{i=0}^{L-1}Z_i.\n", + "$$\n", + "\n", + "Here, $X$, $Y$, and $Z$ are Pauli matrices with eigenvalues $\\pm1$, not spin\n", + "operators with eigenvalues $\\pm1/2$. Setting `Jz=0` gives the XY model used in\n", + "{doc}`analog_simulation`:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "xy = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "These builders and `Hamiltonian.pauli` use open boundaries by default. Set\n", + "`bc=\"periodic\"` to include the bond from the last site to site 0.\n", + "\n", + "## Specify your own Pauli terms\n", + "\n", + "`Hamiltonian.pauli` repeats each `two_body` term over neighboring sites and each\n", + "`one_body` term over all sites. Coefficients enter with the sign you supply. For\n", + "example, the following builds the same XY Hamiltonian as above:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "xy_from_terms = Hamiltonian.pauli(\n", + " length=length,\n", + " two_body=[(-0.5, \"X\", \"X\"), (-0.5, \"Y\", \"Y\")],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "Add an on-site field with an entry such as `one_body=[(-0.2, \"Z\")]`. These\n", + "structured builders require finite real coefficients and qubit sites.\n", + "\n", + "For site-dependent fields, separated sites, or longer strings, build an MPO from\n", + "explicit `(coefficient, string)` pairs:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import MPO\n", + "\n", + "mpo = MPO()\n", + "mpo.from_pauli_sum(\n", + " terms=[(0.4, \"Z0 Z3\"), (0.2, \"Y1\"), (0.1, \"\")],\n", + " length=length,\n", + ")\n", + "custom = Hamiltonian.from_mpo(mpo)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "This operator is $0.4Z_0Z_3+0.2Y_1+0.1I$. Site indices start at zero; omitted\n", + "sites act as identities, and an empty string denotes the identity operator.\n", + "Labels `I`, `X`, `Y`, and `Z` are case-insensitive. Use real coefficients for\n", + "Hermitian Pauli terms.\n", + "\n", + "## Use other local dimensions\n", + "\n", + "Hubbard and device models need an initial state with the same local dimensions\n", + "as the Hamiltonian. For a uniform layout, use `physical_dimensions=local_dim`;\n", + "for different dimensions, supply a list in site order. The\n", + "{doc}`state_initialization` guide explains these preparations.\n", + "\n", + "The coupled-transmon builder alternates transmons and resonators, starting with\n", + "a transmon. Its `length` counts both kinds of sites. The trapped ion builder\n", + "uses one site per ion, with a local dimension equal to the number of grid\n", + "points. See {doc}`transmon_emulation` and {doc}`trapped_ion` for worked device\n", + "examples, noise, and the relevant units.\n", + "\n", + ":::{dropdown} Fermi–Hubbard: physical sites and Jordan–Wigner orbitals\n", + "\n", + "The default builder uses dimension-four sites with local basis $|0\\rangle$,\n", + "$|\\!\\downarrow\\rangle$, $|\\!\\uparrow\\rangle$, $|\\!\\uparrow\\downarrow\\rangle$:\n", + "\n", + "```python\n", + "num_sites = 3\n", + "fermi = Hamiltonian.fermi_hubbard_1d(num_sites, t=1.0, u=0.5)\n", + "fermi_state = State(num_sites, physical_dimensions=4)\n", + "```\n", + "\n", + "This mode uses ladder operators on composite sites. For a Pauli-chain model with\n", + "full Jordan–Wigner signs between spin orbitals, set `jordan_wigner=True`:\n", + "\n", + "```python\n", + "jw = Hamiltonian.fermi_hubbard_1d(2 * num_sites, t=1.0, u=0.5, jordan_wigner=True)\n", + "jw_state = State(2 * num_sites)\n", + "```\n", + "\n", + "The two modes use different hopping-sign conventions. Match both the state and\n", + "operator basis when comparing them.\n", + "\n", + "Here, `length` counts spin orbitals and must be even and at least two. Site\n", + "order is $1\\uparrow,1\\downarrow,2\\uparrow,2\\downarrow,\\ldots$. Both builders use\n", + "open boundaries and omit a chemical-potential term. The\n", + "{func}`~mqt.yaqs.core.libraries.circuit_library.create_1d_fermi_hubbard_circuit`\n", + "provides a digital Trotter circuit with a chemical-potential option.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} Bose–Hubbard and the occupation cutoff\n", + "\n", + "`local_dim` retains occupations from zero through `local_dim - 1` at each site.\n", + "The builder includes an on-site frequency, an interaction $U n_i(n_i-1)/2$, and\n", + "nearest-neighbor hopping with coefficient $-J$:\n", + "\n", + "```python\n", + "local_dim = 3\n", + "bose = Hamiltonian.from_mpo(\n", + " MPO.bose_hubbard(\n", + " length=3,\n", + " local_dim=local_dim,\n", + " omega=1.0,\n", + " hopping_j=0.2,\n", + " hubbard_u=0.5,\n", + " )\n", + ")\n", + "bose_state = State(3, physical_dimensions=local_dim)\n", + "```\n", + "\n", + "Choose a large enough occupation cutoff for your preparation and dynamics. Check\n", + "convergence by increasing `local_dim` when higher occupations matter.\n", + "\n", + ":::\n", + "\n", + "## Supply a dense or sparse matrix\n", + "\n", + "For a small custom operator, pass exactly one of `matrix`, `sparse_matrix`, or\n", + "`tensors`. Dense and sparse matrices must be finite, square, and Hermitian. The\n", + "manual constructor uses a uniform `physical_dimension`, defaulting to two; it\n", + "infers `length` from the matrix size when omitted.\n", + "\n", + "(physical-site-ordering)=\n", + "\n", + "### Physical-site ordering\n", + "\n", + "Dense and sparse matrices use site 0 as the least-significant, fastest-varying\n", + "subsystem, matching Qiskit's qubit ordering. An operator acting on site 0 is\n", + "therefore the rightmost Kronecker factor:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from scipy.sparse import csr_matrix\n", + "\n", + "identity = np.eye(2, dtype=complex)\n", + "pauli_x = np.array([[0, 1], [1, 0]], dtype=complex)\n", + "x_on_site_0 = np.kron(identity, pauli_x)\n", + "\n", + "dense = Hamiltonian(matrix=x_on_site_0)\n", + "sparse = Hamiltonian(sparse_matrix=csr_matrix(x_on_site_0))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "For local dimensions $d_0,d_1,\\ldots$, the flat index for basis digits\n", + "$(s_0,s_1,\\ldots)$ is $s_0+d_0s_1+d_0d_1s_2+\\cdots$. This order applies to full\n", + "spatial state vectors and operator matrices. Local observable and noise matrices\n", + "use the order of their explicit site list: for example,\n", + "`Observable(np.kron(X, Z), sites=[0, 1])` means $X_0Z_1$. Custom adjacent\n", + "two-site noise matrices require ascending site lists. Circuit matrices follow\n", + "Qiskit's gate convention.\n", + "\n", + "The initial state's representation selects the analog backend, independently of\n", + "the Hamiltonian's source data. See {doc}`representation_comparison` for the\n", + "supported choices.\n", + "\n", + "```{warning}\n", + "Converting a sparse matrix to an MPO densifies the full operator. A sparse\n", + "source therefore does not avoid the full matrix allocation when you run with an\n", + "MPS state. For large MPS simulations, build an MPO directly with a preset, Pauli\n", + "terms, or custom tensors.\n", + "```\n", + "\n", + "## Time-dependent Hamiltonians\n", + "\n", + "Use `Hamiltonian.piecewise` for an analog quench: evolve under one static\n", + "Hamiltonian and then another, on a shared time grid. Every duration must be a\n", + "positive integer multiple of `dt`, and their sum must equal `elapsed_time`. All\n", + "pieces must have matching site counts and local dimensions:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import AnalogSimParams, Observable, Simulator\n", + "\n", + "quench = Hamiltonian.piecewise([\n", + " (hamiltonian, 0.2),\n", + " (Hamiltonian.ising(length, J=1.0, g=2.0), 0.2),\n", + "])\n", + "params = AnalogSimParams(\n", + " observables=[Observable(\"z\", 0)], elapsed_time=quench.duration, dt=0.05\n", + ")\n", + "\n", + "sim = Simulator(show_progress=False)\n", + "result = sim.run(state, quench, params)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "The result contains nine sample times, including time zero, on one continuous\n", + "timeline. This path supports a single MPS state and the default TDVP evolution;\n", + "it also accepts a shared noise model. It does not support dense state\n", + "representations, BUG evolution, or list-of-state ensembles.\n", + "\n", + "For digital gates, different time steps, or segment-specific noise, use a\n", + "`SimulationProgram`; see {doc}`digital_analog_simulation`. A piecewise\n", + "Hamiltonian has no single static MPO or matrix. Select a static entry from\n", + "`quench.pieces` when you need its operator.\n", + "\n", + "## Advanced operator use\n", + "\n", + ":::{dropdown} Custom MPO tensors and construction accuracy\n", + "\n", + "Manual cores use `(left, right, physical_out, physical_in)` axes in ascending\n", + "site order. Neighboring bonds must match, exterior bonds must have dimension\n", + "one, and entries must be finite. The manual `Hamiltonian` constructor requires\n", + "uniform local dimensions. For example, these bond-one cores build the same $X_0$\n", + "operator as the matrix example:\n", + "\n", + "```python\n", + "tensor_hamiltonian = Hamiltonian(\n", + " tensors=[\n", + " pauli_x.reshape(1, 1, 2, 2),\n", + " identity.reshape(1, 1, 2, 2),\n", + " ]\n", + ")\n", + "```\n", + "\n", + "When you already have an MPO, use `Hamiltonian.from_mpo`. It references the same\n", + "MPO, checks its structure, and takes its local dimensions from the cores.\n", + "Wrapped MPOs and tensor inputs must represent a globally Hermitian operator;\n", + "YAQS does not perform a full-matrix Hermiticity check for these inputs.\n", + "\n", + "Pauli builders expose `tol`, `max_bond_dim`, and `n_sweeps` for operator\n", + "compression. Dense-to-MPO conversion also uses an SVD cutoff, so the cached MPO\n", + "can approximate the source matrix. Set conversion options explicitly with\n", + "`MPO.from_matrix(matrix, d=2, cutoff=..., max_bond=...)` and wrap the result\n", + "when you need control over this approximation. These choices concern the\n", + "operator itself; simulation-parameter presets control the evolving state.\n", + "\n", + "Static Hamiltonians cache converted forms. Call `ensure_mpo()` before reading\n", + "`.mpo`, or `ensure_sparse()` before reading `.sparse_matrix` if the form is not\n", + "already available. `to_matrix()` and `to_sparse_matrix()` return full operators\n", + "for small-system inspection. Create a new Hamiltonian when changing the\n", + "operator; cached forms do not track edits to the source data.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} Energy and long-range correlations of an MPS\n", + "\n", + "Contract an MPS with a static Hamiltonian's MPO to obtain its energy. For an\n", + "arbitrary nonzero state, divide the raw contraction by the squared norm:\n", + "\n", + "```python\n", + "hamiltonian.ensure_mpo()\n", + "norm_squared = state.mps.norm() ** 2\n", + "energy = state.mps.expect_mpo(hamiltonian.mpo) / norm_squared\n", + "```\n", + "\n", + "`expect_mpo` returns the raw complex contraction without normalizing the state.\n", + "It uses the cached MPO, including any construction approximation. A Hermitian\n", + "energy is real up to numerical error.\n", + "\n", + "The same method evaluates a full-chain Pauli product on separated sites:\n", + "\n", + "```python\n", + "correlation = MPO()\n", + "correlation.from_pauli_sum(terms=[(1.0, \"Z0 Z3\")], length=length, n_sweeps=0)\n", + "zz = state.mps.expect_mpo(correlation) / norm_squared\n", + "z0 = state.mps.expect(Observable(\"z\", 0)) / norm_squared\n", + "z3 = state.mps.expect(Observable(\"z\", 3)) / norm_squared\n", + "connected = zz - z0 * z3\n", + "```\n", + "\n", + "Here, `zz` is $\\langle Z_0Z_3\\rangle$ and `connected` subtracts\n", + "$\\langle Z_0\\rangle\\langle Z_3\\rangle$. Omitted sites act as identities. Use\n", + "`MPO.from_local_ops` instead when you already have one local matrix per site,\n", + "including identities between separated factors.\n", + "\n", + ":::\n", + "\n", + "With the operator and initial state prepared, continue with\n", + "{doc}`analog_simulation` for noisy dynamics or {doc}`simulation_parameters` for\n", + "accuracy and sampling choices. The {class}`~mqt.yaqs.Hamiltonian` API reference\n", + "lists the full constructor signatures." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 120, + "number_source_lines": true + }, + "source_map": [ + 10, + 48, + 54, + 68, + 70, + 81, + 86, + 94, + 103, + 193, + 203, + 231, + 244 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/memory_surrogate.ipynb b/docs/_outputs/examples/memory_surrogate.ipynb new file mode 100644 index 000000000..32ddaa3a6 --- /dev/null +++ b/docs/_outputs/examples/memory_surrogate.ipynb @@ -0,0 +1,1020 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Predicting Non-Markovian Dynamics\n", + "\n", + "A control pulse changes a quantum system and its later interaction with the\n", + "environment. A surrogate learns that response from simulated sequences, then\n", + "predicts reduced system states for new controls. Here we train one small model\n", + "and compare a pulse-angle sweep with exact two-qubit evolution.\n", + "\n", + "```{note}\n", + "**Experimental feature.** Surrogate modeling is not yet supported by a published\n", + "YAQS paper. This small example teaches the workflow; it does not demonstrate a\n", + "speed advantage or establish accuracy for longer control sequences. Validate\n", + "predictions against simulations or measurements for your intended use.\n", + "```\n", + "\n", + "Install the PyTorch extra with `uv pip install \"mqt.yaqs[torch]\"`. The plot also\n", + "uses Matplotlib. Run the cells in order in a notebook; for a script, use the\n", + "entry-point guard in {doc}`simulator_initialization`.\n", + "\n", + "## 1. Choose the system and train on random controls\n", + "\n", + "Site 0 is the probe, and site 1 is an environment qubit initially in\n", + "$|0\\rangle$. Their Hamiltonian is $H=-Z_0Z_1-0.5(X_0+X_1)$, with $\\hbar=1$. We\n", + "apply two random single-qubit rotations to the probe, each followed by evolution\n", + "for $0.6$. The environment can retain information about the earlier control." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import torch\n", + "\n", + "from mqt.yaqs import AnalogSimParams, Hamiltonian, MemoryCharacterizer\n", + "\n", + "interval = 0.6\n", + "schedule = [0.0, interval, interval]\n", + "hamiltonian = Hamiltonian.ising(2, J=1.0, g=0.5)\n", + "params = AnalogSimParams(elapsed_time=interval, dt=interval, preset=\"fast\")\n", + "characterizer = MemoryCharacterizer(show_progress=False)\n", + "\n", + "torch.manual_seed(7)\n", + "validation = characterizer.sample(\n", + " hamiltonian, params, num_interventions=2, n=128, seed=99,\n", + " timesteps=schedule, intervention_style=\"haar\",\n", + ")\n", + "model = characterizer.train(\n", + " hamiltonian, params, num_interventions=2, n=2048, seed=7,\n", + " timesteps=schedule, intervention_style=\"haar\",\n", + " model_kwargs={\"d_model\": 64, \"num_layers\": 2, \"dim_ff\": 128},\n", + " train_kwargs={\"epochs\": 200, \"lr\": 1e-3, \"device\": \"cpu\", \"val_dataset\": validation},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "`timesteps` contains the initial delay and one duration after each intervention.\n", + "The initial `0.0` places the first rotation immediately after preparation, and\n", + "the final time is $1.2$. Training varies the probe preparation and draws random\n", + "unitaries with `intervention_style=\"haar\"`; the environment preparation stays\n", + "fixed. YAQS restores the model with the lowest validation loss. The validation\n", + "set selects the model, so it is not an independent accuracy test.\n", + "\n", + "## 2. Predict a pulse-angle sweep\n", + "\n", + "Prepare the probe in $|+\\rangle$. Use the identity as the first control, then\n", + "apply $R_z(\\theta)$ between the two evolution intervals. These chosen controls\n", + "were not supplied during training. At $\\theta=0$ the system evolves freely;\n", + "$\\theta=\\pi$ gives a phase flip halfway through." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "plus = np.array([1, 1], dtype=complex) / np.sqrt(2)\n", + "rho0 = np.outer(plus, plus.conj())\n", + "identity = np.eye(2, dtype=complex)\n", + "pulse_angles = np.linspace(0, 2 * np.pi, 41)\n", + "sequences = [\n", + " [\n", + " {\"unitary\": identity},\n", + " {\"unitary\": np.diag(np.exp(-0.5j * angle * np.array([1, -1])))},\n", + " ]\n", + " for angle in pulse_angles\n", + "]\n", + "predicted = np.stack([\n", + " characterizer.predict(model, rho0, sequence) for sequence in sequences\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "`predict` returns a complex `(2, 2)` density-matrix estimate. Stacking the sweep\n", + "produces shape `(41, 2, 2)`. All entries describe the same final time, not a\n", + "trajectory through time.\n", + "\n", + "(short-horizon-validation)=\n", + "\n", + "## 3. Check against exact evolution\n", + "\n", + "For two qubits, SciPy's matrix exponential gives a cheap reference independent\n", + "of YAQS's solvers. Site 0 is the least significant bit, so the joint initial\n", + "state is $|0\\rangle_{\\mathrm{env}}\\otimes|+\\rangle_{\\mathrm{probe}}$ and a probe\n", + "pulse acts as $I\\otimes R_z(\\theta)$. Tracing out the environment gives the\n", + "reference probe state." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Maximum matrix error: 0.0823\n", + "Maximum trace error: 0.0137; minimum eigenvalue: 0.1237\n" + ] + } + ], + "source": [ + "from scipy.linalg import expm\n", + "\n", + "pauli_x = np.array([[0, 1], [1, 0]], dtype=complex)\n", + "pauli_z = np.diag([1.0, -1.0])\n", + "dense_hamiltonian = -np.kron(pauli_z, pauli_z) - 0.5 * (\n", + " np.kron(identity, pauli_x) + np.kron(pauli_x, identity)\n", + ")\n", + "evolution = expm(-1j * interval * dense_hamiltonian)\n", + "initial_joint = np.kron([1, 0], plus)\n", + "states = []\n", + "for sequence in sequences:\n", + " joint = evolution @ np.kron(identity, sequence[1][\"unitary\"]) @ evolution @ initial_joint\n", + " amplitudes = joint.reshape(2, 2)\n", + " states.append(amplitudes.T @ amplitudes.conj())\n", + "reference = np.stack(states)\n", + "\n", + "matrix_errors = 0.5 * np.sum(np.abs(np.linalg.eigvalsh(predicted - reference)), axis=1)\n", + "trace_error = np.max(np.abs(np.trace(predicted, axis1=1, axis2=2) - 1))\n", + "minimum_eigenvalue = np.min(np.linalg.eigvalsh(predicted))\n", + "print(f\"Maximum matrix error: {matrix_errors.max():.4f}\")\n", + "print(f\"Maximum trace error: {trace_error:.4f}; minimum eigenvalue: {minimum_eigenvalue:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "The matrix error is half the trace norm of the difference. It equals trace\n", + "distance when both matrices are normalized physical states. The public API makes\n", + "predictions Hermitian but does not enforce unit trace or positivity. The printed\n", + "checks expose those limitations; we do not normalize the predictions or clip\n", + "negative eigenvalues." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:38:27.366336\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " P\n", + " u\n", + " l\n", + " s\n", + " e\n", + "  \n", + " a\n", + " n\n", + " g\n", + " l\n", + " e\n", + "  \n", + " θ\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.7\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " F\n", + " i\n", + " n\n", + " a\n", + " l\n", + "  \n", + " c\n", + " o\n", + " h\n", + " e\n", + " r\n", + " e\n", + " n\n", + " c\n", + " e\n", + "  \n", + " 2\n", + " |\n", + " |\n", + " ρ\n", + " 0\n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) Response to a control pulse\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Surrogate\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Exact evolution\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " P\n", + " u\n", + " l\n", + " s\n", + " e\n", + "  \n", + " a\n", + " n\n", + " g\n", + " l\n", + " e\n", + "  \n", + " θ\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.02\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.04\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.06\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.08\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " M\n", + " a\n", + " t\n", + " r\n", + " i\n", + " x\n", + "  \n", + " e\n", + " r\n", + " r\n", + " o\n", + " r\n", + "  \n", + " ‖\n", + " −\n", + " ‖\n", + " 1\n", + " 2\n", + " p\n", + " r\n", + " e\n", + " d\n", + " r\n", + " e\n", + " f\n", + " 1\n", + " ρ\n", + " ρ\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b) Error against exact evolution\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\", \"font.serif\": [\"STIXGeneral\"], \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 10, \"axes.labelsize\": 11, \"axes.linewidth\": 0.7,\n", + " \"xtick.direction\": \"in\", \"ytick.direction\": \"in\",\n", + " \"xtick.top\": True, \"ytick.right\": True,\n", + " \"legend.frameon\": False, \"figure.constrained_layout.use\": True,\n", + " \"savefig.bbox\": \"tight\", \"svg.fonttype\": \"none\",\n", + "})\n", + "fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8))\n", + "axes[0].plot(pulse_angles, 2 * np.abs(predicted[:, 0, 1]), color=\"#0072B2\",\n", + " linewidth=2, label=\"Surrogate\")\n", + "axes[0].plot(pulse_angles[::2], 2 * np.abs(reference[::2, 0, 1]), \"o\", color=\"#D55E00\",\n", + " markerfacecolor=\"white\", markersize=4, label=\"Exact evolution\")\n", + "axes[0].set_ylabel(r\"Final coherence $2|\\rho_{01}|$\")\n", + "axes[0].set_title(\"(a) Response to a control pulse\", loc=\"left\", fontsize=10)\n", + "axes[0].legend(fontsize=9)\n", + "axes[1].plot(pulse_angles, matrix_errors, color=\"#0072B2\", linewidth=2)\n", + "axes[1].fill_between(pulse_angles, 0, matrix_errors, color=\"#0072B2\", alpha=0.12)\n", + "axes[1].set_ylabel(r\"Matrix error $\\frac{1}{2}\\|\\rho_{\\rm pred}-\\rho_{\\rm ref}\\|_1$\")\n", + "axes[1].set_title(\"(b) Error against exact evolution\", loc=\"left\", fontsize=10)\n", + "axes[1].set_ylim(bottom=0)\n", + "for ax in axes:\n", + " ax.set(xlabel=r\"Pulse angle $\\theta$\", xlim=(0, 2 * np.pi),\n", + " xticks=[0, np.pi, 2 * np.pi], xticklabels=[\"0\", r\"$\\pi$\", r\"$2\\pi$\"])\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "An instantaneous $Z$ rotation preserves coherence magnitude when applied. The\n", + "variation here arises during subsequent interaction with the environment. The\n", + "reference and error panel show how well this small model captures that response.\n", + "This training budget keeps the example inexpensive and leaves visible prediction\n", + "errors. Results can vary with seeds and PyTorch versions.\n", + "\n", + "## Scope and other options\n", + "\n", + "The model uses a fixed Hamiltonian, environment preparation, and control\n", + "schedule. Retrain and validate when those change. The public training path does\n", + "not accept a `NoiseModel`, and this two-intervention example does not establish\n", + "accuracy for longer protocols.\n", + "\n", + "Use `predict(model, rho0, sequence, return_sequence=True)` to obtain shape\n", + "`(num_interventions, 2, 2)`, with one state after each intervention and its\n", + "following evolution interval. Sampling and training also support `\"clifford\"`\n", + "and `\"measure_prepare\"` controls; validate other control families separately.\n", + "For environmental memory diagnostics and short process-tensor references, see\n", + "{doc}`characterization`." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 120, + "number_source_lines": true + }, + "source_map": [ + 10, + 37, + 60, + 76, + 91, + 107, + 129, + 137, + 168 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/quickstart.ipynb b/docs/_outputs/examples/quickstart.ipynb new file mode 100644 index 000000000..580c98fa5 --- /dev/null +++ b/docs/_outputs/examples/quickstart.ipynb @@ -0,0 +1,12241 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Quickstart\n", + "\n", + "Explore the main YAQS workflows with executable examples and their results.\n", + "After {doc}`installing YAQS <../installation>`, run the cells in a notebook. For\n", + "a standalone script, put the execution code inside an\n", + "`if __name__ == \"__main__\":` guard; see {doc}`simulator_initialization`.\n", + "\n", + "The examples use `show_progress=False` to keep the documentation quiet. Omit\n", + "this argument to see progress. Times and rates use units consistent with each\n", + "Hamiltonian, with $\\hbar=1$. Expand the plotting cells to reuse the figures." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\",\n", + " \"font.serif\": [\"STIXGeneral\"],\n", + " \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 11,\n", + " \"axes.labelsize\": 11,\n", + " \"axes.linewidth\": 0.7,\n", + " \"xtick.labelsize\": 10,\n", + " \"ytick.labelsize\": 10,\n", + " \"xtick.direction\": \"in\",\n", + " \"ytick.direction\": \"in\",\n", + " \"xtick.top\": True,\n", + " \"ytick.right\": True,\n", + " \"legend.fontsize\": 9,\n", + " \"legend.frameon\": False,\n", + " \"lines.linewidth\": 1.6,\n", + " \"lines.markersize\": 4,\n", + " \"figure.figsize\": (6.6, 3.0),\n", + " \"figure.constrained_layout.use\": True,\n", + " \"savefig.dpi\": 180,\n", + "})" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "## Noisy analog dynamics\n", + "\n", + "Compare coherent transport and damping in a **20-site XY spin chain**. YAQS uses\n", + "an MPS and averages noisy dynamics over Monte Carlo trajectories." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseModel, Observable, Simulator, State\n", + "\n", + "length = 20\n", + "center = length // 2\n", + "basis = \"0\" * center + \"1\" + \"0\" * (length - center - 1)\n", + "state = State(length, initial=\"basis\", basis_string=basis)\n", + "hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0)\n", + "params = AnalogSimParams(\n", + " observables=[Observable(\"z\", site) for site in range(length)],\n", + " elapsed_time=3.0,\n", + " dt=0.25,\n", + " num_traj=32,\n", + " preset=\"fast\",\n", + " random_seed=7,\n", + ")\n", + "relaxation_rate = 1.0\n", + "noise = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": relaxation_rate} for site in range(length)\n", + "])\n", + "\n", + "simulator = Simulator(show_progress=False)\n", + "coherent = simulator.run(state, hamiltonian, params)\n", + "dissipative = simulator.run(state, hamiltonian, params, noise)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-4", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:38:40.731158\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib.colors import PowerNorm\n", + "\n", + "occupation = np.stack([\n", + " (1 - np.asarray(result.expectation_values).real) / 2\n", + " for result in (coherent, dissipative)\n", + "])\n", + "fig, axes = plt.subplots(1, 3, figsize=(7.2, 2.8))\n", + "for ax, values, title in zip(\n", + " axes[:2], occupation, (\"(a) Noiseless\", \"(b) Damped\"), strict=True,\n", + "):\n", + " image = ax.pcolormesh(\n", + " coherent.times, np.arange(length), values, shading=\"auto\", cmap=\"cividis\",\n", + " norm=PowerNorm(0.5, vmin=0, vmax=1), rasterized=True,\n", + " )\n", + " ax.set(xlabel=r\"Time $t$\", ylabel=r\"Site $i$\")\n", + " ax.set_title(title, loc=\"left\", fontsize=11)\n", + "fig.colorbar(image, ax=list(axes[:2]), label=r\"$\\langle n_i\\rangle$\", ticks=[0, 0.1, 0.5, 1])\n", + "axes[2].plot(coherent.times, occupation[0].sum(axis=0), color=\"#0072B2\", label=\"Noiseless\")\n", + "axes[2].plot(coherent.times, occupation[1].sum(axis=0), \"o-\", color=\"#D55E00\", label=\"Damped\")\n", + "axes[2].plot(coherent.times, np.exp(-relaxation_rate * coherent.times), \"--\", color=\"0.3\", label=\"Damping law\")\n", + "axes[2].set(xlabel=r\"Time $t$\", ylabel=r\"Total excitation $\\sum_i\\langle n_i\\rangle$\", ylim=(0, 1.08))\n", + "axes[2].set_title(\"(c) Excitation loss\", loc=\"left\", fontsize=11)\n", + "axes[2].legend()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-5", + "metadata": {}, + "source": [ + "The occupation $n_i=(1-Z_i)/2$ shows propagation and interference. Both heatmaps\n", + "use the same color scale, which emphasizes small occupations. Damping removes\n", + "excitations; the 32-trajectory estimate fluctuates around the exponential loss\n", + "law. This low-excitation state has limited entanglement. See\n", + "{doc}`analog_simulation` and {doc}`simulation_parameters` for larger budgets and\n", + "convergence checks.\n", + "\n", + "## Noisy circuit readout\n", + "\n", + "Prepare a **16-qubit graph state** and compare noiseless and damped readout." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-6", + "metadata": {}, + "outputs": [], + "source": [ + "from qiskit import QuantumCircuit\n", + "\n", + "from mqt.yaqs import DigitalSimParams, NoiseModel, Simulator, State\n", + "\n", + "num_qubits = 16\n", + "circuit = QuantumCircuit(num_qubits)\n", + "circuit.h(range(num_qubits))\n", + "for site in range(num_qubits - 1):\n", + " circuit.cz(site, site + 1)\n", + "circuit.measure_all()\n", + "\n", + "state = State(num_qubits, initial=\"zeros\")\n", + "params = DigitalSimParams(shots=256, preset=\"fast\", random_seed=7)\n", + "noise = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": 0.5} for site in range(num_qubits)\n", + "])\n", + "\n", + "simulator = Simulator(show_progress=False)\n", + "ideal = simulator.run(state, circuit, params)\n", + "damped = simulator.run(state, circuit, params, noise)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-7", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:38:51.375352\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "excitation_number = np.arange(num_qubits + 1)\n", + "readout_probabilities = []\n", + "fig, ax = plt.subplots(figsize=(5.4, 2.9))\n", + "for result, color, offset, label in (\n", + " (ideal, \"#0072B2\", -0.22, \"Noiseless\"),\n", + " (damped, \"#D55E00\", 0.22, \"Damped\"),\n", + "):\n", + " probability = np.zeros(num_qubits + 1)\n", + " for outcome, count in result.counts.items():\n", + " probability[outcome.bit_count()] += count / params.shots\n", + " readout_probabilities.append(probability)\n", + " ax.bar(excitation_number + offset, probability, width=0.42, color=color,\n", + " edgecolor=\"white\", linewidth=0.5, alpha=0.9, label=label)\n", + "ax.set(xlabel=\"Number of excited qubits\", ylabel=\"Measured probability\",\n", + " xlim=(-0.5, num_qubits + 0.5), xticks=np.arange(0, num_qubits + 1, 2))\n", + "ax.legend()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "Damping shifts the readout toward fewer excitations. Each histogram summarizes\n", + "256 shots. See {doc}`circuit_shots` for bitstring counts and\n", + "{doc}`circuit_observables` for expectation values and OpenQASM input.\n", + "\n", + "## Noisy Analog-Digital Simulation\n", + "\n", + "Can digital gates reverse the spreading in an analog spin chain? Prepare one\n", + "excitation in a **20-site XY chain**, then apply $Z$ gates on even sites halfway\n", + "through the evolution. Compare free evolution, refocusing, and refocusing with\n", + "dephasing." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "from qiskit import QuantumCircuit\n", + "\n", + "from mqt.yaqs import AnalogSimParams, DigitalSimParams, Hamiltonian, NoiseModel, Observable, SimulationProgram, Simulator, State\n", + "\n", + "length = 20\n", + "center = length // 2\n", + "state = State(length, initial=\"zeros\")\n", + "hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0)\n", + "observables = [Observable(\"z\", site) for site in range(length)]\n", + "preparation = QuantumCircuit(length)\n", + "preparation.x(center)\n", + "phase_pulse = QuantumCircuit(length)\n", + "phase_pulse.z(range(0, length, 2))\n", + "\n", + "analog_params = AnalogSimParams(elapsed_time=1.5, dt=0.25, order=2, preset=\"fast\")\n", + "digital_params = DigitalSimParams(preset=\"fast\")\n", + "free_program = SimulationProgram(\n", + " [(preparation, digital_params), (hamiltonian, analog_params), (hamiltonian, analog_params)],\n", + " observables=observables, num_traj=32, random_seed=7,\n", + ")\n", + "echo_program = SimulationProgram(\n", + " [(preparation, digital_params), (hamiltonian, analog_params),\n", + " (phase_pulse, digital_params), (hamiltonian, analog_params), (phase_pulse, digital_params)],\n", + " observables=observables, num_traj=32, random_seed=7,\n", + ")\n", + "dephasing_rate = 0.2\n", + "noise = NoiseModel([\n", + " {\"name\": \"pauli_z\", \"sites\": [site], \"strength\": dephasing_rate} for site in range(length)\n", + "])\n", + "\n", + "simulator = Simulator(show_progress=False)\n", + "free_result = simulator.run(state, free_program)\n", + "echo_result = simulator.run(state, echo_program)\n", + "noisy_echo_result = simulator.run(state, echo_program, noise_model=noise)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-10", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:38:59.436639\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib.colors import PowerNorm\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(7.2, 2.8), sharex=True, sharey=True)\n", + "for index, (ax, result, title) in enumerate(zip(\n", + " axes, (free_result, echo_result, noisy_echo_result),\n", + " (\"(a) Free evolution\", \"(b) Refocusing\", \"(c) Noisy refocusing\"), strict=True,\n", + ")):\n", + " segments = [segment for segment in result.segment_results if segment.segment_type == \"analog\"]\n", + " times = np.concatenate([segment.times + segment.time_offset for segment in segments])\n", + " values = np.concatenate([np.asarray(segment.expectation_values) for segment in segments], axis=1)\n", + " keep = np.r_[True, np.diff(times) > 0]\n", + " occupation = (1 - values[:, keep]) / 2\n", + " image = ax.pcolormesh(\n", + " times[keep], np.arange(length), occupation, shading=\"auto\", cmap=\"cividis\",\n", + " norm=PowerNorm(0.5, vmin=0, vmax=1), rasterized=True,\n", + " )\n", + " if index > 0:\n", + " ax.axvline(1.5, color=\"white\", linestyle=\"--\", linewidth=1)\n", + " ax.set(xlabel=r\"Time $t$\", xlim=(0, 3), yticks=[0, 5, 10, 15, 19])\n", + " ax.set_title(title, loc=\"left\", fontsize=10)\n", + "axes[0].set_ylabel(r\"Site $i$\")\n", + "fig.colorbar(image, ax=list(axes), label=r\"$\\langle n_i\\rangle$\", ticks=[0, 0.1, 0.5, 1])\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-11", + "metadata": {}, + "source": [ + "The phase pulse reverses the XY exchange, bringing the excitation back at $t=3$.\n", + "A final pulse restores the phase frame. Dephasing during the analog intervals\n", + "preserves excitation but disrupts refocusing; the noisy panel averages 32\n", + "trajectories at Lindblad rate $\\gamma_z=0.2$. The gates are ideal and\n", + "instantaneous. All panels share one color scale. See\n", + "{doc}`digital_analog_simulation` for the pulse mechanism, program outputs, and\n", + "noise comparisons.\n", + "\n", + "## Circuit equivalence\n", + "\n", + "Verify a transpiled circuit, then compare the effects of an added rotation and\n", + "increasing Pauli noise." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cell-12", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Equivalent: True\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "from qiskit import QuantumCircuit, transpile\n", + "\n", + "from mqt.yaqs import EquivalenceChecker, NoiseModel\n", + "\n", + "circuit = QuantumCircuit(4)\n", + "circuit.h(0)\n", + "for site in range(3):\n", + " circuit.cx(site, site + 1)\n", + "decomposed = transpile(circuit, basis_gates=[\"rz\", \"sx\", \"x\", \"cx\"])\n", + "\n", + "checker = EquivalenceChecker()\n", + "print(\"Equivalent:\", checker.check(circuit, decomposed)[\"equivalent\"])\n", + "angles = np.linspace(0, np.pi, 17)\n", + "rotation_overlaps = []\n", + "for angle in angles:\n", + " perturbed = decomposed.copy()\n", + " perturbed.rz(float(angle), 0)\n", + " rotation_overlaps.append(checker.check(circuit, perturbed)[\"fidelity\"])\n", + "\n", + "error_probabilities = np.linspace(0, 0.9, 10)\n", + "noisy_checks = []\n", + "for probability in error_probabilities:\n", + " noise = NoiseModel([{\"name\": \"pauli_z\", \"sites\": [0], \"strength\": float(probability)}])\n", + " noisy_checks.append(checker.check(\n", + " circuit, decomposed, noise_model=noise, num_traj=128, random_seed=7,\n", + " ))" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cell-13", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:39:05.252290\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(6.8, 2.9), sharey=True)\n", + "axes[0].plot(angles, rotation_overlaps, \"o\", color=\"#0072B2\", label=\"YAQS\")\n", + "axes[0].plot(angles, np.abs(np.cos(angles / 2)), \"--\", color=\"0.3\", label=r\"$|\\cos(\\theta/2)|$\")\n", + "axes[0].set(xlabel=r\"Added rotation $\\theta$ (rad)\", ylabel=\"Normalized overlap\",\n", + " xlim=(-0.03, np.pi + 0.03), ylim=(0, 1.05), xticks=[0, np.pi / 2, np.pi],\n", + " xticklabels=[\"0\", r\"$\\pi/2$\", r\"$\\pi$\"])\n", + "axes[0].set_title(\"(a) Coherent error\", loc=\"left\", fontsize=11)\n", + "axes[0].legend()\n", + "axes[1].errorbar(error_probabilities, [check[\"fidelity\"] for check in noisy_checks],\n", + " yerr=[check[\"fidelity_error\"] for check in noisy_checks],\n", + " fmt=\"o\", color=\"#D55E00\", capsize=2, label=\"YAQS\")\n", + "axes[1].plot(error_probabilities, np.sqrt(1 - error_probabilities), \"--\", color=\"0.3\", label=r\"$\\sqrt{1-p}$\")\n", + "axes[1].set(xlabel=r\"Pauli error probability $p$\", xlim=(-0.03, 0.93))\n", + "axes[1].set_title(\"(b) Stochastic error\", loc=\"left\", fontsize=11)\n", + "axes[1].legend()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "An overlap of one indicates agreement up to a global phase. Here, site 0 has one\n", + "noise opportunity, after the first CX gate. A $Z$ error has zero overlap, so the\n", + "ensemble's root-mean-square overlap is $\\sqrt{1-p}$. Error bars show Monte Carlo\n", + "standard errors. Noise is applied to the second circuit. See\n", + "{doc}`equivalence_checking` for supported noise and accuracy controls.\n", + "\n", + "## Environmental memory\n", + "\n", + "Sweep the Ising coupling in a three-spin chain and compare the probe qubit's\n", + "memory spectra using the same probe grid." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "cell-15", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from mqt.yaqs import AnalogSimParams, Hamiltonian, MemoryCharacterizer\n", + "\n", + "couplings = np.linspace(0, 1.5, 13)\n", + "params = AnalogSimParams(elapsed_time=0.5, dt=0.5, preset=\"fast\")\n", + "characterizer = MemoryCharacterizer(show_progress=False)\n", + "memories = []\n", + "for coupling in couplings:\n", + " hamiltonian = Hamiltonian.ising(3, J=coupling, g=1.0)\n", + " memories.append(characterizer.characterize(\n", + " hamiltonian, params, num_interventions=4, cut=2, preset=\"quick\",\n", + " rng=np.random.default_rng(7),\n", + " ))" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "cell-16", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:39:12.268844\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(6.8, 3.0), width_ratios=[1.4, 1])\n", + "colors = plt.colormaps[\"Reds\"](np.linspace(0.35, 0.95, len(couplings)))\n", + "for memory, color in zip(memories, colors, strict=True):\n", + " spectrum = memory.singular_values(2)\n", + " weights = spectrum**2 / np.sum(spectrum**2)\n", + " axes[0].semilogy(np.arange(1, len(weights) + 1), weights, \"o-\",\n", + " color=color, linewidth=1.2, markersize=3)\n", + "axes[0].text(0.28, 0.14, \"Coupling increases\", transform=axes[0].transAxes,\n", + " ha=\"center\", va=\"center\", fontsize=10, color=\"black\")\n", + "axes[0].annotate(\"\", xy=(0.82, 0.76), xytext=(0.50, 0.20),\n", + " xycoords=\"axes fraction\",\n", + " arrowprops={\"arrowstyle\": \"->\", \"color\": \"black\",\n", + " \"lw\": 1.5, \"connectionstyle\": \"arc3,rad=0.25\"})\n", + "axes[0].set(xlabel=\"Mode index\", ylabel=r\"Resolved mode weight $p_k$\", ylim=(1e-13, 2))\n", + "axes[0].set_title(\"(a) Memory spectra\", loc=\"left\", fontsize=11)\n", + "entropies = np.array([memory.entropy(2) for memory in memories])\n", + "axes[1].fill_between(couplings, entropies, color=colors[3], alpha=0.3)\n", + "axes[1].plot(couplings, entropies, \"o-\", color=colors[-1], linewidth=2.6,\n", + " markerfacecolor=\"white\", markeredgewidth=1.2)\n", + "axes[1].set(xlabel=r\"Coupling $J$\", ylabel=r\"Memory entropy $S_V$\",\n", + " xlim=(-0.03, 1.53), ylim=(-0.008, 0.34), xticks=[0, 0.5, 1, 1.5])\n", + "axes[1].grid(axis=\"y\", color=\"0.9\", linewidth=0.5)\n", + "axes[1].set_axisbelow(True)\n", + "axes[1].set_title(\"(b) Resolved memory\", loc=\"left\", fontsize=11)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-17", + "metadata": {}, + "source": [ + "The weights $p_k=s_k^2/\\sum_j s_j^2$ describe memory resolved by the sampled\n", + "probes. Darker curves show larger $J$. Coupling redistributes weight among the\n", + "resolved modes. The entropy measures this spread and peaks within this sweep.\n", + "These weights are not environment-state populations. See {doc}`characterization`\n", + "for probe choices and interpretation.\n", + "\n", + "## Create a digital twin\n", + "\n", + "Learn\n", + "**relaxation and dephasing from measurements at the ends of a spin chain**. Then\n", + "rerun the fitted model to predict transport through the unmeasured interior." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "cell-18", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fitted rates: [0.35 0.12]\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "\n", + "from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseCharacterizer, NoiseModel, Observable, Simulator, State\n", + "\n", + "length = 4\n", + "state = State(length, initial=\"basis\", basis_string=\"1000\", representation=\"density_matrix\")\n", + "hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0)\n", + "observables = [Observable(\"z\", site) for site in range(length)]\n", + "params = AnalogSimParams(observables=observables, elapsed_time=8.0, dt=0.1, preset=\"fast\")\n", + "reference = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [3], \"strength\": 0.35},\n", + " {\"name\": \"pauli_z\", \"sites\": [2], \"strength\": 0.12},\n", + "])\n", + "guess = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [3], \"strength\": 0.2},\n", + " {\"name\": \"pauli_z\", \"sites\": [2], \"strength\": 0.2},\n", + "])\n", + "\n", + "simulator = Simulator(show_progress=False)\n", + "measured = simulator.run(state, hamiltonian, params, reference)\n", + "characterizer = NoiseCharacterizer(show_progress=False)\n", + "fit = characterizer.characterize(\n", + " hamiltonian,\n", + " params,\n", + " init_state=state,\n", + " init_guess=guess,\n", + " observables=[observables[0], observables[-1]],\n", + " ref_expectations=np.asarray(measured.expectation_values)[[0, -1]],\n", + " x_low=np.zeros(2),\n", + " x_up=np.ones(2),\n", + " max_iter=40,\n", + " seed=7,\n", + ")\n", + "reconstructed = simulator.run(state, hamiltonian, params, fit.optimal_model)\n", + "print(\"Fitted rates:\", fit.best_parameters.round(3))" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "cell-19", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:39:20.391718\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "reference_dynamics = (1 - np.asarray(measured.expectation_values).real) / 2\n", + "fitted_dynamics = (1 - np.asarray(reconstructed.expectation_values).real) / 2\n", + "fig, axes = plt.subplots(1, 2, figsize=(6.8, 2.8), sharey=True)\n", + "for ax, dynamics, title in zip(\n", + " axes, (reference_dynamics, fitted_dynamics),\n", + " (\"(a) Reference transport\", \"(b) Fitted model\"), strict=True,\n", + "):\n", + " image = ax.pcolormesh(measured.times, np.arange(length), dynamics,\n", + " shading=\"auto\", cmap=\"cividis\", vmin=0, vmax=1, rasterized=True)\n", + " ax.set(xlabel=r\"Time $t$\", yticks=np.arange(length))\n", + " ax.set_title(title, loc=\"left\", fontsize=11)\n", + "axes[0].set_ylabel(r\"Site $i$\")\n", + "fig.colorbar(image, ax=list(axes), label=r\"$\\langle n_i\\rangle$\", ticks=[0, 0.5, 1])\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-20", + "metadata": {}, + "source": [ + "The excitation propagates and reflects while a local sink removes population and\n", + "dephasing changes transport. Only sites 0 and 3 enter the fit; sites 1 and 2\n", + "check its predictions. Both heatmaps share a scale. This example uses synthetic\n", + "observations without measurement noise from density-matrix simulation; replace\n", + "the reference array with your measured traces. The fit assumes the two channel\n", + "types and their sites are known. See {doc}`digital_twin` for data preparation\n", + "and validation.\n", + "\n", + "## Predict non-Markovian dynamics\n", + "\n", + "YAQS also provides experimental surrogate models that learn a probe's response\n", + "to controls from simulated training sequences. See {doc}`memory_surrogate` for a\n", + "small example with an independent reference. This feature is not yet supported\n", + "by a published YAQS paper; validate predictions for your controls and time\n", + "horizon.\n", + "\n", + "## Next steps\n", + "\n", + "| Task | Guide |\n", + "| -------------------------------------- | -------------------------------- |\n", + "| Choose accuracy settings | {doc}`simulation_parameters` |\n", + "| Choose states and simulation backends | {doc}`state_initialization` |\n", + "| Combine analog evolution and circuits | {doc}`digital_analog_simulation` |\n", + "| Check whether two circuits agree | {doc}`equivalence_checking` |\n", + "| Create a digital twin | {doc}`digital_twin` |\n", + "| Study memory in a system's environment | {doc}`characterization` |\n", + "| Predict non-Markovian dynamics | {doc}`memory_surrogate` |" + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 180, + "number_source_lines": true + }, + "source_map": [ + 10, + 23, + 51, + 58, + 84, + 110, + 123, + 146, + 165, + 178, + 215, + 240, + 255, + 285, + 303, + 316, + 333, + 360, + 374, + 412, + 428 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/realistic_noise_models.ipynb b/docs/_outputs/examples/realistic_noise_models.ipynb new file mode 100644 index 000000000..bb72c4fff --- /dev/null +++ b/docs/_outputs/examples/realistic_noise_models.ipynb @@ -0,0 +1,525 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Noise Models\n", + "\n", + "A {class}`~mqt.yaqs.NoiseModel` describes how a system loses energy, gains\n", + "excitations, or suffers other disturbances during a simulation. Assemble named\n", + "jump operators or supply your own matrices, then pass the model to\n", + "`Simulator.run`. Use fixed strengths for a calibrated model, or distributions to\n", + "represent static variation between runs.\n", + "\n", + "## Define the noise processes\n", + "\n", + "Each process has a `name`, a list of `sites`, and a `strength`. Site indices\n", + "start at zero. This four-qubit model combines local relaxation and dephasing:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import NoiseModel\n", + "\n", + "length = 4\n", + "noise = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": 0.1}\n", + " for site in range(length)\n", + "] + [\n", + " {\"name\": \"pauli_z\", \"sites\": [site], \"strength\": 0.02}\n", + " for site in range(length)\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "The named operators act on qubits. Names are case-sensitive:\n", + "\n", + "| Process name | Effect | Sites |\n", + "| ------------------------------------- | --------------------------------------------------------------------- | --------------------------------- |\n", + "| `\"lowering\"` | Relaxation from $\\lvert1\\rangle$ to $\\lvert0\\rangle$. | One. |\n", + "| `\"raising\"` | Excitation from $\\lvert0\\rangle$ to $\\lvert1\\rangle$. | One. |\n", + "| `\"pauli_x\"`, `\"pauli_y\"`, `\"pauli_z\"` | Pauli errors; $Z$ causes dephasing. Aliases: `\"x\"`, `\"y\"`, `\"z\"`. | One. |\n", + "| `\"lowering_two\"`, `\"raising_two\"` | Joint relaxation $\\lvert11\\rangle\\to\\lvert00\\rangle$, or the reverse. | Two adjacent sites. |\n", + "| `\"crosstalk_xx\"`, `\"crosstalk_xy\"`, … | Correlated Pauli errors; any pair of `x`, `y`, and `z`. | Two; see the support table below. |\n", + "\n", + "Joint relaxation is one two-site jump. To model independent relaxation on two\n", + "sites, supply two `\"lowering\"` processes instead.\n", + "\n", + "## Interpret the strengths\n", + "\n", + "For `Simulator`, `strength` is a finite, nonnegative Lindblad rate $\\gamma$. Use\n", + "inverse units of the simulation time. YAQS supplies the factor $\\sqrt{\\gamma}$;\n", + "pass the unscaled operator as `matrix`. Operators need not be Hermitian or\n", + "unitary, and YAQS does not normalize them.\n", + "\n", + "Analog noise acts over the physical time steps set by `AnalogSimParams.dt`.\n", + "Circuit noise uses a unit noise step after each multi-qubit gate, with only the\n", + "processes whose sites lie within that gate's qubits. Single-qubit gates,\n", + "barriers, and idle qubits do not create noise opportunities. Circuit strengths\n", + "therefore do not represent hardware gate durations or direct error\n", + "probabilities. See {doc}`circuit_observables` for a worked example.\n", + "\n", + ":::{important}\n", + "`EquivalenceChecker` interprets resolved strengths as direct per-opportunity\n", + "branch probabilities and imposes its own probability constraints. A simulator\n", + "rate cannot be reused as a checker probability without choosing a conversion.\n", + "See {ref}`equivalence-noise-model`.\n", + ":::\n", + "\n", + ":::{dropdown} Relate rates to relaxation and dephasing times\n", + "For a jump operator $L$, the rate multiplies the dissipator\n", + "\n", + "$$\n", + "\\gamma\\mathcal{D}[L](\\rho)=\\gamma\\left(\n", + "L\\rho L^\\dagger-\\tfrac12\\{L^\\dagger L,\\rho\\}\\right).\n", + "$$\n", + "\n", + "With only `\"lowering\"` and no Hamiltonian, the excited-state population decays\n", + "as $e^{-\\gamma t}$, so $\\gamma=1/T_1$. With only `\"pauli_z\"`, the off-diagonal\n", + "density-matrix entries decay as $e^{-2\\gamma t}$, so $\\gamma=1/(2T_\\phi)$. These\n", + "conventions matter when converting measured lifetimes to strengths. Scaling $L$\n", + "by a factor $c$ scales its dissipator by $|c|^2$.\n", + "\n", + "Negative rates, including time-local descriptions with temporarily negative\n", + "coefficients, are not supported. Negative or complex matrix entries remain valid\n", + "parts of a jump operator.\n", + ":::\n", + "\n", + "## Run and inspect the model\n", + "\n", + "Pass the model with the initial state, Hamiltonian, and simulation parameters.\n", + "This short Ising evolution uses the default MPS representation and averages\n", + "eight trajectories:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(5,)\n" + ] + } + ], + "source": [ + "from mqt.yaqs import AnalogSimParams, Hamiltonian, Observable, Simulator, State\n", + "\n", + "state = State(length, initial=\"ones\")\n", + "hamiltonian = Hamiltonian.ising(length, J=1.0, g=0.5)\n", + "params = AnalogSimParams(\n", + " observables=[Observable(\"z\", site) for site in range(length)],\n", + " elapsed_time=0.2,\n", + " dt=0.05,\n", + " num_traj=8,\n", + " random_seed=7,\n", + ")\n", + "\n", + "sim = Simulator(show_progress=False)\n", + "result = sim.run(state, hamiltonian, params, noise_model=noise)\n", + "print(result.expectation_values[0].shape)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "The shape is `(5,)`: five sampled times, including the initial time.\n", + "`result.expectation_values` contains one such array per observable, in the\n", + "supplied order. Parallel execution remains enabled; the documentation hides\n", + "progress bars. Eight trajectories suffice to demonstrate the call, but\n", + "scientific results need a convergence check. The model used in the run is\n", + "available as `result.noise_model`.\n", + "\n", + "## Represent static variation\n", + "\n", + "Replace a scalar strength with a distribution dictionary. This example gives\n", + "each qubit an independent log-normal relaxation rate:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0.10004921824250348, 0.1126931232496979, 0.08961431240104942, 0.0700306813124216]\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "\n", + "variable_noise = NoiseModel([\n", + " {\n", + " \"name\": \"lowering\",\n", + " \"sites\": [site],\n", + " \"strength\": {\"distribution\": \"lognormal\", \"mean\": np.log(0.1), \"std\": 0.4},\n", + " }\n", + " for site in range(length)\n", + "])\n", + "\n", + "resolved_noise = variable_noise.sample(rng=7)\n", + "print([process[\"strength\"] for process in resolved_noise.processes])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "{meth}`~mqt.yaqs.NoiseModel.sample` returns a new model with concrete rates; it\n", + "leaves the original model unchanged. For `\"lognormal\"`, `mean` and `std`\n", + "describe the normal distribution of $\\log\\gamma$, so the median rate above is\n", + "`0.1`.\n", + "\n", + "| `distribution` | Meaning of `mean` and `std` | Treatment of negative draws |\n", + "| -------------------- | ---------------------------------------------------------- | --------------------------------------------------------------- |\n", + "| `\"lognormal\"` | Mean and standard deviation of $\\log\\gamma$. | All draws are positive. |\n", + "| `\"normal\"` | Mean and standard deviation of a normal rate distribution. | Clamped to zero with a warning. |\n", + "| `\"truncated_normal\"` | Parameters of the underlying normal distribution. | Sampled from that distribution restricted to nonnegative rates. |\n", + "\n", + "Passing `variable_noise` directly to `Simulator.run` draws one rate per process\n", + "at the start of the run. All trajectories share those rates. This represents\n", + "static disorder: rates do not change during the evolution or between\n", + "trajectories. To average over disorder, perform separate runs with separate\n", + "draws; increasing `num_traj` only improves the trajectory average for one draw.\n", + "\n", + "To compare setups using the same rates, pass a resolved model:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0.10004921824250348, 0.1126931232496979, 0.08961431240104942, 0.0700306813124216]\n" + ] + } + ], + "source": [ + "resolved_result = sim.run(state, hamiltonian, params, noise_model=resolved_noise)\n", + "print([process[\"strength\"] for process in resolved_result.noise_model.processes])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "The printed rates match the earlier sample. You can also reuse a previous run's\n", + "`result.noise_model`. Setting `params.random_seed` fixes automatic disorder\n", + "draws and trajectory random streams for a fixed setup. It does not seed\n", + "independently prepared random states or final shot sampling, or guarantee\n", + "identical numbers across software versions and platforms. For successive manual\n", + "disorder draws, reuse a NumPy `Generator`; repeating `.sample(rng=7)` repeats\n", + "the same draw.\n", + "\n", + ":::{dropdown} Distribution parameters and limits\n", + "`mean` and `std` default to zero when omitted; specify both to make the intended\n", + "distribution clear. `mean` must be finite, and `std` must be finite and\n", + "nonnegative. A zero-width normal or truncated normal resolves to `max(0, mean)`;\n", + "a zero-width log-normal resolves to `exp(mean)`.\n", + "\n", + "There is no default distribution or upper rate limit. Choose a distribution from\n", + "the variation you intend to model, and choose the time step to resolve the\n", + "resulting dynamics. Clamping a normal distribution creates a point mass at zero;\n", + "truncating it renormalizes the positive part instead. Neither choice creates a\n", + "time-dependent noise model.\n", + ":::\n", + "\n", + "(noise-custom-operators)=\n", + "\n", + "## Supply a custom operator\n", + "\n", + "Add `matrix` to override the library lookup; `name` then serves as an\n", + "identifier. A one-site operator must be a finite, square matrix matching that\n", + "site's local dimension. This explicit matrix gives the same jump as\n", + "`\"lowering\"`:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "sigma_minus = np.array([[0, 1], [0, 0]], dtype=complex)\n", + "custom_noise = NoiseModel([\n", + " {\"name\": \"relaxation\", \"sites\": [0], \"strength\": 0.1, \"matrix\": sigma_minus},\n", + " {\"name\": \"pauli_z\", \"sites\": [1], \"strength\": 0.02},\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "You can mix custom and named processes. For higher local dimensions, supply an\n", + "operator in that local basis. A truncated oscillator with levels $0,1,2$ uses\n", + "the annihilation operator" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "source": [ + "annihilation = np.diag(np.sqrt(np.arange(1, 3)), k=1)\n", + "qutrit_noise = NoiseModel([\n", + " {\"name\": \"loss\", \"sites\": [0], \"strength\": 0.1, \"matrix\": annihilation},\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "This matrix is $3\\times3$ and requires a dimension-three site. See\n", + "{doc}`transmon_emulation` for a device example with such operators.\n", + "\n", + "## Choose a supported combination\n", + "\n", + "The state representation selects the analog backend. One-site and adjacent\n", + "two-site process matrices work with all three analog representations. Long-range\n", + "processes need the choices below:\n", + "\n", + "| Workflow | One-site and adjacent two-site noise | Non-adjacent two-site noise |\n", + "| -------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------- |\n", + "| Analog MPS (TJM) | Supported. | Products of Pauli operators, up to a unit-modulus phase per factor. |\n", + "| Analog vector (MCWF) | Supported. | Custom factor pairs supported. |\n", + "| Analog density matrix (Lindblad) | Supported. | Custom factor pairs supported. |\n", + "| Circuit simulation (MPS) | Supported on the gate qubits at each noise opportunity. | Not supported. |\n", + "\n", + "All operators must match the site's local dimension. Named Pauli operators are\n", + "$2\\times2$; supply custom matrices for other dimensions. YAQS checks dimensions\n", + "and site bounds against the state when the simulation starts. See\n", + "{doc}`representation_comparison` to choose a representation.\n", + "\n", + "Noisy MPS, vector, and circuit runs cannot retain a final pure state with\n", + "`get_state=True`; noisy density-matrix runs can retain their final mixed state.\n", + "The analog `list[State]` ensemble workflow does not support process noise. See\n", + "{doc}`simulation_parameters` for output choices.\n", + "\n", + ":::{dropdown} Construct adjacent and long-range two-site processes\n", + "For adjacent sites, supply a `matrix` acting on their joint basis. Use ascending\n", + "site order: the first tensor factor acts on the first listed site. If a matrix\n", + "was written for descending sites, swap both its input and output tensor-factor\n", + "axes as well as reversing the site list.\n", + "\n", + "```python\n", + "adjacent_noise = NoiseModel([\n", + " {\n", + " \"name\": \"joint_relaxation\",\n", + " \"sites\": [0, 1],\n", + " \"strength\": 0.05,\n", + " \"matrix\": np.kron(sigma_minus, sigma_minus),\n", + " },\n", + "])\n", + "```\n", + "\n", + "For non-adjacent sites, the names `\"crosstalk_xy\"` and\n", + "`\"longrange_crosstalk_xy\"` both construct the factors $X$ and $Y$. Any pair of\n", + "`x`, `y`, and `z` is accepted:\n", + "\n", + "```python\n", + "long_range_pauli = NoiseModel([\n", + " {\"name\": \"longrange_crosstalk_xy\", \"sites\": [0, 3], \"strength\": 0.05},\n", + "])\n", + "```\n", + "\n", + "For a custom non-adjacent process, supply two local `factors` in the order of\n", + "the listed sites. This lowering-and-dephasing product requires an analog vector\n", + "or density-matrix simulation:\n", + "\n", + "```python\n", + "long_range_custom = NoiseModel([\n", + " {\n", + " \"name\": \"correlated_loss\",\n", + " \"sites\": [0, 3],\n", + " \"strength\": 0.05,\n", + " \"factors\": (sigma_minus, np.diag([1, -1])),\n", + " },\n", + "])\n", + "```\n", + "\n", + "YAQS sorts the sites and reorders the factors together. Use `matrix` for\n", + "adjacent sites and `factors` for non-adjacent sites; do not provide both.\n", + ":::\n", + "\n", + "(noise-scheduled-jumps)=\n", + "\n", + "## Apply a scheduled jump\n", + "\n", + "Use `scheduled_jumps` for an operator applied at a specified analog time. Each\n", + "entry gives a `time`, `sites`, and a library `name`, or a custom `matrix` with\n", + "an identifying name. Scheduled events have no `strength`: the operator is\n", + "applied when the simulation reaches its time.\n", + "\n", + "This four-site example isolates the event by setting the Hamiltonian to zero. An\n", + "$X$ flip at $t=0.1$ changes $\\langle Z_0\\rangle$ from $+1$ to $-1$:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-13", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[ 1. 1. -1. -1. -1.]\n" + ] + } + ], + "source": [ + "scheduled_noise = NoiseModel(scheduled_jumps=[\n", + " {\"time\": 0.1, \"sites\": [0], \"name\": \"x\"},\n", + "])\n", + "jump_state = State(length, initial=\"zeros\")\n", + "zero_hamiltonian = Hamiltonian.ising(length, J=0.0, g=0.0)\n", + "jump_params = AnalogSimParams(\n", + " observables=[Observable(\"z\", 0)],\n", + " elapsed_time=0.2,\n", + " dt=0.05,\n", + " num_traj=1,\n", + " order=1,\n", + ")\n", + "jump_result = sim.run(jump_state, zero_hamiltonian, jump_params, noise_model=scheduled_noise)\n", + "print(jump_result.expectation_values[0])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "The five values are `[1, 1, -1, -1, -1]`, sampled at times\n", + "`[0, 0.05, 0.1, 0.15, 0.2]`. Event-only runs are deterministic, so one\n", + "trajectory suffices; they can also retain the final MPS with `get_state=True`.\n", + "\n", + "Scheduled jumps require a single MPS `State` and `AnalogSimParams(order=1)`.\n", + "MCWF, Lindblad, order-2 TJM, circuit runs, and `list[State]` ensembles do not\n", + "support them. Event times must lie on the simulation time grid, including its\n", + "endpoints. Two-site events must act on adjacent sites; custom matrices require\n", + "ascending site order.\n", + "\n", + ":::{important}\n", + "At a matching time after zero, scheduled operators replace the ordinary\n", + "stochastic-jump draw for that step; process dissipation still runs. For control\n", + "pulses that also sample stochastic noise at pulse times, use an\n", + "{doc}`analog-digital program `.\n", + ":::\n", + "\n", + ":::{dropdown} Custom scheduled operators and event order\n", + "Add `matrix` to supply a custom operator. For example, this schedules a $\\pi/2$\n", + "rotation about $Y$:\n", + "\n", + "```python\n", + "ry_pi2 = np.array([[1, -1], [1, 1]], dtype=complex) / np.sqrt(2)\n", + "custom_event = NoiseModel(\n", + " scheduled_jumps=[\n", + " {\"time\": 0.1, \"sites\": [0], \"name\": \"ry_pi2\", \"matrix\": ry_pi2},\n", + " ]\n", + ")\n", + "```\n", + "\n", + "Matrices must be finite, square, and match the selected sites. Scheduled events\n", + "accept `matrix`, not long-range `factors`. Operators need not be unitary; YAQS\n", + "normalizes the state after the events and rejects a zero or nonfinite norm. A\n", + "non-unitary scheduled operator is a prescribed normalized state update, not a\n", + "randomly sampled Lindblad channel.\n", + "\n", + "At time zero, events act before dissipation and the initial measurement. At\n", + "later grid points, including the final time, the order is Hamiltonian evolution,\n", + "process dissipation, all matching scheduled operators, normalization, then\n", + "measurement. Events at the same time follow their order in `scheduled_jumps`.\n", + "Inside a `SimulationProgram`, event times use the local clock of an analog run;\n", + "see {doc}`digital_analog_simulation` for segment boundaries and noise overrides.\n", + ":::\n", + "\n", + "## Next steps\n", + "\n", + "Use {doc}`analog_simulation` or {doc}`circuit_observables` to see how noise\n", + "changes measured dynamics. The device guides show how to combine noise with\n", + "{doc}`transmon_emulation` and {doc}`trapped_ion`. For deterministic control\n", + "sequences that combine gates with analog evolution, see\n", + "{doc}`digital_analog_simulation`." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 120, + "number_source_lines": true + }, + "source_map": [ + 10, + 25, + 36, + 97, + 113, + 127, + 141, + 162, + 165, + 197, + 203, + 209, + 214, + 300, + 315 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/representation_comparison.ipynb b/docs/_outputs/examples/representation_comparison.ipynb new file mode 100644 index 000000000..44dd98289 --- /dev/null +++ b/docs/_outputs/examples/representation_comparison.ipynb @@ -0,0 +1,3992 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# State Representations\n", + "\n", + "The choice of state representation determines which solver YAQS uses and how\n", + "large a system you can study. Matrix product states keep large simulations\n", + "manageable when entanglement remains limited. Dense vectors and density matrices\n", + "provide useful small-system references, with different costs for noisy dynamics.\n", + "\n", + "Choose the representation when constructing a {class}`~mqt.yaqs.State`. YAQS\n", + "selects the solver from that choice; it does not switch representations when the\n", + "calculation becomes expensive.\n", + "\n", + "## Choose a representation and solver\n", + "\n", + "| `State` representation | Analog solver | When to use it |\n", + "| ---------------------- | ---------------------------------------- | ------------------------------------------------------------------------------ |\n", + "| `\"mps\"` (default) | Tensor jump method (TJM). | Large chains with manageable entanglement, and circuit simulation. |\n", + "| `\"vector\"` | Monte Carlo wave-function method (MCWF). | Small systems where you want pure-state trajectories without MPS compression. |\n", + "| `\"density_matrix\"` | Lindblad master equation. | Small open systems, mixed initial states, and deterministic ensemble averages. |\n", + "\n", + "For presets, use `State(length, initial=\"zeros\", representation=\"vector\")`, for\n", + "example. Supplying `vector=`, `density_matrix=`, or `tensors=` selects the\n", + "matching representation automatically. See {doc}`state_initialization` for state\n", + "preparation and local dimensions.\n", + "\n", + "With noise, TJM and MCWF evolve independent pure-state trajectories and average\n", + "their observables. Lindblad evolution propagates the ensemble density matrix\n", + "directly. Without noise, the trajectory solvers evolve a single pure state;\n", + "Lindblad evolution still carries a density matrix.\n", + "\n", + "## How state storage scales\n", + "\n", + "Let $L$ be the number of sites, $d$ their common local dimension, and $D=d^L$\n", + "the full Hilbert-space dimension. For qubits, $d=2$. An MPS stores local tensors\n", + "joined by bonds; the maximum bond dimension $\\chi$ controls how much\n", + "entanglement it can represent.\n", + "\n", + "| Representation | Complex numbers in one state | For qubits |\n", + "| -------------- | ---------------------------- | ------------------------------------ |\n", + "| MPS | $O(Ld\\chi^2)$. | Linear in $L$ if $\\chi$ stays fixed. |\n", + "| Vector | $D$. | $2^L$. |\n", + "| Density matrix | $D^2$. | $4^L$. |\n", + "\n", + "Each additional qubit doubles vector storage and quadruples density-matrix\n", + "storage. Increasing an MPS bond dimension by a factor of two can increase its\n", + "state storage by about four. For strongly entangled states, the bond dimension\n", + "needed for an accurate MPS can itself grow exponentially with system size;\n", + "linear scaling at fixed bond dimension is not a guarantee for every physical\n", + "problem. See the\n", + "[TJM publication](https://www.nature.com/articles/s41467-025-66846-x) for the\n", + "trajectory formulation.\n", + "\n", + "The figure counts `complex128` state arrays, at 16 bytes per entry. The MPS\n", + "curves use bond caps of 16 and 64, with each bond also limited by the dimensions\n", + "of the two subsystems it separates. These are calculated storage estimates; no\n", + "large states are allocated." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:39:28.850630\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\",\n", + " \"font.serif\": [\"STIXGeneral\"],\n", + " \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 11,\n", + " \"axes.labelsize\": 11,\n", + " \"axes.linewidth\": 0.7,\n", + " \"xtick.labelsize\": 10,\n", + " \"ytick.labelsize\": 10,\n", + " \"xtick.direction\": \"in\",\n", + " \"ytick.direction\": \"in\",\n", + " \"xtick.top\": True,\n", + " \"ytick.right\": True,\n", + " \"legend.fontsize\": 9,\n", + " \"legend.frameon\": False,\n", + " \"lines.linewidth\": 1.8,\n", + " \"figure.constrained_layout.use\": True,\n", + " \"savefig.dpi\": 180,\n", + "})\n", + "\n", + "qubits = np.arange(4, 41)\n", + "vector_bytes = 16 * 2.0**qubits\n", + "density_bytes = 16 * 4.0**qubits\n", + "mps_bytes = {}\n", + "for cap in (16, 64):\n", + " sizes = []\n", + " for sites in qubits:\n", + " bonds = [min(cap, 2**min(cut, sites - cut)) for cut in range(sites + 1)]\n", + " sizes.append(16 * sum(2 * left * right for left, right in zip(bonds[:-1], bonds[1:], strict=True)))\n", + " mps_bytes[cap] = np.array(sizes)\n", + "\n", + "fig, ax = plt.subplots(figsize=(6.6, 3.6))\n", + "ax.semilogy(qubits, density_bytes, color=\"0.2\", label=r\"Density matrix: $4^L$\")\n", + "ax.semilogy(qubits, vector_bytes, color=\"#D55E00\", label=r\"Vector: $2^L$\")\n", + "ax.semilogy(qubits, mps_bytes[64], color=\"#0072B2\", label=r\"MPS: $\\chi\\leq64$\")\n", + "ax.semilogy(qubits, mps_bytes[16], \"--\", color=\"#56B4E9\", label=r\"MPS: $\\chi\\leq16$\")\n", + "ax.axhline(2.0**30, color=\"0.6\", linewidth=0.8, linestyle=\":\")\n", + "ax.text(39, 2.0**30 * 1.6, \"1 GiB reference\", ha=\"right\", fontsize=9, color=\"0.35\")\n", + "ax.set(xlabel=r\"Number of qubits $L$\", ylabel=\"State storage\", xlim=(4, 40), ylim=(2.0**8, 2.0**64))\n", + "ax.set_xticks([4, 10, 20, 30, 40])\n", + "ax.set_yticks([2.0**10, 2.0**20, 2.0**30, 2.0**40, 2.0**50, 2.0**60],\n", + " labels=[\"1 KiB\", \"1 MiB\", \"1 GiB\", \"1 TiB\", \"1 PiB\", \"1 EiB\"])\n", + "ax.legend(loc=\"lower left\", bbox_to_anchor=(0, 1.02), ncols=2, borderaxespad=0)\n", + "ax.grid(axis=\"y\", alpha=0.15)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "The plot shows one state's arrays, not peak solver memory or a practical qubit\n", + "limit. The density-matrix curve continues above the plotted range. Hamiltonians,\n", + "jump operators, temporary arrays, worker copies, and saved observable\n", + "trajectories also use memory. In particular, a cached propagator can be much\n", + "larger than the state it evolves.\n", + "\n", + "## How solver work scales\n", + "\n", + "For an MPS with fixed local dimension, Hamiltonian MPO bond dimension, and local\n", + "solver effort, a TDVP sweep costs roughly $O(L\\chi^3)$. The MPO bond dimension\n", + "measures the size of the Hamiltonian's tensor-network representation. More\n", + "complex interactions and growing state bonds increase the work. This estimate\n", + "describes local TDVP sweeps, not every supported integrator or noise pattern.\n", + "\n", + "Dense solvers have two regimes in the current implementation:\n", + "\n", + "| Solver | Small-system method | Method above the cache threshold |\n", + "| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |\n", + "| Vector / MCWF | Build a dense $D\\times D$ step propagator: roughly $O(D^3)$ setup, $O(D^2)$ storage and work per step. | Apply the exponential through sparse Krylov methods; work depends on operator nonzeros and iteration count. |\n", + "| Density matrix / Lindblad | Build a dense $D^2\\times D^2$ generator and step propagator: roughly $O(D^6)$ exponential setup, $O(D^4)$ storage and work per step. | Integrate the matrix master equation with adaptive RK45, without storing the full generator. |\n", + "\n", + "For a fixed number of Krylov iterations and short-range sparse operators, vector\n", + "evolution typically needs order $LD$ work per exponential action. For a\n", + "Hamiltonian with order $LD$ nonzeros and $K$ local jump channels, one Lindblad\n", + "derivative evaluation costs order $(L+K)D^2$. The number of adaptive steps\n", + "depends on the dynamics and tolerances, so these estimates do not give a single\n", + "runtime law for every problem.\n", + "\n", + ":::{dropdown} Propagator thresholds and other memory costs\n", + "The vector solver caches its dense step propagator when `D <= 4096` (up to 12\n", + "qubits). The density-matrix solver builds a dense generator and caches its step\n", + "propagator when `D**2 <= 4096` (up to 6 qubits). At either upper threshold, one\n", + "propagator alone occupies 256 MiB in `complex128`; preprocessing needs\n", + "additional arrays. These are implementation thresholds, not recommended system\n", + "sizes or user memory limits.\n", + "\n", + "MPS evolution also stores the Hamiltonian MPO and contraction environments. For\n", + "uniform dimensions and an MPO bond dimension $w$, their typical storage is\n", + "$O(Ld^2w^2)$ and $O(Lw\\chi^2)$, respectively. Intermediate bonds can exceed the\n", + "final retained bonds during an update.\n", + "\n", + "For unequal local dimensions, replace $D=d^L$ with $D=\\prod_i d_i$. The exact\n", + "MPS state entry count is $\\sum_i d_i\\chi_i\\chi_{i+1}$, where the end bonds have\n", + "dimension one. Local dimensions above two can raise costs sharply even when the\n", + "number of sites stays fixed.\n", + ":::\n", + "\n", + "### Trajectories, time steps, and parallelism\n", + "\n", + "Noisy TJM and MCWF work grows approximately in proportion to `num_traj` and the\n", + "number of evolution steps, at fixed numerical settings. Standard Monte Carlo\n", + "error decreases as $1/\\sqrt{N_{\\mathrm{traj}}}$: halving that error generally\n", + "requires four times as many trajectories. Increasing the trajectory count does\n", + "not remove timestep or MPS approximation errors.\n", + "\n", + "Lindblad evolution needs one deterministic run, so increasing `num_traj` has no\n", + "effect. Parallel workers can reduce the wall time of trajectory ensembles, but\n", + "they do not reduce the total numerical work and can increase memory use. There\n", + "is no universal fastest representation; the balance depends on the model,\n", + "accuracy, and hardware. The\n", + "[computational-regimes study](https://arxiv.org/abs/2606.13779) compares\n", + "trajectory costs and sampling effort.\n", + "\n", + "## Compare the same noisy dynamics\n", + "\n", + "A four-site version of the XY chain in {doc}`analog_simulation` provides a small\n", + "comparison. One excitation starts at site 1, moves between neighbors, and can be\n", + "lost through local relaxation. We measure the occupation of its starting site,\n", + "$n_1=(1-Z_1)/2$, using identical physical inputs for all three solvers." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseModel, Observable, Simulator, State\n", + "\n", + "length = 4\n", + "initial_site = 1\n", + "basis = \"0\" * initial_site + \"1\" + \"0\" * (length - initial_site - 1)\n", + "hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0)\n", + "noise = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": 0.6}\n", + " for site in range(length)\n", + "])\n", + "params = AnalogSimParams(\n", + " observables=[Observable(\"z\", initial_site)],\n", + " elapsed_time=1.0,\n", + " dt=0.05,\n", + " num_traj=64,\n", + " random_seed=7,\n", + ")\n", + "sim = Simulator(show_progress=False)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "Only the `State` representation changes between runs. Product-state presets\n", + "create the same initial physical state directly in each representation, so no\n", + "manual tensor copying or dense conversion is needed:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "results = {}\n", + "for representation in (\"density_matrix\", \"vector\", \"mps\"):\n", + " state = State(length, initial=\"basis\", basis_string=basis, representation=representation)\n", + " results[representation] = sim.run(state, hamiltonian, params, noise_model=noise)\n", + "\n", + "times = results[\"density_matrix\"].times" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "Parallel execution remains enabled, and the documentation hides progress bars.\n", + "Run the cells in order in a notebook; see {doc}`simulator_initialization` for\n", + "the main guard required in a script. The MPS and vector results each store an\n", + "array of means in `expectation_values[0]` and an array of shape `(64, 21)` in\n", + "`trajectories[0]`. The density-matrix result has a single deterministic row." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:39:35.827397\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(6.6, 3.2))\n", + "reference = (1 - results[\"density_matrix\"].expectation_values[0]) / 2\n", + "ax.plot(times, reference, color=\"0.2\", label=\"Lindblad reference\")\n", + "for representation, color, marker, style, offset, label in (\n", + " (\"mps\", \"#0072B2\", \"o\", \"-\", 0, \"TJM / MPS\"),\n", + " (\"vector\", \"#D55E00\", \"s\", \"--\", 1, \"MCWF / vector\"),\n", + "):\n", + " result = results[representation]\n", + " mean = (1 - result.expectation_values[0]) / 2\n", + " samples = (1 - result.trajectories[0]) / 2\n", + " standard_error = samples.std(axis=0, ddof=1) / np.sqrt(samples.shape[0])\n", + " ax.fill_between(times, mean - standard_error, mean + standard_error, color=color, alpha=0.14, linewidth=0)\n", + " ax.plot(times, mean, color=color, marker=marker, linestyle=style,\n", + " markevery=(offset, 2), markersize=3.2, markerfacecolor=\"white\",\n", + " linewidth=1.2, label=label)\n", + "ax.set(xlabel=r\"Time $t$\", ylabel=r\"Occupation $\\langle n_1\\rangle$\", xlim=(0, 1), ylim=(-0.03, 1.04))\n", + "ax.legend(loc=\"upper right\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "The lines show excitation transport combined with loss. Shaded bands give one\n", + "estimated standard error of the trajectory means at each time; they are not\n", + "bounds on the numerical error or simultaneous confidence bands. Sixty-four\n", + "trajectories illustrate statistical variation, not a converged benchmark or a\n", + "runtime comparison. The shared seed can correlate the two trajectory estimates.\n", + "Lindblad supplies a deterministic numerical reference, not an error-free\n", + "solution.\n", + "\n", + "## Check accuracy and supported workflows\n", + "\n", + "For noisy MPS and vector results, increase `num_traj` to test sampling error,\n", + "then reduce `dt` to check time resolution. For MPS, also increase `max_bond_dim`\n", + "and reduce `svd_threshold` to check compression. A product initial state can\n", + "become entangled during evolution; its initial simplicity does not make the\n", + "later MPS approximation exact. Use {doc}`simulation_parameters` for presets and\n", + "solver-specific controls.\n", + "\n", + "| Workflow or input | Supported representations |\n", + "| ---------------------------------------- | --------------------------------------------------------------------------------------------------- |\n", + "| Static-Hamiltonian analog evolution | MPS, vector, and density matrix; noise must meet the restrictions in {doc}`realistic_noise_models`. |\n", + "| Circuits and analog-digital programs | MPS. |\n", + "| Mixed initial state | Density matrix. |\n", + "| Bitstring-probability observables | MPS with qubits at every site. |\n", + "| Entropy and Schmidt-spectrum observables | MPS; noisy results describe pure trajectories, not the spectrum of a mixed density matrix. |\n", + "| Piecewise Hamiltonian | MPS with TDVP; see {doc}`hamiltonians`. |\n", + "| Unitary `list[State]` ensemble | MPS; see {doc}`ensemble_evolution`. |\n", + "\n", + "Noisy MPS, vector, and circuit runs do not return one final pure state for the\n", + "trajectory ensemble. Noisy density-matrix evolution can retain its final mixed\n", + "state with `get_state=True`.\n", + "\n", + "Choose MPS when the required bond dimensions remain affordable, vector when a\n", + "full pure state fits and provides a useful reference, and density matrix when\n", + "you need a small-system ensemble average or mixed-state evolution. Check\n", + "convergence for the quantities you intend to report before scaling up." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 120, + "number_source_lines": true + }, + "source_map": [ + 10, + 68, + 120, + 192, + 211, + 217, + 224, + 232, + 252 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/simulation_parameters.ipynb b/docs/_outputs/examples/simulation_parameters.ipynb new file mode 100644 index 000000000..e6bc3011e --- /dev/null +++ b/docs/_outputs/examples/simulation_parameters.ipynb @@ -0,0 +1,357 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Configuring Simulation Parameters\n", + "\n", + "Simulation parameters choose what to measure, when to record it, and the\n", + "numerical accuracy. Start with a preset, then change the settings that matter\n", + "for your calculation.\n", + "\n", + "| Class | Use for |\n", + "| ------------------ | ---------------------------------------------------------------------- |\n", + "| `AnalogSimParams` | Hamiltonian evolution, including noisy dynamics and unitary ensembles. |\n", + "| `DigitalSimParams` | Circuit expectation values, shot counts, or a final state. |\n", + "\n", + "Pass the parameter object to `Simulator.run` with the initial state and\n", + "Hamiltonian or circuit. Supply noise through `noise_model` in that call. The\n", + "state representation selects the analog backend; see\n", + "{doc}`representation_comparison`. Parallel execution and progress controls\n", + "belong to `Simulator`, as described in {doc}`simulator_initialization`.\n", + "\n", + "## Choose a preset\n", + "\n", + "Both classes default to `preset=\"balanced\"`. A preset supplies four settings:\n", + "\n", + "| Preset | `svd_threshold` | `max_bond_dim` | `num_traj` | `krylov_tol` |\n", + "| ------------ | --------------- | -------------- | ---------- | ------------ |\n", + "| `\"fast\"` | `1e-3` | `16` | `128` | `1e-3` |\n", + "| `\"balanced\"` | `1e-6` | `128` | `256` | `1e-4` |\n", + "| `\"accurate\"` | `1e-9` | `4096` | `1024` | `1e-6` |\n", + "| `\"exact\"` | `1e-13` | `None` | `1024` | `1e-12` |\n", + "\n", + "Use `\"fast\"` to explore a model and `\"balanced\"` as a starting point for\n", + "accuracy checks. `\"accurate\"` tightens the numerical settings and increases the\n", + "trajectory budget, at greater cost. `\"exact\"` provides strict reference settings\n", + "and removes the bond cap; finite timesteps, truncation, and sampling error still\n", + "apply. No preset establishes convergence for every model.\n", + "\n", + "Explicit constructor arguments replace only the corresponding preset values:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import AnalogSimParams, DigitalSimParams, Observable\n", + "\n", + "custom_params = AnalogSimParams(\n", + " preset=\"accurate\",\n", + " max_bond_dim=256,\n", + " num_traj=64,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "Here, `svd_threshold` and `krylov_tol` retain their `\"accurate\"` values. Omit\n", + "`max_bond_dim` to keep the preset cap; pass `None` explicitly to remove it.\n", + "Choose the preset when constructing the object. Assigning a new value to\n", + "`params.preset` later does not reset the other fields. `shots` is always an\n", + "explicit budget and is not part of a preset.\n", + "\n", + "## Set analog measurements and times\n", + "\n", + "For a four-site chain, record the local Pauli $Z$ expectations over two time\n", + "units:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "analog_params = AnalogSimParams(\n", + " observables=[Observable(\"z\", site) for site in range(4)],\n", + " elapsed_time=2.0,\n", + " dt=0.05,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "The default `sample_timesteps=True` records 41 samples, including time zero and\n", + "the final time. After the run, `result.times` holds the sampled times and\n", + "`result.expectation_values[i]` holds the values for the $i$th supplied\n", + "observable. Set `sample_timesteps=False` to keep only the final sample;\n", + "evolution still uses the same `dt`.\n", + "\n", + "The timestep must be positive, and the duration must be non-negative and an\n", + "integer multiple of `dt`. For a computed grid, set\n", + "`elapsed_time = num_steps * dt` to keep the duration consistent with the step\n", + "count. Invalid grids raise `ValueError`.\n", + "\n", + "For noisy trajectory evolution, `num_traj` sets the number of realizations to\n", + "average. A noiseless single-state run uses one realization. A density-matrix\n", + "backend evolves the ensemble directly, while a supplied `list[State]` uses the\n", + "list length as its ensemble size. See {doc}`analog_simulation` for a noisy\n", + "walkthrough and {doc}`ensemble_evolution` for unitary ensemble averages.\n", + "\n", + "## Choose circuit outputs\n", + "\n", + "Request observables, shots, or both. At least one output must be requested in a\n", + "standalone run; `get_state=True` is another option for supported noiseless runs." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "observable_params = DigitalSimParams(observables=[Observable(\"z\", 0)])\n", + "shot_params = DigitalSimParams(shots=1024)\n", + "combined_params = DigitalSimParams(\n", + " observables=[Observable(\"z\", 0)],\n", + " shots=1024,\n", + " num_traj=64,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "Observables are recorded at the circuit's end by default. With\n", + "`sample_layers=True`, YAQS also records the initial state and checkpoints marked\n", + "by `circuit.barrier(label=\"SAMPLE_OBSERVABLES\")`. It does not sample every\n", + "circuit layer automatically. See {doc}`circuit_observables` for checkpoint\n", + "placement and the resulting sample axis.\n", + "\n", + "`shots` is the total readout budget. `num_traj` controls the noisy observable\n", + "ensemble, so the two settings have different roles:\n", + "\n", + "| Run | How YAQS uses the budgets |\n", + "| ----------------------- | ------------------------------------------------------------------------------- |\n", + "| Noiseless | Evolve once and sample all requested shots from the final state. |\n", + "| Noisy, with observables | Average `num_traj` trajectories. Distribute any requested shots across them. |\n", + "| Noisy, shots only | Run one single-shot trajectory per shot; `num_traj` does not control this path. |\n", + "\n", + "A combined run supports `shots < num_traj`: every trajectory contributes to the\n", + "observable mean, while some receive no readout samples. Counts appear in\n", + "`result.counts` as integer keys and integer counts. Site 0 is the\n", + "least-significant bit, matching Qiskit's `int(bitstring, 2)` convention. See\n", + "{doc}`circuit_shots` for histogram plotting.\n", + "\n", + "## Choose observables and diagnostics\n", + "\n", + "Named operators avoid imports from gate libraries. The Pauli names below refer\n", + "to $\\sigma^\\alpha$, rather than spin operators $S^\\alpha=\\sigma^\\alpha/2$.\n", + "\n", + "| Request | Example |\n", + "| --------------------------------- | ---------------------------------------------------------------------- |\n", + "| Single-site Pauli operator | `Observable(\"z\", 0)`; also `\"x\"` and `\"y\"` |\n", + "| Identity or basis projector | `Observable(\"id\", 0)`, `Observable(\"p0\", 0)`, or `Observable(\"p1\", 0)` |\n", + "| Two-site Pauli operator | `Observable(\"zz\", [0, 1])`; also `\"xx\"` and `\"yy\"` |\n", + "| Position on a supplied grid | `Observable(\"position\", 0, positions=grid)` |\n", + "| Probability of a full basis state | `Observable(\"0101\")` for a four-qubit system |\n", + "| Custom Hermitian operator | `Observable(matrix, sites=0)` or `Observable(matrix, sites=[0, 1])` |\n", + "\n", + "Two-site local operators support adjacent sites and the periodic end-to-end bond\n", + "on qubit chains. Matrix factors follow the supplied site order. Bitstring\n", + "probabilities require an all-qubit MPS and cannot share an observable list with\n", + "ordinary operators or entanglement diagnostics. Shot counts can accompany\n", + "ordinary observables.\n", + "\n", + "For MPS entanglement across the bond between sites 1 and 2, request the adjacent\n", + "pair:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [], + "source": [ + "diagnostic_params = AnalogSimParams(\n", + " observables=[\n", + " Observable(\"entropy\", sites=[1, 2]),\n", + " Observable(\"schmidt_spectrum\", sites=[1, 2]),\n", + " ],\n", + " elapsed_time=2.0,\n", + " dt=0.05,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "These diagnostics work at sampled times or circuit checkpoints, including noisy\n", + "MPS runs. Spectra contain descending Schmidt coefficients, with up to 500\n", + "entries and `NaN` padding. With noise, YAQS averages the pure-trajectory\n", + "entropies and coefficients; these are not the entropy or spectrum of the\n", + "ensemble's mixed density matrix. Missing ranks contribute zero to coefficient\n", + "means, while ranks absent from every trajectory remain `NaN`.\n", + "\n", + "## Refine accuracy and retain results\n", + "\n", + "Change one source of error at a time and compare the observable that matters for\n", + "your calculation:\n", + "\n", + "| Setting | When to change it |\n", + "| --------------- | ---------------------------------------------------------------------------------------------------------- |\n", + "| `dt` | Reduce it at fixed analog duration to check time-step error. |\n", + "| `num_traj` | Increase it to reduce uncertainty in noisy trajectory averages. |\n", + "| `max_bond_dim` | Increase the MPS bond cap if it limits the evolving state. Larger bonds cost memory and time. |\n", + "| `svd_threshold` | Reduce it to retain more information during tensor truncation. |\n", + "| `krylov_tol` | Reduce it to tighten local matrix-exponential solves in TDVP or BUG. This does not tighten SVD truncation. |\n", + "\n", + "Keep an explicit trajectory budget when comparing presets, since a preset\n", + "changes that budget along with the numerical settings. Tensor truncation and\n", + "integrator controls concern MPS evolution; dense backends use different\n", + "numerical methods. See {doc}`representation_comparison` before changing them.\n", + "\n", + "Set `random_seed` to a non-negative integer to repeat jump decisions and sampled\n", + "static disorder for the same input and configuration. It does not seed random\n", + "state initialization or measurement-shot sampling.\n", + "\n", + "Set `get_state=True` to retain a supported final state in `result.output_state`.\n", + "Noisy MPS, statevector, and circuit runs cannot return one final pure state for\n", + "the trajectory ensemble. Noisy density-matrix evolution can return its final\n", + "mixed state. Unitary list-of-state ensembles do not return a final ensemble\n", + "state.\n", + "\n", + "## Advanced numerical options\n", + "\n", + ":::{dropdown} Analog integrators and two-time correlations\n", + "\n", + "MPS analog evolution defaults to `evolution_mode=EvolutionMode.TDVP`. Import\n", + "`EvolutionMode` from `mqt.yaqs` and select `EvolutionMode.BUG` to use the BUG\n", + "integrator. See {doc}`analog_simulation` for the workflow.\n", + "\n", + "`order=1` or `order=2` selects the TJM splitting order for noisy MPS evolution.\n", + "This is separate from `tdvp_mode`, which controls the TDVP state updates:\n", + "\n", + "- `\"2site\"` (default) allows bond growth through two-site updates.\n", + "- `\"1site\"` uses single-site updates with fixed bond dimensions.\n", + "- `\"dynamic\"` switches between single-site and two-site updates.\n", + "\n", + "`tdvp_sweeps` defaults to 1. Increasing it subdivides each TDVP evolution step\n", + "into symmetric substeps with the same total evolution time. In noisy analog\n", + "runs, noise still acts on the full physical timestep `dt`; extra TDVP substeps\n", + "do not refine that noise timestep.\n", + "\n", + "For two-time correlations, pass `multi_time_observables=[(A, B)]` with a\n", + "noiseless MPS `list[State]` and a static Hamiltonian. `B` acts at time zero and\n", + "`A` at the later time. The complex results appear in `multi_time_results`, with\n", + "one row per pair. See {doc}`ensemble_evolution` for the definition and example.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} Circuit gate-application modes\n", + "\n", + "Keep `gate_mode=\"mpo\"` unless you need to compare another method. It applies\n", + "gates directly and compresses the result using the configured tensor truncation.\n", + "The available modes are:\n", + "\n", + "| Mode | Two-qubit gates |\n", + "| ----------------- | ----------------------------------------------------------------------------------- |\n", + "| `\"mpo\"` (default) | Direct local updates for neighbors; an extended gate MPO for separated sites. |\n", + "| `\"swaps\"` | Route separated sites together with SWAPs, apply the gate, and restore their order. |\n", + "| `\"tdvp\"` | Direct local updates for neighbors; generator-based TDVP for separated sites. |\n", + "| `\"full-tdvp\"` | Generator-based TDVP for both neighboring and separated sites. |\n", + "\n", + "TDVP gate paths are variational approximations and can miss the bond growth an\n", + "entangling gate requires. Additional `tdvp_sweeps` may help, but do not\n", + "guarantee exact gate application. Use `\"mpo\"`, or `\"swaps\"` for two-qubit gates,\n", + "for direct application up to the configured truncation. Digital generator-based\n", + "TDVP requires `tdvp_mode=\"2site\"`.\n", + "\n", + "Matrix-backed custom gates without an analytic generator use direct local\n", + "updates for neighbors and the MPO path for separated sites, including in TDVP\n", + "modes. Gates on three or more qubits use an MPO, except supported product-form\n", + "generators such as `ccx` and `ccz` in TDVP modes. See\n", + "{ref}`circuit-custom-gates` for custom gate inputs.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} SVD truncation modes\n", + "\n", + "`trunc_mode=\"discarded_weight\"` is the default. `svd_threshold` limits the sum\n", + "of discarded squared singular values. The other modes interpret the threshold as\n", + "a fraction of total squared weight (`\"relative_discarded_weight\"`), a ratio to\n", + "the largest singular value (`\"relative\"`), or an absolute singular-value cutoff\n", + "(`\"hard_cutoff\"`). `max_bond_dim` can force further truncation in every mode.\n", + "Presets do not change `trunc_mode`.\n", + "\n", + ":::\n", + "\n", + "## Related guides\n", + "\n", + "- {doc}`quickstart` — simulation and characterization workflows.\n", + "- {doc}`analog_simulation` — noisy spin dynamics and convergence checks.\n", + "- {doc}`circuit_observables` — circuit dynamics and sampling checkpoints.\n", + "- {doc}`circuit_shots` — noisy readout distributions.\n", + "- {doc}`simulator_initialization` — execution controls and result fields.\n", + "\n", + "Full constructor signatures are in the API reference for\n", + "{class}`~mqt.yaqs.AnalogSimParams` and {class}`~mqt.yaqs.DigitalSimParams`." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 48, + 56, + 69, + 75, + 99, + 107, + 153, + 162 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/simulator_initialization.ipynb b/docs/_outputs/examples/simulator_initialization.ipynb new file mode 100644 index 000000000..ca14cbd10 --- /dev/null +++ b/docs/_outputs/examples/simulator_initialization.ipynb @@ -0,0 +1,301 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Configuring the Simulator\n", + "\n", + "`Simulator` controls how a calculation runs: parallel workers, progress bars,\n", + "and worker-error handling. Construct one instance and reuse it for successive\n", + "runs. The state, Hamiltonian or circuit, measurements, and noise are supplied\n", + "with each call; their settings are described in {doc}`simulation_parameters`.\n", + "\n", + "## Run a small noisy simulation\n", + "\n", + "This four-site Ising chain starts with every spin in $|1\\rangle$. Local\n", + "relaxation acts during the evolution, and eight trajectories contribute to the\n", + "mean Pauli $Z$ expectations:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseModel, Observable, Simulator, State\n", + "\n", + "length = 4\n", + "state = State(length, initial=\"ones\")\n", + "hamiltonian = Hamiltonian.ising(length, J=1.0, g=0.5)\n", + "params = AnalogSimParams(\n", + " observables=[Observable(\"z\", site) for site in range(length)],\n", + " elapsed_time=0.4,\n", + " dt=0.05,\n", + " num_traj=8,\n", + " random_seed=7,\n", + ")\n", + "noise = NoiseModel([\n", + " {\"name\": \"lowering\", \"sites\": [site], \"strength\": 0.4}\n", + " for site in range(length)\n", + "])\n", + "\n", + "sim = Simulator(show_progress=False)\n", + "result = sim.run(state, hamiltonian, params, noise_model=noise)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "Parallel execution remains enabled. The documentation suppresses progress bars\n", + "with `show_progress=False`; use `Simulator()` to see progress in your own runs.\n", + "The small trajectory budget illustrates execution and result access, rather than\n", + "sampling convergence.\n", + "\n", + "## Read the result and reuse the simulator\n", + "\n", + "`run` returns a `Result`. Observable order matches the supplied list:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "times = result.times\n", + "z0_mean = result.expectation_values[0]\n", + "z0_trajectories = result.trajectories[0]" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "Here, `times` and `z0_mean` have nine entries, including time zero.\n", + "`z0_trajectories` has shape `(8, 9)`: one row per trajectory and one column per\n", + "time. `z0_mean` averages those rows. Circuit runs can also return readout counts\n", + "in `result.counts`; see {doc}`circuit_shots`.\n", + "\n", + "Reuse the same simulator and inputs for a noiseless reference:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "reference = sim.run(state, hamiltonian, params)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "Each run starts from the supplied initial state. It does not continue from the\n", + "previous result. Sequential calls share execution settings, but they do not keep\n", + "a worker pool alive. YAQS creates a pool only when the calculation has multiple\n", + "independent jobs and more than one worker is available.\n", + "\n", + "## Choose the common execution controls\n", + "\n", + "All constructor options are keyword-only. Leave the defaults in place unless you\n", + "need a specific execution budget or a quieter run.\n", + "\n", + "| Option | Default | When to change it |\n", + "| --------------- | --------- | --------------------------------------------------------------------------------------- |\n", + "| `show_progress` | `True` | Set `False` to suppress trajectory and readout bars in documentation or logs. |\n", + "| `max_workers` | Automatic | Set a positive integer to cap worker processes, for example `Simulator(max_workers=4)`. |\n", + "| `parallel` | `True` | Set `False` to debug in the calling process or avoid process startup for a small job. |\n", + "\n", + "A pool requires `parallel=True`, more than one independent job, and\n", + "`max_workers > 1`. Noiseless single-state evolution and density-matrix evolution\n", + "run in the calling process. `max_workers=1` also keeps execution in that\n", + "process, even when parallel execution is enabled, and limits numerical threads\n", + "to one. Worker processes limit their own numerical threads to avoid multiplying\n", + "thread pools across CPUs.\n", + "\n", + "You can change settings between calls, for example `sim.max_workers = 2` or\n", + "`sim.show_progress = True`. Setting `sim.max_workers = None` restores automatic\n", + "worker selection.\n", + "\n", + "## Run from a Python script\n", + "\n", + "Worker processes start through `forkserver` on Linux and `spawn` on Windows and\n", + "macOS by default. These methods need a script entry-point guard so importing the\n", + "script in a child process does not start the simulation again.\n", + "\n", + "Keep the imports and input definitions from the first example. Replace its\n", + "simulator creation and run calls with this block, and keep subsequent run calls\n", + "inside the guard:\n", + "\n", + "```python\n", + "if __name__ == \"__main__\":\n", + " sim = Simulator()\n", + " result = sim.run(state, hamiltonian, params, noise_model=noise)\n", + " reference = sim.run(state, hamiltonian, params)\n", + "```\n", + "\n", + "Run the file with `python your_script.py`. In a notebook, execute the cells in\n", + "order without adding this guard. If process startup is the cause of a debugging\n", + "problem, `parallel=False` lets you inspect the calculation in the notebook's\n", + "process.\n", + "\n", + "## Advanced execution options\n", + "\n", + ":::{dropdown} Automatic worker budgets and numerical threads\n", + "\n", + "With `max_workers=None`, the simulator uses `max(1, available_cpus() - 1)`. CPU\n", + "discovery takes the first valid hint from:\n", + "\n", + "1. `YAQS_MAX_WORKERS`.\n", + "2. A pytest-xdist worker, which reports one CPU to avoid nested pools.\n", + "3. `SLURM_CPUS_PER_TASK`, then `SLURM_CPUS_ON_NODE`.\n", + "4. Process CPU affinity, when available.\n", + "5. The operating system's CPU count.\n", + "\n", + "Invalid or non-positive environment hints are ignored. The default worker policy\n", + "then leaves one reported CPU free. Thus `YAQS_MAX_WORKERS=4` normally resolves\n", + "to three workers; `Simulator(max_workers=4)` explicitly permits four. Use the\n", + "constructor argument when you need an exact process cap. CPU affinity can\n", + "restrict the available cores, but this discovery does not read every container\n", + "CPU-quota setting.\n", + "\n", + "A worker cap bounds process count, not total memory. Each process needs its own\n", + "simulation state. Reduce the cap when several concurrent trajectories exceed\n", + "your memory budget. Numerical libraries are capped inside workers; turning off\n", + "process parallelism does not promise unrestricted BLAS threading.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} Multiprocessing start methods\n", + "\n", + "`mp_context=\"auto\"` selects `\"forkserver\"` on Linux and `\"spawn\"` elsewhere. For\n", + "an explicit selection, the public options are `\"spawn\"` and `\"fork\"` on\n", + "platforms that support them. There is no fallback for an unavailable method.\n", + "Keep `\"auto\"` unless the environment requires a specific method.\n", + "\n", + "`\"spawn\"` starts a fresh interpreter for each worker. It can also be useful when\n", + "combining YAQS with libraries that need fresh process initialization. `\"fork\"`\n", + "copies the application's process and can be unsafe when the parent has active\n", + "threads. Thread limits do not remove that risk. The automatic Linux context\n", + "starts workers from a separate server process; its first pool has additional\n", + "startup cost.\n", + "\n", + "Worker inputs must be pickleable. Pools are scoped to individual runs, although\n", + "the Python forkserver helper can remain alive between calls. These execution\n", + "choices do not change the requested simulation model.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} Retries for worker errors\n", + "\n", + "`max_retries=10` allows up to ten additional attempts for a failed job in a\n", + "process pool. The default `retry_exceptions` tuple contains\n", + "`concurrent.futures.CancelledError`, `TimeoutError`, and `OSError`.\n", + "\n", + "Only matching exceptions raised when retrieving a worker result trigger a retry.\n", + "After the retry budget is exhausted, the error propagates. Other exceptions,\n", + "including `ValueError`, propagate immediately under the default policy. Set\n", + "`max_retries=0` to propagate the first worker failure, or supply a tuple of\n", + "exception classes for a specific transient failure in your environment.\n", + "\n", + "Retries do not impose a timeout, apply to in-process execution, or restart a\n", + "broken pool. Pool startup and submission failures are outside this retry policy.\n", + "Do not increase the retry budget to handle a repeatable error in the model.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} Other result fields and configuration references\n", + "\n", + "Outputs that do not apply to a run remain `None` or empty. The API reference for\n", + "{class}`~mqt.yaqs.Result` describes the full result structure:\n", + "\n", + "| Fields | Use |\n", + "| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n", + "| `observables`, `expectation_values`, `trajectories` | Requested observables, their means, and individual realization data in the supplied order. |\n", + "| `times` | Analog sample times, or the stitched timeline of a program with observables. Standalone circuits have no physical time axis. |\n", + "| `counts`, `measurements` | Total circuit readout counts and per-trajectory histograms when shots are requested. |\n", + "| `output_state` | A final state when `get_state=True` is supported; see {doc}`simulation_parameters`. |\n", + "| `max_bond`, `total_bond`, `runtime_cost` | MPS bond diagnostics and an estimated contraction cost, when recorded. |\n", + "| `multi_time_times`, `multi_time_results` | Complex two-time correlations for unitary ensembles; see {doc}`ensemble_evolution`. |\n", + "| `segment_results` | Individual results from a `SimulationProgram`; see {doc}`digital_analog_simulation`. |\n", + "| `sim_params`, `noise_model` | The validated simulation parameters and the noise model used by the run. |\n", + "\n", + "For a standalone run, `result.sim_params` references the supplied parameter\n", + "object. Validation normalizes that object and rebuilds its analog time grid; it\n", + "is not an immutable snapshot. Construct a new parameter object when you need to\n", + "retain a separate configuration. State and Hamiltonian wrappers may also\n", + "populate cached representations, while evolution uses copies of their input\n", + "states.\n", + "\n", + "A program's top-level `sim_params` is `None`; read segment parameters through\n", + "`result.segment_results`. Program settings and trajectory budgets are explained\n", + "in {doc}`digital_analog_simulation`.\n", + "\n", + "For temporary storage, pickle can save results for later analysis with matching\n", + "Python, YAQS, and dependency versions. This stores the result; it does not\n", + "resume an interrupted simulation. Only load pickle files from a trusted source.\n", + "\n", + ":::\n", + "\n", + "## Related guides\n", + "\n", + "- {doc}`simulation_parameters` — accuracy, sampling budgets, and output\n", + " requests.\n", + "- {doc}`analog_simulation` — noisy analog dynamics.\n", + "- {doc}`circuit_observables` — circuit expectations and checkpoints.\n", + "- {doc}`digital_analog_simulation` — analog-digital programs and segment\n", + " results.\n", + "- {doc}`representation_comparison` — choosing the analog state representation.\n", + "\n", + "Full constructor and `run` signatures are in the API reference for\n", + "{class}`~mqt.yaqs.Simulator`." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 25, + 45, + 56, + 60, + 69, + 71 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/state_initialization.ipynb b/docs/_outputs/examples/state_initialization.ipynb new file mode 100644 index 000000000..260878c93 --- /dev/null +++ b/docs/_outputs/examples/state_initialization.ipynb @@ -0,0 +1,368 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Initializing Quantum States\n", + "\n", + "The initial state sets the starting point for a YAQS simulation. Use `State` to\n", + "choose a named preparation or supply your own data. The default matrix product\n", + "state (MPS) representation supports analog and circuit simulation without\n", + "storing a full state vector.\n", + "\n", + "## Choose a product state\n", + "\n", + "Specify the number of sites and, optionally, a preset. Sites are qubits unless\n", + "you provide other local dimensions:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import State\n", + "\n", + "zeros = State(20)\n", + "polarized = State(20, initial=\"x+\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "Here, `zeros` puts every site in $|0\\rangle$, while `polarized` puts every site\n", + "in $(|0\\rangle + |1\\rangle)/\\sqrt{2}$. Both are product states: the sites have\n", + "no initial entanglement.\n", + "\n", + "| `initial` | Preparation |\n", + "| ------------------- | ------------------------------------------------------------------------------------------------ |\n", + "| `\"zeros\"` (default) | Every site in $\\lvert 0\\rangle$. |\n", + "| `\"ones\"` | Every site in $\\lvert 1\\rangle$. |\n", + "| `\"x+\"`, `\"x-\"` | Every site in $(\\lvert 0\\rangle \\pm \\lvert 1\\rangle)/\\sqrt{2}$. |\n", + "| `\"y+\"`, `\"y-\"` | Every site in $(\\lvert 0\\rangle \\pm i\\lvert 1\\rangle)/\\sqrt{2}$. |\n", + "| `\"Neel\"` | Alternating levels, starting with site 0 in $\\lvert 1\\rangle$: `1010…` in site order. |\n", + "| `\"wall\"` | The first `length // 2` sites in $\\lvert 0\\rangle$ and the remaining sites in $\\lvert 1\\rangle$. |\n", + "| `\"basis\"` | One computational-basis configuration, supplied with `basis_string`. |\n", + "| `\"random\"` | A random product state; see the random-state examples below. |\n", + "\n", + "## Place an excitation and check site order\n", + "\n", + "The {doc}`analog_simulation` guide follows an excitation that starts near the\n", + "center of a chain. Prepare that state by giving one basis digit per site:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "length = 20\n", + "center = length // 2\n", + "basis = \"0\" * center + \"1\" + \"0\" * (length - center - 1)\n", + "localized = State(length, initial=\"basis\", basis_string=basis)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "Character `i` in `basis_string` selects site `i`, starting with site 0 on the\n", + "left. A measurement bitstring instead displays site 0 on the right:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0.+0.j 1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j]\n" + ] + } + ], + "source": [ + "site_zero = State(3, initial=\"basis\", basis_string=\"100\")\n", + "print(site_zero.mps.to_vec())" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "This state has site 0 in $|1\\rangle$ and the other sites in $|0\\rangle$. Its\n", + "dense vector has its only nonzero entry at index 1, and its readout bitstring is\n", + "`\"001\"`. Dense vectors and the rows and columns of density matrices use site 0\n", + "as the fastest-varying subsystem. For qubits, this matches Qiskit's ordering.\n", + "\n", + "## Choose a representation\n", + "\n", + "Set `representation` when constructing a preset state, for example\n", + "`State(4, initial=\"x+\", representation=\"vector\")`. YAQS selects the analog\n", + "backend from this choice:\n", + "\n", + "| `representation` | State storage and supported use |\n", + "| ------------------ | ---------------------------------------------------------------------------------- |\n", + "| `\"mps\"` (default) | Tensor network for analog evolution, circuits, and unitary ensembles. |\n", + "| `\"vector\"` | Dense pure state for analog evolution with Monte Carlo wave-function trajectories. |\n", + "| `\"density_matrix\"` | Dense pure or mixed state for analog Lindblad evolution. |\n", + "\n", + "For $N$ qubits, a dense vector has $2^N$ entries and a density matrix has $4^N$\n", + "entries. Keep dense calculations small; circuit simulation requires `\"mps\"`. See\n", + "{doc}`representation_comparison` for a comparison on the same physical model.\n", + "\n", + "## Prepare random states\n", + "\n", + "Use `\"random\"` for an unentangled state with real, nonnegative random local\n", + "amplitudes. To allow initial entanglement, use `\"haar-random\"` and choose an\n", + "initial maximum bond dimension with `pad`:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [], + "source": [ + "random_product = State(6, initial=\"random\")\n", + "random_mps = State(6, initial=\"haar-random\", pad=4)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "`\"haar-random\"` builds an MPS from random isometries. It does not sample\n", + "uniformly from all pure states in the full Hilbert space. The bond dimensions\n", + "are limited by `pad` and the sizes of the neighboring subsystems. Omitting `pad`\n", + "gives a maximum bond dimension of one, so the state is then a product state.\n", + "This initial choice is separate from `max_bond_dim` during evolution.\n", + "\n", + "For a reproducible random product state in a dense representation, pass `seed`:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "repeatable = State(4, initial=\"random\", representation=\"vector\", seed=7)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "The same seed reproduces the same initial vector. It also works with\n", + "`representation=\"density_matrix\"`, which forms the corresponding pure-state\n", + "density matrix. Currently, random MPS initialization and `\"haar-random\"` ignore\n", + "`seed`. To reuse those preparations, construct the state once and pass the same\n", + "object to successive runs. The simulation parameter `random_seed` controls\n", + "stochastic evolution; it does not seed state preparation.\n", + "\n", + "## Supply a vector or a mixed state\n", + "\n", + "Pass exactly one of `vector`, `density_matrix`, or `tensors` for manual data.\n", + "The representation is inferred, so omit `representation`. Do not combine manual\n", + "data with preset options such as `initial`, `basis_string`, `seed`, or `pad`.\n", + "\n", + "The vector below prepares the entangled Bell state\n", + "$(|00\\rangle + |11\\rangle)/\\sqrt{2}$:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0.70710678+0.j 0. +0.j 0. +0.j 0.70710678+0.j]\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "\n", + "bell = State(vector=np.array([1, 0, 0, 1], dtype=complex))\n", + "print(bell.vector)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "YAQS copies and normalizes the vector. The input must be a finite, nonzero,\n", + "one-dimensional array. Without explicit local dimensions, its size must be a\n", + "power of two; YAQS infers the number of qubits.\n", + "\n", + "A mixed state describes a statistical preparation. This example puts the system\n", + "in $|00\\rangle$ with probability 0.6 and $|11\\rangle$ with probability 0.4:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-13", + "metadata": {}, + "outputs": [], + "source": [ + "rho = np.diag([0.6, 0.0, 0.0, 0.4]).astype(complex)\n", + "mixed = State(density_matrix=rho)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "YAQS copies the matrix and normalizes its trace. The input must be finite,\n", + "square, Hermitian, positive semidefinite, and have positive real trace. Manual\n", + "vectors and density matrices select their dense analog backends. They cannot\n", + "serve as circuit inputs; use a preset or MPS tensors instead.\n", + "\n", + "## Use other local dimensions\n", + "\n", + "Set `physical_dimensions` to an integer for a uniform chain or a list for\n", + "different dimensions at each site:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cell-15", + "metadata": {}, + "outputs": [], + "source": [ + "qutrits = State(3, physical_dimensions=3)\n", + "qubit_qutrit = State(\n", + " 2, initial=\"basis\", basis_string=\"12\", physical_dimensions=[2, 3]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-16", + "metadata": {}, + "source": [ + "The second state has a qubit at site 0 in level 1 and a qutrit at site 1 in\n", + "level 2. Presets such as `\"ones\"` still use level 1, not the highest local\n", + "level. For manual dense data, local dimensions must multiply to the vector\n", + "length or matrix dimension. Bitstring probabilities and shot measurements\n", + "require qubits; digital gates currently require qubit target sites. See\n", + "{doc}`transmon_emulation` and {doc}`trapped_ion` for device examples.\n", + "\n", + "## Advanced MPS preparation\n", + "\n", + ":::{dropdown} Supply MPS tensors or wrap an existing MPS\n", + "\n", + "Use `tensors=` for custom open-boundary MPS cores. Each core has axes\n", + "`(physical, left, right)`. Neighboring bond dimensions must match, exterior\n", + "bonds must have dimension one, and all entries must be finite. This example\n", + "prepares the same Bell state as the vector above, now in MPS form:\n", + "\n", + "```python\n", + "left = (np.eye(2) / np.sqrt(2)).reshape(2, 1, 2)\n", + "right = np.eye(2).reshape(2, 2, 1)\n", + "bell_mps = State(tensors=[left, right])\n", + "wrapped = State.from_mps(bell_mps.mps)\n", + "```\n", + "\n", + "`State(tensors=...)` infers the site count and normalizes the MPS. For other\n", + "physical dimensions, supply matching `physical_dimensions`.\n", + "\n", + "When you already have an {class}`~mqt.yaqs.MPS`, use\n", + "{meth}`~mqt.yaqs.State.from_mps` to wrap it. This method references the same MPS\n", + "without copying or normalizing it. Changes through either reference affect the\n", + "same state. For an independent normalized state, supply copies of its tensors\n", + "through `State(tensors=...)` and preserve its physical dimensions.\n", + "\n", + ":::\n", + "\n", + ":::{dropdown} Pad the initial MPS bonds\n", + "\n", + "For product presets, `pad` adds zero entries to enlarge the initial MPS bonds\n", + "without changing the physical state. For `\"haar-random\"`, it instead sets the\n", + "maximum initial bond dimension used during random construction.\n", + "\n", + "Padding allocates initial MPS space; it does not set the bond limit during\n", + "evolution. That limit is `max_bond_dim` in the simulation parameters. Dense\n", + "product-state construction does not use MPS padding.\n", + "\n", + ":::\n", + "\n", + "Once the initial state is prepared, pass it to `Simulator.run` with the model,\n", + "measurements, and optional noise. The {doc}`simulator_initialization` guide\n", + "shows how to run and reuse a simulator, while {doc}`simulation_parameters`\n", + "describes accuracy and sampling choices. Full constructor details are in the\n", + "{class}`~mqt.yaqs.State` API reference." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 24, + 29, + 51, + 56, + 61, + 64, + 93, + 96, + 106, + 108, + 126, + 131, + 140, + 143, + 155, + 160 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/transmon_emulation.ipynb b/docs/_outputs/examples/transmon_emulation.ipynb new file mode 100644 index 000000000..edcad4c13 --- /dev/null +++ b/docs/_outputs/examples/transmon_emulation.ipynb @@ -0,0 +1,4280 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Superconducting Qubit Emulation\n", + "\n", + "A resonator can carry an excitation between superconducting qubits. How much\n", + "reaches the receiving qubit, and how does noise affect the transfer? We model\n", + "two transmons coupled through a resonator, follow their populations, and compare\n", + "several relaxation and dephasing strengths. Keeping a third transmon level also\n", + "lets us track occupation outside the qubit computational subspace.\n", + "\n", + "This guide uses the standard YAQS installation and Matplotlib. Run the cells in\n", + "order in a notebook. For a script, use the entry-point guard in\n", + "{doc}`simulator_initialization`.\n", + "\n", + "## 1. Build the transmon–resonator model\n", + "\n", + "`Hamiltonian.coupled_transmon` places transmons at even sites and resonators at\n", + "odd sites. Here, sites 0 and 2 are three-level transmons, and site 1 is a\n", + "four-level resonator. Each transmon has a Duffing term\n", + "$\\omega_q n+\\alpha n(n-1)/2$, while the resonator has energy $\\omega_r n$.\n", + "Neighboring sites interact through the full dipole coupling\n", + "$g(b+b^\\dagger)(a+a^\\dagger)$, where $b$ and $a$ lower the transmon and\n", + "resonator levels." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from mqt.yaqs import Hamiltonian\n", + "\n", + "qubit_dim = 3\n", + "resonator_dim = 4\n", + "physical_dimensions = [qubit_dim, resonator_dim, qubit_dim]\n", + "coupling = 1.0\n", + "hamiltonian = Hamiltonian.coupled_transmon(\n", + " length=3,\n", + " qubit_dim=qubit_dim,\n", + " resonator_dim=resonator_dim,\n", + " qubit_freq=20.0 * coupling,\n", + " resonator_freq=20.0 * coupling,\n", + " anharmonicity=-1.5 * coupling,\n", + " coupling=coupling,\n", + ")\n", + "transfer_time = np.pi / (np.sqrt(2) * coupling)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "We use $\\hbar=1$ and measure frequencies and rates in units of $g$, so time is\n", + "in units of $1/g$. The frequency arguments enter the Hamiltonian directly:\n", + "convert ordinary frequencies to angular frequencies before supplying dimensional\n", + "values. YAQS adds no factor of $2\\pi$.\n", + "\n", + "On resonance, $T=\\pi/(\\sqrt{2}g)$ estimates the first complete transfer in the\n", + "rotating-wave approximation. The factory retains counter-rotating terms, so\n", + "transfer at this time is approximate and total excitation is not exactly\n", + "conserved. This is a model of excitation transfer; one prepared state does not\n", + "validate a SWAP gate on arbitrary inputs.\n", + "\n", + "## 2. Excite the left transmon\n", + "\n", + "Start in $|100\\rangle$: the left transmon is excited, while the resonator and\n", + "right transmon start in their ground states. Characters in `basis_string` follow\n", + "site order, starting at site 0." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import State\n", + "\n", + "state = State(\n", + " 3, initial=\"basis\", basis_string=\"100\", physical_dimensions=physical_dimensions,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "The explicit dimensions must match the Hamiltonian. YAQS uses an MPS by default\n", + "and supports different local dimensions within the same chain.\n", + "\n", + "## 3. Choose populations and the time grid\n", + "\n", + "Use local matrix observables to measure each site's $|1\\rangle$ population. On\n", + "the transmons, also measure $|2\\rangle$ population to detect leakage from the\n", + "computational subspace. Mean occupation $\\langle n\\rangle$ on all three sites\n", + "lets us track total excitation." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import AnalogSimParams, Observable\n", + "\n", + "observables = [\n", + " Observable(np.diag(np.arange(dim) == 1).astype(float), site)\n", + " for site, dim in enumerate(physical_dimensions)\n", + "]\n", + "observables += [Observable(np.diag([0.0, 0.0, 1.0]), site) for site in (0, 2)]\n", + "observables += [\n", + " Observable(np.diag(np.arange(dim)).astype(float), site)\n", + " for site, dim in enumerate(physical_dimensions)\n", + "]\n", + "params = AnalogSimParams(\n", + " observables=observables,\n", + " elapsed_time=transfer_time,\n", + " dt=transfer_time / 80,\n", + " order=2,\n", + " num_traj=24,\n", + " preset=\"balanced\",\n", + " random_seed=7,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "Each matrix matches its site's dimension. The observable order is three\n", + "$|1\\rangle$ populations, two transmon $|2\\rangle$ populations, then three mean\n", + "occupations. Binary bitstring observables and shot counts require an all-qubit\n", + "state; local matrix observables also work with higher levels.\n", + "\n", + "The grid contains 81 samples over one transfer interval. The `balanced` preset\n", + "sets numerical tolerances; `order=2` selects second-order TJM for noisy runs.\n", + "Each noisy calculation averages 24 trajectories. Sampling error, timestep error,\n", + "and the chosen level cutoffs need separate convergence checks.\n", + "\n", + "## 4. Follow the noiseless transfer\n", + "\n", + "Initialize the simulator separately and omit a noise model for the baseline.\n", + "YAQS preserves the input state, so subsequent runs can reuse the same\n", + "preparation." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Right transmon population at T: 0.996\n" + ] + } + ], + "source": [ + "from mqt.yaqs import Simulator\n", + "\n", + "simulator = Simulator(show_progress=False)\n", + "noiseless = simulator.run(state, hamiltonian, params)\n", + "times = noiseless.times\n", + "values = np.asarray(noiseless.expectation_values)\n", + "print(f\"Right transmon population at T: {values[2, -1]:.3f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "`values` has shape `(8, 81)`: observables by sampled times. The first three rows\n", + "show the excitation moving through the chain. Rows 3 and 4 measure leakage on\n", + "the left and right transmons. Their sum is the expected number of transmons in\n", + "$|2\\rangle$, rather than the probability that either transmon has leaked. The\n", + "resonator's higher photon states are not qubit leakage." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:39:47.238793\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " /\n", + " t\n", + " T\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " |\n", + " 1\n", + " ⟩\n", + "  \n", + " p\n", + " o\n", + " p\n", + " u\n", + " l\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) Excitation transfer\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Left transmon\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Resonator\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Right transmon\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " /\n", + " t\n", + " T\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " |\n", + " 2\n", + " ⟩\n", + "  \n", + " p\n", + " o\n", + " p\n", + " u\n", + " l\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " (\n", + " 1\n", + " 0\n", + " )\n", + " −\n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b) Transmon leakage\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Left transmon\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Right transmon\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\", \"font.serif\": [\"STIXGeneral\"], \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 10, \"axes.labelsize\": 11, \"axes.linewidth\": 0.8,\n", + " \"xtick.direction\": \"in\", \"ytick.direction\": \"in\", \"svg.fonttype\": \"none\",\n", + " \"legend.frameon\": False,\n", + "})\n", + "scaled_times = times / transfer_time\n", + "site_labels = [\"Left transmon\", \"Resonator\", \"Right transmon\"]\n", + "site_colors = [\"#D55E00\", \"0.5\", \"#0072B2\"]\n", + "fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.9), layout=\"constrained\")\n", + "for site, label, color in zip(range(3), site_labels, site_colors, strict=True):\n", + " axes[0].plot(scaled_times, values[site], color=color, linewidth=1.8, label=label)\n", + "for row, label, color in ((3, \"Left transmon\", site_colors[0]), (4, \"Right transmon\", site_colors[2])):\n", + " axes[1].plot(scaled_times, 1e3 * values[row], color=color, linewidth=1.4, label=label)\n", + "axes[0].set(ylabel=r\"$|1\\rangle$ population\", ylim=(0, 1.05))\n", + "axes[1].set(ylabel=r\"$|2\\rangle$ population ($10^{-3}$)\")\n", + "for ax, title in zip(axes, [\"(a) Excitation transfer\", \"(b) Transmon leakage\"], strict=True):\n", + " ax.set(xlabel=r\"Time $t/T$\", xlim=(0, 1))\n", + " ax.set_title(title, loc=\"left\", fontsize=11)\n", + " ax.legend(fontsize=8)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "**The resonator mediates transfer between the transmons.** The resonator\n", + "population rises and falls while the receiving transmon approaches unit\n", + "population near $T$. Small, rapid excursions into $|2\\rangle$ remain visible on\n", + "the separate linear scale. The full dipole interaction permits these excursions\n", + "even from a single-excitation preparation. The plotted receiver population does\n", + "not measure the fidelity of an arbitrary transferred quantum state.\n", + "\n", + "## 5. Add multilevel relaxation and dephasing\n", + "\n", + "The built-in `lowering` and `pauli_z` channels are two-dimensional. For a\n", + "three-level transmon, supply explicit matrices. The annihilation matrix $b$\n", + "relaxes $|1\\rangle$ to $|0\\rangle$ and $|2\\rangle$ to $|1\\rangle$, with the\n", + "oscillator's $\\sqrt{2}$ matrix element. The number operator $n$ produces pure\n", + "dephasing without directly changing populations." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import NoiseModel\n", + "\n", + "lowering = np.diag(np.sqrt(np.arange(1, qubit_dim)), k=1)\n", + "number = np.diag(np.arange(qubit_dim)).astype(float)\n", + "relaxation_rates = coupling * np.array([0.05, 0.15, 0.6])\n", + "results = {0.0: noiseless}\n", + "for rate in relaxation_rates:\n", + " noise = NoiseModel(\n", + " [{\"name\": \"relaxation\", \"sites\": [site], \"strength\": rate, \"matrix\": lowering}\n", + " for site in (0, 2)]\n", + " + [{\"name\": \"dephasing\", \"sites\": [site], \"strength\": 2 * rate, \"matrix\": number}\n", + " for site in (0, 2)]\n", + " )\n", + " results[rate] = simulator.run(state, hamiltonian, params, noise)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "`strength` is a Lindblad rate: YAQS multiplies each supplied matrix by\n", + "`sqrt(strength)`. Here the jumps are $\\sqrt{\\gamma}\\,b$ and $\\sqrt{2\\gamma}\\,n$.\n", + "For an isolated transmon's $|0\\rangle$–$|1\\rangle$ transition, the relaxation\n", + "and pure-dephasing times are both $1/\\gamma$. The sweep increases both channels\n", + "together and leaves the resonator noise-free. These deliberately short coherence\n", + "times make the competition with transfer visible; the parameters are\n", + "illustrative, rather than a fit to a device.\n", + "\n", + "Parallel execution is enabled by default. The documentation suppresses progress\n", + "bars with `show_progress=False`; omit that setting to see progress. The seed\n", + "fixes random streams for repeated runs with the same configuration." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-13", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:40:18.099931\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " /\n", + " t\n", + " T\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Left\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Resonator\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Right\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) No noise\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " /\n", + " t\n", + " T\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Left\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Resonator\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Right\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (\n", + " b\n", + " )\n", + "  \n", + " /\n", + " =\n", + " 0\n", + " .\n", + " 0\n", + " 5\n", + " γ\n", + " g\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " /\n", + " t\n", + " T\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Left\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Resonator\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Right\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (\n", + " c\n", + " )\n", + "  \n", + " /\n", + " =\n", + " 0\n", + " .\n", + " 1\n", + " 5\n", + " γ\n", + " g\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " /\n", + " t\n", + " T\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Left\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Resonator\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Right\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (\n", + " d\n", + " )\n", + "  \n", + " /\n", + " =\n", + " 0\n", + " .\n", + " 6\n", + " γ\n", + " g\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " /\n", + " t\n", + " T\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.25\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.50\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.75\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " R\n", + " i\n", + " g\n", + " h\n", + " t\n", + "  \n", + " t\n", + " r\n", + " a\n", + " n\n", + " s\n", + " m\n", + " o\n", + " n\n", + "  \n", + " |\n", + " 1\n", + " ⟩\n", + "  \n", + " p\n", + " o\n", + " p\n", + " u\n", + " l\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (e) Received excitation\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " No noise\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " γ\n", + " g\n", + " /\n", + " =\n", + " 0\n", + " .\n", + " 0\n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " γ\n", + " g\n", + " /\n", + " =\n", + " 0\n", + " .\n", + " 1\n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " γ\n", + " g\n", + " /\n", + " =\n", + " 0\n", + " .\n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " /\n", + " t\n", + " T\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.25\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.50\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.75\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " o\n", + " t\n", + " a\n", + " l\n", + "  \n", + " e\n", + " x\n", + " c\n", + " i\n", + " t\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " ∑\n", + " ⟨\n", + " ⟩\n", + " i\n", + " i\n", + " n\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (f) Excitation remaining\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " |\n", + " 1\n", + " ⟩\n", + "  \n", + " p\n", + " o\n", + " p\n", + " u\n", + " l\n", + " a\n", + " t\n", + " i\n", + " o\n", + " n\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib.colors import Normalize\n", + "\n", + "colors = [\"0.2\", \"#56B4E9\", \"#0072B2\", \"#D55E00\"]\n", + "fig, axes = plt.subplots(3, 2, figsize=(7.2, 6.6), layout=\"constrained\")\n", + "for index, (ax, (rate, result)) in enumerate(zip(axes[:2].flat, results.items(), strict=True)):\n", + " means = np.asarray(result.expectation_values)\n", + " image = ax.pcolormesh(scaled_times, np.arange(3), means[:3], shading=\"auto\",\n", + " cmap=\"cividis\", norm=Normalize(0, 1), rasterized=True)\n", + " title = \"(a) No noise\" if index == 0 else rf\"({chr(97 + index)}) $\\gamma/g={rate / coupling:g}$\"\n", + " ax.set(xlabel=r\"Time $t/T$\", yticks=[0, 1, 2], yticklabels=[\"Left\", \"Resonator\", \"Right\"], xlim=(0, 1))\n", + " ax.set_title(title, loc=\"left\", fontsize=11)\n", + "fig.colorbar(image, ax=list(axes[:2].flat), label=r\"$|1\\rangle$ population\", ticks=[0, 0.5, 1], shrink=0.8)\n", + "\n", + "for (rate, result), color in zip(results.items(), colors, strict=True):\n", + " means = np.asarray(result.expectation_values)\n", + " trajectories = np.asarray(result.trajectories)\n", + " label = \"No noise\" if rate == 0 else rf\"$\\gamma/g={rate / coupling:g}$\"\n", + " for ax, mean, samples in (\n", + " (axes[2, 0], means[2], trajectories[2]),\n", + " (axes[2, 1], means[5:8].sum(axis=0), trajectories[5:8].sum(axis=0)),\n", + " ):\n", + " ax.plot(scaled_times, mean, color=color, linewidth=1.8, label=label)\n", + " if samples.shape[0] > 1:\n", + " standard_error = samples.std(axis=0, ddof=1) / np.sqrt(samples.shape[0])\n", + " ax.fill_between(scaled_times, mean - standard_error, mean + standard_error, color=color, alpha=0.15)\n", + "axes[2, 0].set(ylabel=r\"Right transmon $|1\\rangle$ population\")\n", + "axes[2, 1].set(ylabel=r\"Total excitation $\\sum_i\\langle n_i\\rangle$\")\n", + "for ax, title in zip(axes[2], [\"(e) Received excitation\", \"(f) Excitation remaining\"], strict=True):\n", + " ax.set(xlabel=r\"Time $t/T$\", xlim=(0, 1), ylim=(0, 1.08))\n", + " ax.set_title(title, loc=\"left\", fontsize=11)\n", + "axes[2, 0].legend(fontsize=8, loc=\"upper left\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "**Stronger noise suppresses transfer to the right transmon.** Relaxation removes\n", + "excitation, while dephasing disrupts the coherent exchange through the\n", + "resonator. The lower panels separate received population from total excitation;\n", + "the latter can have small coherent excursions because of the counter-rotating\n", + "terms. All heatmaps use one color scale. Shading shows one standard error from\n", + "the trajectory samples, with sites summed within each trajectory before\n", + "estimating uncertainty in total excitation. These bands measure sampling\n", + "uncertainty, not numerical or level-cutoff error.\n", + "\n", + "## Further options\n", + "\n", + "Increase `num_traj` to reduce sampling fluctuations and refine `dt` and\n", + "numerical tolerances to check propagation accuracy. Increase `qubit_dim` and\n", + "`resonator_dim` separately to check level truncation, rebuilding the state,\n", + "observables, and jump matrices to match. Three transmon levels suffice to\n", + "illustrate leakage here; they do not establish convergence for a driven device.\n", + "\n", + "To add photon loss, supply a resonator-sized annihilation matrix at site 1.\n", + "Other custom channels and distributed strengths are described in\n", + "{doc}`realistic_noise_models`. See {doc}`hamiltonians` for longer alternating\n", + "chains, {doc}`state_initialization` for other preparations, and\n", + "{doc}`simulation_parameters` for accuracy and trajectory settings." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 34, + 53, + 72, + 78, + 90, + 111, + 129, + 137, + 145, + 172, + 189, + 204, + 218, + 252 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/examples/trapped_ion.ipynb b/docs/_outputs/examples/trapped_ion.ipynb new file mode 100644 index 000000000..b9eba7e23 --- /dev/null +++ b/docs/_outputs/examples/trapped_ion.ipynb @@ -0,0 +1,4058 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Trapped Ion Emulation\n", + "\n", + "Moving a trapped ion changes its position, but can also leave it oscillating\n", + "after the trap stops. Random force impulses add another source of motion. We\n", + "first follow a displaced wavepacket in a fixed harmonic well, then move the well\n", + "and compare several noise strengths. Position distributions and motional energy\n", + "show how coherent transport excitation differs from heating.\n", + "\n", + "This guide uses the standard YAQS installation and Matplotlib. Run the cells in\n", + "order in a notebook. For a script, use the entry-point guard in\n", + "{doc}`simulator_initialization`.\n", + "\n", + "## 1. Build a harmonic trap on a position grid\n", + "\n", + "Each ion occupies one MPO site, whose local basis consists of position-grid\n", + "points. `MPO.trapped_ion` combines a finite-difference kinetic operator with a\n", + "harmonic potential centered at `trap_center`. We use one ion and dimensionless\n", + "units with $\\hbar=m=\\omega=1$. Position is in oscillator-length units and time\n", + "is in units of $1/\\omega$." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from mqt.yaqs import Hamiltonian, MPO, State\n", + "\n", + "positions = np.linspace(-8.0, 8.0, 65)\n", + "grid_dim = len(positions)\n", + "grid_spacing = positions[1] - positions[0]\n", + "omega = 1.0\n", + "\n", + "\n", + "def trap_at(center):\n", + " return Hamiltonian.from_mpo(\n", + " MPO.trapped_ion(positions, masses=[1.0], omega=omega, trap_center=center)\n", + " )\n", + "\n", + "\n", + "def state_at(center):\n", + " packet = np.exp(-0.5 * (positions - center) ** 2).astype(complex)\n", + " packet /= np.linalg.norm(packet)\n", + " return State(\n", + " 1, tensors=[packet.reshape(grid_dim, 1, 1)], physical_dimensions=[grid_dim],\n", + " )\n", + "\n", + "\n", + "static_hamiltonian = trap_at(0.0)\n", + "static_state = state_at(1.0)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "The normalized Gaussian approximates a displaced oscillator ground state. Its\n", + "components are amplitudes on the finite grid, so their squared magnitudes sum to\n", + "one. A single MPS tensor has shape `(grid_dim, 1, 1)`: one physical index and\n", + "two bond indices. This representation supports both the fixed and moving\n", + "Hamiltonians below. Supplying `vector=` instead selects the MCWF backend, which\n", + "does not support piecewise Hamiltonians.\n", + "\n", + "## 2. Follow an oscillating wavepacket\n", + "\n", + "Measure the mean position and the population of every grid point. The latter\n", + "uses projectors $|x_j\\rangle\\langle x_j|$ and gives a position distribution at\n", + "each sampled time, without requesting a sequence of output states." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import AnalogSimParams, Observable, Simulator\n", + "\n", + "position_observable = Observable(\"position\", 0, positions=positions)\n", + "grid_projectors = [Observable(np.diag(row), 0) for row in np.eye(grid_dim)]\n", + "period = 2 * np.pi / omega\n", + "static_params = AnalogSimParams(\n", + " observables=[position_observable, *grid_projectors],\n", + " elapsed_time=period,\n", + " dt=period / 80,\n", + " preset=\"balanced\",\n", + ")\n", + "simulator = Simulator(show_progress=False)\n", + "static_result = simulator.run(static_state, static_hamiltonian, static_params)\n", + "static_times = static_result.times\n", + "static_values = np.asarray(static_result.expectation_values)\n", + "static_density = static_values[1:] / grid_spacing" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "`expectation_values` follows the supplied observable order. Stacking these\n", + "arrays gives shape `(66, 81)`: the mean position, then 65 grid populations.\n", + "Divide populations by the grid spacing to plot probability density per unit\n", + "position. In the continuum, the mean follows $\\langle x(t)\\rangle=\\cos t$. That\n", + "curve is a useful comparison; the finite-difference Hamiltonian differs slightly\n", + "from the continuum oscillator." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-5", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:40:22.505079\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " P\n", + " o\n", + " s\n", + " i\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " x\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) Position distribution and mean\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " π\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " M\n", + " e\n", + " a\n", + " n\n", + "  \n", + " p\n", + " o\n", + " s\n", + " i\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " ⟨\n", + " ⟩\n", + " x\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (b) Oscillation in a fixed well\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " YAQS\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Continuum\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " Probability density\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib_inline.backend_inline import set_matplotlib_formats\n", + "\n", + "set_matplotlib_formats(\"svg\")\n", + "plt.rcParams.update({\n", + " \"font.family\": \"serif\", \"font.serif\": [\"STIXGeneral\"], \"mathtext.fontset\": \"stix\",\n", + " \"font.size\": 10, \"axes.labelsize\": 11, \"axes.linewidth\": 0.8,\n", + " \"xtick.direction\": \"in\", \"ytick.direction\": \"in\", \"svg.fonttype\": \"none\",\n", + " \"legend.frameon\": False,\n", + "})\n", + "fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.9), layout=\"constrained\")\n", + "image = axes[0].pcolormesh(\n", + " static_times, positions, static_density, shading=\"auto\", cmap=\"cividis\",\n", + " vmin=0, vmax=0.6, rasterized=True,\n", + ")\n", + "axes[0].plot(static_times, static_values[0], color=\"white\", linewidth=1.3)\n", + "axes[0].set(ylabel=r\"Position $x$\", ylim=(-3, 3))\n", + "axes[0].set_title(\"(a) Position distribution and mean\", loc=\"left\", fontsize=11)\n", + "fig.colorbar(image, ax=axes[0], label=\"Probability density\", shrink=0.85)\n", + "axes[1].plot(static_times, static_values[0], color=\"#0072B2\", linewidth=1.8, label=\"YAQS\")\n", + "axes[1].plot(static_times, np.cos(static_times), color=\"0.4\", linestyle=\"--\", label=\"Continuum\")\n", + "axes[1].set(ylabel=r\"Mean position $\\langle x\\rangle$\", ylim=(-1.15, 1.15))\n", + "axes[1].set_title(\"(b) Oscillation in a fixed well\", loc=\"left\", fontsize=11)\n", + "axes[1].legend(fontsize=9)\n", + "for ax in axes:\n", + " ax.set(xlabel=r\"Time $t$\", xlim=(0, period), xticks=[0, np.pi, 2 * np.pi],\n", + " xticklabels=[\"0\", r\"$\\pi$\", r\"$2\\pi$\"])\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "**The packet oscillates about the trap center.** Its mean nearly follows the\n", + "continuum curve over one period. The heatmap contains the actual YAQS grid\n", + "populations; the white line marks their mean. Small changes in shape and phase\n", + "reflect the finite grid and its kinetic operator.\n", + "\n", + "## 3. Move the well, then hold it fixed\n", + "\n", + "Start a new packet at $q=-1$ and translate the well to $q=1$ over four time\n", + "units. The control is a staircase: each trap center remains fixed for `dt=0.1`,\n", + "followed by a four-unit hold at the target. This deliberately simple protocol\n", + "leaves enough residual motion to see in the position distribution." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-7", + "metadata": {}, + "outputs": [], + "source": [ + "start_center = -1.0\n", + "target_center = 1.0\n", + "transport_duration = 4.0\n", + "hold_duration = 4.0\n", + "dt = 0.1\n", + "n_transport = round(transport_duration / dt)\n", + "transport_centers = np.linspace(start_center, target_center, n_transport, endpoint=False)\n", + "target_hamiltonian = trap_at(target_center)\n", + "moving_hamiltonian = Hamiltonian.piecewise([\n", + " *[(trap_at(center), dt) for center in transport_centers],\n", + " (target_hamiltonian, hold_duration),\n", + "])\n", + "transport_state = state_at(start_center)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "`Hamiltonian.piecewise` selects the well for each interval. Piece durations must\n", + "be integer multiples of the simulation timestep, and their sum must equal\n", + "`elapsed_time`. Piecewise evolution currently requires an MPS and TDVP, which\n", + "are used here. Reducing `dt` while rebuilding the staircase also changes the\n", + "control waveform; it is a separate check from refining the spatial grid.\n", + "\n", + "## 4. Measure residual motion and heating\n", + "\n", + "Alongside the mean and grid populations, measure $\\langle x^2\\rangle$ and the\n", + "energy of the final well. The ensemble position width is\n", + "$\\sigma_x=\\sqrt{\\langle x^2\\rangle-\\langle x\\rangle^2}$. During the hold, the\n", + "final-well energy measures motion left by transport and added by noise." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "transport_params = AnalogSimParams(\n", + " observables=[\n", + " position_observable,\n", + " Observable(np.diag(positions**2), 0),\n", + " Observable(target_hamiltonian.to_matrix(), 0),\n", + " *grid_projectors,\n", + " ],\n", + " elapsed_time=transport_duration + hold_duration,\n", + " dt=dt,\n", + " order=2,\n", + " num_traj=32,\n", + " preset=\"balanced\",\n", + " random_seed=7,\n", + ")\n", + "noiseless = simulator.run(transport_state, moving_hamiltonian, transport_params)\n", + "times = noiseless.times" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "The observable arrays have shape `(68, 81)`: mean position, mean squared\n", + "position, final-well energy, then grid populations. The `balanced` preset sets\n", + "numerical tolerances. Second-order TJM averages 32 trajectories for each noisy\n", + "run below; the noiseless baseline needs only one trajectory.\n", + "\n", + "## 5. Add random momentum kicks\n", + "\n", + "Model random force impulses as momentum kicks in either direction. Multiplying\n", + "the wavefunction by $e^{\\pm i\\kappa x}$ shifts its momentum by $\\pm\\kappa$ in\n", + "our units. The two custom jumps are $L_\\pm=\\sqrt{\\gamma/2}\\,e^{\\pm i\\kappa X}$,\n", + "where $X=\\operatorname{diag}(x_j)$ and $\\kappa=1$. Equal rates give no preferred\n", + "direction. The kicks heat the ion without friction or thermal relaxation." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "source": [ + "from mqt.yaqs import NoiseModel\n", + "\n", + "noise_rates = [0.1, 0.4, 1.0]\n", + "kick_size = 1.0\n", + "results = {0.0: noiseless}\n", + "for rate in noise_rates:\n", + " noise = NoiseModel([\n", + " {\"name\": \"momentum_kick\", \"sites\": [0], \"strength\": rate / 2,\n", + " \"matrix\": np.diag(np.exp(1j * sign * kick_size * positions))}\n", + " for sign in (-1, 1)\n", + " ])\n", + " results[rate] = simulator.run(transport_state, moving_hamiltonian, transport_params, noise)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "YAQS multiplies each supplied matrix by `sqrt(strength)`. Each direction has\n", + "rate $\\gamma/2$, so $\\gamma$ is the total kick rate in oscillator units. The\n", + "noise acts throughout transport and the hold, while the initial state, control\n", + "waveform, time grid, and numerical settings stay fixed. The stronger rates make\n", + "spreading visible over this short protocol. The grid extends to $x=\\pm8$ to\n", + "leave room for the heated packet.\n", + "\n", + "Parallel execution is enabled by default. The documentation suppresses progress\n", + "bars with `show_progress=False`; omit that setting to see progress. The seed\n", + "fixes random streams for the same configuration, and each run preserves the\n", + "input state." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-13", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " 2026-10-10T22:40:58.370729\n", + " image/svg+xml\n", + " \n", + " \n", + " Matplotlib v3.11.2, https://matplotlib.org/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " P\n", + " o\n", + " s\n", + " i\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " x\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (a) No noise\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Trap center\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Mean position\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " P\n", + " o\n", + " s\n", + " i\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " x\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (\n", + " b\n", + " )\n", + "  \n", + " =\n", + " 0\n", + " .\n", + " 1\n", + " γ\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " P\n", + " o\n", + " s\n", + " i\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " x\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (\n", + " c\n", + " )\n", + "  \n", + " =\n", + " 0\n", + " .\n", + " 4\n", + " γ\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " −5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " P\n", + " o\n", + " s\n", + " i\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " x\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (\n", + " d\n", + " )\n", + "  \n", + " =\n", + " 1\n", + " γ\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.75\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.25\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.50\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1.75\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2.00\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " P\n", + " o\n", + " s\n", + " i\n", + " t\n", + " i\n", + " o\n", + " n\n", + "  \n", + " w\n", + " i\n", + " d\n", + " t\n", + " h\n", + "  \n", + " σ\n", + " x\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (e) Ensemble position spread\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " No noise\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " γ\n", + " =\n", + " 0\n", + " .\n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " γ\n", + " =\n", + " 0\n", + " .\n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " γ\n", + " =\n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 6\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 7\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 8\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " T\n", + " i\n", + " m\n", + " e\n", + "  \n", + " t\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " E\n", + " n\n", + " e\n", + " r\n", + " g\n", + " y\n", + "  \n", + " ⟨\n", + " (\n", + " =\n", + " 1\n", + " )\n", + " ⟩\n", + " H\n", + " q\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " (f) Motional energy during the hold\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.0\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.1\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.2\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.3\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.4\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.5\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " 0.6\n", + " \n", + " \n", + " \n", + " Probability density\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib.colors import Normalize\n", + "\n", + "colors = [\"0.2\", \"#56B4E9\", \"#0072B2\", \"#D55E00\"]\n", + "n_hold = round(hold_duration / dt)\n", + "scheduled_centers = np.r_[transport_centers, np.full(n_hold + 1, target_center)]\n", + "hold_mask = times >= transport_duration\n", + "fig, axes = plt.subplots(3, 2, figsize=(7.2, 7.1), layout=\"constrained\")\n", + "for index, (ax, (rate, result)) in enumerate(zip(axes[:2].flat, results.items(), strict=True)):\n", + " means = np.asarray(result.expectation_values)\n", + " density = means[3:] / grid_spacing\n", + " image = ax.pcolormesh(times, positions, density, shading=\"auto\", cmap=\"cividis\",\n", + " norm=Normalize(0, 0.6), rasterized=True)\n", + " ax.step(times, scheduled_centers, where=\"post\", color=\"white\", linestyle=\"--\",\n", + " linewidth=1.1, label=\"Trap center\")\n", + " ax.plot(times, means[0], color=\"#E69F00\", linewidth=1.2, label=\"Mean position\")\n", + " title = \"(a) No noise\" if index == 0 else rf\"({chr(97 + index)}) $\\gamma={rate:g}$\"\n", + " ax.set(xlabel=r\"Time $t$\", ylabel=r\"Position $x$\", xlim=(0, times[-1]), ylim=(-8, 8))\n", + " ax.set_title(title, loc=\"left\", fontsize=11)\n", + " if index == 0:\n", + " ax.legend(loc=\"upper left\", fontsize=8, labelcolor=\"white\")\n", + "fig.colorbar(image, ax=list(axes[:2].flat), label=\"Probability density\", shrink=0.8)\n", + "\n", + "for (rate, result), color in zip(results.items(), colors, strict=True):\n", + " means = np.asarray(result.expectation_values)\n", + " samples = result.trajectories[2]\n", + " energy_se = samples.std(axis=0, ddof=1) / np.sqrt(len(samples)) if len(samples) > 1 else np.zeros_like(times)\n", + " width = np.sqrt(means[1] - means[0] ** 2)\n", + " label = \"No noise\" if rate == 0 else rf\"$\\gamma={rate:g}$\"\n", + " axes[2, 0].plot(times, width, color=color, linewidth=1.7, label=label)\n", + " axes[2, 1].plot(times[hold_mask], means[2, hold_mask], color=color, linewidth=1.7)\n", + " axes[2, 1].fill_between(times[hold_mask], (means[2] - energy_se)[hold_mask],\n", + " (means[2] + energy_se)[hold_mask], color=color, alpha=0.15, linewidth=0)\n", + "axes[2, 0].axvline(transport_duration, color=\"0.5\", linestyle=\":\", linewidth=1)\n", + "axes[2, 0].set(xlabel=r\"Time $t$\", ylabel=r\"Position width $\\sigma_x$\", xlim=(0, times[-1]))\n", + "axes[2, 0].set_title(\"(e) Ensemble position spread\", loc=\"left\", fontsize=11)\n", + "axes[2, 0].legend(fontsize=8, ncol=2)\n", + "axes[2, 1].set(xlabel=r\"Time $t$\", ylabel=r\"Energy $\\langle H(q=1)\\rangle$\",\n", + " xlim=(transport_duration, times[-1]))\n", + "axes[2, 1].set_title(\"(f) Motional energy during the hold\", loc=\"left\", fontsize=11)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "**Transport leaves a coherent oscillation; random kicks broaden the packet and\n", + "raises its energy.** After $t=4$, the dashed trap center stays fixed while the\n", + "mean position continues to oscillate. Without noise, the packet remains narrow\n", + "and its energy stays constant during the hold. Stronger noise spreads the\n", + "position distribution and increases the motional energy. All heatmaps share one\n", + "color scale.\n", + "\n", + "The width in panel (e) describes the ensemble position distribution, including\n", + "variation between trajectories. It is not an error bar on the mean position.\n", + "Shading in panel (f) shows one standard error of the trajectory-averaged energy.\n", + "Thirty-two trajectories make the trend visible, but the curves retain sampling\n", + "fluctuations. Increase `num_traj` to resolve smaller differences.\n", + "\n", + "## 6. Adapt the model\n", + "\n", + "Check grid spacing and boundaries before interpreting a quantitative result. The\n", + "kinetic operator uses zero exterior boundary values, and a heated packet can\n", + "reach the edges. Refine the grid and enlarge its range separately. The continuum\n", + "Gaussian is also only an approximate ground state of the discrete Hamiltonian.\n", + "For dimensional inputs, pass `hbar` to `MPO.trapped_ion` in units consistent\n", + "with the masses, positions, and angular frequency. The kinetic term depends on\n", + "$\\hbar^2$. For time in seconds, divide one MPO core by $\\hbar$ with\n", + "`mpo.tensors[0] /= hbar` before wrapping it with `Hamiltonian.from_mpo`. This\n", + "converts the energy operator to $H/\\hbar$ for YAQS's evolution convention\n", + "$\\exp(-iH\\,dt)$.\n", + "\n", + "A slower or smoother transport protocol can reduce coherent residual motion. The\n", + "kick model isolates heating from random impulses. Other noise processes require\n", + "suitable jump operators; this model does not describe cooling. The factory\n", + "supports one or two ions, with a softened Coulomb interaction for two ions. It\n", + "describes motional dynamics on a position grid, rather than internal spin states\n", + "or a full laser-driven gate model.\n", + "\n", + "For a noiseless run, `get_state=True` also returns the final state. Noisy MPS\n", + "runs return ensemble observables and trajectory data instead of a single final\n", + "pure state. Grid projectors remain available for both cases, as shown above.\n", + "\n", + "## Related guides\n", + "\n", + "- {doc}`hamiltonians` — trap parameters, two-ion interactions, and piecewise\n", + " models.\n", + "- {doc}`analog_simulation` — noisy evolution, accuracy, and trajectory sampling.\n", + "- {doc}`state_initialization` — custom local dimensions and manual MPS tensors.\n", + "- {doc}`transmon_emulation` — excitation transfer in a multilevel hardware\n", + " model." + ] + } + ], + "metadata": { + "file_format": "mystnb", + "kernelspec": { + "display_name": "python3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + }, + "mystnb": { + "execution_timeout": 300, + "number_source_lines": true + }, + "source_map": [ + 10, + 32, + 59, + 74, + 91, + 100, + 130, + 144, + 158, + 173, + 190, + 205, + 218, + 232, + 274 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/_outputs/manifest.json b/docs/_outputs/manifest.json new file mode 100644 index 000000000..eeafcfea2 --- /dev/null +++ b/docs/_outputs/manifest.json @@ -0,0 +1,85 @@ +{ + "runtime": "26a68889dee2dd035426197b92930edf467dc2231aaf0617087d9a60a95a894b", + "environment": { + "python": "3.14.2", + "mqt.yaqs": "0.6.1.dev306+gd3f16427f.d20261010", + "numpy": "2.5.4", + "scipy": "1.18.1", + "qiskit": "2.5.2", + "myst-nb": "1.4.0" + }, + "notebooks": { + "examples/analog_simulation.md": { + "inputs": "82e212babe360ab5e463ec8df7e4cf20fd50ed1e332280d85f1ae3da77782f2a", + "sha256": "da7932f0aa5ad7ddbb88c6c202a14913458d4c2c30393fa5cb76018d950edf6d" + }, + "examples/characterization.md": { + "inputs": "76a808e463c921cd3f2615d718ab8a4eee344cb79f379919cd2b7d08bb4702a3", + "sha256": "c4c892043be034706de59be6342fe4f20a4f0b0838184cd331e26305d7583eb2" + }, + "examples/circuit_observables.md": { + "inputs": "001b16f304bebc81a638bcf7f8825782b2635fe99567e2ccc78d5abd54f462e3", + "sha256": "ccb255261f53b9ef06b7a8ea29ca34a46451e19775d29be4738dcf473f517ed5" + }, + "examples/circuit_shots.md": { + "inputs": "c70da7ba94370119b025bd136ef95dfd969804f2daad264b6d43db279bf22211", + "sha256": "bafba85bc2805db14ff545fa9bbd94371ebd5dfb611656eafc92c46d34e1c016" + }, + "examples/digital_analog_simulation.md": { + "inputs": "e50dae39c76469ecf9200247f527e75c3b61a47ceff3b84f55f94ead817a7b4a", + "sha256": "381ad238d82cc4633974f40fa86339399109fe5cb34c64a92fd328c4e57f98b3" + }, + "examples/digital_twin.md": { + "inputs": "15b9a88f309282017c5f4778262cd10ea4dbe578d81ff237447e8ebae2bd002a", + "sha256": "1562cee35f913fc910e0e5210b1f3213b2ea123a9ada6ce3f8bd7cbf8ac69f72" + }, + "examples/ensemble_evolution.md": { + "inputs": "7248268da7bafd0bd135dbcc7dba71e3a5f445a813759f4e9211c75104a0891b", + "sha256": "c44717678def06e333bbfe61cdbcdb5680d402931563a2d0b7efee5f4473cf78" + }, + "examples/equivalence_checking.md": { + "inputs": "2699f078a801ab9cc39fc02a2918c5f19a1a8c310b501e587f743972d980f6dd", + "sha256": "4fbd529ced5be38d33cccb5f1384af6778d2aa0f51c1588b7a376c25d00f41bc" + }, + "examples/hamiltonians.md": { + "inputs": "b62224074d85fb4e0c277e52e379c1a083b2611ea45b0da0aea6bd41ab9ad8d4", + "sha256": "e7acd992c5ae7b6569bea733b774b07f81a81904c6cde3557b64eb83009f26ea" + }, + "examples/memory_surrogate.md": { + "inputs": "24601f1e39b1f4615c947c5bbd1e3329b3d2fff76f94969c3d85d8a87352d98f", + "sha256": "003a5c27c843f7e1a744307652e782e138910f153724f02cbba03f047eef37bc" + }, + "examples/quickstart.md": { + "inputs": "13df95d9944015b3b5ab87a820c58b35897b572c0497cd444f05f8c209bca245", + "sha256": "2c0ddd9d86ed51264dbee2d278d5456176f5ff4daa75872dc1c8d7d2e9c7ed46" + }, + "examples/realistic_noise_models.md": { + "inputs": "25f2cb4f20dc93446b340828c1f481fd36a933af2f69a3a0fbaebfcd28f3475f", + "sha256": "0665c26d5c20dfb16f9fb36efae5af8fd7a06d9c240d005b7c738d2804376c83" + }, + "examples/representation_comparison.md": { + "inputs": "98be45e8dbcd5f8031598e4f1da4fb36b8269cd0575b177fed84511577875b93", + "sha256": "f982b824f982a4177e873565aa8ac629d765b076d50540feb310f2587300eac8" + }, + "examples/simulation_parameters.md": { + "inputs": "491837e1260626ff1bc5bac53076e8aaf801a1ef46f7032b7d49a9185f557b5e", + "sha256": "fe21a22f1d07ec1e53ea98ef0c52966160c2ae40e4059629a591894c9b8c18ad" + }, + "examples/simulator_initialization.md": { + "inputs": "633cdce6caca17f471a25cd6874451d6d3c52149bf81e3328900e6955fa3ef6e", + "sha256": "2f4788b60ac63e2ec7910cc0b76c33fc0422d92fb6f5b1e1b93f9663c83d1665" + }, + "examples/state_initialization.md": { + "inputs": "c84de05e36fc2d4de745881e6c4a9342060c02c4a6bee91bba9e8653ea0bfec4", + "sha256": "a188d39df83150e4e94d7930a870ee2f1d25375a4a286c9ff0cb1879a6c2cb76" + }, + "examples/transmon_emulation.md": { + "inputs": "17f7c82a1b786008e00bbf555a30e87b2b38c1201e3489a5a9a8d7ba6b189ddd", + "sha256": "4cd86d9a562ad23851fbff0078d68e21895ed728a611762b49345bd95a82960f" + }, + "examples/trapped_ion.md": { + "inputs": "2669cd14c6e706b2dd5a44de291e78d77d457d41522afc662a6a4db694fa8d3a", + "sha256": "9c36e491e5b247c4520ba59c7a9f1e9c91dbfbd9b185a7cfc22ceac578c12b1d" + } + } +} diff --git a/docs/conf.py b/docs/conf.py index b3fbed286..2748cdccc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,6 +10,8 @@ from __future__ import annotations import os +import re +import sys from importlib import metadata from pathlib import Path from typing import TYPE_CHECKING @@ -21,8 +23,22 @@ if TYPE_CHECKING: from pybtex.database import Entry from pybtex.richtext import HRef + from sphinx.application import Sphinx ROOT = Path(__file__).parent.parent.resolve() +sys.path.insert(0, str(Path(__file__).parent / "_ext")) + +# Limit docs kernels and child builds unless the runner supplies a budget. +for name in ( + "MKL_NUM_THREADS", + "NUMBA_NUM_THREADS", + "NUMEXPR_NUM_THREADS", + "OMP_NUM_THREADS", + "OPENBLAS_NUM_THREADS", +): + os.environ.setdefault(name, "1") +# Automatic worker selection reserves one CPU, so this hint permits two workers. +os.environ.setdefault("YAQS_MAX_WORKERS", "3") # Keep matplotlib/font cache writable and local during docs builds. os.environ.setdefault("MPLCONFIGDIR", str(ROOT / "docs" / "_build" / ".mplconfig")) @@ -59,12 +75,16 @@ "sphinx.ext.viewcode", "sphinxcontrib.bibtex", "sphinxext.opengraph", + "yaqs_api", + "yaqs_examples", ] source_suffix = [".rst", ".md"] +nitpicky = True exclude_patterns = [ "_build", + "_outputs", "**.ipynb_checkpoints", "**.jupyter_cache", "**jupyter_execute", @@ -79,7 +99,9 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable/", None), - "qiskit": ("https://docs.quantum.ibm.com/api/qiskit", None), + "scipy": ("https://docs.scipy.org/doc/scipy/", None), + "torch": ("https://docs.pytorch.org/docs/stable/", None), + "qiskit": ("https://quantum.cloud.ibm.com/docs/api/qiskit", None), "mqt": ("https://mqt.readthedocs.io/en/stable", None), "core": ("https://mqt.readthedocs.io/projects/core/en/stable", None), "ddsim": ("https://mqt.readthedocs.io/projects/ddsim/en/stable", None), @@ -104,6 +126,26 @@ nb_execution_mode = "cache" nb_execution_raise_on_error = True +nb_execution_cache_path = os.environ.get("YAQS_DOCS_CACHE", str(ROOT / "docs" / "_build" / ".jupyter_cache")) +# MyST-NB does not know sphinx-llm's builder name. Preserve figures instead of +# selecting only their text representation in the generated Markdown. +nb_mime_priority_overrides = [ + ("llms-markdown", mime, priority) + for priority, mime in enumerate(( + "image/svg+xml", + "image/png", + "image/jpeg", + "image/gif", + "text/markdown", + "text/latex", + "text/html", + "text/plain", + )) +] + +# Reuse HTML doctrees and notebook outputs when generating the Markdown files. +llms_txt_build_parallel = False +llms_txt_full_build = True class CDAStyle(UnsrtStyle): @@ -139,17 +181,20 @@ def format_url(self, _e: Entry) -> HRef: # ruff:ignore[no-self-use] ] autoapi_options = [ "members", - "imported-members", "show-inheritance", "special-members", "undoc-members", ] -autoapi_keep_files = True +# Do not carry generated pages for removed modules into the next build. +autoapi_keep_files = False add_module_names = False toc_object_entries_show_parents = "hide" python_use_unqualified_type_names = True napoleon_google_docstring = True napoleon_numpy_docstring = False +# AutoAPI already indexes the real attributes. Keep Google-style attribute +# descriptions as fields, without creating a second target for each attribute. +napoleon_use_ivar = True # -- Options for HTML output ------------------------------------------------- @@ -163,3 +208,21 @@ def format_url(self, _e: Entry) -> HRef: # ruff:ignore[no-self-use] "source_directory": "docs/", "navigation_with_keys": True, } + + +def _release_source_links(app: Sphinx, relative_path: Path, parent_docname: str, content: list[str]) -> None: + """Link repository source files from included release notes to GitHub.""" + del parent_docname + if relative_path.as_posix() not in {"../CHANGELOG.md", "../UPGRADING.md"}: + return + repository = Path(app.srcdir).parent + for target in re.findall(r"\]\((src/[^)\s]+)\)", content[0]): + if (repository / target).is_file(): + content[0] = content[0].replace( + f"]({target})", f"](https://github.com/munich-quantum-toolkit/yaqs/blob/main/{target})" + ) + + +def setup(app: Sphinx) -> None: + """Preserve repository links when release notes are included in the docs.""" + app.connect("include-read", _release_source_links) diff --git a/docs/examples/analog_simulation.md b/docs/examples/analog_simulation.md index a85435211..bdbdadb8f 100644 --- a/docs/examples/analog_simulation.md +++ b/docs/examples/analog_simulation.md @@ -2,176 +2,323 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 600 + execution_timeout: 180 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Noisy Analog Simulation -This guide walks through an open-system **analog** simulation with the tensor -jump method (TJM): build a Hamiltonian, attach a noise model, configure -{class}`~mqt.yaqs.core.data_structures.simulation_parameters.AnalogSimParams`, -and visualize time-resolved observables. +Relaxation can remove an excitation before it travels across a spin chain. To +study this competition between transport and loss, we follow an excitation +through a **20-site XY chain** and compare its motion at several relaxation +rates. The occupation heatmaps show where the excitation travels, while the +total occupation tells us how much survives. + +This guide extends the analog example in {doc}`quickstart` using only the +standard YAQS installation. YAQS represents the state as a matrix product state +(MPS) and simulates noise with the tensor jump method (TJM), averaging +independent quantum-jump trajectories. Run the cells in order in a notebook. In +a script, put execution inside an `if __name__ == "__main__":` guard, as shown +in {doc}`simulator_initialization`. + +## 1. Build the Hamiltonian -For log-normal disorder on strengths and static calibration spread, see -{doc}`realistic_noise_models`. For execution options (parallelism, progress -bars), see {doc}`simulator_initialization`. To build Ising, Hubbard, -Pauli-string, or hardware Hamiltonians, see {doc}`hamiltonians`. +The XY model lets an excitation move between neighboring spins without changing +the total number of excitations. With open ends and the coefficients below, its +Hamiltonian is -## 1. Hamiltonian +$$ +H=-\frac{1}{2}\sum_{i=0}^{L-2}(X_iX_{i+1}+Y_iY_{i+1}). +$$ -```{code-cell} ipython3 +```{code-cell} python from mqt.yaqs import Hamiltonian -L = 5 -J, g = 1.0, 0.8 -H_0 = Hamiltonian.ising(L, J, g) +length = 20 +hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0) ``` -See {doc}`hamiltonians` for Pauli sums, Fermi–Hubbard, Bose–Hubbard, and -coupled-transmon factories. +Setting `Jz=0` removes the ZZ interaction from the Heisenberg model. The default +boundary condition is open, so sites 0 and 19 have one neighbor each. Because +the Hamiltonian conserves excitation, a noiseless run gives us a baseline +against which to measure relaxation. -## 2. Initial state and noise model +We use $\hbar=1$ and set the excitation-hopping amplitude to one. Time is +measured in its inverse units. See {doc}`hamiltonians` for other built-in +models, custom terms, and boundary conditions. -We prepare a Néel state $\ket{01010\ldots}$ and track staggered magnetization -under a transverse-field Ising model with on-site amplitude damping. The -alternating $\langle Z_i \rangle$ pattern at $t=0$ spreads and decays in a -site-dependent way. +## 2. Prepare one localized excitation -```{code-cell} ipython3 -from mqt.yaqs import NoiseModel, State +To see the excitation spread, start every spin in $|0\rangle$ except the spin at +site 10, which starts in $|1\rangle$. Each character of `basis_string` specifies +the state of the corresponding site, starting at site 0. -state = State(L, initial="Neel") +```{code-cell} python +from mqt.yaqs import State -gamma = 0.08 -noise_model = NoiseModel([ - {"name": "lowering", "sites": [i], "strength": gamma} for i in range(L) -]) +center = length // 2 +basis = "0" * center + "1" + "0" * (length - center - 1) +state = State(length, initial="basis", basis_string=basis) ``` -Pass a float for each `strength` here. For distribution-valued strengths -(log-normal and other distributions), see {doc}`realistic_noise_models`. +`State` uses an MPS by default. A single excitation keeps entanglement modest, +so this example is inexpensive compared with a general interacting state on 20 +sites. For other initial states and representations, see +{doc}`state_initialization` and {doc}`representation_comparison`. + +## 3. Choose observables, times, and accuracy -## 3. Simulation parameters +The spatial dynamics require one observable per site. We use $Z_i$, whose +expectation gives the occupation through +$\langle n_i\rangle=(1-\langle Z_i\rangle)/2$. -```{code-cell} ipython3 +```{code-cell} python from mqt.yaqs import AnalogSimParams, Observable -sim_params = AnalogSimParams( - observables=[Observable("z", site) for site in range(L)], - elapsed_time=6.0, - dt=0.1, - num_traj=20, - max_bond_dim=16, - svd_threshold=1e-6, - order=2, - sample_timesteps=True, +params = AnalogSimParams( + observables=[Observable("z", site) for site in range(length)], + elapsed_time=3.0, + dt=0.25, + num_traj=32, + preset="fast", + random_seed=7, ) ``` -Optional `tdvp_sweeps` (default `1`) runs multiple symmetric TDVP substeps per -physical step `dt`, improving unitary accuracy without changing the noise -timestep. +These settings sample 13 times from $t=0$ to $t=3$, long enough to see the +excitation spread away from the center. Time sampling is enabled by default, and +`elapsed_time` must be an integer multiple of `dt`. -**Evolution integrator:** analog simulations default to `EvolutionMode.TDVP` -(two-site TDVP sweeps). Switch to BUG with: +The `fast` preset sets numerical tolerances for a quick example. We override its +trajectory budget with `num_traj=32`, so each noisy run averages 32 +trajectories; the noiseless calculation needs only one. Increasing this budget +reduces sampling error, while timestep and MPS truncation errors require +separate convergence checks. -```{code-cell} ipython3 -from mqt.yaqs import AnalogSimParams, EvolutionMode +`random_seed` fixes the jump random streams for repeat runs with the same +configuration. It does not improve accuracy. See {doc}`simulation_parameters` +for presets, overrides, and convergence settings. -bug_params = AnalogSimParams( - evolution_mode=EvolutionMode.BUG, - elapsed_time=0.1, - dt=0.1, -) -``` +## 4. Define relaxation + +Relaxation competes with the motion generated by the Hamiltonian. The `lowering` +channel turns $|1\rangle$ into $|0\rangle$ without adding new excitations. Give +every site the same relaxation rate $\gamma$: -That uses center-augmented alternating-endpoint BUG composition with one -compression and renormalization after each ``dt`` step. +```{code-cell} python +from mqt.yaqs import NoiseModel -## 4. Reproducible stochastic runs +relaxation_rate = 4.0 +noise = NoiseModel([ + {"name": "lowering", "sites": [site], "strength": relaxation_rate} + for site in range(length) +]) +``` -With `num_traj > 1`, each {meth}`~mqt.yaqs.Simulator.run` call averages -independent quantum-jump trajectories. Set -{attr}`~mqt.yaqs.core.data_structures.simulation_parameters.AnalogSimParams.random_seed` -to fix the pseudorandom stream across trajectories (and for distribution-valued -noise strengths): +For analog evolution, `strength` is a Lindblad rate, with units of inverse time. +YAQS forms the jump operator $\sqrt{\gamma}\,|0\rangle\langle1|$ internally; +supply $\gamma$, not its square root. -```{code-cell} ipython3 -import copy +The corresponding lifetime is $1/\gamma$. At $\gamma=4$, loss occurs on a +shorter timescale than hopping between sites. For site-dependent rates, other +channels, custom operators, and distribution-valued strengths, see +{doc}`realistic_noise_models`. -import numpy as np +## 5. Run the noiseless and noisy cases -from mqt.yaqs import AnalogSimParams, Observable, Simulator - -repro_params = AnalogSimParams( - observables=[Observable("z", site) for site in range(L)], - elapsed_time=1.0, - dt=0.1, - num_traj=16, - max_bond_dim=4, - svd_threshold=1e-6, - order=2, - sample_timesteps=True, - random_seed=42, -) +Initialize the simulator separately, then pass the state, Hamiltonian, and +parameters to `run`. Omit the noise model for the noiseless baseline. -sim = Simulator(parallel=True, show_progress=False) +```{code-cell} python +from mqt.yaqs import Simulator +simulator = Simulator(show_progress=False) +noiseless = simulator.run(state, hamiltonian, params) +noisy = simulator.run(state, hamiltonian, params, noise) +``` -def run_reproducible() -> list[np.ndarray]: - st = copy.deepcopy(state) - params = copy.deepcopy(repro_params) - result = sim.run(st, H_0, params, copy.deepcopy(noise_model)) - return result.expectation_values +Parallel execution remains enabled by default. `show_progress=False` keeps the +documentation quiet; omit it to see progress. Execution and worker options are +explained in {doc}`simulator_initialization`. +## 6. Read the results -first_run = run_reproducible() -second_run = run_reproducible() -``` +`expectation_values` follows the order of the supplied observables. Here, row +$i$ contains $\langle Z_i\rangle$ and columns follow `times`. Convert the +expectations to an array of occupations: -Circuit simulations expose the same `random_seed` setting through -{class}`~mqt.yaqs.DigitalSimParams`. +```{code-cell} python +import numpy as np -## 5. Run and visualize +times = np.asarray(noisy.times) +occupation = (1 - np.asarray(noisy.expectation_values)) / 2 +print(occupation.shape) +``` -```{code-cell} ipython3 -result = sim.run(state, H_0, sim_params, noise_model) +The shape is `(20, 13)`: sites by sampled times. At $t=0$, only site 10 has +occupation one. In noisy runs, each entry is a trajectory average, rather than a +single measurement outcome. `noisy.trajectories` retains the per-trajectory +observable data when you need to examine sampling fluctuations. + +## 7. Compare four relaxation strengths + +The relaxation lifetime determines how long the excitation can propagate before +loss. To compare that timescale with the transport dynamics, keep the model and +numerical settings fixed and add rates between the two runs above: + +```{code-cell} python +rates = [0.0, 0.5, 1.5, relaxation_rate] +results = {0.0: noiseless, relaxation_rate: noisy} +for rate in rates[1:-1]: + rate_noise = NoiseModel([ + {"name": "lowering", "sites": [site], "strength": rate} + for site in range(length) + ]) + results[rate] = simulator.run(state, hamiltonian, params, rate_noise) + +occupations = np.stack([ + (1 - np.asarray(results[rate].expectation_values)) / 2 + for rate in rates +]) ``` -```{code-cell} ipython3 ---- -mystnb: - image: - width: 80% - align: center ---- -import matplotlib.pyplot as plt +The nonzero rates give lifetimes of $2$, $2/3$, and $1/4$ in the time units used +here. The heatmaps below compare how far the excitation spreads before its +occupation decays. All panels use one square-root color scale to keep small +occupations visible, with no normalization by the remaining excitation. -heatmap = result.expectation_values +```{code-cell} python +:tags: [hide-input] +import matplotlib.pyplot as plt +from matplotlib.colors import PowerNorm +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["STIXGeneral"], + "mathtext.fontset": "stix", + "font.size": 11, + "axes.labelsize": 11, + "axes.linewidth": 0.7, + "xtick.labelsize": 10, + "ytick.labelsize": 10, + "xtick.direction": "in", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "legend.fontsize": 9, + "legend.frameon": False, + "lines.linewidth": 1.6, + "figure.constrained_layout.use": True, + "savefig.dpi": 180, +}) +fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.4), sharex=True, sharey=True) +for ax, values, rate, panel in zip( + axes.flat, occupations, rates, "abcd", strict=True, +): + image = ax.pcolormesh( + times, np.arange(length), values, shading="auto", cmap="cividis", + norm=PowerNorm(0.5, vmin=0, vmax=1), rasterized=True, + ) + label = "Noiseless" if rate == 0 else rf"$\gamma={rate:g}$" + ax.set_title(f"({panel}) {label}", loc="left", fontsize=11) + ax.set(xticks=[0, 1, 2, 3], yticks=[0, 5, 10, 15, 19]) +for ax in axes[-1]: + ax.set_xlabel(r"Time $t$") +for ax in axes[:, 0]: + ax.set_ylabel(r"Site $i$") +fig.colorbar(image, ax=axes.ravel().tolist(), + label=r"Occupation $\langle n_i\rangle$", ticks=[0, 0.1, 0.5, 1]) +plt.show() +``` -fig, ax = plt.subplots(figsize=(7, 4), layout="constrained") -im = ax.imshow(heatmap, aspect="auto", extent=(0, 6, L, 0), vmin=-1, vmax=1) -ax.set_xlabel("Time") -ax.set_yticks([x - 0.5 for x in range(1, L + 1)], [str(x) for x in range(L)]) -ax.set_ylabel("Site") -fig.colorbar(im, ax=ax, shrink=0.9, label=r"$\langle Z \rangle$") +**Excitation transport under uniform relaxation.** Without noise, the excitation +spreads outward and forms an interference pattern. Increasing the relaxation +rate suppresses occupation at later times and more distant sites. At $\gamma=4$, +most of the excitation is lost before it travels far from the center. + +The fading pattern does not imply slower propagation. Conditioned on no jump, +the excitation follows the noiseless spatial dynamics because this Hamiltonian +conserves excitation and the loss rate is uniform. Site-dependent relaxation or +a different channel can change that profile. This time window mainly shows +outward propagation; longer runs can also show reflections from the open ends. + +## 8. Check the total excitation + +The heatmaps combine spreading with loss. Summing occupation over sites removes +the spatial information and isolates the survival probability. Since the initial +state has exactly one excitation and every site has the same relaxation rate, +the ensemble mean obeys + +$$ +N(t)=\sum_i\langle n_i(t)\rangle=e^{-\gamma t}. +$$ + +```{code-cell} python +:tags: [hide-input] +total_excitation = occupations.sum(axis=1) +fine_times = np.linspace(times[0], times[-1], 200) +colors = ["0.2", "#0072B2", "#009E73", "#D55E00"] +fig, ax = plt.subplots(figsize=(6.2, 3.2)) +for rate, values, color in zip(rates, total_excitation, colors, strict=True): + ax.plot(times, values, "o-", color=color, markersize=4, + label="Noiseless" if rate == 0 else rf"$\gamma={rate:g}$") + ax.plot(fine_times, np.exp(-rate * fine_times), "--", color=color, + linewidth=1.1, alpha=0.75) +ax.set(xlabel=r"Time $t$", ylabel=r"Total excitation $N(t)$", + xlim=(0, 3), ylim=(0, 1.08), xticks=[0, 1, 2, 3]) +ax.legend(ncols=4, loc="lower center", bbox_to_anchor=(0.5, 1.02)) plt.show() ``` +**Excitation survival compared with the decay law.** Markers show the YAQS +estimates, and dashed lines show $e^{-\gamma t}$. At $t=3$, the exact survival +probabilities are one without noise and about $0.22$, $0.011$, and +$6\times10^{-6}$ for the three nonzero rates. + +Each noisy trajectory either retains its excitation or loses it to a jump. With +only 32 trajectories, the estimated survival fraction changes in steps and may +reach zero while the exact mean is still positive. Finite sampling can also make +curves for different rates cross. Increase `num_traj` to reduce these +fluctuations; the sampling error decreases as $1/\sqrt{\mathtt{num\_traj}}$. + +Together, the two figures distinguish loss from redistribution along the chain. +Uniform relaxation reduces the chance of finding the excitation, while surviving +excitations retain the coherent transport pattern. This conclusion and the decay +law rely on uniform loss and excitation-conserving dynamics. The noiseless total +also checks conservation, but that check alone cannot establish the accuracy of +the spatial dynamics. + +## Accuracy and other options + +Before using a larger or more demanding model, check convergence separately: + +| Change | What to check | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Increase `num_traj` | Whether noisy observables stabilize within sampling uncertainty. | +| Reduce `dt` at fixed duration | Whether the dynamics stabilize with a finer evolution step. | +| Use `balanced` or `accurate` | Whether tighter truncation and solver settings change the observables. Keep an explicit trajectory budget when comparing numerical settings. | +| Increase `length` or `elapsed_time` | Whether boundaries, entanglement growth, and runtime affect the question being studied. | + +The default MPS evolution uses TDVP updates. To use the BUG integrator, import +`EvolutionMode` from `mqt.yaqs` and set `evolution_mode=EvolutionMode.BUG` in +`AnalogSimParams`. For TDVP, `tdvp_sweeps` adds unitary substeps without +changing the noise timestep. See {doc}`simulation_parameters` for these advanced +choices. + ## Related topics -- {doc}`digital_analog_simulation` — combine analog evolution with digital - operations in one program -- {doc}`hamiltonians` — Pauli, Hubbard, hardware, and piecewise time-dependent +- {doc}`realistic_noise_models` — other channels, custom jumps, and static + disorder +- {doc}`digital_analog_simulation` — combine analog evolution and digital + operations +- {doc}`hamiltonians` — built-in, custom, hardware, and time-dependent Hamiltonians -- {doc}`representation_comparison` — MPS, MCWF, and Lindblad backends -- {doc}`scheduled_jumps` — deterministic jumps at specified times +- {doc}`representation_comparison` — MPS, statevector, and density matrix + backends +- {ref}`noise-scheduled-jumps` — deterministic jumps at specified times - {doc}`ensemble_evolution` — unitary ensemble correlations -- {doc}`quickstart` — minimal first simulation diff --git a/docs/examples/characterization.md b/docs/examples/characterization.md index 1b1e3ac2b..597606d1b 100644 --- a/docs/examples/characterization.md +++ b/docs/examples/characterization.md @@ -2,354 +2,395 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 900 + execution_timeout: 300 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Probing Environmental Memory -Open quantum systems in YAQS couple a **probe qubit** (site 0) to an -**environment** simulated by the remaining chain. **Environmental memory** -measures how long the environment keeps past control and measurement choices -relevant for future probe responses, evaluated at a temporal cut $c$ in a -sequence of interventions. +A quantum system can leave information in its environment and encounter that +information again later. To study this memory, we control one probe qubit, +interrupt its evolution with a measurement and preparation, then ask whether its +future responses still depend on the past. The interruption removes the probe's +direct link to its earlier state while the environment keeps evolving. -Memory characterization currently supports qubit Hamiltonians only. +We extend the coupling sweep in {doc}`quickstart` to explain the probing +schedule, the response matrix, and its spectrum. We then test memory persistence +under repeated resets and add dephasing in a short process-tensor example. The +examples use the standard YAQS installation and Matplotlib for plotting. Run the +cells in order in a notebook; for a script, use the entry-point guard in +{doc}`simulator_initialization`. -Use {meth}`~mqt.yaqs.memory_characterizer.MemoryCharacterizer.characterize` to -probe **operational memory**: assemble the **response matrix** $V(c)$, then read -$S_V(c)$, $R(c)=\exp(S_V(c))$, and the mode spectrum. +## 1. Define the probe and its environment -Alternatively, build a process tensor (default: direct MPO) and call -`compute_temporal_entropy` for **temporal entanglement** $S_{PT}(c)$ of the -multi-time process itself — a distinct quantity from $S_V(c)$. For fast dynamics -under control sequences, see {doc}`memory_surrogate`. +Site 0 is our probe qubit. Sites 1 and 2 form an environment that we do not +control or measure directly. All three spins evolve under the transverse-field +Ising Hamiltonian -## Setup +$$ +H=-J(Z_0Z_1+Z_1Z_2)-g(X_0+X_1+X_2). +$$ -```{code-cell} ipython3 -import matplotlib.pyplot as plt +We fix $g=1$, use $\hbar=1$, and vary $J$. This changes both the +probe–environment coupling and the bond within the environment. At $J=0$ the +probe is isolated, giving a reference with no environmental memory. The default +initial state is $|000\rangle$. + +```{code-cell} python import numpy as np from mqt.yaqs import AnalogSimParams, Hamiltonian, MemoryCharacterizer -from mqt.yaqs.characterization.memory.shared.utils import make_zero_psi -length = 4 -ham = Hamiltonian.ising(length=length, J=1.0, g=1.0) -params = AnalogSimParams(dt=0.1, max_bond_dim=16, order=1) -mc = MemoryCharacterizer(show_progress=False) -psi0 = make_zero_psi(length) +length = 3 +couplings = np.linspace(0, 1.5, 13) +params = AnalogSimParams(elapsed_time=0.5, dt=0.5, preset="fast") +characterizer = MemoryCharacterizer(show_progress=False) ``` -Throughout, `num_interventions` is the probe-sequence length $k$ and `cut` is -the causal-break index $c$ (the break sits at step $c-1$; future legs use steps -$c+1,\ldots,k$). Use $k>1$ and an interior cut so both past and future probe -legs contribute to $V(c)$. - -## Characterize with the Hamiltonian backend - -The full chain (system + environment) is simulated for each probe sequence. This -is the reference memory metric when you have a microscopic open-system model. - -```{code-cell} ipython3 -cut, num_interventions = 4, 6 -ham_result = mc.characterize( - ham, - params, - cut=cut, - num_interventions=num_interventions, - n_pasts=8, - n_futures=8, - initial_psi=psi0, - rng=np.random.default_rng(42), -) - -sv = ham_result.singular_values(cut) -fig, axes = plt.subplots(1, 2, figsize=(8, 3)) -axes[0].semilogy(sv, "o-") -axes[0].set_xlabel("mode index") -axes[0].set_ylabel("singular value") -axes[0].set_title(r"Memory spectrum at cut $c=4$") - -v = ham_result.response_matrix(cut) -im = axes[1].imshow(np.abs(v), aspect="auto", cmap="viridis") -axes[1].set_title(r"$|V(c)|$") -axes[1].set_xlabel("history index") -axes[1].set_ylabel("future probe and response channel") -fig.colorbar(im, ax=axes[1], fraction=0.046, pad=0.04) -fig.suptitle( - rf"$S_V(c={cut})={ham_result.entropy(cut):.3f}$, " - rf"$R(c)={ham_result.modes(cut):.2f}$", - y=1.02, -) -fig.tight_layout() -``` +The characterizer selects the state representation automatically: vectors for +small systems, and MPS for larger ones. Parallel execution remains enabled. The +documentation suppresses progress bars; omit `show_progress=False` to see them. +Memory characterization currently supports qubit Hamiltonians. -Use `preset="quick"`, `"balanced"`, or `"accurate"` for default probe-grid -sizes, or set `n_pasts` / `n_futures` explicitly. +(memory-theory)= -### Reading `CharacterizationResult` +## 2. Choose the probing schedule -| Access | Meaning | -| ---------------------------------- | -------------------------------------------------------------------------- | -| `result.entropy(c)` | Environmental memory entropy $S_V(c)$ | -| `result.modes(c)` | Effective memory modes $R(c)=\exp(S_V(c))$ | -| `result.singular_values(c)` | Resolution-retained spectrum used to compute $S_V(c)$ | -| `result.singular_values_full(c)` | Every compact-SVD value, including zero and unresolved tail values | -| `result.left_singular_vectors(c)` | All compact-SVD future-response directions as columns | -| `result.right_singular_vectors(c)` | All compact-SVD history-combination directions as columns | -| `result.response_matrix(c)` | $V(c)$ with $4N_f$ IXYZ future-response rows and $N_h$ history columns | -| `result.probes(c)` | Probe arrays used at cut $c$ (for reuse or inspection) | -| `result.summary()` | Human-readable table of entropies and modes | +Use four interventions separated by evolution intervals of `dt=0.5`. YAQS also +evolves before the first intervention and after the last, giving five intervals +and a total duration of 2.5. With `cut=2`, the second intervention is the +**causal break**: measure a selected outcome and prepare a new probe state. +There is one control before the break and two controls after it. -(memory-theory)= +The default `intervention_style="haar"` draws random single-qubit unitaries for +these controls. Past probes also choose the measurement at the break; future +probes choose its preparation. Keeping these choices separate lets us test +whether past settings affect the future through the environment. -## Theory: split-cut probing - -Environmental memory asks: across a grid of past and future control settings on -the probe, how many independent ways does the **environment** still correlate -past choices with accessible future responses? - -The split-cut protocol: - -1. Sample past control legs $\alpha=(U_1,\ldots,U_{c-1})$ and future legs - $\beta=(V_{c+1},\ldots,V_k)$ on the probe. -2. Insert a **causal break** at step $c$: measure on the past side and prepare - on the future side while the environment continues to evolve. -3. For each grid entry, simulate the open system, record the joint probability - of the retained outcomes and the normalized final-system Pauli response - $\mathbf{r}=(\langle I\rangle,\langle X\rangle,\langle Y\rangle,\langle Z\rangle)$. -4. Assemble the sampled response coefficients into $V(c)$. Its rows contain one - $(I,X,Y,Z)$ block per future probe, its columns label conditioned histories, - and $V_{(j,I),i}=w_{ij}$ for normalized output states. Compute $S_V(c)$ from - the normalized mode spectrum. - -For an SVD $V=U\Sigma W^\dagger$, each column pair associated with a retained, -nonzero singular value defines a response mode: the column of $U$ gives the -future-response direction, while the column of $W$ gives a combination of -conditioned histories. With `U = result.left_singular_vectors(c)`, -`s = result.singular_values_full(c)`, and -`W = result.right_singular_vectors(c)`, the full factors satisfy -`V = U @ np.diag(s) @ W.conj().T`. The full factors also contain directions -paired with exact zeros or an unresolved numerical tail. Do not interpret those -directions as resolved memory modes. Singular vectors are also not unique inside -a degenerate singular subspace. - -Hamiltonian `characterize` obtains joint probabilities of the retained outcomes -from the simulated intervention sequence (MCWF or TJM/MPS, per -`representation`). Process-tensor backends obtain the same probabilities from -the trace of each subnormalized contraction, while surrogates estimate them from -their predicted pre-intervention reduced states. - -### Coupling strength and memory - -Stronger Ising coupling $J$ between the probe and the environment typically -increases cross-cut memory. Reuse one `probe_set` when sweeping $J$: - -```{code-cell} ipython3 -j_values = np.linspace(0.0, 2.0, 9) -anchor = mc.characterize( - Hamiltonian.ising(length=length, J=0.0, g=1.0), +```{code-cell} python +num_interventions = 4 +cut = 2 +anchor = characterizer.characterize( + Hamiltonian.ising(length, J=0.0, g=1.0), params, num_interventions=num_interventions, cut=cut, - n_pasts=8, - n_futures=8, - initial_psi=psi0, - rng=np.random.default_rng(42), + preset="quick", + rng=np.random.default_rng(7), ) -entropies = [] -for j in j_values: - result = mc.characterize( - Hamiltonian.ising(length=length, J=float(j), g=1.0), + +print(anchor.summary()) +print("Response matrix shape:", anchor.response_matrix(cut).shape) +``` + +`preset="quick"` selects eight past probes and eight future probes, giving 64 +sequences. This preset sets the probe grid; the preset on `AnalogSimParams` sets +numerical accuracy. For Hamiltonian characterization, `dt` sets the interval +between interventions. `elapsed_time` does not set the full probing horizon. +Here it equals one interval so the simulation parameters have a valid time grid. + +For each past–future pair, YAQS retains the selected measurement outcome and +records the final probe's $(I,X,Y,Z)$ responses. It multiplies each conditional +response by the joint probability of the retained outcomes. The +**response matrix** $V$ places each history in a column and each future's four +response channels in consecutive rows. Its shape here is $(32,8)$, and its +identity rows contain the outcome probabilities. Keeping these probabilities +avoids treating a rare branch as though it occurred on every run. + +## 3. Sweep the coupling with the same probes + +Reuse `anchor` as `probe_set` so every coupling uses the same controls, +measurements, and preparations. Otherwise a change in the sampled probes could +be confused with a change in the environment. + +```{code-cell} python +memories = [anchor] +for coupling in couplings[1:]: + memory = characterizer.characterize( + Hamiltonian.ising(length, J=float(coupling), g=1.0), params, num_interventions=num_interventions, cut=cut, probe_set=anchor, ) - entropies.append(result.entropy(cut)) - -fig, ax = plt.subplots(figsize=(5.5, 3)) -ax.plot(j_values, entropies, "o-") -ax.set_xlabel(r"Ising coupling $J$") -ax.set_ylabel(r"$S_V(c)$") -ax.set_title(r"Environmental memory grows with probe-environment coupling") -fig.tight_layout() + memories.append(memory) + +mode_weights = [] +for memory in memories: + spectrum = memory.singular_values(cut) + mode_weights.append(spectrum**2 / np.sum(spectrum**2)) +entropies = np.array([memory.entropy(cut) for memory in memories]) ``` -### Intervention styles +The singular values $s_k$ describe independent combinations of past settings and +future responses. Their normalized squared weights and entropy are -`characterize` accepts `intervention_style=` (default `"haar"`): +$$ +p_k=\frac{s_k^2}{\sum_j s_j^2},\qquad +S_V=-\sum_k p_k\ln p_k. +$$ -- **`"haar"`** — random unitaries on sequence legs; measure/prepare only at the - causal cut. -- **`"measure_prepare"`** — rank-1 measure–prepare maps on every leg. -- **`"clifford"`** — random single-qubit Clifford gates on legs. +`singular_values(cut)` returns the spectrum retained for this entropy, after +removing a numerical tail with relative squared weight at most $10^{-12}$. +`singular_values_full(cut)` returns every compact-SVD value. The entropy uses +natural logarithms, and `memory.modes(cut)` gives the effective mode number +$R=\exp(S_V)$. One retained mode gives $S_V=0$ and $R=1$. -Pass `probe_set=` from a Hamiltonian run so surrogate or exact-reference -backends evaluate the **same** probe ensemble ({doc}`memory_surrogate`). -Surrogate characterization also requires `initial_rho=`: the site-0 density -matrix after the schedule's initial evolution segment and before its first -intervention. For a surrogate trained against a reference process tensor, use -that tensor's `initial_rho`. +## 4. Read the spectrum and entropy -(reset-delay)= +The spectrum shows how coupling redistributes the response among modes. The +entropy summarizes this spread, allowing us to compare the same probing +experiment across the coupling sweep. -## Memory persistence: conditioned reset delay - -Pass `delay=N`, for any $N\geq0$, to use the conditioned-reset protocol from -Figure 5 of the response-matrix paper. The intervention at the history boundary -applies the selected measurement and prepares $\lvert0\rangle$. YAQS then -inserts $N$ selected-zero reset slots $(\lvert0\rangle,\lvert0\rangle)$ and -applies a second selected-zero measurement before the sampled future -preparation. The environment keeps evolving between these interventions. The -selected history outcome and every selected-zero outcome contribute to the -complete branch probability. - -The two boundary interventions remain separate at `delay=0`. The physical -sequence length is therefore `num_interventions + delay + 1` for every explicit -delay. Omitting `delay` uses the standard one-step causal break -`(selected_history_measurement, sampled_future_preparation)` instead. This keeps -ordinary characterization aligned across Hamiltonian, process-tensor, and -surrogate backends. - -Extra reset time lets the environment decouple from the past before future -controls act, so $S_V(c)$ often decreases at strong probe-environment coupling. -Weaker coupling can show a nonmonotonic profile. The example below uses a -smaller probe grid and shorter sequences than the paper campaign, but it uses -the same conditioned-reset geometry. Reuse the same `probe_set` across the delay -sweep. An explicit `delay` is supported for Hamiltonian characterization only. - -```{code-cell} ipython3 -delay_length = 6 -ham_delay = Hamiltonian.ising(length=delay_length, J=2.0, g=1.0) -params_delay = AnalogSimParams(dt=0.1) -mc_delay = MemoryCharacterizer(show_progress=False) -delay_cut = 4 -delay_k = 6 -anchor_delay = mc_delay.characterize( - ham_delay, - params_delay, - num_interventions=delay_k, - cut=delay_cut, - delay=0, - n_pasts=6, - n_futures=6, - initial_psi=make_zero_psi(delay_length), - rng=np.random.default_rng(999_991), -) -delays = [0, 1, 2, 3] -delay_entropies = [] -for delay in delays: - result = mc_delay.characterize( - ham_delay, - params_delay, - num_interventions=delay_k, - cut=delay_cut, - delay=delay, - probe_set=anchor_delay, - ) - delay_entropies.append(result.entropy(delay_cut)) - -fig, ax = plt.subplots(figsize=(4.5, 3)) -ax.plot(delays, delay_entropies, "s-") -ax.set_xlabel("reset delay at causal cut") -ax.set_ylabel(r"$S_V(c)$") -ax.set_title(r"Strong coupling: memory erodes with longer reset delay") -ax.set_xticks(delays) -fig.tight_layout() +```{code-cell} python +:tags: [hide-input] +import matplotlib.pyplot as plt +from matplotlib.colors import LinearSegmentedColormap, Normalize +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["STIXGeneral"], + "mathtext.fontset": "stix", + "font.size": 10, + "axes.labelsize": 11, + "axes.linewidth": 0.8, + "xtick.direction": "in", + "ytick.direction": "in", + "svg.fonttype": "none", +}) +red_map = LinearSegmentedColormap.from_list("coupling_reds", plt.colormaps["Reds"](np.linspace(0.3, 0.95, 100))) +norm = Normalize(couplings[0], couplings[-1]) +fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8), layout="constrained") +for coupling, weights in zip(couplings, mode_weights, strict=True): + axes[0].semilogy(np.arange(1, len(weights) + 1), weights, "o-", color=red_map(norm(coupling)), lw=1.3, ms=3) +axes[0].set(xlabel="Mode index", ylabel=r"Retained mode weight $p_k$", ylim=(1e-13, 2), xticks=[1, 2, 4, 6, 8]) +fig.colorbar(plt.cm.ScalarMappable(norm=norm, cmap=red_map), ax=axes[0], label=r"Coupling $J/g$", ticks=[0, 0.5, 1, 1.5], fraction=0.05, pad=0.03) +axes[1].fill_between(couplings, entropies, color=red_map(0.35), alpha=0.3) +axes[1].plot(couplings, entropies, "o-", color=red_map(0.95), lw=2.2, ms=4, markerfacecolor="white") +axes[1].set(xlabel=r"Coupling $J/g$", ylabel=r"Memory entropy $S_V$ (nats)", xlim=(-0.03, 1.53), ylim=(-0.01, 0.34), xticks=[0, 0.5, 1, 1.5]) +for label, ax in zip(("(a)", "(b)"), axes, strict=True): + ax.text(0.02, 1.03, label, transform=ax.transAxes, va="bottom", fontweight="bold") + ax.spines[["top", "right"]].set_visible(False) +plt.show() ``` -## Representation +**Coupling creates additional response modes, with a peak inside this sweep.** +(a) Darker curves show larger $J$. The uncoupled probe has one retained mode; +coupled dynamics distribute weight among additional modes. (b) The entropy +reaches a maximum near $J/g=1.4$, then decreases. Stronger coupling does not +imply a larger entropy for a fixed schedule and probe grid. -`AnalogSimParams` configures the evolution and does not select the memory -backend. `MemoryCharacterizer(representation="auto")` mirrors `Simulator`: -`"vector"` selects MCWF, `"mps"` selects TJM for the **environment** chain. With -`"auto"`, MCWF is used when `hamiltonian.length <= vector_max_qubits` (default -10). +These weights describe the responses accessible through the chosen experiment. +They are not environment populations or Schmidt weights of a mixed state. A +small entropy means that a few modes dominate these responses; it does not prove +that every possible experiment would find the environment memoryless. Changing +the interval, temporal cut, or controls can reveal different memory. The +spectrum alone also does not certify that the memory is quantum rather than +classical. -## Temporal entanglement from a process tensor +## Further experiments -Operational memory ($S_V$) comes from probe responses. **Temporal entanglement** -$S_{PT}(c)$ is computed directly from a process tensor at the same causal cut. -By default, `build_process_tensor` uses direct MPO construction -(`return_type="mpo"`). Pass `return_type="dense"` for exhaustive tomography -(required for `noise_model`): +(reset-delay)= -```{code-cell} ipython3 -k = 2 -cut_pt = 1 -timesteps = [0.1] * (k + 1) +### How long does memory persist under resets? -pt_mpo = mc.build_process_tensor( - ham, - params, - timesteps=timesteps, -) -pt_dense = mc.build_process_tensor( - ham, - params, - timesteps=timesteps, - return_type="dense", -) +A causal break interrupts the probe once. Repeated resets ask whether the +selected histories remain distinguishable after a longer interruption. With +`delay=N`, YAQS measures the selected history outcome and prepares $|0\rangle$, +inserts $N$ selected-zero measure–prepare resets, then measures zero once more +before the sampled future preparation. The environment evolves between all these +interventions. -s_mpo = pt_mpo.compute_temporal_entropy(cut_pt) -s_dense = pt_dense.compute_temporal_entropy(cut_pt) -print( - f"S_PT(c={cut_pt}): mpo={s_mpo['entropy']:.4f}, " - f"dense={s_dense['entropy']:.4f}, schmidt_rank={s_mpo['schmidt_rank']}" -) +```{code-cell} python +hamiltonian = Hamiltonian.ising(length, J=1.0, g=1.0) +delays = np.arange(7) +delay_memories = [] +for delay in delays: + delay_memories.append(characterizer.characterize( + hamiltonian, + params, + num_interventions=num_interventions, + cut=cut, + delay=int(delay), + probe_set=anchor, + )) +delay_entropies = [memory.entropy(cut) for memory in delay_memories] +``` -# The same exact process tensor also supports operational memory via characterize: -pt_result = mc.characterize( - pt_mpo, - cut=cut_pt, - num_interventions=k, - n_pasts=6, - n_futures=6, - rng=np.random.default_rng(7), -) -print(f"S_V(c={cut_pt}) from process-tensor probes: {pt_result.entropy(cut_pt):.4f}") +Every integer delay, including zero, uses separate boundary interventions. The +sequence has `num_interventions + delay + 1` interventions and one more +evolution interval. Omitting `delay` uses the standard single causal break, so +the ordinary result and the `delay=0` result have different schedules. + +```{code-cell} python +:tags: [hide-input] +fig, ax = plt.subplots(figsize=(4.5, 2.6), layout="constrained") +ax.plot(delays, delay_entropies, "o-", color="#225c80", lw=2, ms=5, markerfacecolor="white") +ax.fill_between(delays, delay_entropies, color="#225c80", alpha=0.12) +ax.set(xlabel="Selected-zero reset slots", ylabel=r"Memory entropy $S_V$ (nats)", xticks=delays, ylim=(0, None)) +ax.spines[["top", "right"]].set_visible(False) +plt.show() +``` + +**The conditioned memory varies nonmonotonically with reset delay.** The finite +spin environment continues to evolve and can return information to the probe. +These resets retain particular outcomes rather than averaging over every +measurement result. Their joint probability contributes to $V$, so this is a +conditioned persistence experiment. It does not establish an all-outcome memory +length. Explicit `delay` is supported for Hamiltonian characterization. + +### What changes when we add dephasing? + +The Hamiltonian `characterize` path does not accept a `NoiseModel`. To include +Markovian noise, first reconstruct a **dense process tensor**, which records the +response to interventions, then pass that tensor to `characterize`. We use two +spins and one causal break to keep this exhaustive reconstruction small. Two +evolution intervals of 0.5 surround the break; the probe and its one-spin +environment retain $J=g=1$. + +```{code-cell} python +from mqt.yaqs import NoiseModel + +short_hamiltonian = Hamiltonian.ising(2, J=1.0, g=1.0) +noise_params = AnalogSimParams(elapsed_time=0.5, dt=0.025, preset="fast", random_seed=7) +dephasing_rates = [0.0, 1.0, 4.0] +process_tensors = [] +noise_memories = [] +short_anchor = None +for rate in dephasing_rates: + noise = None if rate == 0 else NoiseModel([{"name": "pauli_z", "sites": [0], "strength": rate}]) + process = characterizer.build_process_tensor( + short_hamiltonian, + noise_params, + timesteps=[0.5, 0.5], + return_type="dense", + noise_model=noise, + num_trajectories=512, + ) + memory = characterizer.characterize( + process, + cut=1, + preset="quick", + probe_set=short_anchor, + rng=np.random.default_rng(7), + ) + short_anchor = memory if short_anchor is None else short_anchor + process_tensors.append(process) + noise_memories.append(memory) + +for rate, memory in zip(dephasing_rates, noise_memories, strict=True): + print(f"Dephasing rate {rate:g}: {memory.summary()}") ``` -Dense and default direct MPO construction agree on $S_{PT}$ for small $k$. The -supported direct path is uncapped: at intervention leg $k$, it can retain up to -$16^k$ histories and construct the same number of rank-one terms. Use it only -for short horizons. `compress_every` limits an accumulation batch; it does not -limit the number of histories. Dense tomography has the same $16^k$ sequence -count and is required when you use `noise_model`. +Here `strength=rate` is a Lindblad rate, with jump operator +$L=\sqrt{\gamma}\,Z_0$ and dissipator $\gamma(Z_0\rho Z_0-\rho)$. `timesteps` +defines the two evolution intervals, while `noise_params.dt` sets the +integration step within them. Each of the 16 tomography sequences averages 512 +trajectories for nonzero noise. The noiseless reconstruction uses one. + +```{code-cell} python +:tags: [hide-input] +fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.6), layout="constrained") +noise_colors = ["#225c80", "#b78730", "#bb563b"] +for rate, memory, color in zip(dephasing_rates, noise_memories, noise_colors, strict=True): + spectrum = memory.singular_values(1) + weights = spectrum**2 / np.sum(spectrum**2) + axes[0].semilogy(np.arange(1, len(weights) + 1), weights, "o-", color=color, ms=4, lw=1.6, label=rf"$\gamma/g={rate:g}$") +axes[0].set(xlabel="Mode index", ylabel=r"Retained mode weight $p_k$", xticks=[1, 2, 3, 4], ylim=(1e-8, 2)) +axes[0].legend(frameon=False, fontsize=9, loc="lower left") +axes[1].bar(np.arange(3), [memory.entropy(1) for memory in noise_memories], color=noise_colors, width=0.6) +axes[1].set(xlabel=r"Dephasing rate $\gamma/g$", ylabel=r"Memory entropy $S_V$ (nats)", xticks=np.arange(3), xticklabels=["0", "1", "4"]) +for label, ax in zip(("(a)", "(b)"), axes, strict=True): + ax.text(0.02, 1.03, label, transform=ax.transAxes, va="bottom", fontweight="bold") + ax.spines[["top", "right"]].set_visible(False) +plt.show() +``` + +**Dephasing reduces the weight outside the leading response mode.** (a) The +leading mode gains relative weight as the dephasing rate increases. (b) The +sampled entropy falls. These values describe the two-spin, one-break schedule +and should not be compared directly with the earlier four-intervention sweep. +Small spectral weights can reflect trajectory sampling error; they do not all +establish resolved physical modes. Increase the trajectory count and refine the +integration step before interpreting the smallest weights. + +Coupling, resets, and added dephasing answer different questions about the same +physical issue: which traces of earlier probe choices can affect later +responses? Keep the probes and schedule fixed when comparing models, and check +whether the observed spectrum persists as numerical and sampling accuracy +improve. + +## Further options + +### Probe coverage and representations + +`preset="balanced"` uses a $32\times32$ grid and `"accurate"` uses +$128\times128$. Set `n_pasts` and `n_futures` to choose counts explicitly. +Larger grids test more controls; they are not extra trajectories of the same +experiment. Check probe coverage as well as evolution accuracy. + +Use `intervention_style="clifford"` for random single-qubit Clifford controls, +or `"measure_prepare"` for rank-one measurement and preparation on every leg. +The default `"haar"` uses random unitaries away from the cut. The choice changes +what memory the experiment can resolve. + +Pass `cuts=[...]` or `cuts="all"` to compare temporal cuts. Each cut needs its +own probe geometry, so `probe_set` reuse is restricted to a single cut. +`representation="auto"` on `MemoryCharacterizer` selects vectors up to ten +qubits and MPS above that size. Set `"vector"` or `"mps"` explicitly when +needed. `initial_psi` replaces the default all-zero state for Hamiltonian +characterization. See {class}`~mqt.yaqs.MemoryCharacterizer` for execution and +accuracy options. + +### Inspecting response modes + +Use `memory.response_matrix(cut)` to inspect $V$. The full compact-SVD factors +from `left_singular_vectors`, `singular_values_full`, and +`right_singular_vectors` satisfy $V=U\operatorname{diag}(s)W^\dagger$. The +columns of $U$ describe future responses, while the columns of $W$ combine +histories. Directions paired with an unresolved tail are not resolved modes, and +vectors inside a degenerate singular subspace are not unique. + +### Process tensors and temporal entanglement + +`build_process_tensor` defaults to direct MPO construction for noiseless models. +Use `return_type="dense"` for added noise, as above. Both constructions grow +with $16^k$ intervention sequences or histories, so reserve them for short +horizons. Direct construction retains all branches by default; `compress_every` +controls an accumulation batch, not the total number of histories. + +A process tensor also provides `compute_temporal_entropy(cut)`, `qmi`, and +`cmi`. These describe the multi-time process, while $S_V$ describes the sampled +probe responses. They are distinct quantities. Reuse the noiseless short-horizon +tensor to compute its temporal operator-Schmidt entropy: + +```{code-cell} python +temporal = process_tensors[0].compute_temporal_entropy(1) +print(f"Temporal entropy S_PT: {temporal['entropy']:.4f}") +print(f"Response entropy S_V: {noise_memories[0].entropy(1):.4f}") +``` -`MPOProcessTensor.compute_temporal_entropy()`, `MPOProcessTensor.qmi()`, and -`MPOProcessTensor.cmi()` currently convert the complete MPO to a dense matrix. -The matrix alone uses $64\,16^k$ bytes for $k$ intervention legs, before -analysis workspace. On a typical workstation, restrict these calculations to -about five legs. The matrix uses 64 MiB at five legs and 1 GiB at six legs. +Both entropies use natural logarithms. Their values differ because they describe +different objects. The MPO implementations of these diagnostics currently +densify the tensor. Dense storage alone takes 64 MiB at five intervention legs +and 1 GiB at six, before analysis workspace. Operational probing of an MPO +process tensor does not require this conversion. ```{warning} -Passing a finite `max_bond_dim` enables experimental direct-MPO truncation and -emits a `RuntimeWarning`. This uncontrolled approximation can change the process -tensor and does not preserve positivity or causal normalization. Do not use a -capped result as a stable scientific reference. +A finite `max_bond_dim` in direct process-tensor construction enables an +experimental approximation that can violate positivity and causal normalization. +Keep the supported default `None` for scientific references. Noisy tomography +also has finite-sample error; validate reconstructed responses before using them +as a reference. ``` -Operational characterization requires each contracted branch to be Hermitian and -positive semidefinite, with trace in $[0,1]$. Response assembly also checks that -each normalized qubit response lies in the Bloch ball. QMI and CMI always -normalize the process tensor and validate positive semidefiniteness and causal -normalization. Remove an experimental finite cap by setting `max_bond_dim=None`, -or use a sufficiently accurate dense reconstruction when you need $S_V$ from a -process tensor. `characterize(pt, ...)` uses native MPO -`evaluate_probes_with_weights` without densifying the V-matrix path. - -## Related topics - -- {doc}`quickstart` — minimal characterize and surrogate predict snippets -- {doc}`memory_surrogate` — train a surrogate, predict dynamics, validate - against exact references -- API reference: :class:`~mqt.yaqs.memory_characterizer.MemoryCharacterizer` +For predicting dynamics under new controls with a trained model, see +{doc}`memory_surrogate`. Surrogate characterization requires `initial_rho`, the +site-0 state after initial evolution and before the first intervention. Use the +reference process tensor's `initial_rho` when the surrogate was trained against +that tensor, and reuse the same `probe_set` for a direct comparison. diff --git a/docs/examples/circuit_observables.md b/docs/examples/circuit_observables.md index db1464745..fd65ebbde 100644 --- a/docs/examples/circuit_observables.md +++ b/docs/examples/circuit_observables.md @@ -2,205 +2,301 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 600 + execution_timeout: 300 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` +# Observable-Based Circuit Simulation -# Circuit Observables +The excitation transport in {doc}`analog_simulation` can also be simulated with +a quantum circuit. We use the same **20-site XY chain**, localized excitation, +and relaxation rates, then replace continuous Hamiltonian evolution with short +sequences of exchange gates. Sampling observables between these sequences lets +us reconstruct the occupation heatmaps and compare digital and analog dynamics. -Evolve a matrix-product state (MPS) through a Qiskit circuit and evaluate Pauli -(or custom) observables. Pass observables on {class}`~mqt.yaqs.DigitalSimParams` -and an optional {class}`~mqt.yaqs.NoiseModel` as the fourth argument to -{meth}`~mqt.yaqs.Simulator.run` for open-system tensor-jump trajectories; omit -it for a single unitary path (regardless of `num_traj`). +The example uses the standard YAQS installation and public interfaces. Run the +cells in order in a notebook; in a script, use the entry-point guard in +{doc}`simulator_initialization`. For bitstring counts rather than expectation +values, see {doc}`circuit_shots`. -For computational-basis shot histograms, see {doc}`circuit_shots`. You can also -request observables and `shots` together on one `DigitalSimParams`. +## 1. Turn the XY Hamiltonian into gates -| Workflow | Typical use | Key settings | -| --------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| **Final observables** | Noise scaling, benchmarking, device studies | `DigitalSimParams(observables=...)` evaluated after the last gate | -| **Mid-circuit observables** | Layer-wise diagnostics, depth-dependent calibration | `sample_layers=True` plus `barrier(label="SAMPLE_OBSERVABLES")` markers in the circuit | -| **Shot-based readout** | Hardware-like bitstring statistics | `DigitalSimParams(shots=...)` — see {doc}`circuit_shots` | +The Hamiltonian exchanges excitations between neighboring sites, -Circuits enter YAQS as {class}`qiskit.circuit.QuantumCircuit` objects (or -OpenQASM strings). The initial state should use `representation="mps"` (the -default for {class}`~mqt.yaqs.core.data_structures.state.State` presets). For -accuracy presets, truncation knobs, and `random_seed`, see -{doc}`simulation_parameters`. For log-normal disorder on noise strengths, see -{doc}`realistic_noise_models`. +$$ +H=-\frac{1}{2}\sum_{i=0}^{L-2}(X_iX_{i+1}+Y_iY_{i+1}). +$$ -```{code-cell} ipython3 -import matplotlib.pyplot as plt -import numpy as np +As in the analog guide, we set the hopping amplitude to one and use $\hbar=1$. +An exchange gate on sites $i$ and $i+1$ implements their contribution to the +evolution. Qiskit's `XXPlusYYGate(-2 * duration)` gives +$\exp[+i\,\mathtt{duration}(XX+YY)/2]$, with the sign set by this Hamiltonian. -from mqt.yaqs import Simulator +Gates on overlapping bonds do not commute. We approximate a time step with half +a step on even bonds, a full step on odd bonds, then another half step on even +bonds. This symmetric Trotter formula approaches the Hamiltonian dynamics as the +step decreases. Each exchange gate preserves excitation number, including when +noise acts between gates. -sim = Simulator(show_progress=False) +```{code-cell} python +import numpy as np +from qiskit import QuantumCircuit +from qiskit.circuit.library import XXPlusYYGate + + +def xy_circuit(length, dt, steps): + """Build a symmetric XY Trotter circuit and count gate exposures per step.""" + step_circuit = QuantumCircuit(length) + gate_exposures = np.zeros(length, dtype=int) + for parity, fraction in ((0, 0.5), (1, 1.0), (0, 0.5)): + for site in range(parity, length - 1, 2): + step_circuit.append(XXPlusYYGate(-2 * dt * fraction), [site, site + 1]) + gate_exposures[[site, site + 1]] += 1 + + circuit = QuantumCircuit(length) + for step in range(steps): + circuit.compose(step_circuit, inplace=True) + if step < steps - 1: + circuit.barrier(label="SAMPLE_OBSERVABLES") + return circuit, gate_exposures + + +length = 20 +dt = 0.25 +steps = 12 +circuit, gate_exposures = xy_circuit(length, dt, steps) ``` -## 1. Minimal run: unitary vs open-system noise +The circuit represents evolution to $t=3$. Labelled barriers separate the +Trotter steps so YAQS can sample observables at the same 13 times as the analog +guide. `gate_exposures` counts each site's two-qubit gates in one step; we will +use these counts to match the relaxation rate. -Evolve a short Trotterized Ising circuit and compare final $\langle Z_i\rangle$ -without noise and with on-site amplitude damping: +## 2. Prepare the state and observables -```{code-cell} ipython3 -from mqt.yaqs import NoiseModel, Observable, State, DigitalSimParams -from mqt.yaqs.core.libraries.circuit_library import create_ising_circuit - -num_qubits = 3 -qc = create_ising_circuit(L=num_qubits, J=1.0, g=0.8, dt=0.1, timesteps=6) -circuit_state = State(num_qubits, initial="zeros") -circuit_params = DigitalSimParams( - observables=[Observable("z", site) for site in range(num_qubits)], +Start with one excitation at site 10. Measuring $Z_i$ at every site gives the +occupation through $\langle n_i\rangle=(1-\langle Z_i\rangle)/2$. + +```{code-cell} python +from mqt.yaqs import DigitalSimParams, NoiseModel, Observable, Simulator, State + +center = length // 2 +basis = "0" * center + "1" + "0" * (length - center - 1) +state = State(length, initial="basis", basis_string=basis) +observables = [Observable("z", site) for site in range(length)] +params = DigitalSimParams( + observables=observables, + sample_layers=True, + num_traj=16, preset="fast", - num_traj=32, + random_seed=7, ) -noise_model = NoiseModel([ - {"name": "lowering", "sites": [site], "strength": 0.05} for site in range(num_qubits) -]) +sim = Simulator(show_progress=False) +``` -clean_result = sim.run(circuit_state, qc, circuit_params) -noisy_result = sim.run(State(num_qubits, initial="zeros"), qc, circuit_params, noise_model) -clean_z = np.array([float(np.real(v[0])) for v in clean_result.expectation_values]) -noisy_z = np.array([float(np.real(v[0])) for v in noisy_result.expectation_values]) - -fig, ax = plt.subplots(figsize=(5, 3), layout="constrained") -x = np.arange(num_qubits) -bar_width = 0.35 -ax.bar(x - bar_width / 2, clean_z, bar_width, label="unitary", color="0.55") -ax.bar(x + bar_width / 2, noisy_z, bar_width, label="with damping", color="C0") -ax.set_xticks(x, [rf"$\langle Z_{i}\rangle$" for i in range(num_qubits)]) -ax.set_ylim(-1.05, 1.05) -ax.set_ylabel("expectation value") -ax.set_title("Optional noise model on the fourth `run` argument") -ax.legend(frameon=False) +`sample_layers=True` records observables at the circuit start, at each barrier +labelled `SAMPLE_OBSERVABLES`, and after the final gates. Barrier labels are +case-insensitive; unlabelled barriers do not trigger sampling. These checkpoints +evaluate expectations without collapsing the state, unlike hardware mid-circuit +measurements. + +The noisy results average 16 trajectories, while a noiseless run uses one. +Parallel execution remains enabled. The documentation suppresses progress bars; +omit `show_progress=False` to see them. The preset controls numerical +tolerances, while `num_traj` controls sampling uncertainty. + +## 3. Match relaxation to circuit steps + +The analog model uses a uniform relaxation rate $\gamma$ per unit of physical +time. Circuit noise instead acts for one unit of noise time after each gate on +two or more qubits, using only processes supported on that gate's qubits. +Single-qubit gates and idle sites receive no noise. + +Using the same numerical strength for every gate would make damping depend on +the number of gates rather than the represented duration. For a site involved in +$m_i$ gates per Trotter step, set its circuit strength to + +$$ +\mathtt{strength}_i=\frac{\gamma\,\Delta t}{m_i}. +$$ + +Here the end sites encounter two gates per step and interior sites encounter +three. Dividing by these counts gives each site a total relaxation exposure of +$\gamma\Delta t$ per step. Noise is still interleaved with the gates, so the +finite-step evolution can differ from the continuous analog model. This rate +mapping describes a digital approximation to that model; hardware gate noise +should instead follow the device and its compiled operations. + +```{code-cell} python +rates = [0.0, 0.5, 1.5, 4.0] +results = {} +for rate in rates: + noise = None if rate == 0 else NoiseModel([ + {"name": "lowering", "sites": [site], + "strength": rate * dt / gate_exposures[site]} + for site in range(length) + ]) + results[rate] = sim.run(state, circuit, params, noise) ``` -In digital simulation the noise model is applied once per gate on two or more -qubits: after the unitary update, one noise layer acts on processes whose sites -all belong to the gate's qubits. Single-qubit gates and idle sites between the -gate qubits receive no noise. Each gate counts as one unit of noise time; to -model compiled depth, simulate the transpiled circuit. +## 4. Read the sampled dynamics -## 2. Noise-strength sweep +`expectation_values` contains one NumPy array per observable. The observable +order follows the supplied list, and each array follows checkpoint order. +Combine the arrays and convert $Z_i$ to occupation: -On a longer chain, sweep a global relaxation rate $\gamma$ and track how each -qubit's final $\langle Z_i \rangle$ moves toward $+1$ as damping dominates: +```{code-cell} python +times = dt * np.arange(steps + 1) +occupations = np.stack([ + (1 - np.asarray(results[rate].expectation_values).real) / 2 + for rate in rates +]) +print(occupations.shape) +``` -```{code-cell} ipython3 -num_qubits = 5 -circuit = create_ising_circuit(L=num_qubits, J=1.0, g=0.5, dt=0.1, timesteps=10) -state = State(num_qubits, initial="zeros") -sim_params = DigitalSimParams( - observables=[Observable("z", site) for site in range(num_qubits)], - num_traj=64, - max_bond_dim=8, - svd_threshold=1e-6, -) +The shape is `(4, 20, 13)`: relaxation rates by sites by checkpoints. Digital +results can store complex values; `.real` selects the real expectation of these +Hermitian observables. `result.trajectories` retains the per-trajectory data. -gammas = [1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1] -heatmap = np.empty((num_qubits, len(gammas))) -for j, gamma in enumerate(gammas): - damping = NoiseModel([ - {"name": "lowering", "sites": [site], "strength": gamma} for site in range(num_qubits) - ]) - result = sim.run(state, circuit, sim_params, damping) - for i in range(num_qubits): - heatmap[i, j] = float(np.real(result.expectation_values[i][0])) - -fig, ax = plt.subplots(figsize=(7, 4), layout="constrained") -colors = plt.cm.viridis(np.linspace(0.15, 0.85, num_qubits)) -for i in range(num_qubits): - ax.semilogx(gammas, heatmap[i], "o-", color=colors[i], linewidth=1.8, markersize=5, label=rf"$q_{i}$") -ax.set_xlabel(r"Relaxation rate $\gamma$") -ax.set_ylabel(r"$\langle Z_i \rangle$") -ax.set_ylim(-1.05, 1.05) -ax.legend(ncol=num_qubits, fontsize=8, loc="lower left", frameon=False) -ax.set_title("Final magnetization vs damping strength") -ax.grid(alpha=0.3, which="both") -``` +A standalone circuit has `result.times=None` because its gates do not define +physical durations. The `times` array above assigns physical times from our +Trotter construction. For final observables only, omit `sample_layers=True`; +each observable then has one sample. To collect counts too, supply `shots` and +YAQS distributes that total budget across the noisy observable trajectories. -## 3. Mid-circuit observables +## 5. Reconstruct the transport heatmaps -(mid-circuit-observables)= +The following panels use the analog guide's time window, noise strengths, and +shared square-root color scale. They show absolute occupation, without +normalizing by the remaining excitation. -```{note} -This section uses `num_traj=64` during the documentation build. Increase -`num_traj` locally for lower-variance layer curves. +```{code-cell} python +:tags: [hide-input] +import matplotlib.pyplot as plt +from matplotlib.colors import PowerNorm +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["STIXGeneral"], + "mathtext.fontset": "stix", + "font.size": 11, + "axes.labelsize": 11, + "axes.linewidth": 0.7, + "xtick.labelsize": 10, + "ytick.labelsize": 10, + "xtick.direction": "in", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "legend.fontsize": 9, + "legend.frameon": False, + "lines.linewidth": 1.6, + "figure.constrained_layout.use": True, + "savefig.dpi": 180, +}) +fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.4), sharex=True, sharey=True) +for ax, values, rate, panel in zip(axes.flat, occupations, rates, "abcd", strict=True): + image = ax.pcolormesh(times, np.arange(length), values, shading="auto", + cmap="cividis", norm=PowerNorm(0.5, vmin=0, vmax=1), + rasterized=True) + label = "Noiseless" if rate == 0 else rf"$\gamma={rate:g}$" + ax.set_title(f"({panel}) {label}", loc="left", fontsize=11) + ax.set(xticks=[0, 1, 2, 3], yticks=[0, 5, 10, 15, 19]) +for ax in axes[-1]: + ax.set_xlabel(r"Represented time $t$") +for ax in axes[:, 0]: + ax.set_ylabel(r"Site $i$") +fig.colorbar(image, ax=axes.ravel().tolist(), + label=r"Occupation $\langle n_i\rangle$", ticks=[0, 0.1, 0.5, 1]) +plt.show() ``` -Set `sample_layers=True` on -{class}`~mqt.yaqs.core.data_structures.simulation_parameters.DigitalSimParams` -and insert barriers labelled `SAMPLE_OBSERVABLES` (case-insensitive) where you -want measurements. YAQS records observables at the circuit start, after each -labelled barrier, and after the final gate layer. - -The example below starts from $\ket{+}^{\otimes n}$, applies a chain of $R_{ZZ}$ -entanglers, and tracks how amplitude damping gradually drives each -$\langle Z_i \rangle$ toward $+1$. Only barriers labelled `SAMPLE_OBSERVABLES` -trigger sampling; unlabelled barriers are ignored. +**Digital reconstruction of excitation transport and loss.** Exchange gates +spread the initial excitation along the chain, while increasing relaxation +suppresses occupation at later times. The broad patterns reproduce the analog +example, with differences from Trotter splitting and finite trajectory sampling. -```{code-cell} ipython3 -from qiskit.circuit import QuantumCircuit +The heatmaps alone do not tell us how close the circuit is to Hamiltonian +evolution. To separate the approximation in the gates from sampling noise, we +next compare the noiseless circuit with an analog reference and a finer circuit. -layer_qubits = 5 -qc = QuantumCircuit(layer_qubits) +## 6. Compare with analog evolution -for segment in range(6): - for i in range(layer_qubits - 1): - qc.rzz(0.7, i, i + 1) - if segment < 5: - qc.barrier(label="SAMPLE_OBSERVABLES") +Run the same Hamiltonian without noise, then halve the circuit step while +keeping the total duration fixed. Select every second checkpoint of the finer +circuit to compare at the original sampling times. -noise_factor = 0.1 -layer_noise = NoiseModel([ - {"name": "lowering", "sites": [i], "strength": noise_factor} for i in range(layer_qubits) -]) +```{code-cell} python +from mqt.yaqs import AnalogSimParams, Hamiltonian -layer_state = State(layer_qubits, initial="x+", pad=16) -layer_params = DigitalSimParams( - observables=[Observable("z", i) for i in range(layer_qubits)], - num_traj=64, - sample_layers=True, - max_bond_dim=12, +hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0) +analog_params = AnalogSimParams( + observables=observables, elapsed_time=steps * dt, dt=dt, preset="fast", ) +analog = sim.run(state, hamiltonian, analog_params) +analog_occupation = (1 - np.asarray(analog.expectation_values)) / 2 -layer_result = sim.run(layer_state, qc, layer_params, layer_noise) -layer_traj = np.vstack([np.real(v) for v in layer_result.expectation_values]) - -fig, ax = plt.subplots(figsize=(8, 4), layout="constrained") -depth = np.arange(layer_traj.shape[1]) -qubit_labels = [rf"$q_{i}$" for i in range(layer_qubits)] -im = ax.imshow( - layer_traj, - aspect="auto", - origin="lower", - vmin=-1, - vmax=1, - extent=(-0.5, layer_traj.shape[1] - 0.5, -0.5, layer_qubits - 0.5), -) -ax.set_xlabel("Sampling index") -ax.set_ylabel("Qubit") -ax.set_xticks(depth) -ax.set_yticks(range(layer_qubits), qubit_labels) -ax.set_title(r"Mid-circuit $\langle Z \rangle$ under damping") -fig.colorbar(im, ax=ax, shrink=0.9, label=r"$\langle Z \rangle$") +fine_circuit, _ = xy_circuit(length, dt / 2, steps * 2) +fine_result = sim.run(state, fine_circuit, params) +fine_occupation = (1 - np.asarray(fine_result.expectation_values).real[:, ::2]) / 2 ``` -The checkpoint index is a circuit-analysis coordinate rather than physical time. -Standalone digital results leave `result.times` as `None`; plot against -`np.arange(len(values))` for circuit depth. In a digital–analog -`SimulationProgram`, digital checkpoints are placed on `result.times` at their -segment's physical-time offset, preserving order as coincident samples. +The first panel compares final occupation profiles. The second sums occupation +over sites and compares noisy circuit estimates with the analog model's decay +law, $N(t)=e^{-\gamma t}$. This law holds because the initial state contains one +excitation, the Hamiltonian conserves excitation, and relaxation is uniform. + +```{code-cell} python +:tags: [hide-input] +fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.2)) +ax = axes[0] +for values, label, style, color in ( + (analog_occupation, "Analog", "-", "0.2"), + (occupations[0], r"Circuit $\Delta t=0.25$", "o", "#0072B2"), + (fine_occupation, r"Circuit $\Delta t=0.125$", "+", "#D55E00"), +): + ax.plot(np.arange(length), values[:, -1], style, color=color, + label=label, markersize=4) +ax.set(xlabel="Site", ylabel=r"Final occupation $\langle n_i\rangle$", + xticks=[0, 5, 10, 15, 19]) +ax.set_title("(a) Noiseless transport", loc="left", fontsize=11) +ax.legend(loc="upper center", fontsize=8) + +ax = axes[1] +colors = ["0.2", "#0072B2", "#009E73", "#D55E00"] +fine_times = np.linspace(0, times[-1], 200) +for rate, values, color in zip(rates, occupations, colors, strict=True): + ax.plot(times, values.sum(axis=0), "o", color=color, markersize=3, + label="Noiseless" if rate == 0 else rf"$\gamma={rate:g}$") + ax.plot(fine_times, np.exp(-rate * fine_times), "--", color=color, linewidth=1) +ax.set(xlabel=r"Represented time $t$", ylabel=r"Total excitation $N(t)$", + xlim=(0, 3), ylim=(0, 1.08), xticks=[0, 1, 2, 3]) +ax.set_title("(b) Excitation survival", loc="left", fontsize=11) +ax.legend(ncols=2, loc="upper right", bbox_to_anchor=(1, 0.9), fontsize=8) +plt.show() +``` + +**Analog and digital transport compared at the same duration.** The final +noiseless profiles agree closely, and a smaller Trotter step reduces the +circuit's splitting error. Noisy survival estimates follow the uniform-loss +decay within the resolution of this 16-trajectory example; dashed lines show the +continuous-model reference. + +This comparison connects the two workflows through their physical model, rather +than equating a circuit's gate count with elapsed time. Check Trotter-step +convergence separately from MPS tolerances and trajectory uncertainty. For noisy +refinement, rebuild the circuit and rescale the strengths using the new step and +gate exposures. Finite-step noise splitting can also affect the spatial profile; +agreement of total excitation alone does not validate that profile. + +(circuit-qasm-inputs)= -## 4. OpenQASM inputs +## 7. OpenQASM inputs Pass an OpenQASM 2 source string (or file path) directly to {meth}`~mqt.yaqs.Simulator.run` instead of building a @@ -235,12 +331,13 @@ OpenQASM 3 requires `uv pip install mqt-yaqs[qasm3]`. {class}`~mqt.yaqs.EquivalenceChecker` accepts the same path and string forms; see {doc}`equivalence_checking`. -## 5. Gate application modes +## 8. Gate application modes `DigitalSimParams.gate_mode` selects how two-qubit gates are applied to the MPS. The default `"mpo"` uses extended gate MPOs for long-range pairs; `"tdvp"` uses a local TDVP window when an analytic generator is available. See -{doc}`simulation_parameters` and {doc}`custom_gates` for the full matrix. +{doc}`simulation_parameters` for the available modes and +{ref}`circuit-custom-gates` for matrix-backed gates. Below, a long-range `cx` on qubits 0 and 2 is simulated noiselessly with both modes: @@ -265,13 +362,150 @@ for mode in ("mpo", "tdvp"): print({mode: round(value, 4) for mode, value in z0_by_mode.items()}) ``` -## 6. Related topics +(circuit-custom-gates)= + +## 9. Supply custom gates + +Add a custom unitary to a Qiskit circuit with `UnitaryGate`. YAQS translates the +matrix automatically, so no gate registration is needed. This two-qubit example +applies a phase only to the $|11\rangle$ component: + +```{code-cell} python +from qiskit.circuit.library import UnitaryGate + +custom_unitary = np.diag([1, 1, 1, np.exp(0.4j)]) +custom_circuit = QuantumCircuit(2) +custom_circuit.h([0, 1]) +custom_circuit.append(UnitaryGate(custom_unitary), [0, 1]) + +custom_params = DigitalSimParams(observables=[Observable("x", 0)]) +custom_sim = Simulator(show_progress=False) +custom_result = custom_sim.run(State(2, initial="zeros"), custom_circuit, custom_params) +print(custom_result.expectation_values[0]) +``` + +The final expectation is $\langle X_0\rangle=(1+\cos 0.4)/2$, about 0.9605. The +matrix uses Qiskit's qubit ordering; YAQS converts it to its internal gate +layout. The same input works with `shots`, noise, and sampling checkpoints under +the circuit rules described above. + +Custom gate bodies in {ref}`circuit-qasm-inputs` follow the same translation +path. Unknown unitary operations use a matrix fallback on up to eight qubits; +decompose larger operations first. A matrix-backed gate has no analytic +generator: TDVP gate modes use direct local updates for adjacent pairs and the +MPO path for separated sites or larger gates. Keep `gate_mode="mpo"` unless you +need another method. + +:::{dropdown} Supported instructions and gate translation + +Bind symbolic Qiskit parameters before simulation. YAQS translates known gate +names through its gate library; other operations must provide a unitary matrix +through Qiskit's `to_matrix()` or `Operator`. This also supports gates defined +by a reusable Qiskit circuit or an OpenQASM gate body. Use a distinct name for a +custom operation, since a name matching a built-in alias selects that built-in +implementation. + +Terminal measurements are removed before simulation; request `shots` for +readout. Measurements followed by further operations on the measured qubits are +unsupported. `reset`, `delay`, `store`, classical conditions, and control-flow +instructions are also unsupported. Ordinary barriers do not change the state; +barriers labelled `SAMPLE_OBSERVABLES` mark sampling points. + +Digital gates require qubit target sites. Idle sites can have other local +dimensions, but `gate_mode="swaps"` cannot route through a non-qubit site. + +Built-in `ccx`, `ccz`, and `cswap` gates translate without decomposition. +Simulation applies gates on three or more qubits through an MPO, except +supported product generators such as `ccx` and `ccz` in TDVP modes. The +`"swaps"` mode also uses the MPO path for these larger gates. + +`EquivalenceChecker` accepts the same unitary and OpenQASM inputs. Gates on more +than two qubits require its `"matrix"` backend; decompose them before using the +`"mpo"` backend. See {doc}`equivalence_checking` for backend choice and +measurement restrictions. Translation details and the supported alias list are +in {mod}`~mqt.yaqs.digital.utils.dag_utils`. + +::: + +:::{dropdown} Low-level gate objects + +Application code should supply Qiskit circuits. For code that works directly +with YAQS gate kernels, `GateLibrary.custom` constructs a matrix-backed +{class}`~mqt.yaqs.core.libraries.gate_library.BaseGate`: + +```python +from mqt.yaqs.core.libraries.gate_library import GateLibrary + +gate = GateLibrary.custom(np.eye(4, dtype=complex)) +gate.name = "my_gate" +gate.set_sites(0, 1) +``` + +This constructor checks that the matrix is square with dimension $2^n$. The +caller must supply a finite unitary. The resulting fields are: + +| Field | Meaning | +| ------------- | -------------------------------------------------------------- | +| `matrix` | Gate matrix in YAQS gate order. | +| `interaction` | Number of target qubits, inferred from the matrix size. | +| `sites` | Target sites in their declared order. | +| `tensor` | Gate tensor; `set_sites` reshapes gates on two or more qubits. | +| `generator` | Optional local factors for a product-form TDVP generator. | +| `name` | Gate identifier. | + +In a manually supplied gate matrix, the first tensor factor acts on the first +declared site. Qiskit translation handles its different matrix convention +automatically. Creating this object does not register a new Qiskit gate or make +it a valid operator argument to `Simulator.run`. + +Built-in gates subclass `BaseGate` and prepare tensors and optional generators +in `set_sites`. See {class}`~mqt.yaqs.core.libraries.gate_library.CX` and +{class}`~mqt.yaqs.core.libraries.gate_library.CCX` for examples. + +::: + +:::{dropdown} Product generators for digital TDVP + +A TDVP-capable gate has one $2\times2$ generator factor per target site. The +factors define a product $G$ whose exponential at evolution time one must +reproduce the gate, $U=\exp(-iG)$. For example, a ZZ phase rotation has +$G=(\theta Z/2)\otimes Z$: + +```python +from scipy.linalg import expm + +from mqt.yaqs.core.libraries.gate_library import GateLibrary + +theta = 0.3 +pauli_z = np.diag([1.0, -1.0]) +generator_factors = [theta * pauli_z / 2, pauli_z] +phase_unitary = expm(-1j * np.kron(*generator_factors)) +phase_gate = GateLibrary.custom(phase_unitary) +phase_gate.set_sites(0, 2) +phase_gate.generator = generator_factors +``` + +The factors follow the declared `sites` order. YAQS places identities between +separated factors when constructing the generator MPO. The caller must check +that the full generator is Hermitian and reproduces the unitary; YAQS does not +verify that relation. This low-level assignment does not change how a Qiskit +`UnitaryGate` is translated. + +Digital generator evolution requires `tdvp_mode="2site"`. `tdvp_sweeps` divides +the total generator time of one into substeps. TDVP gate application remains +approximate and can miss required bond growth; see {doc}`simulation_parameters` +for accuracy limits. Single-qubit gates always use direct contraction. For +implementation details, see +{func}`~mqt.yaqs.digital.digital_tjm.construct_generator_mpo`. + +::: + +## Related topics - {doc}`digital_analog_simulation` — combine digital operations with analog evolution in one program - {doc}`circuit_shots` — computational-basis shot histograms with {class}`~mqt.yaqs.DigitalSimParams` -- {doc}`custom_gates` — custom unitaries and gate translation - {doc}`realistic_noise_models` — log-normal and other distributed noise strengths - {doc}`equivalence_checking` — verify that two circuits implement the same diff --git a/docs/examples/circuit_shots.md b/docs/examples/circuit_shots.md index f4c728748..62d1a896e 100644 --- a/docs/examples/circuit_shots.md +++ b/docs/examples/circuit_shots.md @@ -2,141 +2,237 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 600 + execution_timeout: 180 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Shot-Based Circuit Simulation -Digital circuit simulation can sample computational-basis **shots** after -circuit evolution, mimicking hardware readout statistics. Use -{class}`~mqt.yaqs.DigitalSimParams` with `shots=...` and read bitstring counts -from {attr}`~mqt.yaqs.Result.counts`. +Relaxation changes which bitstrings a quantum circuit produces. We study this +change by preparing a **16-qubit graph state** and sampling its readout at +several damping strengths. Grouping the outcomes by the number of excited qubits +makes the loss of excitation visible without plotting all $2^{16}$ bitstrings. -For expectation-value simulation and mid-circuit observables, see -{doc}`circuit_observables`. For parameter presets and truncation settings, see -{doc}`simulation_parameters`. +This guide extends the circuit-readout example in {doc}`quickstart` using the +standard YAQS installation. YAQS evolves a matrix product state (MPS) through +the circuit and samples the final state in the computational basis. Run the +cells in order in a notebook. For a script, use the `if __name__ == "__main__":` +guard shown in {doc}`simulator_initialization`. -You can pass an OpenQASM file path or raw OpenQASM string to -{meth}`~mqt.yaqs.Simulator.run` instead of building a -{class}`qiskit.circuit.QuantumCircuit` in Python (OpenQASM 3 requires -`uv pip install mqt-yaqs[qasm3]`). +## 1. Prepare the circuit and initial state -## 1. Circuit +Start from $|0\rangle^{\otimes16}$, apply a Hadamard gate to each qubit, then +entangle neighboring qubits with CZ gates. These gates change relative phases +without changing computational-basis probabilities, so the ideal readout has +equal probability for every bitstring. -We use a shallow randomized ansatz—single-qubit $R_y$ rotations followed by a -linear chain of $CZ$ gates—typical of variational benchmarks. +```{code-cell} python +from qiskit import QuantumCircuit -```{code-cell} ipython3 -import numpy as np -from qiskit.circuit import QuantumCircuit +from mqt.yaqs import State -num_qubits = 6 +num_qubits = 16 circuit = QuantumCircuit(num_qubits) -rng = np.random.default_rng(42) -for i in range(num_qubits): - circuit.ry(float(rng.uniform(0.6, 2.2)), i) -for i in range(num_qubits - 1): - circuit.cz(i, i + 1) +circuit.h(range(num_qubits)) +for site in range(num_qubits - 1): + circuit.cz(site, site + 1) circuit.measure_all() + +state = State(num_qubits, initial="zeros") ``` -## 2. Initial state and noise model +`State` uses an MPS by default. `shots` requests final computational-basis +sampling, including when a circuit has no explicit measurement gates. YAQS +accepts the terminal measurements above; it does not support measurements +followed by further gates on the measured qubits or classical feedback. -```{code-cell} ipython3 -from mqt.yaqs import NoiseModel, State +## 2. Set the sampling budget and damping -state = State(num_qubits, initial="zeros") +Each shot produces one bitstring. We use 256 shots per run to keep the example +quick, with the `fast` preset controlling numerical tolerances. -gamma = 0.5 -noise_model = NoiseModel([ - {"name": "lowering", "sites": [i], "strength": gamma} for i in range(num_qubits) +```{code-cell} python +from mqt.yaqs import DigitalSimParams, NoiseModel + +params = DigitalSimParams(shots=256, preset="fast", random_seed=7) +damping_rate = 1.5 +noise = NoiseModel([ + {"name": "lowering", "sites": [site], "strength": damping_rate} + for site in range(num_qubits) ]) ``` -Amplitude damping relaxes each qubit toward $\ket{0}$. During circuit execution -the noise channels compete with unitary spreading, so readout mass shifts toward -the all-zeros bitstring compared with the noiseless run. +The `lowering` channel relaxes $|1\rangle$ toward $|0\rangle$. In circuit +simulation, `strength` is a Lindblad rate per unit of gate noise time, rather +than a direct error probability. YAQS applies one unit of noise time after each +gate on two or more qubits, using only noise processes supported entirely on +that gate's qubits. Single-qubit gates and idle sites receive no noise. + +Here, each end qubit participates in one CZ gate, while each interior qubit +participates in two. The noise therefore acts during entangling operations, +rather than as a separate readout-error channel. Simulate a transpiled circuit +when its compiled gates should determine the noise opportunities. -## 3. Simulation parameters and run +`shots` sets the total sample budget and must be supplied explicitly. In a noisy +shots-only run, YAQS uses one stochastic trajectory per shot. The noiseless run +evolves once and samples that final state repeatedly. Setting `num_traj` does +not change a shots-only budget. See {doc}`simulation_parameters` for presets and +the separate roles of shots and trajectories. -`DigitalSimParams` requires an explicit `shots` count (not covered by accuracy -presets). We run the **same** circuit twice: once without noise (ideal readout -statistics) and once with on-site amplitude damping. +## 3. Run the noiseless and noisy cases -```{code-cell} ipython3 -from mqt.yaqs import Simulator, DigitalSimParams +Use the same circuit, state, and parameters for both runs so that the comparison +isolates the added noise. Initialize the simulator separately, then omit the +noise model for the baseline. -sim_params = DigitalSimParams(shots=1024, max_bond_dim=16, svd_threshold=1e-6, random_seed=7) +```{code-cell} python +from mqt.yaqs import Simulator -sim = Simulator(show_progress=False) -result_clean = sim.run(state, circuit, sim_params) -result_noisy = sim.run(state, circuit, sim_params, noise_model) +simulator = Simulator(show_progress=False) +ideal = simulator.run(state, circuit, params) +damped = simulator.run(state, circuit, params, noise) ``` -For log-normal disorder on relaxation rates, see {doc}`realistic_noise_models`. -To collect observables and shots in one call, set both fields on -{class}`~mqt.yaqs.DigitalSimParams`. In a noisy combined run, `shots` is the -**total** sample budget distributed across `num_traj` stochastic trajectories; -see {doc}`simulation_parameters`. +Parallel execution remains enabled by default. `show_progress=False` suppresses +bars in the documentation; omit it to see progress. The seed repeats the random +streams for jumps and sampled disorder for the same configuration, but does not +seed final readout sampling. Shot counts can therefore vary between runs. -## 4. Noiseless vs noisy readout histogram +## 4. Read individual outcomes -Bitstrings are sorted lexicographically among **low Hamming-weight** outcomes -(at most two excitations), where amplitude damping concentrates probability. -`Result.counts` keys are integers (site 0 is the least-significant bit); see -{doc}`simulation_parameters` for the encoding. +`Result.counts` maps integer outcomes to their counts. Site 0 is the +least-significant bit, so outcome 1 means that only site 0 is excited. +Formatting an outcome as binary places site 0 on the right. -```{code-cell} ipython3 ---- -mystnb: - image: - width: 90% - align: center ---- -import matplotlib.pyplot as plt +```{code-cell} python +most_common = sorted(damped.counts.items(), key=lambda item: item[1], reverse=True)[:5] +for outcome, count in most_common: + bitstring = format(outcome, f"0{num_qubits}b") + print(f"{bitstring}: {count} shots, estimated probability {count / params.shots:.3f}") +``` + +The counts sum to 256. An outcome absent from the dictionary was not observed; +its underlying probability need not be zero. To read a specific qubit, use +`(outcome >> site) & 1`. Bitstring counts preserve spatial information that the +grouped histogram below discards. + +## 5. Compare damping strengths + +The quickstart compares a noiseless run with one damped run. Here we add two +rates to show how the distribution moves as damping increases, reusing the +baseline and strong-noise calculations above. + +```{code-cell} python import numpy as np -def format_bitstring(key: int, num_bits: int) -> str: - """Format a little-endian integer outcome as a zero-padded bitstring.""" - return format(key, f"0{num_bits}b") - -def hamming_weight(key: int) -> int: - return key.bit_count() - -# Low-weight outcomes (|0...0> and nearby strings) where T1 noise accumulates -keys = sorted( - k - for k in set(result_clean.counts) | set(result_noisy.counts) - if hamming_weight(k) <= 2 -) -bitstrings = [format_bitstring(k, num_qubits) for k in keys] -x = np.arange(len(keys)) -width = 0.38 - -clean_vals = [result_clean.counts.get(k, 0) for k in keys] -noisy_vals = [result_noisy.counts.get(k, 0) for k in keys] - -fig, ax = plt.subplots(figsize=(9, 4), layout="constrained") -ax.bar(x - width / 2, clean_vals, width, label="noiseless", color="black", alpha=0.75) -ax.bar(x + width / 2, noisy_vals, width, label="noisy (amplitude damping)", color="tab:orange", alpha=0.85) -ax.set_xticks(x) -ax.set_xticklabels(bitstrings, rotation=45, ha="right", fontsize=8) -ax.set_xlabel("Bitstring (Hamming weight $\\leq 2$)") -ax.set_ylabel("Counts") -ax.set_title(f"Shot readout: relaxation drives counts toward $|0\\rangle^{{\\otimes {num_qubits}}}$") -ax.legend() -ax.grid(alpha=0.3, axis="y") +rates = [0.0, 0.1, 0.5, damping_rate] +results = {0.0: ideal, damping_rate: damped} +for rate in rates[1:-1]: + rate_noise = NoiseModel([ + {"name": "lowering", "sites": [site], "strength": rate} + for site in range(num_qubits) + ]) + results[rate] = simulator.run(state, circuit, params, rate_noise) + +probabilities = np.zeros((len(rates), num_qubits + 1)) +for row, rate in enumerate(rates): + for outcome, count in results[rate].counts.items(): + probabilities[row, outcome.bit_count()] += count / params.shots +``` + +`outcome.bit_count()` gives the number of excited qubits, often called the +Hamming weight. Each row of `probabilities` has 17 bins, from zero to 16 +excitations, and sums to one. Every outcome contributes, including the tails of +the distribution. + +The following histograms use shared axes. The gray outline repeats the sampled +noiseless baseline in the noisy panels so that the shift remains easy to +compare. + +```{code-cell} python +:tags: [hide-input] +import matplotlib.pyplot as plt +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["STIXGeneral"], + "mathtext.fontset": "stix", + "font.size": 11, + "axes.labelsize": 11, + "axes.linewidth": 0.7, + "xtick.labelsize": 10, + "ytick.labelsize": 10, + "xtick.direction": "in", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "legend.fontsize": 9, + "legend.frameon": False, + "figure.constrained_layout.use": True, + "savefig.dpi": 180, +}) +excitation_number = np.arange(num_qubits + 1) +colors = ["#0072B2", "#009E73", "#D55E00", "#CC79A7"] +fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.6), sharex=True, sharey=True) +for ax, rate, probability, color, panel in zip( + axes.flat, rates, probabilities, colors, "abcd", strict=True, +): + if rate != 0: + ax.bar(excitation_number, probabilities[0], width=0.85, + facecolor="none", edgecolor="0.55", linewidth=0.9, label="Noiseless") + ax.bar(excitation_number, probability, width=0.65, color=color, + edgecolor="white", linewidth=0.4, alpha=0.9, + label="Noiseless" if rate == 0 else "Damped") + label = "Noiseless" if rate == 0 else rf"$\gamma={rate:g}$" + ax.set_title(f"({panel}) {label}", loc="left", fontsize=11) + ax.set(xlim=(-0.7, num_qubits + 0.7), xticks=np.arange(0, num_qubits + 1, 4)) + ax.legend(loc="upper right") +for ax in axes[-1]: + ax.set_xlabel("Number of excited qubits") +for ax in axes[:, 0]: + ax.set_ylabel("Measured probability") plt.show() ``` +**Readout shifts toward fewer excitations as damping increases.** Each panel +contains 256 shots from the same 16-qubit circuit. The noiseless distribution is +centered near eight excitations, while strong damping concentrates probability +near zero. The baseline outlines use the same samples in all panels. + +Without noise, each qubit has excitation probability $1/2$, so the excitation +number follows a binomial distribution. The graph state's phases do not appear +in this measurement basis. A matching histogram alone therefore cannot verify +that the intended entangled state was prepared. + +With noise, the shift measures excitation loss during the CZ gates. Different +bitstrings can have the same excitation number, so use `counts` or site-resolved +observables when the spatial distribution matters. For a bin of probability $p$, +independent shots have sampling uncertainty of order +$\sqrt{p(1-p)/\mathtt{shots}}$. Increase `shots` to reduce these fluctuations; +use tighter presets separately to check numerical error. + +## Other measurement workflows + +To obtain expectation values instead of counts, supply `observables` on +`DigitalSimParams`; see {doc}`circuit_observables`. You can request both outputs +in one call. In a noisy combined run, `num_traj` sets the observable ensemble, +and YAQS distributes the total `shots` across those trajectories. Multiple shots +from one trajectory share its noise history, so their uncertainty differs from +independent one-shot trajectories. + +You can pass an OpenQASM source string or file path to `Simulator.run` in place +of a Qiskit circuit. OpenQASM 3 requires the `qasm3` extra. See +{doc}`circuit_observables` for an executable example, mid-circuit observable +checkpoints, and gate-application modes. + ## Related topics -- {doc}`circuit_observables` — expectation values and mid-circuit sampling -- {doc}`custom_gates` — custom unitaries and gate translation +- {doc}`simulation_parameters` — sampling budgets and accuracy presets +- {doc}`realistic_noise_models` — other channels, custom operators, and disorder +- {ref}`circuit-custom-gates` — custom unitaries and gate translation +- {doc}`equivalence_checking` — compare circuit behavior diff --git a/docs/examples/custom_gates.md b/docs/examples/custom_gates.md index 0efc73ba4..df4e51f77 100644 --- a/docs/examples/custom_gates.md +++ b/docs/examples/custom_gates.md @@ -1,341 +1,12 @@ -# Custom Gates in YAQS - -```{note} -This is a **reference guide** with static code blocks; it is not executed during -the documentation build. Runnable circuit examples are in -{doc}`circuit_observables` and {doc}`equivalence_checking`. -``` - -YAQS represents every digital gate as a -{class}`~mqt.yaqs.core.libraries.gate_library.BaseGate` instance from -{class}`~mqt.yaqs.core.libraries.gate_library.GateLibrary`. Circuits enter YAQS -as Qiskit {class}`qiskit.circuit.QuantumCircuit` objects; the library converts -each DAG operation into an internal gate, then applies that gate during -**circuit simulation** or **equivalence checking**. - -This page explains that pipeline, how built-in and custom gates are translated, -and how to supply an analytic **generator** when you want long-range and -multi-qubit gates to use the TDVP window path. - -## How YAQS handles gates end-to-end - -```mermaid -flowchart TD - qc[QuantumCircuit] --> dag[DAGCircuit] - dag --> convert[convert_dag_to_tensor_algorithm] - convert --> hardcoded[GateLibrary alias] - convert --> fallback[GateLibrary.custom matrix] - hardcoded --> bg[BaseGate] - fallback --> bg - bg --> sim[Simulator / digital_tjm] - bg --> equiv[EquivalenceChecker] - sim --> mps[MPS site updates] - equiv --> mpo[MPO or dense tensor backend] -``` - -### Internal gate objects - -Each {class}`~mqt.yaqs.core.libraries.gate_library.BaseGate` carries: - -| Field | Role | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `matrix` | Dense unitary as a `2^n × 2^n` complex matrix (`n` = number of qubits the gate acts on). | -| `tensor` | Tensor layout used in MPS/MPO contractions (shape `(2,) * (2 * interaction)` after `set_sites` for gates on two or more qubits). | -| `interaction` | Number of qubits (`1`, `2`, …); inferred from `matrix` size (`dim = 2**interaction`). | -| `sites` | MPS site indices the gate acts on, set via `set_sites(...)`. | -| `generator` | Optional. For built-ins that support TDVP: a list with one `2×2` local operator per qubit used to build a generator MPO. Not set by plain `GateLibrary.custom(...)`. | -| `name` | Qiskit operation name or `"custom"`. | - -Built-in gates (`cx`, `h`, `rxx`, …) are classes on -{class}`~mqt.yaqs.core.libraries.gate_library.GateLibrary`. -`GateLibrary.custom(matrix)` returns a generic -{class}`~mqt.yaqs.core.libraries.gate_library.BaseGate` backed only by the -unitary matrix. - -### Translation from Qiskit - -{func}`~mqt.yaqs.digital.utils.dag_utils.convert_dag_to_tensor_algorithm` walks -a {class}`qiskit.dagcircuit.DAGCircuit` and produces a list of `BaseGate` -objects. - -For each operation node: - -1. **Unsupported instructions** raise `ValueError`: `reset`, `delay`, `store`, - mid-circuit `measure`, classically controlled ops, and control-flow - instructions (`if_else`, `for_loop`, …). -2. **`barrier`** nodes are skipped during conversion (ignored for unitary - evolution). -3. **Known Qiskit names** (for example `h`, `cx`, `u3`, `u1`, `swap`, `rxx`) map - to hardcoded {class}`~mqt.yaqs.core.libraries.gate_library.GateLibrary` - classes via `getattr(GateLibrary, name)`. -4. **Any other unitary** falls back to the **matrix path**: Qiskit's - `to_matrix()` / {class}`qiskit.quantum_info.Operator` data is wrapped as - `GateLibrary.custom(matrix)` with the Qiskit `op.name` preserved. - -```{note} -**Three-qubit and larger gates** translate natively: `ccx` (Toffoli), `ccz`, and -`cswap` are hardcoded {class}`~mqt.yaqs.core.libraries.gate_library.GateLibrary` -classes, and any other multi-qubit unitary on up to eight qubits is accepted on -the matrix fallback path. -``` - -Symbolic parameters must be bound before translation; unbound -{class}`qiskit.circuit.Parameter` objects raise `ValueError`. - -### Application during circuit simulation - -{class}`~mqt.yaqs.Simulator` runs digital circuits through -{mod}`~mqt.yaqs.digital.digital_tjm`: - -- **Single-qubit gates** — contract the gate tensor onto the corresponding MPS - site. -- **Two-qubit gates** — routed by `DigitalSimParams.gate_mode` (see - {doc}`simulation_parameters`): - - **`mpo`** (default) — TEBD/SVD on nearest-neighbor pairs; long-range gates - via extended gate MPO. - - **`swaps`** — TEBD with SWAP routing for long-range pairs. - - **`tdvp`** — TEBD on nearest-neighbor pairs; long-range pairs use **2TDVP** - only when the gate has a `generator`; otherwise the MPO path is used. - - **`full-tdvp`** — 2TDVP on every two-qubit gate that has a `generator`; - generator-less gates fall back to TEBD (NN) or MPO (long-range). -- **Gates on three or more qubits** — in the TDVP modes, gates with a - `generator` (`ccx`, `ccz`) use the generator MPO and TDVP window; all other - cases, including `gate_mode="swaps"`, use the extended gate MPO. - -**Measurements** are handled differently from unitary conversion: - -- During **simulation**, terminal `measure` nodes are removed from the live DAG - so evolution can proceed; sampling uses the remaining circuit structure. -- During **equivalence checking**, final measurements are stripped from both - circuits before comparison; mid-circuit measurements raise `ValueError`. - -Plain `barrier` instructions are dropped in simulation except barriers labelled -`SAMPLE_OBSERVABLES` (used for digital layer sampling). - -### Equivalence checking - -{class}`~mqt.yaqs.EquivalenceChecker` compares two circuits by forming -$W = U_1 U_2^\dagger$ and testing whether $W$ is identity-like up to global -phase. Custom and QASM-defined gates use the same translation path as -simulation; unknown unitaries work via matrix fallback. Gates on more than two -qubits require the matrix backend; use `representation="matrix"` explicitly when -`auto` would select the MPO backend. See {doc}`equivalence_checking` for backend -choice (`representation="mpo"` recommended for one- and two-qubit circuits at -scale). - --- - -## Custom gates from Qiskit (most common) - -You do **not** need to register custom gates manually when they come from -Qiskit. - -### `UnitaryGate` - -```python -import numpy as np -from qiskit import QuantumCircuit -from qiskit.circuit.library import UnitaryGate - -u = np.array([[0, 1], [1, 0]], dtype=complex) -qc = QuantumCircuit(1) -qc.append(UnitaryGate(u), [0]) -``` - -YAQS translates this to a matrix-backed `BaseGate` named `"unitary"` with **no** -`generator` attribute. - -### OpenQASM 2 custom gates - -OpenQASM 2 lets you declare reusable gate bodies (fixed or parameterized) and -call them like built-in instructions. Pass a file path or raw OpenQASM string -directly to {meth}`~mqt.yaqs.EquivalenceChecker.check` or -{meth}`~mqt.yaqs.Simulator.run`, or load with `qiskit.qasm2.loads` / `load` -first. Qiskit produces a {class}`qiskit.circuit.QuantumCircuit` whose operations -retain the **user-defined gate names**. - -YAQS does not maintain a separate registry of QASM gate definitions. Each DAG -node is translated by name: if the name matches a -{class}`~mqt.yaqs.core.libraries.gate_library.GateLibrary` entry, the hardcoded -class is used; otherwise YAQS builds a matrix-backed gate from Qiskit's unitary -representation (matrix fallback). You do not need to inline or transpile custom -gates to a fixed basis set before simulation or equivalence checking. - -```python -from mqt.yaqs import EquivalenceChecker, Simulator, State, DigitalSimParams - -qasm = """ -OPENQASM 2.0; -include "qelib1.inc"; - -gate entangle a,b { - h a; - cx a,b; -} - -qreg q[2]; -entangle q[0], q[1]; -""" - -checker = EquivalenceChecker(representation="mpo") -checker.check(qasm, qasm) - -state = State(2, initial="zeros") -Simulator(show_progress=False).run(state, qasm, DigitalSimParams(shots=128, max_bond_dim=4)) -``` - -The same rules apply to **legacy or backend-specific Qiskit gate names** that -are not in the hardcoded alias list: if Qiskit can supply a unitary matrix, -translation succeeds via matrix fallback. Names that already have -{class}`~mqt.yaqs.core.libraries.gate_library.GateLibrary` aliases (including -common single-qubit parameterizations) continue to use the built-in -implementations. - -### TDVP behaviour for matrix-backed custom gates - -Matrix-backed custom gates have **no analytic generator**. In `gate_mode="tdvp"` -or `"full-tdvp"`: - -| Gate width | Routing | -| -------------------------------- | --------------------------------------------- | -| Nearest-neighbor (\|i − j\| = 1) | TEBD/SVD | -| Long-range (\|i − j\| > 1) | Extended gate MPO (same as `gate_mode="mpo"`) | -| Three or more qubits | Extended gate MPO (same as `gate_mode="mpo"`) | - -This is intentional: 2TDVP requires a split local generator; a bare unitary -matrix does not provide one. Use `gate_mode="mpo"` if you want consistent -long-range handling for all custom unitaries without defining generators. - ---- - -## Manually defining custom gates - -Use this path for library code, tests, or workflows that construct gates -directly in YAQS (without Qiskit). - -### Matrix-only custom gate - -```python -import numpy as np -from mqt.yaqs.core.libraries.gate_library import GateLibrary - -unitary = np.eye(4, dtype=complex) # example 2-qubit unitary -gate = GateLibrary.custom(unitary) -gate.name = "my_gate" -gate.set_sites(0, 1) -# gate.matrix, gate.tensor, gate.sites are ready for TEBD/MPO application -``` - -`GateLibrary.custom` validates that the matrix is square with dimension $2^n$. - -For **equivalence checking**, comparing two circuits that use the same custom -unitary (or a custom gate versus an equivalent decomposition) works the same as -for built-in gates. - -### Subclassing `BaseGate` - -Built-in gates subclass {class}`~mqt.yaqs.core.libraries.gate_library.BaseGate` -and often override `set_sites` to set `tensor`, optional `mpo_tensors`, and—for -TDVP-capable gates—`generator`. See -{class}`~mqt.yaqs.core.libraries.gate_library.CX` and -{class}`~mqt.yaqs.core.libraries.gate_library.CCX` in -{mod}`~mqt.yaqs.core.libraries.gate_library` for reference implementations. - ---- - -## Generators and the TDVP path - -Some {class}`~mqt.yaqs.core.libraries.gate_library.GateLibrary` gates (`cx`, -`cz`, `cp`, `rxx`, `ryy`, `rzz`, `ccx`, `ccz`, …) define a **`generator`**: a -list with one `2×2` complex matrix per qubit, ordered as the gate's declared -`sites`. - -{func}`~mqt.yaqs.digital.digital_tjm.construct_generator_mpo` places each local -operator on its declared site and identity `2×2` blocks elsewhere, producing an -MPO that represents the product generator on the full chain. -{func}`~mqt.yaqs.digital.digital_tjm.apply_two_qubit_gate_tdvp` then runs -**two-site TDVP** (`tdvp_mode="2site"`) on a window around the gate for a total -evolution time of **1** (split across `tdvp_sweeps` substeps on -{class}`~mqt.yaqs.core.data_structures.simulation_parameters.DigitalSimParams`). - -The controlled-NOT gate illustrates the pattern (see source for exact matrices): - -```python -from mqt.yaqs.core.libraries.gate_library import GateLibrary - -gate = GateLibrary.cx() -gate.set_sites(0, 1) -# gate.generator is a list of two 2x2 arrays, set inside set_sites -``` - -Routing checks `getattr(gate, "generator", None) is not None` in -{func}`~mqt.yaqs.digital.digital_tjm.apply_two_qubit_gate`. Plain -`GateLibrary.custom(...)` does **not** set `generator`; TDVP long-range -application therefore does not activate unless you add one yourself. - -### Attaching a generator to a custom gate - -Advanced use: if you know a product-form generator decomposition compatible with -YAQS digital TDVP, you can assign it after `set_sites` (one `2×2` factor per -site, in the declared site order): - -```python -import numpy as np -from mqt.yaqs.core.libraries.gate_library import GateLibrary - -unitary = ... # 4x4 unitary on qubits (i, j), i < j -G_i = ... # 2x2 local operator on site i -G_j = ... # 2x2 local operator on site j - -gate = GateLibrary.custom(unitary) -gate.set_sites(i, j) -gate.generator = [G_i, G_j] -``` - -```{warning} -YAQS does not verify that `exp(-i (G_i ⊗ G_j))` at evolution time `1` reproduces -`unitary`. Deriving consistent local generators is the caller's responsibility; -use built-in gates as templates. Each factor is paired with the site declared at -the same position in `sites`; -{func}`~mqt.yaqs.digital.digital_tjm.construct_generator_mpo` places the factors -by site. -``` - -Generators apply to digital gates on **two or more qubits**. Single-qubit custom -gates always use direct MPS contraction; there is no single-qubit TDVP gate path -in circuit simulation. - +orphan: true --- -## Quick reference - -| Source | `generator` | TDVP long-range (`gate_mode="tdvp"`) | Equivalence check | -| ------------------------------------------ | ---------------------- | ------------------------------------ | --------------------------- | -| Built-in `cx`, `rxx`, … | Yes (in `set_sites`) | 2TDVP window | Supported | -| Qiskit `UnitaryGate` / QASM custom | No | MPO fallback | Supported (matrix fallback) | -| `GateLibrary.custom(matrix)` | No (unless you set it) | MPO fallback | Supported | -| `GateLibrary.custom` + manual `.generator` | Yes (if you set it) | 2TDVP window | Supported | -| Built-in `ccx`, `ccz` | Yes (in `set_sites`) | 2TDVP window | Matrix backend only | -| `cswap`, 3+ qubit unitaries | No | MPO fallback | Matrix backend only | - -### Rejected Qiskit instructions (translation) - -| Instruction | Conversion | Simulation | Equivalence | -| ------------------------------ | -------------------------- | ------------------------------------- | --------------------- | -| `barrier` | Skipped | Removed (except `SAMPLE_OBSERVABLES`) | Skipped in MPO zones | -| Final `measure` | Rejected in raw conversion | Removed from DAG | Stripped before check | -| Mid-circuit `measure` | Rejected | Removed only if per-qubit terminal | `ValueError` | -| `reset`, `delay`, control-flow | Rejected | — | — | - ---- +# Custom Gates -## Related topics +See {ref}`circuit-custom-gates` for custom unitaries, supported circuit +instructions, and low-level gate construction. The circuit guide also covers +{ref}`circuit-qasm-inputs` and gate-application modes. -- {doc}`simulation_parameters` — `gate_mode`, `tdvp_sweeps`, `tdvp_mode` -- {doc}`equivalence_checking` — comparing original and transpiled circuits -- {doc}`circuit_observables` — running circuits with - {class}`~mqt.yaqs.Simulator` -- {mod}`~mqt.yaqs.digital.utils.dag_utils` — translation implementation and - `SUPPORTED_QISKIT_GATE_NAMES` -- {mod}`~mqt.yaqs.core.libraries.gate_library` — built-in gate definitions and - generator examples +For comparing circuits that contain custom gates, see +{doc}`equivalence_checking`. diff --git a/docs/examples/digital_analog_simulation.md b/docs/examples/digital_analog_simulation.md index 59018a197..9c8c00f71 100644 --- a/docs/examples/digital_analog_simulation.md +++ b/docs/examples/digital_analog_simulation.md @@ -2,312 +2,312 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 600 + execution_timeout: 300 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` +# Analog-Digital Simulation -# Digital–Analog Simulation +An excitation spreads through the XY chain in {doc}`analog_simulation`. Can +digital gates bring it back? Here we interrupt that same continuous Hamiltonian +evolution with a pattern of phase gates. The excitation refocuses at its +starting site, giving a direct way to see how relaxation and dephasing affect +the return. -Some simulations need both analog parts (continuous Hamiltonian evolution) and -digital parts (quantum circuits). A YAQS {class}`~mqt.yaqs.SimulationProgram` -accepts an ordered list of `(operator, params)` pairs and runs them as one -program. YAQS passes the evolving state from one segment to the next, keeps each -noisy trajectory continuous across segment boundaries, and returns a normal -{class}`~mqt.yaqs.Result` with stitched `times` and `expectation_values`. +A `SimulationProgram` combines circuits and analog intervals in their execution +order. YAQS carries the evolving state through the whole program, including each +noisy trajectory. This guide uses the standard installation and Matplotlib for +plotting. Run the cells in order in a notebook; for a script, use the +entry-point guard in {doc}`simulator_initialization`. -To demonstrate this workflow, the following example: +## 1. Prepare the chain with a circuit -1. prepares a phase-sensitive state with a digital operation; -2. evolves it continuously under a simple static-$Z$ Hamiltonian that - accumulates phase; -3. optionally inserts an instantaneous digital pulse between two analog parts; -4. runs both programs with and without noise. +Use the same 20-site open XY chain and hopping amplitude as the analog and +{doc}`circuit_observables` guides, -## 1. Create the digital operations +$$ +H=-\frac{1}{2}\sum_{i=0}^{L-2}(X_iX_{i+1}+Y_iY_{i+1}). +$$ -One qubit is enough for this demonstration. A Hadamard gate prepares the -phase-sensitive state $\lvert+\rangle$ from the initial state $\lvert0\rangle$ -(`"zeros"`, specified later on). An $X$ gate will act as the midpoint refocusing -pulse and, when repeated at the end, return the sequence to its original frame. +Time is measured in inverse hopping units, with $\hbar=1$. Start from an +all-zero MPS, then use a digital $X$ gate to prepare one excitation at site 10. +Site numbers match Qiskit's qubit indices. -```{code-cell} ipython3 -from qiskit.circuit import QuantumCircuit +```{code-cell} python +import numpy as np +from qiskit import QuantumCircuit -number_of_qubits = 1 +from mqt.yaqs import AnalogSimParams, DigitalSimParams, Hamiltonian, Observable, SimulationProgram, Simulator, State -preparation = QuantumCircuit(number_of_qubits) -preparation.h(0); +length = 20 +center = length // 2 +state = State(length, initial="zeros") +hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0) +observables = [Observable("z", site) for site in range(length)] -refocusing_pulse = QuantumCircuit(number_of_qubits) -refocusing_pulse.x(0); +preparation = QuantumCircuit(length) +preparation.x(center) ``` -These are normal Qiskit circuits. They will appear as the first entry of each -digital pair in the program. - -## 2. Configure the parameters +Unlike the circuit guide, we will let YAQS evolve the Hamiltonian directly +between gates. We need no Trotter circuit to represent those intervals. -Segment parameters carry timing, truncation, gate-mode, and digital `shots`. -Observables, `random_seed`, and `get_state` belong on the program; `num_traj` is -either unanimous on the segments or set on the program. +## 2. Build the refocusing pulse -```{code-cell} ipython3 -from mqt.yaqs import AnalogSimParams, DigitalSimParams, Hamiltonian, Observable +After half the evolution, apply a $Z$ gate to every even site. The pulse changes +phases without changing site occupations at that instant. Every XY bond has +exactly one pulsed endpoint, so the combined pulse $P$ satisfies $PHP=-H$. The +subsequent evolution therefore unwinds the earlier spreading. -half_duration = 0.7 -number_of_trajectories = 256 +```{code-cell} python +phase_pulse = QuantumCircuit(length) +phase_pulse.z(range(0, length, 2)) -hamiltonian = Hamiltonian.pauli( - length=number_of_qubits, - one_body=[(1.1, "z")], -) -x_observable = Observable("x", 0) -analog_parameters = AnalogSimParams( - elapsed_time=half_duration, - dt=0.05, - sample_timesteps=True, -) -digital_parameters = DigitalSimParams(sample_layers=True) +half_duration = 1.5 +analog_params = AnalogSimParams(elapsed_time=half_duration, dt=0.25, order=2, preset="fast") +digital_params = DigitalSimParams(sample_layers=True, preset="fast") ``` -`elapsed_time` is the duration of each analog segment, while `dt` controls its -time-step size. With `sample_layers=True`, digital segments also record the -shared observable at circuit entry and exit. +The two analog intervals give a final time of $3$, matching the other transport +guides. `dt=0.25` samples seven times per interval. We use second-order TJM +evolution for the noisy comparison. `sample_layers=True` also records the +observables at each circuit's entry and exit. -## 3. Put the operations into programs +The phase pulse reverses this XY Hamiltonian because it changes the sign of +every exchange term. Added terms such as ZZ interactions generally do not +reverse under the same pulse; another Hamiltonian needs a separate check. -A program is an ordered list of `(operator, params)` pairs. The operator type -selects the mode: a {class}`~qiskit.circuit.QuantumCircuit` (or an OpenQASM -string / file path) with {class}`~mqt.yaqs.DigitalSimParams`, or a -{class}`~mqt.yaqs.Hamiltonian` with {class}`~mqt.yaqs.AnalogSimParams`. +## 3. Assemble and run the programs -```{code-cell} ipython3 -from mqt.yaqs import SimulationProgram +Each segment is an `(operator, params)` pair. A circuit selects digital +simulation, while a `Hamiltonian` selects analog evolution. Set the shared +observables and random seed on the program; leave those fields unset on the +segment parameters. We also set the trajectory budget on the program so it +applies to the complete sequence. -free_evolution = SimulationProgram( +```{code-cell} python +free_program = SimulationProgram( [ - (preparation, digital_parameters), - (hamiltonian, analog_parameters), - (hamiltonian, analog_parameters), + (preparation, digital_params), + (hamiltonian, analog_params), + (hamiltonian, analog_params), ], - observables=[x_observable], - num_traj=number_of_trajectories, - random_seed=7, + observables=observables, num_traj=32, random_seed=7, ) -``` - -The second program inserts the refocusing pulse between the analog intervals and -repeats it at the end as a frame correction. - -```{code-cell} ipython3 -evolution_with_hahn_echo = SimulationProgram( +echo_program = SimulationProgram( [ - (preparation, digital_parameters), - (hamiltonian, analog_parameters), - (refocusing_pulse, digital_parameters), - (hamiltonian, analog_parameters), - (refocusing_pulse, digital_parameters), + (preparation, digital_params), + (hamiltonian, analog_params), + (phase_pulse, digital_params), + (hamiltonian, analog_params), + (phase_pulse, digital_params), ], - observables=[x_observable], - num_traj=number_of_trajectories, - random_seed=7, + observables=observables, num_traj=32, random_seed=7, ) -``` - -There is no need to run these segments individually or manually extract and -resubmit an intermediate state. YAQS carries the state through each complete -list in order. - -## 4. Run the programs with and without noise - -Create a simulator and an initial state, then pass each program to -{meth}`~mqt.yaqs.Simulator.run`. - -```{code-cell} ipython3 -from mqt.yaqs import NoiseModel, Simulator, State -simulator = Simulator(parallel=False, show_progress=False) -initial_state = State(number_of_qubits, initial="zeros") - -free_noiseless = simulator.run(initial_state, free_evolution) -echo_noiseless = simulator.run(initial_state, evolution_with_hahn_echo) +simulator = Simulator(show_progress=False) +free_result = simulator.run(state, free_program) +echo_result = simulator.run(state, echo_program) ``` -Adding noise does not require changing either program. Pass YAQS's built-in -Markovian `pauli_z` dephasing process to the same calls. - -```{code-cell} ipython3 -dephasing_noise = NoiseModel( - [{"name": "pauli_z", "sites": [0], "strength": 0.15}] -) - -free_noisy = simulator.run( - initial_state, - free_evolution, - noise_model=dephasing_noise, -) -echo_noisy = simulator.run( - initial_state, - evolution_with_hahn_echo, - noise_model=dephasing_noise, -) -``` - -During a noisy run, each trajectory passes through the complete program on one -worker (one MPS and one RNG) before YAQS averages the recorded observables. -Parallelism is over trajectories, not over segments. - -You can also pass the pair list directly to `run`, which builds a -`SimulationProgram` under the hood: - -```python -result = simulator.run( - initial_state, - [(preparation, digital_parameters), (hamiltonian, analog_parameters)], - observables=[x_observable], - num_traj=number_of_trajectories, - random_seed=7, -) +The final phase pulse restores the original frame. With $U(\tau)=\exp(-iH\tau)$, +the echo part obeys $PU(\tau)PU(\tau)=I$ in the noiseless limit. This last pulse +does not change the plotted occupations, but it also restores the phases of a +general initial state. + +YAQS preserves the input `state`, so both programs start from the same all-zero +state and apply the same preparation. Noiseless programs need only one +trajectory. Noisy programs average 32 complete trajectories, with parallel +execution enabled by default. The documentation suppresses progress bars; omit +`show_progress=False` to see them. + +## 4. Read the occupation heatmaps + +The outer result contains stitched `times` and `expectation_values`. Digital +gates are instantaneous on this timeline, so their samples share timestamps with +analog boundaries. Each entry in `segment_results` also contains the segment's +type, time offset, and local output. + +For heatmaps, collect the analog samples, add each segment's time offset, and +remove the repeated midpoint. The $Z$ pulse leaves occupation unchanged there, +so either adjacent analog sample gives the same value. The initial analog sample +already includes the digital preparation. + +```{code-cell} python +def analog_occupations(result): + """Extract analog times and site occupations from a program result.""" + segments = [segment for segment in result.segment_results if segment.segment_type == "analog"] + times = np.concatenate([segment.times + segment.time_offset for segment in segments]) + values = np.concatenate([np.asarray(segment.expectation_values) for segment in segments], axis=1) + keep = np.r_[True, np.diff(times) > 0] + return times[keep], (1 - values[:, keep]) / 2 + + +times, free_occupation = analog_occupations(free_result) +_, echo_occupation = analog_occupations(echo_result) +print(f"Return occupation: free={free_occupation[center, -1]:.4f}, echo={echo_occupation[center, -1]:.4f}") ``` -## 5. OpenQASM, scheduled jumps, and per-segment results +The occupation array has shape `(20, 13)`. Rows follow the observable list, and +columns follow the extracted times from $0$ to $3$. -Programs reuse the same **operator / params / noise** objects as MPS TJM analog -and digital runs. A few knobs are program-owned or unsupported here: the initial -state must be MPS; observables, `random_seed`, and `get_state` belong on the -program; `multi_time_observables` are not supported. - -**Digital operators.** A segment may take a -{class}`~qiskit.circuit.QuantumCircuit`, or an OpenQASM string / file path, with -{class}`~mqt.yaqs.DigitalSimParams`. `shots` stay on the digital segment params: - -```python -program = SimulationProgram( - [("prep.qasm", digital_parameters), (hamiltonian, analog_parameters)], - observables=[x_observable], -) -``` - -**Scheduled jumps.** Attach them through a {class}`~mqt.yaqs.NoiseModel` as in -{doc}`scheduled_jumps` (analog MPS TJM, `order=1`, times on that analog run's -`dt` grid). Jump times are relative to the start of the analog run that carries -the model. Consecutive analog segments that share one interval schedule also -share that clock; a digital gate starts a new analog run. Use a third-tuple -noise override when only one segment should fire them: - -```python -jumps = NoiseModel(scheduled_jumps=[{"time": 0.1, "sites": [0], "name": "x"}]) -program = SimulationProgram( - [ - (preparation, digital_parameters), - (hamiltonian, analog_parameters, jumps), - ], - observables=[x_observable], -) +```{code-cell} python +:tags: [hide-input] +import matplotlib.pyplot as plt +from matplotlib.colors import PowerNorm +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", "font.serif": ["STIXGeneral"], "mathtext.fontset": "stix", + "font.size": 10, "axes.labelsize": 11, "axes.linewidth": 0.8, + "xtick.direction": "in", "ytick.direction": "in", "svg.fonttype": "none", + "legend.frameon": False, +}) +occupation_norm = PowerNorm(gamma=0.5, vmin=0, vmax=1) +fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8), sharex=True, sharey=True, layout="constrained") +for ax, occupation, title in zip( + axes, [free_occupation, echo_occupation], ["(a) Free evolution", "(b) Digital refocusing"], strict=True, +): + image = ax.pcolormesh(times, np.arange(length), occupation, shading="auto", cmap="cividis", norm=occupation_norm) + ax.set(xlabel="Time", title=title, yticks=[0, 5, 10, 15, 19], xlim=(0, 3)) +axes[0].set_ylabel("Site") +axes[1].axvline(half_duration, color="white", linestyle="--", linewidth=1) +fig.colorbar(image, ax=axes, label=r"Occupation $\langle n_i\rangle$", ticks=[0, 0.25, 0.5, 1], shrink=0.9) +plt.show() ``` -**Trajectory count.** Set `num_traj=` on the program, or use the same `num_traj` -on every segment. If segments disagree, pass an explicit program value. - -**Results.** The outer result stitches `result.times` and -`result.expectation_values`. Outer `result.counts` is the histogram from the -last segment that recorded shots. Each segment also keeps an ordinary -{class}`~mqt.yaqs.Result` at `result.segment_results[i]`. - -## 6. Compare the four results - -Program results look like ordinary analog/digital results: use `result.times` -and `result.expectation_values`. Digital samples sit at their physical time -offset (operations are instantaneous on the program timeline), so repeated -timestamps around pulses are expected. - -```{code-cell} ipython3 -times = echo_noiseless.times -signal = echo_noiseless.expectation_values[0] +**The phase pulse brings the spreading excitation back.** Both programs follow +the same dynamics until $t=1.5$. Free evolution continues to spread, while the +pulsed program refocuses at site 10 at $t=3$. The white dashed line marks the +midpoint pulse. Both panels use the same square-root color scale to retain weak +occupation without changing the normalization. + +## 5. Add relaxation and dephasing + +A pulse can reverse coherent spreading, but it cannot reverse an irreversible +noise process. Uniform relaxation removes the excitation. Local dephasing +preserves total excitation while disrupting the phases needed for refocusing. +Apply each noise model to the same echo program. + +```{code-cell} python +from mqt.yaqs import NoiseModel + +relaxation_rate = 0.5 +dephasing_rates = [0.05, 0.2] +noise_models = { + "Relaxation": NoiseModel([ + {"name": "lowering", "sites": [site], "strength": relaxation_rate} + for site in range(length) + ]), + **{ + f"Dephasing {rate:g}": NoiseModel([ + {"name": "pauli_z", "sites": [site], "strength": rate} + for site in range(length) + ]) + for rate in dephasing_rates + }, +} +echo_results = {"No noise": echo_result} +for label, noise in noise_models.items(): + echo_results[label] = simulator.run(state, echo_program, noise_model=noise) + +occupations = {label: analog_occupations(result)[1] for label, result in echo_results.items()} ``` -We now plot the results, indicating noisy simulation with dashed lines. - -```{code-cell} ipython3 ---- -mystnb: - image: - width: 80% - align: center ---- -import matplotlib.pyplot as plt - -colors = plt.colormaps["viridis"]([0.2, 0.75]) -traces = [ - (free_noiseless, "free evolution, noiseless", colors[0], "-"), - (echo_noiseless, "with digital pulses, noiseless", colors[1], "-"), - (free_noisy, "free evolution, noisy", colors[0], "--"), - (echo_noisy, "with digital pulses, noisy", colors[1], "--"), -] - -fig, ax = plt.subplots(figsize=(7, 4), layout="constrained") -for result, label, color, line_style in traces: - ax.plot( - result.times, - result.expectation_values[0], - "o", - color=color, - linestyle=line_style, - markersize=2.5, - label=label, - ) - -ax.axvline(half_duration, color="0.65", linewidth=1, label="digital pulse") -ax.set( - xlabel="Time", - ylabel=r"Phase-sensitive signal $\langle X\rangle$", - ylim=(-1.05, 1.05), -) -ax.legend(ncols=2) +`strength` is a Lindblad rate in inverse time. The jump operators are +$\sqrt{\gamma_-}\,|0\rangle\langle1|_i$ for relaxation and +$\sqrt{\gamma_z}\,Z_i$ for dephasing. In this convention an isolated qubit's +off-diagonal density-matrix entries decay at rate $2\gamma_z$. + +The run-level noise model is inherited by all segments. These one-qubit gates +receive no stochastic circuit noise in YAQS, so noise acts only during the +analog intervals here. The pulses are ideal and instantaneous. The same noisy +trajectory and random stream continue across the midpoint pulse. + +For a single excitation with uniform relaxation, the exact total population is +$N(t)=\exp(-\gamma_-t)$. Surviving trajectories still refocus, so the exact +final return occupation has the same value, about $0.223$ at $t=3$. Under +Pauli-Z dephasing, $N(t)=1$, but the return becomes weaker and occupation +remains away from the center. This comparison uses different noise channels; +their numerical rates do not represent equal physical error strengths. + +```{code-cell} python +:tags: [hide-input] +labels = list(echo_results) +colors = ["0.15", "#D55E00", "#56B4E9", "#0072B2"] +fig, axes = plt.subplots(3, 2, figsize=(7.2, 6.8), layout="constrained") +titles = ["(a) No noise", r"(b) Relaxation, $\gamma_-=0.5$", + r"(c) Dephasing, $\gamma_z=0.05$", r"(d) Dephasing, $\gamma_z=0.2$"] +for ax, label, title in zip(axes[:2].flat, labels, titles, strict=True): + image = ax.pcolormesh(times, np.arange(length), occupations[label], shading="auto", cmap="cividis", norm=occupation_norm) + ax.axvline(half_duration, color="white", linestyle="--", linewidth=1) + ax.set(xlabel="Time", ylabel="Site", title=title, yticks=[0, 10, 19], xlim=(0, 3)) +fig.colorbar(image, ax=list(axes[:2].flat), label=r"Occupation $\langle n_i\rangle$", + ticks=[0, 0.25, 0.5, 1], shrink=0.8) + +for label, color in zip(labels, colors, strict=True): + occupation = occupations[label] + result = echo_results[label] + segments = [segment for segment in result.segment_results if segment.segment_type == "analog"] + raw_times = np.concatenate([segment.times + segment.time_offset for segment in segments]) + keep = np.r_[True, np.diff(raw_times) > 0] + trajectories = (1 - np.concatenate([np.asarray(segment.trajectories) for segment in segments], axis=-1)) / 2 + trajectories = trajectories[..., keep] + for ax, mean, samples in ( + (axes[2, 0], occupation[center], trajectories[center]), + (axes[2, 1], occupation.sum(axis=0), trajectories.sum(axis=0)), + ): + ax.plot(times, mean, color=color, linewidth=1.7, label=label) + if samples.shape[0] > 1: + standard_error = samples.std(axis=0, ddof=1) / np.sqrt(samples.shape[0]) + ax.fill_between(times, mean - standard_error, mean + standard_error, color=color, alpha=0.15) +axes[2, 1].plot(times, np.exp(-relaxation_rate * times), ":", color="#D55E00", linewidth=1.4, label=r"$e^{-\gamma_-t}$") +for ax in axes[2]: + ax.axvline(half_duration, color="0.6", linestyle="--", linewidth=0.8) + ax.set(xlabel="Time", xlim=(0, 3)) +axes[2, 0].set(title="(e) Return to the center", ylabel=r"$\langle n_{10}\rangle$") +axes[2, 1].set(title="(f) Surviving excitation", ylabel=r"$N=\sum_i\langle n_i\rangle$") +axes[2, 1].legend(fontsize=7, loc="lower left") plt.show() ``` -This simulation combines continuous Hamiltonian evolution and instantaneous -digital operations in one digital–analog program. In this simple application, -the digital midpoint pulse reverses the coherent phase accumulated during the -analog evolution, a phase-cancellation technique known as a Hahn echo. With -Markovian dephasing, the coherent phase is still cancelled, but the pulse cannot -recover lost signal contrast. - -## Useful things to know - -- Segments run in the order they appear in `SimulationProgram`. -- Analog-only Hamiltonian quenches belong in {doc}`hamiltonians` - (`Hamiltonian.piecewise`). Use a program when analog evolution sits in a - protocol with digital gates. -- Observables, `random_seed`, and `get_state` are program-wide; leave them unset - on segment params. `num_traj` may be unanimous on segments or set on the - program. `shots` stay on digital segment params. -- YAQS passes the state between segments automatically and does not mutate the - input state you give `Simulator.run`. -- A noise model passed to `Simulator.run` is inherited by every segment unless - that segment supplies a third-tuple override (`None` inherits; an empty - `NoiseModel()` disables noise). -- Noisy trajectories and their RNG streams remain continuous across segment - boundaries. -- A noiseless program can retain its final state with - `SimulationProgram(..., get_state=True)`. -- Digital segments may operate on qubit sites in a heterogeneous state while - non-qubit sites remain spectators. Digital gates themselves currently require - qubit targets. -- Independent trajectories may run in parallel with `Simulator(parallel=True)`. - -## Related topics - -- {doc}`hamiltonians` — analog quench with `Hamiltonian.piecewise` -- {doc}`analog_simulation` — standalone noisy analog evolution -- {doc}`circuit_observables` — standalone digital circuit simulation and - OpenQASM -- {doc}`scheduled_jumps` — deterministic jumps on an analog time grid -- {doc}`simulation_parameters` — simulation accuracy and output controls +**Loss and dephasing limit the return in different ways.** Relaxation reduces +the total population, while the dephased excitation survives in a broader +spatial distribution. Increasing dephasing weakens the echo over the rates +shown. The lower panels separate the occupation returning to site 10 from the +total excitation remaining in the chain. Shading shows one standard error +estimated from complete trajectories; the dotted line gives the exact relaxation +envelope. These bands describe sampling uncertainty, not timestep or MPS +truncation error. Increase `num_traj` to reduce sampling fluctuations, and check +numerical convergence before interpreting small differences. + +## Further options + +Programs require an MPS initial state. Segment parameters control local timing, +accuracy, gate mode, and digital `shots`. Observables, `random_seed`, and +`get_state` belong on the program. A noiseless program can retain its final +state with `get_state=True`; noisy programs do not return a single final MPS. A +program-level `num_traj` overrides segment budgets. If omitted, all segment +budgets must agree. Digital segments can target qubits in a heterogeneous MPS; +non-qubit sites remain spectators. For analog-only Hamiltonian changes, use +`Hamiltonian.piecewise` as described in {doc}`hamiltonians`. + +An optional third tuple entry overrides noise for one segment, for example +`(hamiltonian, analog_params, local_noise)`. `None` inherits run-level noise; an +empty `NoiseModel()` disables it for that segment. Digital operators may also be +OpenQASM strings or paths. Pass the pair list directly to `Simulator.run` with +program-wide keywords when a named program is unnecessary. The outer `counts` +contains the histogram from the last segment that sampled shots; inspect +`segment_results` for earlier histograms. Program execution does not support +`multi_time_observables`. + +For deterministic scheduled jumps, see {ref}`noise-scheduled-jumps`. Jump times +use the analog run's local clock and must follow its `dt` grid with `order=1`. +Consecutive compatible analog segments share that clock; a digital gate starts a +new analog run. Use a segment noise override to attach a schedule to one +interval. For device-specific noise strengths and distributions, see +{doc}`realistic_noise_models`. diff --git a/docs/examples/digital_twin.md b/docs/examples/digital_twin.md index 6ef752796..c494f170f 100644 --- a/docs/examples/digital_twin.md +++ b/docs/examples/digital_twin.md @@ -2,276 +2,321 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 900 + execution_timeout: 300 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Building a Digital Twin -Build a **digital twin** of an open quantum system: learn unknown Lindblad jump -rates from observable time series via simulator forward modeling and CMA-ES -(analytical optimization), validate the fit on the measured traces, then deploy -the learned model in {class}`~mqt.yaqs.Simulator` to predict **held-out** -observables. +Noise changes how excitations move through a quantum system. Measurements can +help us estimate that noise and build a model of the observed dynamics. Here we +learn relaxation and dephasing rates from measurements at the ends of a +four-spin chain, then use the fitted model to predict transport through its +unmeasured interior. -The entry point is {class}`~mqt.yaqs.NoiseCharacterizer`. +This extends {doc}`quickstart` with data preparation, parameter bounds, and +validation. We generate synthetic observations so the underlying rates are +known, but pass only the observed traces to the fitter. The example uses the +standard YAQS installation and Matplotlib for plotting. Run the cells in order +in a notebook; for a script, use the entry-point guard in +{doc}`simulator_initialization`. -```{note} -A machine-learning pipeline with the same I/O (reference trajectories in, fitted -``NoiseModel`` out) is planned for a future release. -``` +## 1. Set up excitation transport + +The XY Hamiltonian exchanges excitations between neighboring spins, + +$$ +H=-\frac{1}{2}\sum_{i=0}^{2}(X_iX_{i+1}+Y_iY_{i+1}). +$$ + +We start with one excitation at site 0. As in {doc}`analog_simulation`, the +hopping amplitude is one and $\hbar=1$. Measuring $Z_i$ gives the occupation +through $\langle n_i\rangle=(1-\langle Z_i\rangle)/2$. + +```{code-cell} python +import numpy as np + +from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseCharacterizer, NoiseModel, Observable, Simulator, State -```{note} -Rates are not always uniquely identifiable from a sparse observable set. Judge a -fit by **trajectory overlap** first; rate bars are secondary validation. +length = 4 +state = State(length, initial="basis", basis_string="1000", representation="density_matrix") +hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0) +observables = [Observable("z", site) for site in range(length)] +params = AnalogSimParams(observables=observables, elapsed_time=8.0, dt=0.1, preset="fast") +simulator = Simulator(show_progress=False) ``` -```{note} -**Forward backends:** `representation="auto"` (default) prefers deterministic -Lindblad on small chains, then MCWF (`"vector"`), then TJM (`"mps"`). See -{doc}`representation_comparison` for cross-backend validation. +The density-matrix state gives deterministic Lindblad dynamics without +trajectory sampling error. We record all four sites to validate predictions +later; only the endpoints will enter the fit. The documentation suppresses +progress bars with `show_progress=False`; omit this argument to see progress. + +## 2. See how noise changes the dynamics + +A local relaxation channel at site 3 removes excitations after they reach the +far end of the chain. A dephasing channel at site 2 leaves the total excitation +number unchanged by itself, but changes the interference that drives transport. +The reference rates are $\gamma_{\mathrm{loss}}=0.35$ and $\gamma_\phi=0.12$. + +```{code-cell} python +def transport_noise(scale): + """Scale the reference relaxation and dephasing rates together.""" + return NoiseModel([ + {"name": "lowering", "sites": [3], "strength": 0.35 * scale}, + {"name": "pauli_z", "sites": [2], "strength": 0.12 * scale}, + ]) + + +noise_scales = [0.0, 0.5, 1.0, 3.0] +reference_runs = {} +occupations = {} +for scale in noise_scales: + noise = None if scale == 0 else transport_noise(scale) + result = simulator.run(state, hamiltonian, params, noise) + reference_runs[scale] = result + occupations[scale] = (1 - np.asarray(result.expectation_values)) / 2 + +times = reference_runs[1.0].times ``` -## 1. Minimal fit +`strength` is a Lindblad rate in inverse time. The jump operators are +$L_{\mathrm{loss}}=\sqrt{\gamma_{\mathrm{loss}}}\,|0\rangle\langle1|_3$ and +$L_\phi=\sqrt{\gamma_\phi}\,Z_2$. This convention gives the dephasing term +$\gamma_\phi(Z_2\rho Z_2-\rho)$. The dimensionless `scale` multiplies both +rates; it is not a per-gate error probability. -Three-site transverse-field Ising chain with homogeneous Pauli noise. Pass -`reference_model=` to simulate target trajectories internally (benchmark -shortcut); for lab data use `ref_expectations=` instead (section 3). +The four heatmaps share their axes and a square-root color normalization, which +keeps weak occupation visible while retaining the full range from zero to one. -```{code-cell} ipython3 +```{code-cell} python +:tags: [hide-input] import matplotlib.pyplot as plt -import numpy as np +from matplotlib.colors import PowerNorm +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["STIXGeneral"], + "mathtext.fontset": "stix", + "font.size": 10, + "axes.labelsize": 11, + "axes.linewidth": 0.8, + "xtick.direction": "in", + "ytick.direction": "in", + "svg.fonttype": "none", +}) +occupation_norm = PowerNorm(gamma=0.5, vmin=0, vmax=1) +fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.4), sharex=True, sharey=True, layout="constrained") +titles = ["(a) No noise", "(b) Half the reference rates", "(c) Reference rates", "(d) Three times the reference rates"] +for ax, scale, title in zip(axes.flat, noise_scales, titles, strict=True): + image = ax.pcolormesh(times, np.arange(length), occupations[scale], shading="auto", cmap="cividis", norm=occupation_norm, rasterized=True) + ax.set(title=title, yticks=np.arange(length), xlim=(0, 8)) +for ax in axes[-1]: + ax.set_xlabel(r"Time $t$") +for ax in axes[:, 0]: + ax.set_ylabel(r"Site $i$") +fig.colorbar(image, ax=axes, label=r"Occupation $\langle n_i\rangle$", ticks=[0, 0.25, 0.5, 1], shrink=0.92) +plt.show() +``` -from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseCharacterizer, NoiseModel, Observable, Simulator, State +**Noise changes transport and weakens the returning excitation.** Without noise, +the excitation reflects through the chain while its total population stays one. +Relaxation removes population, and dephasing changes its spatial distribution. +Both rates change together in this comparison, so the panels show their combined +effect. We will fit the reference-rate case and use the other panels only to +illustrate the physical changes. -n_sites = 3 -j_coupling = 1.0 -transverse_field = 2.0 -gamma_true = 0.08 -gamma_init = 0.35 -cma_seed = 42 -sites = list(range(n_sites)) - -hamiltonian = Hamiltonian.ising(n_sites, J=j_coupling, g=transverse_field) -init_state = State(n_sites, initial="zeros") - -fitting_observables = [ - Observable("y", 0), - Observable("z", 0), - Observable("y", 1), -] -prediction_observables = [ - Observable("x", 0), - Observable("x", 1), - Observable("x", 2), - Observable("z", 2), -] - -sim_params = AnalogSimParams( - observables=fitting_observables, - elapsed_time=0.8, - dt=0.1, - order=1, - sample_timesteps=True, -) +## 3. Select the observations and candidate channels -reference_model = NoiseModel( - [{"name": "pauli_x", "sites": [s], "strength": gamma_true} for s in sites] - + [{"name": "pauli_y", "sites": [s], "strength": gamma_true} for s in sites] - + [{"name": "pauli_z", "sites": [s], "strength": gamma_true} for s in sites] -) +Assume that only the endpoints can be measured. Select rows 0 and 3 of the +synthetic $Z$ traces, keeping their time samples unchanged. -init_guess = NoiseModel( - [{"name": "pauli_x", "sites": [s], "strength": gamma_init} for s in sites] - + [{"name": "pauli_y", "sites": [s], "strength": gamma_init} for s in sites] - + [{"name": "pauli_z", "sites": [s], "strength": gamma_init} for s in sites] -) +```{code-cell} python +fitting_sites = [0, 3] +fitting_observables = [observables[site] for site in fitting_sites] +reference_z = np.asarray(reference_runs[1.0].expectation_values) +measured_z = reference_z[fitting_sites] -rate_bounds_low = np.zeros(len(init_guess.processes)) -rate_bounds_high = np.full(len(init_guess.processes), 0.5) -pauli_labels = ["X", "Y", "Z"] +print("Observation shape:", measured_z.shape) +``` -nc = NoiseCharacterizer(show_progress=False) -result = nc.characterize( - hamiltonian, - sim_params, - init_state=init_state, - init_guess=init_guess, - observables=fitting_observables, - reference_model=reference_model, - x_low=rate_bounds_low, - x_up=rate_bounds_high, - sigma0=0.05, - popsize=8, - max_iter=40, - seed=cma_seed, -) +`ref_expectations` must have shape `(n_observables, n_times)`. Here it is +`(2, 81)`: rows follow `fitting_observables`, and columns follow `params.times`, +including $t=0$. We fit $Z$ expectations, not occupations; convert measured +occupations with `measured_z = 1 - 2 * measured_occupations` when necessary. +Data from another time grid must first be aligned with the simulation grid. -gamma_learned = np.array([ - result.best_parameters[0:n_sites].mean(), - result.best_parameters[n_sites : 2 * n_sites].mean(), - result.best_parameters[2 * n_sites : 3 * n_sites].mean(), -]) -times = result.times -print(f"√J: {result.sqrt_loss_before():.3f} → {result.sqrt_loss_after():.2e}") -print(f"fitting trajectory RMSE: {result.trajectory_rmse():.2e}") -``` +The candidate model specifies which channels exist and where they act. The +optimizer will change their strengths only. We start both rates at 0.2 and allow +each to range from zero to one. -## 2. Validate fitted dynamics and rates - -```{code-cell} ipython3 -gamma_reference = np.full(len(pauli_labels), gamma_true) -ref_traj = result.ref_traj -fit_traj = result.fit_traj - -fig, axes = plt.subplots(1, 3, figsize=(9, 2.8), gridspec_kw={"width_ratios": [1.1, 1.0, 1.0]}) - -x_pos = np.arange(len(pauli_labels)) -bar_width = 0.35 -axes[0].bar(x_pos - bar_width / 2, gamma_reference, bar_width, label=r"$\gamma_{\mathrm{true}}$", color="0.35") -axes[0].bar(x_pos + bar_width / 2, gamma_learned, bar_width, label="learned twin", color="C0") -axes[0].set_xticks(x_pos, pauli_labels) -axes[0].set_ylabel(r"$\gamma$") -axes[0].set_title("Learned rates vs. hidden truth") -axes[0].legend(loc="upper right", fontsize=8) - -fit_panels = [(0, r"$\langle Y_0\rangle$"), (1, r"$\langle Z_0\rangle$")] -for ax, (obs_idx, ylabel) in zip(axes[1:], fit_panels, strict=True): - ax.plot(times, fit_traj[obs_idx], color="C0", lw=2.5, label="twin", zorder=1) - ax.plot(times, ref_traj[obs_idx], color="0.2", ls=":", lw=2.5, label="experiment", zorder=2) - ax.set_xlabel("time") - ax.set_ylabel(ylabel) - ax.set_ylim(-1.05, 1.05) - panel_rmse = float(np.sqrt(np.mean((fit_traj[obs_idx] - ref_traj[obs_idx]) ** 2))) - ax.text(0.03, 0.06, rf"RMSE={panel_rmse:.1e}", transform=ax.transAxes, fontsize=8) - ax.legend(loc="upper right", fontsize=8) - -fig.suptitle("Twin reproduces the experimental fitting observables", y=1.05, fontsize=11) -fig.tight_layout() +```{code-cell} python +initial_guess = NoiseModel([ + {"name": "lowering", "sites": [3], "strength": 0.2}, + {"name": "pauli_z", "sites": [2], "strength": 0.2}, +]) +lower_bounds = np.zeros(2) +upper_bounds = np.ones(2) ``` -## 3. Experimental data +Bounds and fitted parameters follow the order of `initial_guess.processes`: +relaxation first, dephasing second. These bounds restrict the search; they are +not uncertainty intervals. The fit assumes the Hamiltonian, initial state, +channel types, and channel locations are known. It does not discover an +arbitrary noise model from the observations. -When trajectories come from the lab (or an external simulator), pass them as -`ref_expectations` with shape `(n_obs, n_times)` matching `observables` and -`sim_params.times`. Below we reuse the reference trajectories from section 1 as -a stand-in for measured data. +## 4. Fit the rates -```{code-cell} ipython3 -experimental_data = np.asarray(result.ref_traj, dtype=float) +`NoiseCharacterizer` repeatedly simulates candidate models and minimizes the +mean-squared difference from the supplied traces. Its default backend selection +uses deterministic Lindblad evolution for this four-spin problem. -lab_result = NoiseCharacterizer(show_progress=False).characterize( +```{code-cell} python +characterizer = NoiseCharacterizer(show_progress=False) +fit = characterizer.characterize( hamiltonian, - sim_params, - init_state=init_state, - init_guess=init_guess, + params, + init_state=state, + init_guess=initial_guess, observables=fitting_observables, - ref_expectations=experimental_data, - x_low=rate_bounds_low, - x_up=rate_bounds_high, - sigma0=0.05, - popsize=8, + ref_expectations=measured_z, + x_low=lower_bounds, + x_up=upper_bounds, max_iter=40, - seed=cma_seed, + seed=7, ) -print(f"lab-data fit RMSE: {lab_result.trajectory_rmse():.2e}") + +print(f"Endpoint Z-trace RMSE: {fit.sqrt_loss_before():.4f} → {fit.trajectory_rmse():.2e}") +for name, rate in zip(("Relaxation", "Dephasing"), fit.best_parameters, strict=True): + print(f"{name} rate: {rate:.4f}") ``` -## 4. Predict held-out observables with the twin +With two free parameters, YAQS uses the derivative-free CMA-ES optimizer. +`max_iter=40` limits its generations, and `seed` fixes the optimizer's random +search. This seed is separate from `AnalogSimParams.random_seed`, which controls +stochastic simulation. `NoiseCharacterizer` defaults to in-process execution; +set `parallel=True` to parallelize trajectories for vector or MPS forward +models. -Plug `result.optimal_model` into {class}`~mqt.yaqs.Simulator` and compare to the -hidden reference on observables **not** used during fitting. +For observations $z_{o,t}$, the fitted objective is -```{code-cell} ipython3 -pred_params = AnalogSimParams( - observables=prediction_observables, - elapsed_time=sim_params.elapsed_time, - dt=sim_params.dt, - order=sim_params.order, - sample_timesteps=True, -) -simulator = Simulator(show_progress=False) +$$ +J=\frac{1}{N_{\mathrm{obs}}N_t}\sum_{o,t} +\left(z_{o,t}^{\mathrm{model}}-z_{o,t}^{\mathrm{data}}\right)^2. +$$ -twin_result = simulator.run(init_state, hamiltonian, pred_params, result.optimal_model) -truth_result = simulator.run(init_state, hamiltonian, pred_params, reference_model) -twin_traj = np.asarray(twin_result.expectation_values, dtype=float) -truth_traj = np.asarray(truth_result.expectation_values, dtype=float) - -fig, axes = plt.subplots(1, 2, figsize=(7, 2.8)) -holdout_panels = [(0, r"$\langle X_0\rangle$"), (3, r"$\langle Z_2\rangle$")] -for ax, (obs_idx, ylabel) in zip(axes, holdout_panels, strict=True): - ax.plot(times, twin_traj[obs_idx], color="C0", lw=2.5, label="twin", zorder=1) - ax.plot(times, truth_traj[obs_idx], color="0.2", ls=":", lw=2.5, label="reference", zorder=2) - ax.set_xlabel("time") - ax.set_ylabel(ylabel) - ax.set_ylim(-1.05, 1.05) - ax.legend(loc="upper right", fontsize=8) - -fig.suptitle("Twin predicts observables outside the fitting set", y=1.05, fontsize=11) -fig.tight_layout() -``` +`sqrt_loss_before()` reports the initial model's RMSE. `trajectory_rmse()` +reports the mismatch of the final fitted traces. `best_parameters` gives the +rates in process order, and `optimal_model` is the fitted `NoiseModel` ready for +simulation. The known synthetic rates let us check recovery, but a low training +error alone does not establish unique parameters. -## 5. Stochastic experimental data (MCWF) +## 5. Predict the unmeasured interior -The same workflow works with trajectory-averaged MCWF data. Increase `num_traj` -until observables stabilize; the objective becomes stochastic. +Rerun the fitted model with all four observables. Sites 1 and 2 were withheld +from the optimization, so they test predictions beyond the fitted traces. -```{code-cell} ipython3 -mcwf_sim_params = AnalogSimParams( - observables=fitting_observables, - elapsed_time=0.8, - dt=0.1, - order=1, - num_traj=32, - sample_timesteps=True, -) +```{code-cell} python +reconstructed = simulator.run(state, hamiltonian, params, fit.optimal_model) +fitted_z = np.asarray(reconstructed.expectation_values) +fitted_occupation = (1 - fitted_z) / 2 +heldout_sites = [1, 2] +heldout_rmse = np.sqrt(np.mean((fitted_z[heldout_sites] - reference_z[heldout_sites]) ** 2)) -mcwf_result = NoiseCharacterizer(show_progress=False, representation="vector").characterize( - hamiltonian, - mcwf_sim_params, - init_state=init_state, - init_guess=init_guess, - observables=fitting_observables, - reference_model=reference_model, - x_low=rate_bounds_low, - x_up=rate_bounds_high, - sigma0=0.05, - popsize=8, - max_iter=20, - seed=cma_seed, -) - -fig, ax = plt.subplots(figsize=(4.5, 2.8)) -obs_idx = 1 -ax.plot(times, mcwf_result.fit_traj[obs_idx], color="C0", lw=2.5, label="MCWF twin", zorder=1) -ax.plot(times, mcwf_result.ref_traj[obs_idx], color="0.2", ls=":", lw=2.5, label="experiment", zorder=2) -ax.set_xlabel("time") -ax.set_ylabel(r"$\langle Z_0\rangle$") -ax.set_ylim(-1.05, 1.05) -ax.legend(loc="upper right", fontsize=8) -ax.set_title(f"MCWF fit: √J → {mcwf_result.sqrt_loss_after():.2e}") -fig.tight_layout() +print(f"Withheld interior Z-trace RMSE: {heldout_rmse:.2e}") ``` -## Workflow summary - -| Step | Action | -| ---- | ------------------------------------------------------------------- | -| 1 | Collect experimental trajectories on a fitting observable set | -| 2 | `NoiseCharacterizer.characterize(..., ref_expectations=...)` | -| 3 | Compare learned rates and fitted-observable dynamics to reference | -| 4 | `Simulator.run` with `result.optimal_model` on held-out observables | - -## See also +Compare the full dynamics on the same color scale, then inspect the withheld +sites as time traces. Reference markers are spaced out for readability; all 81 +samples enter the error calculation. + +```{code-cell} python +:tags: [hide-input] +fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.6), layout="constrained") +for ax, dynamics, title in zip( + axes[0], + (occupations[1.0], fitted_occupation), + ("(a) Synthetic reference", "(b) Fitted noise model"), + strict=True, +): + image = ax.pcolormesh(times, np.arange(length), dynamics, shading="auto", cmap="cividis", norm=occupation_norm, rasterized=True) + ax.set(title=title, xlabel=r"Time $t$", ylabel=r"Site $i$", yticks=np.arange(length), xlim=(0, 8)) +fig.colorbar(image, ax=list(axes[0]), label=r"Occupation $\langle n_i\rangle$", ticks=[0, 0.5, 1]) +for ax, site, label in zip(axes[1], heldout_sites, ("(c)", "(d)"), strict=True): + ax.plot(times, fitted_occupation[site], color="#225c80", lw=1.8, label="Fitted prediction") + ax.plot(times[::4], occupations[1.0][site, ::4], "o", color="#bb563b", ms=3.5, markerfacecolor="white", label="Withheld reference") + ax.set(title=f"{label} Withheld site {site}", xlabel=r"Time $t$", ylabel=rf"Occupation $\langle n_{site}\rangle$", xlim=(0, 8), ylim=(0, 1)) + ax.spines[["top", "right"]].set_visible(False) +axes[1, 0].legend(frameon=False, fontsize=9, loc="upper right") +plt.show() +``` -- {doc}`representation_comparison` — Lindblad vs MCWF vs TJM on the same - benchmark -- {doc}`analog_simulation` — open-system simulation overview -- {doc}`characterization` — non-Markovian **memory** characterization (the - memory twin submodule) +**Endpoint observations recover the transport through the interior in this +model.** The fitted heatmap reproduces the reference, including both withheld +sites. This supports the fitted model for the specified Hamiltonian, initial +state, and observation window. It does not certify the assumed channels or +establish accuracy for other preparations, controls, or longer times. In +experimental work, reserve independent measurements for this validation step. + +## Using measured data + +Replace `measured_z` with your measured expectation array and keep the same +observable and time ordering. Supply exactly one of `ref_expectations` and +`reference_model`. The latter generates reference traces internally and is a +shortcut for synthetic benchmarks; it is not needed when measurements are +already available. + +The example contains no measurement noise. Finite-shot data add uncertainty, and +calibration drift or an incorrect Hamiltonian can also affect the fit. The +current objective weights every observable and time sample equally; it does not +accept per-sample uncertainty weights or return confidence intervals for the +rates. Check residuals against measurement uncertainty and test withheld data +before interpreting small differences between fitted parameters. + +Sparse observations can leave several rate combinations indistinguishable. Use +more times, observables, or preparations to test identifiability. A successful +optimization shows that a candidate model fits the chosen data; it does not +prove that this model is unique or that the environment has no memory. For +memory-sensitive probing, see {doc}`characterization`. + +## Further options + +### Forward models and sampling + +`NoiseCharacterizer(representation="auto")` uses density matrices up to eight +qubits, vectors up to ten, and MPS above that size by default. Choose +`"density_matrix"`, `"vector"`, or `"mps"` explicitly when needed. See +{doc}`representation_comparison` for the numerical trade-offs. + +Vector and MPS fits use trajectory-averaged MCWF and TJM simulations. +`sim_params.num_traj` controls their sampling budget; increasing it reduces +sampling error at greater cost. Refine the time step and numerical tolerances as +well as the trajectory count. A fixed `random_seed` makes the forward runs +repeatable but does not remove their sampling error. Recheck the fitted model +with more trajectories and independent seeds before drawing conclusions. For a +small system, deterministic fitting can also use stochastic or measured +reference data without making the candidate simulations stochastic. + +### Optimizer controls and results + +`sigma0` sets the initial CMA-ES search scale, and `popsize` sets the number of +candidates per generation. A larger search budget or several starting points can +help assess sensitivity to initialization. When there is only one free parameter +with finite bounds, YAQS uses a bounded scalar search; `max_iter` then limits +search evaluations rather than CMA-ES generations. Initial-model and final +fitted-trajectory evaluations are outside either limit. + +`fit.ref_traj`, `fit.fit_traj`, and `fit.times` retain the fitted-observable +comparison. `fit.loss_history` stores candidate losses; it excludes the initial +model's separately evaluated baseline. `fit.best_loss` is the best search +objective, while `fit.sqrt_loss_after()` gives its square root. On stochastic +backends, the final rerun can differ from the best sampled objective. Inspect +the traces as well as the optimizer's reported loss. + +See {class}`~mqt.yaqs.NoiseCharacterizer` for the full interface and +{doc}`realistic_noise_models` for supported one-site and two-site jump +processes. diff --git a/docs/examples/ensemble_evolution.md b/docs/examples/ensemble_evolution.md index 9727006f3..71da36239 100644 --- a/docs/examples/ensemble_evolution.md +++ b/docs/examples/ensemble_evolution.md @@ -2,258 +2,232 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true execution_timeout: 600 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Ensemble Evolution -Use this page when you need **two-time correlators** (`multi_time_observables` -on -{class}`~mqt.yaqs.core.data_structures.simulation_parameters.AnalogSimParams`) -or **ensemble averages** over `list[State]` inputs—for dynamical typicality -studies, transport correlators, and finite-temperature observables estimated -from random pure states. +A two-time correlation follows how a measurement at a later time relates to an +operator applied to the initial state. Averaging these correlations over several +initial states helps study spin dynamics and transport. This guide starts with +one state, compares it with a small ensemble, and then follows local +spin-current correlations in a periodic chain. All evolution is unitary. + +This guide uses the standard YAQS installation and Matplotlib. Run the cells in +order in a notebook. For a script, use the entry-point guard in +{doc}`simulator_initialization`. -This page demonstrates workflows for computing two-time correlations in a -deterministic (noiseless, unitary) ensemble in YAQS. The focus is on compact, -executable examples: +## 1. Follow one state in an open spin chain -- Single-state auto/two-time correlations. -- Ensemble-averaged correlations (typicality view). -- Small periodic spin-current transport example. +Use six spin-$1/2$ sites with nearest-neighbor XXZ interactions and a transverse +field. With $S^\alpha=\sigma^\alpha/2$, the Hamiltonian is -## 1. Unitary analog evolution primer +```{math} +H = \sum_{r=0}^{L-2}\left[ +J_{xx}(S_r^x S_{r+1}^x+S_r^y S_{r+1}^y) ++\Delta S_r^z S_{r+1}^z\right] ++h_x\sum_{r=0}^{L-1}S_r^x. +``` -In unitary analog evolution, we have no noise or tensor jumps. Omit -`noise_model` in {meth}`~mqt.yaqs.Simulator.run` (it defaults to `None`). +`Hamiltonian.pauli` uses Pauli matrices, so two-spin coefficients include a +factor of $1/4$ and the field coefficient includes $1/2$. Set $J_{xx}=1$ and +$\hbar=1$, so time is in units of $1/J_{xx}$. -```{code-cell} ipython3 +```{code-cell} python import numpy as np -import matplotlib.pyplot as plt from mqt.yaqs import AnalogSimParams, Hamiltonian, Observable, Simulator, State -sim = Simulator(show_progress=False) -``` - -```{code-cell} ipython3 L = 6 Jxx = 1.0 delta = 0.7 h_x = 0.4 - -# Open XXZ + transverse field: H = Jxx ∑_r (S^x_r S^x_{r+1} + S^y_r S^y_{r+1}) + Δ ∑_r S^z_r S^z_{r+1} + h_x ∑_r S^x_r -# (Pauli convention in code: S^α = σ^α/2, matching two_body prefactors 0.25 * Jxx / Δ.) H_open = Hamiltonian.pauli( length=L, two_body=[(0.25 * Jxx, "X", "X"), (0.25 * Jxx, "Y", "Y"), (0.25 * delta, "Z", "Z")], one_body=[(0.5 * h_x, "X")], bc="open", ) - mid = L // 2 psi0 = State(L, initial="haar-random", pad=2) +sim = Simulator(show_progress=False) +``` + +The `haar-random` preset builds a random MPS from Haar-random isometries. Here, +`pad=2` limits its initial bond dimension to two. This is not a uniformly +sampled vector from the full Hilbert space. State initialization is unseeded, so +rerunning the notebook changes the numerical curves. Save the initial MPS +tensors when you need to reproduce a particular ensemble. + +First measure the central site's Pauli $Z$ expectation. `Observable("z", mid)` +represents $\sigma^z_m$; divide its expectation by two for $S^z_m$. +```{code-cell} python primer_params = AnalogSimParams( observables=[Observable("z", mid)], elapsed_time=5.1, dt=0.15, max_bond_dim=64, svd_threshold=1e-10, - sample_timesteps=True, ) - result_primer = sim.run(psi0, H_open, primer_params) -times_primer = primer_params.times +times_primer = result_primer.times zexp_primer = result_primer.expectation_values[0] ``` -```{code-cell} ipython3 -fig, ax = plt.subplots(1, 1, figsize=(5.4, 3.2)) -ax.plot(times_primer, zexp_primer, marker="o", ms=3) -ax.set_xlabel("t") -ax.set_ylabel(r"$\langle S^z_m(t)\rangle$") -ax.set_title("Single-state unitary evolution") -ax.grid(alpha=0.3) +```{code-cell} python +:tags: [hide-input] +import matplotlib.pyplot as plt +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", "font.serif": ["STIXGeneral"], "mathtext.fontset": "stix", + "font.size": 10, "axes.labelsize": 11, "axes.linewidth": 0.8, + "xtick.direction": "in", "ytick.direction": "in", "svg.fonttype": "none", + "legend.frameon": False, +}) +fig, ax = plt.subplots(figsize=(5.4, 2.8), layout="constrained") +ax.plot(times_primer, zexp_primer, color="#0072B2", linewidth=1.8) +ax.axhline(0, color="0.7", linewidth=0.6) +ax.set(xlabel=r"Time $t$", ylabel=r"$\langle\sigma^z_m(t)\rangle$", xlim=(0, 5.1)) +ax.set_title("Local spin dynamics", loc="left", fontsize=11) plt.show() ``` -## 2. Two-time Correlations - -For an initial state $|\psi(0)\rangle$ and unitary $U(t)$: +The local expectation changes even though the whole chain evolves unitarily. A +single curve depends on its initial state. Two-time correlations let us ask how +a specified initial perturbation affects the later dynamics. -- Autocorrelation (for one observable $O$): +## 2. Request two-time correlations - ```{math} - C_{OO}(t) = \langle \psi(0)| U^\dagger(t)\, O\, U(t)\, O |\psi(0)\rangle - ``` +For a state $|\psi_0\rangle$ and propagator $U(t)$, define -- Generic two-time correlation (probe $A$ and kick $B$): - - ```{math} - C_{AB}(t) = \langle \psi(0)| U^\dagger(t)\, A\, U(t)\, B |\psi(0)\rangle - ``` +```{math} +C_{AB}(t)=\langle\psi_0|U^\dagger(t)\,A\,U(t)\,B|\psi_0\rangle. +``` -These quantities probe dynamical memory and relaxation. They are standard -observables in **dynamical quantum typicality (DQT)** and related -finite-temperature dynamics studies, where one compares single-trajectory and -ensemble-averaged behavior. +Pass `(A, B)` in `multi_time_observables`: `B` acts at time zero and `A` is +measured at time $t$. Setting `A` and `B` equal gives an autocorrelation. The +product need not be Hermitian, so the result can be complex. -The unitary-ensemble backend computes `multi_time_observables` pairs for -`list[State]` inputs (each with `representation="mps"`, the default). -Autocorrelation is the special case where both the observables are the same -`(O, O)`. For a single-state demonstration, we pass a list with one element. +The correlation backend takes a `list[State]` of MPS inputs. A list with one +state gives a single-state result. Reuse `psi0` to connect the correlation +calculation with the local dynamics above. -```{code-cell} ipython3 +```{code-cell} python sz_mid = Observable("z", mid) sx_mid = Observable("x", mid) - single_state_params = AnalogSimParams( observables=[], elapsed_time=5.1, dt=0.15, max_bond_dim=64, svd_threshold=1e-10, - sample_timesteps=True, - multi_time_observables=[(sz_mid, sz_mid), (sz_mid, sx_mid)], # row 0: C_zz(t), row 1: C_zx(t) -) - -sim = Simulator(show_progress=False) -result_single = sim.run( - [State(L, initial="haar-random", pad=2)], H_open, single_state_params + multi_time_observables=[(sz_mid, sz_mid), (sz_mid, sx_mid)], ) - +result_single = sim.run([psi0], H_open, single_state_params) t_single = result_single.multi_time_times czz_single = result_single.multi_time_results[0] czx_single = result_single.multi_time_results[1] ``` -```{code-cell} ipython3 -fig, ax = plt.subplots(1, 1, figsize=(5.8, 3.4)) -ax.plot(t_single, np.real(czz_single), "o-", label=r"$C_{zz}(t)$") -ax.plot(t_single, np.real(czx_single), "s--", label=r"$C_{zx}(t)$") -ax.set_xlabel("t") -ax.set_ylabel(r"$C_{ab}(t)$") -ax.set_title("Single-state two-time correlations") -ax.legend() -ax.grid(alpha=0.3) -plt.show() -``` - -## 3. Typicality view: from one state to an ensemble +`multi_time_results` has shape `(2, 35)`: one row per pair, in the supplied +order, and one column per sampled time. `multi_time_times` supplies the matching +time axis. The rows contain $C_{zz}$ and $C_{zx}$ for Pauli operators; divide by +four for spin-$1/2$ correlations. In particular, $C_{zz}(0)=1$. -In dynamical typicality studies, one often averages correlations over an -ensemble of initial states. Under certain thermalisation guarantees, one can -show that the typical relaxation behavior of _any_ state can be represented by -an ensemble average of the expectation over randomly initialised states. For -sufficiently rich ensembles, this can approximate high-temperature traces and -reveal robust transport trends. +## 3. Average over initial states -YAQS supports this directly by passing `list[State]` into `Simulator.run`. Each -member evolves independently, which, when parallelized by the unitary backend, -offers computational advantage to calculate these variables. +Pass several states to average the same correlations with equal weights. Keep +the original state as the first member and add three independently initialized +random MPS. Each state evolves separately, and the list length determines the +ensemble size; `num_traj` does not set it. -```{code-cell} ipython3 +```{code-cell} python num_states = 4 -ensemble_states = [State(L, initial="haar-random", pad=2) for _ in range(num_states)] - +ensemble_states = [psi0, *[State(L, initial="haar-random", pad=2) for _ in range(num_states - 1)]] ensemble_params = AnalogSimParams( observables=[], elapsed_time=5.1, dt=0.15, max_bond_dim=64, svd_threshold=1e-10, - sample_timesteps=True, - multi_time_observables=[ - (Observable("z", mid), Observable("z", mid)), # C_zz(t) autocorrelation - (Observable("z", mid), Observable("x", mid)), # C_zx(t) - ], + multi_time_observables=[(sz_mid, sz_mid), (sz_mid, sx_mid)], ) - result_ens = sim.run(ensemble_states, H_open, ensemble_params) t_ens = result_ens.multi_time_times czz_ens = result_ens.multi_time_results[0] czx_ens = result_ens.multi_time_results[1] ``` -```{code-cell} ipython3 -fig, ax = plt.subplots(1, 1, figsize=(5.8, 3.4)) -ax.plot(t_ens, np.real(czz_ens), "o-", label=r"ensemble $C_{zz}(t)$") -ax.plot(t_ens, np.real(czx_ens), "s--", label=r"ensemble $C_{zx}(t)$") -ax.set_xlabel("t") -ax.set_ylabel(r"$\overline{C}_{ab}(t)$") -ax.set_title(f"Typicality-style ensemble average of $C_{{zz}}(t)$ and $C_{{zx}}(t)$ (N={num_states})") -ax.legend() -ax.grid(alpha=0.3) +`multi_time_results` now contains the ensemble mean, with the same pair and time +axes. Ordinary `observables`, if supplied, also produce ensemble means in +`expectation_values` and individual member data in `trajectories`. Per-member +two-time correlations are not exposed in `Result`. + +```{code-cell} python +:tags: [hide-input] +fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.9), layout="constrained") +for ax, single, average, label in zip( + axes, [czz_single, czx_single], [czz_ens, czx_ens], ["zz", "zx"], strict=True, +): + ax.plot(t_single, single.real, color="0.55", linestyle="--", linewidth=1.3, label="One state") + ax.plot(t_ens, average.real, color="#0072B2", linewidth=1.8, label=f"{num_states}-state mean") + ax.axhline(0, color="0.75", linewidth=0.6) + ax.set(xlabel=r"Time $t$", ylabel=rf"$\mathrm{{Re}}\,C_{{{label}}}(t)$", xlim=(0, 5.1)) + ax.legend(fontsize=8) +axes[0].set_title("(a) Autocorrelation", loc="left", fontsize=11) +axes[1].set_title("(b) Cross correlation", loc="left", fontsize=11) plt.show() ``` -In this illustrative run, the ensemble-averaged $C_{zz}(t)$ appears to decay -toward zero while $C_{zx}(t)$ stays comparatively close to zero over the sampled -window; other runs may show different behavior. +The plots compare the real parts; the result retains both real and imaginary +components. Averaging changes the state-dependent fluctuations, but four states +do not establish a converged thermal average. This example demonstrates the +ensemble workflow. Dynamical quantum typicality uses suitable random-state +sampling to estimate traces; finite-temperature calculations also need thermal +weighting or filtering. The small random-MPS ensemble here supplies neither a +convergence study nor finite-temperature preparation. -## 4. Spin transport example: periodic spin-current autocorrelation +## 4. Compare local spin-current correlations -For periodic XXZ chains, define local bond current +A periodic XXZ chain lets us study how spin currents change with the interaction +strength. For each directed bond $(r,r+1)$, with site indices wrapped modulo +$L$, define ```{math} -j_r = J_{xx} \bigl(S_r^x S_{r+1}^y - S_r^y S_{r+1}^x\bigr) +j_r=J_{xx}\left(S_r^x S_{r+1}^y-S_r^y S_{r+1}^x\right). ``` -and total current $J = \sum_r j_r$. The normalized autocorrelator +Measure each bond's autocorrelation and average over bonds and initial states: ```{math} -C_{JJ}(t) = \frac{1}{L}\,\langle J(t)\,J(0)\rangle +C_{\mathrm{bond}}(t)=\frac{1}{L}\sum_r\langle j_r(t)j_r(0)\rangle_{\mathrm{ensemble}}. ``` -can be assembled from all bond-pair two-time correlators. Such current -autocorrelations are central to linear-response spin transport; dynamical -typicality makes it practical to estimate high-temperature ensemble quantities -from a few random pure-state trajectories -([Steiningeweg _et al._, Phys. Rev. Lett. **112**, 120601 (2014)](https://doi.org/10.1103/PhysRevLett.112.120601)). -For finite-temperature Drude weights, diffusion, and integrable XXZ -phenomenology—including the role of conservation laws—see the review -([Bertini _et al._, Rev. Mod. Phys. **93**, 025003 (2021)](https://doi.org/10.1103/RevModPhys.93.025003)). - -```{code-cell} ipython3 -def spin_current_bond_matrix(j_coupling: float) -> np.ndarray: - x = np.array([[0.0, 1.0], [1.0, 0.0]], dtype=np.complex128) - y = np.array([[0.0, -1.0j], [1.0j, 0.0]], dtype=np.complex128) - return 0.25 * j_coupling * (np.kron(x, y) - np.kron(y, x)) - +The two-site matrix below follows the listed site order, including the periodic +bond `(L - 1, 0)`. -def periodic_bonds(length: int) -> list[tuple[int, int]]: - return [(i, (i + 1) % length) for i in range(length)] +```{code-cell} python +def spin_current_bond_matrix(j_coupling): + x = np.array([[0.0, 1.0], [1.0, 0.0]], dtype=complex) + y = np.array([[0.0, -1.0j], [1.0j, 0.0]], dtype=complex) + return 0.25 * j_coupling * (np.kron(x, y) - np.kron(y, x)) -def current_observables(length: int, j_coupling: float) -> list[Observable]: - j_mat = spin_current_bond_matrix(j_coupling) - return [Observable(j_mat, sites=[i, j]) for i, j in periodic_bonds(length)] -``` - -```{code-cell} ipython3 Ltr = 6 -Jxx = 1.0 -# Keep the docs build light: two anisotropies and bond autocorrelations only -# (full J_i–J_j cross terms are L^2 correlators and time out on Read the Docs). deltas = [0.1, 1.5] -t_final = 3.0 -dt = 0.2 -n_transport_states = 2 - -states_transport = [State(Ltr, initial="haar-random", pad=2) for _ in range(n_transport_states)] -bond_obs = current_observables(Ltr, Jxx) +states_transport = [State(Ltr, initial="haar-random", pad=2) for _ in range(2)] +j_mat = spin_current_bond_matrix(Jxx) +bond_obs = [Observable(j_mat, sites=[r, (r + 1) % Ltr]) for r in range(Ltr)] pairs_jj = [(obs, obs) for obs in bond_obs] - -transport_curves: dict[float, np.ndarray] = {} -t_transport = None +transport_curves = {} +transport_results = {} for d in deltas: h_periodic = Hamiltonian.pauli( length=Ltr, @@ -261,59 +235,63 @@ for d in deltas: one_body=[], bc="periodic", ) - sp = AnalogSimParams( + transport_params = AnalogSimParams( observables=[], - elapsed_time=t_final, - dt=dt, + elapsed_time=3.0, + dt=0.1, max_bond_dim=32, svd_threshold=1e-10, - sample_timesteps=True, multi_time_observables=pairs_jj, ) - result_transport = sim.run(states_transport, h_periodic, sp) + result_transport = sim.run(states_transport, h_periodic, transport_params) t_transport = result_transport.multi_time_times - c_jj = np.real(np.sum(result_transport.multi_time_results, axis=0) / Ltr) - transport_curves[d] = c_jj + transport_results[d] = result_transport + transport_curves[d] = result_transport.multi_time_results.mean(axis=0) ``` -```{code-cell} ipython3 -fig, ax = plt.subplots(1, 1, figsize=(6.0, 3.5)) -for d in deltas: - ax.plot(t_transport, transport_curves[d], marker="o", ms=3, label=rf"$\Delta={d}$") -ax.set_xlabel("t") -ax.set_ylabel(r"$C_{JJ}(t)$") -ax.set_title("Periodic XXZ spin-current autocorrelation (small illustrative setup)") -ax.legend() -ax.grid(alpha=0.3) +Each `multi_time_results` array has shape `(6, 31)`, with one row for each bond. +The final mean over rows gives $C_{\mathrm{bond}}$. Reusing the same initial +states for both interaction strengths keeps the ensemble fixed. + +```{code-cell} python +:tags: [hide-input] +fig, ax = plt.subplots(figsize=(5.4, 2.9), layout="constrained") +for d, color in zip(deltas, ["#0072B2", "#D55E00"], strict=True): + ax.plot(t_transport, transport_curves[d].real, color=color, linewidth=1.8, label=rf"$\Delta={d}$") +ax.axhline(0, color="0.75", linewidth=0.6) +ax.set(xlabel=r"Time $t$", ylabel=r"$\mathrm{Re}\,C_{\mathrm{bond}}(t)$", xlim=(0, 3)) +ax.set_title("Local spin-current autocorrelation", loc="left", fontsize=11) +ax.legend(fontsize=9) plt.show() ``` -This finite-size, short-time run already shows different relaxation trends for -different anisotropies. In the thermodynamic limit and Kubo picture, the -long-time behavior of $C_{JJ}(t)$ is tied to the spin Drude weight and to -ballistic versus diffusive transport in the XXZ chain; -[Bertini _et al._, Rev. Mod. Phys. **93**, 025003 (2021)](https://doi.org/10.1103/RevModPhys.93.025003) -summarizes the established finite-temperature picture (including subtleties at -$\Delta=1$ and in finite systems). The illustrative curves here use small $L$ -and a handful of Haar-random states; larger-scale or higher-accuracy studies -follow typicality, as in -[Steiningeweg _et al._, Phys. Rev. Lett. **112**, 120601 (2014)](https://doi.org/10.1103/PhysRevLett.112.120601). - -:::{tip} Practical notes: scaling runs and MPS entanglement - -- Scale gradually: `L`, ensemble size, `dt`, `elapsed_time`, and `max_bond_dim`. -- Enable ensemble parallelization (`Simulator(parallel=True)`) when you have - many initial states. -- **MPS entanglement:** under unitary evolution, entanglement entropy and - required bond dimension typically **grow** with time (until truncation or - saturation). For longer times or larger $L$, increase `max_bond_dim`, tighten - `svd_threshold` only with care, or shorten the window so the MPS remains an - accurate ansatz for your observable. - -::: - -## Related topics - -- {doc}`analog_simulation` — noisy and unitary TJM evolution -- {doc}`state_initialization` — Haar-random and ensemble `list[State]` inputs -- {doc}`simulator_initialization` — `Simulator(parallel=True)` for ensemble runs +The curves show how the bond-current correlation depends on the interaction +strength over this short window. They are not the full total-current +correlation. For $J=\sum_r j_r$, the latter contains all cross-bond terms, +$C_{JJ}(t)=L^{-1}\sum_{r,s}\langle j_r(t)j_s(0)\rangle$. Computing it requires +$L^2$ operator pairs instead of the $L$ pairs used here. + +These small-chain curves do not determine a diffusion constant or a Drude +weight. For the connection between typicality and current correlations, see +[Steinigeweg et al., Phys. Rev. Lett. **112**, 120601 (2014)](https://doi.org/10.1103/PhysRevLett.112.120601). +The broader transport setting is covered in +[Bertini et al., Rev. Mod. Phys. **93**, 025003 (2021)](https://doi.org/10.1103/RevModPhys.93.025003). + +## Scale the calculation + +Parallel execution is enabled by default for ensembles with several members. The +documentation suppresses progress with `show_progress=False`; omit that setting +to see progress. Increase the number of initial states to check sampling +convergence, and check timestep and bond-dimension convergence separately. The +random state's initial `pad` and the evolution's `max_bond_dim` serve different +purposes. Longer evolution can require larger bonds as entanglement grows. + +The list-of-state path requires MPS inputs and a static Hamiltonian. It returns +ensemble observables and correlations, rather than a final ensemble state. + +## Related guides + +- {doc}`analog_simulation` — single-state analog evolution and numerical + settings. +- {doc}`state_initialization` — random MPS, custom states, and list inputs. +- {doc}`simulator_initialization` — parallel workers and script execution. diff --git a/docs/examples/equivalence_checking.md b/docs/examples/equivalence_checking.md index 6b8c8ec81..61056b661 100644 --- a/docs/examples/equivalence_checking.md +++ b/docs/examples/equivalence_checking.md @@ -2,360 +2,328 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true execution_timeout: 300 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - -# Equivalence Checking - -YAQS can test whether two quantum circuits implement the same unitary map, up to -a **global phase** and numerical tolerance. The public API is -{class}`~mqt.yaqs.EquivalenceChecker`, which forms the composed operator -$W = U_1 U_2^\dagger$ from the two circuits and checks whether $W$ is close to -the identity. - -For most workflows—comparing a high-level circuit to a transpiled variant, -regression tests on compiled circuits, or checking compiler passes—the -**MPO backend** (`representation="mpo"`) is the intended tool. It scales to -larger qubit counts via tensor-network updates and SVD truncation controlled by -`threshold`. The **matrix backend** (`representation="matrix"`) is a dense, -tensorized reference useful on very small circuits; both backends target the -same equivalence criterion. - -## Choosing a backend +# Circuit Verification -| Backend | When to use | Scaling | Numerical knobs | -| ----------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- | -| **`mpo`** (recommended) | Default for real circuits; long-range gates; anything beyond a handful of qubits | Polynomial in qubits for many structured circuits; memory grows with bond dimension | `threshold` (SVD truncation), `fidelity` | -| **`matrix`** | Small-circuit checks, debugging, cross-checking the MPO path | Exponential in qubits ($4^n$ complex numbers for the dense operator tensor) | `fidelity` only | -| **`auto`** | Convenience: picks matrix for `num_qubits <= matrix_max_qubits`, otherwise MPO | Same as the selected backend | Both when MPO is selected | +A circuit must use the gates and connections that a quantum device supports. +Transpilation makes those changes, but the compiled circuit should still perform +the intended operation. We first verify a circuit compiled for a hardware +target, then introduce a rotation-angle bug and ask how hardware noise changes +its agreement with the original circuit. -```{note} -`representation="auto"` remains the constructor default, but -**you should pass `representation="mpo"` explicitly** when equivalence checking -is part of a pipeline you care about. Auto only avoids thinking about backend -choice on tiny circuits; it does not change the fact that MPO is the primary -algorithm in YAQS. -``` - -With the default cutover of **7** qubits (`matrix_max_qubits` on -{class}`~mqt.yaqs.EquivalenceChecker`), auto uses the matrix backend only for -circuits with **at most seven qubits**. From eight qubits upward, auto selects -MPO. Override the cutover with `matrix_max_qubits` if needed. +This extends the circuit comparison in {doc}`quickstart`. The four-qubit example +uses the standard YAQS installation and Matplotlib for plotting, without a +hardware account. Run the cells in order in a notebook; for a script, use the +entry-point guard in {doc}`simulator_initialization`. -## What “equivalent” means - -Two circuits $C_1$ and $C_2$ on $n$ qubits are reported as equivalent when their -unitaries $U_1$ and $U_2$ satisfy - -```{math} -U_1 U_2^\dagger \approx e^{i\phi}\, I -``` +## 1. Compile for hardware constraints -for some global phase $\phi$, within `fidelity`. On the **matrix** path, only -**final** measurements are stripped before building $U$; mid-circuit -measurements raise an error. Barriers are ignored on the matrix path. The -**MPO** backend walks circuit DAGs directly (measurements and barriers are -skipped during zone extraction); mid-circuit measurements are not supported for -unitary equivalence on either backend. Gates on more than two qubits (for -example `ccx`) are supported on the matrix backend only; the MPO backend rejects -them with a `ValueError`. Unknown unitaries translate via the matrix fallback, -which supports at most eight qubits (see {doc}`custom_gates`). See -{cite:p}`sander2025_EquivalenceChecking` for the underlying MPO method. - -A noiseless `check` returns a dictionary: - -| Key | Type | Meaning | -| --------------------------------- | ------------------- | ------------------------------------------------------------------------- | -| `equivalent` | `bool` | Whether the circuits pass the identity test | -| `fidelity` | `float` | Measured normalized overlap of $W=U_1U_2^\dagger$ with the identity | -| `elapsed_time` | `float` | Wall time in seconds | -| `representation` | `str` | `"matrix"` or `"mpo"` — which backend ran | -| `matrix` | `ndarray` or `None` | Dense composed operator $W$ as a $(2^n, 2^n)$ matrix; matrix backend only | -| `mpo` | `MPO` or `None` | Composed operator on the MPO backend; `None` on matrix | -| `schmidt_values` | `ndarray` or `None` | Center-cut operator Schmidt values (`length // 2`); MPO backend only | -| `center_cut_entanglement_entropy` | `float` or `None` | Operator entanglement entropy at `length // 2`; MPO backend only | -| `global_entanglement_entropy` | `float` or `None` | Sum of operator entanglement entropies over internal bonds; MPO only | - -## Parameters - -{class}`~mqt.yaqs.EquivalenceChecker` stores settings on the instance; circuits -are passed to {meth}`~mqt.yaqs.EquivalenceChecker.check` each time. - -- **`threshold`** (default `1e-13`): singular-value cutoff during MPO updates. - Smaller values retain more bond dimension and are stricter; larger values - speed up checks at the cost of accuracy. -- **`fidelity`** (default `1 - 1e-13`): minimum normalized overlap between $W$ - and the identity (global phase removed). It must be finite and between `0` and - `1`. Noiseless and noisy results use this same scale and threshold. -- **`representation`**: `"mpo"`, `"matrix"`, or `"auto"`. -- **`matrix_max_qubits`** (default **7**): only affects `"auto"`. -- **`parallel`** (default `True`): enables checkerboard **MPO** pair updates in - a **thread pool** from 12 qubits upward and allows noisy trajectories to use a - **process pool**. Set it to `False` to keep either path serial. -- **`max_workers`** (default `None`): cap on worker threads when `parallel=True` - (noiseless MPO checks), and on worker processes for noisy ensembles. Worker - counts also respect the available CPUs and number of trajectories. -- **`mp_context`**: start method for noisy-ensemble process pools (`"auto"`, - `"fork"`, `"spawn"`) when one is created. Noiseless MPO zone parallelism - inside `iterate()` still uses in-process threads. - -```{code-cell} ipython3 -from mqt.yaqs import EquivalenceChecker +Our circuit entangles qubit 0 with every other qubit, then applies local +rotations. A device with a line of nearest-neighbor connections cannot execute +all three controlled-X gates directly. The transpiler must route the circuit and +express its rotations in the device's native gate set. -# Recommended: MPO for the circuits you care about -mpo_checker = EquivalenceChecker( - representation="mpo", - threshold=1e-6, - fidelity=1 - 1e-13, +```{code-cell} python +import numpy as np +from qiskit import QuantumCircuit, transpile +from qiskit.providers.fake_provider import GenericBackendV2 + +num_qubits = 4 +original = QuantumCircuit(num_qubits) +original.h(0) +for site in range(1, num_qubits): + original.cx(0, site) +for site in range(num_qubits): + original.ry(0.3 * (site + 1), site) + +connections = [[0, 1], [1, 0], [1, 2], [2, 1], [2, 3], [3, 2]] +backend = GenericBackendV2( + num_qubits, + basis_gates=["rz", "sx", "x", "cx"], + coupling_map=connections, + noise_info=False, +) +compiled = transpile( + original, + backend=backend, + initial_layout=list(range(num_qubits)), + optimization_level=1, + seed_transpiler=7, ) -# Auto: matrix if num_qubits <= 7, else MPO -auto_checker = EquivalenceChecker(representation="auto") -``` - -## Loading from OpenQASM - -{meth}`~mqt.yaqs.EquivalenceChecker.check` accepts OpenQASM 2 and OpenQASM 3 -inputs directly — no need to call Qiskit's loaders first. Pass a filesystem -path, a `pathlib.Path`, or a raw OpenQASM string (when the first substantive -line declares `OPENQASM`): - -```python -checker = EquivalenceChecker(representation="mpo") - -# File paths (preferred when the program uses include directives) -result = checker.check("original.qasm", "transpiled.qasm") - -# Raw source strings -result = checker.check(qasm_source_a, qasm_source_b) +print("Original gates:", dict(original.count_ops())) +print("Compiled gates:", dict(compiled.count_ops())) ``` -OpenQASM 3 requires the optional package `qiskit-qasm3-import` -(`uv pip install mqt-yaqs[qasm3]`). The same path and string forms work with -{meth}`~mqt.yaqs.Simulator.run` for circuit simulation. +`GenericBackendV2` supplies an **offline hardware target**, not measured device +data. To compile for a real device, pass that device's Qiskit backend instead. +The native gates and connectivity then come from its target. YAQS does not +import the backend's calibration data into a noise model; we define the noise +separately below. -## Example: compare original and transpiled circuits +## 2. Align the outputs and verify the compiled circuit -The workflow below builds a parameterized circuit, transpiles it to another gate -set, and checks equivalence with the **MPO backend**. This matches typical -compiler-verification use cases. +Routing can leave logical outputs on different physical qubits. The checker +compares circuit wires directly, so we must account for this mapping before +interpreting a mismatch as a compiler bug. We fixed the initial placement to +`[0, 1, 2, 3]`; the remaining change is the final output permutation. -Define the number of qubits and circuit depth. +Append that permutation to the **reference** circuit. The compiled circuit stays +as the device would execute it, including its routing gates. Qiskit's +`PermutationGate` lists the input wire for each output position, so we invert +the logical-to-physical map returned by `final_index_layout()`. -```{code-cell} ipython3 -num_qubits = 5 -depth = num_qubits -``` +```{code-cell} python +from qiskit.circuit.library import PermutationGate -Create a TwoLocal circuit and decompose it. +from mqt.yaqs import EquivalenceChecker -```{code-cell} ipython3 -from qiskit.circuit.library.n_local import TwoLocal +output_mapping = compiled.layout.final_index_layout() +reference = original.copy() +reference.append(PermutationGate(np.argsort(output_mapping).tolist()), range(num_qubits)) +reference = reference.decompose(gates_to_decompose=["permutation"]) -import numpy as np +checker = EquivalenceChecker() +verified = checker.check(reference, compiled) -circuit = TwoLocal(num_qubits, ["rx"], ["rzz"], entanglement="linear", reps=depth).decompose() -num_pars = len(circuit.parameters) -rng = np.random.default_rng() -values = rng.uniform(-np.pi, np.pi, size=num_pars) -circuit.assign_parameters(values, inplace=True) -circuit.measure_all() +print("Logical output → physical qubit:", output_mapping) +print("Equivalent:", verified["equivalent"]) +print(f"Overlap: {verified['fidelity']:.12f}") ``` -Transpile the circuit to a new basis. +For the reference unitary $U$ and compiled unitary $V$, the returned overlap is -```{code-cell} ipython3 -from qiskit import transpile +$$ +a=\frac{|\operatorname{Tr}(UV^\dagger)|}{2^n}. +$$ -basis_gates = ["cz", "rz", "sx", "x", "id"] -transpiled_circuit = transpile(circuit, basis_gates=basis_gates, optimization_level=1) -``` - -Run equivalence checking with the MPO backend. +An overlap of one means that the circuits agree on every input state, up to a +global phase. `equivalent` tests whether this value reaches the checker's +`fidelity` setting, which defaults to `1 - 1e-13`. The compiled circuit passes +this numerical check. This compares the full operation, rather than only the +output from one chosen input state. -```{code-cell} ipython3 -from mqt.yaqs import EquivalenceChecker +The default checker selects its backend automatically: dense matrices for at +most seven qubits, and a matrix product operator (MPO) for larger circuits. This +small example therefore uses the matrix backend. -checker = EquivalenceChecker(representation="mpo", threshold=1e-6, fidelity=1 - 1e-13) -result = checker.check(circuit, transpiled_circuit) +```{note} +The mapping above assumes the identity initial placement and equal circuit +widths. For another initial layout or a backend that adds ancillas, also align +the input wires and the circuit widths before checking. YAQS does not apply +Qiskit's transpilation layout automatically. ``` -The same pair with `representation="auto"` on this five-qubit example selects -the matrix backend because $5 \leq 7$. For a consistent pipeline, keep -`representation="mpo"` as above. - -```{code-cell} ipython3 -auto_result = EquivalenceChecker(representation="auto").check(circuit, transpiled_circuit) -``` +## 3. Introduce a rotation-angle bug -## Matrix backend (small circuits) +Suppose a compiler pass changes one native $R_z$ angle by $\delta$. We copy the +compiled circuit and change its first `rz` instruction, leaving the routing and +all other gates intact. -The matrix backend builds $W = U_1 U_2^\dagger$ as a tensor with $2n$ indices of -dimension 2 and applies local gate contractions. It uses the same trace-based -identity test as the MPO path. Memory and time grow as $\mathcal{O}(4^n)$, so -this backend is practical only for very small $n$. +```{code-cell} python +def with_angle_error(circuit, delta): + """Offset the first native Z rotation by delta radians.""" + changed = circuit.copy() + index = next(i for i, instruction in enumerate(changed.data) if instruction.operation.name == "rz") + instruction = changed.data[index] + rotation = instruction.operation.copy() + rotation.params[0] += delta + changed.data[index] = instruction.replace(operation=rotation) + return changed -Use it when: -- You want a dense reference on at most a few qubits. -- You are debugging the equivalence machinery itself. +angles = np.linspace(0, np.pi, 25) +angle_overlaps = [checker.check(reference, with_angle_error(compiled, delta))["fidelity"] for delta in angles] +bug_angle = np.pi / 2 +buggy = with_angle_error(compiled, bug_angle) +bug_result = checker.check(reference, buggy) -```python -small_checker = EquivalenceChecker(representation="matrix", fidelity=1 - 1e-13) +print("Buggy circuit equivalent:", bug_result["equivalent"]) +print(f"Overlap with a π/2 angle error: {bug_result['fidelity']:.4f}") ``` -Forcing `representation="matrix"` on large circuits is allowed but can exhaust -memory; prefer MPO instead. - -## Parallel execution - -Set `parallel=True` on {class}`~mqt.yaqs.EquivalenceChecker` to speed up **MPO** -checks on circuits where many independent updates can run at once. This is the -default; below 12 qubits the implementation keeps a single noiseless check -serial even when `parallel=True`, because thread overhead would dominate. A -single matrix check is also serial. When a noise model is supplied, independent -matrix or MPO trajectories can instead run across processes. - -Within each checkerboard sweep, disjoint nearest-neighbor pairs update different -MPO site tensors and can be computed in parallel in a shared thread pool (one -pool per `iterate()` call). Temporal zones are still extracted from the DAGs -serially; only the tensor contraction and SVD step runs concurrently. Long-range -gate handling stays serial in this version. - -```{code-cell} ipython3 -wide_checker = EquivalenceChecker( - representation="mpo", - max_workers=4, -) -``` +For this single rotation error, the overlap is exactly $|\cos(\delta/2)|$ in +exact arithmetic. The other gates cancel inside the trace, so the formula does +not depend on where the faulty rotation occurs. A $\pi/2$ error gives an overlap +of about 0.707 and fails the equivalence check. A smaller error also fails once +its overlap falls below the chosen tolerance. (equivalence-noise-model)= -## Comparing with a noise model - -Passing a {class}`~mqt.yaqs.NoiseModel` asks how close a -**compiled, hardware-like** circuit remains to an **ideal** specification under -sampled noise. Each trajectory materializes a stochastic realization of the -noise model on the compiled circuit. This gives a Monte Carlo comparison rather -than an exact noisy-channel equivalence certificate. - -The checker rejects noise processes that it cannot materialize as stochastic -circuit operations; those remain available through the simulator. - -Noise is sampled onto the **second** circuit argument only. A supported -two-qubit unitary gate is a noise opportunity; single-qubit gates, gates on -three or more qubits, barriers, and measurements are not. A process is eligible -when its complete site support is contained in the gate support. The selected -equivalence backend must also support the original gate. - -Within `EquivalenceChecker`, each resolved `strength` is a dimensionless branch -probability $p_i$ at every eligible gate. Processes with the same exact site -support form one categorical draw: process $i$ occurs with probability $p_i$, -and no process from that support occurs with probability $1-\sum_i p_i$. -Consequently, each same-support sum must be at most one. Different exact -supports are sampled independently, so multiple errors may follow one gate. This -also applies to overlapping supports such as `[0]` and `[0, 1]`, which may both -be selected in one trajectory. - -For an isotropic Pauli error with total probability $p$ on each gate qubit, -assign `strength=p/3` to X, Y, and Z on that one-qubit support. The identity -then has probability $1-p$ on each qubit, and the per-qubit draws are -independent. - -Writing $U_{\mathrm{ideal}}$ for the first circuit and $U_{\mathrm{noisy},r}$ -for trajectory $r$ of the second, the relative operator has the order - -```{math} -Q_r = U_{\mathrm{ideal}} U_{\mathrm{noisy},r}^\dagger. -``` - -If $a_r = |\operatorname{Tr}(Q_r)| / d$ is the normalized root overlap, the -sampled channel's process fidelity $F_{\mathrm{pro}}=\mathbb E[a_r^2]$ is -estimated internally by +## 4. Add a hardware noise model -```{math} -\widehat F_{\mathrm{pro}} = \frac{1}{N}\sum_{r=1}^{N} a_r^2. -``` +A correctly compiled circuit can still deviate from the intended operation +because its gates are noisy. We model a Pauli error after each controlled-X +gate: on each participating qubit, apply $X$, $Y$, or $Z$ with probability $p/3$ +each, and apply no error with probability $1-p$. Sweeping $p$ separates the +noiseless compiler check from the effect of executing the circuit on noisy +hardware. -The public result stays on the noiseless scale: +```{code-cell} python +from mqt.yaqs import NoiseModel -```{math} -\mathtt{fidelity}=\sqrt{\widehat F_{\mathrm{pro}}}. +probabilities = np.array([0.0, 0.005, 0.015, 0.03, 0.06, 0.1]) +implementations = {"Correct compilation": compiled, "Rotation-angle bug": buggy} +noise_results = {label: [] for label in implementations} + +for probability in probabilities: + noise = NoiseModel([ + {"name": f"pauli_{axis}", "sites": [site], "strength": float(probability / 3)} + for site in range(num_qubits) + for axis in "xyz" + ]) + for label, circuit in implementations.items(): + result = checker.check( + reference, + circuit, + noise_model=noise, + num_traj=256, + random_seed=7, + ) + noise_results[label].append(result) ``` -For $N>1$, `fidelity_error` is the approximate delta-method Monte Carlo standard -error on this root-fidelity scale. It is `0.0` when every sampled overlap is -zero and `None` for one trajectory. - -Apply noise to the transpiled circuit from the earlier example. The noisy call -returns the same primary fields as the noiseless call: - -```{code-cell} ipython3 -from mqt.yaqs import NoiseModel - -checker = EquivalenceChecker(representation="mpo", threshold=1e-6) -noiseless = checker.check(circuit, transpiled_circuit) -noise = NoiseModel([ - {"name": "pauli_x", "sites": [qubit], "strength": 0.02} for qubit in range(num_qubits) -]) -noisy = checker.check( - circuit, - transpiled_circuit, - noise_model=noise, - num_traj=24, - random_seed=0, -) +**Noise acts on the second circuit only.** Every supported two-qubit unitary is +a noise opportunity when it contains all sites of a process. Thus routing gates +also contribute noise. Single-qubit gates, barriers, measurements, and gates on +three or more qubits do not create noise opportunities. + +For the checker, `strength` is a +**dimensionless probability per eligible gate**, not a Lindblad rate or a +probability for the whole circuit. This differs from noise strengths in +{doc}`analog_simulation` and {doc}`circuit_observables`. Processes with the same +exact support are mutually exclusive, and their probabilities must sum to at +most one. Different supports are sampled independently, including overlapping +supports. + +Each trajectory gives a unitary $V_r$ with sampled Pauli errors. The noisy +result reports the square root of the estimated process fidelity, + +$$ +\mathtt{fidelity}=\sqrt{\frac{1}{N}\sum_{r=1}^{N} +\left|\frac{\operatorname{Tr}(UV_r^\dagger)}{2^n}\right|^2}. +$$ + +This is the root-mean-square trajectory overlap, not the mean overlap or a +measurement success probability. `fidelity_error` estimates its Monte Carlo +standard error. Increasing `num_traj` reduces sampling uncertainty; it does not +make the assumed hardware model more accurate. + +## 5. Compare the bug and noise effects + +The left panel checks the controlled rotation error against its exact formula. +The right panel compares correct and faulty compilations under the same noise +model. Plotting code is folded so the verification workflow remains visible. + +```{code-cell} python +:tags: [hide-input] +import matplotlib.pyplot as plt + +plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["STIXGeneral"], + "mathtext.fontset": "stix", + "font.size": 10, + "axes.labelsize": 11, + "axes.linewidth": 0.8, + "xtick.direction": "in", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "svg.fonttype": "none", +}) +%config InlineBackend.figure_formats = ['svg'] -print(f"noiseless: equivalent={noiseless['equivalent']}, fidelity={noiseless['fidelity']:.6f}") -print( - "noisy: " - f"sample threshold passed={noisy['equivalent']}, " - f"root process fidelity={noisy['fidelity']:.4f} " - f"+/- {noisy['fidelity_error']:.4f}" -) -print(f"trajectories: {noisy['num_traj']}") +colors = ["#225c80", "#bb563b"] +fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8), sharey=True, layout="constrained") +fine_angles = np.linspace(0, np.pi, 200) +axes[0].plot(fine_angles / np.pi, np.abs(np.cos(fine_angles / 2)), color="0.55", lw=1.7, label=r"$|\cos(\delta/2)|$") +axes[0].plot(angles / np.pi, angle_overlaps, "o", ms=3.5, color=colors[0], label="YAQS") +axes[0].plot(0.5, bug_result["fidelity"], "D", ms=5, color=colors[1], label=r"Bug: $\delta=\pi/2$") +axes[0].set(xlabel=r"Rotation-angle error $\delta/\pi$", ylabel="Root process fidelity", xlim=(-0.03, 1.03)) +axes[0].legend(frameon=False, fontsize=9, loc="lower left") + +for (label, results), color in zip(noise_results.items(), colors, strict=True): + values = [result["fidelity"] for result in results] + errors = [result["fidelity_error"] for result in results] + axes[1].errorbar(100 * probabilities, values, yerr=errors, color=color, marker="o", ms=4, lw=1.6, capsize=2.5, label=label) +axes[1].set(xlabel=r"Pauli-error probability per gate qubit $p$ (%)", xlim=(-0.3, 10.3)) +axes[1].legend(frameon=False, fontsize=9, loc="lower left") +for label, ax in zip(("(a)", "(b)"), axes, strict=True): + ax.text(0.02, 1.03, label, transform=ax.transAxes, va="bottom", fontweight="bold") + ax.set_ylim(-0.04, 1.06) + ax.spines[["top", "right"]].set_visible(False) + ax.tick_params(top=False, right=False) +plt.show() ``` -Here `strength=0.02` means a 2% X-error probability on that qubit after each -eligible two-qubit gate containing it. It is neither a Lindblad rate nor a 2% -error probability for the complete circuit. - -A noisy `check` returns -{class}`~mqt.yaqs.equivalence_checker.EquivalenceEnsembleResult` with the same -fields as a noiseless check, plus `fidelity_error` and `num_traj`. Noisy -`equivalent` compares the point estimate with `checker.fidelity`; it does not -use the error bar and is not an exact certificate. For MPO checks, entropies are -trajectory means and `schmidt_values` is the zero-padded mean trajectory -spectrum, not a channel spectrum. `matrix` and `mpo` are `None` because the -ensemble is a channel rather than one relative unitary. Pass -`return_trajectories=True` to include the individual trajectory results. - -Distribution-valued strengths are resolved once per `check` call, so every -trajectory uses the same resolved probabilities. The checker then validates the -same-support sums; an out-of-range draw raises `ValueError`. A nonnegative -`random_seed` makes the sampled ensemble reproducible independently of process -scheduling. - -## Performance notes - -Internal benchmarks (`benchmarks/bench_equivalence_matrix_vs_mpo.py`) on random -`EfficientSU2` circuits show the matrix backend winning only at very small qubit -counts; MPO is faster from roughly eight qubits upward on those workloads. That -aligns with the default auto cutover at seven qubits: auto uses matrix only -where it is still affordable, and MPO for everything larger. - -## Related topics - -- {doc}`realistic_noise_models` — Pauli and dissipative process names, disorder -- {doc}`custom_gates` — Qiskit translation, matrix fallback, and TDVP generators -- {doc}`simulator_initialization` — running simulations with - {class}`~mqt.yaqs.Simulator` -- {doc}`simulation_parameters` — presets and truncation for **simulation** - (separate from equivalence `threshold`) +**Compiler errors and hardware noise reduce agreement in different ways.** (a) +The noiseless overlap follows the single-angle error formula. (b) With no noise, +the correct compilation agrees with the reference, while the faulty circuit +starts below one. As the Pauli-error probability increases, both estimates fall +in this example. Error bars show one Monte Carlo standard error from 256 +trajectories. Lines join sampled points. + +This comparison tells us whether compilation preserves the intended operation +and how a specified hardware model changes its process fidelity. It does not +identify an unknown bug or infer device noise from measurements. For learning +noise strengths from observed dynamics, see {doc}`digital_twin`. + +For noisy results, `equivalent` applies the same overlap threshold to the point +estimate without using its error bar. Treat this as a sampled comparison, not an +exact noisy-channel certificate. In particular, a zero reported error bar when +every sampled overlap is zero does not establish zero uncertainty. + +## Further options + +### Accuracy and backends + +Keep `representation="auto"` for automatic selection, or choose `"matrix"` or +`"mpo"` explicitly. Dense matrix storage grows as $4^n$; MPO cost depends on the +operator's bond dimensions and can also grow rapidly. The MPO method is +described in {footcite:p}`sander2025_EquivalenceChecking`. + +The constructor's `fidelity` sets the decision threshold, while `threshold` sets +the MPO singular-value cutoff. These control different errors. For an +approximate comparison, choose a decision threshold that matches your purpose +and check that numerical truncation does not determine the answer. The returned +`representation` records which backend ran. + +Terminal measurements are ignored for unitary checks; mid-circuit measurements +are unsupported. Decompose gates on more than two qubits before using the MPO +backend. See {ref}`circuit-custom-gates` for supported gate translation. + +### Noise models and returned data + +The checker supports stochastic Pauli errors, including Pauli products. It +rejects dissipative channels such as relaxation; use the simulator for those +channels. See {doc}`realistic_noise_models` for model construction. +Distribution-valued strengths are drawn once per `check`, then held fixed across +its trajectories. The resolved same-support probabilities must still sum to at +most one. + +Pass `return_trajectories=True` to keep each trajectory result. Noisy results +have `matrix=None` and `mpo=None` because the ensemble is a channel. For MPO +checks, returned entropies and zero-padded Schmidt spectra are trajectory means, +not spectra of that channel. `fidelity_error` is `None` for a single trajectory. + +### Inputs and execution + +`check` accepts Qiskit circuits, OpenQASM file paths, and raw OpenQASM source +strings. OpenQASM 3 requires the optional `mqt-yaqs[qasm3]` extra. File paths +allow includes to resolve relative to the source file. + +Parallel execution is enabled by default. `max_workers` caps concurrency, and +`mp_context` controls the start method for noisy process pools. A nonnegative +`random_seed` makes sampled trajectories reproducible across worker scheduling. +See {class}`~mqt.yaqs.EquivalenceChecker` for the full settings and returned +fields. + +```{footbibliography} +``` diff --git a/docs/examples/hamiltonians.md b/docs/examples/hamiltonians.md index 0992ac3e7..5e65e3f61 100644 --- a/docs/examples/hamiltonians.md +++ b/docs/examples/hamiltonians.md @@ -2,511 +2,330 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true execution_timeout: 120 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Building Hamiltonians -Analog simulations take a -{class}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian` as the operator -argument to {meth}`~mqt.yaqs.Simulator.run`. Most models are built as -**matrix product operators (MPOs)** under the hood; the `Hamiltonian` wrapper -materialises once at construction and can be reused across parameter sweeps. - -**Backend selection** is driven only by -{class}`~mqt.yaqs.core.data_structures.state.State` representation — not by how -the Hamiltonian was constructed: - -| `State.representation` | Backend | Form materialized at `run` | -| ---------------------- | -------- | -------------------------- | -| `"mps"` (default) | TJM | MPO | -| `"vector"` | MCWF | sparse | -| `"density_matrix"` | Lindblad | sparse | +A Hamiltonian defines the energies and interactions in an analog simulation. +Build one with a named model, a sum of Pauli terms, or your own operator data, +then pass it to `Simulator.run`. Match its site count and local dimensions to +those of the initial `State`. -Hamiltonian inputs (`tensors`, `matrix`, `sparse_matrix`, or a preset such as -`Hamiltonian.ising(...)`) are **source data**, not backend choices. The same -`Hamiltonian` instance works with all three state representations; -`Simulator.run` converts and caches the required MPO or sparse form. - -```{warning} -Selecting ``State.representation="mps"`` (TJM) calls -{meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.ensure_mpo`. For a -``sparse_matrix`` source this densifies the operator before MPO factorization, -allocating the full Hilbert-space matrix and risking out-of-memory failures on -large systems. Prefer an MPO preset, ``Hamiltonian.from_mpo(...)``, or -``tensors=`` when targeting TJM. -``` +## Choose a built-in model -This page covers the factory methods in the library. For open-system evolution -after the Hamiltonian is defined, see {doc}`analog_simulation`. For choosing a -state representation, see {doc}`state_initialization` and -{doc}`representation_comparison`. +Most builders create a matrix product operator (MPO), which stores the operator +as a tensor network. Use the `Hamiltonian` methods directly when available; wrap +an MPO with `Hamiltonian.from_mpo` for the other models. -## `Hamiltonian` versus `MPO` +| Model | Constructor | Site layout | +| ------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- | +| Transverse-field Ising | {meth}`~mqt.yaqs.Hamiltonian.ising` | Qubits. | +| Heisenberg or XY | {meth}`~mqt.yaqs.Hamiltonian.heisenberg` | Qubits. | +| On-site and nearest-neighbor Pauli terms | {meth}`~mqt.yaqs.Hamiltonian.pauli` | Qubits. | +| Indexed Pauli strings, including long-range terms | {meth}`~mqt.yaqs.MPO.from_pauli_sum` | Qubits; wrap the MPO. | +| 1D Fermi–Hubbard | {meth}`~mqt.yaqs.Hamiltonian.fermi_hubbard_1d` | Dimension-four sites, or a Jordan–Wigner qubit chain. | +| Bose–Hubbard | {meth}`~mqt.yaqs.MPO.bose_hubbard` | Truncated boson occupation; wrap the MPO. | +| Coupled transmons and resonators | {meth}`~mqt.yaqs.Hamiltonian.coupled_transmon` | Alternating transmon and resonator dimensions. | +| Trapped ion position grid | {meth}`~mqt.yaqs.MPO.trapped_ion` | One grid per ion, for one or two ions; wrap the MPO. | -| Layer | Role | -| --------------------------------------------------------------- | ------------------------------------------------- | -| {class}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian` | User-facing type passed to `Simulator.run` | -| {class}`~mqt.yaqs.core.data_structures.mpo.MPO` | Tensor-network operator; built by factories below | +YAQS evolves with $\exp(-itH)$, using $\hbar=1$. Hamiltonian coefficients and +times must use consistent units. For energies in SI units, divide the operator +by $\hbar$ before evolving with time in seconds. -Typical patterns: +## Build a spin chain -- **Preset classmethods** — `Hamiltonian.ising(...)`, `Hamiltonian.pauli(...)`, - etc. (no `representation=` argument). -- **Wrap an MPO** — `Hamiltonian.from_mpo(mpo)` after `MPO.bose_hubbard(...)` or - a custom build. -- **Manual data** — `Hamiltonian(tensors=...)`, `Hamiltonian(matrix=...)`, or - `Hamiltonian(sparse_matrix=...)`. Any of these can drive TJM, MCWF, or - Lindblad once paired with the matching `State.representation`. -- **Piecewise time dependence** — `Hamiltonian.piecewise([(H, duration), ...])` - switches static Hamiltonians on the analog `dt` grid. +For an open chain, the Ising shortcut constructs -For a static Hamiltonian, access the materialized MPO with `H.mpo` (after a TJM -run or `H.ensure_mpo()`) and the sparse form with `H.sparse_matrix` (after an -MCWF / Lindblad run or `H.ensure_sparse()`). Both can coexist on one instance. A -piecewise Hamiltonian is a sequence of static pieces, so it cannot be -materialized with `ensure_mpo()` or `ensure_sparse()`. +$$ +H_{\mathrm{Ising}}=-J\sum_{i=0}^{L-2}Z_iZ_{i+1}-g\sum_{i=0}^{L-1}X_i. +$$ -## Hamiltonian energy - -For an MPS-backed state, contract the state with a static Hamiltonian's -materialized MPO: - -```{code-cell} ipython3 +```{code-cell} python from mqt.yaqs import Hamiltonian, State -state = State(4, initial="zeros") -hamiltonian = Hamiltonian.ising(4, J=1.0, g=0.5) -hamiltonian.ensure_mpo() -energy = state.mps.expect_mpo(hamiltonian.mpo) -``` - -This computes the raw value $\langle\psi|H_{\mathrm{MPO}}|\psi\rangle$ for the -cached MPO. If the Hamiltonian came from a dense or sparse matrix, the result -therefore includes any approximation made when that source was factorized into -an MPO. The method does not normalize the state. It returns the raw complex -value and does not require the MPO to be Hermitian. A Hamiltonian energy should -be real up to numerical error. - -A piecewise Hamiltonian has no single MPO or energy. Select the applicable -static piece first: - -```python -selected_hamiltonian, _duration = piecewise_hamiltonian.pieces[piece_index] -selected_hamiltonian.ensure_mpo() -energy = state.mps.expect_mpo(selected_hamiltonian.mpo) +length = 4 +hamiltonian = Hamiltonian.ising(length, J=1.0, g=0.5) +state = State(length, initial="zeros") ``` -## Long-range correlations +The Heisenberg shortcut uses the sign convention -Build an individual Pauli product or string as a full-chain MPO. Sites omitted -from the string act as identities, so the listed sites do not need to be -adjacent: +$$ +H_{\mathrm{Heisenberg}}=-\sum_{i=0}^{L-2} +\left(J_xX_iX_{i+1}+J_yY_iY_{i+1}+J_zZ_iZ_{i+1}\right) +-h\sum_{i=0}^{L-1}Z_i. +$$ -```{code-cell} ipython3 -from mqt.yaqs import MPO, Observable, State +Here, $X$, $Y$, and $Z$ are Pauli matrices with eigenvalues $\pm1$, not spin +operators with eigenvalues $\pm1/2$. Setting `Jz=0` gives the XY model used in +{doc}`analog_simulation`: -state = State(8, initial="zeros") -correlation = MPO() -correlation.from_pauli_sum( - terms=[(1.0, "Z0 Z7")], - length=state.mps.length, - n_sweeps=0, -) -zz_raw = state.mps.expect_mpo(correlation) +```{code-cell} python +xy = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0) ``` -For a connected correlation, normalize each raw expectation value by the squared -state norm. For example, - -```{code-cell} ipython3 -norm_squared = state.mps.norm() ** 2 -z0_raw = state.mps.expect(Observable("z", 0)) -z7_raw = state.mps.expect(Observable("z", 7)) -zz_connected = zz_raw / norm_squared - z0_raw * z7_raw / norm_squared**2 -``` - -For a normalized state, this is -$\langle Z_0Z_7\rangle-\langle Z_0\rangle\langle Z_7\rangle$. The one-site terms -continue to use the existing local-observable API, including fast local -contraction when the tracked center covers the site. - -If one local matrix per site is already available, with identity matrices -between separated factors, `MPO.from_local_ops` builds the corresponding -bond-one tensor product. This workflow does not factor an arbitrary joint, -non-product matrix acting on separated sites or construct an optimized all-pairs -correlation matrix. Those features need separate decomposition, cutoff, -site-order, and environment-reuse contracts. - -## Time-dependent Hamiltonians - -Switch between static Hamiltonians at times that land on the analog `dt` grid. -Each duration must be a positive multiple of `dt`. This path supports a single -MPS `State` with TDVP. - -### Analog quench +These builders and `Hamiltonian.pauli` use open boundaries by default. Set +`bc="periodic"` to include the bond from the last site to site 0. -Use {meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.piecewise` -when the experiment is analog-only: evolve under one Hamiltonian, then another. -One {class}`~mqt.yaqs.AnalogSimParams`, one {class}`~mqt.yaqs.Result`, one time -grid. Pass ``elapsed_time=H.duration`` so the total time matches the pieces. +## Specify your own Pauli terms -```python -from mqt.yaqs import AnalogSimParams, Hamiltonian, Observable, Simulator, State +`Hamiltonian.pauli` repeats each `two_body` term over neighboring sites and each +`one_body` term over all sites. Coefficients enter with the sign you supply. For +example, the following builds the same XY Hamiltonian as above: -L = 4 -H = Hamiltonian.piecewise([ - (Hamiltonian.ising(L, J=1.0, g=0.5), 1.0), - (Hamiltonian.ising(L, J=1.0, g=2.0), 1.0), -]) -params = AnalogSimParams( - observables=[Observable("z", 0)], - elapsed_time=H.duration, - dt=0.1, +```{code-cell} python +xy_from_terms = Hamiltonian.pauli( + length=length, + two_body=[(-0.5, "X", "X"), (-0.5, "Y", "Y")], ) -result = Simulator().run(State(L, initial="zeros"), H, params) ``` -### Switching Hamiltonians in a program +Add an on-site field with an entry such as `one_body=[(-0.2, "Z")]`. These +structured builders require finite real coefficients and qubit sites. -Use a {class}`~mqt.yaqs.SimulationProgram` when analog evolution is part of a -protocol — digital gates, mixed `dt`, or per-segment noise. Consecutive analog -segments with the same `dt` match a piecewise Hamiltonian only when their analog -settings and noise behavior are compatible (evolution mode, order, sampling, -truncation, and a shared noise model). Mixed `dt` or a digital gate in between -splits the analog runs. A piecewise Hamiltonian is also valid on one analog -segment. +For site-dependent fields, separated sites, or longer strings, build an MPO from +explicit `(coefficient, string)` pairs: -```python -from mqt.yaqs import ( - AnalogSimParams, - Hamiltonian, - Observable, - SimulationProgram, - Simulator, - State, -) +```{code-cell} python +from mqt.yaqs import MPO -L = 4 -program = SimulationProgram( - [ - ( - Hamiltonian.ising(L, J=1.0, g=0.5), - AnalogSimParams(elapsed_time=1.0, dt=0.1), - ), - ( - Hamiltonian.ising(L, J=1.0, g=2.0), - AnalogSimParams(elapsed_time=1.0, dt=0.1), - ), - ], - observables=[Observable("z", 0)], +mpo = MPO() +mpo.from_pauli_sum( + terms=[(0.4, "Z0 Z3"), (0.2, "Y1"), (0.1, "")], + length=length, ) -result = Simulator().run(State(L, initial="zeros"), program) +custom = Hamiltonian.from_mpo(mpo) ``` -Insert a {class}`~qiskit.circuit.QuantumCircuit` between analog segments when -the protocol needs a digital operation. See {doc}`digital_analog_simulation`. - -## Built-in models (quick reference) - -| Model | Entry point | Local dimension per site | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | -| Transverse-field Ising | {meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.ising` | 2 (qubits) | -| Heisenberg | {meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.heisenberg` | 2 | -| Generic Pauli sums | {meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.pauli` or {meth}`~mqt.yaqs.core.data_structures.mpo.MPO.from_pauli_sum` | 2 | -| 1D Fermi–Hubbard | {meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.fermi_hubbard_1d` | 4 (fermionic) or 2 (Jordan–Wigner) | -| Bose–Hubbard | {meth}`~mqt.yaqs.core.data_structures.mpo.MPO.bose_hubbard` → `Hamiltonian.from_mpo` | `local_dim` (boson occupation cutoff) | -| Coupled transmon chain | {meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.coupled_transmon` | alternating qubit / resonator dims | -| Trapped ion (position grid) | {meth}`~mqt.yaqs.core.data_structures.mpo.MPO.trapped_ion` → `Hamiltonian.from_mpo` | grid points per ion (1–2 ions) | - -Open (`bc="open"`) and periodic (`bc="periodic"`) boundaries are supported on -the Pauli builders. - -## Pauli-string Hamiltonians - -### Ising model (shortcut) - -The transverse-field Ising Hamiltonian on an open chain is +This operator is $0.4Z_0Z_3+0.2Y_1+0.1I$. Site indices start at zero; omitted +sites act as identities, and an empty string denotes the identity operator. +Labels `I`, `X`, `Y`, and `Z` are case-insensitive. Use real coefficients for +Hermitian Pauli terms. -```{math} -H = -J \sum_i Z_i Z_{i+1} - g \sum_i X_i. -``` +## Use other local dimensions -```{code-cell} ipython3 -from mqt.yaqs import Hamiltonian +Hubbard and device models need an initial state with the same local dimensions +as the Hamiltonian. For a uniform layout, use `physical_dimensions=local_dim`; +for different dimensions, supply a list in site order. The +{doc}`state_initialization` guide explains these preparations. -L = 4 -J, g = 1.0, 0.5 -H_ising = Hamiltonian.ising(L, J, g) -``` +The coupled-transmon builder alternates transmons and resonators, starting with +a transmon. Its `length` counts both kinds of sites. The trapped ion builder +uses one site per ion, with a local dimension equal to the number of grid +points. See {doc}`transmon_emulation` and {doc}`trapped_ion` for worked device +examples, noise, and the relevant units. -### Structured one- and two-body Pauli terms +:::{dropdown} Fermi–Hubbard: physical sites and Jordan–Wigner orbitals -{meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.pauli` expands -nearest-neighbour `two_body` and on-site `one_body` lists into Pauli strings -automatically: +The default builder uses dimension-four sites with local basis $|0\rangle$, +$|\!\downarrow\rangle$, $|\!\uparrow\rangle$, $|\!\uparrow\downarrow\rangle$: -```{code-cell} ipython3 -H_ising = Hamiltonian.pauli( - length=L, - two_body=[(-J, "Z", "Z")], - one_body=[(-g, "X")], - bc="open", -) +```python +num_sites = 3 +fermi = Hamiltonian.fermi_hubbard_1d(num_sites, t=1.0, u=0.5) +fermi_state = State(num_sites, physical_dimensions=4) ``` -The Heisenberg model is available as a one-liner as well: +This mode uses ladder operators on composite sites. For a Pauli-chain model with +full Jordan–Wigner signs between spin orbitals, set `jordan_wigner=True`: -```{code-cell} ipython3 -H_heisenberg = Hamiltonian.heisenberg(L, Jx=1.0, Jy=1.0, Jz=1.0, h=0.2) +```python +jw = Hamiltonian.fermi_hubbard_1d(2 * num_sites, t=1.0, u=0.5, jordan_wigner=True) +jw_state = State(2 * num_sites) ``` -### Explicit Pauli strings (`from_pauli_sum`) +The two modes use different hopping-sign conventions. Match both the state and +operator basis when comparing them. -For **arbitrary** Pauli strings—including long-range couplings—pass -`(coefficient, spec)` pairs to -{meth}`~mqt.yaqs.core.data_structures.mpo.MPO.from_pauli_sum`. Each `spec` lists -operators with **site indices**, e.g. `"Z0 Z3"` or `"X2"`: +Here, `length` counts spin orbitals and must be even and at least two. Site +order is $1\uparrow,1\downarrow,2\uparrow,2\downarrow,\ldots$. Both builders use +open boundaries and omit a chemical-potential term. The +{func}`~mqt.yaqs.core.libraries.circuit_library.create_1d_fermi_hubbard_circuit` +provides a digital Trotter circuit with a chemical-potential option. -```{code-cell} ipython3 -from mqt.yaqs import MPO +::: -terms = [(-J, f"Z{i} Z{i+1}") for i in range(L - 1)] + [(-g, f"X{i}") for i in range(L)] -mpo = MPO() -mpo.from_pauli_sum(terms=terms, length=L) -H_custom = Hamiltonian.from_mpo(mpo) -``` +:::{dropdown} Bose–Hubbard and the occupation cutoff -Long-range terms are ordinary entries in `terms`: +`local_dim` retains occupations from zero through `local_dim - 1` at each site. +The builder includes an on-site frequency, an interaction $U n_i(n_i-1)/2$, and +nearest-neighbor hopping with coefficient $-J$: ```python -terms.append((0.1, "Z0 Z3")) # Z on sites 0 and 3 +local_dim = 3 +bose = Hamiltonian.from_mpo( + MPO.bose_hubbard( + length=3, + local_dim=local_dim, + omega=1.0, + hopping_j=0.2, + hubbard_u=0.5, + ) +) +bose_state = State(3, physical_dimensions=local_dim) ``` -Pauli labels are `I`, `X`, `Y`, `Z` (case-insensitive). Only -`physical_dimension=2` is supported for this builder. +Choose a large enough occupation cutoff for your preparation and dynamics. Check +convergence by increasing `local_dim` when higher occupations matter. -## Fermi–Hubbard (1D) +::: -{meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.fermi_hubbard_1d` -implements +## Supply a dense or sparse matrix -```{math} -H = -t \sum_{i,\sigma} \left(c^\dagger_{i,\sigma} c_{i+1,\sigma} + \mathrm{h.c.}\right) -+ U \sum_i n_{i,\uparrow} n_{i,\downarrow} -``` +For a small custom operator, pass exactly one of `matrix`, `sparse_matrix`, or +`tensors`. Dense and sparse matrices must be finite, square, and Hermitian. The +manual constructor uses a uniform `physical_dimension`, defaulting to two; it +infers `length` from the matrix size when omitted. -(open boundaries, no chemical potential). +(physical-site-ordering)= -### Fermionic sites (default) +### Physical-site ordering -One **physical site** has local dimension 4 with basis -$|0\rangle, |\!\downarrow\rangle, |\!\uparrow\rangle, |\!\uparrow\downarrow\rangle$. -Ladder operators act on the composite ↑/↓ space per site (not a Jordan–Wigner -qubit chain across sites). +Dense and sparse matrices use site 0 as the least-significant, fastest-varying +subsystem, matching Qiskit's qubit ordering. An operator acting on site 0 is +therefore the rightmost Kronecker factor: -```{code-cell} ipython3 -num_sites = 4 -t, u = 1.0, 0.5 +```{code-cell} python +import numpy as np +from scipy.sparse import csr_matrix -H_fermi = Hamiltonian.fermi_hubbard_1d(num_sites, t=t, u=u) -``` +identity = np.eye(2, dtype=complex) +pauli_x = np.array([[0, 1], [1, 0]], dtype=complex) +x_on_site_0 = np.kron(identity, pauli_x) -Pair with {class}`~mqt.yaqs.core.data_structures.state.State` using -`physical_dimensions=[4] * num_sites` when building product Fock states (see -{doc}`state_initialization`). +dense = Hamiltonian(matrix=x_on_site_0) +sparse = Hamiltonian(sparse_matrix=csr_matrix(x_on_site_0)) +``` -### Jordan–Wigner Pauli chain +For local dimensions $d_0,d_1,\ldots$, the flat index for basis digits +$(s_0,s_1,\ldots)$ is $s_0+d_0s_1+d_0d_1s_2+\cdots$. This order applies to full +spatial state vectors and operator matrices. Local observable and noise matrices +use the order of their explicit site list: for example, +`Observable(np.kron(X, Z), sites=[0, 1])` means $X_0Z_1$. Custom adjacent +two-site noise matrices require ascending site lists. Circuit matrices follow +Qiskit's gate convention. -Pass `jordan_wigner=True` for a qubit chain in the order 1↑, 1↓, 2↑, 2↓, … Here -`length` is the number of **spin orbitals** (must be even): +The initial state's representation selects the analog backend, independently of +the Hamiltonian's source data. See {doc}`representation_comparison` for the +supported choices. -```{code-cell} ipython3 -num_orbitals = 2 * num_sites -H_jw = Hamiltonian.fermi_hubbard_1d(num_orbitals, t=t, u=u, jordan_wigner=True) +```{warning} +Converting a sparse matrix to an MPO densifies the full operator. A sparse +source therefore does not avoid the full matrix allocation when you run with an +MPS state. For large MPS simulations, build an MPO directly with a preset, Pauli +terms, or custom tensors. ``` -Use this mode when you need Pauli-string semantics with full JW signs between -orbitals. - -```{note} -The analog MPO factories omit a chemical potential $\mu$. For a **digital** -Trotter circuit with $\mu$, see -{func}`~mqt.yaqs.core.libraries.circuit_library.create_1d_fermi_hubbard_circuit` -and {doc}`circuit_observables`. -``` +## Time-dependent Hamiltonians -Correctness of the fermionic and JW MPOs is covered by `test_fermi_hubbard_1d_*` -in the package test suite. +Use `Hamiltonian.piecewise` for an analog quench: evolve under one static +Hamiltonian and then another, on a shared time grid. Every duration must be a +positive integer multiple of `dt`, and their sum must equal `elapsed_time`. All +pieces must have matching site counts and local dimensions: -## Bose–Hubbard +```{code-cell} python +from mqt.yaqs import AnalogSimParams, Observable, Simulator -The Bose–Hubbard model +quench = Hamiltonian.piecewise([ + (hamiltonian, 0.2), + (Hamiltonian.ising(length, J=1.0, g=2.0), 0.2), +]) +params = AnalogSimParams( + observables=[Observable("z", 0)], elapsed_time=quench.duration, dt=0.05 +) -```{math} -H = \sum_i \left(\omega\, n_i + \frac{U}{2}\, n_i(n_i-1)\right) -- J \sum_i \left(a^\dagger_i a_{i+1} + \mathrm{h.c.}\right) +sim = Simulator(show_progress=False) +result = sim.run(state, quench, params) ``` -is available on {meth}`~mqt.yaqs.core.data_structures.mpo.MPO.bose_hubbard`. -Wrap the MPO for analog simulation: - -```{code-cell} ipython3 -from mqt.yaqs import Hamiltonian, MPO +The result contains nine sample times, including time zero, on one continuous +timeline. This path supports a single MPS state and the default TDVP evolution; +it also accepts a shared noise model. It does not support dense state +representations, BUG evolution, or list-of-state ensembles. -local_dim = 3 # occupations 0, 1, …, local_dim - 1 -H_bh = Hamiltonian.from_mpo( - MPO.bose_hubbard( - length=3, - local_dim=local_dim, - omega=1.0, - hopping_j=0.2, - hubbard_u=0.5, - ) -) -``` +For digital gates, different time steps, or segment-specific noise, use a +`SimulationProgram`; see {doc}`digital_analog_simulation`. A piecewise +Hamiltonian has no single static MPO or matrix. Select a static entry from +`quench.pieces` when you need its operator. -Initial states must respect the boson dimension, e.g. -`State(length, initial="zeros", physical_dimensions=[local_dim] * length)`. +## Advanced operator use -## Coupled transmon–resonator chains +:::{dropdown} Custom MPO tensors and construction accuracy -{meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.coupled_transmon` -builds an alternating chain of transmon qubits and resonators with local -dimensions `qubit_dim` and `resonator_dim`: +Manual cores use `(left, right, physical_out, physical_in)` axes in ascending +site order. Neighboring bonds must match, exterior bonds must have dimension +one, and entries must be finite. The manual `Hamiltonian` constructor requires +uniform local dimensions. For example, these bond-one cores build the same $X_0$ +operator as the matrix example: -```{code-cell} ipython3 -H_transmon = Hamiltonian.coupled_transmon( - length=3, - qubit_dim=3, - resonator_dim=5, - qubit_freq=5.0, - resonator_freq=7.0, - anharmonicity=-0.3, - coupling=0.1, +```python +tensor_hamiltonian = Hamiltonian( + tensors=[ + pauli_x.reshape(1, 1, 2, 2), + identity.reshape(1, 1, 2, 2), + ] ) ``` -A full SWAP-style open-system example is in {doc}`transmon_emulation`. +When you already have an MPO, use `Hamiltonian.from_mpo`. It references the same +MPO, checks its structure, and takes its local dimensions from the cores. +Wrapped MPOs and tensor inputs must represent a globally Hermitian operator; +YAQS does not perform a full-matrix Hermiticity check for these inputs. -## Trapped-ion position grid +Pauli builders expose `tol`, `max_bond_dim`, and `n_sweeps` for operator +compression. Dense-to-MPO conversion also uses an SVD cutoff, so the cached MPO +can approximate the source matrix. Set conversion options explicitly with +`MPO.from_matrix(matrix, d=2, cutoff=..., max_bond=...)` and wrap the result +when you need control over this approximation. These choices concern the +operator itself; simulation-parameter presets control the evolving state. -{meth}`~mqt.yaqs.core.data_structures.mpo.MPO.trapped_ion` builds a **static** -Hamiltonian for one or two ions on a uniform position grid. Each ion is one MPO -site with local dimension equal to the number of grid points. The local terms -are a harmonic trap plus a centered finite-difference kinetic energy; for two -ions, a softened Coulomb repulsion is compressed into MPO channels (optional SVD -truncation via `coulomb_cutoff` or `max_bond_dim`). +Static Hamiltonians cache converted forms. Call `ensure_mpo()` before reading +`.mpo`, or `ensure_sparse()` before reading `.sparse_matrix` if the form is not +already available. `to_matrix()` and `to_sparse_matrix()` return full operators +for small-system inspection. Create a new Hamiltonian when changing the +operator; cached forms do not track edits to the source data. -```{math} -H = \sum_i \left[-\frac{\hbar^2}{2m_i}\frac{d^2}{dx_i^2} + \tfrac{1}{2} m_i \omega^2 (x_i - q)^2\right] -+ \frac{g}{\sqrt{(x_1-x_2)^2 + a^2}} -``` - -(the Coulomb term applies only when two masses are supplied). +::: -```{code-cell} ipython3 -from mqt.yaqs import Hamiltonian, MPO -import numpy as np - -positions = np.linspace(-6.0, 6.0, 25) -H_ion = Hamiltonian.from_mpo( - MPO.trapped_ion( - positions, - masses=[1.0], - omega=1.0, - trap_center=0.0, - ) -) - -# Two ions with softened Coulomb repulsion on the same grid spacing -H_pair = Hamiltonian.from_mpo( - MPO.trapped_ion( - positions, - masses=[1.0, 1.0], - omega=1.0, - coulomb_strength=1.0, - softening_length=float(positions[1] - positions[0]), - ) -) -``` +:::{dropdown} Energy and long-range correlations of an MPS -Pair with {class}`~mqt.yaqs.core.data_structures.state.State` using -`physical_dimensions=[len(positions)]` per ion site. A wavepacket reflection -benchmark is in {doc}`trapped_ion`. +Contract an MPS with a static Hamiltonian's MPO to obtain its energy. For an +arbitrary nonzero state, divide the raw contraction by the squared norm: -```{note} -YAQS applies $\exp(-\mathrm{i}\,\Delta t\, H)$ during evolution. When using SI -units, pass energies and times in consistent units or rescale $H/\hbar$ -explicitly (see the factory docstring). +```python +hamiltonian.ensure_mpo() +norm_squared = state.mps.norm() ** 2 +energy = state.mps.expect_mpo(hamiltonian.mpo) / norm_squared ``` -## Manual Hamiltonians +`expect_mpo` returns the raw complex contraction without normalizing the state. +It uses the cached MPO, including any construction approximation. A Hermitian +energy is real up to numerical error. -For imported MPO cores or small-system dense/sparse operators: +The same method evaluates a full-chain Pauli product on separated sites: ```python -# MPO cores in ascending site order with (left, right, output, input) axes -# — preferred for TJM -H = Hamiltonian(tensors=my_cores) - -# Dense or sparse matrix — YAQS converts to MPO or sparse as needed at run time. -# sparse_matrix → TJM densifies; prefer tensors=/from_mpo/presets for large systems. -H = Hamiltonian(matrix=dense_h, physical_dimension=2) -H = Hamiltonian(sparse_matrix=sparse_h, physical_dimension=2) +correlation = MPO() +correlation.from_pauli_sum(terms=[(1.0, "Z0 Z3")], length=length, n_sweeps=0) +zz = state.mps.expect_mpo(correlation) / norm_squared +z0 = state.mps.expect(Observable("z", 0)) / norm_squared +z3 = state.mps.expect(Observable("z", 3)) / norm_squared +connected = zz - z0 * z3 ``` -(physical-site-ordering)= - -### Physical-site ordering +Here, `zz` is $\langle Z_0Z_3\rangle$ and `connected` subtracts +$\langle Z_0\rangle\langle Z_3\rangle$. Omitted sites act as identities. Use +`MPO.from_local_ops` instead when you already have one local matrix per site, +including identities between separated factors. -YAQS uses one mixed-radix order for full spatial states and operators. Site 0 is -the least-significant, fastest-varying subsystem. For local dimensions -$d_0,d_1,\ldots$, the basis digits $(s_0,s_1,\ldots)$ have flat index -$s_0+d_0s_1+d_0d_1s_2+\cdots$. For qubits, this is Qiskit's little-endian order. -The convention applies to dense `State` arrays, `MPS.to_vec()`, dense and sparse -`Hamiltonian` inputs, MPO matrix conversions, and `EquivalenceChecker` matrices. -A full operator on site 0 is therefore the rightmost Kronecker factor. For -example, `np.kron(I, X)` applies `X` to site 0 of a two-site system: - -```{code-cell} ipython3 -identity = np.eye(2, dtype=np.complex128) -pauli_x = np.array([[0.0, 1.0], [1.0, 0.0]], dtype=np.complex128) -x_on_site_0 = np.kron(identity, pauli_x) - -manual = Hamiltonian(matrix=x_on_site_0) -np.testing.assert_allclose(manual.to_matrix(), x_on_site_0) -np.testing.assert_allclose(manual.to_sparse_matrix().toarray(), x_on_site_0) -manual.ensure_mpo() -np.testing.assert_allclose(manual.mpo.to_matrix(), x_on_site_0) -np.testing.assert_allclose(manual.mpo.to_sparse_matrix().toarray(), x_on_site_0) -``` +::: -Local observable and analog-noise matrices use their explicit site list instead. -The first matrix tensor factor acts on the first listed site. Thus -`Observable(np.kron(X, Z), sites=[0, 1])` means $X_0Z_1$; with `sites=[1, 0]`, -it means $X_1Z_0$. YAQS permutes local matrix legs when it embeds such an -operator into the full site-0-LSB space. Two-qubit observables support -nearest-neighbor pairs and the periodic pair `{0, L - 1}` in either site order -on all analog representations. Named two-site noise processes retain the letter -order of the supplied sites when YAQS normalizes a reversed site list. Custom -adjacent two-site noise matrices require ascending sites. Circuit gate matrices -instead retain Qiskit's qarg and matrix convention. This distinction lets each -local API keep its declared tensor-factor meaning while every full matrix uses -one global basis order. - -## Related topics - -- {doc}`analog_simulation` — TJM evolution, noise, and observables -- {doc}`digital_analog_simulation` — analog evolution mixed with digital gates -- {doc}`transmon_emulation` — multi-level transmon physics -- {doc}`trapped_ion` — position-grid wavepacket dynamics -- {doc}`state_initialization` — `physical_dimensions` and representations -- {doc}`simulation_parameters` — truncation presets for MPO evolution +With the operator and initial state prepared, continue with +{doc}`analog_simulation` for noisy dynamics or {doc}`simulation_parameters` for +accuracy and sampling choices. The {class}`~mqt.yaqs.Hamiltonian` API reference +lists the full constructor signatures. diff --git a/docs/examples/memory_surrogate.md b/docs/examples/memory_surrogate.md index daa5ff9e2..943b26195 100644 --- a/docs/examples/memory_surrogate.md +++ b/docs/examples/memory_surrogate.md @@ -2,308 +2,187 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 900 + execution_timeout: 120 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` +# Predicting Non-Markovian Dynamics -# Memory Surrogate Training and Prediction +A control pulse changes a quantum system and its later interaction with the +environment. A surrogate learns that response from simulated sequences, then +predicts reduced system states for new controls. Here we train one small model +and compare a pulse-angle sweep with exact two-qubit evolution. -For control sequences beyond what you can simulate exhaustively, train a -**causal Transformer surrogate** on Hamiltonian rollouts of the open system. -{meth}`~mqt.yaqs.memory_characterizer.MemoryCharacterizer.predict` returns the -**reduced density matrix of the probe qubit** after a control sequence. +```{note} +**Experimental feature.** Surrogate modeling is not yet supported by a published +YAQS paper. This small example teaches the workflow; it does not demonstrate a +speed advantage or establish accuracy for longer control sequences. Validate +predictions against simulations or measurements for your intended use. +``` -Surrogate training requires PyTorch (`uv pip install mqt.yaqs[torch]`). Over -**short temporal horizons** (few intervention steps), compare surrogate rollouts -to Hamiltonian training targets and to exact **dense** or -**MPO process tensors** built with -{meth}`~mqt.yaqs.memory_characterizer.MemoryCharacterizer.build_process_tensor`. -Environmental memory probing (`characterize`) is covered in -{doc}`characterization`. +Install the PyTorch extra with `uv pip install "mqt.yaqs[torch]"`. The plot also +uses Matplotlib. Run the cells in order in a notebook; for a script, use the +entry-point guard in {doc}`simulator_initialization`. -```{warning} -Exact references scale exponentially with sequence length. Use them only over -**few intervention steps** — short probes in time, not long open-system runs. -``` +## 1. Choose the system and train on random controls -## Setup +Site 0 is the probe, and site 1 is an environment qubit initially in +$|0\rangle$. Their Hamiltonian is $H=-Z_0Z_1-0.5(X_0+X_1)$, with $\hbar=1$. We +apply two random single-qubit rotations to the probe, each followed by evolution +for $0.6$. The environment can retain information about the earlier control. -```{code-cell} ipython3 -import matplotlib.pyplot as plt +```{code-cell} python import numpy as np +import torch from mqt.yaqs import AnalogSimParams, Hamiltonian, MemoryCharacterizer -from mqt.yaqs.characterization.memory.shared.encoding import encode_rho_pauli, unpack_rho8 -from mqt.yaqs.characterization.memory.shared.metrics import mean_trace_distance_rho8 - -PAULI_Z = np.array([[1, 0], [0, -1]], dtype=np.complex128) - - -def z_expectation(rho8_row: np.ndarray) -> float: - """Return ⟨Z⟩ from a packed 8-float density-matrix row.""" - rho = unpack_rho8(rho8_row) - return float(np.trace(PAULI_Z @ rho).real) - -length = 2 -ham = Hamiltonian.ising(length=length, J=1.0, g=1.0) -params = AnalogSimParams(dt=0.1) -mc = MemoryCharacterizer(show_progress=False) -num_interventions = 2 -timesteps = [0.0, 0.0, 0.0] -intervention_style = "measure_prepare" -``` -Use a **probe + environment** chain (`length >= 2`). Match `intervention_style` -and `timesteps` between training, `predict`, and `build_process_tensor` (length -`num_interventions + 1`). +interval = 0.6 +schedule = [0.0, interval, interval] +hamiltonian = Hamiltonian.ising(2, J=1.0, g=0.5) +params = AnalogSimParams(elapsed_time=interval, dt=interval, preset="fast") +characterizer = MemoryCharacterizer(show_progress=False) -## Train a surrogate - -Training fixes `num_interventions` on the model — the horizon the network was -fit to. The settings below mirror the accuracy regression in the test suite -(`measure_prepare` legs, short schedule). - -```{code-cell} ipython3 -model = mc.train( - ham, - params, - num_interventions=num_interventions, - n=60, - seed=0, - timesteps=timesteps, - intervention_style=intervention_style, - train_kwargs={ - "epochs": 120, - "batch_size": 16, - "lr": 2e-3, - "device": "cpu", - "prefix_loss": "full", - }, - model_kwargs={ - "d_model": 32, - "nhead": 4, - "num_layers": 1, - "dim_ff": 64, - "dropout": 0.0, - }, +torch.manual_seed(7) +validation = characterizer.sample( + hamiltonian, params, num_interventions=2, n=128, seed=99, + timesteps=schedule, intervention_style="haar", ) -``` - -## Evaluate on held-out Hamiltonian rollouts - -Generate fresh training sequences with a different seed and compare the -surrogate’s final-step predictions to the Hamiltonian targets used during -dataset construction: - -```{code-cell} ipython3 -held_out = mc.sample( - ham, - params, - num_interventions=num_interventions, - n=60, - seed=999, - intervention_style=intervention_style, - show_progress=False, - timesteps=timesteps, +model = characterizer.train( + hamiltonian, params, num_interventions=2, n=2048, seed=7, + timesteps=schedule, intervention_style="haar", + model_kwargs={"d_model": 64, "num_layers": 2, "dim_ff": 128}, + train_kwargs={"epochs": 200, "lr": 1e-3, "device": "cpu", "val_dataset": validation}, ) -e_test, rho0_test, rho_true = held_out.tensors -rho_pred = model.predict(e_test.numpy(), rho0_test.numpy(), return_numpy=True) - -z_true = np.array([z_expectation(row) for row in rho_true.numpy()[:, -1, :]]) -z_pred = np.array([z_expectation(row) for row in rho_pred[:, -1, :]]) -mean_td = mean_trace_distance_rho8(rho_pred[:, -1, :], rho_true.numpy()[:, -1, :]) - -fig, ax = plt.subplots(figsize=(4.5, 4)) -ax.scatter(z_true, z_pred, s=18, alpha=0.75) -lims = (min(z_true.min(), z_pred.min()) - 0.05, max(z_true.max(), z_pred.max()) + 0.05) -ax.plot(lims, lims, "k--", linewidth=1) -ax.set_xlim(lims) -ax.set_ylim(lims) -ax.set_xlabel(r"Hamiltonian $\langle Z \rangle$ (final step)") -ax.set_ylabel(r"Surrogate $\langle Z \rangle$ (final step)") -ax.set_title(rf"Held-out rollouts (mean trace distance = {mean_td:.3f})") -ax.set_aspect("equal") -fig.tight_layout() ``` -## Compare explicit control sequences - -The usual workflow after training is to call -{meth}`~mqt.yaqs.memory_characterizer.MemoryCharacterizer.predict` with -**your own control sequence** and compare outcomes across choices. Train a -one-leg surrogate on random unitary controls, then pass different explicit -unitary lists to the same model. This mirrors the quickstart workflow -({doc}`quickstart`) with the longer training budget used above: - -```{code-cell} ipython3 -unitary_timesteps = [0.0, 0.0] -controls_model = mc.train( - ham, - params, - num_interventions=1, - n=120, - seed=2, - timesteps=unitary_timesteps, - intervention_style="haar", - train_kwargs={ - "epochs": 120, - "batch_size": 16, - "lr": 2e-3, - "device": "cpu", - "prefix_loss": "full", - }, - model_kwargs={ - "d_model": 32, - "nhead": 4, - "num_layers": 1, - "dim_ff": 64, - "dropout": 0.0, - }, -) - -rho0_controls = np.eye(2, dtype=np.complex128) / 2.0 -hadamard = np.array([[1, 1], [1, -1]], dtype=np.complex128) / np.sqrt(2) -pauli_x = np.array([[0, 1], [1, 0]], dtype=np.complex128) -control_sequences = { - r"$\mathrm{H}$": [{"unitary": hadamard}], - r"$\mathrm{X}$": [{"unitary": pauli_x}], -} - -pauli_ops = { - "X": np.array([[0, 1], [1, 0]], dtype=np.complex128), - "Y": np.array([[0, -1j], [1j, 0]], dtype=np.complex128), - "Z": np.array([[1, 0], [0, -1]], dtype=np.complex128), -} -expectations = { - label: [ - float(np.trace(op @ mc.predict(controls_model, rho0_controls, controls, num_interventions=1)).real) - for op in pauli_ops.values() +`timesteps` contains the initial delay and one duration after each intervention. +The initial `0.0` places the first rotation immediately after preparation, and +the final time is $1.2$. Training varies the probe preparation and draws random +unitaries with `intervention_style="haar"`; the environment preparation stays +fixed. YAQS restores the model with the lowest validation loss. The validation +set selects the model, so it is not an independent accuracy test. + +## 2. Predict a pulse-angle sweep + +Prepare the probe in $|+\rangle$. Use the identity as the first control, then +apply $R_z(\theta)$ between the two evolution intervals. These chosen controls +were not supplied during training. At $\theta=0$ the system evolves freely; +$\theta=\pi$ gives a phase flip halfway through. + +```{code-cell} python +plus = np.array([1, 1], dtype=complex) / np.sqrt(2) +rho0 = np.outer(plus, plus.conj()) +identity = np.eye(2, dtype=complex) +pulse_angles = np.linspace(0, 2 * np.pi, 41) +sequences = [ + [ + {"unitary": identity}, + {"unitary": np.diag(np.exp(-0.5j * angle * np.array([1, -1])))}, ] - for label, controls in control_sequences.items() -} - -pauli_names = list(pauli_ops) -x = np.arange(len(pauli_names)) -width = 0.35 - -fig, ax = plt.subplots(figsize=(5.5, 3.5)) -for offset, (label, values) in zip((-width / 2, width / 2), expectations.items()): - ax.bar(x + offset, values, width, label=f"control {label}") -ax.set_xticks(x, pauli_names) -ax.set_ylabel(r"$\langle P \rangle$") -ax.set_title("Probe Pauli expectations for two control sequences") -ax.legend(frameon=False) -fig.tight_layout() + for angle in pulse_angles +] +predicted = np.stack([ + characterizer.predict(model, rho0, sequence) for sequence in sequences +]) ``` -Extend the per-leg list when `num_interventions > 1` to probe multi-step -sequences (for example `[H, X]`). +`predict` returns a complex `(2, 2)` density-matrix estimate. Stacking the sweep +produces shape `(41, 2, 2)`. All entries describe the same final time, not a +trajectory through time. (short-horizon-validation)= -## Validate against exact references - -Build process tensors for the same schedule. By default, `build_process_tensor` -returns an uncapped MPO from direct construction (noiseless). Its branch count -grows as `16**num_interventions`, so use it only for short horizons. A finite -`max_bond_dim` selects an experimental uncontrolled approximation and emits a -`RuntimeWarning`. Pass `return_type="dense"` for exhaustive tomography. For -process tensors, `rho0` in `predict` must match `pt.initial_rho` (the site-0 -state after the initial leg of the reference schedule). +## 3. Check against exact evolution -**Dense** and **MPO** implementations should agree on identical interventions. -Compare all three backends on a -**stochastic sequence drawn from the training style** (`measure_prepare` here): -pass a **fresh** `np.random.default_rng(seed)` to each `predict` call (reusing -one RNG object advances its state between calls). +For two qubits, SciPy's matrix exponential gives a cheap reference independent +of YAQS's solvers. Site 0 is the least significant bit, so the joint initial +state is $|0\rangle_{\mathrm{env}}\otimes|+\rangle_{\mathrm{probe}}$ and a probe +pulse acts as $I\otimes R_z(\theta)$. Tracing out the environment gives the +reference probe state. -```{code-cell} ipython3 -pt_mpo = mc.build_process_tensor(ham, params, timesteps=timesteps) -pt_dense = mc.build_process_tensor( - ham, params, timesteps=timesteps, return_type="dense", num_trajectories=48, -) +```{code-cell} python +from scipy.linalg import expm -rho0 = pt_mpo.initial_rho -compare_seed = 7 - -rho_dense = mc.predict( - pt_dense, rho0, intervention_style, num_interventions=num_interventions, - rng=np.random.default_rng(compare_seed), -) -rho_mpo = mc.predict( - pt_mpo, rho0, intervention_style, num_interventions=num_interventions, - rng=np.random.default_rng(compare_seed), +pauli_x = np.array([[0, 1], [1, 0]], dtype=complex) +pauli_z = np.diag([1.0, -1.0]) +dense_hamiltonian = -np.kron(pauli_z, pauli_z) - 0.5 * ( + np.kron(identity, pauli_x) + np.kron(pauli_x, identity) ) -rho_surrogate = mc.predict( - model, rho0, intervention_style, num_interventions=num_interventions, - rng=np.random.default_rng(compare_seed), -) - -pauli_labels = [r"$X$", r"$Y$", r"$Z$"] -pauli_dense = encode_rho_pauli(rho_dense)[1:] -pauli_mpo = encode_rho_pauli(rho_mpo)[1:] -pauli_surrogate = encode_rho_pauli(rho_surrogate)[1:] -x = np.arange(len(pauli_labels)) -width = 0.25 - -fig, ax = plt.subplots(figsize=(5.5, 3.5)) -ax.bar(x - width, pauli_dense, width, label="dense", color="tab:blue") -ax.bar(x, pauli_mpo, width, label="MPO", color="tab:orange", alpha=0.85) -ax.bar(x + width, pauli_surrogate, width, label="surrogate", color="tab:green", alpha=0.85) -ax.set_xticks(x, pauli_labels) -ax.set_ylabel(r"$\langle P \rangle$") -ax.set_title( - rf"Matched {intervention_style} sequence ($\|\rho_{{\mathrm{{dense}}}}-\rho_{{\mathrm{{MPO}}}}\|_F$ = " - rf"{np.linalg.norm(rho_dense - rho_mpo):.1e})" -) -ax.legend(frameon=False) -fig.tight_layout() +evolution = expm(-1j * interval * dense_hamiltonian) +initial_joint = np.kron([1, 0], plus) +states = [] +for sequence in sequences: + joint = evolution @ np.kron(identity, sequence[1]["unitary"]) @ evolution @ initial_joint + amplitudes = joint.reshape(2, 2) + states.append(amplitudes.T @ amplitudes.conj()) +reference = np.stack(states) + +matrix_errors = 0.5 * np.sum(np.abs(np.linalg.eigvalsh(predicted - reference)), axis=1) +trace_error = np.max(np.abs(np.trace(predicted, axis1=1, axis2=2) - 1)) +minimum_eigenvalue = np.min(np.linalg.eigvalsh(predicted)) +print(f"Maximum matrix error: {matrix_errors.max():.4f}") +print(f"Maximum trace error: {trace_error:.4f}; minimum eigenvalue: {minimum_eigenvalue:.4f}") ``` -Dense and MPO should overlap; the surrogate approximates the same draw at the -reference `rho0`. Held-out Hamiltonian rollouts above use random probe `rho0` -values from data generation — a different setup than the fixed reference state -stored on process tensors. - -The same process-Choi information functionals are available on either backend. -QMI measures total correlation between the final output and the selected -intervention slots, including direct system transmission. It is not by itself a -measure of non-Markovian memory. CMI tests conditional independence for the -stated partition. For this short horizon, CMI is near zero while QMI grows when -more past legs are included: +The matrix error is half the trace norm of the difference. It equals trace +distance when both matrices are normalized physical states. The public API makes +predictions Hermitian but does not enforce unit trace or positivity. The printed +checks expose those limitations; we do not normalize the predictions or clip +negative eigenvalues. -```{code-cell} ipython3 -past_choices = ("all", "first", "last") -qmi_dense = [mc.compute_qmi(pt_dense, past=p) for p in past_choices] -qmi_mpo = [mc.compute_qmi(pt_mpo, past=p) for p in past_choices] -cmi_dense = mc.compute_cmi(pt_dense) -cmi_mpo = mc.compute_cmi(pt_mpo) - -fig, ax = plt.subplots(figsize=(5, 3.5)) -ax.plot(past_choices, qmi_dense, "o-", label="QMI (dense)") -ax.plot(past_choices, qmi_mpo, "s--", label="QMI (MPO)", alpha=0.85) -ax.axhline(cmi_dense, color="tab:purple", linestyle=":", linewidth=1.5, label=rf"CMI (dense) = {cmi_dense:.2e}") -ax.axhline(cmi_mpo, color="tab:gray", linestyle="--", linewidth=1, label=rf"CMI (MPO) = {cmi_mpo:.2e}") -ax.set_ylabel("bits") -ax.set_xlabel(r"past legs in QMI") -ax.set_title("Process-tensor information metrics") -ax.legend(frameon=False, fontsize=8, loc="upper left") -fig.tight_layout() +```{code-cell} python +:tags: [hide-input] +import matplotlib.pyplot as plt +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", "font.serif": ["STIXGeneral"], "mathtext.fontset": "stix", + "font.size": 10, "axes.labelsize": 11, "axes.linewidth": 0.7, + "xtick.direction": "in", "ytick.direction": "in", + "xtick.top": True, "ytick.right": True, + "legend.frameon": False, "figure.constrained_layout.use": True, + "savefig.bbox": "tight", "svg.fonttype": "none", +}) +fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8)) +axes[0].plot(pulse_angles, 2 * np.abs(predicted[:, 0, 1]), color="#0072B2", + linewidth=2, label="Surrogate") +axes[0].plot(pulse_angles[::2], 2 * np.abs(reference[::2, 0, 1]), "o", color="#D55E00", + markerfacecolor="white", markersize=4, label="Exact evolution") +axes[0].set_ylabel(r"Final coherence $2|\rho_{01}|$") +axes[0].set_title("(a) Response to a control pulse", loc="left", fontsize=10) +axes[0].legend(fontsize=9) +axes[1].plot(pulse_angles, matrix_errors, color="#0072B2", linewidth=2) +axes[1].fill_between(pulse_angles, 0, matrix_errors, color="#0072B2", alpha=0.12) +axes[1].set_ylabel(r"Matrix error $\frac{1}{2}\|\rho_{\rm pred}-\rho_{\rm ref}\|_1$") +axes[1].set_title("(b) Error against exact evolution", loc="left", fontsize=10) +axes[1].set_ylim(bottom=0) +for ax in axes: + ax.set(xlabel=r"Pulse angle $\theta$", xlim=(0, 2 * np.pi), + xticks=[0, np.pi, 2 * np.pi], xticklabels=["0", r"$\pi$", r"$2\pi$"]) +plt.show() ``` -These information metrics and the split-cut response metric $S_V(c)$ from -{doc}`characterization` are complementary. The response construction tests which -differences between past probes remain visible in future responses. +An instantaneous $Z$ rotation preserves coherence magnitude when applied. The +variation here arises during subsequent interaction with the environment. The +reference and error panel show how well this small model captures that response. +This training budget keeps the example inexpensive and leaves visible prediction +errors. Results can vary with seeds and PyTorch versions. -## Related topics +## Scope and other options -- {doc}`characterization` — split-cut probing, response matrix, reset-delay - sweeps -- {doc}`quickstart` — minimal train/predict snippet -- API reference: :class:`~mqt.yaqs.memory_characterizer.MemoryCharacterizer` +The model uses a fixed Hamiltonian, environment preparation, and control +schedule. Retrain and validate when those change. The public training path does +not accept a `NoiseModel`, and this two-intervention example does not establish +accuracy for longer protocols. + +Use `predict(model, rho0, sequence, return_sequence=True)` to obtain shape +`(num_interventions, 2, 2)`, with one state after each intervention and its +following evolution interval. Sampling and training also support `"clifford"` +and `"measure_prepare"` controls; validate other control families separately. +For environmental memory diagnostics and short process-tensor references, see +{doc}`characterization`. diff --git a/docs/examples/quickstart.md b/docs/examples/quickstart.md index 7ae4690e3..6a0217c1f 100644 --- a/docs/examples/quickstart.md +++ b/docs/examples/quickstart.md @@ -2,345 +2,455 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 900 + execution_timeout: 180 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Quickstart -This page runs minimal workflows end-to-end: analog and digital simulation, -equivalence checking, environmental memory (characterize from the response -matrix, then train a surrogate to predict probe density matrices under a control -sequence), and Markovian noise digital-twin fitting. Install the package first -({doc}`installation`), then copy the cells below. - -Every example in this guide uses `show_progress=False` on `Simulator`, -`MemoryCharacterizer`, and `NoiseCharacterizer` so tqdm progress bars do not -clutter the documentation; figures below each cell show the main results. - -## 1. Analog simulation +Explore the main YAQS workflows with executable examples and their results. +After {doc}`installing YAQS <../installation>`, run the cells in a notebook. For +a standalone script, put the execution code inside an +`if __name__ == "__main__":` guard; see {doc}`simulator_initialization`. -Néel-initialized transverse-field Ising chain with on-site damping. Staggered -$\langle Z_i \rangle$ spreads and decays in a site-dependent way under -open-system evolution: +The examples use `show_progress=False` to keep the documentation quiet. Omit +this argument to see progress. Times and rates use units consistent with each +Hamiltonian, with $\hbar=1$. Expand the plotting cells to reuse the figures. -```{code-cell} ipython3 +```{code-cell} python +:tags: [hide-input] import matplotlib.pyplot as plt import numpy as np +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["STIXGeneral"], + "mathtext.fontset": "stix", + "font.size": 11, + "axes.labelsize": 11, + "axes.linewidth": 0.7, + "xtick.labelsize": 10, + "ytick.labelsize": 10, + "xtick.direction": "in", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "legend.fontsize": 9, + "legend.frameon": False, + "lines.linewidth": 1.6, + "lines.markersize": 4, + "figure.figsize": (6.6, 3.0), + "figure.constrained_layout.use": True, + "savefig.dpi": 180, +}) +``` -from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseModel, Observable, Simulator, State +## Noisy analog dynamics -L = 5 -state = State(L, initial="Neel") -hamiltonian = Hamiltonian.ising(L, J=1.0, g=0.8) -noise_model = NoiseModel([ - {"name": "lowering", "sites": [i], "strength": 0.06} for i in range(L) -]) +Compare coherent transport and damping in a **20-site XY spin chain**. YAQS uses +an MPS and averages noisy dynamics over Monte Carlo trajectories. +```{code-cell} python +from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseModel, Observable, Simulator, State + +length = 20 +center = length // 2 +basis = "0" * center + "1" + "0" * (length - center - 1) +state = State(length, initial="basis", basis_string=basis) +hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0) params = AnalogSimParams( - observables=[Observable("z", site) for site in range(L)], - elapsed_time=4.0, - dt=0.1, - num_traj=16, - max_bond_dim=16, - order=2, - sample_timesteps=True, + observables=[Observable("z", site) for site in range(length)], + elapsed_time=3.0, + dt=0.25, + num_traj=32, + preset="fast", + random_seed=7, ) +relaxation_rate = 1.0 +noise = NoiseModel([ + {"name": "lowering", "sites": [site], "strength": relaxation_rate} for site in range(length) +]) -sim = Simulator(show_progress=False) -result = sim.run(state, hamiltonian, params, noise_model) - -heatmap = np.vstack([np.real(v) for v in result.expectation_values]) -fig, ax = plt.subplots(figsize=(6, 3.5), layout="constrained") -im = ax.imshow(heatmap, aspect="auto", extent=(0, 4.0, L, 0), vmin=-1, vmax=1, cmap="RdBu_r") -ax.set_xlabel("time") -ax.set_yticks([x - 0.5 for x in range(1, L + 1)], [str(x) for x in range(L)]) -ax.set_ylabel("site") -fig.colorbar(im, ax=ax, shrink=0.9, label=r"$\langle Z \rangle$") -ax.set_title("Staggered magnetization under damping") +simulator = Simulator(show_progress=False) +coherent = simulator.run(state, hamiltonian, params) +dissipative = simulator.run(state, hamiltonian, params, noise) ``` -## 2. Circuit observables +```{code-cell} python +:tags: [hide-input] +from matplotlib.colors import PowerNorm -Evolve a short Trotterized Ising circuit and compare final $\langle Z_i\rangle$ -without noise and with an optional {class}`~mqt.yaqs.NoiseModel`. See -{doc}`circuit_observables` for noise sweeps, mid-circuit sampling, and gate -modes. +occupation = np.stack([ + (1 - np.asarray(result.expectation_values).real) / 2 + for result in (coherent, dissipative) +]) +fig, axes = plt.subplots(1, 3, figsize=(7.2, 2.8)) +for ax, values, title in zip( + axes[:2], occupation, ("(a) Noiseless", "(b) Damped"), strict=True, +): + image = ax.pcolormesh( + coherent.times, np.arange(length), values, shading="auto", cmap="cividis", + norm=PowerNorm(0.5, vmin=0, vmax=1), rasterized=True, + ) + ax.set(xlabel=r"Time $t$", ylabel=r"Site $i$") + ax.set_title(title, loc="left", fontsize=11) +fig.colorbar(image, ax=list(axes[:2]), label=r"$\langle n_i\rangle$", ticks=[0, 0.1, 0.5, 1]) +axes[2].plot(coherent.times, occupation[0].sum(axis=0), color="#0072B2", label="Noiseless") +axes[2].plot(coherent.times, occupation[1].sum(axis=0), "o-", color="#D55E00", label="Damped") +axes[2].plot(coherent.times, np.exp(-relaxation_rate * coherent.times), "--", color="0.3", label="Damping law") +axes[2].set(xlabel=r"Time $t$", ylabel=r"Total excitation $\sum_i\langle n_i\rangle$", ylim=(0, 1.08)) +axes[2].set_title("(c) Excitation loss", loc="left", fontsize=11) +axes[2].legend() +plt.show() +``` -```{code-cell} ipython3 -from mqt.yaqs import NoiseModel, Observable, DigitalSimParams -from mqt.yaqs.core.libraries.circuit_library import create_ising_circuit +The occupation $n_i=(1-Z_i)/2$ shows propagation and interference. Both heatmaps +use the same color scale, which emphasizes small occupations. Damping removes +excitations; the 32-trajectory estimate fluctuates around the exponential loss +law. This low-excitation state has limited entanglement. See +{doc}`analog_simulation` and {doc}`simulation_parameters` for larger budgets and +convergence checks. -num_qubits = 3 -qc = create_ising_circuit(L=num_qubits, J=1.0, g=0.8, dt=0.1, timesteps=6) -circuit_state = State(num_qubits, initial="zeros") -circuit_params = DigitalSimParams( - observables=[Observable("z", site) for site in range(num_qubits)], - preset="fast", - num_traj=32, -) -noise_model = NoiseModel([ - {"name": "lowering", "sites": [site], "strength": 0.05} for site in range(num_qubits) -]) +## Noisy circuit readout -clean_result = sim.run(circuit_state, qc, circuit_params) -noisy_result = sim.run(State(num_qubits, initial="zeros"), qc, circuit_params, noise_model) -clean_z = np.array([float(np.real(v[0])) for v in clean_result.expectation_values]) -noisy_z = np.array([float(np.real(v[0])) for v in noisy_result.expectation_values]) - -fig, ax = plt.subplots(figsize=(5, 3), layout="constrained") -x = np.arange(num_qubits) -bar_width = 0.35 -ax.bar(x - bar_width / 2, clean_z, bar_width, label="unitary", color="0.55") -ax.bar(x + bar_width / 2, noisy_z, bar_width, label="with damping", color="C0") -ax.set_xticks(x, [rf"$\langle Z_{i}\rangle$" for i in range(num_qubits)]) -ax.set_ylim(-1.05, 1.05) -ax.set_ylabel("expectation value") -ax.set_title("Digital Ising circuit: optional open-system noise") -ax.legend(frameon=False) -``` +Prepare a **16-qubit graph state** and compare noiseless and damped readout. -## 3. Equivalence checking +```{code-cell} python +from qiskit import QuantumCircuit -Verify that a native GHZ circuit matches its transpiled decomposition (different -gate basis, same unitary) with {class}`~mqt.yaqs.EquivalenceChecker`: +from mqt.yaqs import DigitalSimParams, NoiseModel, Simulator, State -```{code-cell} ipython3 -from qiskit import transpile -from qiskit.circuit import QuantumCircuit +num_qubits = 16 +circuit = QuantumCircuit(num_qubits) +circuit.h(range(num_qubits)) +for site in range(num_qubits - 1): + circuit.cz(site, site + 1) +circuit.measure_all() -from mqt.yaqs import EquivalenceChecker +state = State(num_qubits, initial="zeros") +params = DigitalSimParams(shots=256, preset="fast", random_seed=7) +noise = NoiseModel([ + {"name": "lowering", "sites": [site], "strength": 0.5} for site in range(num_qubits) +]) -ghz_native = QuantumCircuit(3) -ghz_native.h(0) -ghz_native.cx(0, 1) -ghz_native.cx(1, 2) +simulator = Simulator(show_progress=False) +ideal = simulator.run(state, circuit, params) +damped = simulator.run(state, circuit, params, noise) +``` + +```{code-cell} python +:tags: [hide-input] +excitation_number = np.arange(num_qubits + 1) +readout_probabilities = [] +fig, ax = plt.subplots(figsize=(5.4, 2.9)) +for result, color, offset, label in ( + (ideal, "#0072B2", -0.22, "Noiseless"), + (damped, "#D55E00", 0.22, "Damped"), +): + probability = np.zeros(num_qubits + 1) + for outcome, count in result.counts.items(): + probability[outcome.bit_count()] += count / params.shots + readout_probabilities.append(probability) + ax.bar(excitation_number + offset, probability, width=0.42, color=color, + edgecolor="white", linewidth=0.5, alpha=0.9, label=label) +ax.set(xlabel="Number of excited qubits", ylabel="Measured probability", + xlim=(-0.5, num_qubits + 0.5), xticks=np.arange(0, num_qubits + 1, 2)) +ax.legend() +plt.show() +``` -ghz_transpiled = transpile( - ghz_native, - basis_gates=["rz", "sx", "x", "cx"], - optimization_level=1, +Damping shifts the readout toward fewer excitations. Each histogram summarizes +256 shots. See {doc}`circuit_shots` for bitstring counts and +{doc}`circuit_observables` for expectation values and OpenQASM input. + +## Noisy Analog-Digital Simulation + +Can digital gates reverse the spreading in an analog spin chain? Prepare one +excitation in a **20-site XY chain**, then apply $Z$ gates on even sites halfway +through the evolution. Compare free evolution, refocusing, and refocusing with +dephasing. + +```{code-cell} python +from qiskit import QuantumCircuit + +from mqt.yaqs import AnalogSimParams, DigitalSimParams, Hamiltonian, NoiseModel, Observable, SimulationProgram, Simulator, State + +length = 20 +center = length // 2 +state = State(length, initial="zeros") +hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0) +observables = [Observable("z", site) for site in range(length)] +preparation = QuantumCircuit(length) +preparation.x(center) +phase_pulse = QuantumCircuit(length) +phase_pulse.z(range(0, length, 2)) + +analog_params = AnalogSimParams(elapsed_time=1.5, dt=0.25, order=2, preset="fast") +digital_params = DigitalSimParams(preset="fast") +free_program = SimulationProgram( + [(preparation, digital_params), (hamiltonian, analog_params), (hamiltonian, analog_params)], + observables=observables, num_traj=32, random_seed=7, ) +echo_program = SimulationProgram( + [(preparation, digital_params), (hamiltonian, analog_params), + (phase_pulse, digital_params), (hamiltonian, analog_params), (phase_pulse, digital_params)], + observables=observables, num_traj=32, random_seed=7, +) +dephasing_rate = 0.2 +noise = NoiseModel([ + {"name": "pauli_z", "sites": [site], "strength": dephasing_rate} for site in range(length) +]) -checker = EquivalenceChecker(representation="mpo", threshold=1e-6) -equiv = checker.check(ghz_native, ghz_transpiled) -print(f"equivalent: {equiv['equivalent']}") -print(f"fidelity: {equiv['fidelity']:.4e}") -print(f"center-cut operator entropy: {equiv['center_cut_entanglement_entropy']:.4f}") -print(f"global operator entropy: {equiv['global_entanglement_entropy']:.4f}") - -fig, ax = plt.subplots(figsize=(4.5, 3)) -ax.semilogy(equiv["schmidt_values"], "o-") -ax.set_xlabel("Schmidt index") -ax.set_ylabel("singular value") -ax.set_title("Composed operator $W = U_1 U_2^\\dagger$") -fig.tight_layout() +simulator = Simulator(show_progress=False) +free_result = simulator.run(state, free_program) +echo_result = simulator.run(state, echo_program) +noisy_echo_result = simulator.run(state, echo_program, noise_model=noise) ``` -For larger circuits, compiler passes, and OpenQASM inputs, see -{doc}`equivalence_checking`. - -## 4. Characterize environmental memory +```{code-cell} python +:tags: [hide-input] +from matplotlib.colors import PowerNorm + +fig, axes = plt.subplots(1, 3, figsize=(7.2, 2.8), sharex=True, sharey=True) +for index, (ax, result, title) in enumerate(zip( + axes, (free_result, echo_result, noisy_echo_result), + ("(a) Free evolution", "(b) Refocusing", "(c) Noisy refocusing"), strict=True, +)): + segments = [segment for segment in result.segment_results if segment.segment_type == "analog"] + times = np.concatenate([segment.times + segment.time_offset for segment in segments]) + values = np.concatenate([np.asarray(segment.expectation_values) for segment in segments], axis=1) + keep = np.r_[True, np.diff(times) > 0] + occupation = (1 - values[:, keep]) / 2 + image = ax.pcolormesh( + times[keep], np.arange(length), occupation, shading="auto", cmap="cividis", + norm=PowerNorm(0.5, vmin=0, vmax=1), rasterized=True, + ) + if index > 0: + ax.axvline(1.5, color="white", linestyle="--", linewidth=1) + ax.set(xlabel=r"Time $t$", xlim=(0, 3), yticks=[0, 5, 10, 15, 19]) + ax.set_title(title, loc="left", fontsize=10) +axes[0].set_ylabel(r"Site $i$") +fig.colorbar(image, ax=list(axes), label=r"$\langle n_i\rangle$", ticks=[0, 0.1, 0.5, 1]) +plt.show() +``` -Probe a probe qubit coupled to a short chain at an interior temporal cut. The -memory spectrum and response matrix show how many independent past branches -remain visible at the cut ($S_V$). For temporal entanglement of a process tensor -($S_{PT}$), see {doc}`characterization`. +The phase pulse reverses the XY exchange, bringing the excitation back at $t=3$. +A final pulse restores the phase frame. Dephasing during the analog intervals +preserves excitation but disrupts refocusing; the noisy panel averages 32 +trajectories at Lindblad rate $\gamma_z=0.2$. The gates are ideal and +instantaneous. All panels share one color scale. See +{doc}`digital_analog_simulation` for the pulse mechanism, program outputs, and +noise comparisons. -```{code-cell} ipython3 -import numpy as np +## Circuit equivalence -from mqt.yaqs import AnalogSimParams, Hamiltonian, MemoryCharacterizer -from mqt.yaqs.characterization.memory.shared.utils import make_zero_psi +Verify a transpiled circuit, then compare the effects of an added rotation and +increasing Pauli noise. -length = 4 -ham = Hamiltonian.ising(length=length, J=1.0, g=1.0) -params = AnalogSimParams(dt=0.1, max_bond_dim=16, order=1) -mc = MemoryCharacterizer(show_progress=False) +```{code-cell} python +import numpy as np +from qiskit import QuantumCircuit, transpile + +from mqt.yaqs import EquivalenceChecker, NoiseModel + +circuit = QuantumCircuit(4) +circuit.h(0) +for site in range(3): + circuit.cx(site, site + 1) +decomposed = transpile(circuit, basis_gates=["rz", "sx", "x", "cx"]) + +checker = EquivalenceChecker() +print("Equivalent:", checker.check(circuit, decomposed)["equivalent"]) +angles = np.linspace(0, np.pi, 17) +rotation_overlaps = [] +for angle in angles: + perturbed = decomposed.copy() + perturbed.rz(float(angle), 0) + rotation_overlaps.append(checker.check(circuit, perturbed)["fidelity"]) + +error_probabilities = np.linspace(0, 0.9, 10) +noisy_checks = [] +for probability in error_probabilities: + noise = NoiseModel([{"name": "pauli_z", "sites": [0], "strength": float(probability)}]) + noisy_checks.append(checker.check( + circuit, decomposed, noise_model=noise, num_traj=128, random_seed=7, + )) +``` -cut, num_interventions = 4, 6 -result = mc.characterize( - ham, - params, - num_interventions=num_interventions, - cut=cut, - n_pasts=6, - n_futures=6, - initial_psi=make_zero_psi(length), - rng=np.random.default_rng(0), -) -sv = result.singular_values(cut) -v = result.response_matrix(cut) -# v.shape == (4 * n_futures, n_pasts), with I, X, Y, Z rows per future probe - -fig, axes = plt.subplots(1, 2, figsize=(8, 3)) -axes[0].semilogy(sv, "o-") -axes[0].set_xlabel("mode index") -axes[0].set_ylabel("singular value") -axes[0].set_title(rf"Memory spectrum: $S_V(c={cut})={result.entropy(cut):.2f}$") - -im = axes[1].imshow(np.abs(v), aspect="auto", cmap="viridis") -axes[1].set_title(rf"$|V(c)|$, $R(c)={result.modes(cut):.1f}$") -axes[1].set_xlabel("history") -axes[1].set_ylabel("future probe and response channel") -fig.colorbar(im, ax=axes[1], fraction=0.046, pad=0.04) -fig.tight_layout() +```{code-cell} python +:tags: [hide-input] +fig, axes = plt.subplots(1, 2, figsize=(6.8, 2.9), sharey=True) +axes[0].plot(angles, rotation_overlaps, "o", color="#0072B2", label="YAQS") +axes[0].plot(angles, np.abs(np.cos(angles / 2)), "--", color="0.3", label=r"$|\cos(\theta/2)|$") +axes[0].set(xlabel=r"Added rotation $\theta$ (rad)", ylabel="Normalized overlap", + xlim=(-0.03, np.pi + 0.03), ylim=(0, 1.05), xticks=[0, np.pi / 2, np.pi], + xticklabels=["0", r"$\pi/2$", r"$\pi$"]) +axes[0].set_title("(a) Coherent error", loc="left", fontsize=11) +axes[0].legend() +axes[1].errorbar(error_probabilities, [check["fidelity"] for check in noisy_checks], + yerr=[check["fidelity_error"] for check in noisy_checks], + fmt="o", color="#D55E00", capsize=2, label="YAQS") +axes[1].plot(error_probabilities, np.sqrt(1 - error_probabilities), "--", color="0.3", label=r"$\sqrt{1-p}$") +axes[1].set(xlabel=r"Pauli error probability $p$", xlim=(-0.03, 0.93)) +axes[1].set_title("(b) Stochastic error", loc="left", fontsize=11) +axes[1].legend() +plt.show() ``` -## 5. Fit a Markovian noise digital twin (analytical optimization) +An overlap of one indicates agreement up to a global phase. Here, site 0 has one +noise opportunity, after the first CX gate. A $Z$ error has zero overlap, so the +ensemble's root-mean-square overlap is $\sqrt{1-p}$. Error bars show Monte Carlo +standard errors. Noise is applied to the second circuit. See +{doc}`equivalence_checking` for supported noise and accuracy controls. + +## Environmental memory -Learn Lindblad jump rates from observable trajectories with -{class}`~mqt.yaqs.NoiseCharacterizer` using **analytical optimization** -(simulator forward model + CMA-ES trajectory matching). +Sweep the Ising coupling in a three-spin chain and compare the probe qubit's +memory spectra using the same probe grid. -```{code-cell} ipython3 +```{code-cell} python import numpy as np -from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseCharacterizer, NoiseModel, Observable, State - -n_sites = 3 -sites = list(range(n_sites)) -hamiltonian = Hamiltonian.ising(n_sites, J=1.0, g=2.0) -init_state = State(n_sites, initial="zeros") -fitting_observables = [Observable("y", 0), Observable("z", 0), Observable("y", 1)] -sim_params = AnalogSimParams( - observables=fitting_observables, - elapsed_time=0.8, - dt=0.1, - order=1, - sample_timesteps=True, -) -reference_model = NoiseModel( - [{"name": "pauli_x", "sites": [s], "strength": 0.08} for s in sites] - + [{"name": "pauli_y", "sites": [s], "strength": 0.08} for s in sites] - + [{"name": "pauli_z", "sites": [s], "strength": 0.08} for s in sites] -) -init_guess = NoiseModel( - [{"name": "pauli_x", "sites": [s], "strength": 0.35} for s in sites] - + [{"name": "pauli_y", "sites": [s], "strength": 0.35} for s in sites] - + [{"name": "pauli_z", "sites": [s], "strength": 0.35} for s in sites] -) +from mqt.yaqs import AnalogSimParams, Hamiltonian, MemoryCharacterizer -result = NoiseCharacterizer(show_progress=False).characterize( - hamiltonian, - sim_params, - init_state=init_state, - init_guess=init_guess, - observables=fitting_observables, - reference_model=reference_model, - x_low=np.zeros(len(init_guess.processes)), - x_up=np.full(len(init_guess.processes), 0.5), - sigma0=0.05, - popsize=8, - max_iter=20, - seed=42, -) +couplings = np.linspace(0, 1.5, 13) +params = AnalogSimParams(elapsed_time=0.5, dt=0.5, preset="fast") +characterizer = MemoryCharacterizer(show_progress=False) +memories = [] +for coupling in couplings: + hamiltonian = Hamiltonian.ising(3, J=coupling, g=1.0) + memories.append(characterizer.characterize( + hamiltonian, params, num_interventions=4, cut=2, preset="quick", + rng=np.random.default_rng(7), + )) +``` -times = result.times -obs_labels = [r"$\langle Y_0\rangle$", r"$\langle Z_0\rangle$", r"$\langle Y_1\rangle$"] -fig, axes = plt.subplots(1, 2, figsize=(8, 2.8), layout="constrained", sharey=True) -for ax, traj, title in zip(axes, [result.fit_traj, result.ref_traj], ["learned twin", "reference"], strict=True): - im = ax.imshow( - traj, - aspect="auto", - extent=(times[0], times[-1], len(obs_labels), 0), - vmin=-1, - vmax=1, - cmap="RdBu_r", - ) - ax.set_yticks([i + 0.5 for i in range(len(obs_labels))], obs_labels) - ax.set_xlabel("time") - ax.set_title(title) -fig.colorbar(im, ax=axes, shrink=0.9, label="expectation") -fig.suptitle(rf"Twin fit: RMSE={result.trajectory_rmse():.2e}", y=1.02) +```{code-cell} python +:tags: [hide-input] +fig, axes = plt.subplots(1, 2, figsize=(6.8, 3.0), width_ratios=[1.4, 1]) +colors = plt.colormaps["Reds"](np.linspace(0.35, 0.95, len(couplings))) +for memory, color in zip(memories, colors, strict=True): + spectrum = memory.singular_values(2) + weights = spectrum**2 / np.sum(spectrum**2) + axes[0].semilogy(np.arange(1, len(weights) + 1), weights, "o-", + color=color, linewidth=1.2, markersize=3) +axes[0].text(0.28, 0.14, "Coupling increases", transform=axes[0].transAxes, + ha="center", va="center", fontsize=10, color="black") +axes[0].annotate("", xy=(0.82, 0.76), xytext=(0.50, 0.20), + xycoords="axes fraction", + arrowprops={"arrowstyle": "->", "color": "black", + "lw": 1.5, "connectionstyle": "arc3,rad=0.25"}) +axes[0].set(xlabel="Mode index", ylabel=r"Resolved mode weight $p_k$", ylim=(1e-13, 2)) +axes[0].set_title("(a) Memory spectra", loc="left", fontsize=11) +entropies = np.array([memory.entropy(2) for memory in memories]) +axes[1].fill_between(couplings, entropies, color=colors[3], alpha=0.3) +axes[1].plot(couplings, entropies, "o-", color=colors[-1], linewidth=2.6, + markerfacecolor="white", markeredgewidth=1.2) +axes[1].set(xlabel=r"Coupling $J$", ylabel=r"Memory entropy $S_V$", + xlim=(-0.03, 1.53), ylim=(-0.008, 0.34), xticks=[0, 0.5, 1, 1.5]) +axes[1].grid(axis="y", color="0.9", linewidth=0.5) +axes[1].set_axisbelow(True) +axes[1].set_title("(b) Resolved memory", loc="left", fontsize=11) +plt.show() ``` -See {doc}`digital_twin` for the full analytical-optimization workflow, -experimental-data fitting, held-out prediction, and MCWF fitting. +The weights $p_k=s_k^2/\sum_j s_j^2$ describe memory resolved by the sampled +probes. Darker curves show larger $J$. Coupling redistributes weight among the +resolved modes. The entropy measures this spread and peaks within this sweep. +These weights are not environment-state populations. See {doc}`characterization` +for probe choices and interpretation. + +## Create a digital twin -## 6. Train a surrogate and predict under controls +Learn +**relaxation and dephasing from measurements at the ends of a spin chain**. Then +rerun the fitted model to predict transport through the unmeasured interior. -Train a causal surrogate with -{class}`~mqt.yaqs.memory_characterizer.MemoryCharacterizer`, then predict the -probe-qubit state after one or more control legs. Pass an explicit per-leg list -to compare different sequences on the same trained model. Surrogate training -requires PyTorch (`uv pip install mqt.yaqs[torch]`). +```{code-cell} python +import numpy as np -```{code-cell} ipython3 -rho0 = np.eye(2, dtype=np.complex128) / 2.0 -ham_sure = Hamiltonian.ising(length=2, J=1.0, g=1.0) +from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseCharacterizer, NoiseModel, Observable, Simulator, State -model = mc.train( - ham_sure, +length = 4 +state = State(length, initial="basis", basis_string="1000", representation="density_matrix") +hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0) +observables = [Observable("z", site) for site in range(length)] +params = AnalogSimParams(observables=observables, elapsed_time=8.0, dt=0.1, preset="fast") +reference = NoiseModel([ + {"name": "lowering", "sites": [3], "strength": 0.35}, + {"name": "pauli_z", "sites": [2], "strength": 0.12}, +]) +guess = NoiseModel([ + {"name": "lowering", "sites": [3], "strength": 0.2}, + {"name": "pauli_z", "sites": [2], "strength": 0.2}, +]) + +simulator = Simulator(show_progress=False) +measured = simulator.run(state, hamiltonian, params, reference) +characterizer = NoiseCharacterizer(show_progress=False) +fit = characterizer.characterize( + hamiltonian, params, - num_interventions=1, - n=32, - train_kwargs={"epochs": 30, "batch_size": 8}, - model_kwargs={"d_model": 32, "nhead": 4, "num_layers": 1, "dim_ff": 64}, + init_state=state, + init_guess=guess, + observables=[observables[0], observables[-1]], + ref_expectations=np.asarray(measured.expectation_values)[[0, -1]], + x_low=np.zeros(2), + x_up=np.ones(2), + max_iter=40, + seed=7, ) +reconstructed = simulator.run(state, hamiltonian, params, fit.optimal_model) +print("Fitted rates:", fit.best_parameters.round(3)) +``` -hadamard = np.array([[1, 1], [1, -1]], dtype=np.complex128) / np.sqrt(2) -pauli_x = np.array([[0, 1], [1, 0]], dtype=np.complex128) -control_sequences = { - r"$\mathrm{H}$": [{"unitary": hadamard}], - r"$\mathrm{X}$": [{"unitary": pauli_x}], -} - -pauli_ops = { - "X": np.array([[0, 1], [1, 0]], dtype=np.complex128), - "Y": np.array([[0, -1j], [1j, 0]], dtype=np.complex128), - "Z": np.array([[1, 0], [0, -1]], dtype=np.complex128), -} -expectations = { - label: [ - float(np.trace(op @ mc.predict(model, rho0, controls, num_interventions=1)).real) - for op in pauli_ops.values() - ] - for label, controls in control_sequences.items() -} - -pauli_names = list(pauli_ops) -x = np.arange(len(pauli_names)) -width = 0.35 - -fig, ax = plt.subplots(figsize=(5, 3.5)) -for offset, (label, values) in zip((-width / 2, width / 2), expectations.items()): - ax.bar(x + offset, values, width, label=f"control {label}") -ax.set_xticks(x, pauli_names) -ax.set_ylabel("expectation value") -ax.set_title("Probe Pauli expectations for two control sequences") -ax.legend(frameon=False) -fig.tight_layout() +```{code-cell} python +:tags: [hide-input] +reference_dynamics = (1 - np.asarray(measured.expectation_values).real) / 2 +fitted_dynamics = (1 - np.asarray(reconstructed.expectation_values).real) / 2 +fig, axes = plt.subplots(1, 2, figsize=(6.8, 2.8), sharey=True) +for ax, dynamics, title in zip( + axes, (reference_dynamics, fitted_dynamics), + ("(a) Reference transport", "(b) Fitted model"), strict=True, +): + image = ax.pcolormesh(measured.times, np.arange(length), dynamics, + shading="auto", cmap="cividis", vmin=0, vmax=1, rasterized=True) + ax.set(xlabel=r"Time $t$", yticks=np.arange(length)) + ax.set_title(title, loc="left", fontsize=11) +axes[0].set_ylabel(r"Site $i$") +fig.colorbar(image, ax=list(axes), label=r"$\langle n_i\rangle$", ticks=[0, 0.5, 1]) +plt.show() ``` -`predict` also accepts a style string (for example `"haar"`) or a per-leg list -mixing unitaries and measure–prepare slots. See {doc}`characterization` for -environmental memory probing and {doc}`memory_surrogate` for held-out accuracy -checks and exact-reference validation. - -## 7. Where to go next - -| Goal | Start here | -| ---------------------------------------------------- | ----------------------------- | -| Environmental memory probing | {doc}`characterization` | -| Markovian noise digital-twin fitting | {doc}`digital_twin` | -| Surrogate training, prediction, and exact validation | {doc}`memory_surrogate` | -| Open-system dynamics, noise, time grids | {doc}`analog_simulation` | -| Bell-curve (log-normal) noise strengths | {doc}`realistic_noise_models` | -| Circuit observables, mid-circuit sampling, OpenQASM | {doc}`circuit_observables` | -| Accuracy presets and truncation knobs | {doc}`simulation_parameters` | -| Check two circuits for equivalence | {doc}`equivalence_checking` | - -## Related topics - -- {doc}`state_initialization` — `State` presets and representations -- {doc}`simulator_initialization` — parallelism, progress bars, `Result` fields -- {doc}`representation_comparison` — when to use MPS, MCWF, or Lindblad backends -- {doc}`equivalence_checking` — MPO backend, transpiler regression tests, - OpenQASM +The excitation propagates and reflects while a local sink removes population and +dephasing changes transport. Only sites 0 and 3 enter the fit; sites 1 and 2 +check its predictions. Both heatmaps share a scale. This example uses synthetic +observations without measurement noise from density-matrix simulation; replace +the reference array with your measured traces. The fit assumes the two channel +types and their sites are known. See {doc}`digital_twin` for data preparation +and validation. + +## Predict non-Markovian dynamics + +YAQS also provides experimental surrogate models that learn a probe's response +to controls from simulated training sequences. See {doc}`memory_surrogate` for a +small example with an independent reference. This feature is not yet supported +by a published YAQS paper; validate predictions for your controls and time +horizon. + +## Next steps + +| Task | Guide | +| -------------------------------------- | -------------------------------- | +| Choose accuracy settings | {doc}`simulation_parameters` | +| Choose states and simulation backends | {doc}`state_initialization` | +| Combine analog evolution and circuits | {doc}`digital_analog_simulation` | +| Check whether two circuits agree | {doc}`equivalence_checking` | +| Create a digital twin | {doc}`digital_twin` | +| Study memory in a system's environment | {doc}`characterization` | +| Predict non-Markovian dynamics | {doc}`memory_surrogate` | diff --git a/docs/examples/realistic_noise_models.md b/docs/examples/realistic_noise_models.md index 1d11a43ac..8e83916f1 100644 --- a/docs/examples/realistic_noise_models.md +++ b/docs/examples/realistic_noise_models.md @@ -2,363 +2,366 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 300 + execution_timeout: 120 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` +# Noise Models + +A {class}`~mqt.yaqs.NoiseModel` describes how a system loses energy, gains +excitations, or suffers other disturbances during a simulation. Assemble named +jump operators or supply your own matrices, then pass the model to +`Simulator.run`. Use fixed strengths for a calibrated model, or distributions to +represent static variation between runs. + +## Define the noise processes + +Each process has a `name`, a list of `sites`, and a `strength`. Site indices +start at zero. This four-qubit model combines local relaxation and dephasing: -# Realistic Noise Models - -YAQS ships a library of physically motivated jump operators—relaxation -(`lowering`), excitation (`raising`), single-qubit Pauli channels, and -nearest-neighbor crosstalk (`crosstalk_xx`, `crosstalk_zz`, …)—that you assemble -into a {class}`~mqt.yaqs.core.data_structures.noise_model.NoiseModel`. - -For hardware with **static disorder** (calibration drift, fabrication spread), -each process strength can be a **distribution** instead of a fixed float. YAQS -samples one concrete strength per process when {meth}`~mqt.yaqs.Simulator.run` -starts; all trajectories in that run share the same sampled disorder. The -realized model is stored on {attr}`~mqt.yaqs.Result.noise_model`. - -This page shows: - -1. A typical multi-channel noise model for an analog chain. -2. **Log-normal disorder on strengths** (recommended when rates span orders of - magnitude) and other built-in distributions. -3. How sampled disorder changes open-system dynamics compared to a - median-strength baseline. -4. **Custom jump operators** via an explicit `matrix` (not only built-in library - names). - -## 1. Built-in noise processes - -Each process is a dictionary with `name`, `sites`, and `strength`. YAQS fills in -the operator `matrix` (or per-site `factors` for long-range crosstalk) from -{class}`~mqt.yaqs.core.libraries.noise_library.NoiseLibrary`. - -Malformed configs raise `TypeError` (wrong types, including booleans used as -sites/strengths) or `ValueError` (invalid values). Fixed `strength` values must -be finite and **nonnegative**—they are Lindblad/quantum-jump rates $\gamma$, not -signed dissipator coefficients. Temporarily negative rates can appear in -time-local non-Markovian master equations, but YAQS does not implement signed -generators or reverse-jump unravelings. Custom `matrix` / `factors` entries may -still contain negative elements. Site lists/tuples must contain exactly one or -two distinct nonnegative integers. Custom adjacent two-site matrices require -**ascending** site order. To convert a matrix written for descending sites, -reverse the site list and swap the matrix's input and output tensor-factor axes. -Use `factors` only for non-adjacent pairs. - -This rate interpretation applies to `Simulator`. For sampled equivalence -checking, `EquivalenceChecker` instead treats resolved strengths as direct -per-opportunity branch probabilities; see {ref}`equivalence-noise-model`. - -```{code-cell} ipython3 +```{code-cell} python from mqt.yaqs import NoiseModel -L = 4 -processes = [ - {"name": "lowering", "sites": [i], "strength": 0.05} for i in range(L) +length = 4 +noise = NoiseModel([ + {"name": "lowering", "sites": [site], "strength": 0.1} + for site in range(length) ] + [ - {"name": "pauli_z", "sites": [i], "strength": 0.02} for i in range(L) -] + [ - {"name": "crosstalk_xx", "sites": [i, i + 1], "strength": 0.01} for i in range(L - 1) -] + {"name": "pauli_z", "sites": [site], "strength": 0.02} + for site in range(length) +]) +``` -noise_model = NoiseModel(processes) +The named operators act on qubits. Names are case-sensitive: + +| Process name | Effect | Sites | +| ------------------------------------- | --------------------------------------------------------------------- | --------------------------------- | +| `"lowering"` | Relaxation from $\lvert1\rangle$ to $\lvert0\rangle$. | One. | +| `"raising"` | Excitation from $\lvert0\rangle$ to $\lvert1\rangle$. | One. | +| `"pauli_x"`, `"pauli_y"`, `"pauli_z"` | Pauli errors; $Z$ causes dephasing. Aliases: `"x"`, `"y"`, `"z"`. | One. | +| `"lowering_two"`, `"raising_two"` | Joint relaxation $\lvert11\rangle\to\lvert00\rangle$, or the reverse. | Two adjacent sites. | +| `"crosstalk_xx"`, `"crosstalk_xy"`, … | Correlated Pauli errors; any pair of `x`, `y`, and `z`. | Two; see the support table below. | + +Joint relaxation is one two-site jump. To model independent relaxation on two +sites, supply two `"lowering"` processes instead. + +## Interpret the strengths + +For `Simulator`, `strength` is a finite, nonnegative Lindblad rate $\gamma$. Use +inverse units of the simulation time. YAQS supplies the factor $\sqrt{\gamma}$; +pass the unscaled operator as `matrix`. Operators need not be Hermitian or +unitary, and YAQS does not normalize them. + +Analog noise acts over the physical time steps set by `AnalogSimParams.dt`. +Circuit noise uses a unit noise step after each multi-qubit gate, with only the +processes whose sites lie within that gate's qubits. Single-qubit gates, +barriers, and idle qubits do not create noise opportunities. Circuit strengths +therefore do not represent hardware gate durations or direct error +probabilities. See {doc}`circuit_observables` for a worked example. + +:::{important} +`EquivalenceChecker` interprets resolved strengths as direct per-opportunity +branch probabilities and imposes its own probability constraints. A simulator +rate cannot be reused as a checker probability without choosing a conversion. +See {ref}`equivalence-noise-model`. +::: + +:::{dropdown} Relate rates to relaxation and dephasing times +For a jump operator $L$, the rate multiplies the dissipator + +$$ +\gamma\mathcal{D}[L](\rho)=\gamma\left( +L\rho L^\dagger-\tfrac12\{L^\dagger L,\rho\}\right). +$$ + +With only `"lowering"` and no Hamiltonian, the excited-state population decays +as $e^{-\gamma t}$, so $\gamma=1/T_1$. With only `"pauli_z"`, the off-diagonal +density-matrix entries decay as $e^{-2\gamma t}$, so $\gamma=1/(2T_\phi)$. These +conventions matter when converting measured lifetimes to strengths. Scaling $L$ +by a factor $c$ scales its dissipator by $|c|^2$. + +Negative rates, including time-local descriptions with temporarily negative +coefficients, are not supported. Negative or complex matrix entries remain valid +parts of a jump operator. +::: + +## Run and inspect the model + +Pass the model with the initial state, Hamiltonian, and simulation parameters. +This short Ising evolution uses the default MPS representation and averages +eight trajectories: + +```{code-cell} python +from mqt.yaqs import AnalogSimParams, Hamiltonian, Observable, Simulator, State + +state = State(length, initial="ones") +hamiltonian = Hamiltonian.ising(length, J=1.0, g=0.5) +params = AnalogSimParams( + observables=[Observable("z", site) for site in range(length)], + elapsed_time=0.2, + dt=0.05, + num_traj=8, + random_seed=7, +) + +sim = Simulator(show_progress=False) +result = sim.run(state, hamiltonian, params, noise_model=noise) +print(result.expectation_values[0].shape) ``` -## 2. Log-normal disorder on strengths +The shape is `(5,)`: five sampled times, including the initial time. +`result.expectation_values` contains one such array per observable, in the +supplied order. Parallel execution remains enabled; the documentation hides +progress bars. Eight trajectories suffice to demonstrate the call, but +scientific results need a convergence check. The model used in the run is +available as `result.noise_model`. -When calibration rates vary across devices or qubits, strengths often span -**several orders of magnitude**. A **log-normal** distribution is usually more -realistic than a symmetric Gaussian on the rate itself. +## Represent static variation -Replace a scalar `strength` with a dict. For log-normal sampling, `mean` and -`std` are the parameters of the underlying normal distribution on $\log\gamma$: +Replace a scalar strength with a distribution dictionary. This example gives +each qubit an independent log-normal relaxation rate: -```{code-cell} ipython3 -bell_curve_strength = {"distribution": "lognormal", "mean": -2.3, "std": 0.5} +```{code-cell} python +import numpy as np -disordered_processes = [ +variable_noise = NoiseModel([ { - "name": "pauli_z", - "sites": [i], - "strength": bell_curve_strength, + "name": "lowering", + "sites": [site], + "strength": {"distribution": "lognormal", "mean": np.log(0.1), "std": 0.4}, } - for i in range(L) -] + for site in range(length) +]) -disordered_model = NoiseModel(disordered_processes) +resolved_noise = variable_noise.sample(rng=7) +print([process["strength"] for process in resolved_noise.processes]) ``` -Other supported distributions: - -| `distribution` | Parameters | Use when | -| -------------------- | ------------- | ----------------------------------------------------------------------------------------- | -| `"lognormal"` | `mean`, `std` | **Default choice** for positive rates spanning magnitudes (`mean`/`std` on $\log\gamma$). | -| `"normal"` | `mean`, `std` | Symmetric spread around a target rate; negatives are clamped to `0`. | -| `"truncated_normal"` | `mean`, `std` | Same shape as normal but sampled only for non-negative strengths. | - -Sample many independent disorder realizations and plot the bell curve on a log -scale: - -```{code-cell} ipython3 -import matplotlib.pyplot as plt -import matplotlib.ticker as mticker -import numpy as np -from scipy import stats - -rng = np.random.default_rng(0) -samples = [disordered_model.sample(rng=rng).processes[0]["strength"] for _ in range(5000)] - -mu = bell_curve_strength["mean"] -sigma = bell_curve_strength["std"] -x = np.logspace(np.log10(min(samples)), np.log10(max(samples)), 200) -pdf = stats.lognorm.pdf(x, s=sigma, scale=np.exp(mu)) - -fig, ax = plt.subplots(figsize=(7, 3.8), layout="constrained") -ax.hist(samples, bins=40, density=True, alpha=0.7, color="tab:blue", label="sampled strengths") -ax.plot(x, pdf, color="black", lw=1.5, label="log-normal pdf") -ax.set_xscale("log") - -# Sparse decade ticks with plain decimal labels (avoids crowded sci-notation on log axes) -lo, hi = float(min(samples)), float(max(samples)) -tick_decades = np.arange(int(np.floor(np.log10(lo))), int(np.ceil(np.log10(hi))) + 1) -tick_candidates = np.concatenate([np.array([1, 2, 5]) * 10.0**e for e in tick_decades]) -ticks = tick_candidates[(tick_candidates >= lo * 0.9) & (tick_candidates <= hi * 1.1)] -if len(ticks) > 6: - ticks = ticks[np.linspace(0, len(ticks) - 1, 6, dtype=int)] -ax.set_xticks(ticks) -ax.xaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v:g}")) -ax.xaxis.set_minor_locator(mticker.NullLocator()) - -ax.set_xlabel("sampled dephasing strength") -ax.set_ylabel("density") -ax.set_title("Log-normal disorder (median ≈ {:.3f})".format(np.exp(mu))) -ax.legend() -ax.grid(alpha=0.3, which="both") -plt.show() -``` +{meth}`~mqt.yaqs.NoiseModel.sample` returns a new model with concrete rates; it +leaves the original model unchanged. For `"lognormal"`, `mean` and `std` +describe the normal distribution of $\log\gamma$, so the median rate above is +`0.1`. -## 3. Disorder in an analog simulation +| `distribution` | Meaning of `mean` and `std` | Treatment of negative draws | +| -------------------- | ---------------------------------------------------------- | --------------------------------------------------------------- | +| `"lognormal"` | Mean and standard deviation of $\log\gamma$. | All draws are positive. | +| `"normal"` | Mean and standard deviation of a normal rate distribution. | Clamped to zero with a warning. | +| `"truncated_normal"` | Parameters of the underlying normal distribution. | Sampled from that distribution restricted to nonnegative rates. | -We evolve a short Ising chain from a Néel product state and compare: +Passing `variable_noise` directly to `Simulator.run` draws one rate per process +at the start of the run. All trajectories share those rates. This represents +static disorder: rates do not change during the evolution or between +trajectories. To average over disorder, perform separate runs with separate +draws; increasing `num_traj` only improves the trajectory average for one draw. -- **Baseline:** every site uses the log-normal **median** $\exp(\text{mean})$ as - a fixed strength. -- **Disordered:** strengths are drawn from the log-normal once at the start of - each run. -- **Ensemble band:** several independent disorder draws (different - `random_seed`) to show typical spread. +To compare setups using the same rates, pass a resolved model: -```{code-cell} ipython3 -from mqt.yaqs import AnalogSimParams, Hamiltonian, Observable, Simulator, State +```{code-cell} python +resolved_result = sim.run(state, hamiltonian, params, noise_model=resolved_noise) +print([process["strength"] for process in resolved_result.noise_model.processes]) +``` -# Wider log-normal spread for a visible disorder effect in dynamics -dyn_strength = {"distribution": "lognormal", "mean": -0.7, "std": 1.0} -dyn_disordered = NoiseModel([ - {"name": "pauli_z", "sites": [i], "strength": dyn_strength} for i in range(L) +The printed rates match the earlier sample. You can also reuse a previous run's +`result.noise_model`. Setting `params.random_seed` fixes automatic disorder +draws and trajectory random streams for a fixed setup. It does not seed +independently prepared random states or final shot sampling, or guarantee +identical numbers across software versions and platforms. For successive manual +disorder draws, reuse a NumPy `Generator`; repeating `.sample(rng=7)` repeats +the same draw. + +:::{dropdown} Distribution parameters and limits +`mean` and `std` default to zero when omitted; specify both to make the intended +distribution clear. `mean` must be finite, and `std` must be finite and +nonnegative. A zero-width normal or truncated normal resolves to `max(0, mean)`; +a zero-width log-normal resolves to `exp(mean)`. + +There is no default distribution or upper rate limit. Choose a distribution from +the variation you intend to model, and choose the time step to resolve the +resulting dynamics. Clamping a normal distribution creates a point mass at zero; +truncating it renormalizes the positive part instead. Neither choice creates a +time-dependent noise model. +::: + +(noise-custom-operators)= + +## Supply a custom operator + +Add `matrix` to override the library lookup; `name` then serves as an +identifier. A one-site operator must be a finite, square matrix matching that +site's local dimension. This explicit matrix gives the same jump as +`"lowering"`: + +```{code-cell} python +sigma_minus = np.array([[0, 1], [0, 0]], dtype=complex) +custom_noise = NoiseModel([ + {"name": "relaxation", "sites": [0], "strength": 0.1, "matrix": sigma_minus}, + {"name": "pauli_z", "sites": [1], "strength": 0.02}, ]) +``` -hamiltonian = Hamiltonian.ising(length=L, J=1.0, g=0.5) -state = State(L, initial="Neel") -z_obs = Observable("z", sites=0) +You can mix custom and named processes. For higher local dimensions, supply an +operator in that local basis. A truncated oscillator with levels $0,1,2$ uses +the annihilation operator -sim_params = AnalogSimParams( - observables=[z_obs], - elapsed_time=8.0, - dt=0.1, - num_traj=32, - max_bond_dim=24, - random_seed=7, -) - -median_strength = float(np.exp(dyn_strength["mean"])) -baseline_model = NoiseModel([ - {"name": "pauli_z", "sites": [i], "strength": median_strength} for i in range(L) +```{code-cell} python +annihilation = np.diag(np.sqrt(np.arange(1, 3)), k=1) +qutrit_noise = NoiseModel([ + {"name": "loss", "sites": [0], "strength": 0.1, "matrix": annihilation}, ]) - -sim = Simulator(show_progress=False) -result_baseline = sim.run(state, hamiltonian, sim_params, baseline_model) -result_disordered = sim.run(state, hamiltonian, sim_params, dyn_disordered) - -# Ensemble of disorder realizations for a shaded band (keep small for doc build time) -ensemble_curves = [] -for seed in range(8, 12): - params_i = AnalogSimParams( - observables=[z_obs], - elapsed_time=8.0, - dt=0.1, - num_traj=16, - max_bond_dim=24, - random_seed=seed, - ) - res_i = sim.run(state, hamiltonian, params_i, dyn_disordered) - ensemble_curves.append(res_i.expectation_values[0]) -ensemble_curves = np.asarray(ensemble_curves) ``` -```{code-cell} ipython3 ---- -mystnb: - image: - width: 80% - align: center ---- -import matplotlib.pyplot as plt -import matplotlib.ticker as mticker - -times = sim_params.times -baseline_curve = result_baseline.expectation_values[0] -disordered_curve = result_disordered.expectation_values[0] - -fig, ax = plt.subplots(figsize=(7, 4), layout="constrained") -ax.fill_between( - times, - ensemble_curves.min(axis=0), - ensemble_curves.max(axis=0), - color="tab:orange", - alpha=0.25, - label="disordered ensemble (4 seeds)", -) -ax.plot(times, baseline_curve, label="fixed median strength", color="black", linestyle="--", lw=2) -ax.plot(times, disordered_curve, label="one disordered sample", color="tab:orange", lw=1.5) -ax.set_xlabel("time") -ax.set_ylabel(r"$\langle Z_0 \rangle$") -ax.set_title("Log-normal static disorder shifts open-system decay") -ax.xaxis.set_major_locator(mticker.MaxNLocator(6)) -ax.legend() -ax.grid(alpha=0.3) -plt.show() -``` +This matrix is $3\times3$ and requires a dimension-three site. See +{doc}`transmon_emulation` for a device example with such operators. + +## Choose a supported combination -Re-running with the same `random_seed` reproduces the same sampled strengths and -trajectory-averaged curve. Leave `random_seed=None` for fresh disorder draws in -production Monte Carlo studies. +The state representation selects the analog backend. One-site and adjacent +two-site process matrices work with all three analog representations. Long-range +processes need the choices below: -## 4. Disorder on a noisy circuit +| Workflow | One-site and adjacent two-site noise | Non-adjacent two-site noise | +| -------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------- | +| Analog MPS (TJM) | Supported. | Products of Pauli operators, up to a unit-modulus phase per factor. | +| Analog vector (MCWF) | Supported. | Custom factor pairs supported. | +| Analog density matrix (Lindblad) | Supported. | Custom factor pairs supported. | +| Circuit simulation (MPS) | Supported on the gate qubits at each noise opportunity. | Not supported. | -The same distribution syntax works in digital simulation. Below, bit-flip rates -on each qubit follow independent log-normal draws; one sample is drawn per -`Simulator.run` call. +All operators must match the site's local dimension. Named Pauli operators are +$2\times2$; supply custom matrices for other dimensions. YAQS checks dimensions +and site bounds against the state when the simulation starts. See +{doc}`representation_comparison` to choose a representation. -```{code-cell} ipython3 -from mqt.yaqs import Observable, DigitalSimParams -from mqt.yaqs.core.libraries.circuit_library import create_ising_circuit +Noisy MPS, vector, and circuit runs cannot retain a final pure state with +`get_state=True`; noisy density-matrix runs can retain their final mixed state. +The analog `list[State]` ensemble workflow does not support process noise. See +{doc}`simulation_parameters` for output choices. -num_qubits = 3 -circuit = create_ising_circuit(L=num_qubits, J=1.0, g=0.5, dt=0.1, timesteps=5) +:::{dropdown} Construct adjacent and long-range two-site processes +For adjacent sites, supply a `matrix` acting on their joint basis. Use ascending +site order: the first tensor factor acts on the first listed site. If a matrix +was written for descending sites, swap both its input and output tensor-factor +axes as well as reversing the site list. -circuit_noise = NoiseModel([ +```python +adjacent_noise = NoiseModel([ { - "name": "pauli_x", - "sites": [i], - "strength": {"distribution": "lognormal", "mean": -3.0, "std": 0.4}, - } - for i in range(num_qubits) + "name": "joint_relaxation", + "sites": [0, 1], + "strength": 0.05, + "matrix": np.kron(sigma_minus, sigma_minus), + }, ]) - -circuit_params = DigitalSimParams( - observables=[Observable("z", site) for site in range(num_qubits)], - num_traj=32, - max_bond_dim=8, - random_seed=11, -) - -circuit_result = sim.run(State(num_qubits, initial="zeros"), circuit, circuit_params, circuit_noise) ``` -## 5. Long-range crosstalk +For non-adjacent sites, the names `"crosstalk_xy"` and +`"longrange_crosstalk_xy"` both construct the factors $X$ and $Y$. Any pair of +`x`, `y`, and `z` is accepted: -Non-adjacent pairs use the exact `longrange_crosstalk_[xyz]{2}` naming -convention (e.g. `longrange_crosstalk_xy`); YAQS attaches per-site Pauli factors -automatically. The same physics works on analog MPS TJM, MCWF, and Lindblad. -Digital TJM rejects non-adjacent / factorized two-site noise up front -(gate-local scoping remains nearest-neighbor only). - -```{code-cell} ipython3 -lr_model = NoiseModel([ - {"name": "longrange_crosstalk_xy", "sites": [0, 2], "strength": 0.05}, +```python +long_range_pauli = NoiseModel([ + {"name": "longrange_crosstalk_xy", "sites": [0, 3], "strength": 0.05}, ]) -sampled = lr_model.sample(rng=0) ``` -## 6. Custom jump operators - -Every noise process is a dictionary. Besides the built-in -{class}`~mqt.yaqs.core.libraries.noise_library.NoiseLibrary` names (`lowering`, -`pauli_x`, `crosstalk_xx`, …), you can supply your own operator as a NumPy -array: +For a custom non-adjacent process, supply two local `factors` in the order of +the listed sites. This lowering-and-dephasing product requires an analog vector +or density-matrix simulation: -| Key | Required | Description | -| ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | yes | Nonempty label. When `matrix`/`factors` are omitted, must be a library name, `x`/`y`/`z`, or exact `crosstalk_[xyz]{2}` / `longrange_crosstalk_[xyz]{2}`. | -| `sites` | yes | List/tuple of exactly one or two distinct nonnegative site indices. | -| `strength` | yes | Nonnegative finite rate $\gamma$, or a distribution dict (`normal` / `lognormal` / `truncated_normal`). | -| `matrix` | no | Square finite local operator $L$ (`d×d`, or `d_i d_j` for adjacent two-site). Ascending sites required for custom two-site matrices. | -| `factors` | no | Exactly two square one-site operators for non-adjacent pairs. | +```python +long_range_custom = NoiseModel([ + { + "name": "correlated_loss", + "sites": [0, 3], + "strength": 0.05, + "factors": (sigma_minus, np.diag([1, -1])), + }, +]) +``` -YAQS does not check complete positivity; supply physically meaningful jump -operators. The same `matrix` override works for **scheduled jumps** (see -{doc}`scheduled_jumps`) on supported backends and for process noise on TJM -(`mps`), MCWF (`vector`), Lindblad (`density_matrix`), and supported Simulator -digital-circuit runs. +YAQS sorts the sites and reorders the factors together. Use `matrix` for +adjacent sites and `factors` for non-adjacent sites; do not provide both. +::: -### Amplitude damping with an explicit $\sigma_-$ +(noise-scheduled-jumps)= -The built-in `lowering` operator is $\sigma_- = |0\rangle\langle 1|$. You can -pass the same matrix explicitly and mix custom and library processes in one -model: +## Apply a scheduled jump -```{code-cell} ipython3 -import numpy as np +Use `scheduled_jumps` for an operator applied at a specified analog time. Each +entry gives a `time`, `sites`, and a library `name`, or a custom `matrix` with +an identifying name. Scheduled events have no `strength`: the operator is +applied when the simulation reaches its time. -sigma_minus = np.array([[0, 1], [0, 0]], dtype=complex) +This four-site example isolates the event by setting the Hamiltonian to zero. An +$X$ flip at $t=0.1$ changes $\langle Z_0\rangle$ from $+1$ to $-1$: -custom_model = NoiseModel([ - {"name": "t1_explicit", "sites": [0], "strength": 0.1, "matrix": sigma_minus}, - {"name": "pauli_z", "sites": [1], "strength": 0.05}, +```{code-cell} python +scheduled_noise = NoiseModel(scheduled_jumps=[ + {"time": 0.1, "sites": [0], "name": "x"}, ]) +jump_state = State(length, initial="zeros") +zero_hamiltonian = Hamiltonian.ising(length, J=0.0, g=0.0) +jump_params = AnalogSimParams( + observables=[Observable("z", 0)], + elapsed_time=0.2, + dt=0.05, + num_traj=1, + order=1, +) +jump_result = sim.run(jump_state, zero_hamiltonian, jump_params, noise_model=scheduled_noise) +print(jump_result.expectation_values[0]) ``` -Run a short analog simulation using the custom operator: - -```{code-cell} ipython3 -from mqt.yaqs import AnalogSimParams, Hamiltonian, Observable, Simulator, State - -L2 = 2 -hamiltonian = Hamiltonian.ising(length=L2, J=1.0, g=0.5) -state = State(L2, initial="basis", basis_string="10") - -sim_params = AnalogSimParams( - observables=[Observable("z", sites=0), Observable("z", sites=1)], - elapsed_time=1.0, - dt=0.1, - num_traj=32, - max_bond_dim=8, - random_seed=3, +The five values are `[1, 1, -1, -1, -1]`, sampled at times +`[0, 0.05, 0.1, 0.15, 0.2]`. Event-only runs are deterministic, so one +trajectory suffices; they can also retain the final MPS with `get_state=True`. + +Scheduled jumps require a single MPS `State` and `AnalogSimParams(order=1)`. +MCWF, Lindblad, order-2 TJM, circuit runs, and `list[State]` ensembles do not +support them. Event times must lie on the simulation time grid, including its +endpoints. Two-site events must act on adjacent sites; custom matrices require +ascending site order. + +:::{important} +At a matching time after zero, scheduled operators replace the ordinary +stochastic-jump draw for that step; process dissipation still runs. For control +pulses that also sample stochastic noise at pulse times, use an +{doc}`analog-digital program `. +::: + +:::{dropdown} Custom scheduled operators and event order +Add `matrix` to supply a custom operator. For example, this schedules a $\pi/2$ +rotation about $Y$: + +```python +ry_pi2 = np.array([[1, -1], [1, 1]], dtype=complex) / np.sqrt(2) +custom_event = NoiseModel( + scheduled_jumps=[ + {"time": 0.1, "sites": [0], "name": "ry_pi2", "matrix": ry_pi2}, + ] ) - -result = Simulator(show_progress=False).run(state, hamiltonian, sim_params, custom_model) ``` -For $d>2$ local Hilbert spaces (e.g. transmon leakage), pass a `d×d` `matrix` -matching the site's physical dimension—see {doc}`transmon_emulation`. - -## Related topics - -- {doc}`analog_simulation` — TJM workflow with static noise strengths -- {doc}`circuit_observables` — digital circuit observables and mid-circuit - sampling -- {doc}`scheduled_jumps` — deterministic jumps at fixed times (library or custom - `matrix`) -- {doc}`representation_comparison` — MCWF and Lindblad backends with the same - `NoiseModel` -- {doc}`simulation_parameters` — presets and `random_seed` for reproducible - trajectories -- {doc}`quickstart` — minimal first simulation +Matrices must be finite, square, and match the selected sites. Scheduled events +accept `matrix`, not long-range `factors`. Operators need not be unitary; YAQS +normalizes the state after the events and rejects a zero or nonfinite norm. A +non-unitary scheduled operator is a prescribed normalized state update, not a +randomly sampled Lindblad channel. + +At time zero, events act before dissipation and the initial measurement. At +later grid points, including the final time, the order is Hamiltonian evolution, +process dissipation, all matching scheduled operators, normalization, then +measurement. Events at the same time follow their order in `scheduled_jumps`. +Inside a `SimulationProgram`, event times use the local clock of an analog run; +see {doc}`digital_analog_simulation` for segment boundaries and noise overrides. +::: + +## Next steps + +Use {doc}`analog_simulation` or {doc}`circuit_observables` to see how noise +changes measured dynamics. The device guides show how to combine noise with +{doc}`transmon_emulation` and {doc}`trapped_ion`. For deterministic control +sequences that combine gates with analog evolution, see +{doc}`digital_analog_simulation`. diff --git a/docs/examples/representation_comparison.md b/docs/examples/representation_comparison.md index 713be15bd..ff7b49931 100644 --- a/docs/examples/representation_comparison.md +++ b/docs/examples/representation_comparison.md @@ -2,143 +2,287 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true - execution_timeout: 300 + execution_timeout: 120 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` +# State Representations + +The choice of state representation determines which solver YAQS uses and how +large a system you can study. Matrix product states keep large simulations +manageable when entanglement remains limited. Dense vectors and density matrices +provide useful small-system references, with different costs for noisy dynamics. + +Choose the representation when constructing a {class}`~mqt.yaqs.State`. YAQS +selects the solver from that choice; it does not switch representations when the +calculation becomes expensive. -# Representation Comparison +## Choose a representation and solver -YAQS supports multiple state **representations** for analog evolution. Each path -targets a different scaling regime; the table below summarizes when each is -appropriate. +| `State` representation | Analog solver | When to use it | +| ---------------------- | ---------------------------------------- | ------------------------------------------------------------------------------ | +| `"mps"` (default) | Tensor jump method (TJM). | Large chains with manageable entanglement, and circuit simulation. | +| `"vector"` | Monte Carlo wave-function method (MCWF). | Small systems where you want pure-state trajectories without MPS compression. | +| `"density_matrix"` | Lindblad master equation. | Small open systems, mixed initial states, and deterministic ensemble averages. | -For how to set `representation` on -{class}`~mqt.yaqs.core.data_structures.state.State`, see -{doc}`state_initialization`. +For presets, use `State(length, initial="zeros", representation="vector")`, for +example. Supplying `vector=`, `density_matrix=`, or `tensors=` selects the +matching representation automatically. See {doc}`state_initialization` for state +preparation and local dimensions. -## Choosing a representation +With noise, TJM and MCWF evolve independent pure-state trajectories and average +their observables. Lindblad evolution propagates the ensemble density matrix +directly. Without noise, the trajectory solvers evolve a single pure state; +Lindblad evolution still carries a density matrix. -| Path | When to use | Notes | -| ------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `"mps"` (default) | Larger systems and tensor-network-friendly Hamiltonians | TJM trajectories; tune `num_traj`, `max_bond_dim`, and {doc}`accuracy presets ` | -| `"vector"` | MCWF / state-vector quantum trajectories | Exponential memory in qubits; single-trajectory wavefunction dynamics | -| `"density_matrix"` | Lindblad master-equation evolution | Exponential memory; deterministic ensemble average without trajectory sampling | +## How state storage scales -Practical guidance: +Let $L$ be the number of sites, $d$ their common local dimension, and $D=d^L$ +the full Hilbert-space dimension. For qubits, $d=2$. An MPS stores local tensors +joined by bonds; the maximum bond dimension $\chi$ controls how much +entanglement it can represent. -- Start with `preset="balanced"` (or `"fast"` while exploring) on - {class}`~mqt.yaqs.core.data_structures.simulation_parameters.AnalogSimParams` - and increase `num_traj` until observables stabilize. -- Tighten `max_bond_dim` / `svd_threshold` when entanglement growth demands it. -- For trade-offs between unravellings and trajectory cost, see - {cite:p}`sander2026_computationalregimes` ({doc}`references`). +| Representation | Complex numbers in one state | For qubits | +| -------------- | ---------------------------- | ------------------------------------ | +| MPS | $O(Ld\chi^2)$. | Linear in $L$ if $\chi$ stays fixed. | +| Vector | $D$. | $2^L$. | +| Density matrix | $D^2$. | $4^L$. | -The sections below run the **same** noisy benchmark on all three paths so you -can validate agreement on small systems. We use the product state -$|{+}\rangle^{\otimes L}$ to keep the initial condition transparent. An MPS and -its exact `MPS.to_vec()` result always encode the same physical state. +Each additional qubit doubles vector storage and quadruples density-matrix +storage. Increasing an MPS bond dimension by a factor of two can increase its +state storage by about four. For strongly entangled states, the bond dimension +needed for an accurate MPS can itself grow exponentially with system size; +linear scaling at fixed bond dimension is not a guarantee for every physical +problem. See the +[TJM publication](https://www.nature.com/articles/s41467-025-66846-x) for the +trajectory formulation. -## 1. Noisy open-system benchmark +The figure counts `complex128` state arrays, at 16 bytes per entry. The MPS +curves use bond caps of 16 and 64, with each bond also limited by the dimensions +of the two subsystems it separates. These are calculated storage estimates; no +large states are allocated. -```{code-cell} ipython3 +```{code-cell} python +:tags: [hide-input] import matplotlib.pyplot as plt import numpy as np +from matplotlib_inline.backend_inline import set_matplotlib_formats -from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseModel, Observable, Simulator, State +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["STIXGeneral"], + "mathtext.fontset": "stix", + "font.size": 11, + "axes.labelsize": 11, + "axes.linewidth": 0.7, + "xtick.labelsize": 10, + "ytick.labelsize": 10, + "xtick.direction": "in", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "legend.fontsize": 9, + "legend.frameon": False, + "lines.linewidth": 1.8, + "figure.constrained_layout.use": True, + "savefig.dpi": 180, +}) -sim = Simulator(show_progress=False) +qubits = np.arange(4, 41) +vector_bytes = 16 * 2.0**qubits +density_bytes = 16 * 4.0**qubits +mps_bytes = {} +for cap in (16, 64): + sizes = [] + for sites in qubits: + bonds = [min(cap, 2**min(cut, sites - cut)) for cut in range(sites + 1)] + sizes.append(16 * sum(2 * left * right for left, right in zip(bonds[:-1], bonds[1:], strict=True))) + mps_bytes[cap] = np.array(sizes) -L = 3 -H = Hamiltonian.ising(L, J=1.0, g=0.5) -noise = NoiseModel([{"name": "pauli_z", "sites": [i], "strength": 0.2} for i in range(L)]) -obs = Observable("x", sites=[0]) - -init_ref = State(L, initial="x+") -psi0 = init_ref.mps.to_vec() -rho0 = np.outer(psi0, psi0.conj()) -mps_tensors0 = [np.asarray(t, dtype=np.complex128).copy() for t in init_ref.mps.tensors] - -# Doc-build-friendly settings; increase t_max / num_traj for production runs. -t_max = 1.0 -dt = 0.1 -num_traj = 32 -seed = 7 - -params_rho = AnalogSimParams(observables=[obs], elapsed_time=t_max, dt=dt) -result_rho = sim.run(State(density_matrix=rho0), H, params_rho, noise) -res_rho = result_rho.expectation_values[0].flatten() -times = params_rho.times - -params_vector = AnalogSimParams( - observables=[obs], elapsed_time=t_max, dt=dt, num_traj=num_traj, random_seed=seed, -) -result_vector = sim.run(State(vector=psi0), H, params_vector, noise) -res_vector = result_vector.expectation_values[0].flatten() +fig, ax = plt.subplots(figsize=(6.6, 3.6)) +ax.semilogy(qubits, density_bytes, color="0.2", label=r"Density matrix: $4^L$") +ax.semilogy(qubits, vector_bytes, color="#D55E00", label=r"Vector: $2^L$") +ax.semilogy(qubits, mps_bytes[64], color="#0072B2", label=r"MPS: $\chi\leq64$") +ax.semilogy(qubits, mps_bytes[16], "--", color="#56B4E9", label=r"MPS: $\chi\leq16$") +ax.axhline(2.0**30, color="0.6", linewidth=0.8, linestyle=":") +ax.text(39, 2.0**30 * 1.6, "1 GiB reference", ha="right", fontsize=9, color="0.35") +ax.set(xlabel=r"Number of qubits $L$", ylabel="State storage", xlim=(4, 40), ylim=(2.0**8, 2.0**64)) +ax.set_xticks([4, 10, 20, 30, 40]) +ax.set_yticks([2.0**10, 2.0**20, 2.0**30, 2.0**40, 2.0**50, 2.0**60], + labels=["1 KiB", "1 MiB", "1 GiB", "1 TiB", "1 PiB", "1 EiB"]) +ax.legend(loc="lower left", bbox_to_anchor=(0, 1.02), ncols=2, borderaxespad=0) +ax.grid(axis="y", alpha=0.15) +plt.show() +``` + +The plot shows one state's arrays, not peak solver memory or a practical qubit +limit. The density-matrix curve continues above the plotted range. Hamiltonians, +jump operators, temporary arrays, worker copies, and saved observable +trajectories also use memory. In particular, a cached propagator can be much +larger than the state it evolves. + +## How solver work scales + +For an MPS with fixed local dimension, Hamiltonian MPO bond dimension, and local +solver effort, a TDVP sweep costs roughly $O(L\chi^3)$. The MPO bond dimension +measures the size of the Hamiltonian's tensor-network representation. More +complex interactions and growing state bonds increase the work. This estimate +describes local TDVP sweeps, not every supported integrator or noise pattern. + +Dense solvers have two regimes in the current implementation: + +| Solver | Small-system method | Method above the cache threshold | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| Vector / MCWF | Build a dense $D\times D$ step propagator: roughly $O(D^3)$ setup, $O(D^2)$ storage and work per step. | Apply the exponential through sparse Krylov methods; work depends on operator nonzeros and iteration count. | +| Density matrix / Lindblad | Build a dense $D^2\times D^2$ generator and step propagator: roughly $O(D^6)$ exponential setup, $O(D^4)$ storage and work per step. | Integrate the matrix master equation with adaptive RK45, without storing the full generator. | + +For a fixed number of Krylov iterations and short-range sparse operators, vector +evolution typically needs order $LD$ work per exponential action. For a +Hamiltonian with order $LD$ nonzeros and $K$ local jump channels, one Lindblad +derivative evaluation costs order $(L+K)D^2$. The number of adaptive steps +depends on the dynamics and tolerances, so these estimates do not give a single +runtime law for every problem. + +:::{dropdown} Propagator thresholds and other memory costs +The vector solver caches its dense step propagator when `D <= 4096` (up to 12 +qubits). The density-matrix solver builds a dense generator and caches its step +propagator when `D**2 <= 4096` (up to 6 qubits). At either upper threshold, one +propagator alone occupies 256 MiB in `complex128`; preprocessing needs +additional arrays. These are implementation thresholds, not recommended system +sizes or user memory limits. + +MPS evolution also stores the Hamiltonian MPO and contraction environments. For +uniform dimensions and an MPO bond dimension $w$, their typical storage is +$O(Ld^2w^2)$ and $O(Lw\chi^2)$, respectively. Intermediate bonds can exceed the +final retained bonds during an update. + +For unequal local dimensions, replace $D=d^L$ with $D=\prod_i d_i$. The exact +MPS state entry count is $\sum_i d_i\chi_i\chi_{i+1}$, where the end bonds have +dimension one. Local dimensions above two can raise costs sharply even when the +number of sites stays fixed. +::: + +### Trajectories, time steps, and parallelism -params_mps = AnalogSimParams( - observables=[obs], elapsed_time=t_max, dt=dt, num_traj=num_traj, max_bond_dim=16, random_seed=seed, +Noisy TJM and MCWF work grows approximately in proportion to `num_traj` and the +number of evolution steps, at fixed numerical settings. Standard Monte Carlo +error decreases as $1/\sqrt{N_{\mathrm{traj}}}$: halving that error generally +requires four times as many trajectories. Increasing the trajectory count does +not remove timestep or MPS approximation errors. + +Lindblad evolution needs one deterministic run, so increasing `num_traj` has no +effect. Parallel workers can reduce the wall time of trajectory ensembles, but +they do not reduce the total numerical work and can increase memory use. There +is no universal fastest representation; the balance depends on the model, +accuracy, and hardware. The +[computational-regimes study](https://arxiv.org/abs/2606.13779) compares +trajectory costs and sampling effort. + +## Compare the same noisy dynamics + +A four-site version of the XY chain in {doc}`analog_simulation` provides a small +comparison. One excitation starts at site 1, moves between neighbors, and can be +lost through local relaxation. We measure the occupation of its starting site, +$n_1=(1-Z_1)/2$, using identical physical inputs for all three solvers. + +```{code-cell} python +from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseModel, Observable, Simulator, State + +length = 4 +initial_site = 1 +basis = "0" * initial_site + "1" + "0" * (length - initial_site - 1) +hamiltonian = Hamiltonian.heisenberg(length, Jx=0.5, Jy=0.5, Jz=0.0) +noise = NoiseModel([ + {"name": "lowering", "sites": [site], "strength": 0.6} + for site in range(length) +]) +params = AnalogSimParams( + observables=[Observable("z", initial_site)], + elapsed_time=1.0, + dt=0.05, + num_traj=64, + random_seed=7, ) -result_mps = sim.run(State(L, tensors=[t.copy() for t in mps_tensors0]), H, params_mps, noise) -res_mps = result_mps.expectation_values[0].flatten() +sim = Simulator(show_progress=False) ``` -```{code-cell} ipython3 ---- -mystnb: - image: - width: 80% - align: center ---- -fig, ax = plt.subplots(figsize=(6, 3.5), layout="constrained") -ax.plot(times, res_rho, label="density_matrix (exact)", linewidth=2, color="black") -ax.plot(times, res_vector, label=f"vector ({num_traj} traj)", linestyle="--") -ax.plot(times, res_mps, label=f"mps ({num_traj} traj)", linestyle=":") -ax.set_xlabel("Time") -ax.set_ylabel(r"$\langle X_0 \rangle$") -ax.set_ylim(-1.05, 1.05) -ax.legend() -ax.set_title(r"Open-system evolution across representations ($|+\rangle^{\otimes L}$ init)") -ax.grid(alpha=0.3) -plt.show() -``` +Only the `State` representation changes between runs. Product-state presets +create the same initial physical state directly in each representation, so no +manual tensor copying or dense conversion is needed: + +```{code-cell} python +results = {} +for representation in ("density_matrix", "vector", "mps"): + state = State(length, initial="basis", basis_string=basis, representation=representation) + results[representation] = sim.run(state, hamiltonian, params, noise_model=noise) -```{note} -`vector` and `mps` curves are Monte Carlo means over `num_traj` trajectories; -statistical error scales as $1/\sqrt{N_{\mathrm{traj}}}$. The `density_matrix` -path returns the deterministic ensemble average directly. Increase `num_traj` -and `t_max` for smoother curves. +times = results["density_matrix"].times ``` -## 2. Noiseless cross-check +Parallel execution remains enabled, and the documentation hides progress bars. +Run the cells in order in a notebook; see {doc}`simulator_initialization` for +the main guard required in a script. The MPS and vector results each store an +array of means in `expectation_values[0]` and an array of shape `(64, 21)` in +`trajectories[0]`. The density-matrix result has a single deterministic row. -With `noise_model=None`, all three representations should agree on unitary -observables (single trajectory for `mps` and `vector`). We use a product state -here so the MPS path is exact at modest bond dimension. +```{code-cell} python +:tags: [hide-input] +fig, ax = plt.subplots(figsize=(6.6, 3.2)) +reference = (1 - results["density_matrix"].expectation_values[0]) / 2 +ax.plot(times, reference, color="0.2", label="Lindblad reference") +for representation, color, marker, style, offset, label in ( + ("mps", "#0072B2", "o", "-", 0, "TJM / MPS"), + ("vector", "#D55E00", "s", "--", 1, "MCWF / vector"), +): + result = results[representation] + mean = (1 - result.expectation_values[0]) / 2 + samples = (1 - result.trajectories[0]) / 2 + standard_error = samples.std(axis=0, ddof=1) / np.sqrt(samples.shape[0]) + ax.fill_between(times, mean - standard_error, mean + standard_error, color=color, alpha=0.14, linewidth=0) + ax.plot(times, mean, color=color, marker=marker, linestyle=style, + markevery=(offset, 2), markersize=3.2, markerfacecolor="white", + linewidth=1.2, label=label) +ax.set(xlabel=r"Time $t$", ylabel=r"Occupation $\langle n_1\rangle$", xlim=(0, 1), ylim=(-0.03, 1.04)) +ax.legend(loc="upper right") +plt.show() +``` -```{code-cell} ipython3 -obs_z = Observable("z", sites=[0]) -params_mps_u = AnalogSimParams(observables=[obs_z], elapsed_time=0.5, dt=0.1, max_bond_dim=16) -params_rho_u = AnalogSimParams(observables=[obs_z], elapsed_time=0.5, dt=0.1) +The lines show excitation transport combined with loss. Shaded bands give one +estimated standard error of the trajectory means at each time; they are not +bounds on the numerical error or simultaneous confidence bands. Sixty-four +trajectories illustrate statistical variation, not a converged benchmark or a +runtime comparison. The shared seed can correlate the two trajectory estimates. +Lindblad supplies a deterministic numerical reference, not an error-free +solution. -init_product = State(L, initial="x+") -psi_prod = init_product.mps.to_vec() -rho_prod = np.outer(psi_prod, psi_prod.conj()) -mps_prod = [np.asarray(t, dtype=np.complex128).copy() for t in init_product.mps.tensors] +## Check accuracy and supported workflows -z_mps = sim.run(State(L, tensors=mps_prod), H, params_mps_u, None).expectation_values[0][-1] -z_vec = sim.run(State(vector=psi_prod), H, params_mps_u, None).expectation_values[0][-1] -z_rho = sim.run(State(density_matrix=rho_prod), H, params_rho_u, None).expectation_values[0][-1] -``` +For noisy MPS and vector results, increase `num_traj` to test sampling error, +then reduce `dt` to check time resolution. For MPS, also increase `max_bond_dim` +and reduce `svd_threshold` to check compression. A product initial state can +become entangled during evolution; its initial simplicity does not make the +later MPS approximation exact. Use {doc}`simulation_parameters` for presets and +solver-specific controls. + +| Workflow or input | Supported representations | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Static-Hamiltonian analog evolution | MPS, vector, and density matrix; noise must meet the restrictions in {doc}`realistic_noise_models`. | +| Circuits and analog-digital programs | MPS. | +| Mixed initial state | Density matrix. | +| Bitstring-probability observables | MPS with qubits at every site. | +| Entropy and Schmidt-spectrum observables | MPS; noisy results describe pure trajectories, not the spectrum of a mixed density matrix. | +| Piecewise Hamiltonian | MPS with TDVP; see {doc}`hamiltonians`. | +| Unitary `list[State]` ensemble | MPS; see {doc}`ensemble_evolution`. | -## Related topics +Noisy MPS, vector, and circuit runs do not return one final pure state for the +trajectory ensemble. Noisy density-matrix evolution can retain its final mixed +state with `get_state=True`. -- {doc}`analog_simulation` — TJM workflow with MPS -- {doc}`state_initialization` — choosing a representation -- {doc}`simulation_parameters` — presets, `num_traj`, and truncation -- {doc}`quickstart` — minimal first simulation +Choose MPS when the required bond dimensions remain affordable, vector when a +full pure state fits and provides a useful reference, and density matrix when +you need a small-system ensemble average or mixed-state evolution. Check +convergence for the quantities you intend to report before scaling up. diff --git a/docs/examples/scheduled_jumps.md b/docs/examples/scheduled_jumps.md index e5c58a283..c6988b375 100644 --- a/docs/examples/scheduled_jumps.md +++ b/docs/examples/scheduled_jumps.md @@ -1,178 +1,8 @@ --- -file_format: mystnb -kernelspec: - name: python3 -mystnb: - number_source_lines: true - execution_timeout: 300 +orphan: true --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Scheduled Jumps -This example demonstrates how to use **scheduled noise jumps** in YAQS. -Scheduled jumps allow you to apply specific operators at predetermined times -during an analog simulation. This is useful for simulating controlled gates, -sudden noise events, or time-dependent perturbations without needing a full -time-dependent Hamiltonian. - -In this example, we simulate a 10-site Ising chain and apply a scheduled Pauli-X -flip to a specific site at $t=1.0$. - -```{important} -Scheduled jumps are supported only for **single-`State` analog MPS TJM** with -{class}`~mqt.yaqs.AnalogSimParams` ``order=1`` (the default). They are rejected -for ``order=2``, MCWF, Lindblad, digital circuits, and `list[State]` unitary -ensembles. Jump times must lie on the simulation time grid (`sim_params.times`; -choose `time` as a multiple of `dt`). Two-site scheduled jumps must be -nearest-neighbor. A jump at ``time=0.0`` is applied to the initial state -**before** dissipation and the first measurement, so the recorded initial -observable (and ``get_state``) already include it. For interior timesteps, on a -matching grid point scheduled jumps replace the ordinary stochastic jump channel -for that step (dissipation still runs). -``` - -## 1. Setup - -First, we define the Hamiltonian and the initial state. We'll use a standard -transverse-field Ising model. - -```{code-cell} ipython3 -from mqt.yaqs import Hamiltonian, State - -L = 10 -J = 1.0 -g = 1.0 - -# Hamiltonian: H = -J Σ Z_i Z_{i+1} - g Σ X_i -hamiltonian = Hamiltonian.ising(length=L, J=J, g=g) - -# Initial state: all zeros |00...0> -state = State(L, initial="zeros") -``` - -## 2. Define the Scheduled Jump - -We define a scheduled jump using a list of dictionaries in the `NoiseModel`. -Each dictionary must specify: - -- `time`: The time at which to apply the jump. -- `sites`: A list of site indices the jump acts on. -- `name`: The name of the jump operator (e.g., `"x"`, `"y"`, `"z"`, - `"crosstalk_xx"`), **or** any label when you pass a custom `matrix` (see - below). - -If `matrix` is omitted, `name` is resolved from -{class}`~mqt.yaqs.core.libraries.noise_library.NoiseLibrary`. To apply a custom -operator, add a `matrix` key with a local `d×d` NumPy array (`d=2` for qubits); -`name` is then only an identifier. See {doc}`realistic_noise_models` § 6 for the -full process-dict schema. - -```{code-cell} ipython3 -from mqt.yaqs import NoiseModel - -jump_time = 1.0 -jump_site = 5 # Apply jump to the middle site - -# Schedule a Pauli-X flip on site 5 at t=1.0 -scheduled_jumps = [{"time": jump_time, "sites": [jump_site], "name": "x"}] -noise_model = NoiseModel(scheduled_jumps=scheduled_jumps) -``` - -### Custom operator example - -A $\pi/2 rotation about $Y$ can be scheduled explicitly instead of using a -library name: - -```{code-cell} ipython3 -import numpy as np - -ry_pi2 = np.array([[1, -1], [1, 1]], dtype=complex) / np.sqrt(2) - -custom_jump = [{"time": jump_time, "sites": [jump_site], "name": "ry_pi2", "matrix": ry_pi2}] -custom_noise_model = NoiseModel(scheduled_jumps=custom_jump) -``` - -## 3. Simulation Parameters - -We measure the $Z$ expectation value on the **jumped site** -($\langle Z_5 \rangle$). A Pauli-X jump flips that site's magnetization; -measuring a distant site would show only a weak entanglement signal and can look -like no jump occurred. - -```{code-cell} ipython3 -from mqt.yaqs import AnalogSimParams, Observable - -z_obs = Observable("z", sites=jump_site) - -sim_params = AnalogSimParams( - elapsed_time=5.0, - dt=0.1, - num_traj=1, # Jumps are deterministic, so 1 trajectory is sufficient - observables=[z_obs], -) -``` - -## 4. Run Simulation - -We run two simulations: one with the jump and a baseline without it. - -```{code-cell} ipython3 -from mqt.yaqs import Simulator -import copy - -sim = Simulator(show_progress=False) - -# Baseline -state_baseline = copy.deepcopy(state) -sim_params_baseline = copy.deepcopy(sim_params) -result_baseline = sim.run(state_baseline, hamiltonian, sim_params_baseline) - -# With Jump -state_jump = copy.deepcopy(state) -sim_params_jump = copy.deepcopy(sim_params) -result_jump = sim.run(state_jump, hamiltonian, sim_params_jump, noise_model=noise_model) -``` - -## 5. Visualize Results - -We plot the expectation value $\langle Z_{\text{jump site}} \rangle$ over time. - -```{code-cell} ipython3 ---- -mystnb: - image: - width: 80% - align: center ---- -import matplotlib.pyplot as plt - -times = sim_params_jump.times -res_baseline = result_baseline.expectation_values[0] -res_jump = result_jump.expectation_values[0] - -plt.figure(figsize=(8, 5)) -plt.plot(times, res_baseline, label="Baseline (No Jump)", color="black", linestyle="--") -plt.plot(times, res_jump, label=f"Jump on site {jump_site}", color="tab:blue") -plt.axvline(x=jump_time, color='red', linestyle=':', label="Jump Time") - -plt.xlabel("Time (t)") -plt.ylabel(f"$\\langle Z_{{{jump_site}}} \\rangle$") -plt.title(f"Effect of a Scheduled Jump at $t={jump_time}$ on site {jump_site}") -plt.legend() -plt.grid(True, alpha=0.3) -plt.show() -``` - -## Related topics - -- {doc}`analog_simulation` — TJM workflow and noise models -- {doc}`digital_analog_simulation` — scheduled jumps inside a mixed program - (times are local to each analog run) -- {doc}`realistic_noise_models` — built-in and custom jump operators, - distributed strengths -- {doc}`simulation_parameters` — time grids and `dt` alignment +See {ref}`noise-scheduled-jumps` in {doc}`realistic_noise_models` for fixed-time +operators, custom matrices, and the supported backends and time grids. diff --git a/docs/examples/simulation_parameters.md b/docs/examples/simulation_parameters.md index 375d730d1..ae2bd7fb1 100644 --- a/docs/examples/simulation_parameters.md +++ b/docs/examples/simulation_parameters.md @@ -2,6 +2,8 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true execution_timeout: 300 @@ -9,355 +11,263 @@ mystnb: # Configuring Simulation Parameters -YAQS separates **what you evolve** ({class}`~mqt.yaqs.State`, circuits, -Hamiltonians) from **how you truncate and sample** via parameter objects passed -to {meth}`~mqt.yaqs.Simulator.run`: - -| Class | Use when | -| ----------------------------------- | -------------------------------------------------------------------------------------------- | -| {class}`~mqt.yaqs.AnalogSimParams` | Open-system or unitary time evolution (TDVP / BUG, MCWF trajectories, Lindblad-style paths). | -| {class}`~mqt.yaqs.DigitalSimParams` | Circuit simulation: observables, computational-basis shots, or both. | - -This page shows how to construct each class. For {class}`~mqt.yaqs.Simulator` -execution options (parallelism, progress bars), see -{doc}`simulator_initialization`. - -## Observable string names - -{class}`~mqt.yaqs.Observable` accepts a named Hermitian operator as its first -argument. YAQS resolves the name internally, so standard measurements do not -depend on gate classes. - -| String | Meaning | Example | -| ------------------------ | -------------------------------------------------------------- | ------------------------------------------- | -| `"x"`, `"y"`, `"z"` | Single-qubit Pauli operators | `Observable("z", sites=0)` | -| `"id"` | Single-site identity operator | `Observable("id", sites=0)` | -| `"p0"`, `"p1"` | Single-site computational-basis projectors | `Observable("p0", sites=0)` | -| `"xx"`, `"yy"`, `"zz"` | Two-qubit Pauli strings | `Observable("zz", sites=[0, 1])` | -| `"position"` | Position operator for a supplied local position basis | `Observable("position", 0, positions=grid)` | -| `"entropy"` | Bipartite entanglement entropy across a cut | `Observable("entropy", sites=cut)` | -| `"schmidt_spectrum"` | Schmidt spectrum across a cut | `Observable("schmidt_spectrum", sites=cut)` | -| binary bitstring | Projection-valued measurement onto a computational basis state | see {doc}`circuit_observables` | - -Observable matrices must be Hermitian. For custom unitaries and circuit gates, -use {doc}`custom_gates`; those workflows use `GateLibrary` or Qiskit circuits -directly. - -Named observables that require configuration accept keyword-only factory -arguments. Missing or unknown arguments raise `TypeError`, so misspelled -parameters are not silently ignored. - -## Start with a preset - -You do **not** need to tune every numerical knob before running a simulation. -Pick a **preset** and let it fill in the truncation and sampling settings you -may be unfamiliar with (`svd_threshold`, `max_bond_dim`, `num_traj` on -analog/digital-observable runs, and `krylov_tol`). - -All `*SimParams` classes accept a keyword-only `preset` argument (default -`"balanced"`): - -| `preset` | `svd_threshold` | `max_bond_dim` | `num_traj` (analog / digital observables) | `krylov_tol` | -| ---------------------- | --------------- | -------------- | ----------------------------------------- | ------------ | -| `"fast"` | `1e-3` | `16` | `128` | `1e-3` | -| `"balanced"` (default) | `1e-6` | `128` | `256` | `1e-4` | -| `"accurate"` | `1e-9` | `4096` | `1024` | `1e-6` | -| `"exact"` | `1e-13` | `None` | `1024` | `1e-12` | - -- **`"fast"`** — qualitative exploration and quick tests; not intended for - strict dense comparisons. -- **`"balanced"`** — recommended default for exploratory work. -- **`"accurate"`** — high-quality production settings. -- **`"exact"`** — strict reference/debug preset with minimal internal numerical - relaxation. Stochastic trajectory sampling, finite time steps, and model error - still apply; this is not mathematically exact. - -`svd_threshold` controls **tensor-network SVD truncation** (bond truncation). -`krylov_tol` controls the **adaptive Krylov/Lanczos matrix exponential** inside -TDVP updates. These are independent: tightening one does not change the other. -`trunc_mode` (default `"discarded_weight"`) is unchanged across presets. The -chosen preset name is stored on the object as `params.preset`. - -For analog BUG evolution, set `evolution_mode=EvolutionMode.BUG` (exported from -`mqt.yaqs`). BUG uses center-augmented alternating endpoints with one -compression and renormalization after each `dt` step; see -{doc}`analog_simulation`. - -## Override only what you need - -**Explicit constructor arguments override the preset; everything you omit keeps -the preset value.** - -That is the intended workflow when you know _some_ settings but not all: - -1. Choose the closest preset (`"fast"`, `"balanced"`, `"accurate"`, or - `"exact"`). -2. Pass **only** the fields you want to change. -3. Leave the rest unset — they stay at the preset defaults. - -Overridable preset fields: - -| Argument | What it controls | -| --------------- | -------------------------------------------------- | -| `svd_threshold` | SVD bond truncation during MPS/MPO updates | -| `max_bond_dim` | Hard cap on bond dimension (`None` = no cap) | -| `num_traj` | Trajectory count (analog / digital observables) | -| `krylov_tol` | Adaptive Krylov/Lanczos matrix exponential in TDVP | - -Optional `shots` on `DigitalSimParams` is set explicitly and is **not** part of -any preset. - -If you omit an overridable argument, the preset supplies it. If you pass a value -explicitly, **that value wins** for that field only — the other preset fields -are unchanged. For `max_bond_dim`, omit the argument to keep the preset cap; -pass `None` explicitly to remove the cap. - -## Recommended usage - -```{code-cell} ipython3 -from mqt.yaqs import ( - SIMULATION_PRESETS, - AnalogSimParams, - DigitalSimParams, - Observable, -) +Simulation parameters choose what to measure, when to record it, and the +numerical accuracy. Start with a preset, then change the settings that matter +for your calculation. +| Class | Use for | +| ------------------ | ---------------------------------------------------------------------- | +| `AnalogSimParams` | Hamiltonian evolution, including noisy dynamics and unitary ensembles. | +| `DigitalSimParams` | Circuit expectation values, shot counts, or a final state. | -def _trunc_summary(params: AnalogSimParams | DigitalSimParams) -> dict[str, object]: - """Collect preset-related fields for display.""" - out: dict[str, object] = { - "preset": params.preset, - "svd_threshold": params.svd_threshold, - "max_bond_dim": params.max_bond_dim, - "krylov_tol": params.krylov_tol, - } - if isinstance(params, DigitalSimParams) and params.shots is not None: - out["shots"] = params.shots - out["num_traj"] = params.num_traj - return out -``` +Pass the parameter object to `Simulator.run` with the initial state and +Hamiltonian or circuit. Supply noise through `noise_model` in that call. The +state representation selects the analog backend; see +{doc}`representation_comparison`. Parallel execution and progress controls +belong to `Simulator`, as described in {doc}`simulator_initialization`. -Pick a preset — no other truncation arguments required: +## Choose a preset -```{code-cell} ipython3 -# Default: balanced preset fills in all truncation settings -analog_params = AnalogSimParams() +Both classes default to `preset="balanced"`. A preset supplies four settings: -for name in ("fast", "balanced", "accurate", "exact"): - _trunc_summary(AnalogSimParams(preset=name)) -``` +| Preset | `svd_threshold` | `max_bond_dim` | `num_traj` | `krylov_tol` | +| ------------ | --------------- | -------------- | ---------- | ------------ | +| `"fast"` | `1e-3` | `16` | `128` | `1e-3` | +| `"balanced"` | `1e-6` | `128` | `256` | `1e-4` | +| `"accurate"` | `1e-9` | `4096` | `1024` | `1e-6` | +| `"exact"` | `1e-13` | `None` | `1024` | `1e-12` | -Override **one** field; the rest stay from `"balanced"`: +Use `"fast"` to explore a model and `"balanced"` as a starting point for +accuracy checks. `"accurate"` tightens the numerical settings and increases the +trajectory budget, at greater cost. `"exact"` provides strict reference settings +and removes the bond cap; finite timesteps, truncation, and sampling error still +apply. No preset establishes convergence for every model. -```{code-cell} ipython3 -balanced = AnalogSimParams(preset="balanced") -tighter_krylov = AnalogSimParams(preset="balanced", krylov_tol=1e-8) -``` +Explicit constructor arguments replace only the corresponding preset values: -Override **several** fields when you know exactly what you want; the remaining -preset fields still apply: +```{code-cell} python +from mqt.yaqs import AnalogSimParams, DigitalSimParams, Observable -```{code-cell} ipython3 custom_params = AnalogSimParams( - preset="fast", # start from fast defaults for everything else - max_bond_dim=512, - num_traj=32, + preset="accurate", + max_bond_dim=256, + num_traj=64, ) -_trunc_summary(custom_params) ``` -Shot-based circuit simulation: set `shots` yourself, use a preset for -truncation: +Here, `svd_threshold` and `krylov_tol` retain their `"accurate"` values. Omit +`max_bond_dim` to keep the preset cap; pass `None` explicitly to remove it. +Choose the preset when constructing the object. Assigning a new value to +`params.preset` later does not reset the other fields. `shots` is always an +explicit budget and is not part of a preset. -```{code-cell} ipython3 -shot_params = DigitalSimParams( - shots=1024, - preset="fast", +## Set analog measurements and times + +For a four-site chain, record the local Pauli $Z$ expectations over two time +units: + +```{code-cell} python +analog_params = AnalogSimParams( + observables=[Observable("z", site) for site in range(4)], + elapsed_time=2.0, + dt=0.05, ) -_trunc_summary(shot_params) ``` -## `AnalogSimParams` +The default `sample_timesteps=True` records 41 samples, including time zero and +the final time. After the run, `result.times` holds the sampled times and +`result.expectation_values[i]` holds the values for the $i$th supplied +observable. Set `sample_timesteps=False` to keep only the final sample; +evolution still uses the same `dt`. -Besides the preset (and any overrides), you typically set the time grid -(`elapsed_time`, `dt`), observables, and whether to record intermediate times -(`sample_timesteps`). Analog backends use a fixed step size, so `dt` must be -positive and `elapsed_time` must be a non-negative integer multiple of `dt`. -Non-integral grids raise a `ValueError`; choose the step count first and set -`elapsed_time = num_steps * dt` when constructing a grid programmatically. +The timestep must be positive, and the duration must be non-negative and an +integer multiple of `dt`. For a computed grid, set +`elapsed_time = num_steps * dt` to keep the duration consistent with the step +count. Invalid grids raise `ValueError`. -```{code-cell} ipython3 -L = 4 -observables = [Observable("z", site) for site in range(L)] +For noisy trajectory evolution, `num_traj` sets the number of realizations to +average. A noiseless single-state run uses one realization. A density-matrix +backend evolves the ensemble directly, while a supplied `list[State]` uses the +list length as its ensemble size. See {doc}`analog_simulation` for a noisy +walkthrough and {doc}`ensemble_evolution` for unitary ensemble averages. -analog = AnalogSimParams( - observables=observables, - elapsed_time=0.2, - dt=0.05, - preset="accurate", +## Choose circuit outputs + +Request observables, shots, or both. At least one output must be requested in a +standalone run; `get_state=True` is another option for supported noiseless runs. + +```{code-cell} python +observable_params = DigitalSimParams(observables=[Observable("z", 0)]) +shot_params = DigitalSimParams(shots=1024) +combined_params = DigitalSimParams( + observables=[Observable("z", 0)], + shots=1024, + num_traj=64, ) -_trunc_summary(analog) ``` -Need a smaller bond cap for a quick test, but keep the rest of `"accurate"`? - -```{code-cell} ipython3 -analog_quick = AnalogSimParams( - observables=observables, - elapsed_time=0.2, +Observables are recorded at the circuit's end by default. With +`sample_layers=True`, YAQS also records the initial state and checkpoints marked +by `circuit.barrier(label="SAMPLE_OBSERVABLES")`. It does not sample every +circuit layer automatically. See {doc}`circuit_observables` for checkpoint +placement and the resulting sample axis. + +`shots` is the total readout budget. `num_traj` controls the noisy observable +ensemble, so the two settings have different roles: + +| Run | How YAQS uses the budgets | +| ----------------------- | ------------------------------------------------------------------------------- | +| Noiseless | Evolve once and sample all requested shots from the final state. | +| Noisy, with observables | Average `num_traj` trajectories. Distribute any requested shots across them. | +| Noisy, shots only | Run one single-shot trajectory per shot; `num_traj` does not control this path. | + +A combined run supports `shots < num_traj`: every trajectory contributes to the +observable mean, while some receive no readout samples. Counts appear in +`result.counts` as integer keys and integer counts. Site 0 is the +least-significant bit, matching Qiskit's `int(bitstring, 2)` convention. See +{doc}`circuit_shots` for histogram plotting. + +## Choose observables and diagnostics + +Named operators avoid imports from gate libraries. The Pauli names below refer +to $\sigma^\alpha$, rather than spin operators $S^\alpha=\sigma^\alpha/2$. + +| Request | Example | +| --------------------------------- | ---------------------------------------------------------------------- | +| Single-site Pauli operator | `Observable("z", 0)`; also `"x"` and `"y"` | +| Identity or basis projector | `Observable("id", 0)`, `Observable("p0", 0)`, or `Observable("p1", 0)` | +| Two-site Pauli operator | `Observable("zz", [0, 1])`; also `"xx"` and `"yy"` | +| Position on a supplied grid | `Observable("position", 0, positions=grid)` | +| Probability of a full basis state | `Observable("0101")` for a four-qubit system | +| Custom Hermitian operator | `Observable(matrix, sites=0)` or `Observable(matrix, sites=[0, 1])` | + +Two-site local operators support adjacent sites and the periodic end-to-end bond +on qubit chains. Matrix factors follow the supplied site order. Bitstring +probabilities require an all-qubit MPS and cannot share an observable list with +ordinary operators or entanglement diagnostics. Shot counts can accompany +ordinary observables. + +For MPS entanglement across the bond between sites 1 and 2, request the adjacent +pair: + +```{code-cell} python +diagnostic_params = AnalogSimParams( + observables=[ + Observable("entropy", sites=[1, 2]), + Observable("schmidt_spectrum", sites=[1, 2]), + ], + elapsed_time=2.0, dt=0.05, - preset="accurate", - max_bond_dim=256, ) -_trunc_summary(analog_quick) ``` -Pass the resulting object to {meth}`~mqt.yaqs.Simulator.run` together with a -{class}`~mqt.yaqs.State` and {class}`~mqt.yaqs.Hamiltonian` (see -{doc}`analog_simulation`). - -## `DigitalSimParams` - -Used for circuit simulation. Set non-empty `observables` for expectation values, -`shots` for computational-basis counts, and/or `get_state` for the final MPS. -Observables and shots may be requested together. Optionally enable layer -sampling with `sample_layers=True` (see {doc}`circuit_observables`). - -`num_traj` and `shots` are **independent** controls: - -| Parameter | Meaning | -| ---------- | ------------------------------------------------------------------------ | -| `num_traj` | Noisy stochastic trajectories for observables and trajectory diagnostics | -| `shots` | Total bitstring-sample budget | - -- **Noisy + observables (+ optional shots):** run `num_traj` trajectories. If - `shots` is also set, that **total** budget is distributed across those - trajectories. `shots < num_traj` is supported: some trajectories still - contribute observables but receive zero measurement samples. -- **Noisy + shots only:** `num_traj` is ignored; one single-shot trajectory is - run per shot, so configuring `num_traj` does not affect this path. -- **Noiseless:** one trajectory is enough; all `shots` are sampled from that - final state. - -### Two-qubit gate mode (`gate_mode`) - -Digital circuit simulation on an MPS defaults to **`gate_mode="mpo"`** (generic -MPO--MPS application): nearest-neighbor gates use the same local TEBD/SVD path -as `swaps` (the orthogonality center is moved onto the gate pair first, so the -truncated SVD discards the smallest Schmidt coefficients of the state), and -long-range gates contract an extended gate MPO site-wise (library leg ordering, -MPS virtual index before MPO virtual index) followed by compression with -`svd_threshold` and `max_bond_dim`. Other modes differ only in how two-qubit -gates are applied: - -- **`swaps`** — TEBD/SVD for every two-qubit gate; long-range gates are routed - with adjacent SWAP insertion before and after the local update. -- **`tdvp`** — TEBD/SVD on nearest-neighbor gates; long-range gates use the - generator MPO + **two-site TDVP (2TDVP)** on a local window. -- **`full-tdvp`** — TDVP (generator MPO + 2TDVP on a local window) on every - two-qubit gate. - -Matrix-backed custom gates (from Qiskit `UnitaryGate` or other unknown -unitaries) have no analytic generator. In `gate_mode="tdvp"` or `"full-tdvp"`, -those gates use TEBD on nearest-neighbor pairs and the MPO path on long-range -pairs instead of the TDVP generator window. See {doc}`custom_gates` for the full -gate translation and custom-gate workflow. - -Gates on three or more qubits have no TEBD path: in the TDVP modes, gates with a -product-form generator (`ccx`, `ccz`) use the generator MPO and TDVP window; all -other cases, including `gate_mode="swaps"`, use the extended gate MPO. - -Long-range gates in `gate_mode="tdvp"` apply 2TDVP on the gate support window -via `evolve_window`. - -```{warning} -`"tdvp"` and `"full-tdvp"` are variational, approximate gate paths. With one -sweep, a long-range entangling gate can fail to create every required Schmidt -rank. This affects a long-range CZ on a product MPS, rank growth from two to -four after a shallow preparation, and multi-gate RZZ ladders. More sweeps can -improve some cases but do not guarantee exact gate application. Use the default -`"mpo"` mode, or `"swaps"` for two-qubit gates, when gate-application accuracy -up to the configured truncation is required. -``` +These diagnostics work at sampled times or circuit checkpoints, including noisy +MPS runs. Spectra contain descending Schmidt coefficients, with up to 500 +entries and `NaN` padding. With noise, YAQS averages the pure-trajectory +entropies and coefficients; these are not the entropy or spectrum of the +ensemble's mixed density matrix. Missing ranks contribute zero to coefficient +means, while ranks absent from every trajectory remain `NaN`. + +## Refine accuracy and retain results -Use **`tdvp_sweeps`** (default `1`) to split each TDVP evolution step into -multiple substeps of equal total time. Values greater than `1` are opt-in and -may improve accuracy on some circuits. The setting applies to all TDVP kernels -on `AnalogSimParams` and `DigitalSimParams`. +Change one source of error at a time and compare the observable that matters for +your calculation: -Use **`tdvp_mode`** to select the TDVP integrator: `"1site"` (1TDVP), `"2site"` -(2TDVP), or `"dynamic"` (adaptive single/two-site updates). The default is -**`"2site"`** (2TDVP) on `AnalogSimParams` and `DigitalSimParams`. Pass -`"dynamic"` explicitly for adaptive 1/2-site switching during analog evolution. +| Setting | When to change it | +| --------------- | ---------------------------------------------------------------------------------------------------------- | +| `dt` | Reduce it at fixed analog duration to check time-step error. | +| `num_traj` | Increase it to reduce uncertainty in noisy trajectory averages. | +| `max_bond_dim` | Increase the MPS bond cap if it limits the evolving state. Larger bonds cost memory and time. | +| `svd_threshold` | Reduce it to retain more information during tensor truncation. | +| `krylov_tol` | Reduce it to tighten local matrix-exponential solves in TDVP or BUG. This does not tighten SVD truncation. | -Substep geometry: each substep is **symmetric** (left-to-right then -right-to-left) at evolution time `step_time / tdvp_sweeps` for analog (`dt`) and -digital gates. The total generator time applied to one digital gate remains `1` -across all substeps. Noise and dissipation after TDVP still use the full -physical step `dt` in analog simulation. +Keep an explicit trajectory budget when comparing presets, since a preset +changes that budget along with the numerical settings. Tensor truncation and +integrator controls concern MPS evolution; dense backends use different +numerical methods. See {doc}`representation_comparison` before changing them. -```{code-cell} ipython3 -digital = DigitalSimParams( - observables=[Observable("z", 0)], - gate_mode="tdvp", - tdvp_sweeps=2, - preset="accurate", -) -_trunc_summary(digital) -``` +Set `random_seed` to a non-negative integer to repeat jump decisions and sampled +static disorder for the same input and configuration. It does not seed random +state initialization or measurement-shot sampling. -```{code-cell} ipython3 -digital_default = DigitalSimParams( - observables=[Observable("z", 0)], - preset="accurate", -) -_trunc_summary(digital_default) -``` +Set `get_state=True` to retain a supported final state in `result.output_state`. +Noisy MPS, statevector, and circuit runs cannot return one final pure state for +the trajectory ensemble. Noisy density-matrix evolution can return its final +mixed state. Unitary list-of-state ensembles do not return a final ensemble +state. -When `shots` is set, YAQS stores measurement histograms in `Result.counts` as a -`dict[int, int]`. The integer key encodes the measured bitstring with -**site 0 as the least-significant bit** (little-endian). This matches Qiskit’s -default convention if you interpret Qiskit bitstrings (`c_{n-1}...c_0`) via -`int(bitstring, 2)`. +## Advanced numerical options -```{code-cell} ipython3 -shot_params = DigitalSimParams(shots=1000) -shot_exact = DigitalSimParams(shots=1000, preset="exact") -``` +:::{dropdown} Analog integrators and two-time correlations -### Combined observables and shots +MPS analog evolution defaults to `evolution_mode=EvolutionMode.TDVP`. Import +`EvolutionMode` from `mqt.yaqs` and select `EvolutionMode.BUG` to use the BUG +integrator. See {doc}`analog_simulation` for the workflow. -Request both outputs on one `DigitalSimParams`. For a noisy run, set `num_traj` -for the observable ensemble and `shots` for the total sample budget: +`order=1` or `order=2` selects the TJM splitting order for noisy MPS evolution. +This is separate from `tdvp_mode`, which controls the TDVP state updates: -```{code-cell} ipython3 -combined = DigitalSimParams( - observables=[Observable("z", 0)], - shots=1000, - num_traj=64, -) -# Example of shots < num_traj (valid): two samples total, four trajectories. -combined_sparse = DigitalSimParams( - observables=[Observable("z", 0)], - shots=2, - num_traj=4, -) -_trunc_summary(combined) -``` +- `"2site"` (default) allows bond growth through two-site updates. +- `"1site"` uses single-site updates with fixed bond dimensions. +- `"dynamic"` switches between single-site and two-site updates. -See {doc}`circuit_shots` for a full shot-readout example. +`tdvp_sweeps` defaults to 1. Increasing it subdivides each TDVP evolution step +into symmetric substeps with the same total evolution time. In noisy analog +runs, noise still acts on the full physical timestep `dt`; extra TDVP substeps +do not refine that noise timestep. -## Reference: preset table in code +For two-time correlations, pass `multi_time_observables=[(A, B)]` with a +noiseless MPS `list[State]` and a static Hamiltonian. `B` acts at time zero and +`A` at the later time. The complex results appear in `multi_time_results`, with +one row per pair. See {doc}`ensemble_evolution` for the definition and example. -The built-in values are defined in {data}`~mqt.yaqs.SIMULATION_PRESETS`: +::: -```{code-cell} ipython3 -SIMULATION_PRESETS -``` +:::{dropdown} Circuit gate-application modes + +Keep `gate_mode="mpo"` unless you need to compare another method. It applies +gates directly and compresses the result using the configured tensor truncation. +The available modes are: + +| Mode | Two-qubit gates | +| ----------------- | ----------------------------------------------------------------------------------- | +| `"mpo"` (default) | Direct local updates for neighbors; an extended gate MPO for separated sites. | +| `"swaps"` | Route separated sites together with SWAPs, apply the gate, and restore their order. | +| `"tdvp"` | Direct local updates for neighbors; generator-based TDVP for separated sites. | +| `"full-tdvp"` | Generator-based TDVP for both neighboring and separated sites. | + +TDVP gate paths are variational approximations and can miss the bond growth an +entangling gate requires. Additional `tdvp_sweeps` may help, but do not +guarantee exact gate application. Use `"mpo"`, or `"swaps"` for two-qubit gates, +for direct application up to the configured truncation. Digital generator-based +TDVP requires `tdvp_mode="2site"`. + +Matrix-backed custom gates without an analytic generator use direct local +updates for neighbors and the MPO path for separated sites, including in TDVP +modes. Gates on three or more qubits use an MPO, except supported product-form +generators such as `ccx` and `ccz` in TDVP modes. See +{ref}`circuit-custom-gates` for custom gate inputs. + +::: + +:::{dropdown} SVD truncation modes + +`trunc_mode="discarded_weight"` is the default. `svd_threshold` limits the sum +of discarded squared singular values. The other modes interpret the threshold as +a fraction of total squared weight (`"relative_discarded_weight"`), a ratio to +the largest singular value (`"relative"`), or an absolute singular-value cutoff +(`"hard_cutoff"`). `max_bond_dim` can force further truncation in every mode. +Presets do not change `trunc_mode`. + +::: -## Related topics +## Related guides + +- {doc}`quickstart` — simulation and characterization workflows. +- {doc}`analog_simulation` — noisy spin dynamics and convergence checks. +- {doc}`circuit_observables` — circuit dynamics and sampling checkpoints. +- {doc}`circuit_shots` — noisy readout distributions. +- {doc}`simulator_initialization` — execution controls and result fields. -- {doc}`quickstart` — minimal first simulation -- {doc}`analog_simulation` — analog parameters in context -- {doc}`circuit_observables` — observables, `gate_mode`, and layer sampling -- {doc}`circuit_shots` — shot readout with `DigitalSimParams` +Full constructor signatures are in the API reference for +{class}`~mqt.yaqs.AnalogSimParams` and {class}`~mqt.yaqs.DigitalSimParams`. diff --git a/docs/examples/simulator_initialization.md b/docs/examples/simulator_initialization.md index d8a5f89c6..d65b3697a 100644 --- a/docs/examples/simulator_initialization.md +++ b/docs/examples/simulator_initialization.md @@ -2,314 +2,230 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true execution_timeout: 300 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Configuring the Simulator -YAQS draws a sharp line between **what** you simulate and **how** it runs: - -| Layer | Role | -| ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| {class}`~mqt.yaqs.State`, {class}`~mqt.yaqs.Hamiltonian`, {class}`~mqt.yaqs.AnalogSimParams` / {class}`~mqt.yaqs.DigitalSimParams` | The physics: initial state, operator, time grid, observables, trajectory count, truncation. | -| {class}`~mqt.yaqs.NoiseModel` | Optional physics input supplied to {meth}`~mqt.yaqs.Simulator.run`. | -| {class}`~mqt.yaqs.Simulator` | The execution: parallel vs. serial trajectories, worker count, progress reporting, multiprocessing start method, and retry policy for transient worker errors. | - -This page walks through every option on the {class}`~mqt.yaqs.Simulator` class -so you can tune execution without touching the physics. - -```{code-cell} ipython3 -from mqt.yaqs import AnalogSimParams, Hamiltonian, Observable, Result, Simulator, State -``` - -A small reusable analog problem we will simulate throughout: - -```{code-cell} ipython3 -L = 4 -H = Hamiltonian.ising(L, J=1.0, g=0.5) - - -def make_params(num_traj: int = 8) -> AnalogSimParams: - """A short Ising evolution measuring `` on every site.""" - return AnalogSimParams( - observables=[Observable("z", site) for site in range(L)], - elapsed_time=0.2, - dt=0.05, - num_traj=num_traj, - max_bond_dim=8, - svd_threshold=1e-9, - sample_timesteps=False, - random_seed=0, - ) - - -state = State(L, initial="zeros") -``` - -## Quick start: defaults - -Calling `Simulator()` with no arguments gives you parallel execution across most -of your CPU cores, a `tqdm` progress bar, an `"auto"` multiprocessing context, -and a generous retry policy. - -```{code-cell} ipython3 -sim = Simulator() -``` - -Every option is keyword-only, so you can override one without specifying the -others: - -```{code-cell} ipython3 -quiet_sim = Simulator(show_progress=False) -``` - -## Reusing one `Simulator` across runs - -A `Simulator` instance is **stateless** with respect to the physics; the same -instance can drive arbitrarily many {meth}`~mqt.yaqs.Simulator.run` calls. This -is the recommended pattern in scripts and notebooks because it keeps execution -configuration in one place. +`Simulator` controls how a calculation runs: parallel workers, progress bars, +and worker-error handling. Construct one instance and reuse it for successive +runs. The state, Hamiltonian or circuit, measurements, and noise are supplied +with each call; their settings are described in {doc}`simulation_parameters`. + +## Run a small noisy simulation + +This four-site Ising chain starts with every spin in $|1\rangle$. Local +relaxation acts during the evolution, and eight trajectories contribute to the +mean Pauli $Z$ expectations: + +```{code-cell} python +from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseModel, Observable, Simulator, State + +length = 4 +state = State(length, initial="ones") +hamiltonian = Hamiltonian.ising(length, J=1.0, g=0.5) +params = AnalogSimParams( + observables=[Observable("z", site) for site in range(length)], + elapsed_time=0.4, + dt=0.05, + num_traj=8, + random_seed=7, +) +noise = NoiseModel([ + {"name": "lowering", "sites": [site], "strength": 0.4} + for site in range(length) +]) -```{code-cell} ipython3 sim = Simulator(show_progress=False) - -for noise_strength in (0.0, 0.05, 0.1): - params = make_params() - result = sim.run(state, H, params, noise_model=None) -``` - -Each call constructs a short-lived `ProcessPoolExecutor` when `parallel=True`; -pools are not persisted across {meth}`~mqt.yaqs.Simulator.run` calls, so you can -safely change `sim.max_workers` (or replace `sim` entirely) between calls. - -## `parallel`: process-pool vs. in-process execution - -`parallel=True` (the default) runs trajectories in worker processes via -`concurrent.futures.ProcessPoolExecutor`. `parallel=False` runs every trajectory -in the calling process, which is useful for: - -- Debugging (full tracebacks, no pickling, breakpoints work). -- Very small jobs where the pool startup cost dominates. -- Notebook cells where you want to share state with the caller. - -Both modes produce identical results for a fixed `random_seed`: - -```{code-cell} ipython3 -import numpy as np - -params_serial = make_params() -sim_serial = Simulator(parallel=False, show_progress=False) -result_serial = sim_serial.run(state, H, params_serial) - -params_parallel = make_params() -sim_parallel = Simulator(parallel=True, max_workers=2, show_progress=False) -result_parallel = sim_parallel.run(state, H, params_parallel) -``` - -```{note} -For runs with `num_traj == 1` (e.g. noise-free analog/circuit dynamics, -Lindblad), the simulator automatically takes the in-process path even with -`parallel=True`. The pool is only spun up when there is more than one trajectory -to dispatch. -``` - -## `max_workers` and how the default is chosen - -When `max_workers` is left as `None`, the simulator picks -`max(1, available_cpus() - 1)` to leave one core free for the parent process and -the OS: - -```{code-cell} ipython3 -from mqt.yaqs.simulator import available_cpus - -cpus = available_cpus() -default_workers = Simulator().max_workers -``` - -{func}`~mqt.yaqs.core.parallel_utils.available_cpus` (re-exported as -{func}`~mqt.yaqs.simulator.available_cpus`) is deliberately cgroup- and -scheduler-aware. In priority order it honours: - -1. `YAQS_MAX_WORKERS` (explicit user override; positive integer). -2. `PYTEST_XDIST_WORKER` (returns `1` to avoid nested parallelism in tests). -3. SLURM hints — `SLURM_CPUS_PER_TASK` then `SLURM_CPUS_ON_NODE`. -4. Linux `os.sched_getaffinity(0)` (respects `taskset`, containers, cgroups). -5. `os.cpu_count()` as a final fallback. - -Override the resolution either by setting the environment variable… - -```python -# In a shell: export YAQS_MAX_WORKERS=4 +result = sim.run(state, hamiltonian, params, noise_model=noise) ``` -…or by passing `max_workers` explicitly: - -```{code-cell} ipython3 -sim_four = Simulator(max_workers=4, show_progress=False) -``` +Parallel execution remains enabled. The documentation suppresses progress bars +with `show_progress=False`; use `Simulator()` to see progress in your own runs. +The small trajectory budget illustrates execution and result access, rather than +sampling convergence. -## `show_progress`: tqdm bars +## Read the result and reuse the simulator -`show_progress=True` (default) shows a `tqdm` bar labelled "Running -trajectories" (or "Running unitary ensemble" for the deterministic ensemble -path). Set `show_progress=False` to silence it — useful in test suites, batch -scripts, and CI logs: +`run` returns a `Result`. Observable order matches the supplied list: -```{code-cell} ipython3 -silent = Simulator(show_progress=False) -silent.run(state, H, make_params(num_traj=4)) +```{code-cell} python +times = result.times +z0_mean = result.expectation_values[0] +z0_trajectories = result.trajectories[0] ``` -The bar is suppressed regardless of `parallel`, so the same flag also silences -serial runs. - -## `mp_context`: multiprocessing start method +Here, `times` and `z0_mean` have nine entries, including time zero. +`z0_trajectories` has shape `(8, 9)`: one row per trajectory and one column per +time. `z0_mean` averages those rows. Circuit runs can also return readout counts +in `result.counts`; see {doc}`circuit_shots`. -`mp_context` controls how worker processes start. The default `"auto"` selects a -start method per OS: +Reuse the same simulator and inputs for a noiseless reference: -| Value | Behaviour | -| --------- | --------------------------------------------------------------------------- | -| `"auto"` | `"forkserver"` on Linux, `"spawn"` everywhere else. | -| `"fork"` | Copies the parent process. Avoid when the parent has active threads. | -| `"spawn"` | Fresh interpreter per worker. Used on Windows/macOS and available on Linux. | - -On Linux, `"auto"` creates workers through a separate server process. The first -pool has extra startup cost, but workers do not fork the application's threaded -process. Numerical thread limits control resource use; they do not make an -explicit `"fork"` safe when the parent has active threads. - -```{code-cell} ipython3 -automatic_context = Simulator(mp_context="auto") -portable_context = Simulator(mp_context="spawn") +```{code-cell} python +reference = sim.run(state, hamiltonian, params) ``` -If you mix YAQS with GPU libraries or anything that does not survive `fork()`, -force `mp_context="spawn"`. +Each run starts from the supplied initial state. It does not continue from the +previous result. Sequential calls share execution settings, but they do not keep +a worker pool alive. YAQS creates a pool only when the calculation has multiple +independent jobs and more than one worker is available. -## `max_retries` and `retry_exceptions` +## Choose the common execution controls -Long parallel runs occasionally encounter transient worker failures: a worker is -cancelled by the OS, a `TimeoutError` is raised, or a transient `OSError` (e.g. -a temporary file system hiccup) propagates out of a backend. By default, the -simulator retries each failing trajectory up to **10 times** for the following -exception types: +All constructor options are keyword-only. Leave the defaults in place unless you +need a specific execution budget or a quieter run. -- `concurrent.futures.CancelledError` -- `TimeoutError` -- `OSError` +| Option | Default | When to change it | +| --------------- | --------- | --------------------------------------------------------------------------------------- | +| `show_progress` | `True` | Set `False` to suppress trajectory and readout bars in documentation or logs. | +| `max_workers` | Automatic | Set a positive integer to cap worker processes, for example `Simulator(max_workers=4)`. | +| `parallel` | `True` | Set `False` to debug in the calling process or avoid process startup for a small job. | -```{code-cell} ipython3 -default_retries = Simulator().max_retries -retry_types = Simulator().retry_exceptions -``` +A pool requires `parallel=True`, more than one independent job, and +`max_workers > 1`. Noiseless single-state evolution and density-matrix evolution +run in the calling process. `max_workers=1` also keeps execution in that +process, even when parallel execution is enabled, and limits numerical threads +to one. Worker processes limit their own numerical threads to avoid multiplying +thread pools across CPUs. -Tighten the policy for fail-fast development (e.g. when bisecting an error): +You can change settings between calls, for example `sim.max_workers = 2` or +`sim.show_progress = True`. Setting `sim.max_workers = None` restores automatic +worker selection. -```{code-cell} ipython3 -strict = Simulator(max_retries=0, show_progress=False) -``` +## Run from a Python script -Or broaden it for unreliable environments: +Worker processes start through `forkserver` on Linux and `spawn` on Windows and +macOS by default. These methods need a script entry-point guard so importing the +script in a child process does not start the simulation again. -```{code-cell} ipython3 -import concurrent.futures +Keep the imports and input definitions from the first example. Replace its +simulator creation and run calls with this block, and keep subsequent run calls +inside the guard: -resilient = Simulator( - max_retries=20, - retry_exceptions=(concurrent.futures.CancelledError, TimeoutError, OSError, ConnectionError), - show_progress=False, -) -``` +```python +if __name__ == "__main__": + sim = Simulator() + result = sim.run(state, hamiltonian, params, noise_model=noise) + reference = sim.run(state, hamiltonian, params) +``` + +Run the file with `python your_script.py`. In a notebook, execute the cells in +order without adding this guard. If process startup is the cause of a debugging +problem, `parallel=False` lets you inspect the calculation in the notebook's +process. -Permanent errors (e.g. `ValueError` from your physics setup, `AssertionError` -from invariants) are not retried — they propagate after the first failure -regardless of `max_retries`. +## Advanced execution options -## Inspecting the return value: `Result` +:::{dropdown} Automatic worker budgets and numerical threads -{meth}`~mqt.yaqs.Simulator.run` returns a {class}`~mqt.yaqs.Result` that holds -every simulation output through a small, stable surface. The -{class}`~mqt.yaqs.core.data_structures.simulation_parameters.AnalogSimParams` -you passed in is referenced unchanged at `result.sim_params`: +With `max_workers=None`, the simulator uses `max(1, available_cpus() - 1)`. CPU +discovery takes the first valid hint from: + +1. `YAQS_MAX_WORKERS`. +2. A pytest-xdist worker, which reports one CPU to avoid nested pools. +3. `SLURM_CPUS_PER_TASK`, then `SLURM_CPUS_ON_NODE`. +4. Process CPU affinity, when available. +5. The operating system's CPU count. + +Invalid or non-positive environment hints are ignored. The default worker policy +then leaves one reported CPU free. Thus `YAQS_MAX_WORKERS=4` normally resolves +to three workers; `Simulator(max_workers=4)` explicitly permits four. Use the +constructor argument when you need an exact process cap. CPU affinity can +restrict the available cores, but this discovery does not read every container +CPU-quota setting. + +A worker cap bounds process count, not total memory. Each process needs its own +simulation state. Reduce the cap when several concurrent trajectories exceed +your memory budget. Numerical libraries are capped inside workers; turning off +process parallelism does not promise unrestricted BLAS threading. + +::: + +:::{dropdown} Multiprocessing start methods + +`mp_context="auto"` selects `"forkserver"` on Linux and `"spawn"` elsewhere. For +an explicit selection, the public options are `"spawn"` and `"fork"` on +platforms that support them. There is no fallback for an unavailable method. +Keep `"auto"` unless the environment requires a specific method. + +`"spawn"` starts a fresh interpreter for each worker. It can also be useful when +combining YAQS with libraries that need fresh process initialization. `"fork"` +copies the application's process and can be unsafe when the parent has active +threads. Thread limits do not remove that risk. The automatic Linux context +starts workers from a separate server process; its first pool has additional +startup cost. + +Worker inputs must be pickleable. Pools are scoped to individual runs, although +the Python forkserver helper can remain alive between calls. These execution +choices do not change the requested simulation model. + +::: + +:::{dropdown} Retries for worker errors + +`max_retries=10` allows up to ten additional attempts for a failed job in a +process pool. The default `retry_exceptions` tuple contains +`concurrent.futures.CancelledError`, `TimeoutError`, and `OSError`. + +Only matching exceptions raised when retrieving a worker result trigger a retry. +After the retry budget is exhausted, the error propagates. Other exceptions, +including `ValueError`, propagate immediately under the default policy. Set +`max_retries=0` to propagate the first worker failure, or supply a tuple of +exception classes for a specific transient failure in your environment. + +Retries do not impose a timeout, apply to in-process execution, or restart a +broken pool. Pool startup and submission failures are outside this retry policy. +Do not increase the retry budget to handle a repeatable error in the model. + +::: + +:::{dropdown} Other result fields and configuration references + +Outputs that do not apply to a run remain `None` or empty. The API reference for +{class}`~mqt.yaqs.Result` describes the full result structure: + +| Fields | Use | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `observables`, `expectation_values`, `trajectories` | Requested observables, their means, and individual realization data in the supplied order. | +| `times` | Analog sample times, or the stitched timeline of a program with observables. Standalone circuits have no physical time axis. | +| `counts`, `measurements` | Total circuit readout counts and per-trajectory histograms when shots are requested. | +| `output_state` | A final state when `get_state=True` is supported; see {doc}`simulation_parameters`. | +| `max_bond`, `total_bond`, `runtime_cost` | MPS bond diagnostics and an estimated contraction cost, when recorded. | +| `multi_time_times`, `multi_time_results` | Complex two-time correlations for unitary ensembles; see {doc}`ensemble_evolution`. | +| `segment_results` | Individual results from a `SimulationProgram`; see {doc}`digital_analog_simulation`. | +| `sim_params`, `noise_model` | The validated simulation parameters and the noise model used by the run. | + +For a standalone run, `result.sim_params` references the supplied parameter +object. Validation normalizes that object and rebuilds its analog time grid; it +is not an immutable snapshot. Construct a new parameter object when you need to +retain a separate configuration. State and Hamiltonian wrappers may also +populate cached representations, while evolution uses copies of their input +states. + +A program's top-level `sim_params` is `None`; read segment parameters through +`result.segment_results`. Program settings and trajectory budgets are explained +in {doc}`digital_analog_simulation`. -```{code-cell} ipython3 -sim = Simulator(show_progress=False) -params = make_params() -result = sim.run(state, H, params) -``` - -The properties that don't apply to your simulation kind return `None` (or an -empty list for `observables` when only shots were requested), so you can branch -on them safely. The full set is: - -| Property | Populated for | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `observables` | Analog and digital runs with observables. Empty list for all digital runs without observables (shots-only and state-only, e.g. `get_state=True`). | -| `expectation_values` | Aggregated expectation per observable (parallel to `observables`). | -| `trajectories` | Per-trajectory data per observable (parallel to `observables`). | -| `times` | Shared analog time grid; for digital–analog programs, the stitched physical timeline when observables are configured (`None` for shots-only / no-observable programs and standalone digital circuits). | -| `runtime_cost` | MPS-backed analog and digital runs that are not shots-only (contraction-cost heuristic over time). | -| `max_bond` | MPS-backed analog and digital runs that are not shots-only (maximum bond dimension over time). | -| `total_bond` | MPS-backed analog and digital runs that are not shots-only (sum of internal bond dimensions). | -| `noise_model` | Any run that was given a `NoiseModel`; otherwise `None`. | -| `output_state` | Runs with `get_state=True` on `AnalogSimParams` or `DigitalSimParams`. For Lindblad (`density_matrix`), noisy runs are supported; for `mps`/`vector`, noiseless only. | -| `multi_time_times`, `multi_time_results` | Analog deterministic ensembles with `multi_time_observables` set. | -| `counts` | Digital runs with `shots` set (the `dict[int, int]` of aggregated measurement outcomes). | - -A digital–analog {class}`~mqt.yaqs.SimulationProgram` returns a top-level -{class}`~mqt.yaqs.Result` whose `sim_params` is `None`; read each segment's -parameters from `result.segment_results[i].sim_params`. When observables are -configured, `result.times` and `result.expectation_values` are stitched onto the -physical program timeline; shots-only and other no-observable programs leave -`result.times` as `None`. Observables and `random_seed` are set on the program -(or as keyword arguments when passing a pair list to -{meth}`~mqt.yaqs.Simulator.run`). Prefer setting `num_traj` the same way; when -{attr}`~mqt.yaqs.SimulationProgram.num_traj` is omitted (`None`), execution -falls back to the `num_traj` value specified consistently on every segment's -parameter object. Conflicting segment values require an explicit program-level -`num_traj`. A program that requests shots but no observables follows standalone -digital semantics and executes one complete-program stochastic trajectory per -shot. - -`Result` (and its wrapped `sim_params`) is pickleable, so you can checkpoint and -resume analysis from disk: - -```{code-cell} ipython3 -import pickle - -blob = pickle.dumps(result) -restored: Result = pickle.loads(blob) # noqa: S301 -``` +For temporary storage, pickle can save results for later analysis with matching +Python, YAQS, and dependency versions. This stores the result; it does not +resume an interrupted simulation. Only load pickle files from a trusted source. + +::: + +## Related guides -## Choosing settings for common scenarios - -| Scenario | Recommended `Simulator(...)` | -| ------------------------------------------------- | ---------------------------------------------------------------------- | -| Quick local run, want to see a progress bar | `Simulator()` | -| Notebook / docs build / CI logs | `Simulator(show_progress=False)` | -| Debugging a physics setup | `Simulator(parallel=False, show_progress=False)` | -| Single-process benchmark, all cores in the worker | `Simulator(parallel=False)` and let BLAS/OpenMP use all threads | -| Fixed core budget (e.g. SLURM job step) | `YAQS_MAX_WORKERS=N` in the environment, or `Simulator(max_workers=N)` | -| Mixing with GPU / non-fork-safe code | `Simulator(mp_context="spawn")` | -| Long unattended run on a flaky cluster | `Simulator(max_retries=20)` with broadened `retry_exceptions` | - -For physics-side settings (`num_traj`, `max_bond_dim`, `svd_threshold`, -`random_seed`, `sample_timesteps`, observables, noise), see -{doc}`analog_simulation`, {doc}`representation_comparison`, and -{doc}`state_initialization`. - -## Related topics - -- {doc}`quickstart` — minimal first simulation -- {doc}`simulation_parameters` — physics-side presets and truncation -- {doc}`analog_simulation` — TJM workflow with noise -- {doc}`circuit_observables` — circuit observables, mid-circuit sampling, gate - modes +- {doc}`simulation_parameters` — accuracy, sampling budgets, and output + requests. +- {doc}`analog_simulation` — noisy analog dynamics. +- {doc}`circuit_observables` — circuit expectations and checkpoints. +- {doc}`digital_analog_simulation` — analog-digital programs and segment + results. +- {doc}`representation_comparison` — choosing the analog state representation. + +Full constructor and `run` signatures are in the API reference for +{class}`~mqt.yaqs.Simulator`. diff --git a/docs/examples/state_initialization.md b/docs/examples/state_initialization.md index 6bb209ed4..9e307dcfa 100644 --- a/docs/examples/state_initialization.md +++ b/docs/examples/state_initialization.md @@ -2,228 +2,211 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true execution_timeout: 300 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - # Initializing Quantum States -YAQS separates **what you specify** (a -{class}`~mqt.yaqs.core.data_structures.state.State`) from **how evolution runs** -({class}`~mqt.yaqs.core.data_structures.simulation_parameters.AnalogSimParams`, -Hamiltonian, noise). +The initial state sets the starting point for a YAQS simulation. Use `State` to +choose a named preparation or supply your own data. The default matrix product +state (MPS) representation supports analog and circuit simulation without +storing a full state vector. -| Layer | Role | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`State`** | User-facing initial condition: length, preset name, optional raw data, and **which representation** to evolve in (`"mps"`, `"vector"`, or `"density_matrix"`). | -| **`MPS`** | Internal tensor network; used by the simulator when needed. Prefer {class}`~mqt.yaqs.core.data_structures.state.State` in application code. | +## Choose a product state -**Workflow:** build a {class}`~mqt.yaqs.core.data_structures.state.State` and a -{class}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian` once (both -materialize at construction), then pass them to -{meth}`Simulator.run ` — including in parameter loops. +Specify the number of sites and, optionally, a preset. Sites are qubits unless +you provide other local dimensions: -```{code-cell} ipython3 +```{code-cell} python from mqt.yaqs import State -preset = State(4, initial="x+") - -mcwf_state = State(4, initial="zeros", representation="vector") +zeros = State(20) +polarized = State(20, initial="x+") ``` -## `State` versus `MPS` - -Use `State` in {meth}`Simulator.run `. Use -{class}`~mqt.yaqs.core.data_structures.mps.MPS` directly only for low-level -tensor-network code, or wrap an existing MPS with {meth}`State.from_mps` -`. +Here, `zeros` puts every site in $|0\rangle$, while `polarized` puts every site +in $(|0\rangle + |1\rangle)/\sqrt{2}$. Both are product states: the sites have +no initial entanglement. + +| `initial` | Preparation | +| ------------------- | ------------------------------------------------------------------------------------------------ | +| `"zeros"` (default) | Every site in $\lvert 0\rangle$. | +| `"ones"` | Every site in $\lvert 1\rangle$. | +| `"x+"`, `"x-"` | Every site in $(\lvert 0\rangle \pm \lvert 1\rangle)/\sqrt{2}$. | +| `"y+"`, `"y-"` | Every site in $(\lvert 0\rangle \pm i\lvert 1\rangle)/\sqrt{2}$. | +| `"Neel"` | Alternating levels, starting with site 0 in $\lvert 1\rangle$: `1010…` in site order. | +| `"wall"` | The first `length // 2` sites in $\lvert 0\rangle$ and the remaining sites in $\lvert 1\rangle$. | +| `"basis"` | One computational-basis configuration, supplied with `basis_string`. | +| `"random"` | A random product state; see the random-state examples below. | + +## Place an excitation and check site order + +The {doc}`analog_simulation` guide follows an excitation that starts near the +center of a chain. Prepare that state by giving one basis digit per site: + +```{code-cell} python +length = 20 +center = length // 2 +basis = "0" * center + "1" + "0" * (length - center - 1) +localized = State(length, initial="basis", basis_string=basis) +``` -**Circuit simulation** requires `representation="mps"` (the preset default). -`Simulator.run` with `DigitalSimParams` rejects vector and density-matrix -states. +Character `i` in `basis_string` selects site `i`, starting with site 0 on the +left. A measurement bitstring instead displays site 0 on the right: -## How `representation` is chosen +```{code-cell} python +site_zero = State(3, initial="basis", basis_string="100") +print(site_zero.mps.to_vec()) +``` -| How you build `State` | `representation` | -| ------------------------------------- | ------------------------------------------------------------------------------ | -| Preset only (`length`, `initial=`, …) | Default `"mps"`; override with `representation="vector"` or `"density_matrix"` | -| `tensors=` (MPS cores) | Inferred `"mps"` — do **not** pass `representation=` | -| `vector=` | Inferred `"vector"` | -| `density_matrix=` | Inferred `"density_matrix"` | +This state has site 0 in $|1\rangle$ and the other sites in $|0\rangle$. Its +dense vector has its only nonzero entry at index 1, and its readout bitstring is +`"001"`. Dense vectors and the rows and columns of density matrices use site 0 +as the fastest-varying subsystem. For qubits, this matches Qiskit's ordering. -```{code-cell} ipython3 -import numpy as np +## Choose a representation -vec = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.complex128) -from_vector = State(vector=vec) +Set `representation` when constructing a preset state, for example +`State(4, initial="x+", representation="vector")`. YAQS selects the analog +backend from this choice: -lindblad_ready = State(2, initial="zeros", representation="density_matrix") -``` +| `representation` | State storage and supported use | +| ------------------ | ---------------------------------------------------------------------------------- | +| `"mps"` (default) | Tensor network for analog evolution, circuits, and unitary ensembles. | +| `"vector"` | Dense pure state for analog evolution with Monte Carlo wave-function trajectories. | +| `"density_matrix"` | Dense pure or mixed state for analog Lindblad evolution. | -## Preset product states +For $N$ qubits, a dense vector has $2^N$ entries and a density matrix has $4^N$ +entries. Keep dense calculations small; circuit simulation requires `"mps"`. See +{doc}`representation_comparison` for a comparison on the same physical model. -Presets match `MPS(..., state=...)` names: `"zeros"`, `"ones"`, `"x+"`, -`"Neel"`, `"wall"`, `"basis"`, `"random"`, etc. +## Prepare random states -For **MCWF** or **Lindblad**, set `representation` on the `State` and call -`Simulator.run` — no extra steps: +Use `"random"` for an unentangled state with real, nonnegative random local +amplitudes. To allow initial entanglement, use `"haar-random"` and choose an +initial maximum bond dimension with `pad`: -```{code-cell} ipython3 -neel_mcwf = State(4, initial="Neel", representation="vector") +```{code-cell} python +random_product = State(6, initial="random") +random_mps = State(6, initial="haar-random", pad=4) ``` -Product presets can evolve in dense form without ever building an MPS in memory. -**Entangled** presets (e.g. `"haar-random"`) may still require an internal MPS -when you choose a dense representation. +`"haar-random"` builds an MPS from random isometries. It does not sample +uniformly from all pure states in the full Hilbert space. The bond dimensions +are limited by `pad` and the sizes of the neighboring subsystems. Omitting `pad` +gives a maximum bond dimension of one, so the state is then a product state. +This initial choice is separate from `max_bond_dim` during evolution. -Reproducible `"random"` presets: pass `seed=` on `State`. +For a reproducible random product state in a dense representation, pass `seed`: -```{code-cell} ipython3 -a = State(3, initial="random", seed=7, representation="vector") -b = State(3, initial="random", seed=7, representation="vector") -# Same specification; run() will evolve both consistently. +```{code-cell} python +repeatable = State(4, initial="random", representation="vector", seed=7) ``` -## Manual initialization - -Pass **exactly one** of `tensors`, `vector`, or `density_matrix`. Representation -is **inferred**; do not pass `representation=`. Preset-only kwargs (`initial`, -`pad`, `basis_string`, `seed`) cannot be combined with manual data. - -### MPS cores (`tensors=`) - -```{code-cell} ipython3 -from mqt.yaqs import MPS - -mps_ref = MPS(3, state="zeros") -spec = State(tensors=list(mps_ref.tensors)) -``` +The same seed reproduces the same initial vector. It also works with +`representation="density_matrix"`, which forms the corresponding pure-state +density matrix. Currently, random MPS initialization and `"haar-random"` ignore +`seed`. To reuse those preparations, construct the state once and pass the same +object to successive runs. The simulation parameter `random_seed` controls +stochastic evolution; it does not seed state preparation. -### Dense state vector (`vector=`) +## Supply a vector or a mixed state -`length` is inferred when the Hilbert-space dimension is a power of two. +Pass exactly one of `vector`, `density_matrix`, or `tensors` for manual data. +The representation is inferred, so omit `representation`. Do not combine manual +data with preset options such as `initial`, `basis_string`, `seed`, or `pad`. -```{code-cell} ipython3 -vec = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.complex128) # |00> -spec = State(vector=vec) -``` +The vector below prepares the entangled Bell state +$(|00\rangle + |11\rangle)/\sqrt{2}$: -### Density matrix (`density_matrix=`) +```{code-cell} python +import numpy as np -```{code-cell} ipython3 -rho = np.diag([1.0, 0.0, 0.0, 0.0]).astype(np.complex128) -spec = State(density_matrix=rho) +bell = State(vector=np.array([1, 0, 0, 1], dtype=complex)) +print(bell.vector) ``` -A `State` created only with `vector=` or `density_matrix=` cannot be used for -circuit simulation; use `tensors=` or a preset with `representation="mps"` -instead. +YAQS copies and normalizes the vector. The input must be a finite, nonzero, +one-dimensional array. Without explicit local dimensions, its size must be a +power of two; YAQS infers the number of qubits. -## Representation and backends (analog) +A mixed state describes a statistical preparation. This example puts the system +in $|00\rangle$ with probability 0.6 and $|11\rangle$ with probability 0.4: -Set **`representation` on `State`**, not on `AnalogSimParams`. -{meth}`Simulator.run ` materializes the correct internal -form and dispatches: - -| `representation` | Backend (analog) | -| ------------------ | ------------------------------------- | -| `"mps"` (default) | TJM (`analog_tjm_1` / `analog_tjm_2`) | -| `"vector"` | MCWF | -| `"density_matrix"` | Lindblad (small systems) | - -### Default: MPS / TJM +```{code-cell} python +rho = np.diag([0.6, 0.0, 0.0, 0.4]).astype(complex) +mixed = State(density_matrix=rho) +``` -```{code-cell} ipython3 -from mqt.yaqs import Hamiltonian, MPS, Simulator, AnalogSimParams, Observable +YAQS copies the matrix and normalizes its trace. The input must be finite, +square, Hermitian, positive semidefinite, and have positive real trace. Manual +vectors and density matrices select their dense analog backends. They cannot +serve as circuit inputs; use a preset or MPS tensors instead. -sim = Simulator(show_progress=False) +## Use other local dimensions -L = 3 -H = Hamiltonian.ising(L, J=1.0, g=0.5) -obs = Observable("z", sites=[0]) +Set `physical_dimensions` to an integer for a uniform chain or a list for +different dimensions at each site: -state_mps = State(L, initial="zeros") -params = AnalogSimParams( - observables=[obs], - elapsed_time=0.2, - dt=0.05, +```{code-cell} python +qutrits = State(3, physical_dimensions=3) +qubit_qutrit = State( + 2, initial="basis", basis_string="12", physical_dimensions=[2, 3] ) -result = sim.run(state_mps, H, params, noise_model=None) ``` -### MCWF (`representation="vector"`) +The second state has a qubit at site 0 in level 1 and a qutrit at site 1 in +level 2. Presets such as `"ones"` still use level 1, not the highest local +level. For manual dense data, local dimensions must multiply to the vector +length or matrix dimension. Bitstring probabilities and shot measurements +require qubits; digital gates currently require qubit target sites. See +{doc}`transmon_emulation` and {doc}`trapped_ion` for device examples. -For guidance on choosing a representation, see {doc}`representation_comparison`. +## Advanced MPS preparation -```{code-cell} ipython3 -state_vec = State(L, initial="zeros", representation="vector") -obs_vec = Observable("z", sites=[0]) -params_vec = AnalogSimParams( - observables=[obs_vec], - elapsed_time=0.2, - dt=0.05, -) -result = sim.run(state_vec, H, params_vec, None) +:::{dropdown} Supply MPS tensors or wrap an existing MPS + +Use `tensors=` for custom open-boundary MPS cores. Each core has axes +`(physical, left, right)`. Neighboring bond dimensions must match, exterior +bonds must have dimension one, and all entries must be finite. This example +prepares the same Bell state as the vector above, now in MPS form: + +```python +left = (np.eye(2) / np.sqrt(2)).reshape(2, 1, 2) +right = np.eye(2).reshape(2, 2, 1) +bell_mps = State(tensors=[left, right]) +wrapped = State.from_mps(bell_mps.mps) ``` -### Lindblad (`representation="density_matrix"`) +`State(tensors=...)` infers the site count and normalizes the MPS. For other +physical dimensions, supply matching `physical_dimensions`. -For guidance on choosing a representation, see {doc}`representation_comparison`. +When you already have an {class}`~mqt.yaqs.MPS`, use +{meth}`~mqt.yaqs.State.from_mps` to wrap it. This method references the same MPS +without copying or normalizing it. Changes through either reference affect the +same state. For an independent normalized state, supply copies of its tensors +through `State(tensors=...)` and preserve its physical dimensions. -```{code-cell} ipython3 -state_dm = State(L, initial="zeros", representation="density_matrix") -obs_dm = Observable("z", sites=[0]) -params_dm = AnalogSimParams( - observables=[obs_dm], - elapsed_time=0.2, - dt=0.05, -) -result = sim.run(state_dm, H, params_dm, None) -``` +::: -See {doc}`representation_comparison` for a side-by-side comparison of the three -representations on the same Hamiltonian. +:::{dropdown} Pad the initial MPS bonds -### Passing dense data directly +For product presets, `pad` adds zero entries to enlarge the initial MPS bonds +without changing the physical state. For `"haar-random"`, it instead sets the +maximum initial bond dimension used during random construction. -If you already have $|\psi\rangle$ or $\rho$, pass `vector=` or -`density_matrix=` — representation is inferred: +Padding allocates initial MPS space; it does not set the bond limit during +evolution. That limit is `max_bond_dim` in the simulation parameters. Dense +product-state construction does not use MPS padding. -```{code-cell} ipython3 -psi = np.zeros(2**L, dtype=np.complex128) -psi[0] = 1.0 -state_from_vec = State(vector=psi) -result = sim.run(state_from_vec, H, params_vec, None) -``` +::: -## Practical limits - -- **Memory**: dense `vector` scales as $2^N$; `density_matrix` as $2^{2N}$. - Prefer `representation="mps"` for longer chains. -- **Entangled presets**: `"haar-random"` may need an internal MPS for dense - representations. -- **Circuits**: use `State(..., representation="mps")` (default); `vector=` / - `density_matrix=` states cannot run circuits. -- **Ensemble runs**: `list[State]` for deterministic unitary ensembles requires - each member with `representation="mps"`. -- **`get_state`**: when supported, `result.output_state` is a - {class}`~mqt.yaqs.core.data_structures.state.State`. Use `.mps` for MPS runs, - `.vector` for MCWF, or `.density_matrix` for Lindblad. Not supported with - stochastic noise on `mps` or `vector` representations (use `density_matrix` - for the exact ensemble average). - -For MPO/TJM details without `State`, see {doc}`analog_simulation` and the -{class}`~mqt.yaqs.core.data_structures.mps.MPS` API reference. - -## Related topics - -- {doc}`quickstart` — minimal first simulation -- {doc}`representation_comparison` — MPS, MCWF, and Lindblad backends -- {doc}`analog_simulation` — TJM evolution workflow -- {doc}`simulation_parameters` — presets and trajectory settings +Once the initial state is prepared, pass it to `Simulator.run` with the model, +measurements, and optional noise. The {doc}`simulator_initialization` guide +shows how to run and reuse a simulator, while {doc}`simulation_parameters` +describes accuracy and sampling choices. Full constructor details are in the +{class}`~mqt.yaqs.State` API reference. diff --git a/docs/examples/transmon_emulation.md b/docs/examples/transmon_emulation.md index 3a66dfbb9..dcf762606 100644 --- a/docs/examples/transmon_emulation.md +++ b/docs/examples/transmon_emulation.md @@ -2,201 +2,274 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true execution_timeout: 300 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` - -# Transmon-Resonator Chain Emulation - -This example simulates a **qubit–resonator–qubit** chain with -{meth}`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian.coupled_transmon` -(dipole coupling per -{meth}`~mqt.yaqs.core.data_structures.mpo.MPO.coupled_transmon`). +# Superconducting Qubit Emulation -We prepare $|100\rangle$ (left transmon excited) and evolve for one resonant -swap period $T_{\mathrm{swap}} = \pi/(\sqrt{2}\,g)$. The same evolution is run -**twice**: +A resonator can carry an excitation between superconducting qubits. How much +reaches the receiving qubit, and how does noise affect the transfer? We model +two transmons coupled through a resonator, follow their populations, and compare +several relaxation and dephasing strengths. Keeping a third transmon level also +lets us track occupation outside the qubit computational subspace. -1. **Noiseless** — unitary analog simulation (TDVP on the MPO). -2. **Noisy** — open-system simulation with relaxation and dephasing on the qubit - sites (TJM trajectories). +This guide uses the standard YAQS installation and Matplotlib. Run the cells in +order in a notebook. For a script, use the entry-point guard in +{doc}`simulator_initialization`. -Local projectors track the $|1\rangle$ population on each transmon and the -$|2\rangle$ population on every site. Binary bitstring observables and shot -counts are restricted to all-qubit states, so this non-qubit example uses local -three-level observables instead. The sum of the local $|2\rangle$ populations is -the expected number of sites in the leakage level. +## 1. Build the transmon–resonator model -## 1. Hamiltonian and initial state +`Hamiltonian.coupled_transmon` places transmons at even sites and resonators at +odd sites. Here, sites 0 and 2 are three-level transmons, and site 1 is a +four-level resonator. Each transmon has a Duffing term +$\omega_q n+\alpha n(n-1)/2$, while the resonator has energy $\omega_r n$. +Neighboring sites interact through the full dipole coupling +$g(b+b^\dagger)(a+a^\dagger)$, where $b$ and $a$ lower the transmon and +resonator levels. -```{code-cell} ipython3 +```{code-cell} python import numpy as np -from mqt.yaqs import Hamiltonian, State -length = 3 # qubit – resonator – qubit +from mqt.yaqs import Hamiltonian + qubit_dim = 3 -resonator_dim = 3 -w_q = 4 / (2 * np.pi) -w_r = 4 / (2 * np.pi) -alpha = -0.3 / (2 * np.pi) -g = 0.2 / (2 * np.pi) - -H_0 = Hamiltonian.coupled_transmon( - length=length, +resonator_dim = 4 +physical_dimensions = [qubit_dim, resonator_dim, qubit_dim] +coupling = 1.0 +hamiltonian = Hamiltonian.coupled_transmon( + length=3, qubit_dim=qubit_dim, resonator_dim=resonator_dim, - qubit_freq=w_q, - resonator_freq=w_r, - anharmonicity=alpha, - coupling=g, + qubit_freq=20.0 * coupling, + resonator_freq=20.0 * coupling, + anharmonicity=-1.5 * coupling, + coupling=coupling, ) +transfer_time = np.pi / (np.sqrt(2) * coupling) +``` -T_swap = np.pi / (np.sqrt(2) * g) -dt = T_swap / 100 +We use $\hbar=1$ and measure frequencies and rates in units of $g$, so time is +in units of $1/g$. The frequency arguments enter the Hamiltonian directly: +convert ordinary frequencies to angular frequencies before supplying dimensional +values. YAQS adds no factor of $2\pi$. + +On resonance, $T=\pi/(\sqrt{2}g)$ estimates the first complete transfer in the +rotating-wave approximation. The factory retains counter-rotating terms, so +transfer at this time is approximate and total excitation is not exactly +conserved. This is a model of excitation transfer; one prepared state does not +validate a SWAP gate on arbitrary inputs. + +## 2. Excite the left transmon + +Start in $|100\rangle$: the left transmon is excited, while the resonator and +right transmon start in their ground states. Characters in `basis_string` follow +site order, starting at site 0. + +```{code-cell} python +from mqt.yaqs import State -# |100⟩: left qubit (site 0) in |1⟩ state = State( - length, - initial="basis", - basis_string="100", - physical_dimensions=[qubit_dim, resonator_dim, qubit_dim], + 3, initial="basis", basis_string="100", physical_dimensions=physical_dimensions, ) ``` -## 2. Observables and shared parameters +The explicit dimensions must match the Hamiltonian. YAQS uses an MPS by default +and supports different local dimensions within the same chain. + +## 3. Choose populations and the time grid -```{code-cell} ipython3 +Use local matrix observables to measure each site's $|1\rangle$ population. On +the transmons, also measure $|2\rangle$ population to detect leakage from the +computational subspace. Mean occupation $\langle n\rangle$ on all three sites +lets us track total excitation. + +```{code-cell} python from mqt.yaqs import AnalogSimParams, Observable -projector_1 = np.diag([0.0, 1.0, 0.0]) -projector_2 = np.diag([0.0, 0.0, 1.0]) -population_observables = [ - Observable(projector_1, sites=0), - Observable(projector_1, sites=2), - *(Observable(projector_2, sites=site) for site in range(length)), +observables = [ + Observable(np.diag(np.arange(dim) == 1).astype(float), site) + for site, dim in enumerate(physical_dimensions) ] - -sim_params = AnalogSimParams( - observables=population_observables, - elapsed_time=T_swap, - dt=dt, - sample_timesteps=True, +observables += [Observable(np.diag([0.0, 0.0, 1.0]), site) for site in (0, 2)] +observables += [ + Observable(np.diag(np.arange(dim)).astype(float), site) + for site, dim in enumerate(physical_dimensions) +] +params = AnalogSimParams( + observables=observables, + elapsed_time=transfer_time, + dt=transfer_time / 80, + order=2, + num_traj=24, + preset="balanced", + random_seed=7, ) +``` +Each matrix matches its site's dimension. The observable order is three +$|1\rangle$ populations, two transmon $|2\rangle$ populations, then three mean +occupations. Binary bitstring observables and shot counts require an all-qubit +state; local matrix observables also work with higher levels. -def population_curve(result, observable_index: int) -> np.ndarray: - values = result.expectation_values[observable_index] - if values is None: - msg = f"observable {observable_index} has no values" - raise ValueError(msg) - return np.asarray(values, dtype=float) - - -def leakage_curve(result) -> np.ndarray: - return sum((population_curve(result, index) for index in range(2, 5)), start=np.zeros(len(result.times))) -``` +The grid contains 81 samples over one transfer interval. The `balanced` preset +sets numerical tolerances; `order=2` selects second-order TJM for noisy runs. +Each noisy calculation averages 24 trajectories. Sampling error, timestep error, +and the chosen level cutoffs need separate convergence checks. -## 3. Noiseless SWAP +## 4. Follow the noiseless transfer -```{code-cell} ipython3 -import copy +Initialize the simulator separately and omit a noise model for the baseline. +YAQS preserves the input state, so subsequent runs can reuse the same +preparation. +```{code-cell} python from mqt.yaqs import Simulator -sim = Simulator(show_progress=False) -result_clean = sim.run(copy.deepcopy(state), H_0, copy.deepcopy(sim_params)) +simulator = Simulator(show_progress=False) +noiseless = simulator.run(state, hamiltonian, params) +times = noiseless.times +values = np.asarray(noiseless.expectation_values) +print(f"Right transmon population at T: {values[2, -1]:.3f}") ``` -```{code-cell} ipython3 -left_clean = population_curve(result_clean, 0) -right_clean = population_curve(result_clean, 1) -times = sim_params.times -``` +`values` has shape `(8, 81)`: observables by sampled times. The first three rows +show the excitation moving through the chain. Rows 3 and 4 measure leakage on +the left and right transmons. Their sum is the expected number of transmons in +$|2\rangle$, rather than the probability that either transmon has leaked. The +resonator's higher photon states are not qubit leakage. -## 4. Noisy SWAP - -Relaxation and dephasing on transmon sites (even indices). Built-in `lowering` -and `pauli_z` processes are 2×2; for `qubit_dim = 3` we pass explicit jump -matrices ({class}`~mqt.yaqs.core.libraries.gate_library.Destroy` and a -computational-subspace dephasing operator). For log-normal and other distributed -noise strengths, see {doc}`realistic_noise_models`. - -```{code-cell} ipython3 -from mqt.yaqs import NoiseModel -from mqt.yaqs.core.libraries.gate_library import Destroy +```{code-cell} python +:tags: [hide-input] +import matplotlib.pyplot as plt +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", "font.serif": ["STIXGeneral"], "mathtext.fontset": "stix", + "font.size": 10, "axes.labelsize": 11, "axes.linewidth": 0.8, + "xtick.direction": "in", "ytick.direction": "in", "svg.fonttype": "none", + "legend.frameon": False, +}) +scaled_times = times / transfer_time +site_labels = ["Left transmon", "Resonator", "Right transmon"] +site_colors = ["#D55E00", "0.5", "#0072B2"] +fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.9), layout="constrained") +for site, label, color in zip(range(3), site_labels, site_colors, strict=True): + axes[0].plot(scaled_times, values[site], color=color, linewidth=1.8, label=label) +for row, label, color in ((3, "Left transmon", site_colors[0]), (4, "Right transmon", site_colors[2])): + axes[1].plot(scaled_times, 1e3 * values[row], color=color, linewidth=1.4, label=label) +axes[0].set(ylabel=r"$|1\rangle$ population", ylim=(0, 1.05)) +axes[1].set(ylabel=r"$|2\rangle$ population ($10^{-3}$)") +for ax, title in zip(axes, ["(a) Excitation transfer", "(b) Transmon leakage"], strict=True): + ax.set(xlabel=r"Time $t/T$", xlim=(0, 1)) + ax.set_title(title, loc="left", fontsize=11) + ax.legend(fontsize=8) +plt.show() +``` -relax = Destroy(qubit_dim).matrix -dephase = np.diag([1.0, -1.0, 1.0]).astype(complex) # |2⟩ unaffected +**The resonator mediates transfer between the transmons.** The resonator +population rises and falls while the receiving transmon approaches unit +population near $T$. Small, rapid excursions into $|2\rangle$ remain visible on +the separate linear scale. The full dipole interaction permits these excursions +even from a single-excitation preparation. The plotted receiver population does +not measure the fidelity of an arbitrary transferred quantum state. -noise_model = NoiseModel( - [{"name": "t1", "sites": [i], "strength": 0.03, "matrix": relax} for i in (0, 2)] - + [{"name": "dephase", "sites": [i], "strength": 0.02, "matrix": dephase} for i in (0, 2)] -) +## 5. Add multilevel relaxation and dephasing -noisy_params = AnalogSimParams( - observables=population_observables, - elapsed_time=T_swap, - dt=dt, - sample_timesteps=True, - num_traj=32, - random_seed=7, -) +The built-in `lowering` and `pauli_z` channels are two-dimensional. For a +three-level transmon, supply explicit matrices. The annihilation matrix $b$ +relaxes $|1\rangle$ to $|0\rangle$ and $|2\rangle$ to $|1\rangle$, with the +oscillator's $\sqrt{2}$ matrix element. The number operator $n$ produces pure +dephasing without directly changing populations. -result_noisy = sim.run(copy.deepcopy(state), H_0, noisy_params, noise_model) -``` +```{code-cell} python +from mqt.yaqs import NoiseModel -```{code-cell} ipython3 -left_noisy = population_curve(result_noisy, 0) -right_noisy = population_curve(result_noisy, 1) +lowering = np.diag(np.sqrt(np.arange(1, qubit_dim)), k=1) +number = np.diag(np.arange(qubit_dim)).astype(float) +relaxation_rates = coupling * np.array([0.05, 0.15, 0.6]) +results = {0.0: noiseless} +for rate in relaxation_rates: + noise = NoiseModel( + [{"name": "relaxation", "sites": [site], "strength": rate, "matrix": lowering} + for site in (0, 2)] + + [{"name": "dephasing", "sites": [site], "strength": 2 * rate, "matrix": number} + for site in (0, 2)] + ) + results[rate] = simulator.run(state, hamiltonian, params, noise) ``` -## 5. Comparison plot - -```{code-cell} ipython3 ---- -mystnb: - image: - width: 90% - align: center ---- -import matplotlib.pyplot as plt - -fig, (ax_pop, ax_leak) = plt.subplots(1, 2, figsize=(9, 3.5)) - -ax_pop.plot(times, right_clean, "-", color="tab:blue", label=r"noiseless right $P(|1\rangle)$") -ax_pop.plot(times, left_clean, "-", color="tab:orange", label=r"noiseless left $P(|1\rangle)$") -ax_pop.plot(times, right_noisy, "--", color="tab:blue", label=r"noisy right $P(|1\rangle)$") -ax_pop.plot(times, left_noisy, "--", color="tab:orange", label=r"noisy left $P(|1\rangle)$") -ax_pop.axvline(T_swap, color="gray", linestyle=":", alpha=0.6, label=r"$T_{\mathrm{swap}}$") -ax_pop.set_xlabel("time") -ax_pop.set_ylabel("probability") -ax_pop.set_title("SWAP populations: noiseless vs noisy") -ax_pop.legend(fontsize=8) -ax_pop.grid(alpha=0.3) - -leak_clean = leakage_curve(result_clean) -leak_noisy = leakage_curve(result_noisy) -ax_leak.plot(times, leak_clean, "-", color="tab:green", label="noiseless leakage") -ax_leak.plot(times, leak_noisy, "--", color="tab:red", label="noisy leakage") -ax_leak.set_xlabel("time") -ax_leak.set_ylabel(r"summed $|2\rangle$ population") -ax_leak.set_title("Occupation of the leakage level") -ax_leak.legend(fontsize=8) -ax_leak.grid(alpha=0.3) - -plt.tight_layout() +`strength` is a Lindblad rate: YAQS multiplies each supplied matrix by +`sqrt(strength)`. Here the jumps are $\sqrt{\gamma}\,b$ and $\sqrt{2\gamma}\,n$. +For an isolated transmon's $|0\rangle$–$|1\rangle$ transition, the relaxation +and pure-dephasing times are both $1/\gamma$. The sweep increases both channels +together and leaves the resonator noise-free. These deliberately short coherence +times make the competition with transfer visible; the parameters are +illustrative, rather than a fit to a device. + +Parallel execution is enabled by default. The documentation suppresses progress +bars with `show_progress=False`; omit that setting to see progress. The seed +fixes random streams for repeated runs with the same configuration. + +```{code-cell} python +:tags: [hide-input] +from matplotlib.colors import Normalize + +colors = ["0.2", "#56B4E9", "#0072B2", "#D55E00"] +fig, axes = plt.subplots(3, 2, figsize=(7.2, 6.6), layout="constrained") +for index, (ax, (rate, result)) in enumerate(zip(axes[:2].flat, results.items(), strict=True)): + means = np.asarray(result.expectation_values) + image = ax.pcolormesh(scaled_times, np.arange(3), means[:3], shading="auto", + cmap="cividis", norm=Normalize(0, 1), rasterized=True) + title = "(a) No noise" if index == 0 else rf"({chr(97 + index)}) $\gamma/g={rate / coupling:g}$" + ax.set(xlabel=r"Time $t/T$", yticks=[0, 1, 2], yticklabels=["Left", "Resonator", "Right"], xlim=(0, 1)) + ax.set_title(title, loc="left", fontsize=11) +fig.colorbar(image, ax=list(axes[:2].flat), label=r"$|1\rangle$ population", ticks=[0, 0.5, 1], shrink=0.8) + +for (rate, result), color in zip(results.items(), colors, strict=True): + means = np.asarray(result.expectation_values) + trajectories = np.asarray(result.trajectories) + label = "No noise" if rate == 0 else rf"$\gamma/g={rate / coupling:g}$" + for ax, mean, samples in ( + (axes[2, 0], means[2], trajectories[2]), + (axes[2, 1], means[5:8].sum(axis=0), trajectories[5:8].sum(axis=0)), + ): + ax.plot(scaled_times, mean, color=color, linewidth=1.8, label=label) + if samples.shape[0] > 1: + standard_error = samples.std(axis=0, ddof=1) / np.sqrt(samples.shape[0]) + ax.fill_between(scaled_times, mean - standard_error, mean + standard_error, color=color, alpha=0.15) +axes[2, 0].set(ylabel=r"Right transmon $|1\rangle$ population") +axes[2, 1].set(ylabel=r"Total excitation $\sum_i\langle n_i\rangle$") +for ax, title in zip(axes[2], ["(e) Received excitation", "(f) Excitation remaining"], strict=True): + ax.set(xlabel=r"Time $t/T$", xlim=(0, 1), ylim=(0, 1.08)) + ax.set_title(title, loc="left", fontsize=11) +axes[2, 0].legend(fontsize=8, loc="upper left") plt.show() ``` -## Related topics - -- {doc}`analog_simulation` — analog time evolution and noise models -- {doc}`realistic_noise_models` — distributed noise strengths -- {doc}`state_initialization` — custom `physical_dimensions` and basis states -- {doc}`simulation_parameters` — `sample_timesteps`, `num_traj`, and observables +**Stronger noise suppresses transfer to the right transmon.** Relaxation removes +excitation, while dephasing disrupts the coherent exchange through the +resonator. The lower panels separate received population from total excitation; +the latter can have small coherent excursions because of the counter-rotating +terms. All heatmaps use one color scale. Shading shows one standard error from +the trajectory samples, with sites summed within each trajectory before +estimating uncertainty in total excitation. These bands measure sampling +uncertainty, not numerical or level-cutoff error. + +## Further options + +Increase `num_traj` to reduce sampling fluctuations and refine `dt` and +numerical tolerances to check propagation accuracy. Increase `qubit_dim` and +`resonator_dim` separately to check level truncation, rebuilding the state, +observables, and jump matrices to match. Three transmon levels suffice to +illustrate leakage here; they do not establish convergence for a driven device. + +To add photon loss, supply a resonator-sized annihilation matrix at site 1. +Other custom channels and distributed strengths are described in +{doc}`realistic_noise_models`. See {doc}`hamiltonians` for longer alternating +chains, {doc}`state_initialization` for other preparations, and +{doc}`simulation_parameters` for accuracy and trajectory settings. diff --git a/docs/examples/trapped_ion.md b/docs/examples/trapped_ion.md index ffe81dcc8..caa1d6da2 100644 --- a/docs/examples/trapped_ion.md +++ b/docs/examples/trapped_ion.md @@ -2,228 +2,319 @@ file_format: mystnb kernelspec: name: python3 +language_info: + name: python mystnb: number_source_lines: true execution_timeout: 300 --- -```{code-cell} ipython3 -:tags: [remove-cell] -%config InlineBackend.figure_formats = ['svg'] -``` +# Trapped Ion Emulation -# Static and Moving Trapped-Ion Position-Grid Emulation +Moving a trapped ion changes its position, but can also leave it oscillating +after the trap stops. Random force impulses add another source of motion. We +first follow a displaced wavepacket in a fixed harmonic well, then move the well +and compare several noise strengths. Position distributions and motional energy +show how coherent transport excitation differs from heating. -This example evolves a **single ion** on a finite position grid with -{meth}`~mqt.yaqs.core.data_structures.mpo.MPO.trapped_ion`. Each ion is one MPO -site; the local Hilbert space is the grid itself. The Hamiltonian combines a -finite-difference kinetic term and a harmonic trap—see {doc}`hamiltonians` for -the factory API and two-ion Coulomb extensions. +This guide uses the standard YAQS installation and Matplotlib. Run the cells in +order in a notebook. For a script, use the entry-point guard in +{doc}`simulator_initialization`. -We first initialize a displaced harmonic-oscillator wavepacket in a static -central well. In the continuum limit, its center follows -$\langle x(t)\rangle = x_0 \cos(\omega t)$, so after half a trap period it -reaches the opposite turning point. +## 1. Build a harmonic trap on a position grid -## 1. Hamiltonian and initial state +Each ion occupies one MPO site, whose local basis consists of position-grid +points. `MPO.trapped_ion` combines a finite-difference kinetic operator with a +harmonic potential centered at `trap_center`. We use one ion and dimensionless +units with $\hbar=m=\omega=1$. Position is in oscillator-length units and time +is in units of $1/\omega$. -```{code-cell} ipython3 +```{code-cell} python import numpy as np -from mqt.yaqs import Hamiltonian, MPO, Observable, State +from mqt.yaqs import Hamiltonian, MPO, State +positions = np.linspace(-8.0, 8.0, 65) +grid_dim = len(positions) +grid_spacing = positions[1] - positions[0] omega = 1.0 -initial_displacement = 1.0 -half_period = np.pi / omega -positions = np.linspace(-8.0, 8.0, 33) -grid_dim = len(positions) -initial_grid_state = np.exp(-0.5 * (positions - initial_displacement) ** 2).astype(np.complex128) -initial_grid_state /= np.linalg.norm(initial_grid_state) +def trap_at(center): + return Hamiltonian.from_mpo( + MPO.trapped_ion(positions, masses=[1.0], omega=omega, trap_center=center) + ) + -hamiltonian = Hamiltonian.from_mpo(MPO.trapped_ion(positions, masses=[1.0], omega=omega)) -state = State(length=1, vector=initial_grid_state, physical_dimensions=[grid_dim]) -position_observable = Observable("position", 0, positions=positions) -``` +def state_at(center): + packet = np.exp(-0.5 * (positions - center) ** 2).astype(complex) + packet /= np.linalg.norm(packet) + return State( + 1, tensors=[packet.reshape(grid_dim, 1, 1)], physical_dimensions=[grid_dim], + ) -## 2. Noiseless evolution to $T/2$ - -```{code-cell} ipython3 -from mqt.yaqs import AnalogSimParams, Simulator - -params = AnalogSimParams( - observables=[position_observable], - elapsed_time=half_period, - dt=half_period / 16, - max_bond_dim=None, - svd_threshold=1e-12, - krylov_tol=1e-12, - preset="exact", - get_state=True, - sample_timesteps=True, -) -result = Simulator(show_progress=False).run(state, hamiltonian, params) -final_state = result.output_state.vector -position_expectation = np.real(result.expectation_values[0]) -final_x = float(position_expectation[-1]) +static_hamiltonian = trap_at(0.0) +static_state = state_at(1.0) ``` -The position observable is a custom one-site matrix on the grid basis. The final -$\langle x\rangle$ is close to $-x_0$ but not exact because the simulation uses -a finite grid and a finite-difference kinetic operator. +The normalized Gaussian approximates a displaced oscillator ground state. Its +components are amplitudes on the finite grid, so their squared magnitudes sum to +one. A single MPS tensor has shape `(grid_dim, 1, 1)`: one physical index and +two bond indices. This representation supports both the fixed and moving +Hamiltonians below. Supplying `vector=` instead selects the MCWF backend, which +does not support piecewise Hamiltonians. -```{code-cell} ipython3 -print(f"Initial = {initial_displacement:.6f}") -print(f"Final at T/2 = {final_x:.6f}") -print(f"Continuum target = {-initial_displacement:.6f}") +## 2. Follow an oscillating wavepacket + +Measure the mean position and the population of every grid point. The latter +uses projectors $|x_j\rangle\langle x_j|$ and gives a position distribution at +each sampled time, without requesting a sequence of output states. + +```{code-cell} python +from mqt.yaqs import AnalogSimParams, Observable, Simulator + +position_observable = Observable("position", 0, positions=positions) +grid_projectors = [Observable(np.diag(row), 0) for row in np.eye(grid_dim)] +period = 2 * np.pi / omega +static_params = AnalogSimParams( + observables=[position_observable, *grid_projectors], + elapsed_time=period, + dt=period / 80, + preset="balanced", +) +simulator = Simulator(show_progress=False) +static_result = simulator.run(static_state, static_hamiltonian, static_params) +static_times = static_result.times +static_values = np.asarray(static_result.expectation_values) +static_density = static_values[1:] / grid_spacing ``` -## 3. Wavepacket over time +`expectation_values` follows the supplied observable order. Stacking these +arrays gives shape `(66, 81)`: the mean position, then 65 grid populations. +Divide populations by the grid spacing to plot probability density per unit +position. In the continuum, the mean follows $\langle x(t)\rangle=\cos t$. That +curve is a useful comparison; the finite-difference Hamiltonian differs slightly +from the continuum oscillator. -```{code-cell} ipython3 ---- -mystnb: - image: - width: 90% - align: center ---- +```{code-cell} python +:tags: [hide-input] import matplotlib.pyplot as plt - -dense_hamiltonian = hamiltonian.to_matrix() -eigenvalues, eigenvectors = np.linalg.eigh(dense_hamiltonian) -coefficients = eigenvectors.conj().T @ initial_grid_state -phases = np.exp(-1j * eigenvalues[:, None] * params.times[None, :]) -states = eigenvectors @ (coefficients[:, None] * phases) -probability_density = np.abs(states) ** 2 - -fig, ax = plt.subplots(figsize=(7.2, 3.6), layout="constrained") -image = ax.imshow( - probability_density, - aspect="auto", - origin="lower", - extent=(params.times[0], params.times[-1], positions[0], positions[-1]), - cmap="viridis", +from matplotlib_inline.backend_inline import set_matplotlib_formats + +set_matplotlib_formats("svg") +plt.rcParams.update({ + "font.family": "serif", "font.serif": ["STIXGeneral"], "mathtext.fontset": "stix", + "font.size": 10, "axes.labelsize": 11, "axes.linewidth": 0.8, + "xtick.direction": "in", "ytick.direction": "in", "svg.fonttype": "none", + "legend.frameon": False, +}) +fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.9), layout="constrained") +image = axes[0].pcolormesh( + static_times, positions, static_density, shading="auto", cmap="cividis", + vmin=0, vmax=0.6, rasterized=True, ) -ax.plot(params.times, position_expectation, color="white", lw=1.4, label=r"$\langle x\rangle$") -ax.set_xlabel(r"$t$") -ax.set_ylabel(r"$x$") -ax.set_title("Position-grid wavepacket density") -ax.legend(loc="upper right") -fig.colorbar(image, ax=ax, label=r"$|\psi(x,t)|^2$") +axes[0].plot(static_times, static_values[0], color="white", linewidth=1.3) +axes[0].set(ylabel=r"Position $x$", ylim=(-3, 3)) +axes[0].set_title("(a) Position distribution and mean", loc="left", fontsize=11) +fig.colorbar(image, ax=axes[0], label="Probability density", shrink=0.85) +axes[1].plot(static_times, static_values[0], color="#0072B2", linewidth=1.8, label="YAQS") +axes[1].plot(static_times, np.cos(static_times), color="0.4", linestyle="--", label="Continuum") +axes[1].set(ylabel=r"Mean position $\langle x\rangle$", ylim=(-1.15, 1.15)) +axes[1].set_title("(b) Oscillation in a fixed well", loc="left", fontsize=11) +axes[1].legend(fontsize=9) +for ax in axes: + ax.set(xlabel=r"Time $t$", xlim=(0, period), xticks=[0, np.pi, 2 * np.pi], + xticklabels=["0", r"$\pi$", r"$2\pi$"]) plt.show() ``` -## 4. Transport in a moving harmonic well +**The packet oscillates about the trap center.** Its mean nearly follows the +continuum curve over one period. The heatmap contains the actual YAQS grid +populations; the white line marks their mean. Small changes in shape and phase +reflect the finite grid and its kinetic operator. -A moving trap is a piecewise Hamiltonian: one static well per ``dt`` interval, -then a hold at the target. The trap center is a staircase from -$q\nobreak=\nobreak-1$ to $q\nobreak=\nobreak1$, constant on each analog step. +## 3. Move the well, then hold it fixed -```{code-cell} ipython3 -transport_positions = np.linspace(-6.0, 6.0, 25) -transport_grid_dim = len(transport_positions) +Start a new packet at $q=-1$ and translate the well to $q=1$ over four time +units. The control is a staircase: each trap center remains fixed for `dt=0.1`, +followed by a four-unit hold at the target. This deliberately simple protocol +leaves enough residual motion to see in the position distribution. + +```{code-cell} python start_center = -1.0 target_center = 1.0 -transport_duration = 10.0 -hold_duration = 5.0 -dt = 0.25 - - -def trap_at(trap_center: float) -> Hamiltonian: - return Hamiltonian.from_mpo( - MPO.trapped_ion( - transport_positions, - masses=[1.0], - omega=omega, - trap_center=trap_center, - ) - ) - - +transport_duration = 4.0 +hold_duration = 4.0 +dt = 0.1 n_transport = round(transport_duration / dt) -transport_pieces = [ - (trap_at(start_center + (target_center - start_center) * (step / n_transport)), dt) - for step in range(n_transport) -] +transport_centers = np.linspace(start_center, target_center, n_transport, endpoint=False) +target_hamiltonian = trap_at(target_center) moving_hamiltonian = Hamiltonian.piecewise([ - *transport_pieces, - (trap_at(target_center), hold_duration), + *[(trap_at(center), dt) for center in transport_centers], + (target_hamiltonian, hold_duration), ]) +transport_state = state_at(start_center) +``` -transport_wavepacket = np.exp(-0.5 * (transport_positions - start_center) ** 2).astype(np.complex128) -transport_wavepacket /= np.linalg.norm(transport_wavepacket) -transport_state = State( - length=1, - tensors=[transport_wavepacket.reshape(transport_grid_dim, 1, 1)], - physical_dimensions=[transport_grid_dim], -) -transport_position = Observable("position", 0, positions=transport_positions) +`Hamiltonian.piecewise` selects the well for each interval. Piece durations must +be integer multiples of the simulation timestep, and their sum must equal +`elapsed_time`. Piecewise evolution currently requires an MPS and TDVP, which +are used here. Reducing `dt` while rebuilding the staircase also changes the +control waveform; it is a separate check from refining the spatial grid. + +## 4. Measure residual motion and heating + +Alongside the mean and grid populations, measure $\langle x^2\rangle$ and the +energy of the final well. The ensemble position width is +$\sigma_x=\sqrt{\langle x^2\rangle-\langle x\rangle^2}$. During the hold, the +final-well energy measures motion left by transport and added by noise. + +```{code-cell} python transport_params = AnalogSimParams( - observables=[transport_position], + observables=[ + position_observable, + Observable(np.diag(positions**2), 0), + Observable(target_hamiltonian.to_matrix(), 0), + *grid_projectors, + ], elapsed_time=transport_duration + hold_duration, dt=dt, - tdvp_sweeps=2, - max_bond_dim=None, - svd_threshold=1e-12, - krylov_tol=1e-12, - preset="exact", - sample_timesteps=True, + order=2, + num_traj=32, + preset="balanced", + random_seed=7, ) +noiseless = simulator.run(transport_state, moving_hamiltonian, transport_params) +times = noiseless.times +``` -transport_result = Simulator(parallel=False, show_progress=False).run( - transport_state, - moving_hamiltonian, - transport_params, -) -transport_expectation = np.real(transport_result.expectation_values[0]) -transport_centers = [ - start_center + (target_center - start_center) * (step / n_transport) for step in range(n_transport) -] -n_hold_times = len(transport_params.times) - n_transport -scheduled_centers = np.asarray([*transport_centers, *([target_center] * n_hold_times)]) -hold_mask = transport_params.times >= transport_duration -residual_motion = np.max(np.abs(transport_expectation[hold_mask] - target_center)) -assert residual_motion > 0.1 -print(f"Maximum displacement from the target during the hold: {residual_motion:.3f}") +The observable arrays have shape `(68, 81)`: mean position, mean squared +position, final-well energy, then grid populations. The `balanced` preset sets +numerical tolerances. Second-order TJM averages 32 trajectories for each noisy +run below; the noiseless baseline needs only one trajectory. + +## 5. Add random momentum kicks + +Model random force impulses as momentum kicks in either direction. Multiplying +the wavefunction by $e^{\pm i\kappa x}$ shifts its momentum by $\pm\kappa$ in +our units. The two custom jumps are $L_\pm=\sqrt{\gamma/2}\,e^{\pm i\kappa X}$, +where $X=\operatorname{diag}(x_j)$ and $\kappa=1$. Equal rates give no preferred +direction. The kicks heat the ion without friction or thermal relaxation. + +```{code-cell} python +from mqt.yaqs import NoiseModel + +noise_rates = [0.1, 0.4, 1.0] +kick_size = 1.0 +results = {0.0: noiseless} +for rate in noise_rates: + noise = NoiseModel([ + {"name": "momentum_kick", "sites": [0], "strength": rate / 2, + "matrix": np.diag(np.exp(1j * sign * kick_size * positions))} + for sign in (-1, 1) + ]) + results[rate] = simulator.run(transport_state, moving_hamiltonian, transport_params, noise) ``` -Each transport interval uses a fixed trap center. During the hold the well stays -at the target while the ion continues to move. - -```{code-cell} ipython3 -fig, ax = plt.subplots(figsize=(7.2, 3.4), layout="constrained") -ax.step( - transport_params.times, - scheduled_centers, - where="post", - linestyle="--", - label=r"trap center $q(t)$", -) -ax.plot(transport_params.times, transport_expectation, label=r"ion $\langle x(t)\rangle$") -ax.axvline(transport_duration, color="0.6", ls=":", label="end of transport") -ax.set_xlabel(r"$t$") -ax.set_ylabel(r"$x$") -ax.set_title("Residual motion after linear trap transport") -ax.legend() +YAQS multiplies each supplied matrix by `sqrt(strength)`. Each direction has +rate $\gamma/2$, so $\gamma$ is the total kick rate in oscillator units. The +noise acts throughout transport and the hold, while the initial state, control +waveform, time grid, and numerical settings stay fixed. The stronger rates make +spreading visible over this short protocol. The grid extends to $x=\pm8$ to +leave room for the heated packet. + +Parallel execution is enabled by default. The documentation suppresses progress +bars with `show_progress=False`; omit that setting to see progress. The seed +fixes random streams for the same configuration, and each run preserves the +input state. + +```{code-cell} python +:tags: [hide-input] +from matplotlib.colors import Normalize + +colors = ["0.2", "#56B4E9", "#0072B2", "#D55E00"] +n_hold = round(hold_duration / dt) +scheduled_centers = np.r_[transport_centers, np.full(n_hold + 1, target_center)] +hold_mask = times >= transport_duration +fig, axes = plt.subplots(3, 2, figsize=(7.2, 7.1), layout="constrained") +for index, (ax, (rate, result)) in enumerate(zip(axes[:2].flat, results.items(), strict=True)): + means = np.asarray(result.expectation_values) + density = means[3:] / grid_spacing + image = ax.pcolormesh(times, positions, density, shading="auto", cmap="cividis", + norm=Normalize(0, 0.6), rasterized=True) + ax.step(times, scheduled_centers, where="post", color="white", linestyle="--", + linewidth=1.1, label="Trap center") + ax.plot(times, means[0], color="#E69F00", linewidth=1.2, label="Mean position") + title = "(a) No noise" if index == 0 else rf"({chr(97 + index)}) $\gamma={rate:g}$" + ax.set(xlabel=r"Time $t$", ylabel=r"Position $x$", xlim=(0, times[-1]), ylim=(-8, 8)) + ax.set_title(title, loc="left", fontsize=11) + if index == 0: + ax.legend(loc="upper left", fontsize=8, labelcolor="white") +fig.colorbar(image, ax=list(axes[:2].flat), label="Probability density", shrink=0.8) + +for (rate, result), color in zip(results.items(), colors, strict=True): + means = np.asarray(result.expectation_values) + samples = result.trajectories[2] + energy_se = samples.std(axis=0, ddof=1) / np.sqrt(len(samples)) if len(samples) > 1 else np.zeros_like(times) + width = np.sqrt(means[1] - means[0] ** 2) + label = "No noise" if rate == 0 else rf"$\gamma={rate:g}$" + axes[2, 0].plot(times, width, color=color, linewidth=1.7, label=label) + axes[2, 1].plot(times[hold_mask], means[2, hold_mask], color=color, linewidth=1.7) + axes[2, 1].fill_between(times[hold_mask], (means[2] - energy_se)[hold_mask], + (means[2] + energy_se)[hold_mask], color=color, alpha=0.15, linewidth=0) +axes[2, 0].axvline(transport_duration, color="0.5", linestyle=":", linewidth=1) +axes[2, 0].set(xlabel=r"Time $t$", ylabel=r"Position width $\sigma_x$", xlim=(0, times[-1])) +axes[2, 0].set_title("(e) Ensemble position spread", loc="left", fontsize=11) +axes[2, 0].legend(fontsize=8, ncol=2) +axes[2, 1].set(xlabel=r"Time $t$", ylabel=r"Energy $\langle H(q=1)\rangle$", + xlim=(transport_duration, times[-1])) +axes[2, 1].set_title("(f) Motional energy during the hold", loc="left", fontsize=11) plt.show() ``` -The ion does not end at rest: during the hold, $q(t)$ stays at the target while -$\langle x(t)\rangle$ oscillates around it. This staircase protocol is -intentionally idealized: the trap center is constant on each ``dt`` interval and -jumps at interval boundaries, so the ion is kicked non-adiabatically. Residual -motion can degrade later operations; smooth ramps reduce that error. - -This example uses dimensionless units with $\hbar=m=\omega=1$. For dimensional -inputs, use compatible time and energy units. A finer grid, smaller `dt`, and -slower trajectory reduce spatial and non-adiabatic transport errors. - -## Related topics - -- {doc}`hamiltonians` — `MPO.trapped_ion` parameters and two-ion Coulomb - channels -- {doc}`transmon_emulation` — another mixed-dimensional hardware model -- {doc}`analog_simulation` — analog time evolution and noise models -- {doc}`state_initialization` — custom `physical_dimensions` and manual vectors +**Transport leaves a coherent oscillation; random kicks broaden the packet and +raises its energy.** After $t=4$, the dashed trap center stays fixed while the +mean position continues to oscillate. Without noise, the packet remains narrow +and its energy stays constant during the hold. Stronger noise spreads the +position distribution and increases the motional energy. All heatmaps share one +color scale. + +The width in panel (e) describes the ensemble position distribution, including +variation between trajectories. It is not an error bar on the mean position. +Shading in panel (f) shows one standard error of the trajectory-averaged energy. +Thirty-two trajectories make the trend visible, but the curves retain sampling +fluctuations. Increase `num_traj` to resolve smaller differences. + +## 6. Adapt the model + +Check grid spacing and boundaries before interpreting a quantitative result. The +kinetic operator uses zero exterior boundary values, and a heated packet can +reach the edges. Refine the grid and enlarge its range separately. The continuum +Gaussian is also only an approximate ground state of the discrete Hamiltonian. +For dimensional inputs, pass `hbar` to `MPO.trapped_ion` in units consistent +with the masses, positions, and angular frequency. The kinetic term depends on +$\hbar^2$. For time in seconds, divide one MPO core by $\hbar$ with +`mpo.tensors[0] /= hbar` before wrapping it with `Hamiltonian.from_mpo`. This +converts the energy operator to $H/\hbar$ for YAQS's evolution convention +$\exp(-iH\,dt)$. + +A slower or smoother transport protocol can reduce coherent residual motion. The +kick model isolates heating from random impulses. Other noise processes require +suitable jump operators; this model does not describe cooling. The factory +supports one or two ions, with a softened Coulomb interaction for two ions. It +describes motional dynamics on a position grid, rather than internal spin states +or a full laser-driven gate model. + +For a noiseless run, `get_state=True` also returns the final state. Noisy MPS +runs return ensemble observables and trajectory data instead of a single final +pure state. Grid projectors remain available for both cases, as shown above. + +## Related guides + +- {doc}`hamiltonians` — trap parameters, two-ion interactions, and piecewise + models. +- {doc}`analog_simulation` — noisy evolution, accuracy, and trajectory sampling. +- {doc}`state_initialization` — custom local dimensions and manual MPS tensors. +- {doc}`transmon_emulation` — excitation transfer in a multilevel hardware + model. diff --git a/docs/index.md b/docs/index.md index 76220d385..d3aa9dffe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -# MQT YAQS — Scalable simulation and characterization for open systems, noisy circuits, and realistic hardware +# MQT YAQS — Simulation and characterization of quantum systems and their environments MQT YAQS (pronounced "yaks" like the animals) is a Python library designed for **scalable, computationally efficient** simulation and characterization of open @@ -27,157 +27,104 @@ self ## User guide -MQT YAQS targets workloads that need **scale and efficiency**: large noisy -circuits, long analog time evolution, and hardware models with many degrees of -freedom. For smaller systems, **MCWF** (`vector`) and **Lindblad** -(`density_matrix`) analog backends are available as well; see -{doc}`examples/representation_comparison`. - -The pages below are **executable notebooks**: code cells run during the -documentation build, so examples stay in sync with the library. New users should -start with {doc}`installation`, then {doc}`examples/quickstart`. - -```{mermaid} -flowchart LR - state[State] - op[Hamiltonian or QuantumCircuit] - params["AnalogSimParams / DigitalSimParams"] - sim[Simulator] - result[Result] - state --> sim - op --> sim - params --> sim - sim --> result -``` +Start with installation and the quickstart, then choose a guide for your task. +The examples include working code and plots. -### Learning paths - -| I want to… | Read | -| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| Run my first simulation in under a minute | {doc}`examples/quickstart` | -| Configure truncation, presets, and trajectories | {doc}`examples/simulation_parameters` | -| Build Hamiltonians (Pauli, Hubbard, transmon, trapped ion, …) | {doc}`examples/hamiltonians` | -| Simulate open-system (analog) dynamics with noise | {doc}`examples/analog_simulation` | -| Model realistic noise (log-normal and other distributions) | {doc}`examples/realistic_noise_models` | -| Define custom single-site jump operators | {doc}`examples/realistic_noise_models` | -| Compare scalable MPS, MCWF, and Lindblad analog paths | {doc}`examples/representation_comparison` | -| Two-time correlations and typicality ensembles | {doc}`examples/ensemble_evolution` | -| Scheduled jumps at fixed times | {doc}`examples/scheduled_jumps` | -| Transmon–resonator SWAP (noiseless vs noisy) | {doc}`examples/transmon_emulation` | -| Static and moving trapped-ion position-grid dynamics | {doc}`examples/trapped_ion` | -| Characterize environmental memory effects via probing the process | {doc}`examples/characterization` | -| Study how long environmental memory persists in a system | {ref}`Memory persistence ` in {doc}`examples/characterization` | -| Train a surrogate and predict how a system evolves under control sequences | {doc}`examples/memory_surrogate` | -| Build a Markovian noise digital twin from measured trajectories | {doc}`examples/digital_twin` | -| Validate predictions at short temporal horizons with exact references | {doc}`examples/memory_surrogate` | -| Simulate a circuit and read observables | {doc}`examples/circuit_observables` | -| Get hardware-like shot histograms | {doc}`examples/circuit_shots` | -| Combine analog evolution and digital operations in one program | {doc}`examples/digital_analog_simulation` | -| Verify two circuits are equivalent | {doc}`examples/equivalence_checking` | -| Custom gate translation | {doc}`examples/custom_gates` | +| Section | Guides | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Start here | {doc}`Installation ` · {doc}`Quickstart ` | +| Simulation setup | {doc}`Quantum states ` · {doc}`Hamiltonians ` · {doc}`Noise models ` · {doc}`State representations ` · {doc}`Simulation parameters ` · {doc}`Simulator configuration ` | +| Simulation workflows | {doc}`Analog ` · {doc}`Digital circuits ` · {doc}`Shot-based circuits ` · {doc}`Analog-digital ` | +| Emulation | {doc}`Digital twin ` · {doc}`Superconducting qubits ` · {doc}`Trapped ions ` | +| Characterization and verification | {doc}`Environmental memory ` · {doc}`Circuit verification ` | +| Advanced examples | {doc}`Ensemble evolution ` · {doc}`Non-Markovian surrogate models (experimental) ` | +| Reference and contributing | {doc}`API ` · {doc}`Citations ` · {doc}`Changelog ` · {doc}`Upgrade guide ` · {doc}`Contributing ` · {doc}`Support ` | ```{toctree} -:caption: Getting started +:caption: Start here :hidden: :maxdepth: 1 :titlesonly: -installation -examples/quickstart -examples/state_initialization -examples/simulator_initialization -examples/simulation_parameters +Installation +Quickstart ``` ```{toctree} -:caption: Analog simulation +:caption: Simulation setup :hidden: :maxdepth: 1 :titlesonly: -examples/hamiltonians -examples/analog_simulation -examples/realistic_noise_models -examples/scheduled_jumps -examples/ensemble_evolution -examples/representation_comparison -examples/transmon_emulation -examples/trapped_ion +Quantum states +Hamiltonians +Noise models +State representations +Simulation parameters +Simulator configuration ``` ```{toctree} -:caption: Environmental Memory Characterization +:caption: Simulation workflows :hidden: :maxdepth: 1 :titlesonly: -examples/characterization -examples/memory_surrogate +Analog simulation +Digital (circuit) simulation +Shot-based simulation +Analog-digital simulation ``` ```{toctree} -:caption: Digital Twin +:caption: Emulation :hidden: :maxdepth: 1 :titlesonly: -examples/digital_twin +Digital twin +Superconducting qubit (transmon) emulation +Trapped ion emulation ``` ```{toctree} -:caption: Digital Circuit Simulation +:caption: Characterization and verification :hidden: :maxdepth: 1 :titlesonly: -examples/circuit_observables -examples/circuit_shots -examples/custom_gates -examples/equivalence_checking +Environmental memory characterization +Circuit verification ``` ```{toctree} -:caption: Digital–analog simulation +:caption: Advanced examples :hidden: :maxdepth: 1 :titlesonly: -examples/digital_analog_simulation -``` - -```{toctree} -:caption: Reference -:hidden: -:maxdepth: 1 -:titlesonly: +Ensemble evolution +Non-Markovian surrogate models (experimental) -references -CHANGELOG -UPGRADING ``` ```{toctree} -:caption: Developers +:caption: Reference and contributing :hidden: :maxdepth: 1 :titlesonly: -contributing -ai_usage -tooling -support -``` - -```{toctree} -:caption: API Reference -:hidden: -:glob: -:maxdepth: 1 - -api/mqt/yaqs/index +API reference +References and citations +Changelog +Upgrade guide +Contributing +AI usage +Development tools +Support ``` -## Contributors and Supporters +## Contributors and supporters The _[Munich Quantum Toolkit (MQT)](https://mqt.readthedocs.io)_ is developed by the [Chair for Design Automation](https://www.cda.cit.tum.de/) at the diff --git a/noxfile.py b/noxfile.py index 4fe90c02a..962316d30 100755 --- a/noxfile.py +++ b/noxfile.py @@ -245,13 +245,16 @@ def docs(session: nox.Session) -> None: args, posargs = parser.parse_known_args(session.posargs) serve = args.builder == "html" and session.interactive + install_args = ["--group", "docs", "--exact", "-e", "."] if serve: - session.install("sphinx-autobuild") + install_args.append("sphinx-autobuild") + session.install(*install_args) - env = {"UV_PROJECT_ENVIRONMENT": session.virtualenv.location} shared_args = [ "-n", # nitpicky mode "-T", # full tracebacks + "-W", # fail on warnings + "--keep-going", f"-b={args.builder}", "docs", f"docs/_build/{args.builder}", @@ -259,15 +262,58 @@ def docs(session: nox.Session) -> None: ] session.run( - "uv", - "run", - "--no-dev", # do not auto-install dev dependencies - "--group", - "docs", - "--all-extras", "sphinx-autobuild" if serve else "sphinx-build", *shared_args, - env=env, + env={**_CAPPED_NUMERICAL_THREADS, "YAQS_MAX_WORKERS": "3"}, + ) + + +@nox.session(name="docs-execute", python="3.14", reuse_venv=True) +def docs_execute(session: nox.Session) -> None: + """Execute all examples and regenerate saved notebook outputs.""" + session.install("--group", "docs", "--torch-backend", "cpu", "--exact", "-e", ".[qasm3,torch]") + with tempfile.TemporaryDirectory(prefix="yaqs-docs-execution-") as cache: + session.run( + "sphinx-build", + "-E", + "-a", + "-n", + "-T", + "-W", + "--keep-going", + "-j", + "1", + "-d", + "docs/_build/executed-doctrees", + "docs", + "docs/_build/executed", + env={ + **_CAPPED_NUMERICAL_THREADS, + "YAQS_MAX_WORKERS": "3", + "YAQS_DOCS_EXECUTE": "1", + "YAQS_DOCS_CACHE": cache, + }, + ) + + +@nox.session(name="docs-check", python="3.14", reuse_venv=True) +def docs_check(session: nox.Session) -> None: + """Test the build settings and check references without running guide notebooks.""" + session.install("--group", "docs", "--group", "test", "--exact", "-e", ".") + session.run("pytest", "-n", "0", "tests/docs", env=_CAPPED_NUMERICAL_THREADS) + session.run( + "sphinx-build", + "-E", + "-a", + "-n", + "-T", + "-W", + "--keep-going", + "-D", + "llms_txt_enabled=0", + "docs", + "docs/_build/check", + env={**_CAPPED_NUMERICAL_THREADS, "YAQS_MAX_WORKERS": "3"}, ) diff --git a/pyproject.toml b/pyproject.toml index 57e96e4f1..80d6ad40f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,9 @@ test = [ ] docs = [ "furo>=2025.12.19", + "jupyter-cache>=1", "myst-nb>=1.4", + "nbformat>=5.10", "qiskit[visualization]>=2.1,<3", "sphinx>=9", "sphinx-autoapi>=3.6", diff --git a/src/mqt/yaqs/analog/mcwf.py b/src/mqt/yaqs/analog/mcwf.py index acd133a30..927285b19 100644 --- a/src/mqt/yaqs/analog/mcwf.py +++ b/src/mqt/yaqs/analog/mcwf.py @@ -79,7 +79,7 @@ def preprocess_mcwf( ) -> MCWFContext: """Pre-compute dense operators and initial state for MCWF simulation. - Called once per :meth:`Simulator.run` before trajectory workers start. + Called once per :meth:`~mqt.yaqs.Simulator.run` before trajectory workers start. Args: psi_initial: Dense state vector (unit norm applied here). diff --git a/src/mqt/yaqs/characterization/memory/backends/surrogates/model.py b/src/mqt/yaqs/characterization/memory/backends/surrogates/model.py index 7150f871a..9db8614dd 100644 --- a/src/mqt/yaqs/characterization/memory/backends/surrogates/model.py +++ b/src/mqt/yaqs/characterization/memory/backends/surrogates/model.py @@ -209,7 +209,7 @@ def predict_final_state_batch( the batch size of ``e_features``. e_features: Per-step features of shape ``(B, T, d_e)``. restore_training: If ``False``, do not restore ``.train()`` after inference (for callers that - run many batched predictions in a loop, e.g. :meth:`entropy`). + run many batched predictions in a loop). Returns: Tensor of shape ``(B, d_rho)``. @@ -253,7 +253,9 @@ def _num_interventions_for_probe(self) -> int: return int(self.num_interventions) def evaluate_probes(self, probe_set: ProbeSet, *, initial_rho: np.ndarray | None = None) -> np.ndarray: - """Evaluate split-cut probe responses for :func:`run_memory_characterization`. + """Evaluate split-cut probe responses. + + Used by :func:`~mqt.yaqs.characterization.memory.operational_memory.run.run_memory_characterization`. Args: probe_set: Sampled split-cut probes. diff --git a/src/mqt/yaqs/characterization/memory/backends/surrogates/workflow.py b/src/mqt/yaqs/characterization/memory/backends/surrogates/workflow.py index c66c121db..7d693184b 100644 --- a/src/mqt/yaqs/characterization/memory/backends/surrogates/workflow.py +++ b/src/mqt/yaqs/characterization/memory/backends/surrogates/workflow.py @@ -167,7 +167,8 @@ def build_training_dataset( show_progress: Whether to show progress bars. timesteps: Optional process-tensor schedule evolution durations (defaults to ``[sim_params.dt] * (num_interventions + 1)``). - init_mode: Initial-state sampling mode (see :func:`sample_initial_psi`). + init_mode: Initial-state sampling mode (see + :func:`~mqt.yaqs.characterization.memory.backends.surrogates.utils.sample_initial_psi`). solver: Stochastic solver (``"MCWF"`` or ``"TJM"``); defaults to ``"MCWF"``. intervention_style: Training intervention style (``"haar"``, ``"clifford"``, or ``"measure_prepare"``). @@ -290,7 +291,8 @@ def train_surrogate_model( :func:`build_training_dataset`; defaults to ``"MCWF"``. intervention_style: Training intervention style passed to :func:`build_training_dataset`. model_kwargs: Optional keyword arguments forwarded to :class:`ProcessTensorSurrogate`. - train_kwargs: Optional keyword arguments forwarded to :meth:`ProcessTensorSurrogate.fit`. + train_kwargs: Optional keyword arguments forwarded to + :meth:`~mqt.yaqs.characterization.memory.backends.surrogates.model.ProcessTensorSurrogate.fit`. Returns: Trained :class:`ProcessTensorSurrogate`. diff --git a/src/mqt/yaqs/characterization/memory/backends/tomography/process_tensors.py b/src/mqt/yaqs/characterization/memory/backends/tomography/process_tensors.py index 770b80777..fc7b05a6e 100644 --- a/src/mqt/yaqs/characterization/memory/backends/tomography/process_tensors.py +++ b/src/mqt/yaqs/characterization/memory/backends/tomography/process_tensors.py @@ -200,7 +200,7 @@ def validate_initial_rho( def convert_probe_callable( step: AnyInterventionStep, ) -> Callable[[NDArray[np.complex128]], NDArray[np.complex128]]: - """Convert a probe-grid step to a CP map callable for :meth:`~SupportsPredict.predict`. + """Convert a probe-grid step to a CP map callable for process-tensor prediction. Args: step: Structured dict step or measure/prepare ket pair. @@ -220,13 +220,14 @@ def unitary_map(rho: NDArray[np.complex128]) -> NDArray[np.complex128]: def evaluate_probes(process_tensor: SupportsPredict, probe_set: ProbeSet) -> np.ndarray: - """Evaluate split-cut probe Pauli responses via process-tensor :meth:`predict`. + """Evaluate split-cut probe Pauli responses through process-tensor prediction. Shared by dense and MPO process tensors for operational-memory V-matrix assembly. Does not densify MPO tensors. Args: - process_tensor: Backend implementing :meth:`~SupportsPredict.predict`. + process_tensor: Backend implementing + :meth:`~mqt.yaqs.characterization.memory.backends.tomography.process_tensors.SupportsPredict.predict`. probe_set: Sampled split-cut probes. Returns: @@ -1043,7 +1044,8 @@ def compute_temporal_entropy( def evaluate_probes(self, probe_set: ProbeSet) -> np.ndarray: """Evaluate split-cut probe Pauli responses for V-matrix assembly. - Uses native MPO :meth:`predict` (does not densify the process tensor). + Uses :meth:`~mqt.yaqs.characterization.memory.backends.tomography.process_tensors.MPOProcessTensor.predict` + without densifying the process tensor. Args: probe_set: Sampled split-cut probes. diff --git a/src/mqt/yaqs/characterization/memory/shared/metrics.py b/src/mqt/yaqs/characterization/memory/shared/metrics.py index b5d698ed8..0103f2622 100644 --- a/src/mqt/yaqs/characterization/memory/shared/metrics.py +++ b/src/mqt/yaqs/characterization/memory/shared/metrics.py @@ -59,7 +59,7 @@ def compute_rel_fro_error(a_mat: NDArray[np.complex128], b_mat: NDArray[np.compl b_mat: Reference matrix. Returns: - Relative Frobenius error: ||A-B||_F / max(||B||_F, eps). + float: Relative Frobenius error, ||A-B||_F / max(||B||_F, eps). """ a, b = _validate_square_matrix_pair(a_mat, b_mat, name_a="a_mat", name_b="b_mat") num = np.linalg.norm(a - b, "fro") @@ -75,7 +75,7 @@ def compute_trace_distance(rho: NDArray[np.complex128], sigma: NDArray[np.comple sigma: Density matrix. Returns: - Trace distance: 0.5 * ||rho - sigma||_1. + float: Trace distance, 0.5 * ||rho - sigma||_1. """ rho_h, sigma_h = _validate_square_matrix_pair(rho, sigma, name_a="rho", name_b="sigma") diff_mat = rho_h - sigma_h diff --git a/src/mqt/yaqs/characterization/memory/shared/utils.py b/src/mqt/yaqs/characterization/memory/shared/utils.py index 9717cec20..6fb3f4540 100644 --- a/src/mqt/yaqs/characterization/memory/shared/utils.py +++ b/src/mqt/yaqs/characterization/memory/shared/utils.py @@ -120,7 +120,7 @@ def _dense_state_to_mps(psi: NDArray[np.complex128], *, length: int) -> MPS: """Convert a dense site-0-LSB qubit state to an MPS to numerical precision. Args: - psi: Dense state vector in the same order as :meth:`MPS.to_vec`. + psi: Dense state vector in the same order as :meth:`~mqt.yaqs.MPS.to_vec`. length: Number of qubits in the state. Returns: diff --git a/src/mqt/yaqs/core/data_structures/hamiltonian.py b/src/mqt/yaqs/core/data_structures/hamiltonian.py index 0081c5052..2a61734cf 100644 --- a/src/mqt/yaqs/core/data_structures/hamiltonian.py +++ b/src/mqt/yaqs/core/data_structures/hamiltonian.py @@ -512,7 +512,7 @@ def _warn_large_hilbert_dim(dim: int, *, action: str) -> None: def ensure_mpo(self) -> Hamiltonian: """Materialize and cache an MPO form (used by TJM / ``State.representation='mps'``). - Dense and sparse sources are converted via :meth:`MPO.from_matrix` + Dense and sparse sources are converted via :meth:`~mqt.yaqs.MPO.from_matrix` without changing the public site order. Sparse input is densified only when this path is requested. Large Hilbert-space conversions emit a ``RuntimeWarning`` matching the ``preprocess_mcwf`` threshold. diff --git a/src/mqt/yaqs/core/data_structures/hamiltonian_utils.py b/src/mqt/yaqs/core/data_structures/hamiltonian_utils.py index e95c71326..d5352a5ae 100644 --- a/src/mqt/yaqs/core/data_structures/hamiltonian_utils.py +++ b/src/mqt/yaqs/core/data_structures/hamiltonian_utils.py @@ -26,7 +26,7 @@ def sparse_to_csr(matrix: scipy.sparse.spmatrix) -> scipy.sparse.csr_matrix: def attach_mpo(wrapped: Hamiltonian, mpo: MPO) -> None: - """Initialize ``wrapped`` from an existing MPO (factory helper for :meth:`Hamiltonian.from_mpo`).""" + """Initialize ``wrapped`` from an existing MPO (helper for :meth:`~mqt.yaqs.Hamiltonian.from_mpo`).""" wrapped.length = mpo.length wrapped.physical_dimension = mpo.physical_dimension # Private fields: wrapped is a fresh Hamiltonian from __new__; attach_mpo is the sole initializer. @@ -42,7 +42,7 @@ def attach_piecewise( pieces: tuple[tuple[Hamiltonian, float], ...], length: int, ) -> None: - """Initialize ``wrapped`` from static Hamiltonian pieces (factory helper for :meth:`Hamiltonian.piecewise`).""" + """Initialize ``wrapped`` from static Hamiltonian pieces (helper for :meth:`~mqt.yaqs.Hamiltonian.piecewise`).""" wrapped.length = length wrapped.physical_dimension = pieces[0][0].physical_dimension wrapped._tensors = None # ruff:ignore[private-member-access] diff --git a/src/mqt/yaqs/core/data_structures/mpo.py b/src/mqt/yaqs/core/data_structures/mpo.py index 8d38a3c56..d2db6719a 100644 --- a/src/mqt/yaqs/core/data_structures/mpo.py +++ b/src/mqt/yaqs/core/data_structures/mpo.py @@ -1915,7 +1915,7 @@ def to_matrix(self) -> NDArray[np.complex128]: :meth:`to_sparse_matrix` use the same order. Returns: - Dense operator matrix acting on vectors from :meth:`MPS.to_vec`. + Dense operator matrix acting on vectors from :meth:`~mqt.yaqs.MPS.to_vec`. """ mat = self.tensors[-1] for tensor in reversed(self.tensors[:-1]): diff --git a/src/mqt/yaqs/core/data_structures/state.py b/src/mqt/yaqs/core/data_structures/state.py index 5644695da..772804fb5 100644 --- a/src/mqt/yaqs/core/data_structures/state.py +++ b/src/mqt/yaqs/core/data_structures/state.py @@ -92,7 +92,7 @@ class State: """Initial quantum state for :meth:`~mqt.yaqs.Simulator.run`. Specify *what* to simulate (length, preset, optional raw data) and *how* to represent it - during evolution (:attr:`representation`). Materialization happens at construction; + during evolution (``representation``). Materialization happens at construction; pass the ``State`` to :meth:`~mqt.yaqs.Simulator.run` (including in parameter loops). - **Presets** — ``State(L, initial="zeros")``; default ``representation="mps"`` (TJM). @@ -330,7 +330,7 @@ def _dense_vector_from_preset(self) -> NDArray[np.complex128]: @property def mps(self) -> MPS: - """MPS when :attr:`representation` is ``"mps"``. + """MPS when ``representation`` is ``"mps"``. Raises: RuntimeError: If the state is not encoded as ``"mps"``. diff --git a/src/mqt/yaqs/core/libraries/circuit_library.py b/src/mqt/yaqs/core/libraries/circuit_library.py index bb403e1c7..7b4d11865 100644 --- a/src/mqt/yaqs/core/libraries/circuit_library.py +++ b/src/mqt/yaqs/core/libraries/circuit_library.py @@ -39,8 +39,7 @@ def create_ising_circuit( g (float): Transverse field strength. dt (float): Time step for the simulation. timesteps (int): Number of time steps to simulate. - periodic (bool, optional): If True, add a long-range gate between qubits 0 and L-1. - Defaults to False. + periodic: If True, add a long-range gate between qubits 0 and L-1. Defaults to False. Returns: QuantumCircuit: A quantum circuit representing the Ising model evolution. diff --git a/src/mqt/yaqs/core/libraries/noise_library.py b/src/mqt/yaqs/core/libraries/noise_library.py index 8e68d6bdc..b010359aa 100644 --- a/src/mqt/yaqs/core/libraries/noise_library.py +++ b/src/mqt/yaqs/core/libraries/noise_library.py @@ -209,15 +209,16 @@ class NoiseLibrary: lowering_two: Two-site lowering noise (11 --> 00). crosstalk_zz: Cross talk between neighboring sites along the z-axis. crosstalk_xx: Cross talk between neighboring sites along the x-axis. - crosstalk_y: Cross talk between neighboring sites along the y-axis. + crosstalk_yy: Cross talk between neighboring sites along the y-axis. crosstalk_xy: Cross talk between neighboring sites with X x Y. crosstalk_yx: Cross talk between neighboring sites with Y x X. crosstalk_zy: Cross talk between neighboring sites with Z x Y. crosstalk_zx: Cross talk between neighboring sites with Z x X. crosstalk_yz: Cross talk between neighboring sites with Y x Z. crosstalk_xz: Cross talk between neighboring sites with X x Z. - Note: Long-range crosstalk is handled by NoiseModel by attaching per-site - factors for non-adjacent pairs based on the process name (e.g., 'crosstalk_xy'). + + ``NoiseModel`` handles long-range crosstalk with per-site factors for + non-adjacent pairs, inferred from the process name (e.g., ``crosstalk_xy``). """ # Canonical names diff --git a/src/mqt/yaqs/core/linalg/__init__.py b/src/mqt/yaqs/core/linalg/__init__.py index 346c17060..48198ecf7 100644 --- a/src/mqt/yaqs/core/linalg/__init__.py +++ b/src/mqt/yaqs/core/linalg/__init__.py @@ -8,7 +8,8 @@ """SciPy-style dense linear algebra with BLAS-thread-safe defaults. This package mirrors :mod:`scipy.linalg` for the subset of operations YAQS uses -internally; submodules group related helpers (e.g. :mod:`.expm`, :mod:`.svd`). +internally; submodules group related helpers (e.g. :mod:`~mqt.yaqs.core.linalg.expm`, +:mod:`~mqt.yaqs.core.linalg.svd`). """ from __future__ import annotations diff --git a/src/mqt/yaqs/core/methods/bug.py b/src/mqt/yaqs/core/methods/bug.py index 74197cefa..0c23e2b46 100644 --- a/src/mqt/yaqs/core/methods/bug.py +++ b/src/mqt/yaqs/core/methods/bug.py @@ -43,8 +43,8 @@ def prepare_canonical_site_tensors( mpo: The MPO. Returns: - canon_tensors: The list of the canonical site tensors. - left_blocks: The list of the left environments. + Tuple ``(canon_tensors, left_blocks)`` containing the canonical site tensors + and left MPO environments. """ canon_tensors = copy(state.tensors) left_end_dimension = state.tensors[0].shape[1] diff --git a/src/mqt/yaqs/digital/utils/contraction_utils.py b/src/mqt/yaqs/digital/utils/contraction_utils.py index cd00e50cc..bfc606eda 100644 --- a/src/mqt/yaqs/digital/utils/contraction_utils.py +++ b/src/mqt/yaqs/digital/utils/contraction_utils.py @@ -56,7 +56,7 @@ def apply_gate( theta (NDArray[np.complex128]): The local tensor to update. site0 (int): The first qubit (site) index. site1 (int): The second qubit (site) index. - conjugate (bool, optional): Whether to apply the conjugated version of the gate tensor. Defaults to False. + conjugate: Whether to apply the conjugated version of the gate tensor. Defaults to False. Returns: NDArray[np.complex128]: The updated local tensor after applying the gate. @@ -140,7 +140,7 @@ def apply_temporal_zone( theta (NDArray[np.complex128]): The local tensor to update. dag (DAGCircuit): The DAGCircuit from which to extract the temporal zone. qubits (list[int]): The qubit indices on which to apply the temporal zone (typically two neighboring qubits). - conjugate (bool, optional): Whether to apply the gates in conjugated form. Defaults to False. + conjugate: Whether to apply the gates in conjugated form. Defaults to False. Returns: NDArray[np.complex128]: The updated tensor after applying the temporal zone. diff --git a/tests/docs/test_build.py b/tests/docs/test_build.py new file mode 100644 index 000000000..655889099 --- /dev/null +++ b/tests/docs/test_build.py @@ -0,0 +1,383 @@ +# Copyright (c) 2025 - 2026 Chair for Design Automation, TUM +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Integration tests for executable documentation builds.""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import zlib +from pathlib import Path +from string import Template + +import pytest + + +def _write_configuration(source: Path, extensions: list[str], extra: str = "") -> None: + """Use the real build settings with a small, self-contained documentation tree. + + Args: + source: Documentation source directory. + extensions: Extensions needed by this test. + extra: Additional configuration settings. + """ + configuration = Path(__file__).parents[2] / "docs" / "conf.py" + shutil.copytree(configuration.parent / "_ext", source / "_ext") + (source / "conf.py").write_text( + configuration.read_text() + + f"\nextensions = {extensions!r}\n" + + 'html_theme = "basic"\n' + + "html_theme_options = {}\n" + + "html_static_path = []\n" + + "html_css_files = []\n" + + "templates_path = []\n" + + "intersphinx_mapping = {}\n" + + extra + ) + + +@pytest.mark.parametrize("missing_source", [False, True]) +def test_included_release_links(tmp_path: Path, *, missing_source: bool) -> None: + """Included release notes link to source and guides without changing the originals.""" + pytest.importorskip("sphinx") + pytest.importorskip("myst_nb") + pytest.importorskip("pybtex") + source = tmp_path / "docs" + source.mkdir() + _write_configuration(source, ["myst_nb"], 'nb_execution_mode = "off"\n') + module = tmp_path / "src" / "example.py" + module.parent.mkdir() + if not missing_source: + module.write_text('"""Example source."""\n') + releases = { + "CHANGELOG.md": "# Changelog\n\nSee [example](src/example.py).\n", + "UPGRADING.md": "# Upgrading\n\nSee [guide](docs/guide.md#time-dependent-hamiltonians).\n", + } + for name, text in releases.items(): + (tmp_path / name).write_text(text) + shutil.copyfile(Path(__file__).parents[2] / "docs" / name, source / name) + (source / "index.md").write_text("# Release documentation\n\n```{toctree}\nCHANGELOG\nUPGRADING\nguide\n```\n") + (source / "guide.md").write_text("# Guide\n\n## Time-dependent Hamiltonians\n") + output = tmp_path / "html" + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - sys.executable is trusted. + [sys.executable, "-m", "sphinx", "-W", "-T", "-b", "html", str(source), str(output)], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + log = completed.stdout + completed.stderr + for name, text in releases.items(): + assert (tmp_path / name).read_text() == text + if missing_source: + assert completed.returncode != 0 + assert "cross-reference target not found: 'src/example.py'" in log + return + assert completed.returncode == 0, log + assert "WARNING:" not in log + assert ( + 'href="https://github.com/munich-quantum-toolkit/yaqs/blob/main/src/example.py"' + in (output / "CHANGELOG.html").read_text() + ) + assert 'href="guide.html#time-dependent-hamiltonians"' in (output / "UPGRADING.html").read_text() + + +def test_html_and_markdown_share_notebook_execution(tmp_path: Path) -> None: + """Publishing restores both formats from saved outputs without starting a kernel.""" + pytest.importorskip("sphinx") + pytest.importorskip("myst_nb") + pytest.importorskip("sphinx_llm.txt") + pytest.importorskip("pybtex") + yaml = pytest.importorskip("yaml") + + source = tmp_path / "docs" + source.mkdir() + _write_configuration(source, ["myst_nb", "sphinx_llm.txt", "yaqs_api", "yaqs_examples"]) + records = tmp_path / "executions.jsonl" + (source / "index.md").write_text( + "---\nfile_format: mystnb\nkernelspec:\n name: python3\nlanguage_info:\n name: python\n---\n\n" + "# Executable documentation\n\n" + "```{code-cell} ipython3\n" + "import json\n" + "import os\n" + "from pathlib import Path\n" + "import numba\n" + "import numpy as np\n" + "from threadpoolctl import threadpool_info\n" + "from mqt.yaqs import Simulator\n\n" + "from IPython.display import SVG, display\n\n" + "np.eye(4) @ np.eye(4)\n" + "record = {\n" + ' "thread_limits": {name: os.environ[name] for name in (\n' + ' "MKL_NUM_THREADS", "NUMBA_NUM_THREADS", "NUMEXPR_NUM_THREADS",\n' + ' "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS",\n' + " )},\n" + ' "numba_threads": numba.get_num_threads(),\n' + ' "blas_threads": [pool["num_threads"] for pool in threadpool_info()],\n' + ' "workers": Simulator(show_progress=False).max_workers,\n' + "}\n" + f"with Path({str(records)!r}).open('a') as stream:\n" + " stream.write(json.dumps(record) + '\\n')\n" + 'print("NOTEBOOK_RESULT_" + str(6 * 7))\n' + 'display(SVG(\'' + '\'))\n' + "```\n" + ) + output = tmp_path / "_build" / "html" + environment = os.environ.copy() + for name in ( + "MKL_NUM_THREADS", + "NUMBA_NUM_THREADS", + "NUMEXPR_NUM_THREADS", + "OMP_NUM_THREADS", + "OPENBLAS_NUM_THREADS", + "YAQS_MAX_WORKERS", + "PYTEST_XDIST_WORKER", + ): + environment.pop(name, None) + environment["IPYTHONDIR"] = str(tmp_path / ".ipython") + environment["JUPYTER_RUNTIME_DIR"] = str(tmp_path / ".jupyter") + environment["READTHEDOCS_VIRTUALENV_PATH"] = sys.prefix + environment["READTHEDOCS_OUTPUT"] = str(output.parent) + environment["YAQS_DOCS_CACHE"] = str(tmp_path / "notebook-cache") + configuration = yaml.safe_load((Path(__file__).parents[2] / ".readthedocs.yaml").read_text()) + command = configuration["build"]["jobs"]["build"]["html"][0] + generated = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - sys.executable is trusted. + [Template(argument).substitute(environment) for argument in shlex.split(command)], + cwd=tmp_path, + env={**environment, "YAQS_DOCS_EXECUTE": "1"}, + check=False, + capture_output=True, + text=True, + timeout=60, + ) + assert generated.returncode == 0, generated.stdout + generated.stderr + assert (source / "_outputs" / "index.ipynb").is_file() + assert (source / "_outputs" / "manifest.json").is_file() + shutil.rmtree(output.parent) + shutil.rmtree(source / "_build") + shutil.rmtree(environment["YAQS_DOCS_CACHE"]) + # Prose changes must appear beside the saved outputs without invalidating them. + index = source / "index.md" + index.write_text( + index.read_text().replace("# Executable documentation", "# Updated documentation\n\nRevised guide text.") + ) + environment.pop("YAQS_DOCS_EXECUTE", None) + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - sys.executable is trusted. + [Template(argument).substitute(environment) for argument in shlex.split(command)], + cwd=tmp_path, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=60, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + assert "WARNING:" not in completed.stdout + completed.stderr + child_log = re.search(r"Subprocess output available at: (.+)", completed.stdout) + assert child_log is not None, completed.stdout + assert "WARNING:" not in Path(child_log[1].strip()).read_text(encoding="utf-8") + runs = [json.loads(line) for line in records.read_text().splitlines()] + assert len(runs) == 1 + assert all(limit == "1" for limit in runs[0]["thread_limits"].values()) + assert runs[0]["numba_threads"] == 1 + assert 1 <= runs[0]["workers"] <= 2 + assert all(threads == 1 for threads in runs[0]["blas_threads"]) + for artifact in ("index.html", "index.html.md", "llms-full.txt"): + assert "NOTEBOOK_RESULT_42" in (output / artifact).read_text() + markdown = (output / "index.html.md").read_text() + figures = re.findall(r"!\[[^\]]*\]\(([^)]+\.svg)\)", markdown) + assert len(figures) == 1, markdown + assert (output / figures[0]).is_file() + assert figures[0] in (output / "index.html").read_text() + assert "image/svg+xml" not in markdown + assert "Updated documentation" in (output / "index.html").read_text() + + package = tmp_path / "src" / "example.py" + package.parent.mkdir() + saved = source / "_outputs" / "index.ipynb" + original_source = index.read_text() + original_output = saved.read_text() + invalid_inputs = ( + (index, original_source.replace("6 * 7", "6 * 8"), "stale example outputs"), + ( + index, + original_source.replace("language_info:", "mystnb:\n execution_mode: force\nlanguage_info:"), + "requires", + ), + (package, "changed = True\n", "package code or dependencies"), + (tmp_path / "uv.lock", "changed dependencies\n", "package code or dependencies"), + (source / "_outputs" / "manifest.json", None, "Missing or invalid example output manifest"), + (saved, None, "Missing or changed saved notebook"), + (saved, original_output + "\n", "Missing or changed saved notebook"), + (source / "conf.py", (source / "conf.py").read_text() + '\nnb_execution_mode = "force"\n', "requires"), + ) + for path, replacement, message in invalid_inputs: + previous = path.read_text() if path.exists() else None + if replacement is None: + path.unlink() + else: + path.write_text(replacement) + rejected = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - sys.executable is trusted. + [sys.executable, "-m", "sphinx", "-E", "-W", "-b", "html", str(source), str(output)], + cwd=tmp_path, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=60, + ) + assert rejected.returncode != 0, str(path) + assert message in rejected.stdout + rejected.stderr + assert len(records.read_text().splitlines()) == 1 + if previous is None: + path.unlink() + else: + path.write_text(previous) + + +@pytest.mark.parametrize("missing_reference", [False, True]) +def test_api_reexports_and_type_links(tmp_path: Path, *, missing_reference: bool) -> None: + """Public aliases link to one complete API page, and missing targets still fail.""" + pytest.importorskip("sphinx") + pytest.importorskip("autoapi") + pytest.importorskip("sphinx_llm.txt") + pytest.importorskip("pybtex") + source = tmp_path / "docs" + source.mkdir() + package = tmp_path / "src" / "example_api" + package.mkdir(parents=True) + (package / "__init__.py").write_text('from .model import Example, model\n__all__ = ["Example", "model"]\n') + legacy_module = package / "legacy.py" + legacy_module.write_text('"""A module that will be removed between builds."""\n') + (package / "model.py").write_text( + "from __future__ import annotations\n" + "import numpy as np\n" + "from numpy.typing import NDArray\n\n" + "class Example:\n" + ' """An example with documented attributes.\n\n' + " Attributes:\n" + " weight (float): Weight used by the example.\n" + ' """\n\n' + " weight: float = 1.0\n\n" + " def sample(self, data: NDArray[np.float64], /, *, dtype: type) -> NDArray[np.float64]:\n" + ' """Return the supplied values.\n\n' + " Args:\n" + " data (NDArray[np.float64]): Values to return.\n" + " dtype (type): Type used for sampling.\n" + ' """\n' + " return data\n\n" + "def helper() -> int:\n" + ' """A supported low-level helper."""\n' + " return 1\n" + "\ndef model() -> int:\n" + ' """A function sharing its module name."""\n' + " return 1\n" + ) + inventory = tmp_path / "types.inv" + inventory.write_bytes( + b"# Sphinx inventory version 2\n# Project: External types\n# Version: 1\n" + b"# The remainder of this file is compressed using zlib.\n" + + zlib.compress( + b"numpy.typing.NDArray py:data 1 ndarray.html#numpy.typing.NDArray -\n" + b"numpy.float64 py:attribute 1 scalar.html#numpy.float64 -\n" + b"type py:class 1 type.html#type -\n" + b"float py:class 1 float.html#float -\n" + b"int py:class 1 int.html#int -\n" + ) + ) + _write_configuration( + source, + ["autoapi.extension", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", "sphinx_llm.txt", "yaqs_api"], + f"autoapi_dirs = [{str(package)!r}]\n" + "autoapi_add_toctree_entry = True\n" + f'intersphinx_mapping = {{"python": ("https://example.invalid/types/", {str(inventory)!r})}}\n', + ) + (source / "index.rst").write_text( + "API documentation\n=================\n\n" + ":class:`example_api.Example` and :meth:`example_api.Example.sample` use " + ":attr:`example_api.Example.weight`.\n\n" + "See :func:`example_api.model.helper` for the low-level API.\n\n" + "A module :mod:`example_api.model` and function :func:`example_api.model` share a name.\n\n" + ".. toctree::\n\n api/index\n\n" + + ("Missing :class:`example_api.DoesNotExist`.\n" if missing_reference else "") + ) + output = tmp_path / "html" + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - sys.executable is trusted. + [sys.executable, "-m", "sphinx", "-W", "-T", "-b", "html", str(source), str(output)], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + log = completed.stdout + completed.stderr + if missing_reference: + assert completed.returncode != 0 + assert "reference target not found: example_api.DoesNotExist" in log + return + assert completed.returncode == 0, log + assert "WARNING:" not in log + child_log = re.search(r"Subprocess output available at: (.+)", completed.stdout) + assert child_log is not None, completed.stdout + assert "WARNING:" not in Path(child_log[1].strip()).read_text(encoding="utf-8") + public = (output / "api" / "example_api" / "index.html").read_text() + model = (output / "api" / "example_api" / "model" / "index.html").read_text() + index = (output / "index.html").read_text() + assert "Public imports" in public + assert 'href="model/index.html#example_api.model.Example"' in public + assert 'id="example_api.Example"' not in public + assert model.count('id="example_api.model.Example"') == 1 + assert model.count('id="example_api.model.Example.weight"') == 1 + assert "Weight used by the example." in model + assert 'id="example_api.model.helper"' in model + description = index.split("

", 1)[1].split("

", 1)[0] + for member in ("sample", "weight"): + assert f'href="api/example_api/model/index.html#example_api.model.Example.{member}"' in description + collision = index.split("A module", 1)[1].split("

", 1)[0] + assert 'href="api/example_api/model/index.html#module-example_api.model"' in collision + assert 'href="api/example_api/model/index.html#example_api.model.model"' in collision + assert 'href="https://example.invalid/types/ndarray.html#numpy.typing.NDArray"' in model + assert 'href="https://example.invalid/types/scalar.html#numpy.float64"' in model + assert 'href="https://example.invalid/types/type.html#type"' in model + markdown = (output / "api" / "example_api" / "model" / "index.html.md").read_text() + assert '/' in markdown + assert '\\*' in markdown + inventory_module = pytest.importorskip("sphinx.util.inventory") + with (output / "objects.inv").open("rb") as stream: + published = inventory_module.InventoryFile.load(stream, "", lambda _uri, location: location) + assert published["py:class"]["example_api.Example"].uri == published["py:class"]["example_api.model.Example"].uri + assert ( + published["py:method"]["example_api.Example.sample"].uri + == published["py:method"]["example_api.model.Example.sample"].uri + ) + assert ( + published["py:attribute"]["example_api.Example.weight"].uri + == published["py:attribute"]["example_api.model.Example.weight"].uri + ) + assert "example_api.model" in published["py:module"] + assert published["py:function"]["example_api.model"].uri.endswith("#example_api.model.model") + + legacy_module.unlink() + rebuilt = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - sys.executable is trusted. + [sys.executable, "-m", "sphinx", "-E", "-W", "-T", "-b", "html", str(source), str(output)], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + assert rebuilt.returncode == 0, rebuilt.stdout + rebuilt.stderr + assert "WARNING:" not in rebuilt.stdout + rebuilt.stderr + with (output / "objects.inv").open("rb") as stream: + rebuilt_inventory = inventory_module.InventoryFile.load(stream, "", lambda _uri, location: location) + assert "example_api.legacy" not in rebuilt_inventory["py:module"] diff --git a/uv.lock b/uv.lock index 5990c0bcc..359ee134d 100644 --- a/uv.lock +++ b/uv.lock @@ -3,10 +3,10 @@ revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "(python_full_version >= '3.15' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", @@ -521,10 +521,10 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "(python_full_version >= '3.15' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", @@ -1685,7 +1685,9 @@ dev = [ ] docs = [ { name = "furo" }, + { name = "jupyter-cache" }, { name = "myst-nb" }, + { name = "nbformat" }, { name = "qiskit", extra = ["visualization"] }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -1736,7 +1738,9 @@ dev = [ ] docs = [ { name = "furo", specifier = ">=2025.12.19" }, + { name = "jupyter-cache", specifier = ">=1" }, { name = "myst-nb", specifier = ">=1.4" }, + { name = "nbformat", specifier = ">=5.10" }, { name = "qiskit", extras = ["visualization"], specifier = ">=2.1,<3" }, { name = "sphinx", specifier = ">=9" }, { name = "sphinx-autoapi", specifier = ">=3.6" }, @@ -1853,10 +1857,10 @@ version = "3.7" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "(python_full_version >= '3.15' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", @@ -2025,10 +2029,10 @@ version = "2.5.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "(python_full_version >= '3.15' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", @@ -3159,10 +3163,10 @@ version = "1.18.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "(python_full_version >= '3.15' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", @@ -3333,10 +3337,10 @@ version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "(python_full_version >= '3.15' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",