- from typing import Dict, List, Tuple
- import logging
- import datetime
- import numpy as np
- from qci_client import QciClient
- from eqc_models.base.base import ModelSolver, EqcModel
- log = logging.getLogger(name=__name__)
- class QciClientMixin:
- """
- This class provides an instance method and property that manage the connection to
- the REST API.
- Methods
- -------
- connect
- Properties
- ----------
- client : QciClient
- """
- url = None
- api_token = None
- def connect(self, url: str = None, api_token: str = None):
- """
- Use this method to define connection parameters manually. Returns the string "SUCCESS"
- on a successful connection, raises an a RuntimeError on failure.
- Parameters
- --------------
- url: The URL used to connect to the Dirac machine.
-
- api_token: Authentication token.
-
- """
- self.url = url
- self.api_token = api_token
- client = self.client
- if client is None:
- raise RuntimeError("Failed to connect to Qatalyst service")
- return "SUCCESS"
- @property
- def client(self) -> QciClient:
- """
- Returns a new client object every time. If the connection was not
- configured in code, then QciClient attempts to use environment variables
- to connect to the service
- """
-
- if self.url is None and self.api_token is None:
- log.debug(
- "Getting QciClient connection using environment variables"
- )
- client = QciClient()
- else:
- log.debug("Getting QciClient connection using class variables")
- client = QciClient(url=self.url, api_token=self.api_token)
- return client
- class Dirac1Mixin:
- sampler_type = "dirac-1"
- job_params_names = ["num_samples", "alpha", "atol"]
- class QuboSolverMixin:
- job_type = "qubo"
- def uploadJobFiles(self, client:QciClient, model:EqcModel):
- """
- This method retrieves a QUBO representation from the model's
- :code:`qubo` property and uploads it, returning :code:`qubo_file_id`
- for submission with a job request.
- Parameters
- --------------
- client: The QciClient instance.
- model: The EqcModel instance.
-
- """
-
-
- Q = model.qubo.Q
- qubo_file = {
- "file_name": f"{model.__class__.__name__}-qubo",
- "file_config": {
- "qubo": {"data": Q, "num_variables": Q.shape[0]}
- },
- }
- qubo_file_id = client.upload_file(file=qubo_file)["file_id"]
- return {"qubo_file_id": qubo_file_id}
- class Dirac3Mixin:
- """
- Defines the specifics required for using Dirac-3 as the sampler
- """
- sampler_type = "dirac-3"
- job_params_names = [
- "num_samples",
- "solution_precision",
- "relaxation_schedule",
- "mean_photon_number",
- "normalized_loss_rate",
- ]
- def uploadJobFiles(self, client: QciClient, model: EqcModel):
- """
- Upload a Hamiltonian in polynomial format.
- Parameters
- --------------
- client: The QciClient instance.
- model: The EqcModel instance.
- """
-
- polynomial = model.polynomial
- poly_coeffs = polynomial.coefficients
- poly_indices = polynomial.indices
- data = []
-
- max_degree = 0
- min_degree = len(poly_indices[-1])
- num_variables = 0
- for i in range(len(poly_coeffs)):
- idx = 0
- if num_variables < max(poly_indices[i]):
- num_variables = max(poly_indices[i])
- while max_degree < len(poly_indices[i]) and idx < len(
- poly_indices[i]
- ):
- if (
- poly_indices[i][idx] > 0
- and max_degree < len(poly_indices[i]) - idx
- ):
- max_degree = len(poly_indices[i]) - idx
- idx += len(poly_indices[i])
- else:
- idx += 1
- idx = len(poly_indices[i]) - 1
- while min_degree > 1 and idx > 0:
- if (
- poly_indices[i][idx] > 0
- and min_degree > len(poly_indices[i]) - idx
- ):
- min_degree = len(poly_indices[i]) - idx
- idx = 0
- else:
- idx -= 1
- data.append(
- {
- "idx": poly_indices[i],
- "val": float(poly_coeffs[i]),
- }
- )
- log.debug("Min degree of polynomial %d", min_degree)
- log.debug("Max degree of polynomial %d", max_degree)
- log.debug("Number of polynomial elements %d", len(poly_coeffs))
- polynomial = {
- "file_name": f"{model.__class__.__name__}",
- "file_config": {
- "polynomial": {
- "num_variables": int(num_variables)
- + model.machine_slacks,
- "max_degree": max_degree,
- "min_degree": min_degree,
- "data": data,
- }
- },
- }
- log.debug(polynomial)
- file_id = client.upload_file(file=polynomial)["file_id"]
- log.debug("Upload polynomial file produced file id %s", file_id)
- return {"polynomial_file_id": file_id}
- class QciClientSolver(QciClientMixin, ModelSolver):
- """
- Parameters
- -----------
- url : string
- optional value specifying the QCi API URL
- api_token : string
- optional value specifying the authentication token for the QCi API
- QCi API client wrapper for solving an EQC model. This class provides the
- common method for uploading a file to the API for solving. Since the file
- types change for the job types, the specific files required for the job are
- specified in subclasses within the `uploadFiles` method.
- """
- def __init__(self, url=None, api_token=None):
- self.url = url
- self.api_token = api_token
- @staticmethod
- def uploadFile(
- file_data: np.ndarray,
- file_name: str = None,
- file_type: str = None,
- client: QciClient = None,
- ) -> str:
- """
- Upload the operator file, return the file ID.
- Parameters
- --------------
- file_data: numpy array, dictionary or list
- contains file data to be uploaded
- file_name: str
- Name of the file to be uploaded.
- file_type: str
- Type of the file to be uploaded.
- client: QciClient
- QciClient instance
- """
- n = file_data.shape[0]
- log.debug("Uploading %s file of %d variables", file_type, n)
- if client is None:
- log.debug("Retrieving instance client")
- client = self.client
- file_obj = {"file_config": {file_type: {"data": file_data}}}
- if file_type in (
- "constraints",
- "hamiltonian",
- "qubo",
- "objective",
- ):
- file_obj["file_config"][file_type]["num_variables"] = n
-
- if file_name is None:
- ts = datetime.datetime.now().timestamp()
- file_name = f"{file_type}{n}-{ts}"
- log.debug("Using file name %s", file_name)
- file_obj["file_name"] = file_name
- file_id = client.upload_file(file=file_obj)["file_id"]
- return file_id
- def uploadJobFiles(self, client: QciClient, model: EqcModel):
- raise NotImplementedError("Subclass must override uploadJobFiles")
- def solve(
- self,
- model: EqcModel,
- name: str = None,
- tags: List = None,
- num_samples: int = 1,
- wait=True,
- job_type=None,
- **job_kwargs,
- ) -> Dict:
- """
- Parameters
- --------------
- model: EqcModel
- Instance of a model for solving.
-
- name: str
- Name of the job; default is None.
-
- tags: list
- A list of job tags; default is None.
-
- num_samples: int
- Number of samples used; default is 1.
-
- wait: bool
- The wait flag indicating whether to wait for the job to complete
- before returning the complete job data otherwise return a job ID
- as soon as a job is submitted; default is True.
-
- job_type: str
- Type of the job; default is None. When None, it is constructed
- from the instance `job_type` property.
- Returns
- ----------
- job response dictionary
-
- This method takes the particulars of the instance model and handles
- the QciClient.solve call.
-
- """
- job_config = {}
- job_config.update(
- {"num_samples": num_samples, "device_type": self.sampler_type}
- )
-
- for name in self.job_params_names:
- if name in job_kwargs:
- job_config[name] = job_kwargs.pop(name)
- elif hasattr(model, name):
- job_config[name] = getattr(model, name)
- leftovers = ",".join(job_kwargs.keys())
- if leftovers:
- raise ValueError(
- f"Unused job parameters given to solve method: {leftovers}"
- )
-
- client = self.client
- job_files = self.uploadJobFiles(client, model)
- log.debug(f"Building job body: {job_config}")
- if job_type is None:
- job_type = f"sample-{self.job_type}"
- job_body = client.build_job_body(
- job_type=job_type,
- job_params=job_config,
- job_tags=tags,
- **job_files,
- )
- response = client.process_job(job_body=job_body, wait=wait)
- return response
- def getResults(self, response: Dict) -> Dict[str, List]:
- """
- Extract the results from response.
- Parameters
- --------------
- response: The responce from QciClient.
- Returns
- --------------
- The results json object.
-
- """
- results = response["results"]
- log.debug("Got results object: %s", results)
- return results
- class Dirac1CloudSolver(Dirac1Mixin, QuboSolverMixin, QciClientSolver):
- """
- Overview
- ---------
- Dirac1CloudSolver is a class that encapsulates the different calls to Qatalyst
- for Dirac-1 jobs, which are quadratic binary optimization problems.
- Examples
- -------------------
- >>> C = np.array([[-1], [-1]])
- >>> J = np.array([[0, 1.0], [1.0, 0]])
- >>> from eqc_models.base.quadratic import QuadraticModel
- >>> model = QuadraticModel(C, J)
- >>> model.upper_bound = np.array([1, 1]) # set the domain maximum per variable
- >>> solver = Dirac1CloudSolver()
- >>> response = solver.solve(model, num_samples=5) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
- 2... submitted... COMPLETED...
- >>> response["results"]["energies"][0] <= 1.0
- True
- """
- class Dirac3CloudSolver(Dirac3Mixin, QciClientSolver):
- """
-
- Dirac3CloudSolver is a class that encapsulates the different calls to Qatalyst
- for Dirac-3 jobs. Currently, there are two different jobs, one for integer and
- another for continuous solutions. Calling the solve method with different arguments
- controls which job is submitted. The continuous job requires :code:`sum_constraint`
- and optionally takes the :code:`solution_precision` argument. The integer job
- does not accept either of these parameters, so specifying a sum constraint forces
- the job type to be continuous and not specifying it results in the integer job being
- called.
- Continuous Solver
- -------------------
- Utilizing Dirac-3 as a continuous solver involves encoding the variables in single time bins
- with the values of each determined by a normalized photon count value.
- Integer Solver
- -------------------
- Utilizing Dirac-3 as an integer solver involves encoding the variables in multiple time bins,
- each representing a certain value for that variable, or "qudit".
- Examples
- -------------------
- >>> C = np.array([[1], [1]])
- >>> J = np.array([[-1.0, 0], [0, -1.0]])
- >>> from eqc_models.base.quadratic import QuadraticModel
- >>> model = QuadraticModel(C, J)
- >>> model.upper_bound = np.array([1, 1]) # set the domain maximum per variable
- >>> solver = Dirac3CloudSolver()
- >>> response = solver.solve(model, sum_constraint=1, relaxation_schedule=1,
- ... solution_precision=None) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
- 2... submitted... COMPLETED...
- >>> response["results"]["energies"][0] <= 1.0
- True
- >>> C = np.array([-1, -1], dtype=np.float32)
- >>> J = np.array([[0, 1], [1, 0]], dtype=np.float32)
- >>> model = QuadraticModel(C, J)
- >>> model.upper_bound = np.array([1, 1]) # set the domain maximum per variable
- >>> response = solver.solve(model, relaxation_schedule=1) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
- 2... submitted... COMPLETED...
- >>> response["results"]["energies"][0] == -1.0
- True
- """
- job_type = "hamiltonian"
- job_params_names = Dirac3Mixin.job_params_names + ["num_levels", "sum_constraint"]
- def solve(
- self,
- model: EqcModel,
- name: str = None,
- tags: List = None,
- sum_constraint: float = None,
- relaxation_schedule: int = None,
- solution_precision: float = None,
- num_samples: int = 1,
- wait: bool = True,
- mean_photon_number: float = None,
- normalized_loss_rate: int = None,
- **job_kwargs,
- ):
- """
- Parameters
- --------------
- model: EqcModel
- a model object which supplies a hamiltonian operator for the
- device to sample. Must support the polynomial operator property.
- tags: List
- a list of strings to save with the job
- sum_constraint : float
- a value which applies a constraint to the solution, forcing
- all variables to sum to this value, changes method to continuous
- solver
- relaxation_schedule : int
- a predefined schedule indicator which sets parameters
- on the device to control the sampling through photon
- measurement
- solution_precision : float
- a value which, when not None, indicates the numerical
- precision desired in the solution: 1 for integer, 0.1
- for tenths place, 0.01 for hundreths and None for raw,
- only used with continuous solver
- num_samples : int
- the number of samples to take, defaults to 1
- wait : bool
- a flag for waiting for the response or letting it run asynchronously.
- Asynchronous runs must retrieve results directly using qci-client and
- the job_id.
- mean_photon_number : float
- an optional decimal value which sets the average number
- of photons that are present in a given quantum state.
- Modify this value to control the relaxation schedule more
- precisely than the four presets given in schedules 1
- through 4. Allowed values are decimals between 0.1 and 2.
- normalized_loss_rate : int
- an integer value which Sets the amount of loss introduced
- into the system for each loop during the measurement process.
- Modify this value to control the relaxation schedule more
- precisely than the four presets given in schedules 1
- through 4. Allowed values range from 1 to 50.
- """
-
- continuous = sum_constraint is not None
- if continuous:
- job_kwargs["sum_constraint"] = sum_constraint
- if relaxation_schedule not in (1, 2, 3, 4):
- raise ValueError(
- "relaxation_schedule must be one of 1, 2, 3 or 4"
- )
- job_kwargs["relaxation_schedule"] = relaxation_schedule
- job_kwargs["mean_photon_number"] = mean_photon_number
- job_kwargs["normalized_loss_rate"] = normalized_loss_rate
- job_kwargs["solution_precision"] = solution_precision
- job_type = "sample-" + self.job_type
- return super().solve(
- model,
- name,
- tags=tags,
- num_samples=num_samples,
- wait=wait,
- job_type=job_type,
- **job_kwargs,
- )
- else:
- job_kwargs["mean_photon_number"] = mean_photon_number
- job_kwargs["normalized_loss_rate"] = normalized_loss_rate
- job_kwargs["relaxation_schedule"] = relaxation_schedule
- job_kwargs["num_levels"] = ub = [val + 1 for val in model.upper_bound.tolist()]
- job_type = "sample-" + self.job_type + "-integer"
- return super().solve(
- model,
- name,
- tags=tags,
- num_samples=num_samples,
- wait=wait,
- job_type=job_type,
- **job_kwargs,
- )
- class Dirac3IntegerCloudSolver(Dirac3Mixin, QciClientSolver):
- """
-
- >>> C = np.array([-1, -1], dtype=np.float32)
- >>> J = np.array([[0, 1], [1, 0]], dtype=np.float32)
- >>> from eqc_models.base.quadratic import QuadraticModel
- >>> model = QuadraticModel(C, J)
- >>> model.upper_bound = np.array([1, 1]) # set the domain maximum per variable
- >>> solver = Dirac3IntegerCloudSolver()
- >>> model = QuadraticModel(C, J)
- >>> model.upper_bound = np.array([1, 1]) # set the domain maximum per variable
- >>> response = solver.solve(model, relaxation_schedule=1) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
- 2... submitted... COMPLETED...
- >>> response["results"]["energies"][0] == -1.0
- True
- """
- job_type = "hamiltonian-integer"
- job_params_names = Dirac3Mixin.job_params_names + ["num_levels"]
- def solve(self,
- model : EqcModel,
- name: str = None,
- tags: List = None,
- relaxation_schedule: int = None,
- num_samples: int = 1,
- wait: bool = True,
- mean_photon_number: float = None,
- normalized_loss_rate: int = None,
- **job_kwargs,
- ):
- """
- Parameters
- --------------
- model: EqcModel
- a model object which supplies a hamiltonian operator for the
- device to sample. Must support the polynomial operator property.
- tags: List
- a list of strings to save with the job
- relaxation_schedule : int
- a predefined schedule indicator which sets parameters
- on the device to control the sampling through photon
- measurement
- num_samples : int
- the number of samples to take, defaults to 1
- wait : bool
- a flag for waiting for the response or letting it run asynchronously.
- Asynchronous runs must retrieve results directly using qci-client and
- the job_id.
- mean_photon_number : float
- an optional decimal value which sets the average number
- of photons that are present in a given quantum state.
- Modify this value to control the relaxation schedule more
- precisely than the four presets given in schedules 1
- through 4. Allowed values are decimals between 0.1 and 2.
- normalized_loss_rate : int
- an integer value which Sets the amount of loss introduced
- into the system for each loop during the measurement process.
- Modify this value to control the relaxation schedule more
- precisely than the four presets given in schedules 1
- through 4. Allowed values range from 1 to 50.
- Dirac3IntegerCloudSolver is a class that encapsulates the different calls to
- Qatalyst for Dirac-3 jobs. Utilizing Dirac-3 as an integer solver involves
- encoding the variables in multiple time bins, each representing a certain
- value for that variable, or "qudit".
- """
- return super().solve(
- model,
- name,
- tags=tags,
- num_samples=num_samples,
- wait=wait,
- mean_photon_number=mean_photon_number,
- normalized_loss_rate=normalized_loss_rate,
- relaxation_schedule=relaxation_schedule,
- num_levels=[val + 1 for val in model.upper_bound.tolist()],
- **job_kwargs,
- )
- class Dirac3ContinuousCloudSolver(Dirac3Mixin, QciClientSolver):
- """
- >>> C = np.array([[1], [1]])
- >>> J = np.array([[-1.0, 0], [0, -1.0]])
- >>> from eqc_models.base.quadratic import QuadraticModel
- >>> model = QuadraticModel(C, J)
- >>> model.upper_bound = np.array([1, 1]) # set the domain maximum per variable
- >>> solver = Dirac3ContinuousCloudSolver()
- >>> response = solver.solve(model, sum_constraint=1, relaxation_schedule=1,
- ... solution_precision=None) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
- 2... submitted... COMPLETED...
- >>> response["results"]["energies"][0] <= 1.0
- True
- """
- job_type = "hamiltonian"
- job_params_names = Dirac3Mixin.job_params_names + ["sum_constraint"]
- def solve(
- self,
- model: EqcModel,
- name: str = None,
- tags: List = None,
- sum_constraint: float = None,
- relaxation_schedule: int = None,
- solution_precision: float = None,
- num_samples: int = 1,
- wait: bool = True,
- mean_photon_number: float = None,
- normalized_loss_rate: int = None,
- **job_kwargs,
- ):
- """
- Parameters
- --------------
- model: EqcModel
- a model object which supplies a hamiltonian operator for the
- device to sample. Must support the polynomial operator property.
- tags: List
- a list of strings to save with the job
- sum_constraint : float
- a value which applies a constraint to the solution, forcing
- all variables to sum to this value
- relaxation_schedule : int
- a predefined schedule indicator which sets parameters
- on the device to control the sampling through photon
- measurement
- solution_precision : float
- a value which, when not None, indicates the numerical
- precision desired in the solution: 1 for integer, 0.1
- for tenths place, 0.01 for hundreths and None for raw
- num_samples : int
- the number of samples to take, defaults to 1
- wait : bool
- a flag for waiting for the response or letting it run asynchronously.
- Asynchronous runs must retrieve results directly using qci-client and
- the job_id.
- mean_photon_number : float
- an optional decimal value which sets the average number
- of photons that are present in a given quantum state.
- Modify this value to control the relaxation schedule more
- precisely than the four presets given in schedules 1
- through 4. Allowed values are decimals between 0.1 and 2.
- normalized_loss_rate : int
- an integer value which Sets the amount of loss introduced
- into the system for each loop during the measurement process.
- Modify this value to control the relaxation schedule more
- precisely than the four presets given in schedules 1
- through 4. Allowed values range from 1 to 50.
- """
- if sum_constraint is None:
- raise ValueError(
- "sum_constraint must be specified as a positive number"
- )
- if relaxation_schedule not in (1, 2, 3, 4):
- raise ValueError(
- "relaxation_schedule must be one of 1, 2, 3 or 4"
- )
- job_type = "sample-" + self.job_type
- return super().solve(
- model,
- name,
- tags=tags,
- num_samples=num_samples,
- wait=wait,
- job_type=job_type,
- solution_precision=solution_precision,
- sum_constraint=sum_constraint,
- relaxation_schedule=relaxation_schedule,
- mean_photon_number=mean_photon_number,
- normalized_loss_rate=normalized_loss_rate,
- **job_kwargs,
- )