Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ jobs:
runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0 # Fetch all, necessary to find tags and branches
fetch-tags: true

- uses: actions/setup-python@v5
with:
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/cov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0 # Fetch all, necessary to find tags and branches
fetch-tags: true

- name: Set up Python
uses: actions/setup-python@v5
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/doc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ jobs:
name: "Check documentation"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0 # Fetch all, necessary to find tags and branches
fetch-tags: true

- uses: actions/setup-python@v5
with:
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0 # Fetch all, necessary to find tags and branches
fetch-tags: true

- uses: actions/setup-python@v5
with:
Expand All @@ -46,7 +49,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0 # Fetch all, necessary to find tags and branches
fetch-tags: true

- uses: actions/setup-python@v5
with:
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- #343: Circuit exporter to OpenQASM3:
`graphix.qasm3_exporter.circuit_to_qasm3`.

- #337: New module `graphix.find_pauliflow` with the $O(N^3)$
Pauli-flow finding algorithm introduced in Mitosek and Backens, 2024
(arXiv:2410.23439).
Expand Down
2 changes: 1 addition & 1 deletion graphix/pauli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Pauli gates ± {1,j} × {I, X, Y, Z}.""" # noqa: RUF002
"""Pauli gates ± {1,j} × {I, X, Y, Z}."""

from __future__ import annotations

Expand Down
24 changes: 20 additions & 4 deletions graphix/pretty_print.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ class OutputFormat(Enum):
Unicode = enum.auto()


def angle_to_str(angle: float, output: OutputFormat, max_denominator: int = 1000) -> str:
def angle_to_str(
angle: float, output: OutputFormat, max_denominator: int = 1000, multiplication_sign: bool = False
) -> str:
r"""
Return a string representation of an angle given in units of π.

Expand All @@ -43,6 +45,13 @@ def angle_to_str(angle: float, output: OutputFormat, max_denominator: int = 1000
Desired formatting style: Unicode (π symbol), LaTeX (\pi), or ASCII ("pi").
max_denominator : int, optional
Maximum denominator for detecting a simple fraction (default: 1000).
multiplication_sign : bool
Optional (default: ``False``).
If ``True``, the multiplication sign is made explicit between the
numerator and π:
``2×π`` in Unicode, ``2 \times \pi`` in LaTeX, and ``2*pi`` in ASCII.
If ``False``, the multiplication sign is implicit:
``2π`` in Unicode, ``2\pi`` in LaTeX, ``2pi`` in ASCII.

Returns
-------
Expand All @@ -54,7 +63,7 @@ def angle_to_str(angle: float, output: OutputFormat, max_denominator: int = 1000
if not math.isclose(angle, float(frac)):
rad = angle * math.pi

return f"{rad:.2f}"
return f"{rad}"

num, den = frac.numerator, frac.denominator
sign = "-" if num < 0 else ""
Expand All @@ -65,21 +74,28 @@ def angle_to_str(angle: float, output: OutputFormat, max_denominator: int = 1000

def mkfrac(num: str, den: str) -> str:
return rf"\frac{{{num}}}{{{den}}}"

mul = r" \times "
else:
pi = "π" if output == OutputFormat.Unicode else "pi"

def mkfrac(num: str, den: str) -> str:
return f"{num}/{den}"

mul = "×" if output == OutputFormat.Unicode else "*"

if not multiplication_sign:
mul = ""

if den == 1:
if num == 0:
return "0"
if num == 1:
return f"{sign}{pi}"
return f"{sign}{num}{pi}"
return f"{sign}{num}{mul}{pi}"

den_str = f"{den}"
num_str = pi if num == 1 else f"{num}{pi}"
num_str = pi if num == 1 else f"{num}{mul}{pi}"
return f"{sign}{mkfrac(num_str, den_str)}"


Expand Down
120 changes: 120 additions & 0 deletions graphix/qasm3_exporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Exporter to OpenQASM3."""

from __future__ import annotations

from math import pi
from typing import TYPE_CHECKING

# assert_never added in Python 3.11
from typing_extensions import assert_never

from graphix.fundamentals import Axis, Sign
from graphix.instruction import Instruction, InstructionKind
from graphix.measurements import PauliMeasurement
from graphix.pretty_print import OutputFormat, angle_to_str

if TYPE_CHECKING:
from collections.abc import Iterable, Iterator

from graphix import Circuit
from graphix.parameter import ExpressionOrFloat


def circuit_to_qasm3(circuit: Circuit) -> str:
"""Export circuit instructions to OpenQASM 3.0 representation.

Returns
-------
str
The OpenQASM 3.0 string representation of the circuit.
"""
return "\n".join(circuit_to_qasm3_lines(circuit))


def circuit_to_qasm3_lines(circuit: Circuit) -> Iterator[str]:
"""Export circuit instructions to line-by-line OpenQASM 3.0 representation.

Returns
-------
Iterator[str]
The OpenQASM 3.0 lines that represent the circuit.
"""
yield "OPENQASM 3;"
yield 'include "stdgates.inc";'
yield f"qubit[{circuit.width}] q;"
if any(instr.kind == InstructionKind.M for instr in circuit.instruction):
yield f"bit[{circuit.width}] b;"
for instr in circuit.instruction:
yield f"{instruction_to_qasm3(instr)};"


def qasm3_qubit(index: int) -> str:
"""Return the name of the indexed qubit."""
return f"q[{index}]"


def qasm3_gate_call(gate: str, operands: Iterable[str], args: Iterable[str] | None = None) -> str:
"""Return the OpenQASM3 gate call."""
operands_str = ", ".join(operands)
if args is None:
return f"{gate} {operands_str}"
args_str = ", ".join(args)
return f"{gate}({args_str}) {operands_str}"


def angle_to_qasm3(angle: ExpressionOrFloat) -> str:
"""Get the OpenQASM3 representation of an angle."""
if not isinstance(angle, float):
raise TypeError("QASM export of symbolic pattern is not supported")
rad_over_pi = angle / pi
return angle_to_str(rad_over_pi, output=OutputFormat.ASCII, multiplication_sign=True)


def instruction_to_qasm3(instruction: Instruction) -> str:
"""Get the OpenQASM3 representation of a single circuit instruction."""
if instruction.kind == InstructionKind.M:
if PauliMeasurement.try_from(instruction.plane, instruction.angle) != PauliMeasurement(Axis.Z, Sign.PLUS):
raise ValueError("OpenQASM3 only supports measurements in Z axis.")
return f"b[{instruction.target}] = measure q[{instruction.target}]"
# Use of `==` here for mypy
if (
instruction.kind == InstructionKind.RX # noqa: PLR1714
or instruction.kind == InstructionKind.RY
or instruction.kind == InstructionKind.RZ
):
angle = angle_to_qasm3(instruction.angle)
return qasm3_gate_call(instruction.kind.name.lower(), args=[angle], operands=[qasm3_qubit(instruction.target)])

# Use of `==` here for mypy
if (
instruction.kind == InstructionKind.H # noqa: PLR1714
or instruction.kind == InstructionKind.S
or instruction.kind == InstructionKind.X
or instruction.kind == InstructionKind.Y
or instruction.kind == InstructionKind.Z
):
return qasm3_gate_call(instruction.kind.name.lower(), [qasm3_qubit(instruction.target)])
if instruction.kind == InstructionKind.I:
return qasm3_gate_call("id", [qasm3_qubit(instruction.target)])
if instruction.kind == InstructionKind.CNOT:
return qasm3_gate_call("cx", [qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)])
if instruction.kind == InstructionKind.SWAP:
return qasm3_gate_call("swap", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)])
if instruction.kind == InstructionKind.RZZ:
angle = angle_to_qasm3(instruction.angle)
return qasm3_gate_call(
"crz", args=[angle], operands=[qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)]
)
if instruction.kind == InstructionKind.CCX:
return qasm3_gate_call(
"ccx",
[
qasm3_qubit(instruction.controls[0]),
qasm3_qubit(instruction.controls[1]),
qasm3_qubit(instruction.target),
],
)
# Use of `==` here for mypy
if instruction.kind == InstructionKind._XC or instruction.kind == InstructionKind._ZC: # noqa: PLR1714
raise ValueError("Internal instruction should not appear")
assert_never(instruction.kind)
27 changes: 22 additions & 5 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def install_pytest(session: Session) -> None:
@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"])
def tests_minimal(session: Session) -> None:
"""Run the test suite with minimal dependencies."""
session.install("-e", ".")
session.install(".")
install_pytest(session)
# We cannot run `pytest --doctest-modules` here, since some tests
# involve optional dependencies, like pyzx.
Expand All @@ -27,7 +27,7 @@ def tests_minimal(session: Session) -> None:
@nox.session(python=["3.10", "3.11", "3.12", "3.13"])
def tests_dev(session: Session) -> None:
"""Run the test suite with dev dependencies."""
session.install("-e", ".[dev]")
session.install(".[dev]")
# We cannot run `pytest --doctest-modules` here, since some tests
# involve optional dependencies, like pyzx.
session.run("pytest")
Expand All @@ -36,7 +36,7 @@ def tests_dev(session: Session) -> None:
@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"])
def tests_extra(session: Session) -> None:
"""Run the test suite with extra dependencies."""
session.install("-e", ".[extra]")
session.install(".[extra]")
install_pytest(session)
session.install("nox") # needed for `--doctest-modules`
session.run("pytest", "--doctest-modules")
Expand All @@ -45,14 +45,14 @@ def tests_extra(session: Session) -> None:
@nox.session(python=["3.10", "3.11", "3.12", "3.13"])
def tests_all(session: Session) -> None:
"""Run the test suite with all dependencies."""
session.install("-e", ".[dev,extra]")
session.install(".[dev,extra]")
session.run("pytest", "--doctest-modules")


@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"])
def tests_symbolic(session: Session) -> None:
"""Run the test suite of graphix-symbolic."""
session.install("-e", ".")
session.install(".")
install_pytest(session)
session.install("nox") # needed for `--doctest-modules`
# Use `session.cd` as a context manager to ensure that the
Expand All @@ -65,3 +65,20 @@ def tests_symbolic(session: Session) -> None:
session.run("git", "clone", "https://github.com/TeamGraphix/graphix-symbolic")
with session.cd("graphix-symbolic"):
session.run("pytest", "--doctest-modules")


@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"])
def tests_qasm_parser(session: Session) -> None:
"""Run the test suite of graphix-qasm-parser."""
session.install(".")
install_pytest(session)
session.install("nox") # needed for `--doctest-modules`
# Use `session.cd` as a context manager to ensure that the
# working directory is restored afterward. This is important
# because Windows cannot delete a temporary directory while it
# is the working directory.
with TemporaryDirectory() as tmpdir, session.cd(tmpdir):
session.run("git", "clone", "https://github.com/TeamGraphix/graphix-qasm-parser")
with session.cd("graphix-qasm-parser"):
session.install(".")
session.run("pytest", "--doctest-modules")
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ extend-ignore = [
"W191",
]
# Allow "α" (U+03B1 GREEK SMALL LETTER ALPHA) which could be confused for "a"
allowed-confusables = ["α"]
# Allow "×" (U+00D7 MULTIPLICATION SIGN) which could be confused for "x"
allowed-confusables = ["α", "×"]

[tool.ruff.format]
docstring-code-format = true
Expand Down
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ pytest-mock
# Optional dependencies
qiskit>=1.0
qiskit-aer

graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@id_gate
Loading