#
# Copyright © 2022-2024 University of Strasbourg. All Rights Reserved.
# Copyright © 2023-2025 QPerfect. All Rights Reserved.
#
# 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 mimiqlink
import tempfile
import json
import os
import shutil
from mimiqcircuits.circuit import Circuit
from mimiqcircuits.qcsresults import QCSResults
from mimiqcircuits.__version__ import __version__
import numpy as np
from time import sleep
# maximum number of samples allowed
MAX_SAMPLES = 2**16
# default value for the number of samples
DEFAULT_SAMPLES = 1000
# minimum and maximum bond dimension allowed
MIN_BONDDIM = 1
MAX_BONDDIM = 2**12
# minimum and maximum entanglement dimension allowed
MIN_ENTDIM = 4
MAX_ENTDIM = 64
# default bond dimension
DEFAULT_BONDDIM = 256
# default entanglement dimension
DEFAULT_ENTDIM = 16
# default time limit
DEFAULT_TIME_LIMIT = 30
# default algorithm
DEFAULT_ALGORITHM = "auto"
RESULTSPB_FILE = "results.pb"
CIRCUIT_FNAME = "circuit"
EXTENSION_PROTO = "pb"
EXTENSION_QASM = "qasm"
EXTENSION_STIM = "stim"
TYPE_PROTO = "proto"
TYPE_QASM = "qasm"
TYPE_STIM = "stim"
def _file_is_openqasm2(file_path: str) -> bool:
with open(file_path, "r") as file:
for line in file:
line = line.strip()
if line.startswith("//") or not line:
continue
return line.startswith("OPENQASM 2.0;")
return False
def _file_may_be_stim(filepath: str) -> bool:
STIM_KEYWORDS = {
"I", "X", "Y", "Z", "C_XYZ", "C_ZYX", "H", "H_XY", "H_XZ", "H_YZ", "S",
"SQRT_X", "SQRT_X_DAG", "SQRT_Y", "SQRT_Y_DAG", "SQRT_Z", "SQRT_Z_DAG", "S_DAG",
"CNOT", "CX", "CXSWAP", "CY", "CZ", "CZSWAP", "ISWAP", "ISWAP_DAG",
"SQRT_XX", "SQRT_XX_DAG", "SQRT_YY", "SQRT_YY_DAG", "SQRT_ZZ", "SQRT_ZZ_DAG",
"SWAP", "SWAPCX", "SWAPCZ", "XCX", "XCY", "XCZ", "YCX", "YCY", "YCZ", "ZCX", "ZCY", "ZCZ",
"CORRELATED_ERROR", "DEPOLARIZE1", "DEPOLARIZE2", "E", "ELSE_CORRELATED_ERROR",
"HERALDED_ERASE", "HERALDED_PAULI_CHANNEL_1", "PAULI_CHANNEL_1", "PAULI_CHANNEL_2",
"X_ERROR", "Y_ERROR", "Z_ERROR", "M", "MR", "MRX", "MRY", "MRZ", "MX", "MY", "MZ",
"R", "RX", "RY", "RZ", "MXX", "MYY", "MZZ", "MPP", "SPP", "SPP_DAG",
"REPEAT", "DETECTOR", "MPAD", "OBSERVABLE_INCLUDE", "QUBIT_COORDS", "SHIFT_COORDS", "TICK"
}
try:
with open(filepath, "r") as f:
while True:
line = f.readline()
if not line:
break
line = line.strip()
if line.startswith("#") or line == "":
continue
first_word = line.split()[0] if line.split() else ""
if first_word in STIM_KEYWORDS:
return True
except FileNotFoundError:
raise FileNotFoundError(f"File {filepath} not found.")
return False
class QCSError:
def __init__(self, error):
self.error = error
def __repr__(self):
return self.error
class RemoteConnection:
"""Base class for connections to the Mimiq Server.
This class provides common functionality for both MimiqConnection and PlanqkConnection.
"""
def __init__(self, connection: mimiqlink.AbstractConnection):
"""Initialize a remote connection using a specific connection type.
Args:
connection: A mimiqlink connection object (MimiqConnection or PlanqkConnection)
"""
self.connection = connection
def __get_timelimit(self):
"""Fetch the maximum time limit for execution from the server."""
# For MimiqConnection, get from user_limits
if isinstance(self.connection, mimiqlink.MimiqConnection) and hasattr(
self.connection, "user_limits"
):
limits = self.connection.user_limits
if limits and limits.get("enabledMaxTimeout"):
return limits.get("maxTimeout", DEFAULT_TIME_LIMIT)
return DEFAULT_TIME_LIMIT
# Forward the attribute/method call to the connection object
def __getattr__(self, name):
original = getattr(self.connection, name)
# It it is not callable, jsut return it
if not callable(original):
return original
# If it is a method, we need to wrap the return value
def wrapped(*args, **kwargs):
result = original(*args, **kwargs)
if isinstance(result, type(self.connection)):
return self
return result
return wrapped
def execute(
self,
circuits, # Can be a single Circuit object or a list of Circuit objects or QASM file paths
label="pyapi_v" + __version__,
algorithm=DEFAULT_ALGORITHM,
nsamples=DEFAULT_SAMPLES,
bitstrings=None,
timelimit=None,
bonddim=None,
entdim=None,
fuse=None,
reorderqubits=None,
seed=None,
qasmincludes=None,
force=False,
**kwargs,
):
"""
Execute a circuit or a list of quantum circuits on the Mimiq server.
Args:
circuits (Circuit or list of Circuits or str): A single Circuit object, a list of Circuit objects,
or QASM file paths representing the circuits to be executed.
label (str): A label for the execution. Defaults to "pyapi_v" + __version__.
algorithm (str): The algorithm to use. Defaults to "auto".
nsamples (int): The number of samples to collect. Defaults to DEFAULT_SAMPLES.
bitstrings (list of str, optional): Specific bitstrings to measure. Defaults to None.
timelimit (int, optional): The maximum execution time in minutes. Defaults to None.
bonddim (int, optional): The bond dimension to use. Defaults to None.
entdim (int, optional): The entanglement dimension to use. Defaults to None.
fuse (bool, optional): Whether to fuse gates. Defaults to None (let the remote service decide).
reorderqubits (bool, optional): Whether to reorder qubits. Defaults to None (let the remote service decide).
seed (int, optional): A seed for random number generation. Defaults to None.
qasmincludes (list of str, optional): Additional QASM includes. Defaults to None.
**kwargs: Additional keyword arguments.
Returns:
object: A handle to the execution, typically used to retrieve results.
Raises:
ValueError: If nsamples exceeds MAX_SAMPLES, bond/entanglement dimensions are out of bounds,
or if a circuit contains unevaluated symbolic parameters.
FileNotFoundError: If a QASM file is not found.
TypeError: If the circuits argument is not a Circuit object or a valid file path.
"""
if nsamples > MAX_SAMPLES:
raise ValueError(f"nsamples must be less than {MAX_SAMPLES}")
if timelimit is None:
timelimit = self.__get_timelimit()
maxtimelimit = self.__get_timelimit()
if timelimit > maxtimelimit:
raise ValueError(
f"Timelimit cannot be set more than {maxtimelimit} minutes."
)
if bitstrings is None:
bitstrings = []
elif isinstance(circuits, Circuit):
nq = circuits.num_qubits()
for b in bitstrings:
if len(b) != nq:
raise ValueError(
"The number of qubits in the bitstring is not equal to the number of qubits in the circuit."
)
if seed is None:
seed = int(np.random.randint(0, np.iinfo(np.int_).max, dtype=np.int_))
# Set bond and entangling dimensions based on algorithm
if algorithm == "auto" or algorithm == "mps":
if bonddim is None:
bonddim = DEFAULT_BONDDIM
if entdim is None:
entdim = DEFAULT_ENTDIM
if bonddim is not None and (bonddim < MIN_BONDDIM or bonddim > MAX_BONDDIM):
raise ValueError(f"bonddim must be between {MIN_BONDDIM} and {MAX_BONDDIM}")
# Check for entdim constraint in specific algorithms
if algorithm in ["mps", "auto"]:
actual_entdim = DEFAULT_ENTDIM if entdim is None else entdim
if actual_entdim < MIN_ENTDIM and not force:
raise ValueError(
f"entdim must be between {MIN_ENTDIM} and {MAX_ENTDIM}"
)
elif actual_entdim < MIN_ENTDIM and force:
print(
f"Warning: Running simulation with entdim={actual_entdim}. Results may be misleading."
)
with tempfile.TemporaryDirectory() as tmpdir:
allfiles = []
circuit_files = []
# Ensure circuits is a list
if isinstance(circuits, (Circuit, str)):
circuits = [circuits]
if len(circuits) > 1 and algorithm == "auto":
raise ValueError(
"The 'auto' algorithm is not supported in batch mode. Please specify 'mps' or 'statevector' for batch executions."
)
if not circuits:
raise ValueError(
"The provided list of circuits is empty. At least one circuit is required."
)
for i, c in enumerate(circuits):
if not isinstance(c, (Circuit, str)):
raise TypeError(
f"Invalid type at index {i}: expected Circuit or QASM file path, got {type(c).__name__}."
)
if isinstance(c, Circuit) and len(c) == 0:
raise ValueError(
f"Empty Circuit object at index {i} is not allowed."
)
if isinstance(c, str) and not os.path.isfile(c):
raise ValueError(
f"Invalid QASM file path at index {i}: {c} does not exist."
)
for i, circuit in enumerate(circuits):
if isinstance(circuit, Circuit) and circuit.is_symbolic():
raise ValueError(
"The circuit contains unevaluated symbolic parameters and cannot be processed until all parameters are fully evaluated."
)
if isinstance(circuit, Circuit):
circuit_filename = os.path.join(
tmpdir, f"{CIRCUIT_FNAME}{i + 1}.{EXTENSION_PROTO}"
)
circuit.saveproto(circuit_filename)
circuit_files.append(
{
"file": os.path.basename(circuit_filename),
"type": TYPE_PROTO,
}
)
allfiles.append(circuit_filename)
elif isinstance(circuit, str):
if not os.path.isfile(circuit):
raise FileNotFoundError(f"File {circuit} not found.")
# Case: QASM
if _file_is_openqasm2(circuit):
circuit_filename = os.path.join(tmpdir, f"{CIRCUIT_FNAME}{i+1}.{EXTENSION_QASM}")
shutil.copyfile(circuit, circuit_filename)
if qasmincludes is None:
qasmincludes = []
circuit_files.append({
"file": os.path.basename(circuit_filename),
"type": TYPE_QASM
})
allfiles.append(circuit_filename)
# Case: STIM
elif _file_may_be_stim(circuit):
circuit_filename = os.path.join(tmpdir, f"{CIRCUIT_FNAME}{i+1}.{EXTENSION_STIM}")
shutil.copyfile(circuit, circuit_filename)
circuit_files.append({
"file": os.path.basename(circuit_filename),
"type": TYPE_STIM
})
allfiles.append(circuit_filename)
# Unknown type
else:
raise ValueError(
f"File {circuit} is neither a valid OpenQASM 2.0 file nor a recognizable STIM file."
)
else:
raise TypeError(
"circuits must be Circuit objects or paths to QASM or STIM files"
)
jsonbitstrings = ["bs" + o.to01() for o in bitstrings]
pars = {
"algorithm": algorithm,
"bitstrings": jsonbitstrings,
"samples": nsamples,
"seed": seed,
"circuits": circuit_files,
}
if bonddim is not None:
pars["bondDimension"] = bonddim
if entdim is not None:
pars["entDimension"] = entdim
if fuse is not None:
pars["fuse"] = fuse
if reorderqubits is not None:
pars["reorderQubits"] = reorderqubits
# Add any additional keyword arguments
pars.update(kwargs)
# Save the parameters to a JSON file
pars_filename = os.path.join(tmpdir, "circuits.json")
with open(pars_filename, "w") as f:
json.dump(pars, f)
# Prepare the request
req = {
"executor": "Circuits",
"timelimit": timelimit,
"apilang": "python",
"apiversion": __version__,
"circuitsapiversion": __version__,
}
reqfile = os.path.join(tmpdir, "request.json")
with open(reqfile, "w") as f:
json.dump(req, f)
# Make the request to the server
emutype = "CIRC"
sleep(0.1)
return self.connection.request(
emutype,
algorithm,
label,
timelimit,
[reqfile, pars_filename] + allfiles,
)
def get_results(self, execution, interval=1):
"""Retrieve the results of a completed execution.
Args:
execution (str): The execution identifier.
interval (int): The interval (in seconds) for checking job status (default: 1).
Returns:
List[QCSResults]: A list of QCSResults instances.
Raises:
RuntimeError: If the remote job encounters an error.
"""
# Wait for the job to finish
while not self.connection.isJobDone(execution):
sleep(interval)
infos = self.connection.requestInfo(execution)
if infos.status == "ERROR":
error_message = infos.get("errorMessage", "Remote job errored.")
raise RuntimeError(f"Remote job errored: {error_message}")
elif infos.status == "CANCELED":
raise RuntimeError("Remote job canceled.")
with tempfile.TemporaryDirectory(prefix="mimiq_res_") as tmpdir:
names = self.connection.downloadResults(execution, destdir=tmpdir)
results_file_path = os.path.join(tmpdir, "results.json")
if "results.json" not in names or not os.path.isfile(results_file_path):
raise RuntimeError(f"No results found in execution {execution}.")
with open(results_file_path, "r") as f:
results_list = json.load(f)
results = []
for result in results_list:
if "error" in result:
results.append(QCSError(result["error"]))
else:
fname = os.path.join(tmpdir, result["file"])
if not os.path.isfile(fname):
raise RuntimeError(f"Missing result file {fname}")
results.append(QCSResults.loadproto(fname))
return results
def get_result(self, execution, **kwargs):
"""Retrieve the first result if multiple are found.
Args:
execution (str): The execution identifier.
**kwargs: Additional keyword arguments for result retrieval.
Returns:
QCSResults: The first result found.
Raises:
RuntimeWarning: If multiple results are found.
"""
results = self.get_results(execution, **kwargs)
if len(results) > 1:
print("Warning: Multiple results found. Returning the first one.")
return results[0]
def get_inputs(self, execution):
"""Retrieve the inputs (circuits and parameters) of the execution.
Args:
execution (str): The execution identifier.
Returns:
tuple: A tuple containing a list of Circuit objects and parameters (dict).
Raises:
RuntimeError: If required files are not found in the inputs.
"""
with tempfile.TemporaryDirectory(prefix="mimiq_in_") as tmpdir:
names = self.connection.downloadJobFiles(execution, destdir=tmpdir)
# Print the downloaded files for debugging purposes
print(f"Downloaded files: {names}")
# Get the base names of the downloaded files
base_names = [os.path.basename(name) for name in names]
# Check if the circuits.json and request.json files are present
if "circuits.json" not in base_names or "request.json" not in base_names:
raise RuntimeError(
f"{execution} is not a valid execution for MimiqCircuits: missing necessary files"
)
# Load the parameters from the circuits.json file
circuits_file_path = os.path.join(tmpdir, "circuits.json")
with open(circuits_file_path, "r") as f:
parameters = json.load(f)
circuits = []
for c in parameters["circuits"]:
if c["type"] == TYPE_PROTO:
circuit = Circuit.loadproto(os.path.join(tmpdir, c["file"]))
circuits.append(circuit)
else:
# case of STIM and QASM files
circuits.append(os.path.join(tmpdir, c["file"]))
if len(circuits) == 0:
raise RuntimeError(
"No valid circuit files found. Input parameters not valid."
)
return circuits, parameters
def get_input(self, execution, **kwargs):
"""Retrieve the first circuit and parameters of the execution.
Args:
execution (str): The execution identifier.
Returns:
tuple: A tuple containing the first Circuit object and parameters (dict).
Raises:
RuntimeError: If required files are not found in the inputs.
"""
circuits, parameters = self.get_inputs(execution, **kwargs)
if len(circuits) > 1:
print("Warning: Multiple results found. Returning the first one.")
return circuits[0], parameters
def __repr__(self):
"""Return a string representation of the connection."""
return self.connection.__repr__()
def __str__(self):
"""Return a string representation of the connection."""
return self.connection.__str__()
[docs]
class MimiqConnection(RemoteConnection):
"""Represents a connection to the Mimiq Server via direct cloud connection.
This is a wrapper around mimiqlink.MimiqConnection to provide the circuit execution API.
"""
def __init__(self, url=None):
"""Initialize a MimiqConnection.
Args:
url (str, optional): The URL of the Mimiq server. Defaults to None (using default cloud URL).
"""
connection = mimiqlink.MimiqConnection(url)
super().__init__(connection)
class PlanqkConnection(RemoteConnection):
"""Represents a connection to the Mimiq Server via PlanQK.
This is a wrapper around mimiqlink.PlanqkConnection to provide the circuit execution API.
"""
def __init__(self, url=None, consumer_key=None, consumer_secret=None):
"""Initialize a PlanqkConnection.
Args:
url (str, optional): The URL of the PlanQK API. Defaults to None (using default PlanQK URL).
consumer_key (str, optional): The consumer key for PlanQK authentication. Defaults to None.
consumer_secret (str, optional): The consumer secret for PlanQK authentication. Defaults to None.
"""
connection = mimiqlink.PlanqkConnection(url, consumer_key, consumer_secret)
super().__init__(connection)
__all__ = ["MimiqConnection", "PlanqkConnection", "RemoteConnection"]