diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9498e12..be2e9380 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: permissions: - contents: read + contents: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -29,6 +29,53 @@ jobs: with: python-version: ${{ matrix.python }} + - name: Setup TeX Live + uses: teatimeguest/setup-texlive-action@v3 + with: + packages: | + scheme-basic + standalone + xypic + qcircuit + + - name: Update and install dependencies on Ubuntu + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get upgrade -y + sudo apt-get install -y poppler-utils + + - name: Update and install dependencies on macOS + if: runner.os == 'macOS' + run: | + brew update + brew install poppler + +# Installation and setup of poppler for Windows seems not to work. +# Here, we try to install poppler-windows and update PATH environment variable. This process seems to work, as the command `pdftocairo.exe -v` executes as expected. +# Though, when running the tests, pdftocairo still seems not found. Thus, we are skipping latex related tests on Windows OS for now. + +# - name: Update and install dependencies on Windows +# if: runner.os == 'Windows' +# # Download latest binary of poppler-windows +# # Update PATH with poppler's binary folder +# run: | +# Write-Host "Downloading Poppler..." +# Invoke-RestMethod -Uri https://github.com/oschwartz10612/poppler-windows/releases/download/v24.08.0-0/Release-24.08.0-0.zip -OutFile .\Release-24.08.0-0.zip +# Expand-Archive -Path .\Release-24.08.0-0.zip -DestinationPath .\ +# +# $addPath = (Resolve-Path .\poppler-24.08.0\Library\bin).Path +# $regexAddPath = [regex]::Escape($addPath) +# +# $arrPath = $env:Path -split ';' | Where-Object {$_ -notMatch "^$regexAddPath\\?"} +# $arrPath += $addPath +# $env:Path = -join ($arrPath -join ';') +# +# [System.Environment]::SetEnvironmentVariable('Path', $env:Path, [System.EnvironmentVariableTarget]::User) +# +# $env:Path -split ';' +# pdftocairo.exe -v + - run: python -m pip install --upgrade pip - name: Setup nox diff --git a/.github/workflows/cov.yml b/.github/workflows/cov.yml index 184e038e..d0925f27 100644 --- a/.github/workflows/cov.yml +++ b/.github/workflows/cov.yml @@ -18,11 +18,26 @@ jobs: with: python-version: "3.12" + - name: Setup TeX Live + uses: teatimeguest/setup-texlive-action@v3 + with: + packages: | + scheme-basic + standalone + xypic + qcircuit + + - name: Update and install dependencies + run: | + sudo apt-get update + sudo apt-get upgrade -y + sudo apt-get install -y poppler-utils + - name: Upgrade pip run: python -m pip install --upgrade pip - name: Install graphix with dev deps. - run: pip install .[dev] + run: pip install .[dev,extra] - name: Run pytest run: pytest --cov=./graphix --cov-report=xml --cov-report=term diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8326e8..58208341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ## [Unreleased] ### Added @@ -31,6 +30,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +## [0.3.1] + +This version brings nice ways to visualize both circuit and patterns. + +### Added + +- Both `Circuit` and `Pattern` classes now have a `draw` method with an argument corresponding to the format the user wants to visualize the object and that returns the appropriate visualization object. +- `Circuit.draw()` is based on `qiskit.QuantumCircuit.draw()` method that takes a qasm3 circuit as input to generate the `QuantumCircuit` object and call its local `draw` method. +- Added the `Circuit.to_qasm3()` method to generate the appropriate qasm3 representation of a `Circuit` object. +- Added `Circuit.__str__()` method overload that calls `Circuit.draw()` with the default `text` argument. +- `Pattern.draw()` relates on its own, with the 4 following visualization formats: `ascii`, `latex`, `unicode` or `png`, respectively returning `str`, `str`, `str` or `PIL.Image.Image`. +- Added `command_to_latex(Command)`, `command_to_str(Command)`/`Command.__str__()` and `command_to_unicode(Command)` methods to generate the appropriate visualizations of a `Command` object. +- Added `Pattern.to_png()`, `Pattern.to_unicode()`, `Pattern.to_latex()` and `Pattern._str__()` methods. + +### Fixed + +### Changed + ## [0.3.0] - 2025-02-04 ### Changed @@ -56,6 +73,8 @@ This version introduces several important interface changes, aimed at secure exp - Added `class Instruction` for the gate network expression in quantum circuit model. Every instruction can be instanciated using this class by passing its name as defined in the Enum `InstructionName`. - `class graphix.OpenGraph` to transpile between graphix patterns and pyzx graphs. - `class graphix.pauli.PauliMeasurement` as a new Pauli measurement checks (used in `pattern.perform_pauli_measurements`). +- Now variables, functions, and classes are named based on PEP8. +- `KrausChannel` class now uses `KrausData` class (originally `dict`) to store Kraus operators. ### Fixed diff --git a/graphix/command.py b/graphix/command.py index b5f3c32f..8c0e091a 100644 --- a/graphix/command.py +++ b/graphix/command.py @@ -6,6 +6,7 @@ import enum import sys from enum import Enum +from fractions import Fraction from typing import ClassVar, Literal, Union import numpy as np @@ -22,6 +23,201 @@ Node = int +SUBSCRIPTS = str.maketrans("0123456789+", "₀₁₂₃₄₅₆₇₈₉₊") +SUPERSCRIPTS = str.maketrans("0123456789+", "⁰¹²³⁴⁵⁶⁷⁸⁹⁺") + + +def angle_to_str(angle: ExpressionOrFloat, mode: str) -> str: + """Return the string of an angle according to the given format.""" + assert mode in {"latex", "ascii", "unicode"} + + if not isinstance(angle, float): + return str(angle) + + tol = 1e-9 + + frac = Fraction(angle).limit_denominator(1000) + + if abs(angle - float(frac)) > tol: + rad = angle * np.pi + + return f"{rad:.2f}" + + num, den = frac.numerator, frac.denominator + sign = "-" if num < 0 else "" + num = abs(num) + + if mode == "latex": + if den == 1: + if num == 1: + return f"{sign}\\pi" + + return f"{sign}{num}\\pi" + + if num == 1: + return f"{sign}\\frac{{\\pi}}{{{den}}}" + + return f"{sign}\\frac{{{num}\\pi}}{{{den}}}" + + pi = "π" + if mode == "ascii": + pi = "pi" + + if den == 1: + if num == 1: + return f"{sign}{pi}" + return f"{sign}{num}{pi}" + if num == 1: + return f"{sign}{pi}/{den}" + return f"{sign}{num}{pi}/{den}" + + +def command_to_latex(cmd: Command) -> str: + """Get the latex string representation of a command.""" + kind = cmd.kind + out = [kind.name] + + if isinstance(cmd, (N, M, C, X, Z, S)): + node = str(cmd.node) + + if isinstance(cmd, M): + has_domain = len(cmd.s_domain) != 0 or len(cmd.t_domain) != 0 + + if has_domain: + out = ["[", *out] + + if len(cmd.t_domain) != 0: + out = [f"{{}}_{','.join([str(dom) for dom in cmd.t_domain])}", *out] + + out.append(f"_{{{node}}}") + if cmd.plane != Plane.XY or cmd.angle != 0.0 or len(cmd.s_domain) != 0: + s = [] + if cmd.plane != Plane.XY: + s.append(cmd.plane.name) + if cmd.angle != 0.0: + s.append(angle_to_str(cmd.angle, mode="latex")) + if len(s) != 0: + out.append(f"^{{{','.join(s)}}}") + + if has_domain: + out.append("]") + + if len(cmd.s_domain) != 0: + out.append(f"^{{{','.join([str(dom) for dom in cmd.s_domain])}}}") + if len(cmd.t_domain) != 0 and len(cmd.s_domain) == 0: + out.append("]") + + elif isinstance(cmd, (X, Z, S)): + out.append(f"_{{{node}}}") + if len(cmd.domain) != 0: + out.append(f"^{{{''.join([str(dom) for dom in cmd.domain])}}}") + else: + out.append(f"_{{{node}}}") + if isinstance(cmd, C): + out.append(f"^{{{cmd.clifford}}}") + + if isinstance(cmd, E): + out.append(f"_{{{cmd.nodes[0]},{cmd.nodes[1]}}}") + + return f"{''.join(out)}" + + +def command_to_str(cmd: Command) -> str: + """Get the string representation of a command.""" + kind = cmd.kind + out = [kind.name] + + if isinstance(cmd, (N, M, C, X, Z, S)): + node = str(cmd.node) + if isinstance(cmd, M): + has_domain = len(cmd.s_domain) != 0 or len(cmd.t_domain) != 0 + if has_domain: + out = ["[", *out] + + s = [] + if len(cmd.t_domain) != 0: + out = [f"{{{','.join([str(dom) for dom in cmd.t_domain])}}}", *out] + + s.append(f"{node}") + if cmd.plane != Plane.XY: + s.append(f"{cmd.plane.name}") + if cmd.angle != 0.0: + s.append(angle_to_str(cmd.angle, mode="ascii")) + + out.append(f"({','.join(s)})") + + if has_domain: + out.append("]") + + if len(cmd.s_domain) != 0: + out.append(f"{{{','.join([str(dom) for dom in cmd.s_domain])}}}") + + elif isinstance(cmd, (X, Z, S)): + s = [node] + if len(cmd.domain) != 0: + s.append(f"{{{','.join([str(dom) for dom in cmd.domain])}}}") + out.append(f"({','.join(s)})") + elif isinstance(cmd, C): + out.append(f"({node},{cmd.clifford})") + else: + out.append(f"({node})") + + elif isinstance(cmd, E): + out.append(f"({cmd.nodes[0]},{cmd.nodes[1]})") + + return f"{''.join(out)}" + + +def _get_subscript_from_number(number: int) -> str: + return str(number).translate(SUBSCRIPTS) + + +def _get_superscript_from_number(number: int) -> str: + return str(number).translate(SUPERSCRIPTS) + + +def command_to_unicode(cmd: Command) -> str: + """Get the unicode representation of a command.""" + kind = cmd.kind + out = [kind.name] + if isinstance(cmd, (N, M, C, X, Z, S)): + node = _get_subscript_from_number(cmd.node) + if isinstance(cmd, M): + has_domain = len(cmd.s_domain) != 0 or len(cmd.t_domain) != 0 + if has_domain: + out = ["[", *out] + if len(cmd.t_domain) != 0: + out = [f"{','.join([_get_subscript_from_number(dom) for dom in cmd.t_domain])}", *out] + out.append(node) + if cmd.plane != Plane.XY or cmd.angle != 0.0 or len(cmd.s_domain) != 0: + s = [] + if cmd.plane != Plane.XY: + s.append(f"{cmd.plane.name}") + if cmd.angle != 0.0: + s.append(angle_to_str(cmd.angle, mode="unicode")) + if s != []: + out.append(f"({','.join(s)})") + + if has_domain: + out.append("]") + if len(cmd.s_domain) != 0: + out.append(f"{','.join([_get_superscript_from_number(dom) for dom in cmd.s_domain])}") + + elif isinstance(cmd, (X, Z, S)): + out.append(node) + if len(cmd.domain) != 0: + out.append(f"{','.join([_get_superscript_from_number(dom) for dom in cmd.domain])}") + elif isinstance(cmd, C): + out.append(node) + out.append(f"({cmd.clifford})") + else: + out.append(node) + + elif isinstance(cmd, E): + out.append(f"{_get_subscript_from_number(cmd.nodes[0])}₋{_get_subscript_from_number(cmd.nodes[1])}") + + return "".join(out) + class CommandKind(Enum): """Tag for command kind.""" @@ -52,6 +248,10 @@ class N(_KindChecker): state: State = dataclasses.field(default_factory=lambda: BasicStates.PLUS) kind: ClassVar[Literal[CommandKind.N]] = dataclasses.field(default=CommandKind.N, init=False) + def __repr__(self) -> str: + """Return the representation of a N command.""" + return f"N(node={self.node})" + @dataclasses.dataclass class M(_KindChecker): @@ -79,6 +279,19 @@ def clifford(self, clifford_gate: Clifford) -> M: domains.t_domain, ) + def __repr__(self) -> str: + """Return the representation of a M command.""" + d = [f"node={self.node}"] + if self.plane != Plane.XY: + d.append(f"plane={self.plane.name}") + if self.angle != 0.0: + d.append(f"angle={self.angle}") + if len(self.s_domain) != 0: + d.append(f"s_domain={{{','.join([str(dom) for dom in self.s_domain])}}}") + if len(self.t_domain) != 0: + d.append(f"t_domain={{{','.join([str(dom) for dom in self.t_domain])}}}") + return f"M({','.join(d)})" + @dataclasses.dataclass class E(_KindChecker): @@ -87,6 +300,10 @@ class E(_KindChecker): nodes: tuple[Node, Node] kind: ClassVar[Literal[CommandKind.E]] = dataclasses.field(default=CommandKind.E, init=False) + def __repr__(self) -> str: + """Return the representation of a E command.""" + return f"E(nodes={self.nodes})" + @dataclasses.dataclass class C(_KindChecker): @@ -96,6 +313,10 @@ class C(_KindChecker): clifford: Clifford kind: ClassVar[Literal[CommandKind.C]] = dataclasses.field(default=CommandKind.C, init=False) + def __repr__(self) -> str: + """Return the representation of a C command.""" + return f"C(node={self.node}, clifford={self.clifford})" + @dataclasses.dataclass class X(_KindChecker): @@ -105,6 +326,10 @@ class X(_KindChecker): domain: set[Node] = dataclasses.field(default_factory=set) kind: ClassVar[Literal[CommandKind.X]] = dataclasses.field(default=CommandKind.X, init=False) + def __repr__(self) -> str: + """Return the representation of a X command.""" + return f"X(node={self.node}, domain={str(self.domain) if len(self.domain) != 0 else ''})" + @dataclasses.dataclass class Z(_KindChecker): @@ -114,6 +339,10 @@ class Z(_KindChecker): domain: set[Node] = dataclasses.field(default_factory=set) kind: ClassVar[Literal[CommandKind.Z]] = dataclasses.field(default=CommandKind.Z, init=False) + def __repr__(self) -> str: + """Return the representation of a Z command.""" + return f"Z(node={self.node}, domain={str(self.domain) if len(self.domain) != 0 else ''})" + @dataclasses.dataclass class S(_KindChecker): @@ -123,6 +352,10 @@ class S(_KindChecker): domain: set[Node] = dataclasses.field(default_factory=set) kind: ClassVar[Literal[CommandKind.S]] = dataclasses.field(default=CommandKind.S, init=False) + def __repr__(self) -> str: + """Return the representation of a S command.""" + return f"S({self.node=}{', domain=' + str(self.domain) if len(self.domain) != 0 else ''})" + @dataclasses.dataclass class T(_KindChecker): @@ -130,6 +363,10 @@ class T(_KindChecker): kind: ClassVar[Literal[CommandKind.T]] = dataclasses.field(default=CommandKind.T, init=False) + def __repr__(self) -> str: + """Return the representation of a T command.""" + return "T()" + if sys.version_info >= (3, 10): Command = N | M | E | C | X | Z | S | T diff --git a/graphix/draw_pattern.py b/graphix/draw_pattern.py new file mode 100644 index 00000000..44154568 --- /dev/null +++ b/graphix/draw_pattern.py @@ -0,0 +1,90 @@ +"""Helper module for drawing pattern.""" + +import io +import subprocess +import warnings +from pathlib import Path + +import PIL + +from graphix import Pattern + + +def latex_file_to_image(tmpdirname: Path, tmpfilename: Path) -> PIL.Image.Image: + """Convert a latex file located in `tmpdirname/tmpfilename` to an image representation.""" + try: + subprocess.run( + [ + "pdflatex", + "-halt-on-error", + f"-output-directory={tmpdirname}", + f"{tmpfilename}.tex", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + ) + except OSError as exc: + # OSError should generally not occur, because it's usually only triggered if `pdflatex` + # doesn't exist as a command, but we've already checked that. + raise Exception("`pdflatex` command could not be run.") from exc + except subprocess.CalledProcessError as exc: + with Path("latex_error.log").open("wb") as error_file: + error_file.write(exc.stdout) + warnings.warn( + "Unable to compile LaTeX. Perhaps you are missing the `qcircuit` package." + " The output from the `pdflatex` command is in `latex_error.log`.", + stacklevel=2, + ) + raise Exception("`pdflatex` call did not succeed: see `latex_error.log`.") from exc + base = Path(tmpdirname) / tmpfilename + try: + subprocess.run( + ["pdftocairo", "-singlefile", "-png", "-q", base.with_suffix(".pdf"), base], + check=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + message = "`pdftocairo` failed to produce an image." + warnings.warn(message, stacklevel=2) + raise Exception(message) from exc + + def trim(image) -> PIL.Image.Image: + """Trim a PIL image and remove white space.""" + background = PIL.Image.new(image.mode, image.size, image.getpixel((0, 0))) + diff = PIL.ImageChops.difference(image, background) + diff = PIL.ImageChops.add(diff, diff, 2.0, -100) + bbox = diff.getbbox() + if bbox: + image = image.crop(bbox) + return image + + return trim(PIL.Image.open(base.with_suffix(".png"))) + + +def pattern_to_latex_document(pattern: Pattern, left_to_right: bool) -> str: + """Generate a latex document with the latex representation of the pattern written in it. + + Parameters + ---------- + left_to_right: bool + whether or not represent the pattern from left to right representation. Default is left to right, otherwise it's right to left + """ + header_1 = r"\documentclass[border=2px]{standalone}" + "\n" + + header_2 = r""" +\usepackage{graphicx} + +\begin{document} +""" + + output = io.StringIO() + output.write(header_1) + output.write(header_2) + + output.write(pattern.to_latex(left_to_right)) + + output.write("\n\\end{document}") + contents = output.getvalue() + output.close() + + return contents diff --git a/graphix/instruction.py b/graphix/instruction.py index 645e89f2..62057462 100644 --- a/graphix/instruction.py +++ b/graphix/instruction.py @@ -6,13 +6,64 @@ import enum import sys from enum import Enum +from fractions import Fraction from typing import ClassVar, Literal, Union +import numpy as np +from typing_extensions import assert_never + from graphix import utils from graphix.fundamentals import Plane # Ruff suggests to move this import to a type-checking block, but dataclass requires it here -from graphix.parameter import ExpressionOrFloat # noqa: TC001 +from graphix.parameter import Expression, ExpressionOrFloat + + +def to_qasm3(instruction: Instruction) -> str: + """Get the qasm3 representation of a single circuit instruction.""" + kind = instruction.kind + if kind == InstructionKind.M: + return f"b[{instruction.target}] = measure q[{instruction.target}]" + # Use of `==` here for mypy + if kind in {InstructionKind.RX, InstructionKind.RY, InstructionKind.RZ}: + if isinstance(instruction.angle, Expression): + raise ValueError("QASM export of symbolic pattern is not supported") + rad_over_pi = instruction.angle / np.pi + tol = 1e-9 + frac = Fraction(rad_over_pi).limit_denominator(1000) + if abs(rad_over_pi - float(frac)) > tol: + angle = f"{rad_over_pi}*pi" + num, den = frac.numerator, frac.denominator + sign = "-" if num < 0 else "" + num = abs(num) + if den == 1: + angle = f"{sign}pi" if num == 1 else f"{sign}{num}*pi" + else: + angle = f"{sign}pi/{den}" if num == 1 else f"{sign}{num}*pi/{den}" + return f"{kind.name.lower()}({angle}) q[{instruction.target}]" + + # Use of `==` here for mypy + if ( + kind == InstructionKind.H # noqa: PLR1714 + or kind == InstructionKind.I + or kind == InstructionKind.S + or kind == InstructionKind.X + or kind == InstructionKind.Y + or kind == InstructionKind.Z + ): + return f"{kind.name.lower()} q[{instruction.target}]" + if kind == InstructionKind.CNOT: + return f"cx q[{instruction.control}], q[{instruction.target}]" + if kind == InstructionKind.SWAP: + return f"swap q[{instruction.targets[0]}], q[{instruction.targets[1]}]" + if kind == InstructionKind.RZZ: + return f"rzz q[{instruction.control}], q[{instruction.target}]" + if kind == InstructionKind.CCX: + return f"ccx q[{instruction.controls[0]}], q[{instruction.controls[1]}], q[{instruction.target}]" + # Use of `==` here for mypy + if kind == InstructionKind._XC or kind == InstructionKind._ZC: # noqa: PLR1714 + raise ValueError("Internal instruction should not appear") + assert_never(kind) class InstructionKind(Enum): diff --git a/graphix/pattern.py b/graphix/pattern.py index 0ead9c32..04638e61 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -7,17 +7,20 @@ import copy import dataclasses +import io +import tempfile from collections.abc import Iterator from copy import deepcopy from dataclasses import dataclass -from typing import TYPE_CHECKING, SupportsFloat +from pathlib import Path +from typing import TYPE_CHECKING, Literal, SupportsFloat import networkx as nx import typing_extensions from graphix import command, parameter from graphix.clifford import Clifford -from graphix.command import Command, CommandKind +from graphix.command import Command, CommandKind, command_to_latex, command_to_str, command_to_unicode from graphix.device_interface import PatternRunner from graphix.fundamentals import Axis, Plane, Sign from graphix.gflow import find_flow, find_gflow, get_layers @@ -30,6 +33,8 @@ if TYPE_CHECKING: from abc.collections import Iterator, Mapping + import PIL.Image.Image + from graphix.parameter import ExpressionOrSupportsFloat, Parameter from graphix.sim.base_backend import State @@ -80,7 +85,7 @@ class Pattern: total number of nodes in the resource state """ - def __init__(self, input_nodes: list[int] | None = None) -> None: + def __init__(self, input_nodes: list[int] | None = None, seq: list[Command] | None = None) -> None: """ Construct a pattern. @@ -94,6 +99,8 @@ def __init__(self, input_nodes: list[int] | None = None) -> None: self._pauli_preprocessed = False # flag for `measure_pauli` preprocessing completion self.__seq: list[Command] = [] + if seq is not None: + self.extend(seq) # output nodes are initially input nodes, since none are measured yet self.__output_nodes = list(input_nodes) @@ -195,9 +202,7 @@ def reorder_input_nodes(self, input_nodes: list[int]): # TODO: This is not an evaluable representation. Should be __str__? def __repr__(self) -> str: """Return a representation string of the pattern.""" - return ( - f"graphix.pattern.Pattern object with {len(self.__seq)} commands and {len(self.output_nodes)} output qubits" - ) + return f"Pattern({'' if not self.input_nodes else f'input_nodes={self.input_nodes}'}, seq={self.__seq})" def __eq__(self, other: Pattern) -> bool: """Return `True` if the two patterns are equal, `False` otherwise.""" @@ -207,6 +212,72 @@ def __eq__(self, other: Pattern) -> bool: and self.output_nodes == other.output_nodes ) + def to_latex(self, left_to_right: bool = True) -> str: + """Return a string containing the latex representation of the pattern. + + Parameters + ---------- + left_to_right: bool + whether or not represent the pattern from left to right representation. Default is left to right, otherwise it's right to left + """ + output = io.StringIO() + + seq = self.__seq[::-1] if not left_to_right else self.__seq + sep = "\\," + output.write(f"\\({sep.join([command_to_latex(cmd) for cmd in seq])}\\)") + + contents = output.getvalue() + output.close() + return contents + + def to_png(self, left_to_right: bool = True) -> PIL.Image.Image: + """Generate a PNG image of the latex representation of the pattern. + + Parameters + ---------- + left_to_right: bool + whether or not represent the pattern from left to right representation. Default is left to right, otherwise it's right to left + """ + from graphix.draw_pattern import latex_file_to_image, pattern_to_latex_document + + tmpfilename = "pattern" + + with tempfile.TemporaryDirectory() as tmpdirname: + tmppath = Path(tmpdirname) / tmpfilename + tmppath = tmppath.with_suffix(".tex") + + with tmppath.open("w") as latex_file: + contents = pattern_to_latex_document(self, left_to_right) + latex_file.write(contents) + + return latex_file_to_image(tmpdirname, tmpfilename) + + def __str__(self) -> str: + """Return a string representation of the pattern.""" + return self.to_ascii() + + def to_ascii(self, left_to_right: bool = True) -> str: + """Return the ascii string representation of the pattern. + + Parameters + ---------- + left_to_right: bool + whether or not represent the pattern from left to right representation. Default is left to right, otherwise it's right to left + """ + seq = self.__seq[::-1] if not left_to_right else self.__seq + return " ".join([command_to_str(cmd) for cmd in seq]) + + def to_unicode(self, left_to_right: bool = True) -> str: + """Return the unicode string representation of the pattern. + + Parameters + ---------- + left_to_right: bool + whether or not represent the pattern from left to right representation. Default is left to right, otherwise it's right to left + """ + seq = reversed(self.__seq) if not left_to_right else self.__seq + return " ".join([command_to_unicode(cmd) for cmd in seq]) + def print_pattern(self, lim=40, target: list[CommandKind] | None = None) -> None: """Print the pattern sequence (Pattern.seq). @@ -261,6 +332,26 @@ def print_pattern(self, lim=40, target: list[CommandKind] | None = None) -> None f"{len(self.__seq) - lim} more commands truncated. Change lim argument of print_pattern() to show more" ) + def draw( + self, output: Literal["ascii", "latex", "unicode", "png"] = "ascii", left_to_right: bool = True + ) -> str | PIL.Image.Image: + """Return the appropriate visualization object. + + Parameters + ---------- + left_to_right: bool + + """ + if output == "ascii": + return self.to_ascii(left_to_right) + if output == "png": + return self.to_png(left_to_right) + if output == "latex": + return self.to_latex(left_to_right) + if output == "unicode": + return self.to_unicode(left_to_right) + raise ValueError("Unknown argument value for pattern drawing.") + def standardize(self, method="direct") -> None: """Execute standardization of the pattern. @@ -1408,7 +1499,7 @@ def to_qasm3(self, filename): filename : str file name to export to. example: "filename.qasm" """ - with open(filename + ".qasm", "w") as file: + with Path(filename + ".qasm").open("w") as file: file.write("// generated by graphix\n") file.write("OPENQASM 3;\n") file.write('include "stdgates.inc";\n') diff --git a/graphix/transpiler.py b/graphix/transpiler.py index 05bdc7d9..22fb5975 100644 --- a/graphix/transpiler.py +++ b/graphix/transpiler.py @@ -24,6 +24,10 @@ if TYPE_CHECKING: from collections.abc import Mapping, Sequence + import matplotlib.figure + import PIL.Image.Image + import TextDrawing + @dataclasses.dataclass class TranspileResult: @@ -80,6 +84,53 @@ def __init__(self, width: int): self.instruction: list[instruction.Instruction] = [] self.active_qubits = set(range(width)) + def __repr__(self) -> str: + """Return a representation of the Circuit.""" + return f"Circuit(width={self.width}, instructions={self.instruction})" + + def __str__(self) -> str: + """Return a string representation of the Circuit.""" + try: + return self.draw() + except Exception: + return repr(self) + + def draw(self, output: str = "text") -> TextDrawing | matplotlib.figure | PIL.Image | str: + """Return the appropriate visualization object of a Circuit based on Qiskit. + + Generate the corresponding qasm3 code, load a `qiskit.QuantumCircuit` and call `QuantumCircuit.draw()`. + """ + try: + from qiskit.qasm3 import loads + except ImportError as e: + raise e + + qasm_circuit = self.to_qasm3() + qiskit_circuit = loads(qasm_circuit) + if output == "text": + return qiskit_circuit.draw("text").single_string() + return qiskit_circuit.draw(output=output) + + def to_qasm3(self) -> str: + """Export circuit instructions to OpenQASM 3.0 file. + + Returns + ------- + str + The OpenQASM 3.0 string representation of the circuit. + """ + qasm_lines = [] + + qasm_lines.append("OPENQASM 3;") + qasm_lines.append('include "stdgates.inc";') + qasm_lines.append(f"qubit[{self.width}] q;") + if self.instruction.count(instruction.M) > 0: + qasm_lines.append(f"bit[{self.width}] b;") + + qasm_lines.extend([f"{instruction.to_qasm3(instr)};" for instr in self.instruction]) + + return "\n".join(qasm_lines) + "\n" + def cnot(self, control: int, target: int): """Apply a CNOT gate. diff --git a/noxfile.py b/noxfile.py index 8f2804a1..d96a463d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -17,7 +17,7 @@ def tests_minimal(session: Session) -> None: @nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12"]) def tests(session: Session) -> None: """Run the test suite with full dependencies.""" - session.install("-e", ".[dev]") + session.install("-e", ".[dev,extra]") session.run("pytest", "--doctest-modules") diff --git a/pyproject.toml b/pyproject.toml index 1cac1133..ef701ff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ extend-select = [ "UP", "W", "YTT", + "PTH" ] extend-ignore = [ "E74", # Ambiguous name @@ -119,6 +120,7 @@ files = [ "graphix/channels.py", "graphix/clifford.py", "graphix/command.py", + "graphix/draw_pattern.py", "graphix/fundamentals.py", "graphix/instruction.py", "graphix/linalg_validations.py", diff --git a/requirements-extra.txt b/requirements-extra.txt index a8715589..643e9e89 100644 --- a/requirements-extra.txt +++ b/requirements-extra.txt @@ -1,2 +1,3 @@ -graphix-ibmq -graphix-perceval +pylatexenc +# graphix-ibmq +qiskit_qasm3_import \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 36b535e1..1f40687f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +eval_type_backport autoray>=0.6.0 eval_type_backport galois>=0.3.0 diff --git a/tests/test_generator.py b/tests/test_generator.py index eeda7070..6be216b0 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -39,7 +39,8 @@ def test_pattern_generation_determinism_gflow(self, fx_rng: Generator) -> None: inputs = {1, 3, 5} outputs = {2, 4, 6} angles = fx_rng.normal(size=6) - meas_planes = dict.fromkeys(range(1, 6), Plane.XY) + meas_planes = dict.fromkeys(list(range(1, 6)), Plane.XY) + results = [] repeats = 3 # for testing the determinism of a pattern for _ in range(repeats): diff --git a/tests/test_pattern.py b/tests/test_pattern.py index 8f25964b..20a39c03 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -2,8 +2,10 @@ import copy import itertools +import platform import sys import typing +from shutil import which from typing import TYPE_CHECKING import networkx as nx @@ -668,3 +670,61 @@ def test_remove_qubit(self) -> None: def assert_equal_edge(edge: Sequence[int], ref: Sequence[int]) -> bool: return any(all(ei == ri for ei, ri in zip(edge, other)) for other in (ref, reversed(ref))) + + +def test_draw_pattern() -> None: + randpat = rand_circuit(5, 5).transpile().pattern + try: + randpat.draw("ascii") + randpat.draw("unicode") + except Exception as e: + pytest.fail(str(e)) + + +@pytest.mark.skipif(platform.system() == "Windows", reason="See [Bug]#259") +@pytest.mark.skipif(which("latex") is None, reason="latex not installed") +@pytest.mark.skipif(which("pdftocairo") is None, reason="pdftocairo not installed") +def test_draw_pattern_latex() -> None: + randpat = rand_circuit(5, 5).transpile().pattern + try: + randpat.draw("latex") + randpat.draw("png") + except Exception as e: + pytest.fail(str(e)) + + +def test_draw_pattern_j_alpha() -> None: + p = Pattern() + p.add(N(1)) + p.add(N(2)) + p.add(E((1, 2))) + p.add(M(1)) + p.add(X(2, domain={1})) + assert str(p) == "N(1) N(2) E(1,2) M(1) X(2,{1})" + assert p.to_unicode() == "N₁ N₂ E₁₋₂ M₁ X₂¹" + assert p.to_latex() == r"\(N_{1}\,N_{2}\,E_{1,2}\,M_{1}\,X_{2}^{1}\)" + + +def test_draw_pattern_measure() -> None: + p = Pattern() + p.add(N(1)) + p.add(N(2)) + p.add(N(3)) + p.add(N(10)) + p.add(N(4)) + p.add(E((1, 2))) + p.add(C(1, Clifford.H)) + p.add(M(1, Plane.YZ, 0.5)) + p.add(M(2, Plane.XZ, -0.25)) + p.add(M(10, Plane.XZ, -0.25)) + p.add(M(3, Plane.XY, 0.1, s_domain={1, 10}, t_domain={2})) + p.add(M(4, Plane.XY, 0, s_domain={1}, t_domain={2})) + assert ( + str(p) + == "N(1) N(2) N(3) N(10) N(4) E(1,2) C(1,H) M(1,YZ,pi/2) M(2,XZ,-pi/4) M(10,XZ,-pi/4) {2}[M(3,pi/10)]{1,10} {2}[M(4)]{1}" + ) + assert p.to_unicode() == "N₁ N₂ N₃ N₁₀ N₄ E₁₋₂ C₁(H) M₁(YZ,π/2) M₂(XZ,-π/4) M₁₀(XZ,-π/4) ₂[M₃(π/10)]¹,¹⁰ ₂[M₄]¹" + assert ( + p.to_latex() + == r"\(N_{1}\,N_{2}\,N_{3}\,N_{10}\,N_{4}\,E_{1,2}\,C_{1}^{H}\,M_{1}^{YZ,\frac{\pi}{2}}\,M_{2}^{XZ,-\frac{\pi}{4}}\,M_{10}^{XZ,-\frac{\pi}{4}}\,{}_2[M_{3}^{\frac{\pi}{10}}]^{1,10}\,{}_2[M_{4}]^{1}\)" + ) diff --git a/tests/test_runner.py b/tests/test_runner.py index 4ec6eea5..9b0c6a63 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -37,6 +37,7 @@ def modify_statevector(statevector: npt.ArrayLike, output_qubit: Collection[int] class TestPatternRunner: @pytest.mark.skipif(sys.modules.get("qiskit") is None, reason="qiskit not installed") + @pytest.mark.skip(reason="graphix-ibmq support is broken #251") def test_ibmq_backend(self, mocker: MockerFixture) -> None: # circuit in qiskit qc = qiskit.QuantumCircuit(3) diff --git a/tests/test_transpiler.py b/tests/test_transpiler.py index ca94e7e7..2d78dcd2 100644 --- a/tests/test_transpiler.py +++ b/tests/test_transpiler.py @@ -1,5 +1,9 @@ from __future__ import annotations +import platform +import sys +from shutil import which + import numpy as np import pytest from numpy.random import PCG64, Generator @@ -130,3 +134,65 @@ def simulate_and_measure() -> int: nb_shots = 10000 count = sum(1 for _ in range(nb_shots) if simulate_and_measure()) assert abs(count - nb_shots / 2) < nb_shots / 20 + + +@pytest.mark.skipif(sys.modules.get("qiskit") is None, reason="qiskit not installed") +def test_circuit_draw() -> None: + circuit = Circuit(10) + try: + circuit.draw("text") + circuit.draw("mpl") + except Exception as e: + pytest.fail(str(e)) + + +@pytest.mark.skipif(platform.system() == "Windows", reason="See [Bug]#259") +@pytest.mark.skipif(which("latex") is None, reason="latex not installed") # Since it is optional +@pytest.mark.skipif(sys.modules.get("qiskit") is None, reason="qiskit not installed") +def test_circuit_draw_latex() -> None: + circuit = Circuit(10) + try: + circuit.draw("latex") + circuit.draw("latex_source") + except Exception as e: + pytest.fail(str(e)) + + +@pytest.mark.skipif(sys.modules.get("pyzx") is None, reason="pyzx not installed") +@pytest.mark.parametrize("jumps", range(1, 11)) +def test_to_qasm3_consistency(fx_bg: PCG64, jumps: int) -> None: # Assert qasm converter is consistent with pyzx one. + rng = Generator(fx_bg.jumped(jumps)) + nqubits = 5 + depth = 4 + circuit = rand_circuit(nqubits, depth, rng) + qasm = circuit.to_qasm3() + import pyzx as zx + + z = zx.qasm(qasm) + assert z.to_qasm(version=3) == qasm + + +@pytest.mark.parametrize("jumps", range(1, 11)) +def test_to_qasm3(fx_bg: PCG64, jumps: int) -> None: # Consistency in the state simulation by generating qasm3 + rng = Generator(fx_bg.jumped(jumps)) + nqubits = 2 + depth = 1 + circuit = rand_circuit(nqubits, depth, rng) + qasm = circuit.to_qasm3() + print(qasm) + """ + import pyzx as zx + + from graphix.pyzx import from_pyzx_graph + + z = zx.qasm(qasm) + g = z.to_graph() + og = from_pyzx_graph(g) + pattern = og.to_pattern() + circuit_pat = circuit.transpile().pattern + + state = circuit_pat.simulate_pattern() + state_mbqc = pattern.simulate_pattern() + + assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) + """