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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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"
+ ],
+ "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')",