# Copyright 2025 Qilimanjaro Quantum Tech
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import ast
import math
import re
from pathlib import Path
from qilisdk.digital.circuit import Circuit
from qilisdk.digital.exceptions import UnsupportedGateError
from qilisdk.digital.gates import CNOT, CZ, RX, RY, RZ, U1, U2, U3, Gate, H, M, S, T, X, Y, Z
[docs]
OPENQASM2_MAP: dict[type[Gate], str] = {
X: "x",
Y: "y",
Z: "z",
H: "h",
S: "s",
T: "t",
RX: "rx",
RY: "ry",
RZ: "rz",
U1: "u1",
U2: "u2",
U3: "u3",
CNOT: "cx",
CZ: "cz",
}
_ALLOWED_QASM2_FUNCTIONS = {
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"exp": math.exp,
"ln": math.log,
"sqrt": math.sqrt,
}
_MAX_DEPTH = 2
def _evaluate_qasm2_expression(expr: str) -> float:
"""Safely evaluate a numeric OpenQASM 2.0 parameter expression.
Raises:
ValueError: if the parameter expression in the input string is empty or the invalid.
Returns:
float: the parameter expression.
"""
normalized_expr = expr.strip().replace("^", "**")
if not normalized_expr:
raise ValueError("Empty parameter expression.")
try:
parsed = ast.parse(normalized_expr, mode="eval")
except SyntaxError as error:
raise ValueError(f"Invalid parameter expression: {expr}") from error
return _evaluate_qasm2_ast(parsed.body, expr)
def _evaluate_qasm2_ast_binary(node: ast.BinOp, original_expr: str) -> float:
left = _evaluate_qasm2_ast(node.left, original_expr)
right = _evaluate_qasm2_ast(node.right, original_expr)
if isinstance(node.op, ast.Add):
return left + right
if isinstance(node.op, ast.Sub):
return left - right
if isinstance(node.op, ast.Mult):
return left * right
if isinstance(node.op, ast.Div):
return left / right
if isinstance(node.op, ast.Pow):
return left**right
raise ValueError(f"Unsupported operator in parameter expression: {original_expr}")
def _evaluate_qasm2_ast(node: ast.AST, original_expr: str) -> float:
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.Name):
if node.id == "pi":
return math.pi
raise ValueError(f"Unsupported symbol in parameter expression: {original_expr}")
if isinstance(node, ast.BinOp):
return _evaluate_qasm2_ast_binary(node, original_expr)
if isinstance(node, ast.UnaryOp):
operand = _evaluate_qasm2_ast(node.operand, original_expr)
if isinstance(node.op, ast.UAdd):
return operand
if isinstance(node.op, ast.USub):
return -operand
raise ValueError(f"Unsupported unary operator in parameter expression: {original_expr}")
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name):
raise ValueError(f"Unsupported function in parameter expression: {original_expr}")
func = _ALLOWED_QASM2_FUNCTIONS.get(node.func.id)
if func is None:
raise ValueError(f"Unsupported function in parameter expression: {original_expr}")
if len(node.args) != 1 or node.keywords:
raise ValueError(f"Unsupported function signature in parameter expression: {original_expr}")
argument = _evaluate_qasm2_ast(node.args[0], original_expr)
return float(func(argument))
raise ValueError(f"Unsupported parameter expression: {original_expr}")
def _parse_qasm2_gate_line(line: str) -> tuple[str, str | None, str] | None:
"""Parse a QASM gate line with bounded parenthesis nesting.
Returns:
tuple[str, str | None, str] | None: the gate name, the string representing the parameter if available, and the rest.
Raises:
ValueError: if the parameter expression is nested for deeper than the max depth allowed, or the expression is invalid.
"""
if not line.endswith(";"):
return None
body = line[:-1].strip()
if not body:
return None
name_match = re.match(r"^(\w+)", body)
if name_match is None:
return None
gate_name = name_match.group(1)
rest = body[name_match.end() :].strip()
params_str = None
if rest.startswith("("):
depth = 0
closing_index = None
for index, char in enumerate(rest):
if char == "(":
depth += 1
if depth > _MAX_DEPTH:
raise ValueError(f"Parameter expression nesting deeper than {_MAX_DEPTH} levels is not supported.")
elif char == ")":
depth -= 1
if depth < 0:
raise ValueError(f"Invalid gate syntax: {line}")
if depth == 0:
closing_index = index
break
if closing_index is None:
raise ValueError(f"Unclosed parameter expression in gate: {line}")
params_str = rest[1:closing_index].strip()
rest = rest[closing_index + 1 :].strip()
if not rest:
return None
return gate_name, params_str, rest
[docs]
def to_qasm2(circuit: Circuit) -> str:
"""
Convert the circuit to an OpenQASM 2.0 formatted string.
Args:
circuit: The circuit to convert to OpenQASM 2.0.
Returns:
str: The OpenQASM 2.0 representation of the circuit.
"""
qasm_lines: list[str] = []
# QASM header, standard library and quantum register.
qasm_lines.extend(("OPENQASM 2.0;", 'include "qelib1.inc";', f"qreg q[{circuit.nqubits}];"))
# If any measurement is present, declare a classical register.
if any(isinstance(gate, M) for gate in circuit.gates):
qasm_lines.append(f"creg c[{circuit.nqubits}];")
# Process each gate.
for gate in circuit.gates:
# Special conversion for measurement.
if isinstance(gate, M):
if len(gate.target_qubits) == circuit.nqubits:
qasm_lines.append("measure q -> c;")
else:
# Generate a measurement for each target qubit.
measurements = (f"measure q[{q}] -> c[{q}];" for q in gate.target_qubits)
qasm_lines.extend(measurements)
else:
# Map the internal gate name to its QASM equivalent.
qasm_name = OPENQASM2_MAP.get(type(gate), gate.name.lower())
# Format parameter string, if any.
param_str = ""
if gate.is_parameterized:
parameters = ", ".join(str(p) for p in gate.get_parameter_values())
param_str = f"({parameters})"
# Format qubit operands.
qubit_str = ", ".join(f"q[{q}]" for q in gate.qubits)
qasm_lines.append(f"{qasm_name}{param_str} {qubit_str};")
return "\n".join(qasm_lines)
[docs]
def to_qasm2_file(circuit: Circuit, filename: str) -> None:
"""
Save the QASM representation to a file.
Args:
circuit: The circuit to convert to OpenQASM 2.0.
filename (str): The path to the file where the QASM code will be saved.
"""
qasm_code = to_qasm2(circuit)
Path(filename).write_text(qasm_code, encoding="utf-8")
# TODO(vyron): Add full support for OpenQASM 2.0 grammar.
[docs]
def from_qasm2(qasm_str: str) -> Circuit:
"""
Parse an OpenQASM 2.0 string and create a corresponding Circuit instance.
This parser supports the following instructions:
- Quantum register declaration (e.g., "qreg q[3];")
- Classical register declaration (ignored)
- Gate instructions (one-qubit and two-qubit gates)
- Measurement instructions (e.g., "measure q[0] -> c[0];")
Args:
qasm_str (str): The QASM string to parse.
Returns:
Circuit: The constructed Circuit object.
""" # noqa: DOC501
# Mapping from QASM gate names (lowercase) to internal gate names.
reverse_qasm2_map = {v: k for k, v in OPENQASM2_MAP.items()}
circuit = None
lines = qasm_str.splitlines()
for raw_line in lines:
line = raw_line.strip()
if "//" in line:
line = line.split("//", 1)[0].strip()
if not line or line.startswith("//"):
continue
# Skip header and include lines.
if line.startswith(("OPENQASM", "include")):
continue
# Parse quantum register declaration.
if line.startswith("qreg"):
# e.g., "qreg q[3];"
m = re.match(r"qreg\s+\w+\[(\d+)\];", line)
if m:
nqubits = int(m.group(1))
circuit = Circuit(nqubits)
continue
# Skip classical register declaration.
if line.startswith("creg"):
continue
# Process measurement instructions.
if line.startswith("measure"):
# e.g., "measure q[0] -> c[0];"
m = re.match(r"measure\s+q\[(\d+)\]\s*->\s*c\[\d+\];", line)
if m:
# TODO(vyron): Check consecutive lines of measurement and combine into single M.
q_index = int(m.group(1))
if circuit is None:
raise ValueError("Quantum register must be declared before measurement.")
circuit.add(M(q_index))
else:
# Special case: "measure q -> c;" means measure all qubits
m_all = re.match(r"measure\s+q\s*->\s*c\s*;", line)
if m_all:
if circuit is None:
raise ValueError("Quantum register must be declared before measurement.")
circuit.add(M(*list(range(circuit.nqubits))))
continue
# Process gate instructions.
gate_data = _parse_qasm2_gate_line(line)
if gate_data:
qasm_gate_name, params_str, operands_str = gate_data
# Convert QASM gate name to internal gate name.
gate_class = reverse_qasm2_map.get(qasm_gate_name.lower())
if gate_class is None:
raise UnsupportedGateError(f"Unknown gate: {qasm_gate_name}")
# Extract qubit indices.
qubit_matches = re.findall(r"q\[(\d+)\]", operands_str)
qubits = [int(q) for q in qubit_matches]
# Parse parameters, if any.
parameters = []
if params_str:
parameters = [_evaluate_qasm2_expression(p) for p in params_str.split(",") if p.strip()]
# Instantiate the gate based on the number of qubits.
# For one-qubit gates.
if len(qubits) == 1:
if gate_class.PARAMETER_NAMES:
# Build a dictionary of parameter names to values.
param_dict = {name: parameters[i] for i, name in enumerate(gate_class.PARAMETER_NAMES)}
gate_instance = gate_class(qubits[0], **param_dict) # ty: ignore[too-many-positional-arguments]
else:
gate_instance = gate_class(qubits[0]) # ty: ignore[too-many-positional-arguments]
# For two-qubit gates.
elif len(qubits) == 2: # noqa: PLR2004
if gate_class.PARAMETER_NAMES:
param_dict = {name: parameters[i] for i, name in enumerate(gate_class.PARAMETER_NAMES)}
gate_instance = gate_class(qubits[0], qubits[1], **param_dict) # ty: ignore[too-many-positional-arguments]
else:
gate_instance = gate_class(qubits[0], qubits[1]) # ty: ignore[too-many-positional-arguments]
else:
raise UnsupportedGateError("Only one- and two-qubit gates are supported.")
if circuit is None:
raise ValueError("Quantum register must be declared before adding gates.")
circuit.add(gate_instance)
if circuit is None:
raise ValueError("No quantum register declaration found in QASM.")
return circuit
[docs]
def from_qasm2_file(filename: str) -> Circuit:
"""
Read an OpenQASM 2.0 file and create a corresponding Circuit instance.
Args:
filename (str): The path to the QASM file.
Returns:
Circuit: The reconstructed Circuit object.
"""
qasm_str = Path(filename).read_text(encoding="utf-8")
return from_qasm2(qasm_str)