diff --git a/qiskit_dynamics/backend/__init__.py b/qiskit_dynamics/backend/__init__.py index 89e8ad671..7f7233342 100644 --- a/qiskit_dynamics/backend/__init__.py +++ b/qiskit_dynamics/backend/__init__.py @@ -27,6 +27,8 @@ DynamicsBackend default_experiment_result_function + parse_backend_hamiltonian_dict """ from .dynamics_backend import DynamicsBackend, default_experiment_result_function +from .backend_string_parser import parse_backend_hamiltonian_dict diff --git a/qiskit_dynamics/backend/backend_string_parser/__init__.py b/qiskit_dynamics/backend/backend_string_parser/__init__.py new file mode 100644 index 000000000..9e7d46f72 --- /dev/null +++ b/qiskit_dynamics/backend/backend_string_parser/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Backend string parsing functionality. +""" + +from .hamiltonian_string_parser import parse_backend_hamiltonian_dict diff --git a/qiskit_dynamics/backend/backend_string_parser/hamiltonian_string_parser.py b/qiskit_dynamics/backend/backend_string_parser/hamiltonian_string_parser.py new file mode 100644 index 000000000..9e94deaba --- /dev/null +++ b/qiskit_dynamics/backend/backend_string_parser/hamiltonian_string_parser.py @@ -0,0 +1,305 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +# pylint: disable=invalid-name + +""" +Functionality for importing qiskit.pulse model string representation. + +This file is meant for internal use and may be changed at any point. +""" + +from typing import Tuple, List, Optional +from collections import OrderedDict + +# required for calls to exec +# pylint: disable=unused-import +import numpy as np + +from qiskit import QiskitError + +from .regex_parser import _regex_parser + + +# valid channel characters +CHANNEL_CHARS = ["U", "D", "M", "A", "u", "d", "m", "a"] + + +def parse_backend_hamiltonian_dict( + hamiltonian_dict: dict, subsystem_list: Optional[List[int]] = None +) -> Tuple[np.ndarray, np.ndarray, List[str], dict]: + r"""Convert Pulse backend Hamiltonian dictionary into concrete array format + with an ordered list of corresponding channels. + + The Pulse backend Hamiltonian dictionary, ``hamiltonian_dict``, must have the + following keys: + + * ``'h_str'``: List of Hamiltonian terms in string format (see below). + * ``'qub'``: Dictionary giving subsystem dimensions. Keys are subsystem labels, + values are their dimensions. + * ``'vars'``: Dictionary whose keys are the variables appearing in the terms in + the ``h_str`` list, and values being the numerical values of the variables. + + The optional argument ``subsystem_list`` specifies a subset of subsystems to keep when parsing. + If ``None``, all subsystems are kept. If ``subsystem_list`` is specified, then terms + including subsystems not in the list will be ignored. + + Entries in the list ``hamiltonian_dict['h_str']`` must be formatted as a product of + constants (either numerical constants or variables in ``hamiltonian_dict['vars'].keys()``) + with operators. Operators are indicated with a capital letters followed by an integer + indicating the subsystem the operator acts on. Accepted operator strings are: + + * ``'X'``: If the target subsystem is two dimensional, the + Pauli :math:`X` operator, and if greater than two dimensional, returns + :math:`a + a^\dagger`, where :math:`a` and :math:`a^\dagger` are the + annihiliation and creation operators, respectively. + * ``'Y'``: If the target subsystem is two dimensional, the + Pauli :math:`Y` operator, and if greater than two dimensional, returns + :math:`-i(a - a^\dagger)`, where :math:`a` and :math:`a^\dagger` are the + annihiliation and creation operators, respectively. + * ``'Z'``: If the target subsystem is two dimensional, the + Pauli :math:`Z` operator, and if greater than two dimensional, returns + :math:`I - 2 * N`, where :math:`N` is the number operator. + * ``'a'``, ``'A'``, or ``'Sm'``: If two dimensional, the sigma minus operator, and if greater, + generalizes to the operator. + * ``'C'``, or ``'Sp'``: If two dimensional, sigma plus operator, and if greater, + generalizes to the creation operator. + * ``'N'``, or ``'O'``: The number operator. + * ``'I'``: The identity operator. + + In addition to the above, a term in ``hamiltonian_dict['h_str']`` can be associated with + a channel by ending it with a string of the form ``'||Sxx'``, where ``S`` is a valid channel + label, and ``'xx'`` is an integer. Accepted channel labels are: + + * ``'D'`` or ``'d'`` for drive channels. + * ``'U'`` or ``'u'`` for control channels. + * ``'M'`` or ``'m'`` for measurement channels. + * ``'A'`` or ``'a'`` for acquire channels. + + Finally, summations of terms of the above form can be indicated in + ``hamiltonian_dict['h_str']`` via strings with syntax ``'_SUM[i, lb, ub, aa||S{i}]'``, + where: + + * ``i`` is the summation variable. + * ``lb`` and ``ub`` are the summation endpoints (inclusive). + * ``aa`` is a valid operator string, possibly including the string ``{i}`` to indicate + operators acting on subsystem ``i``. + * ``S{i}`` is the specification of a channel indexed by ``i``. + + + For example, the following ``hamiltonian_dict`` specifies a single + transmon with 4 levels: + + .. code-block:: python + + hamiltonian_dict = { + "h_str": ["v*np.pi*O0", "alpha*np.pi*O0*O0", "r*np.pi*X0||D0"], + "qub": {"0": 4}, + "vars": {"v": 2.1, "alpha": -0.33, "r": 0.02}, + } + + The following example specifies a two transmon system, with single system terms specified + using the summation format: + + .. code-block:: python + + hamiltonian_dict = { + "h_str": [ + "_SUM[i,0,1,wq{i}/2*(I{i}-Z{i})]", + "_SUM[i,0,1,delta{i}/2*O{i}*O{i}]", + "_SUM[i,0,1,-delta{i}/2*O{i}]", + "_SUM[i,0,1,omegad{i}*X{i}||D{i}]", + "jq0q1*Sp0*Sm1", + "jq0q1*Sm0*Sp1", + "omegad1*X0||U0", + "omegad0*X1||U1" + ], + "qub": {"0": 4, "1": 4}, + "vars": { + "delta0": -2.111793476400394, + "delta1": -2.0894421352015744, + "jq0q1": 0.010495754104003914, + "omegad0": 0.9715458990879812, + "omegad1": 0.9803812537440838, + "wq0": 32.517894442809514, + "wq1": 33.0948996120196, + }, + } + + Args: + hamiltonian_dict: Pulse backend Hamiltonian dictionary. + subsystem_list: List of subsystems to include in the model. If ``None`` all are kept. + + Returns: + Tuple: Model converted into concrete arrays - the static Hamiltonian, + a list of Hamiltonians corresponding to different channels, a list of + channel labels corresponding to the list of time-dependent Hamiltonians, + and a dictionary with subsystem dimensions whose keys are the subsystem labels. + """ + + # raise errors for invalid hamiltonian_dict + _hamiltonian_pre_parse_exceptions(hamiltonian_dict) + + # get variables + variables = OrderedDict() + if "vars" in hamiltonian_dict: + variables = OrderedDict(hamiltonian_dict["vars"]) + + # Get qubit subspace dimensions + if subsystem_list is None: + subsystem_list = [int(qubit) for qubit in hamiltonian_dict["qub"]] + else: + # if user supplied, make a copy and sort it + subsystem_list = sorted(subsystem_list) + + # force keys in hamiltonian['qub'] to be ints + qub_dict = {int(key): val for key, val in hamiltonian_dict["qub"].items()} + + subsystem_dims = {int(qubit): qub_dict[int(qubit)] for qubit in subsystem_list} + + # Parse the Hamiltonian + system = _regex_parser( + operator_str=hamiltonian_dict["h_str"], + subsystem_dims=subsystem_dims, + subsystem_list=subsystem_list, + ) + + # Extract which channels are associated with which Hamiltonian terms. + # Assumes one channel appearing in each term appearing at the end. + channels = [] + for _, ham_str in system: + chan_idx = None + + for c in CHANNEL_CHARS: + # if c in ham_str, and all characters after are digits, treat + # as channel + if c in ham_str: + if all(a.isdigit() for a in ham_str[ham_str.index(c) + 1 :]): + chan_idx = ham_str.index(c) + break + + if chan_idx is None: + channels.append(None) + else: + channels.append(ham_str[chan_idx:]) + + # evaluate coefficients + local_vars = {chan: 1.0 for chan in set(channels) if chan is not None} + local_vars.update(variables) + + evaluated_ops = [] + for op, coeff in system: + # pylint: disable=exec-used + exec(f"evaluated_coeff = {coeff}", globals(), local_vars) + evaluated_ops.append(local_vars["evaluated_coeff"] * op) + + # merge terms based on channel + static_hamiltonian = None + hamiltonian_operators = [] + reduced_channels = [] + + for channel, op in zip(channels, evaluated_ops): + # if None, add it to the static hamiltonian + if channel is None: + if static_hamiltonian is None: + static_hamiltonian = op + else: + static_hamiltonian += op + else: + channel = channel.lower() + if channel in reduced_channels: + hamiltonian_operators[reduced_channels.index(channel)] += op + else: + hamiltonian_operators.append(op) + reduced_channels.append(channel) + + # sort channels/operators according to channel ordering + if len(reduced_channels) > 0: + reduced_channels, hamiltonian_operators = zip( + *sorted(zip(reduced_channels, hamiltonian_operators)) + ) + + return static_hamiltonian, list(hamiltonian_operators), list(reduced_channels), subsystem_dims + + +def _hamiltonian_pre_parse_exceptions(hamiltonian_dict: dict): + """Raises exceptions for improperly formatted or unsupported elements of + hamiltonian dict specification. + + Parameters: + hamiltonian_dict: Dictionary specification of hamiltonian. + Returns: + Raises: + QiskitError: If some part of the Hamiltonian dictionary is unsupported or invalid. + """ + + ham_str = hamiltonian_dict.get("h_str", []) + if ham_str in ([], [""]): + raise QiskitError("Hamiltonian dict requires a non-empty 'h_str' entry.") + + if hamiltonian_dict.get("qub", {}) == {}: + raise QiskitError( + "Hamiltonian dict requires non-empty 'qub' entry with subsystem dimensions." + ) + + if hamiltonian_dict.get("osc", {}) != {}: + raise QiskitError("Oscillator-type systems are not supported.") + + # verify that if terms in h_str have the divider ||, then the channels are in the valid format + for term in hamiltonian_dict["h_str"]: + malformed_text = f"""Term '{term}' does not conform to required string format. + Channels may only be specified in the format + 'aa||Cxx', where 'aa' specifies an operator, + C is a valid channel character, + and 'xx' is a string of digits.""" + + # if two vertical bars used together, check if channels in correct format + if term.count("|") == 2 and term.count("||") == 1: + # get the string reserved for channel + channel_str = term[term.index("||") + 2 :] + + # if channel string is empty + if len(channel_str) == 0: + raise QiskitError(malformed_text) + + # if first entry in channel string isn't a valid channel character + if channel_str[0] not in CHANNEL_CHARS: + raise QiskitError(malformed_text) + + # Verify either that: all remaining characters are digits, or, + # if term starts with _SUM[ and ends with ], all remaining characters + # are either digits, or starts and ends with {} + if term[-1] == "]" and len(term) > 5 and term[:5] == "_SUM[": + # drop the closing ] + channel_str = channel_str[:-1] + + # if channel string doesn't contain anything other than channel character + if len(channel_str) == 1: + raise QiskitError(malformed_text) + + # if starts with opening bracket, verify that it ends with closing bracket + if channel_str[1] == "{": + if not channel_str[-1] == "}": + raise QiskitError(malformed_text) + # otherwise verify that the remainder of terms only contains digits + elif any(not c.isdigit() for c in channel_str[1:]): + raise QiskitError(malformed_text) + else: + # if channel string doesn't contain anything other than channel character + if len(channel_str) == 1: + raise QiskitError(malformed_text) + + if any(not c.isdigit() for c in channel_str[1:]): + raise QiskitError(malformed_text) + + # if bars present but not in correct format, raise error + elif term.count("|") != 0: + raise QiskitError(malformed_text) diff --git a/qiskit_dynamics/backend/backend_string_parser/operator_from_string.py b/qiskit_dynamics/backend/backend_string_parser/operator_from_string.py new file mode 100644 index 000000000..ff96c2008 --- /dev/null +++ b/qiskit_dynamics/backend/backend_string_parser/operator_from_string.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +# pylint: disable=invalid-name + +"""Generate operators from string. + +This file is meant for internal use and may be changed at any point. +""" + +from typing import Dict +import numpy as np + +from qiskit import QiskitError +import qiskit.quantum_info as qi + + +def _operator_from_string( + op_label: str, subsystem_label: int, subsystem_dims: Dict[int, int] +) -> np.ndarray: + r"""Generates a dense operator acting on a single subsystem, tensoring + identities for remaining subsystems. + + The single system operator is specified via a string in ``op_label``, + the list of subsystems and their corresponding dimensions are specified in the + dictionary ``subsystem_dims``, with system label being the keys specified as ``int``s, + and system dimensions the values also specified as ``int``s, and ``subsystem_label`` + indicates which subsystem the operator specified by ``op_label`` acts on. + + Accepted ``op_labels`` are: + - `'X'`: If the target subsystem is two dimensional, the + Pauli :math:`X` operator, and if greater than two dimensional, returns + :math:`a + a^\dagger`, where :math:`a` and :math:`a^\dagger` are the + annihiliation and creation operators, respectively. + - `'Y'`: If the target subsystem is two dimensional, the + Pauli :math:`Y` operator, and if greater than two dimensional, returns + :math:`-i(a - a^\dagger)`, where :math:`a` and :math:`a^\dagger` are the + annihiliation and creation operators, respectively. + - `'Z'`: If the target subsystem is two dimensional, the + Pauli :math:`Z` operator, and if greater than two dimensional, returns + :math:`I - 2 * N`, where :math:`N` is the number operator. + - `'a'`, `'A'`, or `'Sm'`: If two dimensional, the sigma minus operator, and if greater, + generalizes to the operator. + - `'C'`, or `'Sp'`: If two dimensional, sigma plus operator, and if greater, + generalizes to the creation operator. + - `'N'`, or `'O'`: The number operator. + - `'I'`: The identity operator. + + Note that the ordering of tensor factors is reversed. + + Args: + op_label: The string labelling the single system operator. + subsystem_label: Index of the subsystem to apply the operator. + subsystem_dims: Dictionary of subsystem labels and dimensions. + + Returns: + np.ndarray corresponding to the specified operator. + + Raises: + QiskitError: If op_label is invalid. + """ + + # construct single system operator + op_func = __operdict.get(op_label, None) + if op_func is None: + raise QiskitError(f"String {op_label} does not correspond to a known operator.") + + dim = subsystem_dims[subsystem_label] + out = qi.Operator(op_func(dim), input_dims=[dim], output_dims=[dim]) + + # sort subsystem labels and dimensions according to subsystem label + sorted_subsystem_keys, sorted_subsystem_dims = zip( + *sorted(zip(subsystem_dims.keys(), subsystem_dims.values())) + ) + + # get subsystem location in ordered list + subsystem_location = sorted_subsystem_keys.index(subsystem_label) + + # construct full operator + return qi.ScalarOp(sorted_subsystem_dims).compose(out, [subsystem_location]).data + + +# functions for generating individual operators +def a(dim: int) -> np.ndarray: + """Annihilation operator.""" + return np.diag(np.sqrt(np.arange(1, dim, dtype=complex)), 1) + + +def adag(dim: int) -> np.ndarray: + """Creation operator.""" + return a(dim).conj().transpose() + + +def N(dim: int) -> np.ndarray: + """Number operator.""" + return np.diag(np.arange(dim, dtype=complex)) + + +def X(dim: int) -> np.ndarray: + """Generalized X operator, written in terms of raising and lowering operators.""" + return a(dim) + adag(dim) + + +def Y(dim: int) -> np.ndarray: + """Generalized Y operator, written in terms of raising and lowering operators.""" + return -1j * (a(dim) - adag(dim)) + + +def Z(dim: int) -> np.ndarray: + """Generalized Z operator, written as id - 2 * N.""" + return ident(dim) - 2 * N(dim) + + +def ident(dim: int) -> np.ndarray: + """Identity operator.""" + return np.eye(dim, dtype=complex) + + +# operator names +__operdict = { + "X": X, + "Y": Y, + "Z": Z, + "a": a, + "A": a, + "Sm": a, + "Sp": adag, + "C": adag, + "N": N, + "O": N, + "I": ident, +} diff --git a/qiskit_dynamics/backend/backend_string_parser/regex_parser.py b/qiskit_dynamics/backend/backend_string_parser/regex_parser.py new file mode 100644 index 000000000..b6b643ff8 --- /dev/null +++ b/qiskit_dynamics/backend/backend_string_parser/regex_parser.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2018, 2019, 2020, 2021, 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +# pylint: disable=invalid-name + +"""Legacy code for parsing operator strings. + +This file is meant for internal use and may be changed at any point. +""" + +import re +import copy +from typing import List, Dict, Tuple +from collections import namedtuple, OrderedDict + +import numpy as np + +from .operator_from_string import _operator_from_string + + +def _regex_parser( + operator_str: List[str], subsystem_dims: Dict[int, int], subsystem_list: List[int] +) -> List[Tuple[np.array, str]]: + """Function wrapper for regex parsing object. + + Args: + operator_str: List of strings in accepted format as described in + string_model_parser.parse_hamiltonian_dict. + subsystem_dims: Dictionary mapping subsystem labels to dimensions. + subsystem_list: List of subsystems on which the operators are to be constructed. + Returns: + List of tuples containing pairs operators and their string coefficients. + """ + + return _HamiltonianParser(h_str=operator_str, subsystem_dims=subsystem_dims).parse( + subsystem_list + ) + + +class _HamiltonianParser: + """Legacy object for parsing string specifications of Hamiltonians.""" + + Token = namedtuple("Token", ("type", "name")) + + str_elements = OrderedDict( + QubOpr=re.compile(r"(?PO|Sp|Sm|X|Y|Z|I)(?P[0-9]+)"), + PrjOpr=re.compile(r"P(?P[0-9]+),(?P[0-9]+),(?P[0-9]+)"), + CavOpr=re.compile(r"(?PA|C|N)(?P[0-9]+)"), + Func=re.compile(r"(?P[a-z]+)\("), + Ext=re.compile(r"\.(?Pdag)"), + Var=re.compile(r"[a-z]+[0-9]*"), + Num=re.compile(r"[0-9.]+"), + MathOrd0=re.compile(r"[*/]"), + MathOrd1=re.compile(r"[+-]"), + BrkL=re.compile(r"\("), + BrkR=re.compile(r"\)"), + ) + + def __init__(self, h_str, subsystem_dims): + """Create new quantum operator generator + + Parameters: + h_str (list): list of Hamiltonian string + subsystem_dims (dict): dimension of subsystems + """ + self.h_str = h_str + self.subsystem_dims = {int(label): int(dim) for label, dim in subsystem_dims.items()} + self.str2qopr = {} + + def parse(self, qubit_list=None): + """Parse and generate Hamiltonian terms.""" + td_hams = [] + tc_hams = [] + + # expand sum + self._expand_sum() + + # convert to reverse Polish notation + for ham in self.h_str: + if len(re.findall(r"\|\|", ham)) > 1: + raise Exception(f"Multiple time-dependent terms in {ham}") + p_td = re.search(r"(?P[\S]+)\|\|(?P[\S]+)", ham) + + # find time-dependent term + if p_td: + coef, token = self._tokenizer(p_td.group("opr"), qubit_list) + if token is None: + continue + # combine coefficient to time-dependent term + if coef: + td = "*".join([coef, p_td.group("ch")]) + else: + td = p_td.group("ch") + token = self._shunting_yard(token) + _td = self._token2qobj(token), td + + td_hams.append(_td) + else: + coef, token = self._tokenizer(ham, qubit_list) + if token is None: + continue + token = self._shunting_yard(token) + + if (coef == "") or (coef is None): + coef = "1." + + _tc = self._token2qobj(token), coef + + tc_hams.append(_tc) + + return tc_hams + td_hams + + def _expand_sum(self): + """Takes a string-based Hamiltonian list and expands the _SUM action items out.""" + sum_str = re.compile(r"_SUM\[(?P[a-z]),(?P[a-z\d{}+-]+),(?P[a-z\d{}+-]+),") + brk_str = re.compile(r"]") + + ham_list = copy.copy(self.h_str) + ham_out = [] + + while any(ham_list): + ham = ham_list.pop(0) + p_sums = list(sum_str.finditer(ham)) + p_brks = list(brk_str.finditer(ham)) + if len(p_sums) != len(p_brks): + raise Exception(f"Missing correct number of brackets in {ham}") + + # find correct sum-bracket correspondence + if any(p_sums) == 0: + ham_out.append(ham) + else: + itr = p_sums[0].group("itr") + _l = int(p_sums[0].group("l")) + _u = int(p_sums[0].group("u")) + for ii in range(len(p_sums) - 1): + if p_sums[ii + 1].end() > p_brks[ii].start(): + break + else: + ii = len(p_sums) - 1 + + # substitute iterator value + _temp = [] + for kk in range(_l, _u + 1): + trg_s = ham[p_sums[0].end() : p_brks[ii].start()] + # generate replacement pattern + pattern = {} + for p in re.finditer(r"\{(?P[a-z0-9*/+-]+)\}", trg_s): + if p.group() not in pattern: + sub = parse_binop(p.group("op_str"), operands={itr: str(kk)}) + if sub.isdecimal(): + pattern[p.group()] = sub + else: + pattern[p.group()] = f"{{{sub}}}" + for key, val in pattern.items(): + trg_s = trg_s.replace(key, val) + _temp.append( + "".join([ham[: p_sums[0].start()], trg_s, ham[p_brks[ii].end() :]]) + ) + ham_list.extend(_temp) + + self.h_str = ham_out + + return ham_out + + def _tokenizer(self, op_str, qubit_list=None): + """Convert string to token and coefficient + Check if the index is in qubit_list + """ + + # generate token + _op_str = copy.copy(op_str) + token_list = [] + prev = "none" + while any(_op_str): + for key, parser in _HamiltonianParser.str_elements.items(): + p = parser.match(_op_str) + if p: + # find quantum operators + if key in ["QubOpr", "CavOpr"]: + _key = key + _name = p.group() + if p.group() not in self.str2qopr: + idx = int(p.group("idx")) + if qubit_list is not None and idx not in qubit_list: + return 0, None + name = p.group("opr") + opr = _operator_from_string(name, idx, self.subsystem_dims) + self.str2qopr[p.group()] = opr + elif key == "PrjOpr": + _key = key + _name = p.group() + if p.group() not in self.str2qopr: + idx = int(p.group("idx")) + name = "P" + opr = _operator_from_string(name, idx, self.subsystem_dims) + self.str2qopr[p.group()] = opr + elif key in ["Func", "Ext"]: + _name = p.group("name") + _key = key + elif key == "MathOrd1": + _name = p.group() + if prev not in ["QubOpr", "PrjOpr", "CavOpr", "Var", "Num"]: + _key = "MathUnitary" + else: + _key = key + else: + _name = p.group() + _key = key + token_list.append(_HamiltonianParser.Token(_key, _name)) + _op_str = _op_str[p.end() :] + prev = _key + break + else: + raise Exception(f"Invalid input string {op_str} is found") + + # split coefficient + coef = "" + if any(k.type == "Var" for k in token_list): + for ii, _ in enumerate(token_list): + if token_list[ii].name == "*": + if all(k.type != "Var" for k in token_list[ii + 1 :]): + coef = "".join([k.name for k in token_list[:ii]]) + token_list = token_list[ii + 1 :] + break + else: + raise Exception(f"Invalid order of operators and coefficients in {op_str}") + + return coef, token_list + + def _shunting_yard(self, token_list): + """Reformat token to reverse Polish notation""" + stack = [] + queue = [] + while any(token_list): + token = token_list.pop(0) + if token.type in ["QubOpr", "PrjOpr", "CavOpr", "Num"]: + queue.append(token) + elif token.type in ["Func", "Ext"]: + stack.append(token) + elif token.type in ["MathUnitary", "MathOrd0", "MathOrd1"]: + while stack and math_priority(token, stack[-1]): + queue.append(stack.pop(-1)) + stack.append(token) + elif token.type in ["BrkL"]: + stack.append(token) + elif token.type in ["BrkR"]: + while stack[-1].type not in ["BrkL", "Func"]: + queue.append(stack.pop(-1)) + if not any(stack): + raise Exception("Missing correct number of brackets") + pop = stack.pop(-1) + if pop.type == "Func": + queue.append(pop) + else: + raise Exception(f"Invalid token {token.name} is found") + + while any(stack): + queue.append(stack.pop(-1)) + + return queue + + def _token2qobj(self, tokens): + """Generate Hamiltonian term from tokens.""" + stack = [] + for token in tokens: + if token.type in ["QubOpr", "PrjOpr", "CavOpr"]: + stack.append(self.str2qopr[token.name]) + elif token.type == "Num": + stack.append(float(token.name)) + elif token.type in ["MathUnitary"]: + if token.name == "-": + stack.append(-stack.pop(-1)) + elif token.type in ["MathOrd0", "MathOrd1"]: + op2 = stack.pop(-1) + op1 = stack.pop(-1) + if token.name == "+": + stack.append(op1 + op2) + elif token.name == "-": + stack.append(op1 - op2) + elif token.name == "*": + stack.append(op1 @ op2) + elif token.name == "/": + stack.append(op1 / op2) + elif token.type in ["Func", "Ext"]: + if token.name == "dag": + stack.append(np.conjugate(np.transpose(stack.pop(-1)))) + else: + raise Exception(f"Invalid token {token.name} of type Func, Ext.") + else: + raise Exception(f"Invalid token {token.name} is found.") + + if len(stack) > 1: + raise Exception("Invalid mathematical operation in string.") + + return stack[0] + + +def math_priority(o1, o2): + """Check priority of given math operation""" + rank = {"MathUnitary": 2, "MathOrd0": 1, "MathOrd1": 0} + diff_ops = rank.get(o1.type, -1) - rank.get(o2.type, -1) + + if diff_ops > 0: + return False + else: + return True + + +# pylint: disable=dangerous-default-value +def parse_binop(op_str, operands={}, cast_str=True): + """Calculate binary operation in string format""" + oprs = OrderedDict( + sum=r"(?P[a-zA-Z0-9]+)\+(?P[a-zA-Z0-9]+)", + sub=r"(?P[a-zA-Z0-9]+)\-(?P[a-zA-Z0-9]+)", + mul=r"(?P[a-zA-Z0-9]+)\*(?P[a-zA-Z0-9]+)", + div=r"(?P[a-zA-Z0-9]+)\/(?P[a-zA-Z0-9]+)", + non=r"(?P[a-zA-Z0-9]+)", + ) + + for key, regr in oprs.items(): + p = re.match(regr, op_str) + if p: + val0 = operands.get(p.group("v0"), p.group("v0")) + if key == "non": + # substitution + retv = val0 + else: + val1 = operands.get(p.group("v1"), p.group("v1")) + # binary operation + if key == "sum": + if val0.isdecimal() and val1.isdecimal(): + retv = int(val0) + int(val1) + else: + retv = "+".join([str(val0), str(val1)]) + elif key == "sub": + if val0.isdecimal() and val1.isdecimal(): + retv = int(val0) - int(val1) + else: + retv = "-".join([str(val0), str(val1)]) + elif key == "mul": + if val0.isdecimal() and val1.isdecimal(): + retv = int(val0) * int(val1) + else: + retv = "*".join([str(val0), str(val1)]) + elif key == "div": + if val0.isdecimal() and val1.isdecimal(): + retv = int(val0) / int(val1) + else: + retv = "/".join([str(val0), str(val1)]) + else: + retv = 0 + break + else: + raise Exception(f"Invalid string {op_str}") + + if cast_str: + return str(retv) + else: + return retv diff --git a/qiskit_dynamics/backend/backend_utils.py b/qiskit_dynamics/backend/backend_utils.py index 1dad54729..1c6f1a39b 100644 --- a/qiskit_dynamics/backend/backend_utils.py +++ b/qiskit_dynamics/backend/backend_utils.py @@ -114,14 +114,14 @@ def _get_memory_slot_probabilities( Args: probability_dict: A list of probabilities for the outcomes of state measurement. Keys - are assumed to all be strings of integers of the same length. + are assumed to all be strings of integers of the same length. memory_slot_indices: Indices of which memory slots store the digits of the keys of - probability_dict. + probability_dict. num_memory_slots: Total number of memory slots for results. If None, - defaults to the maximum index in memory_slot_indices. The default value - of unused memory slots is 0. + defaults to the maximum index in memory_slot_indices. The default value + of unused memory slots is 0. max_outcome_value: Maximum value that can be stored in a memory slot. All outcomes higher - than this will be rounded down. + than this will be rounded down. Returns: Dict: Keys are memory slot outcomes, values are the probabilities of those outcomes. @@ -152,7 +152,7 @@ def _sample_probability_dict( Args: probability_dict: Dictionary representing probability distribution, with keys being - outcomes, values being probabilities. + outcomes, values being probabilities. shots: Number of shots. seed: Seed to use in rng construction. @@ -175,9 +175,9 @@ def _get_subsystem_probabilities(probability_tensor: np.ndarray, sub_idx: int) - Args: probability_tensor: K-dimensional probability array, where the probability of outcome - ``(idx1, ..., idxk)`` is ``probability_tensor[idx1, ..., idxk]``. + ``(idx1, ..., idxk)`` is ``probability_tensor[idx1, ..., idxk]``. sub_idx: Subsystem index to return marginalized probabilities. - ``sub_idx`` is indexed in reverse order to be consistent with qiskit. + ``sub_idx`` is indexed in reverse order to be consistent with qiskit. Returns: The marginalized probability for the specified subsystem. @@ -208,15 +208,13 @@ def _get_iq_data( """Generates IQ data for each physical level. Args: - state: Quantum state. - measurement_subsystems: Labels of subsystems in the system being measured. - memory_slot_indices: Indices of which memory slots store the data of subsystems. + state: Quantum state. measurement_subsystems: Labels of subsystems in the system being + measured. memory_slot_indices: Indices of which memory slots store the data of subsystems. num_memory_slots: Total number of memory slots for results. If None, - defaults to the maximum index in memory_slot_indices. + defaults to the maximum index in memory_slot_indices. iq_centers: centers for IQ distribution. provided in the format - ``iq_centers[subsystem][level] = [I,Q]``. - iq_width: Standard deviation of IQ distribution around the centers. - shots: Number of Shots + ``iq_centers[subsystem][level] = [I,Q]``. + iq_width: Standard deviation of IQ distribution around the centers. shots: Number of Shots seed: Seed for sample generation. Returns: diff --git a/qiskit_dynamics/backend/dynamics_backend.py b/qiskit_dynamics/backend/dynamics_backend.py index ab8b59264..8dd62e052 100644 --- a/qiskit_dynamics/backend/dynamics_backend.py +++ b/qiskit_dynamics/backend/dynamics_backend.py @@ -33,7 +33,9 @@ from qiskit.pulse import Schedule, ScheduleBlock from qiskit.pulse.transforms.canonicalization import block_to_schedule from qiskit.providers.options import Options -from qiskit.providers.backend import BackendV2 +from qiskit.providers.backend import BackendV1, BackendV2 +from qiskit.providers.models.pulsedefaults import PulseDefaults +from qiskit.providers.models.backendconfiguration import PulseBackendConfiguration from qiskit.result import Result from qiskit.result.models import ExperimentResult, ExperimentResultData @@ -41,6 +43,8 @@ from qiskit import schedule as build_schedule from qiskit.quantum_info import Statevector, DensityMatrix +from qiskit_dynamics import RotatingFrame +from qiskit_dynamics.array import Array from qiskit_dynamics.solvers.solver_classes import Solver from .dynamics_job import DynamicsJob @@ -52,6 +56,7 @@ _get_counts_from_samples, _get_iq_data, ) +from .backend_string_parser import parse_backend_hamiltonian_dict class DynamicsBackend(BackendV2): @@ -63,42 +68,49 @@ class DynamicsBackend(BackendV2): * ``solver``: The Qiskit Dynamics :class:`.Solver` instance used for simulation. * ``solver_options``: Dictionary containing optional kwargs for passing to :meth:`Solver.solve`, indicating solver methods and options. Defaults to the empty dictionary ``{}``. - * ``subsystem_dims``: Dimensions of subsystems making up the system in ``solver``. Defaults - to ``[solver.model.dim]``. - * ``subsystem_labels``: Integer labels for subsystems. Defaults to - ``[0, ..., len(subsystem_dims) - 1]``. + * ``subsystem_dims``: Dimensions of subsystems making up the system in ``solver``. Defaults to + ``[solver.model.dim]``. + * ``subsystem_labels``: Integer labels for subsystems. Defaults to ``[0, ..., + len(subsystem_dims) - 1]``. * ``meas_map``: Measurement map. Defaults to ``[[idx] for idx in subsystem_labels]``. + * ``control_channel_map``: A dictionary mapping control channel labels to indices, to be used + for control channel index lookup in the :meth:`DynamicsBackend.control_channel` method. * ``initial_state``: Initial state for simulation, either the string ``"ground_state"``, indicating that the ground state for the system Hamiltonian should be used, or an arbitrary ``Statevector`` or ``DensityMatrix``. Defaults to ``"ground_state"``. - * ``normalize_states``: Boolean indicating whether to normalize states before computing - outcome probabilities. Defaults to ``True``. Setting to ``False`` can result in errors if - the solution tolerance results in probabilities with significant numerical deviation from - proper probability distributions. - * ``meas_level``: Form of measurement output. Supported values are ``1`` and ``2``. - ``1`` returns IQ points and ``2`` returns counts. Defaults to ``meas_level==2``. + * ``normalize_states``: Boolean indicating whether to normalize states before computing outcome + probabilities. Defaults to ``True``. Setting to ``False`` can result in errors if the solution + tolerance results in probabilities with significant numerical deviation from proper + probability distributions. + * ``meas_level``: Form of measurement output. Supported values are ``1`` and ``2``. ``1`` + returns IQ points and ``2`` returns counts. Defaults to ``meas_level==2``. * ``meas_return``: Level of measurement data to return. For ``meas_level=1`` ``"single"`` - returns output from every shot. ``"avg"`` returns average over shots of measurement - output. Defaults to ``"avg"``. + returns output from every shot. ``"avg"`` returns average over shots of measurement output. + Defaults to ``"avg"``. * ``iq_centers``: Centers for IQ distribution when using ``meas_level==1`` results. Must have - type List[List[List[float, float]]] formatted as ``iq_centers[subsystem][level] = [I,Q]``. - If ``None``, the ``iq_centers`` are dynamically generated to be equally spaced points on a - unit circle with ground-state at (1,0). The default is ``None``. + type List[List[List[float, float]]] formatted as ``iq_centers[subsystem][level] = [I,Q]``. If + ``None``, the ``iq_centers`` are dynamically generated to be equally spaced points on a unit + circle with ground-state at (1,0). The default is ``None``. * ``iq_width``: Standard deviation of IQ distribution around the centers for ``meas_level==1``. Must be a positive float. Defaults to ``0.2``. - * ``max_outcome_level``: For ``meas_level==2``, the maximum outcome for each subsystem. - Values will be rounded down to be no larger than ``max_outcome_level``. Must be a positive - integer or ``None``. If ``None``, no rounding occurs. Defaults to ``1``. + * ``max_outcome_level``: For ``meas_level==2``, the maximum outcome for each subsystem. Values + will be rounded down to be no larger than ``max_outcome_level``. Must be a positive integer or + ``None``. If ``None``, no rounding occurs. Defaults to ``1``. * ``memory``: Boolean indicating whether to return a list of explicit measurement outcomes for every experimental shot. Defaults to ``True``. * ``seed_simulator``: Seed to use in random sampling. Defaults to ``None``. - * ``experiment_result_function``: Function for computing the ``ExperimentResult`` - for each simulated experiment. This option defaults to - :func:`default_experiment_result_function`, and any other function set to this option - must have the same signature. Note that the default utilizes various other options that - control results computation, and hence changing it will impact the meaning of other options. - * ``control_channel_map``: A dictionary mapping control channel labels to indices, to be used - for control channel index lookup in the :meth:`DynamicsBackend.control_channel` method. + * ``experiment_result_function``: Function for computing the ``ExperimentResult`` for each + simulated experiment. This option defaults to :func:`default_experiment_result_function`, and + any other function set to this option must have the same signature. Note that the default + utilizes various other options that control results computation, and hence changing it will + impact the meaning of other options. + * ``configuration``: A :class:`PulseBackendConfiguration` instance or ``None``. This option + defaults to ``None``, and is not required for the functioning of this class, but is provided + for compatibility. A set configuration will be returned by + :meth:`DynamicsBackend.configuration()`. + * ``defaults``: A :class:`PulseDefaults` instance or ``None``. This option defaults to ``None``, + and is not required for the functioning of this class, but is provided for compatibility. A + set defaults will be returned by :meth:`DynamicsBackend.defaults()`. """ def __init__( @@ -176,6 +188,7 @@ def _default_options(self): subsystem_dims=None, subsystem_labels=None, meas_map=None, + control_channel_map=None, normalize_states=True, initial_state="ground_state", meas_level=MeasLevel.CLASSIFIED, @@ -186,7 +199,8 @@ def _default_options(self): memory=True, seed_simulator=None, experiment_result_function=default_experiment_result_function, - control_channel_map=None, + configuration=None, + defaults=None, ) def set_options(self, **fields): @@ -199,11 +213,12 @@ def set_options(self, **fields): if not hasattr(self._options, key): raise AttributeError(f"Invalid option {key}") + # validation checks if key == "initial_state": if value != "ground_state" and not isinstance(value, (Statevector, DensityMatrix)): raise QiskitError( - """initial_state must be either "ground_state", - or a Statevector or DensityMatrix instance.""" + 'initial_state must be either "ground_state", or a Statevector or ' + "DensityMatrix instance." ) elif key == "meas_level" and value not in [1, 2]: raise QiskitError("Only meas_level 1 and 2 are supported by DynamicsBackend.") @@ -214,6 +229,12 @@ def set_options(self, **fields): raise QiskitError("max_outcome_level must be a positive integer or None.") elif key == "experiment_result_function" and not callable(value): raise QiskitError("experiment_result_function must be callable.") + elif key == "configuration" and not isinstance(value, PulseBackendConfiguration): + raise QiskitError( + "configuration option must be an instance of PulseBackendConfiguration." + ) + elif key == "defaults" and not isinstance(value, PulseDefaults): + raise QiskitError("defaults option must be an instance of PulseDefaults.") elif key == "iq_width" and (not isinstance(value, float) or (value <= 0)): raise QiskitError("iq_width must be a positive float.") elif key == "iq_centers": @@ -223,8 +244,8 @@ def set_options(self, **fields): for level in sub_system ): raise QiskitError( - """The iq_centers option must be either None or of type - List[List[List[int, int]]], where the innermost list is the (I, Q) pair.""" + "The iq_centers option must be either None or of type " + "List[List[List[int, int]]], where the innermost list is the (I, Q) pair." ) validate_iq_centers = True elif key == "subsystem_dims": @@ -241,6 +262,7 @@ def set_options(self, **fields): if not all(isinstance(x, int) for x in value.values()): raise QiskitError("The control_channel_map values must be of type int.") + # special setting routines if key == "solver": self._set_solver(value) else: @@ -292,7 +314,7 @@ def run( Args: run_input: A list of simulations, specified by ``QuantumCircuit``, ``Schedule``, or - ``ScheduleBlock`` instances. + ``ScheduleBlock`` instances. validate: Whether or not to run validation checks on the input. **options: Additional run options to temporarily override current backend options. @@ -471,6 +493,189 @@ def control_channel( return control_channels + def configuration(self) -> PulseBackendConfiguration: + """Get the backend configuration.""" + return self.options.configuration + + def defaults(self) -> PulseDefaults: + """Get the backend defaults.""" + return self.options.defaults + + @classmethod + def from_backend( + cls, + backend: Union[BackendV1, BackendV2], + subsystem_list: Optional[List[int]] = None, + rotating_frame: Optional[Union[Array, RotatingFrame, str]] = "auto", + evaluation_mode: str = "dense", + rwa_cutoff_freq: Optional[float] = None, + **options, + ) -> "DynamicsBackend": + """Construct a DynamicsBackend instance from an existing Backend instance. + + .. warning:: + + Due to inevitable model inaccuracies, gates calibrated on a real backend will not have + the same performance on the :class:`.DynamicsBackend` instance returned by this method. + As such, gates and calibrations are not be copied into the constructed + :class:`.DynamicsBackend`. + + The ``backend`` must contain sufficient information in the ``target``, ``configuration``, + and/or ``defaults`` attributes to be able to run simulations. The following table indicates + which parameters are required, along with their primary and secondary sources: + + .. list-table:: Backend parameter locations + :widths: 10 25 25 + :header-rows: 1 + + * - Parameter + - Primary source + - Secondary source + * - ``hamiltonian`` dictionary. + - ``configuration.hamiltonian`` + - N/A + * - Control channel frequency specification. + - ``configuration.u_channel_lo`` + - N/A + * - Number of qubits in the backend model. + - ``target.num_qubits`` + - ``configuration.n_qubits`` + * - Pulse schedule sample size ``dt``. + - ``target.dt`` + - ``configuration.dt`` + * - Drive channel frequencies. + - ``target.qubit_properties`` + - ``defaults.qubit_freq_est`` + * - Measurement channel frequencies, if measurement channels explicitly appear in the + model. + - ``defaults.meas_freq_est`` + - N/A + + .. note:: + + The ``target``, ``configuration``, and ``defaults`` attributes of the original backend + are not copied into the constructed :class:`DynamicsBackend` instance, only the required + data stored within these attributes will be extracted. If necessary, these attributes + can be set and configured by the user. + + The optional argument ``subsystem_list`` specifies which subset of qubits to model in the + constructed :class:`DynamicsBackend`. All other qubits are dropped from the model. + + Configuration of the underlying :class:`.Solver` is controlled via the ``rotating_frame``, + ``evaluation_mode``, and ``rwa_cutoff_freq`` options. In contrast to :class:`.Solver` + initialization, ``rotating_frame`` defaults to the string ``"auto"``, which allows this + method to choose the rotating frame based on ``evaluation_mode``: + + * If a dense evaluation mode is chosen, the rotating frame will be set to the + ``static_hamiltonian`` indicated by the Hamiltonian in ``backend.configuration()``. + * If a sparse evaluation mode is chosen, the rotating frame will be set to the diagonal of + ``static_hamiltonian``. + + Otherwise the ``rotating_frame``, ``evaluation_mode``, and ``rwa_cutoff_freq`` are passed + directly to the :class:`.Solver` initialization. + + Args: + backend: The ``Backend`` instance to build the :class:`.DynamicsBackend` from. + subsystem_list: The list of qubits in the backend to include in the model. + rotating_frame: Rotating frame argument for the internal :class:`.Solver`. Defaults to + ``"auto"``, allowing this method to pick a rotating frame. + evaluation_mode: Evaluation mode argument for the internal :class:`.Solver`. + rwa_cutoff_freq: Rotating wave approximation argument for the internal :class:`.Solver`. + **options: Additional options to be applied in construction of the + :class:`.DynamicsBackend`. + + Returns: + DynamicsBackend + + Raises: + QiskitError: If any required parameters are missing from the passed backend. + """ + + # get available target, config, and defaults objects + backend_target = getattr(backend, "target", None) + + if not hasattr(backend, "configuration"): + raise QiskitError( + "DynamicsBackend.from_backend requires that the backend argument has a " + "configuration method." + ) + backend_config = backend.configuration() + + backend_defaults = None + if hasattr(backend, "defaults"): + backend_defaults = backend.defaults() + + # get and parse Hamiltonian string dictionary + if backend_target is not None: + backend_num_qubits = backend_target.num_qubits + else: + backend_num_qubits = backend_config.n_qubits + + if subsystem_list is not None: + subsystem_list = sorted(subsystem_list) + if subsystem_list[-1] >= backend_num_qubits: + raise QiskitError( + f"subsystem_list contained {subsystem_list[-1]}, which is out of bounds for " + f"backend with {backend_num_qubits} qubits." + ) + else: + subsystem_list = list(range(backend_num_qubits)) + + if backend_config.hamiltonian is None: + raise QiskitError( + "DynamicsBackend.from_backend requires that backend.configuration() has a " + "hamiltonian." + ) + + ( + static_hamiltonian, + hamiltonian_operators, + hamiltonian_channels, + subsystem_dims, + ) = parse_backend_hamiltonian_dict(backend_config.hamiltonian, subsystem_list) + subsystem_dims = [subsystem_dims[idx] for idx in subsystem_list] + + # construct model frequencies dictionary from backend + channel_freqs = _get_backend_channel_freqs( + backend_target=backend_target, + backend_config=backend_config, + backend_defaults=backend_defaults, + channels=hamiltonian_channels, + ) + + # build the solver + if rotating_frame == "auto": + if "dense" in evaluation_mode: + rotating_frame = static_hamiltonian + else: + rotating_frame = np.diag(static_hamiltonian) + + # get time step size + if backend_target is not None and backend_target.dt is not None: + dt = backend_target.dt + else: + # config is guaranteed to have a dt + dt = backend_config.dt + + solver = Solver( + static_hamiltonian=static_hamiltonian, + hamiltonian_operators=hamiltonian_operators, + hamiltonian_channels=hamiltonian_channels, + channel_carrier_freqs=channel_freqs, + dt=dt, + rotating_frame=rotating_frame, + evaluation_mode=evaluation_mode, + rwa_cutoff_freq=rwa_cutoff_freq, + ) + + return cls( + solver=solver, + target=Target(dt=dt), + subsystem_labels=subsystem_list, + subsystem_dims=subsystem_dims, + **options, + ) + def default_experiment_result_function( experiment_name: str, @@ -651,8 +856,8 @@ def _get_acquire_instruction_timings( # validate if len(schedule_acquire_times) == 0: raise QiskitError( - """At least one measurement saving a a result in a MemorySlot - must be present in each schedule.""" + "At least one measurement saving a a result in a MemorySlot " + "must be present in each schedule." ) for acquire_time in schedule_acquire_times[1:]: @@ -668,8 +873,8 @@ def _get_acquire_instruction_timings( measurement_subsystems.append(inst.channel.index) else: raise QiskitError( - f"""Attempted to measure subsystem {inst.channel.index}, - but it is not in subsystem_list.""" + f"Attempted to measure subsystem {inst.channel.index}, but it is not in " + "subsystem_list." ) memory_slot_indices.append(inst.mem_slot.index) @@ -703,3 +908,94 @@ def _to_schedule_list( else: raise QiskitError(f"Type {type(sched)} cannot be converted to Schedule.") return schedules, num_memslots + + +def _get_backend_channel_freqs( + backend_target: Optional[Target], + backend_config: PulseBackendConfiguration, + backend_defaults: Optional[PulseDefaults], + channels: List[str], +) -> Dict[str, float]: + """Extract frequencies of channels from a backend configuration and defaults. + + Args: + backend_target: A backend target object or ``None``. + backend_config: A backend configuration object. + backend_defaults: A backend defaults object or ``None``. + channels: Channel labels given as strings, assumed to be unique. + + Returns: + Dict: Mapping of channel labels to frequencies. + + Raises: + QiskitError: If the frequency for one of the channels cannot be found. + """ + + # partition types of channels + drive_channels = [] + meas_channels = [] + u_channels = [] + + for channel in channels: + if channel[0] == "d": + drive_channels.append(channel) + elif channel[0] == "m": + meas_channels.append(channel) + elif channel[0] == "u": + u_channels.append(channel) + else: + raise QiskitError("Unrecognized channel type requested.") + + # extract and validate channel frequency parameters + if drive_channels: + # get drive channel frequencies + drive_frequencies = [] + if (backend_target is not None) and (backend_target.qubit_properties is not None): + drive_frequencies = [q.frequency for q in backend_target.qubit_properties] + elif backend_defaults is not None: + drive_frequencies = backend_defaults.qubit_freq_est + else: + raise QiskitError( + "DriveChannels in model but frequencies not available in target or defaults." + ) + + if meas_channels: + if backend_defaults is not None: + meas_frequencies = backend_defaults.meas_freq_est + else: + raise QiskitError("MeasureChannels in model but defaults does not have meas_freq_est.") + + # backend_config.u_channel_lo is guaranteed to be a list + u_channel_lo = backend_config.u_channel_lo + + # populate frequencies + channel_freqs = {} + + for channel in drive_channels: + idx = int(channel[1:]) + if idx >= len(drive_frequencies): + raise QiskitError(f"DriveChannel index {idx} is out of bounds.") + channel_freqs[channel] = drive_frequencies[idx] + + for channel in meas_channels: + idx = int(channel[1:]) + if idx >= len(meas_frequencies): + raise QiskitError(f"MeasureChannel index {idx} is out of bounds.") + channel_freqs[channel] = meas_frequencies[idx] + + for channel in u_channels: + idx = int(channel[1:]) + if idx >= len(u_channel_lo): + raise QiskitError(f"ControlChannel index {idx} is out of bounds.") + freq = 0.0 + for channel_lo in u_channel_lo[idx]: + freq += drive_frequencies[channel_lo.q] * channel_lo.scale + + channel_freqs[channel] = freq + + # validate that all channels have frequencies + for channel in channels: + if channel not in channel_freqs: + raise QiskitError(f"No carrier frequency found for channel {channel}.") + + return channel_freqs diff --git a/test/dynamics/backend/backend_string_parser/__init__.py b/test/dynamics/backend/backend_string_parser/__init__.py new file mode 100644 index 000000000..90d8c684e --- /dev/null +++ b/test/dynamics/backend/backend_string_parser/__init__.py @@ -0,0 +1,15 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Backend string parser tests. +""" diff --git a/test/dynamics/backend/backend_string_parser/test_hamiltonian_string_parser.py b/test/dynamics/backend/backend_string_parser/test_hamiltonian_string_parser.py new file mode 100644 index 000000000..5c8943987 --- /dev/null +++ b/test/dynamics/backend/backend_string_parser/test_hamiltonian_string_parser.py @@ -0,0 +1,436 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +# pylint: disable=invalid-name + +""" +Tests for pulse.string_model_parser for importing qiskit.pulse model string representation. +""" + +import numpy as np + +from qiskit import QiskitError +from qiskit.quantum_info.operators import Operator + +from qiskit_dynamics.backend.backend_string_parser.hamiltonian_string_parser import ( + parse_backend_hamiltonian_dict, +) + +from qiskit_dynamics.type_utils import to_array + +from ...common import QiskitDynamicsTestCase + + +class TestHamiltonianParseExceptions(QiskitDynamicsTestCase): + """Tests for preparse formatting exceptions.""" + + def test_no_h_str(self): + """Test no h_str empty raises error.""" + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({}) + self.assertTrue("requires a non-empty 'h_str'" in str(qe.exception)) + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": []}) + self.assertTrue("requires a non-empty 'h_str'" in str(qe.exception)) + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": [""]}) + self.assertTrue("requires a non-empty 'h_str'" in str(qe.exception)) + + def test_empty_qub(self): + """Test qub dict empty raises error.""" + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": ["a * X0|||D0"]}) + self.assertTrue("requires non-empty 'qub'" in str(qe.exception)) + + def test_too_many_vertical_bars(self): + """Test that too many vertical bars raises error.""" + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": ["a * X0|||D0"], "qub": {0: 2}}) + self.assertTrue("does not conform" in str(qe.exception)) + + def test_single_vertical_bar(self): + """Test that only a single vertical bar raises error.""" + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": ["a * X0|D0"], "qub": {0: 2}}) + self.assertTrue("does not conform" in str(qe.exception)) + + def test_multiple_channel_error(self): + """Test multiple channels raises error.""" + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": ["a * X0||D0*D1"], "qub": {0: 2}}) + self.assertTrue("does not conform" in str(qe.exception)) + + def test_divider_no_channel(self): + """Test that divider with no channel raises error.""" + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": ["a * X0||"], "qub": {0: 2}}) + self.assertTrue("does not conform" in str(qe.exception)) + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": ["a * X0|"], "qub": {0: 2}}) + self.assertTrue("does not conform" in str(qe.exception)) + + def test_non_digit_after_channel(self): + """Test that everything after the D or U is an int.""" + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": ["a * X0||Da"], "qub": {0: 2}}) + self.assertTrue("does not conform" in str(qe.exception)) + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": ["a * X0||D1a"], "qub": {0: 2}}) + self.assertTrue("does not conform" in str(qe.exception)) + + def test_nothing_after_channel(self): + """Test that everything after the D or U is an int.""" + + with self.assertRaises(QiskitError) as qe: + parse_backend_hamiltonian_dict({"h_str": ["a * X0||U"], "qub": {0: 2}}) + self.assertTrue("does not conform" in str(qe.exception)) + + +class TestParseHamiltonianDict(QiskitDynamicsTestCase): + """Tests for parse_backend_hamiltonian_dict.""" + + def setUp(self): + """Build operators.""" + self.X = Operator.from_label("X").data + self.Y = Operator.from_label("Y").data + self.Z = Operator.from_label("Z").data + + dim = 4 + self.a = np.array(np.diag(np.sqrt(range(1, dim)), k=1), dtype=complex) + self.adag = self.a.conj().transpose() + self.N = np.array(np.diag(range(dim)), dtype=complex) + + def test_only_static_terms(self): + """Test a basic system.""" + ham_dict = {"h_str": ["v*np.pi*Z0"], "qub": {"0": 2}, "vars": {"v": 2.1}} + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict(ham_dict) + self.assertAllClose(static_ham, 2.1 * np.pi * self.Z) + self.assertTrue(not ham_ops) + self.assertTrue(not channels) + self.assertTrue(subsystem_dims == {0: 2}) + + def test_simple_single_q_system(self): + """Test a basic system.""" + ham_dict = { + "h_str": ["v*np.pi*Z0", "0.02*np.pi*X0||D0"], + "qub": {"0": 2}, + "vars": {"v": 2.1}, + } + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict(ham_dict) + + self.assertAllClose(static_ham, 2.1 * np.pi * self.Z) + self.assertAllClose(to_array(ham_ops), [0.02 * np.pi * self.X]) + self.assertTrue(channels == ["d0"]) + self.assertTrue(subsystem_dims == {0: 2}) + + def test_simple_single_q_system_repeat_entries(self): + """Test merging of terms with same channel or no channel.""" + ham_dict = { + "h_str": ["v*np.pi*Z0", "0.02*np.pi*X0||D0", "v*np.pi*Z0", "0.02*np.pi*X0||D0"], + "qub": {"0": 2}, + "vars": {"v": 2.1}, + } + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict(ham_dict) + + self.assertAllClose(static_ham, 2 * 2.1 * np.pi * self.Z) + self.assertAllClose(to_array(ham_ops), [2 * 0.02 * np.pi * self.X]) + self.assertTrue(channels == ["d0"]) + self.assertTrue(subsystem_dims == {0: 2}) + + def test_simple_single_q_system_repeat_entries_different_case(self): + """Test merging of terms with same channel or no channel, + with upper and lower case. + """ + ham_dict = { + "h_str": ["v*np.pi*Z0", "0.02*np.pi*X0||D0", "v*np.pi*Z0", "0.02*np.pi*X0||d0"], + "qub": {"0": 2}, + "vars": {"v": 2.1}, + } + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict(ham_dict) + + self.assertAllClose(static_ham, 2 * 2.1 * np.pi * self.Z) + self.assertAllClose(to_array(ham_ops), [2 * 0.02 * np.pi * self.X]) + self.assertTrue(channels == ["d0"]) + self.assertTrue(subsystem_dims == {0: 2}) + + def test_simple_two_q_system(self): + """Test a two qubit system.""" + + ham_dict = { + "h_str": [ + "v0*np.pi*Z0", + "v1*np.pi*Z1", + "j*np.pi*X0*Y1", + "0.03*np.pi*X1||D1", + "0.02*np.pi*X0||D0", + ], + "qub": {"0": 2, "1": 2}, + "vars": {"v0": 2.1, "v1": 2.0, "j": 0.02}, + } + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict(ham_dict) + + ident = np.eye(2) + self.assertAllClose( + static_ham, + 2.1 * np.pi * np.kron(ident, self.Z) + + 2.0 * np.pi * np.kron(self.Z, ident) + + 0.02 * np.pi * np.kron(self.Y, self.X), + ) + self.assertAllClose( + to_array(ham_ops), + [0.02 * np.pi * np.kron(ident, self.X), 0.03 * np.pi * np.kron(self.X, ident)], + ) + self.assertTrue(channels == ["d0", "d1"]) + self.assertTrue(subsystem_dims == {0: 2, 1: 2}) + + def test_simple_two_q_system_measurement_channel(self): + """Test a two qubit system with a measurement-labelled channel.""" + + ham_dict = { + "h_str": [ + "v0*np.pi*Z0", + "v1*np.pi*Z1", + "j*np.pi*X0*Y1", + "0.03*np.pi*X1||M1", + "0.02*np.pi*X0||D0", + ], + "qub": {"0": 2, "1": 2}, + "vars": {"v0": 2.1, "v1": 2.0, "j": 0.02}, + } + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict(ham_dict) + + ident = np.eye(2) + self.assertAllClose( + static_ham, + 2.1 * np.pi * np.kron(ident, self.Z) + + 2.0 * np.pi * np.kron(self.Z, ident) + + 0.02 * np.pi * np.kron(self.Y, self.X), + ) + self.assertAllClose( + to_array(ham_ops), + [0.02 * np.pi * np.kron(ident, self.X), 0.03 * np.pi * np.kron(self.X, ident)], + ) + self.assertTrue(channels == ["d0", "m1"]) + self.assertTrue(subsystem_dims == {0: 2, 1: 2}) + + def test_single_oscillator_system(self): + """Test single oscillator system.""" + + ham_dict = { + "h_str": ["v*np.pi*O0", "alpha*np.pi*O0*O0", "r*np.pi*X0||D0"], + "qub": {"0": 4}, + "vars": {"v": 2.1, "alpha": -0.33, "r": 0.02}, + } + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict(ham_dict) + + self.assertAllClose(static_ham, 2.1 * np.pi * self.N - 0.33 * np.pi * self.N * self.N) + self.assertAllClose(to_array(ham_ops), [0.02 * np.pi * (self.a + self.adag)]) + self.assertTrue(channels == ["d0"]) + self.assertTrue(subsystem_dims == {0: 4}) + + def test_two_oscillator_system(self): + """Test a two qubit system.""" + + ham_dict = { + "h_str": [ + "v0*np.pi*O0", + "alpha0*np.pi*O0*O0", + "v1*np.pi*O1", + "alpha1*np.pi*O1*O1", + "j*np.pi*X0*Y1", + "0.03*np.pi*X1||D1", + "0.02*np.pi*X0||D0", + ], + "qub": {"0": 4, "1": 4}, + "vars": {"v0": 2.1, "v1": 2.0, "alpha0": -0.33, "alpha1": -0.33, "j": 0.02}, + } + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict(ham_dict) + + ident = np.eye(4) + + self.assertAllClose( + static_ham, + 2.1 * np.pi * np.kron(ident, self.N) + - 0.33 * np.pi * np.kron(ident, self.N * self.N) + + 2.0 * np.pi * np.kron(self.N, ident) + - 0.33 * np.pi * np.kron(self.N * self.N, ident) + + 0.02 * np.pi * np.kron(-1j * (self.a - self.adag), self.a + self.adag), + ) + self.assertAllClose( + to_array(ham_ops), + [ + 0.02 * np.pi * np.kron(ident, self.a + self.adag), + 0.03 * np.pi * np.kron(self.a + self.adag, ident), + ], + ) + self.assertTrue(channels == ["d0", "d1"]) + self.assertTrue(subsystem_dims == {0: 4, 1: 4}) + + def test_single_q_high_dim(self): + """Test single q system but higher dim.""" + ham_dict = { + "h_str": ["v*np.pi*Z0", "0.02*np.pi*X0||D0"], + "qub": {"0": 4}, + "vars": {"v": 2.1}, + } + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict(ham_dict) + + self.assertAllClose(static_ham, 2.1 * np.pi * (np.eye(4) - 2 * self.N)) + self.assertAllClose(to_array(ham_ops), [0.02 * np.pi * (self.a + self.adag)]) + self.assertTrue(channels == ["d0"]) + self.assertTrue(subsystem_dims == {0: 4}) + + def test_dagger(self): + """Test correct parsing of dagger.""" + ham_dict = { + "h_str": ["v*np.pi*dag(A0)"], + "qub": {"0": 4}, + "vars": {"v": 2.1}, + } + + static_ham, _, _, _ = parse_backend_hamiltonian_dict(ham_dict) + self.assertAllClose(static_ham, 2.1 * np.pi * self.adag) + + def test_5q_hamiltonian_reduced(self): + """Test case for complicated Hamiltonian with reductions.""" + ham_dict = { + "h_str": [ + "_SUM[i,0,4,wq{i}/2*(I{i}-Z{i})]", + "_SUM[i,0,4,delta{i}/2*O{i}*O{i}]", + "_SUM[i,0,4,-delta{i}/2*O{i}]", + "_SUM[i,0,4,omegad{i}*X{i}||D{i}]", + "jq1q2*Sp1*Sm2", + "jq1q2*Sm1*Sp2", + "jq3q4*Sp3*Sm4", + "jq3q4*Sm3*Sp4", + "jq0q1*Sp0*Sm1", + "jq0q1*Sm0*Sp1", + "jq2q3*Sp2*Sm3", + "jq2q3*Sm2*Sp3", + "omegad1*X0||U0", + "omegad0*X1||U1", + "omegad2*X1||U2", + "omegad1*X2||U3", + "omegad3*X2||U4", + "omegad4*X3||U6", + "omegad2*X3||U5", + "omegad3*X4||U7", + ], + "qub": {"0": 4, "1": 4, "2": 4, "3": 4, "4": 4}, + "vars": { + "delta0": -2.111793476400394, + "delta1": -2.0894421352015744, + "delta2": -2.1179183671068604, + "delta3": -2.0410045431261215, + "delta4": -2.1119885565086776, + "jq0q1": 0.010495754104003914, + "jq1q2": 0.010781715511200012, + "jq2q3": 0.008920779377814226, + "jq3q4": 0.008985191651087791, + "omegad0": 0.9715458990879812, + "omegad1": 0.9803812537440838, + "omegad2": 0.9494756077681784, + "omegad3": 0.9763998543087951, + "omegad4": 0.9829308019780478, + "wq0": 32.517894442809514, + "wq1": 33.0948996120196, + "wq2": 31.74518096417169, + "wq3": 30.51062025552735, + "wq4": 32.16082685025662, + }, + } + + ident = np.eye(4, dtype=complex) + X = self.a + self.adag + X0 = np.kron(ident, X) + X1 = np.kron(X, ident) + N0 = np.kron(ident, self.N) + N1 = np.kron(self.N, ident) + + # test case for subsystems [0, 1] + + w0 = ham_dict["vars"]["wq0"] + w1 = ham_dict["vars"]["wq1"] + delta0 = ham_dict["vars"]["delta0"] + delta1 = ham_dict["vars"]["delta1"] + j01 = ham_dict["vars"]["jq0q1"] + omegad0 = ham_dict["vars"]["omegad0"] + omegad1 = ham_dict["vars"]["omegad1"] + omegad2 = ham_dict["vars"]["omegad2"] + static_ham_expected = ( + w0 * N0 + + 0.5 * delta0 * (N0 @ N0 - N0) + + w1 * N1 + + 0.5 * delta1 * (N1 @ N1 - N1) + + j01 * (np.kron(self.a, self.adag) + np.kron(self.adag, self.a)) + ) + ham_ops_expected = np.array( + [omegad0 * X0, omegad1 * X1, omegad1 * X0, omegad0 * X1, omegad2 * X1] + ) + channels_expected = ["d0", "d1", "u0", "u1", "u2"] + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict( + ham_dict, subsystem_list=[0, 1] + ) + self.assertAllClose(static_ham, static_ham_expected) + self.assertAllClose(ham_ops, ham_ops_expected) + self.assertTrue(channels == channels_expected) + self.assertTrue(subsystem_dims == {0: 4, 1: 4}) + + # test case for subsystems [3, 4] + + w3 = ham_dict["vars"]["wq3"] + w4 = ham_dict["vars"]["wq4"] + delta3 = ham_dict["vars"]["delta3"] + delta4 = ham_dict["vars"]["delta4"] + j34 = ham_dict["vars"]["jq3q4"] + omegad3 = ham_dict["vars"]["omegad3"] + omegad4 = ham_dict["vars"]["omegad4"] + omegad2 = ham_dict["vars"]["omegad2"] + static_ham_expected = ( + w3 * N0 + + 0.5 * delta3 * (N0 @ N0 - N0) + + w4 * N1 + + 0.5 * delta4 * (N1 @ N1 - N1) + + j34 * (np.kron(self.a, self.adag) + np.kron(self.adag, self.a)) + ) + ham_ops_expected = np.array( + [omegad3 * X0, omegad4 * X1, omegad2 * X0, omegad4 * X0, omegad3 * X1] + ) + channels_expected = ["d3", "d4", "u5", "u6", "u7"] + + static_ham, ham_ops, channels, subsystem_dims = parse_backend_hamiltonian_dict( + ham_dict, subsystem_list=[3, 4] + ) + self.assertAllClose(static_ham, static_ham_expected) + self.assertAllClose(ham_ops, ham_ops_expected) + self.assertTrue(channels == channels_expected) + self.assertTrue(subsystem_dims == {3: 4, 4: 4}) diff --git a/test/dynamics/backend/backend_string_parser/test_operator_from_string.py b/test/dynamics/backend/backend_string_parser/test_operator_from_string.py new file mode 100644 index 000000000..253134470 --- /dev/null +++ b/test/dynamics/backend/backend_string_parser/test_operator_from_string.py @@ -0,0 +1,103 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +# pylint: disable=invalid-name + +""" +Tests for internal _operator_from_string function. +""" + +import numpy as np + +from qiskit_dynamics.backend.backend_string_parser.operator_from_string import _operator_from_string +from ...common import QiskitDynamicsTestCase + + +class TestOperatorFromString(QiskitDynamicsTestCase): + """Test cases for operator_from_string.""" + + def test_correct_single_ops_dim2(self): + """Test that single operators give correct outputs.""" + + ident = np.eye(2) + a = np.array([[0.0, 1.0], [0.0, 0.0]]) + adag = a.conj().transpose() + N = np.diag(np.arange(2)) + X = a + adag + Y = -1j * (a - adag) + Z = ident - 2 * N + + self.assertAllClose(_operator_from_string("X", 0, {0: 2}), X) + self.assertAllClose(_operator_from_string("Y", 0, {0: 2}), Y) + self.assertAllClose(_operator_from_string("Z", 0, {0: 2}), Z) + self.assertAllClose(_operator_from_string("a", 0, {0: 2}), a) + self.assertAllClose(_operator_from_string("A", 0, {0: 2}), a) + self.assertAllClose(_operator_from_string("Sm", 0, {0: 2}), a) + self.assertAllClose(_operator_from_string("C", 0, {0: 2}), adag) + self.assertAllClose(_operator_from_string("Sp", 0, {0: 2}), adag) + self.assertAllClose(_operator_from_string("N", 0, {0: 2}), N) + self.assertAllClose(_operator_from_string("O", 0, {0: 2}), N) + self.assertAllClose(_operator_from_string("I", 0, {0: 2}), np.eye(2)) + + def test_correct_single_ops_dim4(self): + """Test that single operators give correct outputs.""" + + sq2 = np.sqrt(2) + sq3 = np.sqrt(3) + ident = np.eye(4) + a = np.array( + [[0.0, 1.0, 0.0, 0.0], [0.0, 0.0, sq2, 0.0], [0.0, 0.0, 0.0, sq3], [0.0, 0.0, 0.0, 0.0]] + ) + adag = a.conj().transpose() + N = np.diag(np.arange(4)) + X = a + adag + Y = -1j * (a - adag) + Z = ident - 2 * N + + self.assertAllClose(_operator_from_string("X", 0, {0: 4}), X) + self.assertAllClose(_operator_from_string("Y", 0, {0: 4}), Y) + self.assertAllClose(_operator_from_string("Z", 0, {0: 4}), Z) + self.assertAllClose(_operator_from_string("a", 0, {0: 4}), a) + self.assertAllClose(_operator_from_string("A", 0, {0: 4}), a) + self.assertAllClose(_operator_from_string("Sm", 0, {0: 4}), a) + self.assertAllClose(_operator_from_string("C", 0, {0: 4}), adag) + self.assertAllClose(_operator_from_string("Sp", 0, {0: 4}), adag) + self.assertAllClose(_operator_from_string("N", 0, {0: 4}), N) + self.assertAllClose(_operator_from_string("O", 0, {0: 4}), N) + self.assertAllClose(_operator_from_string("I", 0, {0: 4}), ident) + + def test_ident_before(self): + """Test adding identity before the subsystem in question.""" + + out = _operator_from_string("X", 2, {0: 4, 1: 2, 2: 2}) + expected = np.kron(np.array([[0.0, 1.0], [1.0, 0.0]]), np.eye(8)) + self.assertAllClose(out, expected) + + def test_ident_after(self): + """Test adding identity after the subsystem in question.""" + + out = _operator_from_string("Z", 0, {0: 2, 1: 2, 2: 4}) + expected = np.kron(np.eye(8), np.array([[1.0, 0.0], [0.0, -1.0]])) + self.assertAllClose(out, expected) + + def test_ident_after_unordered(self): + """Test adding identity after the subsystem in question.""" + + out = _operator_from_string("Z", 0, {2: 4, 0: 2, 1: 2}) + expected = np.kron(np.eye(8), np.array([[1.0, 0.0], [0.0, -1.0]])) + self.assertAllClose(out, expected) + + def test_ident_before_and_after(self): + """Test adding identity before and after the subsystem in question.""" + + out = _operator_from_string("a", 1, {0: 2, 1: 2, 2: 4}) + expected = np.kron(np.kron(np.eye(4), np.array([[0.0, 1.0], [0.0, 0.0]])), np.eye(2)) + self.assertAllClose(out, expected) diff --git a/test/dynamics/backend/test_dynamics_backend.py b/test/dynamics/backend/test_dynamics_backend.py index 62519260c..fcedb414e 100644 --- a/test/dynamics/backend/test_dynamics_backend.py +++ b/test/dynamics/backend/test_dynamics_backend.py @@ -15,18 +15,27 @@ Test DynamicsBackend. """ +from types import SimpleNamespace + import numpy as np from scipy.integrate._ivp.ivp import OdeResult +from scipy.sparse import csr_matrix from qiskit import QiskitError, pulse, QuantumCircuit from qiskit.circuit.library import XGate from qiskit.transpiler import Target, InstructionProperties from qiskit.quantum_info import Statevector, DensityMatrix from qiskit.result.models import ExperimentResult, ExperimentResultData +from qiskit.providers.models.backendconfiguration import UchannelLO +from qiskit.providers.backend import QubitProperties from qiskit_dynamics import Solver, DynamicsBackend +from qiskit_dynamics.array import Array from qiskit_dynamics.backend import default_experiment_result_function -from qiskit_dynamics.backend.dynamics_backend import _get_acquire_instruction_timings +from qiskit_dynamics.backend.dynamics_backend import ( + _get_acquire_instruction_timings, + _get_backend_channel_freqs, +) from ..common import QiskitDynamicsTestCase @@ -167,6 +176,18 @@ def test_invalid_experiment_result_function(self): with self.assertRaisesRegex(QiskitError, "must be callable."): self.simple_backend.set_options(experiment_result_function=1) + def test_invalid_configuration_type(self): + """Test setting non-PulseBackendConfiguration.""" + + with self.assertRaisesRegex(QiskitError, "configuration option must be"): + self.simple_backend.set_options(configuration=1) + + def test_invalid_defaults_type(self): + """Test setting non-PulseDefaults.""" + + with self.assertRaisesRegex(QiskitError, "defaults option must be"): + self.simple_backend.set_options(defaults=1) + def test_not_implemented_control_channel_map(self): """Test raising of NotImplementError if control_channel called when no control_channel_map specified. @@ -529,6 +550,284 @@ def test_metadata_transfer(self): self.assertDictEqual(res.results[1].header.metadata, {"key1": "value1"}) +class TestDynamicsBackend_from_backend(QiskitDynamicsTestCase): + """Test class for DynamicsBackend.from_backend and resulting DynamicsBackend instances.""" + + def setUp(self): + """Set up a simple backend valid for consumption by from_backend.""" + + configuration = SimpleNamespace() + configuration.n_qubits = 5 + configuration.hamiltonian = { + "h_str": [ + "_SUM[i,0,4,wq{i}/2*(I{i}-Z{i})]", + "_SUM[i,0,4,delta{i}/2*O{i}*O{i}]", + "_SUM[i,0,4,-delta{i}/2*O{i}]", + "_SUM[i,0,4,omegad{i}*X{i}||D{i}]", + "jq1q2*Sp1*Sm2", + "jq1q2*Sm1*Sp2", + "jq3q4*Sp3*Sm4", + "jq3q4*Sm3*Sp4", + "jq0q1*Sp0*Sm1", + "jq0q1*Sm0*Sp1", + "jq2q3*Sp2*Sm3", + "jq2q3*Sm2*Sp3", + "omegad1*X0||U0", + "omegad0*X1||U1", + "omegad2*X1||U2", + "omegad1*X2||U3", + "omegad3*X2||U4", + "omegad4*X3||U6", + "omegad2*X3||U5", + "omegad3*X4||U7", + ], + "osc": {}, + "qub": {"0": 3, "1": 3, "2": 3, "3": 3, "4": 3}, + "vars": { + "delta0": -2111793476.4003937, + "delta1": -2089442135.2015743, + "delta2": -2117918367.1068604, + "delta3": -2041004543.1261215, + "delta4": -2111988556.5086775, + "jq0q1": 10495754.104003914, + "jq1q2": 10781715.511200013, + "jq2q3": 8920779.377814226, + "jq3q4": 8985191.65108779, + "omegad0": 971545899.0879812, + "omegad1": 980381253.7440838, + "omegad2": 949475607.7681785, + "omegad3": 976399854.3087951, + "omegad4": 982930801.9780478, + "wq0": 32517894442.809513, + "wq1": 33094899612.019604, + "wq2": 31745180964.17169, + "wq3": 30510620255.52735, + "wq4": 32160826850.25662, + }, + } + configuration.dt = 2e-9 / 9 + configuration.u_channel_lo = [ + [UchannelLO(1, (1 + 0j))], + [UchannelLO(0, (1 + 0j))], + [UchannelLO(2, (1 + 0j))], + [UchannelLO(1, (1 + 0j))], + [UchannelLO(3, (1 + 0j))], + [UchannelLO(2, (1 + 0j))], + [UchannelLO(4, (1 + 0j))], + [UchannelLO(3, (1 + 0j))], + ] + + defaults = SimpleNamespace() + defaults.qubit_freq_est = [ + 5175383639.513607, + 5267216864.382969, + 5052402469.794663, + 4855916030.466884, + 5118554567.140891, + ] + + # configuration and defaults need to be methods + backend = SimpleNamespace() + backend.configuration = lambda: configuration + backend.defaults = lambda: defaults + + self.valid_backend = backend + + def test_no_configuration_error(self): + """Test that error is raised if no configuration present in backend.""" + + # delete configuration + delattr(self.valid_backend, "configuration") + + with self.assertRaisesRegex(QiskitError, "has a configuration method"): + DynamicsBackend.from_backend(backend=self.valid_backend) + + def test_subsystem_list_out_of_bounds(self): + """Test error is raised if subsystem_list contains values above config.n_qubits.""" + + with self.assertRaisesRegex(QiskitError, "out of bounds"): + DynamicsBackend.from_backend(backend=self.valid_backend, subsystem_list=[5]) + + def test_no_hamiltonian(self): + """Test error is raised if configuration does not have a hamiltonian.""" + + self.valid_backend.configuration().hamiltonian = None + + with self.assertRaisesRegex(QiskitError, "requires that"): + DynamicsBackend.from_backend(backend=self.valid_backend) + + def test_building_model(self): + """Test construction from_backend without additional options to solver.""" + + backend = DynamicsBackend.from_backend(self.valid_backend, subsystem_list=[0, 1]) + + self.assertTrue(backend.target.dt == 2e-9 / 9) + + solver = backend.options.solver + self.assertDictEqual( + solver._schedule_converter._carriers, + { + "d0": 5175383639.513607, + "d1": 5267216864.382969, + "u0": 5267216864.382969, + "u1": 5175383639.513607, + "u2": 5052402469.794663, + }, + ) + + self.assertTrue(isinstance(solver.model.static_operator, Array)) + + N0 = np.diag(np.kron([1.0, 1.0, 1.0], [0.0, 1.0, 2.0])) + N1 = np.diag(np.kron([0.0, 1.0, 2.0], [1.0, 1.0, 1.0])) + a0 = np.kron(np.eye(3), np.diag([1.0, np.sqrt(2)], 1)) + a0dag = a0.transpose() + a1 = np.kron(np.diag([1.0, np.sqrt(2)], 1), np.eye(3)) + a1dag = a1.transpose() + + frame_operator = ( + 32517894442.809513 * N0 + + (-2111793476.4003937 / 2) * (N0 * N0 - N0) + + 33094899612.019604 * N1 + + (-2089442135.2015743 / 2) * (N1 * N1 - N1) + + 10495754.104003914 * (a0 @ a1dag + a0dag @ a1) + ) + + self.assertAllClose(frame_operator, solver.model.rotating_frame.frame_operator) + self.assertAllClose(solver.model.static_operator / 1e9, np.zeros(9)) + + expected_operators = np.array( + [ + 971545899.0879812 * (a0 + a0dag), + 980381253.7440838 * (a1 + a1dag), + 980381253.7440838 * (a0 + a0dag), + 971545899.0879812 * (a1 + a1dag), + 949475607.7681785 * (a1 + a1dag), + ] + ) + self.assertAllClose(expected_operators / 1e9, solver.model.operators / 1e9) + + def test_building_model_target_override(self): + """Test that the parameters retrievable from the target are preferred.""" + + target = Target( + dt=0.1, qubit_properties=[QubitProperties(frequency=float(x)) for x in range(5)] + ) + + self.valid_backend.target = target + backend = DynamicsBackend.from_backend(self.valid_backend, subsystem_list=[0, 1]) + + self.assertTrue(backend.target.dt == 0.1) + + solver = backend.options.solver + self.assertDictEqual( + solver._schedule_converter._carriers, + {"d0": 0.0, "d1": 1.0, "u0": 1.0, "u1": 0.0, "u2": 2.0}, + ) + + def test_building_model_sparse(self): + """Test construction from_backend in sparse mode.""" + + backend = DynamicsBackend.from_backend( + self.valid_backend, subsystem_list=[0, 1], evaluation_mode="sparse" + ) + + self.assertTrue(backend.target.dt == 2e-9 / 9) + + solver = backend.options.solver + self.assertDictEqual( + solver._schedule_converter._carriers, + { + "d0": 5175383639.513607, + "d1": 5267216864.382969, + "u0": 5267216864.382969, + "u1": 5175383639.513607, + "u2": 5052402469.794663, + }, + ) + + self.assertTrue(isinstance(solver.model.static_operator, csr_matrix)) + + N0 = np.diag(np.kron([1.0, 1.0, 1.0], [0.0, 1.0, 2.0])) + N1 = np.diag(np.kron([0.0, 1.0, 2.0], [1.0, 1.0, 1.0])) + a0 = np.kron(np.eye(3), np.diag([1.0, np.sqrt(2)], 1)) + a0dag = a0.transpose() + a1 = np.kron(np.diag([1.0, np.sqrt(2)], 1), np.eye(3)) + a1dag = a1.transpose() + + frame_operator = np.diag( + 32517894442.809513 * N0 + + (-2111793476.4003937 / 2) * (N0 * N0 - N0) + + 33094899612.019604 * N1 + + (-2089442135.2015743 / 2) * (N1 * N1 - N1) + ) + static_operator = 10495754.104003914 * (a0 @ a1dag + a0dag @ a1) + + self.assertAllClose(frame_operator, solver.model.rotating_frame.frame_operator) + self.assertAllCloseSparse( + static_operator / 1e9, solver.model.static_operator.todense() / 1e9 + ) + + expected_operators = np.array( + [ + 971545899.0879812 * (a0 + a0dag), + 980381253.7440838 * (a1 + a1dag), + 980381253.7440838 * (a0 + a0dag), + 971545899.0879812 * (a1 + a1dag), + 949475607.7681785 * (a1 + a1dag), + ] + ) + self.assertAllClose( + expected_operators / 1e9, [x.todense() / 1e9 for x in solver.model.operators] + ) + + def test_building_model_case2(self): + """Test construction from_backend without additional options to solver, case 2.""" + + backend = DynamicsBackend.from_backend(self.valid_backend, subsystem_list=[0, 4]) + + self.assertTrue(backend.target.dt == 2e-9 / 9) + + solver = backend.options.solver + self.assertDictEqual( + solver._schedule_converter._carriers, + { + "d0": 5175383639.513607, + "d4": 5118554567.140891, + "u0": 5267216864.382969, + "u7": 4855916030.466884, + }, + ) + + self.assertTrue(isinstance(solver.model.static_operator, Array)) + + N0 = np.diag(np.kron([1.0, 1.0, 1.0], [0.0, 1.0, 2.0])) + N4 = np.diag(np.kron([0.0, 1.0, 2.0], [1.0, 1.0, 1.0])) + a0 = np.kron(np.eye(3), np.diag([1.0, np.sqrt(2)], 1)) + a0dag = a0.transpose() + a4 = np.kron(np.diag([1.0, np.sqrt(2)], 1), np.eye(3)) + a4dag = a4.transpose() + + frame_operator = ( + 32517894442.809513 * N0 + + (-2111793476.4003937 / 2) * (N0 * N0 - N0) + + 32160826850.25662 * N4 + + (-2111988556.5086775 / 2) * (N4 * N4 - N4) + ) + + self.assertAllClose(frame_operator, solver.model.rotating_frame.frame_operator) + self.assertAllClose(solver.model.static_operator / 1e9, np.zeros(9)) + + expected_operators = np.array( + [ + 971545899.0879812 * (a0 + a0dag), + 982930801.9780478 * (a4 + a4dag), + 980381253.7440838 * (a0 + a0dag), + 976399854.3087951 * (a4 + a4dag), + ] + ) + self.assertAllClose(expected_operators / 1e9, solver.model.operators / 1e9) + + class Test_default_experiment_result_function(QiskitDynamicsTestCase): """Test default_experiment_result_function.""" @@ -568,6 +867,144 @@ def test_simple_example(self): self.assertDictEqual(output.data.counts, {"000": 513, "010": 511}) +class Test_get_channel_backend_freqs(QiskitDynamicsTestCase): + """Test cases for _get_channel_backend_freqs.""" + + def setUp(self): + """Setup a simple configuration and default.""" + + defaults = SimpleNamespace() + defaults.qubit_freq_est = [0.343, 1.131, 2.1232, 3.3534, 4.123, 5.3532] + defaults.meas_freq_est = [0.23432, 1.543, 2.543, 3.543, 4.1321, 5.5433] + self.defaults = defaults + + config = SimpleNamespace() + config.u_channel_lo = [ + [UchannelLO(q=0, scale=1.0), UchannelLO(q=1, scale=-1.0)], + [UchannelLO(q=3, scale=2.1)], + [UchannelLO(q=4, scale=1.1), UchannelLO(q=2, scale=-1.1)], + ] + self.config = config + + def _test_with_setUp_example_no_target(self, channels, expected_output): + """Test with defaults and config from setUp.""" + self.assertDictEqual( + _get_backend_channel_freqs( + backend_target=None, + backend_config=self.config, + backend_defaults=self.defaults, + channels=channels, + ), + expected_output, + ) + + def test_drive_channels(self): + """Test case with just drive channels.""" + channels = ["d0", "d1", "d2"] + expected_output = {f"d{idx}": self.defaults.qubit_freq_est[idx] for idx in range(3)} + self._test_with_setUp_example_no_target(channels=channels, expected_output=expected_output) + + def test_drive_and_meas_channels(self): + """Test case drive and meas channels.""" + channels = ["d0", "d1", "d2", "m0", "m3"] + expected_output = {f"d{idx}": self.defaults.qubit_freq_est[idx] for idx in range(3)} + expected_output.update({f"m{idx}": self.defaults.meas_freq_est[idx] for idx in [0, 3]}) + self._test_with_setUp_example_no_target(channels=channels, expected_output=expected_output) + + def test_drive_and_u_channels(self): + """Test case drive and u channels.""" + channels = ["d0", "d1", "d2", "u1", "u2"] + expected_output = {f"d{idx}": self.defaults.qubit_freq_est[idx] for idx in range(3)} + expected_output.update( + { + "u1": 2.1 * self.defaults.qubit_freq_est[3], + "u2": 1.1 * self.defaults.qubit_freq_est[4] - 1.1 * self.defaults.qubit_freq_est[2], + } + ) + self._test_with_setUp_example_no_target(channels=channels, expected_output=expected_output) + + def test_unrecognized_channel_type(self): + """Test error is raised if unrecognized channel type.""" + + with self.assertRaisesRegex(QiskitError, "Unrecognized"): + _get_backend_channel_freqs( + backend_target=None, + backend_config=SimpleNamespace(), + backend_defaults=SimpleNamespace(), + channels=["r1"], + ) + + def test_no_qubit_freq_est_attribute_error(self): + """Test error if no qubit_freq_est in defaults.""" + + with self.assertRaisesRegex(QiskitError, "frequencies not available in target or defaults"): + _get_backend_channel_freqs( + backend_target=None, + backend_config=SimpleNamespace(), + backend_defaults=None, + channels=["d0"], + ) + + def test_no_meas_freq_est_attribute_error(self): + """Test error if no meas_freq_est in defaults.""" + + with self.assertRaisesRegex(QiskitError, "defaults does not have"): + _get_backend_channel_freqs( + backend_target=None, + backend_config=SimpleNamespace(), + backend_defaults=None, + channels=["m0"], + ) + + def test_missing_u_channel_error(self): + """Raise error if missing u channel.""" + with self.assertRaisesRegex(QiskitError, "ControlChannel index 4"): + _get_backend_channel_freqs( + backend_target=None, + backend_config=self.config, + backend_defaults=self.defaults, + channels=["u4"], + ) + + def test_drive_out_of_bounds(self): + """Raise error if drive channel index too high.""" + with self.assertRaisesRegex(QiskitError, "DriveChannel index 10"): + _get_backend_channel_freqs( + backend_target=None, + backend_config=self.config, + backend_defaults=self.defaults, + channels=["d10"], + ) + + def test_meas_out_of_bounds(self): + """Raise error if drive channel index too high.""" + with self.assertRaisesRegex(QiskitError, "MeasureChannel index 6"): + _get_backend_channel_freqs( + backend_target=None, + backend_config=self.config, + backend_defaults=self.defaults, + channels=["m6"], + ) + + def test_no_defaults(self): + """Test a case where defaults are not needed.""" + target = Target( + dt=0.1, + qubit_properties=[QubitProperties(frequency=0.0), QubitProperties(frequency=1.0)], + ) + + config = SimpleNamespace() + config.u_channel_lo = [] + + channel_freqs = _get_backend_channel_freqs( + backend_target=target, + backend_config=config, + backend_defaults=None, + channels=["d0", "d1"], + ) + self.assertDictEqual(channel_freqs, {"d0": 0.0, "d1": 1.0}) + + class Test_get_acquire_instruction_timings(QiskitDynamicsTestCase): """Tests for _get_acquire_instruction_timings behaviour not covered by DynamicsBackend tests."""