From d5ff028da6c2611f9d104eb5bb35f326aa64739a Mon Sep 17 00:00:00 2001 From: Akhil Pratap Singh <118816443+Akhils777@users.noreply.github.com> Date: Thu, 3 Jul 2025 02:54:02 +0530 Subject: [PATCH 1/3] Fix: Enable JOPT to support open-system optimization with TRACEDIFF fidelity (#49) --- src/qutip_qoc/_jopt.py | 6 ++--- src/qutip_qoc/result.py | 2 +- tests/test_jopt_open_system_bug.py | 39 ++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 tests/test_jopt_open_system_bug.py diff --git a/src/qutip_qoc/_jopt.py b/src/qutip_qoc/_jopt.py index 6274076..6c9cfe2 100644 --- a/src/qutip_qoc/_jopt.py +++ b/src/qutip_qoc/_jopt.py @@ -150,8 +150,8 @@ def _infid(self, params): if self._fid_type == "TRACEDIFF": diff = X - self._target # to prevent if/else in qobj.dag() and qobj.tr() - diff_dag = Qobj(diff.data.adjoint(), dims=diff.dims) - g = 1 / 2 * (diff_dag * diff).data.trace() + diff_dag = diff.dag() # direct access to JAX array, no fallback! + g = 1 / 2 * jnp.trace(diff_dag.data._jxa @ diff.data._jxa) infid = jnp.real(self._norm_fac * g) else: g = self._norm_fac * self._target.overlap(X) @@ -160,4 +160,4 @@ def _infid(self, params): elif self._fid_type == "SU": # f_SU (incl global phase) infid = 1 - jnp.real(g) - return infid + return infid \ No newline at end of file diff --git a/src/qutip_qoc/result.py b/src/qutip_qoc/result.py index be63877..7738aa9 100644 --- a/src/qutip_qoc/result.py +++ b/src/qutip_qoc/result.py @@ -13,7 +13,7 @@ try: import jax import jaxlib - _jitfun_type = jaxlib.xla_extension.PjitFunction + _jitfun_type = type(jax.jit(lambda x: x)) except ImportError: _jitfun_type = None diff --git a/tests/test_jopt_open_system_bug.py b/tests/test_jopt_open_system_bug.py new file mode 100644 index 0000000..a4c9d26 --- /dev/null +++ b/tests/test_jopt_open_system_bug.py @@ -0,0 +1,39 @@ +import numpy as np +import qutip as qt +from qutip_qoc import Objective, optimize_pulses + +from jax import jit, numpy + +def test_open_system_jopt_runs_without_error(): + Hd = qt.Qobj(np.diag([1, 2])) + c_ops = [np.sqrt(0.1) * qt.sigmam()] + Hc = qt.sigmax() + + Ld = qt.liouvillian(H=Hd, c_ops=c_ops) + Lc = qt.liouvillian(Hc) + + initial_state = qt.fock_dm(2, 0) + target_state = qt.fock_dm(2, 1) + + times = np.linspace(0, 2 * np.pi, 250) + + @jit + def sin_x(t, c, **kwargs): + return c[0] * numpy.sin(c[1] * t) + L = [Ld, [Lc, sin_x]] + + guess_params = [1, 0.5] + + res_jopt = optimize_pulses( + objectives = Objective(initial_state, L, target_state), + control_parameters = { + "ctrl_x": {"guess": guess_params, "bounds": [(-1, 1), (0, 2 * np.pi)]} + }, + tlist = times, + algorithm_kwargs = { + "alg": "JOPT", + "fid_err_targ": 0.001, + }, + ) + + assert res_jopt.infidelity < 0.25, f"Fidelity error too high: {res_jopt.infidelity}" \ No newline at end of file From fa422f4d0f8b1f4976f478cd529fbe77d5821af0 Mon Sep 17 00:00:00 2001 From: Akhils777 Date: Thu, 17 Jul 2025 20:01:42 +0530 Subject: [PATCH 2/3] First step-"Refactor _grape.py and _crab.py to use QOC-native structure" --- src/qutip_qoc/_crab.py | 11 +- src/qutip_qoc/_grape.py | 16 +- src/qutip_qoc/q2/__init__.py | 0 src/qutip_qoc/q2/cy_grape.py | 135 ++ src/qutip_qoc/q2/dump.py | 1008 ++++++++++++++ src/qutip_qoc/q2/dynamics.py | 1825 +++++++++++++++++++++++++ src/qutip_qoc/q2/errors.py | 92 ++ src/qutip_qoc/q2/fidcomp.py | 798 +++++++++++ src/qutip_qoc/q2/grape.py | 716 ++++++++++ src/qutip_qoc/q2/logging_utils.py | 114 ++ src/qutip_qoc/q2/optimconfig.py | 101 ++ src/qutip_qoc/q2/optimizer.py | 1365 +++++++++++++++++++ src/qutip_qoc/q2/optimresult.py | 105 ++ src/qutip_qoc/q2/propcomp.py | 430 ++++++ src/qutip_qoc/q2/pulsegen.py | 1323 ++++++++++++++++++ src/qutip_qoc/q2/pulseoptim.py | 2119 +++++++++++++++++++++++++++++ src/qutip_qoc/q2/stats.py | 457 +++++++ src/qutip_qoc/q2/termcond.py | 64 + src/qutip_qoc/q2/test_grape.py | 22 + 19 files changed, 10681 insertions(+), 20 deletions(-) create mode 100644 src/qutip_qoc/q2/__init__.py create mode 100644 src/qutip_qoc/q2/cy_grape.py create mode 100644 src/qutip_qoc/q2/dump.py create mode 100644 src/qutip_qoc/q2/dynamics.py create mode 100644 src/qutip_qoc/q2/errors.py create mode 100644 src/qutip_qoc/q2/fidcomp.py create mode 100644 src/qutip_qoc/q2/grape.py create mode 100644 src/qutip_qoc/q2/logging_utils.py create mode 100644 src/qutip_qoc/q2/optimconfig.py create mode 100644 src/qutip_qoc/q2/optimizer.py create mode 100644 src/qutip_qoc/q2/optimresult.py create mode 100644 src/qutip_qoc/q2/propcomp.py create mode 100644 src/qutip_qoc/q2/pulsegen.py create mode 100644 src/qutip_qoc/q2/pulseoptim.py create mode 100644 src/qutip_qoc/q2/stats.py create mode 100644 src/qutip_qoc/q2/termcond.py create mode 100644 src/qutip_qoc/q2/test_grape.py diff --git a/src/qutip_qoc/_crab.py b/src/qutip_qoc/_crab.py index dcde7de..b0377f1 100644 --- a/src/qutip_qoc/_crab.py +++ b/src/qutip_qoc/_crab.py @@ -5,10 +5,10 @@ with respect to the control parameters, according to the CRAB algorithm. """ -import qutip_qtrl.logging_utils as logging + import copy -logger = logging.get_logger() + class _CRAB: @@ -43,12 +43,7 @@ def infidelity(self, *args): # *** update stats *** if self._qtrl.stats is not None: self._qtrl.stats.num_fidelity_func_calls = self._qtrl.num_fid_func_calls - if self._qtrl.log_level <= logging.DEBUG: - logger.debug( - "fidelity error call {}".format( - self._qtrl.stats.num_fidelity_func_calls - ) - ) + amps = self._qtrl._get_ctrl_amps(args[0].copy()) self._qtrl.dynamics.update_ctrl_amps(amps) diff --git a/src/qutip_qoc/_grape.py b/src/qutip_qoc/_grape.py index e068159..27fd760 100644 --- a/src/qutip_qoc/_grape.py +++ b/src/qutip_qoc/_grape.py @@ -5,11 +5,11 @@ with respect to the control parameters, according to the CRAB algorithm. """ -import qutip_qtrl.logging_utils as logging + import copy -logger = logging.get_logger() + class _GRAPE: @@ -42,12 +42,7 @@ def infidelity(self, *args): # *** update stats *** if self._qtrl.stats is not None: self._qtrl.stats.num_fidelity_func_calls = self._qtrl.num_fid_func_calls - if self._qtrl.log_level <= logging.DEBUG: - logger.debug( - "fidelity error call {}".format( - self._qtrl.stats.num_fidelity_func_calls - ) - ) + amps = self._qtrl._get_ctrl_amps(args[0].copy()) self._qtrl.dynamics.update_ctrl_amps(amps) @@ -81,10 +76,7 @@ def gradient(self, *args): self._qtrl.num_grad_func_calls += 1 if self._qtrl.stats is not None: self._qtrl.stats.num_grad_func_calls = self._qtrl.num_grad_func_calls - if self._qtrl.log_level <= logging.DEBUG: - logger.debug( - "gradient call {}".format(self._qtrl.stats.num_grad_func_calls) - ) + amps = self._qtrl._get_ctrl_amps(args[0].copy()) self._qtrl.dynamics.update_ctrl_amps(amps) fid_comp = self._qtrl.dynamics.fid_computer diff --git a/src/qutip_qoc/q2/__init__.py b/src/qutip_qoc/q2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/qutip_qoc/q2/cy_grape.py b/src/qutip_qoc/q2/cy_grape.py new file mode 100644 index 0000000..4917f53 --- /dev/null +++ b/src/qutip_qoc/q2/cy_grape.py @@ -0,0 +1,135 @@ +"""Fast routines for grape implemented on top of the data layer.""" + +import qutip.core.data as _data + + +def cy_overlap(op1, op2): + """ + Return the overlap of op1 and op2. + + Parameters + ---------- + op1 : :class:`qutip.data.Data` + Data layer representation of first operator. + op2 : :class:`qutip.data.Data` + Data layer representation of second operator. + + Result + ------ + overlap : float + The value of the overlap. + """ + return _data.trace(_data.adjoint(op1) @ op2) / op1.shape[0] + + +def cy_grape_inner( + U, + u, + r, + J, + M, + U_b_list, + U_f_list, + H_ops, + dt, + eps, + alpha, + beta, + phase_sensitive, + use_u_limits, + u_min, + u_max, +): + """ + Perform one iteration of GRAPE control pulse + updates. + + Parameters + ---------- + U : :class:`qutip.data.Data` + The target unitary. + + u : np.ndarray + The generated control pulses. It's shape + is (iterations, controls, times), i.e. + (R, J, M). The result of this iteration + is stored in u[r, :, :]. + + r : int + The number of this GRAPE iteration. + + J : int + The number of controls in the Hamiltonian. + + M : int + The number of times. + + U_b_list : list of :class:`qutip.data.Data` + The backward propagators for each time. + The list has length M. + + U_f_list : list of :class:`qutip.data.Data` + The forward propagators for each time. + The list has length M. + + H_ops : list of :class:`qutip.data.Data` + The control operators from the Hamiltonian. + The list has length J. + + dt : float + The time step. + + eps : float + The distance to move along the gradient when updating + the controls. + + alpha : float + The penalty to apply to higher power control signals. + + beta : float + The penalty to apply to later control signals. + + phase_sensitive : bool + Whether the overlap is phase sensitive. + + use_u_limits : bool + Whether to apply limits to the control amplitudes. + + u_min : float + Minimum control amplitude. + + u_max : float + Maximum control amplitude. + + Result + ------ + The results are stored in u[r + 1, : , :]. + """ + for m in range(M - 1): + P = U_b_list[m] @ U + for j in range(J): + Q = 1j * dt * H_ops[j] @ U_f_list[m] + + if phase_sensitive: + du = -cy_overlap(P, Q) + else: + du = -2 * cy_overlap(P, Q) * cy_overlap(U_f_list[m], P) + + if alpha > 0.0: + # penalty term for high power control signals u + du += -2 * alpha * u[r, j, m] * dt + + if beta: + # penalty term for late control signals u + du += -2 * beta * m**2 * u[r, j, m] * dt + + u[r + 1, j, m] = u[r, j, m] + eps * du.real + + if use_u_limits: + if u[r + 1, j, m] < u_min: + u[r + 1, j, m] = u_min + elif u[r + 1, j, m] > u_max: + u[r + 1, j, m] = u_max + + for j in range(J): + u[r + 1, j, M - 1] = u[r + 1, j, M - 2] \ No newline at end of file diff --git a/src/qutip_qoc/q2/dump.py b/src/qutip_qoc/q2/dump.py new file mode 100644 index 0000000..308c00d --- /dev/null +++ b/src/qutip_qoc/q2/dump.py @@ -0,0 +1,1008 @@ +# -*- coding: utf-8 -*- + +""" +Classes that enable the storing of historical objects created during the +pulse optimisation. +These are intented for debugging. +See the optimizer and dynamics objects for instrutcions on how to enable +data dumping. +""" + +import os +import copy +import numpy as np + +# QuTiP control modules +import qutip_qtrl.io as qtrlio + +# QuTiP logging +import qutip_qtrl.logging_utils + +logger = qutip_qtrl.logging_utils.get_logger("qutip.control.dump") + +DUMP_DIR = "~/.qtrl_dump" + + +class Dump: + """ + A container for dump items. + The lists for dump items is depends on the type + Note: abstract class + + Attributes + ---------- + parent : some control object (Dynamics or Optimizer) + aka the host. Object that generates the data that is dumped and is + host to this dump object. + + dump_dir : str + directory where files (if any) will be written out + the path and be relative or absolute + use ~/ to specify user home directory + Note: files are only written when write_to_file is True + of writeout is called explicitly + Defaults to ~/.qtrl_dump + + level : string + level of data dumping: SUMMARY, FULL or CUSTOM + See property docstring for details + Set automatically if dump is created by the setting host dumping attrib + + write_to_file : bool + When set True data and summaries (as configured) will be written + interactively to file during the processing + Set during instantiation by the host based on its dump_to_file attrib + + dump_file_ext : str + Default file extension for any file names that are auto generated + + fname_base : str + First part of any auto generated file names. + This is usually overridden in the subclass + + dump_summary : bool + If True a summary is recorded each time a new item is added to the + the dump. + Default is True + + summary_sep : str + delimiter for the summary file. + default is a space + + data_sep : str + delimiter for the data files (arrays saved to file). + default is a space + + summary_file : str + File path for summary file. + Automatically generated. Can be set specifically + + """ + + def __init__(self): + self.parent = None + self.reset() + + def reset(self): + if self.parent is not None: + self.log_level = self.parent.log_level + self.write_to_file = self.parent.dump_to_file + else: + self.write_to_file = False + self._dump_dir = None + self.dump_file_ext = "txt" + self._fname_base = "dump" + self.dump_summary = True + self.summary_sep = " " + self.data_sep = " " + self._summary_file_path = None + self._summary_file_specified = False + + @property + def log_level(self): + return logger.level + + @log_level.setter + def log_level(self, lvl): + """ + Set the log_level attribute and set the level of the logger + that is call logger.setLevel(lvl) + """ + logger.setLevel(lvl) + + @property + def level(self): + """ + The level of data dumping that will occur. + + SUMMARY + A summary will be recorded + + FULL + All possible dumping + + CUSTOM + Some customised level of dumping + + When first set to CUSTOM this is equivalent to SUMMARY. It is then up + to the user to specify what specifically is dumped + """ + lvl = "CUSTOM" + if self.dump_summary and not self.dump_any: + lvl = "SUMMARY" + elif self.dump_summary and self.dump_all: + lvl = "FULL" + + return lvl + + @level.setter + def level(self, value): + self._level = value + self._apply_level() + + @property + def dump_any(self): + raise NotImplementedError( + "This is an abstract class, " + "use subclass such as DynamicsDump or OptimDump" + ) + + @property + def dump_all(self): + raise NotImplementedError( + "This is an abstract class, " + "use subclass such as DynamicsDump or OptimDump" + ) + + @property + def dump_dir(self): + if self._dump_dir is None: + self.create_dump_dir() + return self._dump_dir + + @dump_dir.setter + def dump_dir(self, value): + self._dump_dir = value + if not self.create_dump_dir(): + self._dump_dir = None + + def create_dump_dir(self): + """ + Checks dump directory exists, creates it if not + """ + if self._dump_dir is None or len(self._dump_dir) == 0: + self._dump_dir = DUMP_DIR + + dir_ok, self._dump_dir, msg = qtrlio.create_dir( + self._dump_dir, desc="dump" + ) + + if not dir_ok: + self.write_to_file = False + msg += "\ndump file output will be suppressed." + logger.error(msg) + + return dir_ok + + @property + def fname_base(self): + return self._fname_base + + @fname_base.setter + def fname_base(self, value): + if not isinstance(value, str): + raise ValueError("File name base must be a string") + self._fname_base = value + self._summary_file_path = None + + @property + def summary_file(self): + if self._summary_file_path is None: + fname = "{}-summary.{}".format( + self._fname_base, self.dump_file_ext + ) + self._summary_file_path = os.path.join(self.dump_dir, fname) + return self._summary_file_path + + @summary_file.setter + def summary_file(self, value): + if not isinstance(value, str): + raise ValueError("File path must be a string") + self._summary_file_specified = True + if os.path.abspath(value): + self._summary_file_path = value + elif "~" in value: + self._summary_file_path = os.path.expanduser(value) + else: + self._summary_file_path = os.path.join(self.dump_dir, value) + + +class OptimDump(Dump): + """ + A container for dumps of optimisation data generated during the pulse + optimisation. + + Attributes + ---------- + dump_summary : bool + When True summary items are appended to the iter_summary + + iter_summary : list of :class:`qutip.control.optimizer.OptimIterSummary` + Summary at each iteration + + dump_fid_err : bool + When True values are appended to the fid_err_log + + fid_err_log : list of float + Fidelity error at each call of the fid_err_func + + dump_grad_norm : bool + When True values are appended to the fid_err_log + + grad_norm_log : list of float + Gradient norm at each call of the grad_norm_log + + dump_grad : bool + When True values are appended to the grad_log + + grad_log : list of ndarray + Gradients at each call of the fid_grad_func + + """ + + def __init__(self, optim, level="SUMMARY"): + from qutip_qtrl.optimizer import Optimizer + + if not isinstance(optim, Optimizer): + raise TypeError("Must instantiate with {} type".format(Optimizer)) + self.parent = optim + self._level = level + self.reset() + + def reset(self): + Dump.reset(self) + self._apply_level() + self.iter_summary = [] + self.fid_err_log = [] + self.grad_norm_log = [] + self.grad_log = [] + self._fname_base = "optimdump" + self._fid_err_file = None + self._grad_norm_file = None + + def clear(self): + del self.iter_summary[:] + del self.fid_err_log[:] + del self.grad_norm_log[:] + del self.grad_log[:] + + @property + def dump_any(self): + """True if anything other than the summary is to be dumped""" + return self.dump_fid_err or self.dump_grad_norm or self.dump_grad + + @property + def dump_all(self): + """True if everything (ignoring the summary) is to be dumped""" + return self.dump_fid_err and self.dump_grad_norm and self.dump_grad + + def _apply_level(self, level=None): + if level is None: + level = self._level + if not isinstance(level, str): + raise ValueError("Dump level must be a string") + level = level.upper() + if level == "CUSTOM": + if self._level == "CUSTOM": + # dumping level has not changed keep the same specific config + pass + else: + # Switching to custom, start from SUMMARY + level = "SUMMARY" + + if level == "SUMMARY": + self.dump_summary = True + self.dump_fid_err = False + self.dump_grad_norm = False + self.dump_grad = False + elif level == "FULL": + self.dump_summary = True + self.dump_fid_err = True + self.dump_grad_norm = True + self.dump_grad = True + else: + raise ValueError("No option for dumping level '{}'".format(level)) + + def add_iter_summary(self): + """add copy of current optimizer iteration summary""" + optim = self.parent + if optim.iter_summary is None: + raise RuntimeError("Cannot add iter_summary as not available") + ois = copy.copy(optim.iter_summary) + ois.idx = len(self.iter_summary) + self.iter_summary.append(ois) + if self.write_to_file: + if ois.idx == 0: + f = open(self.summary_file, "w") + f.write( + "{}\n{}\n".format( + ois.get_header_line(self.summary_sep), + ois.get_value_line(self.summary_sep), + ) + ) + else: + f = open(self.summary_file, "a") + f.write("{}\n".format(ois.get_value_line(self.summary_sep))) + + f.close() + return ois + + @property + def fid_err_file(self): + if self._fid_err_file is None: + fname = "{}-fid_err_log.{}".format( + self.fname_base, self.dump_file_ext + ) + self._fid_err_file = os.path.join(self.dump_dir, fname) + return self._fid_err_file + + def update_fid_err_log(self, fid_err): + """add an entry to the fid_err log""" + self.fid_err_log.append(fid_err) + if self.write_to_file: + if len(self.fid_err_log) == 1: + mode = "w" + else: + mode = "a" + f = open(self.fid_err_file, mode) + f.write("{}\n".format(fid_err)) + f.close() + + @property + def grad_norm_file(self): + if self._grad_norm_file is None: + fname = "{}-grad_norm_log.{}".format( + self.fname_base, self.dump_file_ext + ) + self._grad_norm_file = os.path.join(self.dump_dir, fname) + return self._grad_norm_file + + def update_grad_norm_log(self, grad_norm): + """add an entry to the grad_norm log""" + self.grad_norm_log.append(grad_norm) + if self.write_to_file: + if len(self.grad_norm_log) == 1: + mode = "w" + else: + mode = "a" + f = open(self.grad_norm_file, mode) + f.write("{}\n".format(grad_norm)) + f.close() + + def update_grad_log(self, grad): + """add an entry to the grad log""" + self.grad_log.append(grad) + if self.write_to_file: + fname = "{}-fid_err_gradients{}.{}".format( + self.fname_base, len(self.grad_log), self.dump_file_ext + ) + fpath = os.path.join(self.dump_dir, fname) + np.savetxt(fpath, grad, delimiter=self.data_sep) + + def writeout(self, f=None): + """write all the logs and the summary out to file(s) + + Parameters + ---------- + f : filename or filehandle + If specified then all summary and object data will go in one file. + If None is specified then type specific files will be generated + in the dump_dir + If a filehandle is specified then it must be a byte mode file + as numpy.savetxt is used, and requires this. + """ + fall = None + # If specific file given then write everything to it + if hasattr(f, "write"): + if "b" not in f.mode: + raise RuntimeError("File stream must be in binary mode") + # write all to this stream + fall = f + fs = f + closefall = False + closefs = False + elif f: + # Assume f is a filename + fall = open(f, "wb") + fs = fall + closefs = False + closefall = True + else: + self.create_dump_dir() + closefall = False + if self.dump_summary: + fs = open(self.summary_file, "wb") + closefs = True + + if self.dump_summary: + for ois in self.iter_summary: + if ois.idx == 0: + fs.write( + str.encode( + "{}\n{}\n".format( + ois.get_header_line(self.summary_sep), + ois.get_value_line(self.summary_sep), + ) + ) + ) + else: + fs.write( + str.encode( + "{}\n".format(ois.get_value_line(self.summary_sep)) + ) + ) + + if closefs: + fs.close() + logger.info( + "Optim dump summary saved to {}".format(self.summary_file) + ) + + if self.dump_fid_err: + if fall: + fall.write(str.encode("Fidelity errors:\n")) + np.savetxt(fall, self.fid_err_log) + else: + np.savetxt(self.fid_err_file, self.fid_err_log) + + if self.dump_grad_norm: + if fall: + fall.write(str.encode("gradients norms:\n")) + np.savetxt(fall, self.grad_norm_log) + else: + np.savetxt(self.grad_norm_file, self.grad_norm_log) + + if self.dump_grad: + g_num = 0 + for grad in self.grad_log: + g_num += 1 + if fall: + fall.write( + str.encode("gradients (call {}):\n".format(g_num)) + ) + np.savetxt(fall, grad) + else: + fname = "{}-fid_err_gradients{}.{}".format( + self.fname_base, g_num, self.dump_file_ext + ) + fpath = os.path.join(self.dump_dir, fname) + np.savetxt(fpath, grad, delimiter=self.data_sep) + + if closefall: + fall.close() + logger.info("Optim dump saved to {}".format(f)) + else: + if fall: + logger.info("Optim dump saved to specified stream") + else: + logger.info("Optim dump saved to {}".format(self.dump_dir)) + + +class DynamicsDump(Dump): + """ + A container for dumps of dynamics data. Mainly time evolution calculations. + + Attributes + ---------- + dump_summary : bool + If True a summary is recorded + + evo_summary : list of :class:`tslotcomp.EvoCompSummary` + Summary items are appended if dump_summary is True + at each recomputation of the evolution. + + dump_amps : bool + If True control amplitudes are dumped + + dump_dyn_gen : bool + If True the dynamics generators (Hamiltonians) are dumped + + dump_prop : bool + If True propagators are dumped + + dump_prop_grad : bool + If True propagator gradients are dumped + + dump_fwd_evo : bool + If True forward evolution operators are dumped + + dump_onwd_evo : bool + If True onward evolution operators are dumped + + dump_onto_evo : bool + If True onto (or backward) evolution operators are dumped + + evo_dumps : list of :class:`EvoCompDumpItem` + A new dump item is appended at each recomputation of the evolution. + That is if any of the calculation objects are to be dumped. + + """ + + def __init__(self, dynamics, level="SUMMARY"): + from qutip_qtrl.dynamics import Dynamics + + if not isinstance(dynamics, Dynamics): + raise TypeError("Must instantiate with {} type".format(Dynamics)) + self.parent = dynamics + self._level = level + self.reset() + + def reset(self): + Dump.reset(self) + self._apply_level() + self.evo_dumps = [] + self.evo_summary = [] + self._fname_base = "dyndump" + + def clear(self): + del self.evo_dumps[:] + del self.evo_summary[:] + + @property + def dump_any(self): + """True if any of the calculation objects are to be dumped""" + return any( + [ + self.dump_amps, + self.dump_dyn_gen, + self.dump_prop, + self.dump_prop_grad, + self.dump_fwd_evo, + self.dump_onwd_evo, + self.dump_onto_evo, + ] + ) + + @property + def dump_all(self): + """True if all of the calculation objects are to be dumped""" + dyn = self.parent + return all( + [ + self.dump_amps, + self.dump_dyn_gen, + self.dump_prop, + self.dump_prop_grad, + self.dump_fwd_evo, + self.dump_onwd_evo == dyn.fid_computer.uses_onwd_evo, + self.dump_onto_evo == dyn.fid_computer.uses_onto_evo, + ] + ) + + def _apply_level(self, level=None): + dyn = self.parent + if level is None: + level = self._level + + if not isinstance(level, str): + raise ValueError("Dump level must be a string") + level = level.upper() + if level == "CUSTOM": + if self._level == "CUSTOM": + # dumping level has not changed keep the same specific config + pass + else: + # Switching to custom, start from SUMMARY + level = "SUMMARY" + + if level == "SUMMARY": + self.dump_summary = True + self.dump_amps = False + self.dump_dyn_gen = False + self.dump_prop = False + self.dump_prop_grad = False + self.dump_fwd_evo = False + self.dump_onwd_evo = False + self.dump_onto_evo = False + elif level == "FULL": + self.dump_summary = True + self.dump_amps = True + self.dump_dyn_gen = True + self.dump_prop = True + self.dump_prop_grad = True + self.dump_fwd_evo = True + self.dump_onwd_evo = dyn.fid_computer.uses_onwd_evo + self.dump_onto_evo = dyn.fid_computer.uses_onto_evo + else: + raise ValueError("No option for dumping level '{}'".format(level)) + + def add_evo_dump(self): + """Add dump of current time evolution generating objects""" + dyn = self.parent + item = EvoCompDumpItem(self) + item.idx = len(self.evo_dumps) + self.evo_dumps.append(item) + if self.dump_amps: + item.ctrl_amps = copy.deepcopy(dyn.ctrl_amps) + if self.dump_dyn_gen: + item.dyn_gen = copy.deepcopy(dyn._dyn_gen) + if self.dump_prop: + item.prop = copy.deepcopy(dyn._prop) + if self.dump_prop_grad: + item.prop_grad = copy.deepcopy(dyn._prop_grad) + if self.dump_fwd_evo: + item.fwd_evo = copy.deepcopy(dyn._fwd_evo) + if self.dump_onwd_evo: + item.onwd_evo = copy.deepcopy(dyn._onwd_evo) + if self.dump_onto_evo: + item.onto_evo = copy.deepcopy(dyn._onto_evo) + + if self.write_to_file: + item.writeout() + return item + + def add_evo_comp_summary(self, dump_item_idx=None): + """add copy of current evo comp summary""" + dyn = self.parent + if dyn.tslot_computer.evo_comp_summary is None: + raise RuntimeError("Cannot add evo_comp_summary as not available") + ecs = copy.copy(dyn.tslot_computer.evo_comp_summary) + ecs.idx = len(self.evo_summary) + ecs.evo_dump_idx = dump_item_idx + if dyn.stats: + ecs.iter_num = dyn.stats.num_iter + ecs.fid_func_call_num = dyn.stats.num_fidelity_func_calls + ecs.grad_func_call_num = dyn.stats.num_grad_func_calls + + self.evo_summary.append(ecs) + if self.write_to_file: + if ecs.idx == 0: + f = open(self.summary_file, "w") + f.write( + "{}\n{}\n".format( + ecs.get_header_line(self.summary_sep), + ecs.get_value_line(self.summary_sep), + ) + ) + else: + f = open(self.summary_file, "a") + f.write("{}\n".format(ecs.get_value_line(self.summary_sep))) + + f.close() + return ecs + + def writeout(self, f=None): + """ + Write all the dump items and the summary out to file(s). + + Parameters + ---------- + f : filename or filehandle + If specified then all summary and object data will go in one file. + If None is specified then type specific files will be generated in + the dump_dir. If a filehandle is specified then it must be a byte + mode file as numpy.savetxt is used, and requires this. + """ + fall = None + # If specific file given then write everything to it + if hasattr(f, "write"): + if "b" not in f.mode: + raise RuntimeError("File stream must be in binary mode") + # write all to this stream + fall = f + fs = f + closefall = False + closefs = False + elif f: + # Assume f is a filename + fall = open(f, "wb") + fs = fall + closefs = False + closefall = True + else: + self.create_dump_dir() + closefall = False + if self.dump_summary: + fs = open(self.summary_file, "wb") + closefs = True + + if self.dump_summary: + for ecs in self.evo_summary: + if ecs.idx == 0: + fs.write( + str.encode( + "{}\n{}\n".format( + ecs.get_header_line(self.summary_sep), + ecs.get_value_line(self.summary_sep), + ) + ) + ) + else: + fs.write( + str.encode( + "{}\n".format(ecs.get_value_line(self.summary_sep)) + ) + ) + + if closefs: + fs.close() + logger.info( + "Dynamics dump summary saved to {}".format( + self.summary_file + ) + ) + + for di in self.evo_dumps: + di.writeout(fall) + + if closefall: + fall.close() + logger.info("Dynamics dump saved to {}".format(f)) + else: + if fall: + logger.info("Dynamics dump saved to specified stream") + else: + logger.info("Dynamics dump saved to {}".format(self.dump_dir)) + + +class DumpItem: + """ + An item in a dump list + """ + + def __init__(self): + pass + + +class EvoCompDumpItem(DumpItem): + """ + A copy of all objects generated to calculate one time evolution. Note the + attributes are only set if the corresponding :class:`DynamicsDump` + ``dump_*`` attribute is set. + """ + + def __init__(self, dump): + if not isinstance(dump, DynamicsDump): + raise TypeError( + "Must instantiate with {} type".format(DynamicsDump) + ) + self.parent = dump + self.reset() + + def reset(self): + self.idx = None + self.ctrl_amps = None + self.dyn_gen = None + self.prop = None + self.prop_grad = None + self.fwd_evo = None + self.onwd_evo = None + self.onto_evo = None + + def writeout(self, f=None): + """write all the objects out to files + + Parameters + ---------- + f : filename or filehandle + If specified then all object data will go in one file. + If None is specified then type specific files will be generated + in the dump_dir + If a filehandle is specified then it must be a byte mode file + as numpy.savetxt is used, and requires this. + """ + dump = self.parent + fall = None + closefall = True + closef = False + # If specific file given then write everything to it + if hasattr(f, "write"): + if "b" not in f.mode: + raise RuntimeError("File stream must be in binary mode") + # write all to this stream + fall = f + closefall = False + f.write(str.encode("EVOLUTION COMPUTATION {}\n".format(self.idx))) + elif f: + fall = open(f, "wb") + else: + # otherwise files for each type will be created + fnbase = "{}-evo{}".format(dump._fname_base, self.idx) + closefall = False + + # ctrl amps + if self.ctrl_amps is not None: + if fall: + f = fall + f.write(str.encode("Ctrl amps\n")) + else: + fname = "{}-ctrl_amps.{}".format(fnbase, dump.dump_file_ext) + f = open(os.path.join(dump.dump_dir, fname), "wb") + closef = True + np.savetxt( + f, self.ctrl_amps, fmt="%14.6g", delimiter=dump.data_sep + ) + if closef: + f.close() + + # dynamics generators + if self.dyn_gen is not None: + k = 0 + if fall: + f = fall + f.write(str.encode("Dynamics Generators\n")) + else: + fname = "{}-dyn_gen.{}".format(fnbase, dump.dump_file_ext) + f = open(os.path.join(dump.dump_dir, fname), "wb") + closef = True + for dg in self.dyn_gen: + f.write( + str.encode( + "dynamics generator for timeslot {}\n".format(k) + ) + ) + np.savetxt(f, self.dyn_gen[k], delimiter=dump.data_sep) + k += 1 + if closef: + f.close() + + # Propagators + if self.prop is not None: + k = 0 + if fall: + f = fall + f.write(str.encode("Propagators\n")) + else: + fname = "{}-prop.{}".format(fnbase, dump.dump_file_ext) + f = open(os.path.join(dump.dump_dir, fname), "wb") + closef = True + for dg in self.dyn_gen: + f.write(str.encode("Propagator for timeslot {}\n".format(k))) + np.savetxt(f, self.prop[k], delimiter=dump.data_sep) + k += 1 + if closef: + f.close() + + # Propagator gradient + if self.prop_grad is not None: + k = 0 + if fall: + f = fall + f.write(str.encode("Propagator gradients\n")) + else: + fname = "{}-prop_grad.{}".format(fnbase, dump.dump_file_ext) + f = open(os.path.join(dump.dump_dir, fname), "wb") + closef = True + for k in range(self.prop_grad.shape[0]): + for j in range(self.prop_grad.shape[1]): + f.write( + str.encode( + "Propagator gradient for timeslot {} " + "control {}\n".format(k, j) + ) + ) + np.savetxt( + f, self.prop_grad[k, j], delimiter=dump.data_sep + ) + if closef: + f.close() + + # forward evolution + if self.fwd_evo is not None: + k = 0 + if fall: + f = fall + f.write(str.encode("Forward evolution\n")) + else: + fname = "{}-fwd_evo.{}".format(fnbase, dump.dump_file_ext) + f = open(os.path.join(dump.dump_dir, fname), "wb") + closef = True + for dg in self.dyn_gen: + f.write(str.encode("Evolution from 0 to {}\n".format(k))) + np.savetxt(f, self.fwd_evo[k], delimiter=dump.data_sep) + k += 1 + if closef: + f.close() + + # onward evolution + if self.onwd_evo is not None: + k = 0 + if fall: + f = fall + f.write(str.encode("Onward evolution\n")) + else: + fname = "{}-onwd_evo.{}".format(fnbase, dump.dump_file_ext) + f = open(os.path.join(dump.dump_dir, fname), "wb") + closef = True + for dg in self.dyn_gen: + f.write(str.encode("Evolution from {} to end\n".format(k))) + np.savetxt(f, self.fwd_evo[k], delimiter=dump.data_sep) + k += 1 + if closef: + f.close() + + # onto evolution + if self.onto_evo is not None: + k = 0 + if fall: + f = fall + f.write(str.encode("Onto evolution\n")) + else: + fname = "{}-onto_evo.{}".format(fnbase, dump.dump_file_ext) + f = open(os.path.join(dump.dump_dir, fname), "wb") + closef = True + for dg in self.dyn_gen: + f.write( + str.encode("Evolution from {} onto target\n".format(k)) + ) + np.savetxt(f, self.fwd_evo[k], delimiter=dump.data_sep) + k += 1 + if closef: + f.close() + + if closefall: + fall.close() + + +class DumpSummaryItem: + """ + A summary of the most recent iteration. Abstract class only. + + Attributes + ---------- + idx : int + Index in the summary list in which this is stored + """ + + min_col_width = 11 + summary_property_names = () + summary_property_fmt_type = () + summary_property_fmt_prec = () + + @classmethod + def get_header_line(cls, sep=" "): + if sep == " ": + line = "" + i = 0 + for a in cls.summary_property_names: + if i > 0: + line += sep + i += 1 + line += format(a, str(max(len(a), cls.min_col_width)) + "s") + else: + line = sep.join(cls.summary_property_names) + return line + + def reset(self): + self.idx = 0 + + def get_value_line(self, sep=" "): + line = "" + i = 0 + for a in zip( + self.summary_property_names, + self.summary_property_fmt_type, + self.summary_property_fmt_prec, + ): + if i > 0: + line += sep + i += 1 + v = getattr(self, a[0]) + w = max(len(a[0]), self.min_col_width) + if v is not None: + fmt = "" + if sep == " ": + fmt += str(w) + else: + fmt += "0" + if a[2] > 0: + fmt += "." + str(a[2]) + fmt += a[1] + line += format(v, fmt) + else: + if sep == " ": + line += format("None", str(w) + "s") + else: + line += "None" + + return line \ No newline at end of file diff --git a/src/qutip_qoc/q2/dynamics.py b/src/qutip_qoc/q2/dynamics.py new file mode 100644 index 0000000..8d7d74a --- /dev/null +++ b/src/qutip_qoc/q2/dynamics.py @@ -0,0 +1,1825 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Classes that define the dynamics of the (quantum) system and target evolution +to be optimised. +The contols are also defined here, i.e. the dynamics generators (Hamiltonians, +Limbladians etc). The dynamics for the time slices are calculated here, along +with the evolution as determined by the control amplitudes. + +See the subclass descriptions and choose the appropriate class for the +application. The choice depends on the type of matrix used to define +the dynamics. + +These class implement functions for getting the dynamics generators for +the combined (drift + ctrls) dynamics with the approriate operator applied + +Note the methods in these classes were inspired by: +DYNAMO - Dynamic Framework for Quantum Optimal Control +See Machnes et.al., arXiv.1011.4874 +""" +import warnings +import numpy as np +import scipy.linalg as la +import scipy.sparse as sp + +# QuTiP +from qutip import Qobj +from qutip.core import data as _data +from qutip.core.data.eigen import eigh +from qutip.settings import settings + +# QuTiP control modules +import qutip_qtrl.errors as errors +import qutip_qtrl.tslotcomp as tslotcomp +import qutip_qtrl.fidcomp as fidcomp +import qutip_qtrl.propcomp as propcomp +import qutip_qtrl.symplectic as sympl +import qutip_qtrl.dump as qtrldump + +# QuTiP logging +import qutip_qtrl.logging_utils as logging + +logger = logging.get_logger() + +DEF_NUM_TSLOTS = 10 +DEF_EVO_TIME = 1.0 + + +def _check_ctrls_container(ctrls): + """ + Check through the controls container. + Convert to an array if its a list of lists + return the processed container + raise type error if the container structure is invalid + """ + if isinstance(ctrls, (list, tuple)): + # Check to see if list of lists + try: + if isinstance(ctrls[0], (list, tuple)): + ctrls_ = np.empty((len(ctrls), len(ctrls[0])), dtype=object) + for i, ctrl in enumerate(ctrls): + ctrls_[i, :] = ctrl + ctrls = ctrls_ + except IndexError: + pass + + if isinstance(ctrls, np.ndarray): + if len(ctrls.shape) != 2: + raise TypeError("Incorrect shape for ctrl dyn gen array") + for k in range(ctrls.shape[0]): + for j in range(ctrls.shape[1]): + if not isinstance(ctrls[k, j], Qobj): + raise TypeError("All control dyn gen must be Qobj") + elif isinstance(ctrls, (list, tuple)): + for ctrl in ctrls: + if not isinstance(ctrl, Qobj): + raise TypeError("All control dyn gen must be Qobj") + else: + raise TypeError("Controls list or array not set correctly") + + return ctrls + + +def _check_drift_dyn_gen(drift): + if isinstance(drift, Qobj): + return + if not isinstance(drift, (list, tuple)): + raise TypeError("drift should be a Qobj or a list of Qobj") + for d in drift: + if not isinstance(d, Qobj): + raise TypeError("drift should be a Qobj or a list of Qobj") + + +def _attrib_deprecation(message, stacklevel=3): + """ + Issue deprecation warning + Using stacklevel=3 will ensure message refers the function + calling with the deprecated parameter, + """ + warnings.warn(message, FutureWarning, stacklevel=stacklevel) + + +def _func_deprecation(message, stacklevel=3): + """ + Issue deprecation warning + Using stacklevel=3 will ensure message refers the function + calling with the deprecated parameter, + """ + warnings.warn(message, FutureWarning, stacklevel=stacklevel) + + +class Dynamics(object): + """ + This is a base class only. See subclass descriptions and choose an + appropriate one for the application. + + Note that initialize_controls must be called before most of the methods + can be used. init_timeslots can be called sometimes earlier in order + to access timeslot related attributes + + This acts as a container for the operators that are used to calculate + time evolution of the system under study. That is the dynamics generators + (Hamiltonians, Lindbladians etc), the propagators from one timeslot to + the next, and the evolution operators. Due to the large number of matrix + additions and multiplications, for small systems at least, the optimisation + performance is much better using ndarrays to represent these operators. + However + + Attributes + ---------- + log_level : integer + level of messaging output from the logger. + Options are attributes of qutip_qtrl.logging_utils, + in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, + assuming everything runs as expected. + The default NOTSET implies that the level will be taken from + the QuTiP settings file, which by default is WARN + + params: Dictionary + The key value pairs are the attribute name and value + Note: attributes are created if they do not exist already, + and are overwritten if they do. + + stats : Stats + Attributes of which give performance stats for the optimisation + set to None to reduce overhead of calculating stats. + Note it is (usually) shared with the Optimizer object + + tslot_computer : TimeslotComputer (subclass instance) + Used to manage when the timeslot dynamics + generators, propagators, gradients etc are updated + + prop_computer : PropagatorComputer (subclass instance) + Used to compute the propagators and their gradients + + fid_computer : FidelityComputer (subclass instance) + Used to computer the fidelity error and the fidelity error + gradient. + + memory_optimization : int + Level of memory optimisation. Setting to 0 (default) means that + execution speed is prioritized over memory. + Setting to 1 means that some memory prioritisation steps will be + taken, for instance using Qobj (and hence sparse arrays) as the + the internal operator data type, and not caching some operators + Potentially further memory saving maybe made with + memory_optimization > 1. + The options are processed in _set_memory_optimizations, see + this for more information. Individual memory saving options can be + switched by settting them directly (see below) + + oper_dtype : type + Data type for internal dynamics generators, propagators and time + evolution operators. This can be ndarray or Qobj. + Qobj may perform better for larger systems, and will also + perform better when (custom) fidelity measures use Qobj methods + such as partial trace. + See _choose_oper_dtype for how this is chosen when not specified + + cache_phased_dyn_gen : bool + If True then the dynamics generators will be saved with and + without the propagation prefactor (if there is one) + Defaults to True when memory_optimization=0, otherwise False + + cache_prop_grad : bool + If the True then the propagator gradients (for exact gradients) will + be computed when the propagator are computed and cache until + the are used by the fidelity computer. If False then the + fidelity computer will calculate them as needed. + Defaults to True when memory_optimization=0, otherwise False + + cache_dyn_gen_eigenvectors_adj: bool + If True then DynamicsUnitary will cached the adjoint of + the Hamiltion eignvector matrix + Defaults to True when memory_optimization=0, otherwise False + + sparse_eigen_decomp: bool + If True then DynamicsUnitary will use the sparse eigenvalue + decomposition. + Defaults to True when memory_optimization<=1, otherwise False + + num_tslots : integer + Number of timeslots (aka timeslices) + + num_ctrls : integer + Number of controls. + Note this is calculated as the length of ctrl_dyn_gen when first used. + And is recalculated during initialise_controls only. + + evo_time : float + Total time for the evolution + + tau : array[num_tslots] of float + Duration of each timeslot + Note that if this is set before initialize_controls is called + then num_tslots and evo_time are calculated from tau, otherwise + tau is generated from num_tslots and evo_time, that is + equal size time slices + + time : array[num_tslots+1] of float + Cumulative time for the evolution, that is the time at the start + of each time slice + + drift_dyn_gen : Qobj or list of Qobj + Drift or system dynamics generator (Hamiltonian) + Matrix defining the underlying dynamics of the system + Can also be a list of Qobj (length num_tslots) for time varying + drift dynamics + + ctrl_dyn_gen : List of Qobj + Control dynamics generator (Hamiltonians) + List of matrices defining the control dynamics + + initial : Qobj + Starting state / gate + The matrix giving the initial state / gate, i.e. at time 0 + Typically the identity for gate evolution + + target : Qobj + Target state / gate: + The matrix giving the desired state / gate for the evolution + + ctrl_amps : array[num_tslots, num_ctrls] of float + Control amplitudes + The amplitude (scale factor) for each control in each timeslot + + initial_ctrl_scaling : float + Scale factor applied to be applied the control amplitudes + when they are initialised + This is used by the PulseGens rather than in any fucntions in + this class + + initial_ctrl_offset : float + Linear offset applied to be applied the control amplitudes + when they are initialised + This is used by the PulseGens rather than in any fucntions in + this class + + dyn_gen : List of Qobj + Dynamics generators + the combined drift and control dynamics generators + for each timeslot + + prop : list of Qobj + Propagators - used to calculate time evolution from one + timeslot to the next + + prop_grad : array[num_tslots, num_ctrls] of Qobj + Propagator gradient (exact gradients only) + Array of Qobj that give the gradient + with respect to the control amplitudes in a timeslot + Note this attribute is only created when the selected + PropagatorComputer is an exact gradient type. + + fwd_evo : List of Qobj + Forward evolution (or propagation) + the time evolution operator from the initial state / gate to the + specified timeslot as generated by the dyn_gen + + onwd_evo : List of Qobj + Onward evolution (or propagation) + the time evolution operator from the specified timeslot to + end of the evolution time as generated by the dyn_gen + + onto_evo : List of Qobj + 'Backward' List of Qobj propagation + the overlap of the onward propagation with the inverse of the + target. + Note this is only used (so far) by the unitary dynamics fidelity + + evo_current : Boolean + Used to flag that the dynamics used to calculate the evolution + operators is current. It is set to False when the amplitudes + change + + fact_mat_round_prec : float + Rounding precision used when calculating the factor matrix + to determine if two eigenvalues are equivalent + Only used when the PropagatorComputer uses diagonalisation + + def_amps_fname : string + Default name for the output used when save_amps is called + + unitarity_check_level : int + If > 0 then unitarity of the system evolution is checked at at + evolution recomputation. + level 1 checks all propagators + level 2 checks eigen basis as well + Default is 0 + + unitarity_tol : + Tolerance used in checking if operator is unitary + Default is 1e-10 + + dump : :class:`qutip.control.dump.DynamicsDump` + Store of historical calculation data. + Set to None (Default) for no storing of historical data + Use dumping property to set level of data dumping + + dumping : string + level of data dumping: NONE, SUMMARY, FULL or CUSTOM + See property docstring for details + + dump_to_file : bool + If set True then data will be dumped to file during the calculations + dumping will be set to SUMMARY during init_evo if dump_to_file is True + and dumping not set. + Default is False + + dump_dir : string + Basically a link to dump.dump_dir. Exists so that it can be set through + dyn_params. + If dump is None then will return None or will set dumping to SUMMARY + when setting a path + + """ + + def __init__(self, optimconfig, params=None): + self.config = optimconfig + self.params = params + self.reset() + + def reset(self): + # Link to optimiser object if self is linked to one + self.parent = None + # Main functional attributes + self.time = None + self.initial = None + self.target = None + self.ctrl_amps = None + self.initial_ctrl_scaling = 1.0 + self.initial_ctrl_offset = 0.0 + self.drift_dyn_gen = None + self.ctrl_dyn_gen = None + self._tau = None + self._evo_time = None + self._num_ctrls = None + self._num_tslots = None + # attributes used for processing evolution + self.memory_optimization = 0 + self.oper_dtype = None + self.cache_phased_dyn_gen = None + self.cache_prop_grad = None + self.cache_dyn_gen_eigenvectors_adj = None + self.sparse_eigen_decomp = None + self.dyn_dims = None + self.dyn_shape = None + self.sys_dims = None + self.sys_shape = None + self.time_depend_drift = False + self.time_depend_ctrl_dyn_gen = False + # These internal attributes will be of the internal operator data type + # used to compute the evolution + # This will be either ndarray or Qobj + self._drift_dyn_gen = None + self._ctrl_dyn_gen = None + self._phased_ctrl_dyn_gen = None + self._dyn_gen_phase = None + self._phase_application = None + self._initial = None + self._target = None + self._onto_evo_target = None + self._dyn_gen = None + self._phased_dyn_gen = None + self._prop = None + self._prop_grad = None + self._fwd_evo = None + self._onwd_evo = None + self._onto_evo = None + # The _qobj attribs are Qobj representations of the equivalent + # internal attribute. They are only set when the extenal accessors + # are used + self._onto_evo_target_qobj = None + self._dyn_gen_qobj = None + self._prop_qobj = None + self._prop_grad_qobj = None + self._fwd_evo_qobj = None + self._onwd_evo_qobj = None + self._onto_evo_qobj = None + # Atrributes used in diagonalisation + # again in internal operator data type (see above) + self._decomp_curr = None + self._prop_eigen = None + self._dyn_gen_eigenvectors = None + self._dyn_gen_eigenvectors_adj = None + self._dyn_gen_factormatrix = None + self.fact_mat_round_prec = 1e-10 + + # Debug and information attribs + self.stats = None + self.id_text = "DYN_BASE" + self.def_amps_fname = "ctrl_amps.txt" + self.log_level = self.config.log_level + # Internal flags + self._dyn_gen_mapped = False + self._evo_initialized = False + self._timeslots_initialized = False + self._ctrls_initialized = False + self._ctrl_dyn_gen_checked = False + self._drift_dyn_gen_checked = False + # Unitary checking + self.unitarity_check_level = 0 + self.unitarity_tol = 1e-10 + # Data dumping + self.dump = None + self.dump_to_file = False + + self.apply_params() + + # Create the computing objects + self._create_computers() + + self.clear() + + def apply_params(self, params=None): + """ + Set object attributes based on the dictionary (if any) passed in the + instantiation, or passed as a parameter + This is called during the instantiation automatically. + The key value pairs are the attribute name and value + Note: attributes are created if they do not exist already, + and are overwritten if they do. + """ + if not params: + params = self.params + + if isinstance(params, dict): + self.params = params + for key in params: + setattr(self, key, params[key]) + + @property + def log_level(self): + return logger.level + + @log_level.setter + def log_level(self, lvl): + """ + Set the log_level attribute and set the level of the logger + that is call logger.setLevel(lvl) + """ + logger.setLevel(lvl) + + @property + def dumping(self): + """ + The level of data dumping that will occur during the time evolution + calculation. + + - NONE : No processing data dumped (Default) + - SUMMARY : A summary of each time evolution will be recorded + - FULL : All operators used or created in the calculation dumped + - CUSTOM : Some customised level of dumping + + When first set to CUSTOM this is equivalent to SUMMARY. It is then up + to the user to specify which operators are dumped. WARNING: FULL could + consume a lot of memory! + """ + if self.dump is None: + lvl = "NONE" + else: + lvl = self.dump.level + + return lvl + + @dumping.setter + def dumping(self, value): + if value is None: + self.dump = None + else: + if not isinstance(value, str): + raise TypeError("Value must be string value") + lvl = value.upper() + if lvl == "NONE": + self.dump = None + else: + if not isinstance(self.dump, qtrldump.DynamicsDump): + self.dump = qtrldump.DynamicsDump(self, level=lvl) + else: + self.dump.level = lvl + + @property + def dump_dir(self): + if self.dump: + return self.dump.dump_dir + else: + return None + + @dump_dir.setter + def dump_dir(self, value): + if not self.dump: + self.dumping = "SUMMARY" + self.dump.dump_dir = value + + def _create_computers(self): + """ + Create the default timeslot, fidelity and propagator computers + """ + # The time slot computer. By default it is set to UpdateAll + # can be set to DynUpdate in the configuration + # (see class file for details) + if self.config.tslot_type == "DYNAMIC": + self.tslot_computer = tslotcomp.TSlotCompDynUpdate(self) + else: + self.tslot_computer = tslotcomp.TSlotCompUpdateAll(self) + + self.prop_computer = propcomp.PropCompFrechet(self) + self.fid_computer = fidcomp.FidCompTraceDiff(self) + + def clear(self): + self.ctrl_amps = None + self.evo_current = False + if self.fid_computer is not None: + self.fid_computer.clear() + + @property + def num_tslots(self): + if not self._timeslots_initialized: + self.init_timeslots() + return self._num_tslots + + @num_tslots.setter + def num_tslots(self, value): + self._num_tslots = value + if self._timeslots_initialized: + self._tau = None + self.init_timeslots() + + @property + def evo_time(self): + if not self._timeslots_initialized: + self.init_timeslots() + return self._evo_time + + @evo_time.setter + def evo_time(self, value): + self._evo_time = value + if self._timeslots_initialized: + self._tau = None + self.init_timeslots() + + @property + def tau(self): + if not self._timeslots_initialized: + self.init_timeslots() + return self._tau + + @tau.setter + def tau(self, value): + self._tau = value + self.init_timeslots() + + def init_timeslots(self): + """ + Generate the timeslot duration array 'tau' based on the evo_time + and num_tslots attributes, unless the tau attribute is already set + in which case this step in ignored + Generate the cumulative time array 'time' based on the tau values + """ + # set the time intervals to be equal timeslices of the total if + # the have not been set already (as part of user config) + if self._num_tslots is None: + self._num_tslots = DEF_NUM_TSLOTS + if self._evo_time is None: + self._evo_time = DEF_EVO_TIME + + if self._tau is None: + self._tau = ( + np.ones(self._num_tslots, dtype="f") + * self._evo_time + / self._num_tslots + ) + else: + self._num_tslots = len(self._tau) + self._evo_time = np.sum(self._tau) + + self.time = np.zeros(self._num_tslots + 1, dtype=float) + # set the cumulative time by summing the time intervals + for t in range(self._num_tslots): + self.time[t + 1] = self.time[t] + self._tau[t] + + self._timeslots_initialized = True + + def _set_memory_optimizations(self): + """ + Set various memory optimisation attributes based on the + memory_optimization attribute + If they have been set already, e.g. in apply_params + then they will not be overridden here + """ + logger.info( + "Setting memory optimisations for level {}".format( + self.memory_optimization + ) + ) + + if self.oper_dtype is None: + self._choose_oper_dtype() + logger.info( + "Internal operator data type choosen to be {}".format( + self.oper_dtype + ) + ) + else: + logger.info("Using operator data type {}".format(self.oper_dtype)) + + if self.cache_phased_dyn_gen is None: + if self.memory_optimization > 0: + self.cache_phased_dyn_gen = False + else: + self.cache_phased_dyn_gen = True + logger.info( + "phased dynamics generator caching {}".format( + self.cache_phased_dyn_gen + ) + ) + + if self.cache_prop_grad is None: + if self.memory_optimization > 0: + self.cache_prop_grad = False + else: + self.cache_prop_grad = True + logger.info( + "propagator gradient caching {}".format(self.cache_prop_grad) + ) + + if self.cache_dyn_gen_eigenvectors_adj is None: + if self.memory_optimization > 0: + self.cache_dyn_gen_eigenvectors_adj = False + else: + self.cache_dyn_gen_eigenvectors_adj = True + logger.info( + "eigenvector adjoint caching {}".format( + self.cache_dyn_gen_eigenvectors_adj + ) + ) + + if self.sparse_eigen_decomp is None: + if self.memory_optimization > 1: + self.sparse_eigen_decomp = True + else: + self.sparse_eigen_decomp = False + logger.info( + "use sparse eigen decomp {}".format(self.sparse_eigen_decomp) + ) + + def _choose_oper_dtype(self): + """ + Attempt select most efficient internal operator data type + """ + + if self.memory_optimization > 0: + self.oper_dtype = Qobj + else: + # Method taken from Qobj.expm() + # if method is not explicitly given, try to make a good choice + # between sparse and dense solvers by considering the size of the + # system and the number of non-zero elements. + if self.time_depend_drift: + dg = self.drift_dyn_gen[0] + else: + dg = self.drift_dyn_gen + if self.time_depend_ctrl_dyn_gen: + ctrls = self.ctrl_dyn_gen[0, :] + else: + ctrls = self.ctrl_dyn_gen + for c in ctrls: + dg = dg + c + + N = dg.shape[0] + if isinstance(dg.data, _data.CSR): + n = _data.csr.nnz(dg.data) + else: + n = N**2 + + if N**2 < 100 * n: + # large number of nonzero elements, revert to dense solver + self.oper_dtype = np.ndarray + elif N > 400: + # large system, and quite sparse -> qutips sparse method + self.oper_dtype = Qobj + else: + # small system, but quite sparse -> qutips sparse/dense method + self.oper_dtype = np.ndarray + + return self.oper_dtype + + def _init_evo(self): + """ + Create the container lists / arrays for the: + dynamics generations, propagators, and evolutions etc + Set the time slices and cumulative time + """ + # check evolution operators + if not self._drift_dyn_gen_checked: + _check_drift_dyn_gen(self.drift_dyn_gen) + if not self._ctrl_dyn_gen_checked: + self.ctrl_dyn_gen = _check_ctrls_container(self.ctrl_dyn_gen) + + if not isinstance(self.initial, Qobj): + raise TypeError("initial must be a Qobj") + + if not isinstance(self.target, Qobj): + raise TypeError("target must be a Qobj") + + self.refresh_drift_attribs() + self.sys_dims = self.initial.dims + self.sys_shape = self.initial.shape + # Set the phase application method + self._init_phase() + + self._set_memory_optimizations() + if self.sparse_eigen_decomp and self.sys_shape[0] <= 2: + raise ValueError( + "Single qubit pulse optimization dynamics cannot use sparse" + " eigenvector decomposition because of limitations in" + " scipy.linalg.eigsh. Pleae set sparse_eigen_decomp to False" + " or increase the size of the system." + ) + + n_ts = self.num_tslots + n_ctrls = self.num_ctrls + if self.oper_dtype == Qobj: + self._initial = self.initial + self._target = self.target + self._drift_dyn_gen = self.drift_dyn_gen + self._ctrl_dyn_gen = self.ctrl_dyn_gen + elif self.oper_dtype == np.ndarray: + self._initial = self.initial.full() + self._target = self.target.full() + if self.time_depend_drift: + self._drift_dyn_gen = [d.full() for d in self.drift_dyn_gen] + else: + self._drift_dyn_gen = self.drift_dyn_gen.full() + if self.time_depend_ctrl_dyn_gen: + self._ctrl_dyn_gen = np.empty([n_ts, n_ctrls], dtype=object) + for k in range(n_ts): + for j in range(n_ctrls): + self._ctrl_dyn_gen[k, j] = self.ctrl_dyn_gen[ + k, j + ].full() + else: + self._ctrl_dyn_gen = [ + ctrl.full() for ctrl in self.ctrl_dyn_gen + ] + else: + raise ValueError( + "Unknown oper_dtype {!r}. The oper_dtype may be qutip.Qobj or" + " numpy.ndarray.".format(self.oper_dtype) + ) + + if self.cache_phased_dyn_gen: + if self.time_depend_ctrl_dyn_gen: + self._phased_ctrl_dyn_gen = np.empty( + [n_ts, n_ctrls], dtype=object + ) + for k in range(n_ts): + for j in range(n_ctrls): + self._phased_ctrl_dyn_gen[k, j] = self._apply_phase( + self._ctrl_dyn_gen[k, j] + ) + else: + self._phased_ctrl_dyn_gen = [ + self._apply_phase(ctrl) for ctrl in self._ctrl_dyn_gen + ] + + self._dyn_gen = [object for x in range(self.num_tslots)] + if self.cache_phased_dyn_gen: + self._phased_dyn_gen = [object for x in range(self.num_tslots)] + self._prop = [object for x in range(self.num_tslots)] + if self.prop_computer.grad_exact and self.cache_prop_grad: + self._prop_grad = np.empty( + [self.num_tslots, self.num_ctrls], dtype=object + ) + # Time evolution operator (forward propagation) + self._fwd_evo = [object for x in range(self.num_tslots + 1)] + self._fwd_evo[0] = self._initial + if self.fid_computer.uses_onwd_evo: + # Time evolution operator (onward propagation) + self._onwd_evo = [object for x in range(self.num_tslots)] + if self.fid_computer.uses_onto_evo: + # Onward propagation overlap with inverse target + self._onto_evo = [object for x in range(self.num_tslots + 1)] + self._onto_evo[self.num_tslots] = self._get_onto_evo_target() + + if isinstance(self.prop_computer, propcomp.PropCompDiag): + self._create_decomp_lists() + + if self.log_level <= logging.DEBUG and isinstance( + self, DynamicsUnitary + ): + self.unitarity_check_level = 1 + + if self.dump_to_file: + if self.dump is None: + self.dumping = "SUMMARY" + self.dump.write_to_file = True + self.dump.create_dump_dir() + logger.info( + "Dynamics dump will be written to:\n{}".format( + self.dump.dump_dir + ) + ) + + self._evo_initialized = True + + @property + def dyn_gen_phase(self): + """ + Some op that is applied to the dyn_gen before expontiating to + get the propagator. + See `phase_application` for how this is applied + """ + # Note that if this returns None then _apply_phase will never be + # called + return self._dyn_gen_phase + + @dyn_gen_phase.setter + def dyn_gen_phase(self, value): + self._dyn_gen_phase = value + + @property + def phase_application(self): + """ + phase_application : scalar(string), default='preop' + Determines how the phase is applied to the dynamics generators + + - 'preop' : P = expm(phase*dyn_gen) + - 'postop' : P = expm(dyn_gen*phase) + - 'custom' : Customised phase application + + The 'custom' option assumes that the _apply_phase method has been + set to a custom function. + """ + return self._phase_application + + @phase_application.setter + def phase_application(self, value): + self._set_phase_application(value) + + def _set_phase_application(self, value): + self._config_phase_application(value) + self._phase_application = value + + def _config_phase_application(self, ph_app=None): + """ + Set the appropriate function for the phase application + """ + err_msg = ( + "Invalid value '{}' for phase application. Must be either " + "'preop', 'postop' or 'custom'".format(ph_app) + ) + + if ph_app is None: + ph_app = self._phase_application + + try: + ph_app = ph_app.lower() + except AttributeError: + raise ValueError(err_msg) + + if ph_app == "preop": + self._apply_phase = self._apply_phase_preop + elif ph_app == "postop": + self._apply_phase = self._apply_phase_postop + elif ph_app == "custom": + # Do nothing, assume _apply_phase set elsewhere + pass + else: + raise ValueError(err_msg) + + def _init_phase(self): + if self.dyn_gen_phase is not None: + self._config_phase_application() + else: + self.cache_phased_dyn_gen = False + + def _apply_phase(self, dg): + """ + This default method does nothing. + It will be set to another method automatically if `phase_application` + is 'preop' or 'postop'. It should be overridden repointed if + `phase_application` is 'custom' + It will never be called if `dyn_gen_phase` is None + """ + return dg + + def _apply_phase_preop(self, dg): + """ + Apply phasing operator to dynamics generator. + This called during the propagator calculation. + In this case it will be applied as phase*dg + """ + if hasattr(self.dyn_gen_phase, "dot"): + phased_dg = self._dyn_gen_phase.dot(dg) + else: + phased_dg = self._dyn_gen_phase * dg + return phased_dg + + def _apply_phase_postop(self, dg): + """ + Apply phasing operator to dynamics generator. + This called during the propagator calculation. + In this case it will be applied as dg*phase + """ + if hasattr(self.dyn_gen_phase, "dot"): + phased_dg = dg.dot(self._dyn_gen_phase) + else: + phased_dg = dg * self._dyn_gen_phase + return phased_dg + + def _create_decomp_lists(self): + """ + Create lists that will hold the eigen decomposition + used in calculating propagators and gradients + Note: used with PropCompDiag propagator calcs + """ + n_ts = self.num_tslots + self._decomp_curr = [False for x in range(n_ts)] + self._prop_eigen = [object for x in range(n_ts)] + self._dyn_gen_eigenvectors = [object for x in range(n_ts)] + if self.cache_dyn_gen_eigenvectors_adj: + self._dyn_gen_eigenvectors_adj = [object for x in range(n_ts)] + self._dyn_gen_factormatrix = [object for x in range(n_ts)] + + def initialize_controls(self, amps, init_tslots=True): + """ + Set the initial control amplitudes and time slices + Note this must be called after the configuration is complete + before any dynamics can be calculated + """ + if not isinstance(self.prop_computer, propcomp.PropagatorComputer): + raise errors.UsageError( + "No prop_computer (propagator computer) " + "set. A default should be assigned by the Dynamics subclass" + ) + + if not isinstance(self.tslot_computer, tslotcomp.TimeslotComputer): + raise errors.UsageError( + "No tslot_computer (Timeslot computer)" + " set. A default should be assigned by the Dynamics class" + ) + + if not isinstance(self.fid_computer, fidcomp.FidelityComputer): + raise errors.UsageError( + "No fid_computer (Fidelity computer)" + " set. A default should be assigned by the Dynamics subclass" + ) + + self.ctrl_amps = None + if not self._timeslots_initialized: + init_tslots = True + if init_tslots: + self.init_timeslots() + self._init_evo() + self.tslot_computer.init_comp() + self.fid_computer.init_comp() + self._ctrls_initialized = True + self.update_ctrl_amps(amps) + + def check_ctrls_initialized(self): + if not self._ctrls_initialized: + raise errors.UsageError( + "Controls not initialised. " + "Ensure Dynamics.initialize_controls has been " + "executed with the initial control amplitudes." + ) + + def get_amp_times(self): + return self.time[: self.num_tslots] + + def save_amps(self, file_name=None, times=None, amps=None, verbose=False): + """ + Save a file with the current control amplitudes in each timeslot + The first column in the file will be the start time of the slot + + Parameters + ---------- + file_name : string + Name of the file + If None given the def_amps_fname attribuite will be used + + times : List type (or string) + List / array of the start times for each slot + If None given this will be retrieved through get_amp_times() + If 'exclude' then times will not be saved in the file, just + the amplitudes + + amps : Array[num_tslots, num_ctrls] + Amplitudes to be saved + If None given the ctrl_amps attribute will be used + + verbose : Boolean + If True then an info message will be logged + """ + self.check_ctrls_initialized() + + inctimes = True + if file_name is None: + file_name = self.def_amps_fname + if amps is None: + amps = self.ctrl_amps + if times is None: + times = self.get_amp_times() + else: + if isinstance(times, str): + if times.lower() == "exclude": + inctimes = False + else: + logger.warn( + "Unknown option for times '{}' " + "when saving amplitudes".format(times) + ) + times = self.get_amp_times() + + try: + if inctimes: + shp = amps.shape + data = np.empty([shp[0], shp[1] + 1], dtype=float) + data[:, 0] = times + data[:, 1:] = amps + else: + data = amps + + np.savetxt(file_name, data, delimiter="\t", fmt="%14.6g") + + if verbose: + logger.info("Amplitudes saved to file: " + file_name) + except Exception as e: + logger.error( + "Failed to save amplitudes due to underling " + "error: {}".format(e) + ) + + def update_ctrl_amps(self, new_amps): + """ + Determine if any amplitudes have changed. If so, then mark the + timeslots as needing recalculation + The actual work is completed by the compare_amps method of the + timeslot computer + """ + + if self.log_level <= logging.DEBUG_INTENSE: + logger.log( + logging.DEBUG_INTENSE, + "Updating amplitudes...\n" + "Current control amplitudes:\n" + + str(self.ctrl_amps) + + "\n(potenially) new amplitudes:\n" + + str(new_amps), + ) + + self.tslot_computer.compare_amps(new_amps) + + def flag_system_changed(self): + """ + Flag evolution, fidelity and gradients as needing recalculation + """ + self.evo_current = False + self.fid_computer.flag_system_changed() + + def get_drift_dim(self): + """ + Returns the size of the matrix that defines the drift dynamics + that is assuming the drift is NxN, then this returns N + """ + if self.dyn_shape is None: + self.refresh_drift_attribs() + return self.dyn_shape[0] + + def refresh_drift_attribs(self): + """Reset the dyn_shape, dyn_dims and time_depend_drift attribs""" + + if isinstance(self.drift_dyn_gen, (list, tuple)): + d0 = self.drift_dyn_gen[0] + self.time_depend_drift = True + else: + d0 = self.drift_dyn_gen + self.time_depend_drift = False + + if not isinstance(d0, Qobj): + raise TypeError( + "Unable to determine drift attributes, " + "because drift_dyn_gen is not Qobj (nor list of)" + ) + + self.dyn_shape = d0.shape + self.dyn_dims = d0.dims + + def get_num_ctrls(self): + """ + calculate the of controls from the length of the control list + sets the num_ctrls property, which can be used alternatively + subsequently + """ + _func_deprecation( + "'get_num_ctrls' has been replaced by " "'num_ctrls' property" + ) + return self.num_ctrls + + def _get_num_ctrls(self): + if not self._ctrl_dyn_gen_checked: + self.ctrl_dyn_gen = _check_ctrls_container(self.ctrl_dyn_gen) + self._ctrl_dyn_gen_checked = True + if isinstance(self.ctrl_dyn_gen, np.ndarray): + self._num_ctrls = self.ctrl_dyn_gen.shape[1] + self.time_depend_ctrl_dyn_gen = True + else: + self._num_ctrls = len(self.ctrl_dyn_gen) + + return self._num_ctrls + + @property + def num_ctrls(self): + """ + calculate the of controls from the length of the control list + sets the num_ctrls property, which can be used alternatively + subsequently + """ + if self._num_ctrls is None: + self._num_ctrls = self._get_num_ctrls() + return self._num_ctrls + + @property + def onto_evo_target(self): + if self._onto_evo_target is None: + self._get_onto_evo_target() + + if self._onto_evo_target_qobj is None: + if isinstance(self._onto_evo_target, Qobj): + self._onto_evo_target_qobj = self._onto_evo_target + else: + rev_dims = [self.sys_dims[1], self.sys_dims[0]] + self._onto_evo_target_qobj = Qobj( + self._onto_evo_target, dims=rev_dims + ) + + return self._onto_evo_target_qobj + + def get_owd_evo_target(self): + _func_deprecation( + "'get_owd_evo_target' has been replaced by " + "'onto_evo_target' property" + ) + return self.onto_evo_target + + def _get_onto_evo_target(self): + """ + Get the inverse of the target. + Used for calculating the 'onto target' evolution + This is actually only relevant for unitary dynamics where + the target.dag() is what is required + However, for completeness, in general the inverse of the target + operator is is required + For state-to-state, the bra corresponding to the is required ket + """ + if self.target.shape[0] == self.target.shape[1]: + # Target is operator + targ = la.inv(self.target.full()) + if self.oper_dtype == Qobj: + rev_dims = [self.target.dims[1], self.target.dims[0]] + self._onto_evo_target = Qobj(targ, dims=rev_dims) + elif self.oper_dtype == np.ndarray: + self._onto_evo_target = targ + else: + assert False, f"Unknown oper_dtype {self.oper_dtype!r}" + else: + if self.oper_dtype == Qobj: + self._onto_evo_target = self.target.dag() + elif self.oper_dtype == np.ndarray: + self._onto_evo_target = self.target.dag().full() + else: + assert False, f"Unknown oper_dtype {self.oper_dtype!r}" + + return self._onto_evo_target + + def combine_dyn_gen(self, k): + """ + Computes the dynamics generator for a given timeslot + The is the combined Hamiltion for unitary systems + """ + _func_deprecation( + "'combine_dyn_gen' has been replaced by " "'_combine_dyn_gen'" + ) + self._combine_dyn_gen(k) + return self._dyn_gen(k) + + def _combine_dyn_gen(self, k): + """ + Computes the dynamics generator for a given timeslot + The is the combined Hamiltion for unitary systems + Also applies the phase (if any required by the propagation) + """ + if self.time_depend_drift: + dg = self._drift_dyn_gen[k] + else: + dg = self._drift_dyn_gen + for j in range(self._num_ctrls): + if self.time_depend_ctrl_dyn_gen: + dg = dg + self.ctrl_amps[k, j] * self._ctrl_dyn_gen[k, j] + else: + dg = dg + self.ctrl_amps[k, j] * self._ctrl_dyn_gen[j] + + self._dyn_gen[k] = dg + if self.cache_phased_dyn_gen: + self._phased_dyn_gen[k] = self._apply_phase(dg) + + def get_dyn_gen(self, k): + """ + Get the combined dynamics generator for the timeslot + Not implemented in the base class. Choose a subclass + """ + _func_deprecation( + "'get_dyn_gen' has been replaced by " "'_get_phased_dyn_gen'" + ) + return self._get_phased_dyn_gen(k) + + def _get_phased_dyn_gen(self, k): + if self.dyn_gen_phase is None: + return self._dyn_gen[k] + else: + if self._phased_dyn_gen is None: + return self._apply_phase(self._dyn_gen[k]) + else: + return self._phased_dyn_gen[k] + + def get_ctrl_dyn_gen(self, j): + """ + Get the dynamics generator for the control + Not implemented in the base class. Choose a subclass + """ + _func_deprecation( + "'get_ctrl_dyn_gen' has been replaced by " + "'_get_phased_ctrl_dyn_gen'" + ) + return self._get_phased_ctrl_dyn_gen(0, j) + + def _get_phased_ctrl_dyn_gen(self, k, j): + if self._phased_ctrl_dyn_gen is not None: + if self.time_depend_ctrl_dyn_gen: + return self._phased_ctrl_dyn_gen[k, j] + else: + return self._phased_ctrl_dyn_gen[j] + else: + if self.time_depend_ctrl_dyn_gen: + if self.dyn_gen_phase is None: + return self._ctrl_dyn_gen[k, j] + else: + return self._apply_phase(self._ctrl_dyn_gen[k, j]) + else: + if self.dyn_gen_phase is None: + return self._ctrl_dyn_gen[j] + else: + return self._apply_phase(self._ctrl_dyn_gen[j]) + + @property + def dyn_gen(self): + """ + List of combined dynamics generators (Qobj) for each timeslot + """ + if self._dyn_gen is not None: + if self._dyn_gen_qobj is None: + if self.oper_dtype == Qobj: + self._dyn_gen_qobj = self._dyn_gen + else: + self._dyn_gen_qobj = [ + Qobj(dg, dims=self.dyn_dims) for dg in self._dyn_gen + ] + return self._dyn_gen_qobj + + @property + def prop(self): + """ + List of propagators (Qobj) for each timeslot + """ + if self._prop is not None: + if self._prop_qobj is None: + if self.oper_dtype == Qobj: + self._prop_qobj = self._prop + else: + self._prop_qobj = [ + Qobj(dg, dims=self.dyn_dims) for dg in self._prop + ] + return self._prop_qobj + + @property + def prop_grad(self): + """ + Array of propagator gradients (Qobj) for each timeslot, control + """ + if self._prop_grad is not None: + if self._prop_grad_qobj is None: + if self.oper_dtype == Qobj: + self._prop_grad_qobj = self._prop_grad + else: + self._prop_grad_qobj = np.empty( + [self.num_tslots, self.num_ctrls], dtype=object + ) + for k in range(self.num_tslots): + for j in range(self.num_ctrls): + self._prop_grad_qobj[k, j] = Qobj( + self._prop_grad[k, j], dims=self.dyn_dims + ) + return self._prop_grad_qobj + + def _get_prop_grad(self, k, j): + if self.cache_prop_grad: + prop_grad = self._prop_grad[k, j] + else: + prop_grad = self.prop_computer._compute_prop_grad( + k, j, compute_prop=False + ) + return prop_grad + + @property + def evo_init2t(self): + _attrib_deprecation("'evo_init2t' has been replaced by '_fwd_evo'") + return self._fwd_evo + + @property + def fwd_evo(self): + """ + List of evolution operators (Qobj) from the initial to the given + timeslot + """ + if self._fwd_evo is not None: + if self._fwd_evo_qobj is None: + if self.oper_dtype == Qobj: + self._fwd_evo_qobj = self._fwd_evo + else: + self._fwd_evo_qobj = [self.initial] + for k in range(1, self.num_tslots + 1): + self._fwd_evo_qobj.append( + Qobj(self._fwd_evo[k], dims=self.sys_dims) + ) + return self._fwd_evo_qobj + + def _get_full_evo(self): + return self._fwd_evo[self._num_tslots] + + @property + def full_evo(self): + """Full evolution - time evolution at final time slot""" + return self.fwd_evo[self.num_tslots] + + @property + def evo_t2end(self): + _attrib_deprecation("'evo_t2end' has been replaced by '_onwd_evo'") + return self._onwd_evo + + @property + def onwd_evo(self): + """ + List of evolution operators (Qobj) from the initial to the given + timeslot + """ + if self._onwd_evo is not None: + if self._onwd_evo_qobj is None: + if self.oper_dtype == Qobj: + self._onwd_evo_qobj = self._fwd_evo + else: + self._onwd_evo_qobj = [ + Qobj(dg, dims=self.sys_dims) for dg in self._onwd_evo + ] + return self._onwd_evo_qobj + + @property + def evo_t2targ(self): + _attrib_deprecation("'evo_t2targ' has been replaced by '_onto_evo'") + return self._onto_evo + + @property + def onto_evo(self): + """ + List of evolution operators (Qobj) from the initial to the given + timeslot + """ + if self._onto_evo is not None: + if self._onto_evo_qobj is None: + if self.oper_dtype == Qobj: + self._onto_evo_qobj = self._onto_evo + else: + self._onto_evo_qobj = [] + for k in range(0, self.num_tslots): + self._onto_evo_qobj.append( + Qobj(self._onto_evo[k], dims=self.sys_dims) + ) + self._onto_evo_qobj.append(self.onto_evo_target) + + return self._onto_evo_qobj + + def compute_evolution(self): + """ + Recalculate the time evolution operators + Dynamics generators (e.g. Hamiltonian) and + prop (propagators) are calculated as necessary + Actual work is completed by the recompute_evolution method + of the timeslot computer + """ + + # Check if values are already current, otherwise calculate all values + if not self.evo_current: + if self.log_level <= logging.DEBUG_VERBOSE: + logger.log(logging.DEBUG_VERBOSE, "Computing evolution") + self.tslot_computer.recompute_evolution() + self.evo_current = True + return True + return False + + def _ensure_decomp_curr(self, k): + """ + Checks to see if the diagonalisation has been completed since + the last update of the dynamics generators + (after the amplitude update) + If not then the diagonlisation is completed + """ + if self._decomp_curr is None: + raise errors.UsageError("Decomp lists have not been created") + if not self._decomp_curr[k]: + self._spectral_decomp(k) + + def _spectral_decomp(self, k): + """ + Calculate the diagonalization of the dynamics generator + generating lists of eigenvectors, propagators in the diagonalised + basis, and the 'factormatrix' used in calculating the propagator + gradient + Not implemented in this base class, because the method is specific + to the matrix type + """ + raise errors.UsageError( + "Decomposition cannot be completed by " + "this class. Try a(nother) subclass" + ) + + def _is_unitary(self, A): + """ + Checks whether operator A is unitary + A can be either Qobj or ndarray + """ + if isinstance(A, Qobj): + unitary = np.allclose( + np.eye(A.shape[0]), + (A * A.dag()).full(), + atol=self.unitarity_tol, + ) + else: + unitary = np.allclose( + np.eye(len(A)), A.dot(A.T.conj()), atol=self.unitarity_tol + ) + + return unitary + + def _calc_unitary_err(self, A): + if isinstance(A, Qobj): + err = np.sum(abs(np.eye(A.shape[0]) - (A * A.dag()).full())) + else: + err = np.sum(abs(np.eye(len(A)) - A.dot(A.T.conj()))) + + return err + + def unitarity_check(self): + """ + Checks whether all propagators are unitary + """ + for k in range(self.num_tslots): + if not self._is_unitary(self._prop[k]): + logger.warning( + "Progator of timeslot {} is not unitary".format(k) + ) + + +class DynamicsGenMat(Dynamics): + """ + This sub class can be used for any system where no additional + operator is applied to the dynamics generator before calculating + the propagator, e.g. classical dynamics, Lindbladian + """ + + def reset(self): + Dynamics.reset(self) + self.id_text = "GEN_MAT" + self.apply_params() + + +class DynamicsUnitary(Dynamics): + """ + This is the subclass to use for systems with dynamics described by + unitary matrices. E.g. closed systems with Hermitian Hamiltonians + Note a matrix diagonalisation is used to compute the exponent + The eigen decomposition is also used to calculate the propagator gradient. + The method is taken from DYNAMO (see file header) + + Attributes + ---------- + drift_ham : Qobj + This is the drift Hamiltonian for unitary dynamics + It is mapped to drift_dyn_gen during initialize_controls + + ctrl_ham : List of Qobj + These are the control Hamiltonians for unitary dynamics + It is mapped to ctrl_dyn_gen during initialize_controls + + H : List of Qobj + The combined drift and control Hamiltonians for each timeslot + These are the dynamics generators for unitary dynamics. + It is mapped to dyn_gen during initialize_controls + """ + + def reset(self): + Dynamics.reset(self) + self.id_text = "UNIT" + self.drift_ham = None + self.ctrl_ham = None + self.H = None + self._dyn_gen_phase = -1j + self._phase_application = "preop" + self.apply_params() + + def _create_computers(self): + """ + Create the default timeslot, fidelity and propagator computers + """ + # The time slot computer. By default it is set to _UpdateAll + # can be set to _DynUpdate in the configuration + # (see class file for details) + if self.config.tslot_type == "DYNAMIC": + self.tslot_computer = tslotcomp.TSlotCompDynUpdate(self) + else: + self.tslot_computer = tslotcomp.TSlotCompUpdateAll(self) + # set the default fidelity computer + self.fid_computer = fidcomp.FidCompUnitary(self) + # set the default propagator computer + self.prop_computer = propcomp.PropCompDiag(self) + + def initialize_controls(self, amplitudes, init_tslots=True): + # Either the _dyn_gen or _ham names can be used + # This assumes that one or other has been set in the configuration + self._map_dyn_gen_to_ham() + Dynamics.initialize_controls(self, amplitudes, init_tslots=init_tslots) + + def _map_dyn_gen_to_ham(self): + if self.drift_dyn_gen is None: + self.drift_dyn_gen = self.drift_ham + else: + self.drift_ham = self.drift_dyn_gen + if self.ctrl_dyn_gen is None: + self.ctrl_dyn_gen = self.ctrl_ham + else: + self.ctrl_ham = self.ctrl_dyn_gen + self._dyn_gen_mapped = True + + @property + def num_ctrls(self): + if not self._dyn_gen_mapped: + self._map_dyn_gen_to_ham() + if self._num_ctrls is None: + self._num_ctrls = self._get_num_ctrls() + return self._num_ctrls + + def _get_onto_evo_target(self): + """ + Get the adjoint of the target. + Used for calculating the 'backward' evolution + """ + if self.oper_dtype == Qobj: + self._onto_evo_target = self.target.dag() + else: + self._onto_evo_target = self._target.T.conj() + return self._onto_evo_target + + def _spectral_decomp(self, k): + """ + Calculates the diagonalization of the dynamics generator + generating lists of eigenvectors, propagators in the diagonalised + basis, and the 'factormatrix' used in calculating the propagator + gradient + """ + + if self.oper_dtype == Qobj: + H = self._dyn_gen[k] + # Returns eigenvalues as array (row) + # and eigenvectors as rows of an array + _type = _data.CSR if self.sparse_eigen_decomp else _data.Dense + eig_val, eig_vec = _data.eigs(_data.to(_type, H.data)) + eig_vec = eig_vec.to_array() + + elif self.oper_dtype == np.ndarray: + H = self._dyn_gen[k] + # returns row vector of eigenvals, columns with the eigenvecs + eig_val, eig_vec = eigh(H) + + else: + assert False, f"Unknown oper_dtype {self.oper_dtype!r}" + + # assuming H is an nxn matrix, find n + n = self.get_drift_dim() + + # Calculate the propagator in the diagonalised basis + eig_val_tau = -1j * eig_val * self.tau[k] + prop_eig = np.exp(eig_val_tau) + + # Generate the factor matrix through the differences + # between each of the eigenvectors and the exponentiations + # create nxn matrix where each eigen val is repeated n times + # down the columns + o = np.ones([n, n]) + eig_val_cols = eig_val_tau * o + # calculate all the differences by subtracting it from its transpose + eig_val_diffs = eig_val_cols - eig_val_cols.T + # repeat for the propagator + prop_eig_cols = prop_eig * o + prop_eig_diffs = prop_eig_cols - prop_eig_cols.T + # the factor matrix is the elementwise quotient of the + # differeneces between the exponentiated eigen vals and the + # differences between the eigen vals + # need to avoid division by zero that would arise due to denegerate + # eigenvalues and the diagonals + degen_mask = np.abs(eig_val_diffs) < self.fact_mat_round_prec + eig_val_diffs[degen_mask] = 1 + factors = prop_eig_diffs / eig_val_diffs + # for degenerate eigenvalues the factor is just the exponent + factors[degen_mask] = prop_eig_cols[degen_mask] + + # Store eigenvectors, propagator and factor matric + # for use in propagator computations + self._decomp_curr[k] = True + if isinstance(factors, np.ndarray): + self._dyn_gen_factormatrix[k] = factors + else: + self._dyn_gen_factormatrix[k] = np.array(factors) + + if self.oper_dtype == Qobj: + self._prop_eigen[k] = Qobj( + np.diagflat(prop_eig), dims=self.dyn_dims + ) + self._dyn_gen_eigenvectors[k] = Qobj(eig_vec, dims=self.dyn_dims) + # The _dyn_gen_eigenvectors_adj list is not used in + # memory optimised modes + if self._dyn_gen_eigenvectors_adj is not None: + self._dyn_gen_eigenvectors_adj[k] = self._dyn_gen_eigenvectors[ + k + ].dag() + elif self.oper_dtype == np.ndarray: + self._prop_eigen[k] = np.diagflat(prop_eig) + self._dyn_gen_eigenvectors[k] = eig_vec + # The _dyn_gen_eigenvectors_adj list is not used in + # memory optimised modes + if self._dyn_gen_eigenvectors_adj is not None: + self._dyn_gen_eigenvectors_adj[k] = ( + self._dyn_gen_eigenvectors[k].conj().T + ) + else: + assert False, f"Unknown oper_dtype {self.oper_dtype!r}" + + def _get_dyn_gen_eigenvectors_adj(self, k): + # The _dyn_gen_eigenvectors_adj list is not used in + # memory optimised modes + if self._dyn_gen_eigenvectors_adj is not None: + return self._dyn_gen_eigenvectors_adj[k] + if self.oper_dtype == Qobj: + return self._dyn_gen_eigenvectors[k].dag() + return self._dyn_gen_eigenvectors[k].conj().T + + def check_unitarity(self): + """ + Checks whether all propagators are unitary + For propagators found not to be unitary, the potential underlying + causes are investigated. + """ + for k in range(self.num_tslots): + prop_unit = self._is_unitary(self._prop[k]) + if not prop_unit: + logger.warning( + "Progator of timeslot {} is not unitary".format(k) + ) + if not prop_unit or self.unitarity_check_level > 1: + # Check Hamiltonian + H = self._dyn_gen[k] + if isinstance(H, Qobj): + herm = H.isherm + else: + diff = np.abs(H.T.conj() - H) + herm = np.all(diff < settings.core["atol"]) + eigval_unit = self._is_unitary(self._prop_eigen[k]) + eigvec_unit = self._is_unitary(self._dyn_gen_eigenvectors[k]) + if self._dyn_gen_eigenvectors_adj is not None: + eigvecadj_unit = self._is_unitary( + self._dyn_gen_eigenvectors_adj[k] + ) + else: + eigvecadj_unit = None + msg = ( + "prop unit: {}; H herm: {}; " + "eigval unit: {}; eigvec unit: {}; " + "eigvecadj_unit: {}".format( + prop_unit, + herm, + eigval_unit, + eigvec_unit, + eigvecadj_unit, + ) + ) + logger.info(msg) + + +class DynamicsSymplectic(Dynamics): + """ + Symplectic systems + This is the subclass to use for systems where the dynamics is described + by symplectic matrices, e.g. coupled oscillators, quantum optics + + Attributes + ---------- + omega : array[drift_dyn_gen.shape] + matrix used in the calculation of propagators (time evolution) + with symplectic systems. + + """ + + def reset(self): + Dynamics.reset(self) + self.id_text = "SYMPL" + self._omega = None + self._omega_qobj = None + self._phase_application = "postop" + self.grad_exact = True + self.apply_params() + + def _create_computers(self): + """ + Create the default timeslot, fidelity and propagator computers + """ + # The time slot computer. By default it is set to _UpdateAll + # can be set to _DynUpdate in the configuration + # (see class file for details) + if self.config.tslot_type == "DYNAMIC": + self.tslot_computer = tslotcomp.TSlotCompDynUpdate(self) + else: + self.tslot_computer = tslotcomp.TSlotCompUpdateAll(self) + + self.prop_computer = propcomp.PropCompFrechet(self) + self.fid_computer = fidcomp.FidCompTraceDiff(self) + + @property + def omega(self): + if self._omega is None: + self._get_omega() + if self._omega_qobj is None: + self._omega_qobj = Qobj(self._omega, dims=self.dyn_dims) + return self._omega_qobj + + def _get_omega(self): + if self._omega is None: + n = self.get_drift_dim() // 2 + omg = sympl.calc_omega(n) + if self.oper_dtype == Qobj: + self._omega = Qobj(omg, dims=self.dyn_dims) + self._omega_qobj = self._omega + else: + self._omega = omg + return self._omega + + def _set_phase_application(self, value): + Dynamics._set_phase_application(self, value) + if self._evo_initialized: + phase = self._get_dyn_gen_phase() + if phase is not None: + self._dyn_gen_phase = phase + + def _get_dyn_gen_phase(self): + if self._phase_application == "postop": + phase = -self._get_omega() + elif self._phase_application == "preop": + phase = self._get_omega() + elif self._phase_application == "custom": + phase = None + # Assume phase set by user + else: + raise ValueError( + "No option for phase_application " + "'{}'".format(self._phase_application) + ) + return phase + + @property + def dyn_gen_phase(self): + r""" + The phasing operator for the symplectic group generators + usually refered to as \Omega + By default this is applied as 'postop' dyn_gen*-\Omega + If phase_application is 'preop' it is applied as \Omega*dyn_gen + """ + # Cannot be calculated until the dyn_shape is set + # that is after the drift dyn gen has been set. + if self._dyn_gen_phase is None: + self._dyn_gen_phase = self._get_dyn_gen_phase() + return self._dyn_gen_phase \ No newline at end of file diff --git a/src/qutip_qoc/q2/errors.py b/src/qutip_qoc/q2/errors.py new file mode 100644 index 0000000..07fc4ae --- /dev/null +++ b/src/qutip_qoc/q2/errors.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Exception classes for the Quantum Control library +""" + + +class Error(Exception): + """Base class for all qutip control exceptions""" + + def __str__(self): + return repr(self.message) + + +class UsageError(Error): + """ + A function has been used incorrectly. Most likely when a base class + was used when a sub class should have been. + Attributes: + funcname: function name where error occurred + msg: Explanation + """ + + def __init__(self, msg): + self.message = msg + + +class FunctionalError(Error): + """ + A function behaved in an unexpected way + Attributes: + funcname: function name where error occurred + msg: Explanation + """ + + def __init__(self, msg): + self.message = msg + + +class OptimizationTerminate(Error): + """ + Superclass for all early terminations from the optimisation algorithm + """ + + pass + + +class GoalAchievedTerminate(OptimizationTerminate): + """ + Exception raised to terminate execution when the goal has been reached + during the optimisation algorithm + """ + + def __init__(self, fid_err): + self.reason = "Goal achieved" + self.fid_err = fid_err + + +class MaxWallTimeTerminate(OptimizationTerminate): + """ + Exception raised to terminate execution when the optimisation time has + exceeded the maximum set in the config + """ + + def __init__(self): + self.reason = "Max wall time exceeded" + + +class MaxFidFuncCallTerminate(OptimizationTerminate): + """ + Exception raised to terminate execution when the number of calls to the + fidelity error function has exceeded the maximum + """ + + def __init__(self): + self.reason = "Number of fidelity error calls has exceeded the maximum" + + +class GradMinReachedTerminate(OptimizationTerminate): + """ + Exception raised to terminate execution when the minimum gradient normal + has been reached during the optimisation algorithm + """ + + def __init__(self, gradient): + self.reason = "Gradient normal minimum reached" + self.gradient = gradient \ No newline at end of file diff --git a/src/qutip_qoc/q2/fidcomp.py b/src/qutip_qoc/q2/fidcomp.py new file mode 100644 index 0000000..46cc1e1 --- /dev/null +++ b/src/qutip_qoc/q2/fidcomp.py @@ -0,0 +1,798 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Fidelity Computer + +These classes calculate the fidelity error - function to be minimised +and fidelity error gradient, which is used to direct the optimisation + +They may calculate the fidelity as an intermediary step, as in some case +e.g. unitary dynamics, this is more efficient + +The idea is that different methods for computing the fidelity can be tried +and compared using simple configuration switches. + +Note the methods in these classes were inspired by: +DYNAMO - Dynamic Framework for Quantum Optimal Control +See Machnes et.al., arXiv.1011.4874 +The unitary dynamics fidelity is taken directly frm DYNAMO +The other fidelity measures are extensions, and the sources are given +in the class descriptions. +""" + +import timeit +import warnings +import numpy as np + +# QuTiP +from qutip import Qobj + +# QuTiP control modules +import qutip_qtrl.errors as errors + +# QuTiP logging +import qutip_qtrl.logging_utils as logging + +logger = logging.get_logger() + + +def _attrib_deprecation(message, stacklevel=3): + """ + Issue deprecation warning + Using stacklevel=3 will ensure message refers the function + calling with the deprecated parameter, + """ + warnings.warn(message, FutureWarning, stacklevel=stacklevel) + + +def _func_deprecation(message, stacklevel=3): + """ + Issue deprecation warning + Using stacklevel=3 will ensure message refers the function + calling with the deprecated parameter, + """ + warnings.warn(message, FutureWarning, stacklevel=stacklevel) + + +def _trace(A): + """wrapper for calculating the trace""" + # input is an operator (Qobj, array, sparse etc), so + if isinstance(A, Qobj): + return A.tr() + else: + return np.trace(A) + + +class FidelityComputer(object): + """ + Base class for all Fidelity Computers. + This cannot be used directly. See subclass descriptions and choose + one appropriate for the application + Note: this must be instantiated with a Dynamics object, that is the + container for the data that the methods operate on + + Attributes + ---------- + log_level : integer + level of messaging output from the logger. + Options are attributes of qutip_qtrl.logging_utils, + in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, + assuming everything runs as expected. + The default NOTSET implies that the level will be taken from + the QuTiP settings file, which by default is WARN + + dimensional_norm : float + Normalisation constant + + fid_norm_func : function + Used to normalise the fidelity + See SU and PSU options for the unitary dynamics + + grad_norm_func : function + Used to normalise the fidelity gradient + See SU and PSU options for the unitary dynamics + + uses_onwd_evo : boolean + flag to specify whether the onwd_evo evolution operator + (see Dynamics) is used by the FidelityComputer + + uses_onto_evo : boolean + flag to specify whether the onto_evo evolution operator + (see Dynamics) is used by the FidelityComputer + + fid_err : float + Last computed value of the fidelity error + + fidelity : float + Last computed value of the normalised fidelity + + fidelity_current : boolean + flag to specify whether the fidelity / fid_err are based on the + current amplitude values. Set False when amplitudes change + + fid_err_grad: array[num_tslot, num_ctrls] of float + Last computed values for the fidelity error gradients wrt the + control in the timeslot + + grad_norm : float + Last computed value for the norm of the fidelity error gradients + (sqrt of the sum of the squares) + + fid_err_grad_current : boolean + flag to specify whether the fidelity / fid_err are based on the + current amplitude values. Set False when amplitudes change + """ + + def __init__(self, dynamics, params=None): + self.parent = dynamics + self.params = params + self.reset() + + def reset(self): + """ + reset any configuration data and + clear any temporarily held status data + """ + self.log_level = self.parent.log_level + self.id_text = "FID_COMP_BASE" + self.dimensional_norm = 1.0 + self.fid_norm_func = None + self.grad_norm_func = None + self.uses_onwd_evo = False + self.uses_onto_evo = False + self.apply_params() + self.clear() + + def clear(self): + """ + clear any temporarily held status data + """ + self.fid_err = None + self.fidelity = None + self.fid_err_grad = None + self.grad_norm = np.inf + self.fidelity_current = False + self.fid_err_grad_current = False + self.grad_norm = 0.0 + + def apply_params(self, params=None): + """ + Set object attributes based on the dictionary (if any) passed in the + instantiation, or passed as a parameter + This is called during the instantiation automatically. + The key value pairs are the attribute name and value + Note: attributes are created if they do not exist already, + and are overwritten if they do. + """ + if not params: + params = self.params + + if isinstance(params, dict): + self.params = params + for key in params: + setattr(self, key, params[key]) + + @property + def log_level(self): + return logger.level + + @log_level.setter + def log_level(self, lvl): + """ + Set the log_level attribute and set the level of the logger + that is call logger.setLevel(lvl) + """ + logger.setLevel(lvl) + + def init_comp(self): + """ + initialises the computer based on the configuration of the Dynamics + """ + # optionally implemented in subclass + pass + + def get_fid_err(self): + """ + returns the absolute distance from the maximum achievable fidelity + """ + # must be implemented by subclass + raise errors.UsageError( + "No method defined for getting fidelity error." + " Suspect base class was used where sub class should have been" + ) + + def get_fid_err_gradient(self): + """ + Returns the normalised gradient of the fidelity error + in a (nTimeslots x n_ctrls) array wrt the timeslot control amplitude + """ + # must be implemented by subclass + raise errors.UsageError( + "No method defined for getting fidelity" + " error gradient. Suspect base class was" + " used where sub class should have been" + ) + + def flag_system_changed(self): + """ + Flag fidelity and gradients as needing recalculation + """ + self.fidelity_current = False + # Flag gradient as needing recalculating + self.fid_err_grad_current = False + + @property + def uses_evo_t2end(self): + _attrib_deprecation( + "'uses_evo_t2end' has been replaced by 'uses_onwd_evo'" + ) + return self.uses_onwd_evo + + @uses_evo_t2end.setter + def uses_evo_t2end(self, value): + _attrib_deprecation( + "'uses_evo_t2end' has been replaced by 'uses_onwd_evo'" + ) + self.uses_onwd_evo = value + + @property + def uses_evo_t2targ(self): + _attrib_deprecation( + "'uses_evo_t2targ' has been replaced by 'uses_onto_evo'" + ) + return self.uses_onto_evo + + @uses_evo_t2targ.setter + def uses_evo_t2targ(self, value): + _attrib_deprecation( + "'uses_evo_t2targ' has been replaced by 'uses_onto_evo'" + ) + self.uses_onto_evo = value + + +class FidCompUnitary(FidelityComputer): + """ + Computes fidelity error and gradient assuming unitary dynamics, e.g. + closed qubit systems + Note fidelity and gradient calculations were taken from DYNAMO + (see file header) + + Attributes + ---------- + phase_option : string + determines how global phase is treated in fidelity calculations: + PSU - global phase ignored + SU - global phase included + + fidelity_prenorm : complex + Last computed value of the fidelity before it is normalised + It is stored to use in the gradient normalisation calculation + + fidelity_prenorm_current : boolean + flag to specify whether fidelity_prenorm are based on the + current amplitude values. Set False when amplitudes change + """ + + def reset(self): + FidelityComputer.reset(self) + self.id_text = "UNIT" + self.uses_onto_evo = True + self._init_phase_option("PSU") + self.apply_params() + + def clear(self): + FidelityComputer.clear(self) + self.fidelity_prenorm = None + self.fidelity_prenorm_current = False + + def set_phase_option(self, phase_option=None): + """ + Deprecated - use phase_option + Phase options are + SU - global phase important + PSU - global phase is not important + """ + _func_deprecation( + "'set_phase_option' is deprecated. " "Use phase_option property" + ) + self._init_phase_option(phase_option) + + @property + def phase_option(self): + return self._phase_option + + @phase_option.setter + def phase_option(self, value): + """ + Phase options are + SU - global phase important + PSU - global phase is not important + """ + self._init_phase_option(value) + + def _init_phase_option(self, value): + self._phase_option = value + if value == "PSU": + self.fid_norm_func = self.normalize_PSU + self.grad_norm_func = self.normalize_gradient_PSU + elif value == "SU": + self.fid_norm_func = self.normalize_SU + self.grad_norm_func = self.normalize_gradient_SU + elif value is None: + raise errors.UsageError( + "phase_option cannot be set to None" + " for this FidelityComputer." + ) + else: + raise errors.UsageError( + "No option for phase_option '{}'".format(value) + ) + + def init_comp(self): + """ + Check configuration and initialise the normalisation + """ + if self.fid_norm_func is None or self.grad_norm_func is None: + raise errors.UsageError( + "The phase_option must be be set" "for this fidelity computer" + ) + self.init_normalization() + + def flag_system_changed(self): + """ + Flag fidelity and gradients as needing recalculation + """ + FidelityComputer.flag_system_changed(self) + # Flag the fidelity (prenormalisation) value as needing calculation + self.fidelity_prenorm_current = False + + def init_normalization(self): + """ + Calc norm of to scale subsequent norms + When considering unitary time evolution operators, this basically + results in calculating the trace of the identity matrix + and is hence equal to the size of the target matrix + There may be situations where this is not the case, and hence it + is not assumed to be so. + The normalisation function called should be set to either the + PSU - global phase ignored + SU - global phase respected + """ + dyn = self.parent + self.dimensional_norm = 1.0 + self.dimensional_norm = self.fid_norm_func( + dyn.target.dag() * dyn.target + ) + + def normalize_SU(self, A): + try: + if A.shape[0] == A.shape[1]: + # input is an operator (Qobj, array), so + norm = _trace(A) + else: + raise TypeError("Cannot compute trace (not square)") + except AttributeError: + # assume input is already scalar and hence assumed + # to be the prenormalised scalar value, e.g. fidelity + norm = A + return np.real(norm) / self.dimensional_norm + + def normalize_gradient_SU(self, grad): + """ + Normalise the gradient matrix passed as grad + This SU version respects global phase + """ + return np.real(grad) / self.dimensional_norm + + def normalize_PSU(self, A): + try: + if A.shape[0] == A.shape[1]: + # input is an operator (Qobj, array, sparse etc), so + norm = _trace(A) + else: + raise TypeError("Cannot compute trace (not square)") + except (AttributeError, IndexError): + # assume input is already scalar and hence assumed + # to be the prenormalised scalar value, e.g. fidelity + norm = A + return np.abs(norm) / self.dimensional_norm + + def normalize_gradient_PSU(self, grad): + """ + Normalise the gradient matrix passed as grad + This PSU version is independent of global phase + """ + fid_pn = self.get_fidelity_prenorm() + return np.real( + grad * np.exp(-1j * np.angle(fid_pn)) / self.dimensional_norm + ) + + def get_fid_err(self): + """ + Gets the absolute error in the fidelity + """ + return np.abs(1 - self.get_fidelity()) + + def get_fidelity(self): + """ + Gets the appropriately normalised fidelity value + The normalisation is determined by the fid_norm_func pointer + which should be set in the config + """ + if not self.fidelity_current: + self.fidelity = self.fid_norm_func(self.get_fidelity_prenorm()) + self.fidelity_current = True + if self.log_level <= logging.DEBUG: + logger.debug("Fidelity (normalised): {}".format(self.fidelity)) + return self.fidelity + + def get_fidelity_prenorm(self): + """ + Gets the current fidelity value prior to normalisation + Note the gradient function uses this value + The value is cached, because it is used in the gradient calculation + """ + if not self.fidelity_prenorm_current: + dyn = self.parent + k = dyn.tslot_computer._get_timeslot_for_fidelity_calc() + dyn.compute_evolution() + if dyn.oper_dtype == Qobj: + f = dyn._onto_evo[k] * dyn._fwd_evo[k] + if isinstance(f, Qobj): + f = f.tr() + else: + f = _trace(dyn._onto_evo[k].dot(dyn._fwd_evo[k])) + self.fidelity_prenorm = f + self.fidelity_prenorm_current = True + if dyn.stats is not None: + dyn.stats.num_fidelity_computes += 1 + if self.log_level <= logging.DEBUG: + logger.debug( + "Fidelity (pre normalisation): {}".format( + self.fidelity_prenorm + ) + ) + return self.fidelity_prenorm + + def get_fid_err_gradient(self): + """ + Returns the normalised gradient of the fidelity error + in a (nTimeslots x n_ctrls) array + The gradients are cached in case they are requested + mutliple times between control updates + (although this is not typically found to happen) + """ + if not self.fid_err_grad_current: + dyn = self.parent + grad_prenorm = self.compute_fid_grad() + if self.log_level <= logging.DEBUG_INTENSE: + logger.log( + logging.DEBUG_INTENSE, + "pre-normalised fidelity " + "gradients:\n{}".format(grad_prenorm), + ) + # AJGP: Note this check should not be necessary if dynamics are + # unitary. However, if they are not then this gradient + # can still be used, however the interpretation is dubious + if self.get_fidelity() >= 1: + self.fid_err_grad = self.grad_norm_func(grad_prenorm) + else: + self.fid_err_grad = -self.grad_norm_func(grad_prenorm) + + self.fid_err_grad_current = True + if dyn.stats is not None: + dyn.stats.num_grad_computes += 1 + + self.grad_norm = np.sqrt(np.sum(self.fid_err_grad**2)) + if self.log_level <= logging.DEBUG_INTENSE: + logger.log( + logging.DEBUG_INTENSE, + "Normalised fidelity error " + "gradients:\n{}".format(self.fid_err_grad), + ) + + if self.log_level <= logging.DEBUG: + logger.debug( + "Gradient (sum sq norm): " "{} ".format(self.grad_norm) + ) + + return self.fid_err_grad + + def compute_fid_grad(self): + """ + Calculates exact gradient of function wrt to each timeslot + control amplitudes. Note these gradients are not normalised + These are returned as a (nTimeslots x n_ctrls) array + """ + dyn = self.parent + n_ctrls = dyn.num_ctrls + n_ts = dyn.num_tslots + + # create n_ts x n_ctrls zero array for grad start point + grad = np.zeros([n_ts, n_ctrls], dtype=complex) + + dyn.tslot_computer.flag_all_calc_now() + dyn.compute_evolution() + + # loop through all ctrl timeslots calculating gradients + time_st = timeit.default_timer() + for j in range(n_ctrls): + for k in range(n_ts): + fwd_evo = dyn._fwd_evo[k] + onto_evo = dyn._onto_evo[k + 1] + if dyn.oper_dtype == Qobj: + g = onto_evo * dyn._get_prop_grad(k, j) * fwd_evo + if isinstance(g, Qobj): + g = g.tr() + else: + g = _trace( + onto_evo.dot(dyn._get_prop_grad(k, j)).dot(fwd_evo) + ) + grad[k, j] = g + if dyn.stats is not None: + dyn.stats.wall_time_gradient_compute += ( + timeit.default_timer() - time_st + ) + return grad + + +class FidCompTraceDiff(FidelityComputer): + """ + Computes fidelity error and gradient for general system dynamics + by calculating the the fidelity error as the trace of the overlap + of the difference between the target and evolution resulting from + the pulses with the transpose of the same. + This should provide a distance measure for dynamics described by matrices + Note the gradient calculation is taken from: + 'Robust quantum gates for open systems via optimal control: + Markovian versus non-Markovian dynamics' + Frederik F Floether, Pierre de Fouquieres, and Sophie G Schirmer + + Attributes + ---------- + scale_factor : float + The fidelity error calculated is of some arbitary scale. This + factor can be used to scale the fidelity error such that it may + represent some physical measure + If None is given then it is caculated as 1/2N, where N + is the dimension of the drift, when the Dynamics are initialised. + """ + + def reset(self): + FidelityComputer.reset(self) + self.id_text = "TRACEDIFF" + self.scale_factor = None + self.uses_onwd_evo = True + if not self.parent.prop_computer.grad_exact: + raise errors.UsageError( + "This FidelityComputer can only be" + " used with an exact gradient PropagatorComputer." + ) + self.apply_params() + + def init_comp(self): + """ + initialises the computer based on the configuration of the Dynamics + Calculates the scale_factor is not already set + """ + if self.scale_factor is None: + self.scale_factor = 1.0 / (2.0 * self.parent.get_drift_dim()) + if self.log_level <= logging.DEBUG: + logger.debug( + "Scale factor calculated as {}".format(self.scale_factor) + ) + + def get_fid_err(self): + """ + Gets the absolute error in the fidelity + """ + if not self.fidelity_current: + dyn = self.parent + dyn.compute_evolution() + n_ts = dyn.num_tslots + evo_final = dyn._fwd_evo[n_ts] + evo_f_diff = dyn._target - evo_final + if self.log_level <= logging.DEBUG_VERBOSE: + logger.log( + logging.DEBUG_VERBOSE, + "Calculating TraceDiff " + "fidelity...\n Target:\n{}\n Evo final:\n{}\n" + "Evo final diff:\n{}".format( + dyn._target, evo_final, evo_f_diff + ), + ) + + # Calculate the fidelity error using the trace difference norm + # Note that the value should have not imagnary part, so using + # np.real, just avoids the complex casting warning + if dyn.oper_dtype == Qobj: + self.fid_err = self.scale_factor * np.real( + (evo_f_diff.dag() * evo_f_diff).tr() + ) + else: + self.fid_err = self.scale_factor * np.real( + _trace(evo_f_diff.conj().T.dot(evo_f_diff)) + ) + + if np.isnan(self.fid_err): + self.fid_err = np.inf + + if dyn.stats is not None: + dyn.stats.num_fidelity_computes += 1 + + self.fidelity_current = True + if self.log_level <= logging.DEBUG: + logger.debug("Fidelity error: {}".format(self.fid_err)) + + return self.fid_err + + def get_fid_err_gradient(self): + """ + Returns the normalised gradient of the fidelity error + in a (nTimeslots x n_ctrls) array + The gradients are cached in case they are requested + mutliple times between control updates + (although this is not typically found to happen) + """ + if not self.fid_err_grad_current: + dyn = self.parent + self.fid_err_grad = self.compute_fid_err_grad() + self.fid_err_grad_current = True + if dyn.stats is not None: + dyn.stats.num_grad_computes += 1 + + self.grad_norm = np.sqrt(np.sum(self.fid_err_grad**2)) + if self.log_level <= logging.DEBUG_INTENSE: + logger.log( + logging.DEBUG_INTENSE, + "fidelity error gradients:\n" + "{}".format(self.fid_err_grad), + ) + + if self.log_level <= logging.DEBUG: + logger.debug("Gradient norm: " "{} ".format(self.grad_norm)) + + return self.fid_err_grad + + def compute_fid_err_grad(self): + """ + Calculate exact gradient of the fidelity error function + wrt to each timeslot control amplitudes. + Uses the trace difference norm fidelity + These are returned as a (nTimeslots x n_ctrls) array + """ + dyn = self.parent + n_ctrls = dyn.num_ctrls + n_ts = dyn.num_tslots + + # create n_ts x n_ctrls zero array for grad start point + grad = np.zeros([n_ts, n_ctrls]) + + dyn.tslot_computer.flag_all_calc_now() + dyn.compute_evolution() + + # loop through all ctrl timeslots calculating gradients + time_st = timeit.default_timer() + + evo_final = dyn._fwd_evo[n_ts] + evo_f_diff = dyn._target - evo_final + for j in range(n_ctrls): + for k in range(n_ts): + fwd_evo = dyn._fwd_evo[k] + if dyn.oper_dtype == Qobj: + evo_grad = dyn._get_prop_grad(k, j) * fwd_evo + if k + 1 < n_ts: + evo_grad = dyn._onwd_evo[k + 1] * evo_grad + # Note that the value should have not imagnary part, so + # using np.real, just avoids the complex casting warning + g = ( + -2 + * self.scale_factor + * np.real((evo_f_diff.dag() * evo_grad).tr()) + ) + else: + evo_grad = dyn._get_prop_grad(k, j).dot(fwd_evo) + if k + 1 < n_ts: + evo_grad = dyn._onwd_evo[k + 1].dot(evo_grad) + g = ( + -2 + * self.scale_factor + * np.real(_trace(evo_f_diff.conj().T.dot(evo_grad))) + ) + if np.isnan(g): + g = np.inf + + grad[k, j] = g + if dyn.stats is not None: + dyn.stats.wall_time_gradient_compute += ( + timeit.default_timer() - time_st + ) + return grad + + +class FidCompTraceDiffApprox(FidCompTraceDiff): + """ + As FidCompTraceDiff, except uses the finite difference method to + compute approximate gradients + + Attributes + ---------- + epsilon : float + control amplitude offset to use when approximating the gradient wrt + a timeslot control amplitude + """ + + def reset(self): + FidelityComputer.reset(self) + self.id_text = "TDAPPROX" + self.uses_onwd_evo = True + self.scale_factor = None + self.epsilon = 0.001 + self.apply_params() + + def compute_fid_err_grad(self): + """ + Calculates gradient of function wrt to each timeslot + control amplitudes. Note these gradients are not normalised + They are calulated + These are returned as a (nTimeslots x n_ctrls) array + """ + dyn = self.parent + prop_comp = dyn.prop_computer + n_ctrls = dyn.num_ctrls + n_ts = dyn.num_tslots + + if self.log_level >= logging.DEBUG: + logger.debug("Computing fidelity error gradient") + # create n_ts x n_ctrls zero array for grad start point + grad = np.zeros([n_ts, n_ctrls]) + + dyn.tslot_computer.flag_all_calc_now() + dyn.compute_evolution() + curr_fid_err = self.get_fid_err() + + # loop through all ctrl timeslots calculating gradients + time_st = timeit.default_timer() + + for j in range(n_ctrls): + for k in range(n_ts): + fwd_evo = dyn._fwd_evo[k] + prop_eps = prop_comp._compute_diff_prop(k, j, self.epsilon) + if dyn.oper_dtype == Qobj: + evo_final_eps = fwd_evo * prop_eps + if k + 1 < n_ts: + evo_final_eps = evo_final_eps * dyn._onwd_evo[k + 1] + evo_f_diff_eps = dyn._target - evo_final_eps + # Note that the value should have not imagnary part, so + # using np.real, just avoids the complex casting warning + fid_err_eps = self.scale_factor * np.real( + (evo_f_diff_eps.dag() * evo_f_diff_eps).tr() + ) + else: + evo_final_eps = fwd_evo.dot(prop_eps) + if k + 1 < n_ts: + evo_final_eps = evo_final_eps.dot(dyn._onwd_evo[k + 1]) + evo_f_diff_eps = dyn._target - evo_final_eps + fid_err_eps = self.scale_factor * np.real( + _trace(evo_f_diff_eps.conj().T.dot(evo_f_diff_eps)) + ) + + g = (fid_err_eps - curr_fid_err) / self.epsilon + if np.isnan(g): + g = np.inf + + grad[k, j] = g + + if dyn.stats is not None: + dyn.stats.wall_time_gradient_compute += ( + timeit.default_timer() - time_st + ) + + return grad \ No newline at end of file diff --git a/src/qutip_qoc/q2/grape.py b/src/qutip_qoc/q2/grape.py new file mode 100644 index 0000000..ca62fce --- /dev/null +++ b/src/qutip_qoc/q2/grape.py @@ -0,0 +1,716 @@ +""" +This module contains functions that implement the GRAPE algorithm for +calculating pulse sequences for quantum systems. +""" + +__all__ = [ + "plot_grape_control_fields", + "grape_unitary", + "cy_grape_unitary", + "grape_unitary_adaptive", +] + +import warnings +import time +import numpy as np +from scipy.interpolate import interp1d + +from qutip import Qobj, qeye +from qutip.core import data as _data +from qutip.ui.progressbar import BaseProgressBar +from qutip_qoc.q2.cy_grape import cy_overlap, cy_grape_inner + +from qutip_qoc.q2 import logging_utils + +logger = logging_utils.get_logger("qutip.control.grape") + + +class GRAPEResult: + """ + Class for representing the result of a GRAPE simulation. + + Attributes + ---------- + u : array + GRAPE control pulse matrix. + + H_t : time-dependent Hamiltonian + The time-dependent Hamiltonian that realize the GRAPE pulse sequence. + + U_f : Qobj + The final unitary transformation that is realized by the evolution + of the system with the GRAPE generated pulse sequences. + """ + + def __init__(self, u=None, H_t=None, U_f=None): + self.u = u + self.H_t = H_t + self.U_f = U_f + + +def plot_grape_control_fields(times, u, labels, uniform_axes=False): + """ + Plot a series of plots showing the GRAPE control fields given in the + given control pulse matrix u. + + Parameters + ---------- + times : array + Time coordinate array. + + u : array + Control pulse matrix. + + labels : list + List of labels for each control pulse sequence in the control pulse + matrix. + + uniform_axes : bool + Whether or not to plot all pulse sequences using the same y-axis scale. + """ + import matplotlib.pyplot as plt + + R, J, M = u.shape + fig, axes = plt.subplots(J, 1, figsize=(8, 2 * J), squeeze=False) + y_max = abs(u).max() + for r in range(R): + for j in range(J): + if r == R - 1: + lw, lc, alpha = 2.0, "k", 1.0 + axes[j, 0].set_ylabel(labels[j], fontsize=18) + axes[j, 0].set_xlabel(r"$t$", fontsize=18) + axes[j, 0].set_xlim(0, times[-1]) + else: + lw, lc, alpha = 0.5, "b", 0.25 + axes[j, 0].step(times, u[r, j, :], lw=lw, color=lc, alpha=alpha) + if uniform_axes: + axes[j, 0].set_ylim(-y_max, y_max) + fig.tight_layout() + return fig, axes + + +def _overlap(A, B): + return (A.dag() * B).tr() / A.shape[0] + + +def grape_unitary( + U, + H0, + H_ops, + R, + times, + eps=None, + u_start=None, + u_limits=None, + interp_kind="linear", + use_interp=False, + alpha=None, + beta=None, + phase_sensitive=True, + progress_bar=None, +): + """ + Calculate control pulses for the Hamiltonian operators in H_ops so that the + unitary U is realized. + + Experimental: Work in progress. + + Parameters + ---------- + U : Qobj + Target unitary evolution operator. + + H0 : Qobj + Static Hamiltonian (that cannot be tuned by the control fields). + + H_ops: list of Qobj + A list of operators that can be tuned in the Hamiltonian via the + control fields. + + R : int + Number of GRAPE iterations. + + time : array / list + Array of time coordinates for control pulse evalutation. + + u_start : array + Optional array with initial control pulse values. + + Returns + ------- + Instance of GRAPEResult, which contains the control pulses calculated + with GRAPE, a time-dependent Hamiltonian that is defined by the + control pulses, as well as the resulting propagator. + """ + progress_bar = progress_bar or BaseProgressBar(R) + if eps is None: + eps = 0.1 * (2 * np.pi) / (times[-1]) + + M = len(times) + J = len(H_ops) + + u = np.zeros((R, J, M)) + + if u_limits and len(u_limits) != 2: + raise ValueError("u_limits must be a list with two values") + + if u_limits: + warnings.warn("Caution: Using experimental feature u_limits") + + if u_limits and u_start: + # make sure that no values in u0 violates the u_limits conditions + u_start = np.array(u_start) + u_start[u_start < u_limits[0]] = u_limits[0] + u_start[u_start > u_limits[1]] = u_limits[1] + + if u_start is not None: + for idx, u0 in enumerate(u_start): + u[0, idx, :] = u0 + + if beta: + warnings.warn("Causion: Using experimental feature time-penalty") + + for r in range(R - 1): + progress_bar.update() + + dt = times[1] - times[0] + + if use_interp: + ip_funcs = [ + interp1d( + times, + u[r, j, :], + kind=interp_kind, + bounds_error=False, + fill_value=u[r, j, -1], + ) + for j in range(J) + ] + + def _H_t(t, args=None): + return H0 + sum( + float(ip_funcs[j](t)) * H_ops[j] for j in range(J) + ) + + U_list = [ + (-1j * _H_t(times[idx]) * dt).expm() for idx in range(M - 1) + ] + + else: + + def _H_idx(idx): + return H0 + sum([u[r, j, idx] * H_ops[j] for j in range(J)]) + + U_list = [(-1j * _H_idx(idx) * dt).expm() for idx in range(M - 1)] + + U_f_list = [] + U_b_list = [] + + U_f = qeye(U.dims[0]) + U_b = qeye(U.dims[0]) + for n in range(M - 1): + U_f = U_list[n] @ U_f + U_f_list.append(U_f) + U_b_list.insert(0, U_b) + U_b = U_list[M - 2 - n].dag() @ U_b + + for j in range(J): + for m in range(M - 1): + P = U_b_list[m] @ U + Q = 1j * dt * H_ops[j] @ U_f_list[m] + + if phase_sensitive: + du = -_overlap(P, Q) + else: + du = -2 * _overlap(P, Q) * _overlap(U_f_list[m], P) + + if alpha: + # penalty term for high power control signals u + du += -2 * alpha * u[r, j, m] * dt + + if beta: + # penalty term for late control signals u + du += -2 * beta * m * u[r, j, m] * dt + + u[r + 1, j, m] = u[r, j, m] + eps * du.real + + if u_limits: + if u[r + 1, j, m] < u_limits[0]: + u[r + 1, j, m] = u_limits[0] + elif u[r + 1, j, m] > u_limits[1]: + u[r + 1, j, m] = u_limits[1] + + u[r + 1, j, -1] = u[r + 1, j, -2] + + if use_interp: + ip_funcs = [ + interp1d( + times, + u[R - 1, j, :], + kind=interp_kind, + bounds_error=False, + fill_value=u[R - 1, j, -1], + ) + for j in range(J) + ] + + H_td_func = [H0] + [ + [H_ops[j], lambda t, args, j=j: ip_funcs[j](t)] for j in range(J) + ] + else: + H_td_func = [H0] + [[H_ops[j], u[-1, j, :]] for j in range(J)] + + progress_bar.finished() + + # return U_f_list[-1], H_td_func, u + return GRAPEResult(u=u, U_f=U_f_list[-1], H_t=H_td_func) + + +def cy_grape_unitary( + U, + H0, + H_ops, + R, + times, + eps=None, + u_start=None, + u_limits=None, + interp_kind="linear", + use_interp=False, + alpha=None, + beta=None, + phase_sensitive=True, + progress_bar=None, +): + """ + Calculate control pulses for the Hamitonian operators in H_ops so that the + unitary U is realized. + + Experimental: Work in progress. + + Parameters + ---------- + U : Qobj + Target unitary evolution operator. + + H0 : Qobj + Static Hamiltonian (that cannot be tuned by the control fields). + + H_ops: list of Qobj + A list of operators that can be tuned in the Hamiltonian via the + control fields. + + R : int + Number of GRAPE iterations. + + time : array / list + Array of time coordinates for control pulse evalutation. + + u_start : array + Optional array with initial control pulse values. + + Returns + ------- + Instance of GRAPEResult, which contains the control pulses calculated + with GRAPE, a time-dependent Hamiltonian that is defined by the + control pulses, as well as the resulting propagator. + """ + progress_bar = progress_bar or BaseProgressBar(R) + + if eps is None: + eps = 0.1 * (2 * np.pi) / (times[-1]) + + M = len(times) + J = len(H_ops) + + u = np.zeros((R, J, M)) + + H_ops_data = [H_op.data for H_op in H_ops] + + if u_limits and len(u_limits) != 2: + raise ValueError("u_limits must be a list with two values") + + if u_limits: + warnings.warn("Causion: Using experimental feature u_limits") + + if u_limits and u_start: + # make sure that no values in u0 violates the u_limits conditions + u_start = np.array(u_start) + u_start[u_start < u_limits[0]] = u_limits[0] + u_start[u_start > u_limits[1]] = u_limits[1] + + if u_limits: + use_u_limits = 1 + u_min = u_limits[0] + u_max = u_limits[1] + else: + use_u_limits = 0 + u_min = 0.0 + u_max = 0.0 + + if u_start is not None: + for idx, u0 in enumerate(u_start): + u[0, idx, :] = u0 + + if beta: + warnings.warn("Causion: Using experimental feature time-penalty") + + alpha_val = alpha if alpha else 0.0 + beta_val = beta if beta else 0.0 + + for r in range(R - 1): + progress_bar.update() + + dt = times[1] - times[0] + + if use_interp: + ip_funcs = [ + interp1d( + times, + u[r, j, :], + kind=interp_kind, + bounds_error=False, + fill_value=u[r, j, -1], + ) + for j in range(J) + ] + + def _H_t(t, args=None): + return H0 + sum( + [float(ip_funcs[j](t)) * H_ops[j] for j in range(J)] + ) + + U_list = [ + (-1j * _H_t(times[idx]) * dt).expm().data + for idx in range(M - 1) + ] + + else: + + def _H_idx(idx): + return H0 + sum([u[r, j, idx] * H_ops[j] for j in range(J)]) + + U_list = [ + (-1j * _H_idx(idx) * dt).expm().data for idx in range(M - 1) + ] + + U_f_list = [] + U_b_list = [] + + U_f = qeye(U.dims[0]).data + U_b = qeye(U.dims[0]).data + for n in range(M - 1): + U_f = U_list[n] @ U_f + U_f_list.append(U_f) + + U_b_list.insert(0, U_b) + U_b = _data.adjoint(U_list[M - 2 - n]) @ U_b + + cy_grape_inner( + U.data, + u, + r, + J, + M, + U_b_list, + U_f_list, + H_ops_data, + dt, + eps, + alpha_val, + beta_val, + phase_sensitive, + use_u_limits, + u_min, + u_max, + ) + + if use_interp: + ip_funcs = [ + interp1d( + times, + u[R - 1, j, :], + kind=interp_kind, + bounds_error=False, + fill_value=u[R - 1, j, -1], + ) + for j in range(J) + ] + + H_td_func = [H0] + [ + [H_ops[j], lambda t, args, j=j: ip_funcs[j](t)] for j in range(J) + ] + else: + H_td_func = [H0] + [[H_ops[j], u[-1, j, :]] for j in range(J)] + + progress_bar.finished() + + return GRAPEResult(u=u, U_f=Qobj(U_f_list[-1], dims=U.dims), H_t=H_td_func) + + +def grape_unitary_adaptive( + U, + H0, + H_ops, + R, + times, + eps=None, + u_start=None, + u_limits=None, + interp_kind="linear", + use_interp=False, + alpha=None, + beta=None, + phase_sensitive=False, + overlap_terminate=1.0, + progress_bar=None, +): + """ + Calculate control pulses for the Hamiltonian operators in H_ops so that + the unitary U is realized. + + Experimental: Work in progress. + + Parameters + ---------- + U : Qobj + Target unitary evolution operator. + + H0 : Qobj + Static Hamiltonian (that cannot be tuned by the control fields). + + H_ops: list of Qobj + A list of operators that can be tuned in the Hamiltonian via the + control fields. + + R : int + Number of GRAPE iterations. + + time : array / list + Array of time coordinates for control pulse evalutation. + + u_start : array + Optional array with initial control pulse values. + + Returns + ------- + Instance of GRAPEResult, which contains the control pulses calculated + with GRAPE, a time-dependent Hamiltonian that is defined by the + control pulses, as well as the resulting propagator. + """ + progress_bar = progress_bar or BaseProgressBar(R) + + if eps is None: + eps = 0.1 * (2 * np.pi) / (times[-1]) + + eps_vec = np.array([eps / 2, eps, 2 * eps]) + eps_log = np.zeros(R) + overlap_log = np.zeros(R) + + best_k = 0 + _k_overlap = np.array([0.0, 0.0, 0.0]) + + M = len(times) + J = len(H_ops) + K = len(eps_vec) + Uf = [None for _ in range(K)] + + u = np.zeros((R, J, M, K)) + + if u_limits and len(u_limits) != 2: + raise ValueError("u_limits must be a list with two values") + + if u_limits: + warnings.warn("Causion: Using experimental feature u_limits") + + if u_limits and u_start: + # make sure that no values in u0 violates the u_limits conditions + u_start = np.array(u_start) + u_start[u_start < u_limits[0]] = u_limits[0] + u_start[u_start > u_limits[1]] = u_limits[1] + + if u_start is not None: + for idx, u0 in enumerate(u_start): + for k in range(K): + u[0, idx, :, k] = u0 + + if beta: + warnings.warn("Causion: Using experimental feature time-penalty") + + if phase_sensitive: + + def _fidelity_function(x): + return x + + else: + + def _fidelity_function(x): + return abs(x) ** 2 + + best_k = 1 + _r = 0 + _prev_overlap = 0 + + for r in range(R - 1): + progress_bar.update() + + _r = r + eps_log[r] = eps_vec[best_k] + + logger.debug("eps_vec: {}".format(eps_vec)) + + _t0 = time.time() + + dt = times[1] - times[0] + + def _H_idx(r, idx, k): + return H0 + sum([u[r, j, idx, k] * H_ops[j] for j in range(J)]) + + if use_interp: + ip_funcs = [ + interp1d( + times, + u[r, j, :, best_k], + kind=interp_kind, + bounds_error=False, + fill_value=u[r, j, -1, best_k], + ) + for j in range(J) + ] + + def _H_t(t, args=None): + return H0 + sum( + [float(ip_funcs[j](t)) * H_ops[j] for j in range(J)] + ) + + U_list = [ + (-1j * _H_t(times[idx]) * dt).expm() for idx in range(M - 1) + ] + + else: + U_list = [ + (-1j * _H_idx(r, idx, best_k) * dt).expm() + for idx in range(M - 1) + ] + + logger.debug("Time 1: %fs" % (time.time() - _t0)) + _t0 = time.time() + + U_f_list = [] + U_b_list = [] + + U_f = qeye(U.dims[0]) + U_b = qeye(U.dims[0]) + for m in range(M - 1): + U_f = U_list[m] @ U_f + U_f_list.append(U_f) + + U_b_list.insert(0, U_b) + U_b = U_list[M - 2 - m].dag() * U_b + + logger.debug("Time 2: %fs" % (time.time() - _t0)) + _t0 = time.time() + + for j in range(J): + for m in range(M - 1): + P = U_b_list[m] @ U + Q = 1j * dt * H_ops[j] * U_f_list[m] + + if phase_sensitive: + du = -cy_overlap(P.data, Q.data) + else: + du = ( + -2 + * cy_overlap(P.data, Q.data) + * cy_overlap(U_f_list[m].data, P.data) + ) + + if alpha: + # penalty term for high power control signals u + du += -2 * alpha * u[r, j, m, best_k] * dt + + if beta: + # penalty term for late control signals u + du += -2 * beta * k**2 * u[r, j, k] * dt + + for k, eps_val in enumerate(eps_vec): + u[r + 1, j, m, k] = u[r, j, m, k] + eps_val * du.real + + if u_limits: + if u[r + 1, j, m, k] < u_limits[0]: + u[r + 1, j, m, k] = u_limits[0] + elif u[r + 1, j, m, k] > u_limits[1]: + u[r + 1, j, m, k] = u_limits[1] + + u[r + 1, j, -1, :] = u[r + 1, j, -2, :] + + logger.debug("Time 3: %fs", time.time() - _t0) + _t0 = time.time() + + for k, eps_val in enumerate(eps_vec): + U_list = [ + (-1j * _H_idx(r + 1, idx, k) * dt).expm() + for idx in range(M - 1) + ] + + Uf[k] = qeye(U.dims[0]) + for m in range(M - 1): + Uf[k] = U_list[m] @ Uf[k] + + _k_overlap[k] = _fidelity_function( + cy_overlap(Uf[k].data, U.data) + ).real + + best_k = np.argmax(_k_overlap) + logger.debug("k_overlap: %s, %e", repr(_k_overlap), best_k) + + if _prev_overlap > _k_overlap[best_k]: + logger.debug("Regression, stepping back with smaller eps.") + + u[r + 1, :, :, :] = u[r, :, :, :] + eps_vec /= 2 + else: + if best_k == 0: + eps_vec /= 2 + + elif best_k == 2: + eps_vec *= 2 + + _prev_overlap = _k_overlap[best_k] + + overlap_log[r] = _k_overlap[best_k] + + if overlap_terminate < 1.0: + if _k_overlap[best_k] > overlap_terminate: + logger.info("Reached target fidelity, terminating.") + break + + logger.debug("Time 4: %fs", time.time() - _t0) + _t0 = time.time() + + if use_interp: + ip_funcs = [ + interp1d( + times, + u[_r, j, :, best_k], + kind=interp_kind, + bounds_error=False, + fill_value=u[R - 1, j, -1], + ) + for j in range(J) + ] + + H_td_func = [H0] + [ + [H_ops[j], lambda t, args, j=j: ip_funcs[j](t)] for j in range(J) + ] + else: + H_td_func = [H0] + [[H_ops[j], u[_r, j, :, best_k]] for j in range(J)] + + progress_bar.finished() + + result = GRAPEResult(u=u[:_r, :, :, best_k], U_f=Uf[best_k], H_t=H_td_func) + + result.eps = eps_log + result.overlap = overlap_log + + return result \ No newline at end of file diff --git a/src/qutip_qoc/q2/logging_utils.py b/src/qutip_qoc/q2/logging_utils.py new file mode 100644 index 0000000..32cac2c --- /dev/null +++ b/src/qutip_qoc/q2/logging_utils.py @@ -0,0 +1,114 @@ +""" +This module contains internal-use functions for configuring and writing to +debug logs, using Python's internal logging functionality by default. +""" + +# IMPORTS +from __future__ import absolute_import +import inspect +import logging + +from qutip.settings import settings + +# EXPORTS +NOTSET = logging.NOTSET +DEBUG_INTENSE = logging.DEBUG - 4 +DEBUG_VERBOSE = logging.DEBUG - 2 +DEBUG = logging.DEBUG +INFO = logging.INFO +WARN = logging.WARN +ERROR = logging.ERROR +CRITICAL = logging.CRITICAL + +__all__ = ["get_logger"] + +# META-LOGGING + +metalogger = logging.getLogger(__name__) +metalogger.addHandler(logging.NullHandler()) + + +# FUNCTIONS + + +def get_logger(name=None): + """ + Returns a Python logging object with handlers configured + in accordance with ~/.qutiprc. By default, this will do + something sensible to integrate with IPython when running + in that environment, and will print to stdout otherwise. + + Note that this function uses a bit of magic, and thus should + not be considered part of the QuTiP API. Rather, this function + is for internal use only. + + Parameters + ---------- + + name : str + Name of the logger to be created. If not passed, + the name will automatically be set to the name of the + calling module. + """ + if name is None: + try: + calling_frame = inspect.stack()[1][0] + calling_module = inspect.getmodule(calling_frame) + name = ( + calling_module.__name__ + if calling_module is not None + else "" + ) + + except Exception: + metalogger.warn("Error creating logger.", exc_info=1) + name = "" + + logger = logging.getLogger(name) + + policy = settings._log_handler + + if policy == "default": + # Let's try to see if we're in IPython mode. + policy = "basic" if settings.ipython else "stream" + + metalogger.debug( + "Creating logger for {} with policy {}.".format(name, policy) + ) + + if policy == "basic": + # Add no handlers, just let basicConfig do it all. + # This is nice for working with IPython, since + # it will use its own handlers instead of our StreamHandler + # below. + if settings._debug: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig() + + elif policy == "stream": + formatter = logging.Formatter( + "[%(asctime)s] %(name)s[%(process)s]: " + "%(funcName)s: %(levelname)s: %(message)s", + "%Y-%m-%d %H:%M:%S", + ) + handler = logging.StreamHandler() + handler.setFormatter(formatter) + logger.addHandler(handler) + + # We're handling things here, so no propagation out. + logger.propagate = False + + elif policy == "null": + # We need to add a NullHandler so that debugging works + # at all, but this policy leaves it to the user to + # make their own handlers. This is particularly useful + # for capturing to logfiles. + logger.addHandler(logging.NullHandler()) + + if settings._debug: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.WARN) + + return logger \ No newline at end of file diff --git a/src/qutip_qoc/q2/optimconfig.py b/src/qutip_qoc/q2/optimconfig.py new file mode 100644 index 0000000..0beedf4 --- /dev/null +++ b/src/qutip_qoc/q2/optimconfig.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Configuration parameters for control pulse optimisation +""" + +import numpy as np + +# QuTiP logging +import qutip_qtrl.logging_utils + +logger = qutip_qtrl.logging_utils.get_logger("qutip.control.optimconfig") +import qutip_qtrl.io as qtrlio + + +class OptimConfig(object): + """ + Configuration parameters for control pulse optimisation + + Attributes + ---------- + log_level : integer + level of messaging output from the logger. + Options are attributes of qutip_qtrl.logging_utils, + in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, + assuming everything runs as expected. + The default NOTSET implies that the level will be taken from + the QuTiP settings file, which by default is WARN + + dyn_type : string + Dynamics type, i.e. the type of matrix used to describe + the dynamics. Options are UNIT, GEN_MAT, SYMPL + (see Dynamics classes for details) + + prop_type : string + Propagator type i.e. the method used to calculate the + propagtors and propagtor gradient for each timeslot + options are DEF, APPROX, DIAG, FRECHET, AUG_MAT + DEF will use the default for the specific dyn_type + (see PropagatorComputer classes for details) + + fid_type : string + Fidelity error (and fidelity error gradient) computation method + Options are DEF, UNIT, TRACEDIFF, TD_APPROX + DEF will use the default for the specific dyn_type + (See FidelityComputer classes for details) + """ + + def __init__(self): + self.reset() + + def reset(self): + self.log_level = logger.getEffectiveLevel() + self.alg = "GRAPE" # Alts: 'CRAB' + self.optim_method = "DEF" + self.dyn_type = "DEF" + self.fid_type = "DEF" + self.fid_type = "DEF" + self.tslot_type = "DEF" + self.init_pulse_type = "DEF" + + @property + def log_level(self): + return logger.level + + @log_level.setter + def log_level(self, lvl): + """ + Set the log_level attribute and set the level of the logger + that is call logger.setLevel(lvl) + """ + logger.setLevel(lvl) + + def check_create_output_dir(self, output_dir, desc="output"): + """ + Checks if the given directory exists, if not it is created. + + Returns + ------- + dir_ok : boolean + True if directory exists (previously or created) + False if failed to create the directory + + output_dir : string + Path to the directory, which may be been made absolute + + msg : string + Error msg if directory creation failed + """ + return qtrlio.create_dir(output_dir, desc=desc) + + +# create global instance +optimconfig = OptimConfig() \ No newline at end of file diff --git a/src/qutip_qoc/q2/optimizer.py b/src/qutip_qoc/q2/optimizer.py new file mode 100644 index 0000000..3afad32 --- /dev/null +++ b/src/qutip_qoc/q2/optimizer.py @@ -0,0 +1,1365 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Classes here are expected to implement a run_optimization function +that will use some method for optimising the control pulse, as defined +by the control amplitudes. The system that the pulse acts upon are defined +by the Dynamics object that must be passed in the instantiation. + +The methods are typically N dimensional function optimisers that +find the minima of a fidelity error function. Note the number of variables +for the fidelity function is the number of control timeslots, +i.e. n_ctrls x Ntimeslots +The methods will call functions on the Dynamics.fid_computer object, +one or many times per interation, +to get the fidelity error and gradient wrt to the amplitudes. +The optimisation will stop when one of the termination conditions are met, +for example: the fidelity aim has be reached, a local minima has been found, +the maximum time allowed has been exceeded + +These function optimisation methods are so far from SciPy.optimize +The two methods implemented are: + + BFGS - Broyden-Fletcher-Goldfarb-Shanno algorithm + + This a quasi second order Newton method. It uses successive calls to + the gradient function to make an estimation of the curvature (Hessian) + and hence direct its search for the function minima + The SciPy implementation is pure Python and hance is execution speed is + not high + use subclass: OptimizerBFGS + + L-BFGS-B - Bounded, limited memory BFGS + + This a version of the BFGS method where the Hessian approximation is + only based on a set of the most recent gradient calls. It generally + performs better where the are a large number of variables + The SciPy implementation of L-BFGS-B is wrapper around a well + established and actively maintained implementation in Fortran + Its is therefore very fast. + # See SciPy documentation for credit and details on the + # scipy.optimize.fmin_l_bfgs_b function + use subclass: OptimizerLBFGSB + +The baseclass Optimizer implements the function wrappers to the +fidelity error, gradient, and iteration callback functions. +These are called from the within the SciPy optimisation functions. +The subclasses implement the algorithm specific pulse optimisation function. +""" + +import functools +import numpy as np +import timeit +import warnings +from packaging.version import parse as _parse_version +import scipy +import scipy.optimize as spopt +import copy +import collections +import timeit + +import numpy as np +import scipy.optimize as spopt + +from qutip import Qobj +import qutip_qtrl.optimresult as optimresult +import qutip_qtrl.termcond as termcond +import qutip_qtrl.errors as errors +import qutip_qtrl.dynamics as dynamics +import qutip_qtrl.pulsegen as pulsegen +import qutip_qtrl.dump as qtrldump + +import qutip_qtrl.logging_utils as logging + +logger = logging.get_logger() + +# Older versions of SciPy use the method numpy.ndarray.tostring(), which has +# been deprecated since Numpy 1.19 in favour of the identical-in-all-but-name +# tobytes() method. This is simply a deprecated call in SciPy, there's nothing +# we or our users can do about it, and the function shouldn't actually be +# removed from Numpy until at least 1.22, by which point we'll have been able +# to drop support for SciPy 1.4. +if _parse_version(scipy.__version__) < _parse_version("1.5"): + + @functools.wraps(spopt.fmin_l_bfgs_b) + def fmin_l_bfgs_b(*args, **kwargs): + with warnings.catch_warnings(): + message = r"tostring\(\) is deprecated\. Use tobytes\(\) instead\." + warnings.filterwarnings( + "ignore", message=message, category=DeprecationWarning + ) + return spopt.fmin_l_bfgs_b(*args, **kwargs) + +else: + fmin_l_bfgs_b = spopt.fmin_l_bfgs_b + + +def _is_string(var): + try: + if isinstance(var, str): + return True + except NameError: + try: + if isinstance(var, str): + return True + except: + return False + except: + return False + + +class Optimizer(object): + """ + Base class for all control pulse optimisers. This class should not be + instantiated, use its subclasses. This class implements the fidelity, + gradient and interation callback functions. All subclass objects must be + initialised with a + + - ``OptimConfig`` instance - various configuration options + - ``Dynamics`` instance - describes the dynamics of the (quantum) system + to be control optimised + + Attributes + ---------- + log_level : integer + level of messaging output from the logger. Options are attributes of + qutip_qtrl.logging_utils, in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, assuming + everything runs as expected. The default NOTSET implies that the level + will be taken from the QuTiP settings file, which by default is WARN. + + params: Dictionary + The key value pairs are the attribute name and value. Note: attributes + are created if they do not exist already, and are overwritten if they + do. + + alg : string + Algorithm to use in pulse optimisation. Options are: + + - 'GRAPE' (default) - GRadient Ascent Pulse Engineering + - 'CRAB' - Chopped RAndom Basis + + alg_params : Dictionary + Options that are specific to the pulse optim algorithm ``alg``. + + disp_conv_msg : bool + Set true to display a convergence message + (for scipy.optimize.minimize methods anyway) + + optim_method : string + a scipy.optimize.minimize method that will be used to optimise + the pulse for minimum fidelity error + + method_params : Dictionary + Options for the optim_method. + Note that where there is an equivalent attribute of this instance + or the termination_conditions (for example maxiter) + it will override an value in these options + + approx_grad : bool + If set True then the method will approximate the gradient itself + (if it has requirement and facility for this) + This will mean that the fid_err_grad_wrapper will not get called + Note it should be left False when using the Dynamics + to calculate approximate gradients + Note it is set True automatically when the alg is CRAB + + amp_lbound : float or list of floats + lower boundaries for the control amplitudes + Can be a scalar value applied to all controls + or a list of bounds for each control + + amp_ubound : float or list of floats + upper boundaries for the control amplitudes + Can be a scalar value applied to all controls + or a list of bounds for each control + + bounds : List of floats + Bounds for the parameters. + If not set before the run_optimization call then the list + is built automatically based on the amp_lbound and amp_ubound + attributes. + Setting this attribute directly allows specific bounds to be set + for individual parameters. + Note: Only some methods use bounds + + dynamics : Dynamics (subclass instance) + describes the dynamics of the (quantum) system to be control optimised + (see Dynamics classes for details) + + config : OptimConfig instance + various configuration options + (see OptimConfig for details) + + termination_conditions : TerminationCondition instance + attributes determine when the optimisation will end + + pulse_generator : PulseGen (subclass instance) + (can be) used to create initial pulses + not used by the class, but set by pulseoptim.create_pulse_optimizer + + stats : Stats + attributes of which give performance stats for the optimisation + set to None to reduce overhead of calculating stats. + Note it is (usually) shared with the Dynamics instance + + dump : :class:`qutip_qtrl.dump.OptimDump` + Container for data dumped during the optimisation. + Can be set by specifying the dumping level or set directly. + Note this is mainly intended for user and a development debugging + but could be used for status information during a long optimisation. + + dumping : string + level of data dumping: NONE, SUMMARY, FULL or CUSTOM + See property docstring for details + + dump_to_file : bool + If set True then data will be dumped to file during the optimisation + dumping will be set to SUMMARY during init_optim + if dump_to_file is True and dumping not set. + Default is False + + dump_dir : string + Basically a link to dump.dump_dir. Exists so that it can be set through + optim_params. + If dump is None then will return None or will set dumping to SUMMARY + when setting a path + + iter_summary : :class:`OptimIterSummary` + Summary of the most recent iteration. + Note this is only set if dummping is on + """ + + def __init__(self, config, dyn, params=None): + self.dynamics = dyn + self.config = config + self.params = params + self.reset() + dyn.parent = self + + def reset(self): + self.log_level = self.config.log_level + self.id_text = "OPTIM" + self.termination_conditions = None + self.pulse_generator = None + self.disp_conv_msg = False + self.iteration_steps = None + self.record_iteration_steps = False + self.alg = "GRAPE" + self.alg_params = None + self.method = "l_bfgs_b" + self.method_params = None + self.method_options = None + self.approx_grad = False + self.amp_lbound = None + self.amp_ubound = None + self.bounds = None + self.num_iter = 0 + self.num_fid_func_calls = 0 + self.num_grad_func_calls = 0 + self.stats = None + self.wall_time_optim_start = 0.0 + + self.dump_to_file = False + self.dump = None + self.iter_summary = None + + # AJGP 2015-04-21: + # These (copying from config) are here for backward compatibility + if hasattr(self.config, "amp_lbound"): + if self.config.amp_lbound: + self.amp_lbound = self.config.amp_lbound + if hasattr(self.config, "amp_ubound"): + if self.config.amp_ubound: + self.amp_ubound = self.config.amp_ubound + + self.apply_params() + + @property + def log_level(self): + return logger.level + + @log_level.setter + def log_level(self, lvl): + """ + Set the log_level attribute and set the level of the logger + that is call logger.setLevel(lvl) + """ + logger.setLevel(lvl) + + def apply_params(self, params=None): + """ + Set object attributes based on the dictionary (if any) passed in the + instantiation, or passed as a parameter + This is called during the instantiation automatically. + The key value pairs are the attribute name and value + Note: attributes are created if they do not exist already, + and are overwritten if they do. + """ + if not params: + params = self.params + + if isinstance(params, dict): + self.params = params + for key in params: + setattr(self, key, params[key]) + + @property + def dumping(self): + """ + The level of data dumping that will occur during the optimisation + + - NONE : No processing data dumped (Default) + - SUMMARY : A summary at each iteration will be recorded + - FULL : All logs will be generated and dumped + - CUSTOM : Some customised level of dumping + + When first set to CUSTOM this is equivalent to SUMMARY. It is then up + to the user to specify which logs are dumped + """ + if self.dump is None: + lvl = "NONE" + else: + lvl = self.dump.level + + return lvl + + @dumping.setter + def dumping(self, value): + if value is None: + self.dump = None + else: + if not isinstance(value, str): + raise TypeError("Value must be string value") + lvl = value.upper() + if lvl == "NONE": + self.dump = None + else: + if not isinstance(self.dump, qtrldump.OptimDump): + self.dump = qtrldump.OptimDump(self, level=lvl) + else: + self.dump.level = lvl + + @property + def dump_dir(self): + if self.dump: + return self.dump.dump_dir + else: + return None + + @dump_dir.setter + def dump_dir(self, value): + if not self.dump: + self.dumping = "SUMMARY" + self.dump.dump_dir = value + + def _create_result(self): + """ + create the result object + and set the initial_amps attribute as the current amplitudes + """ + result = optimresult.OptimResult() + result.initial_fid_err = self.dynamics.fid_computer.get_fid_err() + result.initial_amps = self.dynamics.ctrl_amps.copy() + result.evo_full_initial = self.dynamics.full_evo.copy() + result.time = self.dynamics.time.copy() + result.optimizer = self + return result + + def init_optim(self, term_conds): + """ + Check optimiser attribute status and passed parameters before + running the optimisation. + This is called by run_optimization, but could called independently + to check the configuration. + """ + if term_conds is not None: + self.termination_conditions = term_conds + term_conds = self.termination_conditions + + if not isinstance(term_conds, termcond.TerminationConditions): + raise errors.UsageError( + "No termination conditions for the " "optimisation function" + ) + + if not isinstance(self.dynamics, dynamics.Dynamics): + raise errors.UsageError("No dynamics object attribute set") + self.dynamics.check_ctrls_initialized() + + self.apply_method_params() + + if term_conds.fid_err_targ is None and term_conds.fid_goal is None: + raise errors.UsageError( + "Either the goal or the fidelity " + "error tolerance must be set" + ) + + if term_conds.fid_err_targ is None: + term_conds.fid_err_targ = np.abs(1 - term_conds.fid_goal) + + if term_conds.fid_goal is None: + term_conds.fid_goal = 1 - term_conds.fid_err_targ + + if self.alg == "CRAB": + self.approx_grad = True + + if self.stats is not None: + self.stats.clear() + + if self.dump_to_file: + if self.dump is None: + self.dumping = "SUMMARY" + self.dump.write_to_file = True + self.dump.create_dump_dir() + logger.info( + "Optimiser dump will be written to:\n{}".format( + self.dump.dump_dir + ) + ) + + if self.dump: + self.iter_summary = OptimIterSummary() + else: + self.iter_summary = None + + self.num_iter = 0 + self.num_fid_func_calls = 0 + self.num_grad_func_calls = 0 + self.iteration_steps = None + + def _build_method_options(self): + """ + Creates the method_options dictionary for the scipy.optimize.minimize + function based on the attributes of this object and the + termination_conditions + It assumes that apply_method_params has already been run and + hence the method_options attribute may already contain items. + These values will NOT be overridden + """ + tc = self.termination_conditions + if self.method_options is None: + self.method_options = {} + mo = self.method_options + + if "max_metric_corr" in mo and "maxcor" not in mo: + mo["maxcor"] = mo["max_metric_corr"] + elif hasattr(self, "max_metric_corr") and "maxcor" not in mo: + mo["maxcor"] = self.max_metric_corr + if "accuracy_factor" in mo and "ftol" not in mo: + mo["ftol"] = mo["accuracy_factor"] + elif hasattr(tc, "accuracy_factor") and "ftol" not in mo: + mo["ftol"] = tc.accuracy_factor + if tc.max_iterations > 0 and "maxiter" not in mo: + mo["maxiter"] = tc.max_iterations + if tc.max_fid_func_calls > 0 and "maxfev" not in mo: + mo["maxfev"] = tc.max_fid_func_calls + if tc.min_gradient_norm > 0 and "gtol" not in mo: + mo["gtol"] = tc.min_gradient_norm + if "disp" not in mo: + mo["disp"] = self.disp_conv_msg + return mo + + def apply_method_params(self, params=None): + """ + Loops through all the method_params + (either passed here or the method_params attribute) + If the name matches an attribute of this object or the + termination conditions object, then the value of this attribute + is set. Otherwise it is assumed to a method_option for the + scipy.optimize.minimize function + """ + if not params: + params = self.method_params + + if isinstance(params, dict): + self.method_params = params + unused_params = {} + for key in params: + val = params[key] + if hasattr(self, key): + setattr(self, key, val) + elif hasattr(self.termination_conditions, key): + setattr(self.termination_conditions, key, val) + else: + unused_params[key] = val + + if len(unused_params) > 0: + if not isinstance(self.method_options, dict): + self.method_options = unused_params + else: + self.method_options.update(unused_params) + + def _build_bounds_list(self): + cfg = self.config + dyn = self.dynamics + n_ctrls = dyn.num_ctrls + self.bounds = [] + for t in range(dyn.num_tslots): + for c in range(n_ctrls): + if isinstance(self.amp_lbound, list): + lb = self.amp_lbound[c] + else: + lb = self.amp_lbound + if isinstance(self.amp_ubound, list): + ub = self.amp_ubound[c] + else: + ub = self.amp_ubound + + if lb is not None and np.isinf(lb): + lb = None + if ub is not None and np.isinf(ub): + ub = None + + self.bounds.append((lb, ub)) + + def run_optimization(self, term_conds=None): + """ + This default function optimisation method is a wrapper to the + scipy.optimize.minimize function. + + It will attempt to minimise the fidelity error with respect to some + parameters, which are determined by _get_optim_var_vals (see below) + + The optimisation end when one of the passed termination conditions + has been met, e.g. target achieved, wall time, or + function call or iteration count exceeded. Note these + conditions include gradient minimum met (local minima) for + methods that use a gradient. + + The function minimisation method is taken from the optim_method + attribute. Note that not all of these methods have been tested. + Note that some of these use a gradient and some do not. + See the scipy documentation for details. Options specific to the + method can be passed setting the method_params attribute. + + If the parameter term_conds=None, then the termination_conditions + attribute must already be set. It will be overwritten if the + parameter is not None + + The result is returned in an OptimResult object, which includes + the final fidelity, time evolution, reason for termination etc + """ + self.init_optim(term_conds) + term_conds = self.termination_conditions + dyn = self.dynamics + cfg = self.config + self.optim_var_vals = self._get_optim_var_vals() + st_time = timeit.default_timer() + self.wall_time_optimize_start = st_time + + if self.stats is not None: + self.stats.wall_time_optim_start = st_time + self.stats.wall_time_optim_end = 0.0 + self.stats.num_iter = 0 + + if self.bounds is None: + self._build_bounds_list() + + self._build_method_options() + + result = self._create_result() + + if self.approx_grad: + jac = None + else: + jac = self.fid_err_grad_wrapper + + if self.log_level <= logging.INFO: + msg = ( + "Optimising pulse(s) using {} with " "minimise '{}' method" + ).format(self.alg, self.method) + if self.approx_grad: + msg += " (approx grad)" + logger.info(msg) + + try: + opt_res = spopt.minimize( + self.fid_err_func_wrapper, + self.optim_var_vals, + method=self.method, + jac=jac, + bounds=self.bounds, + options=self.method_options, + callback=self.iter_step_callback_func, + ) + + amps = self._get_ctrl_amps(opt_res.x) + dyn.update_ctrl_amps(amps) + result.termination_reason = opt_res.message + # Note the iterations are counted in this object as well + # so there are compared here for interest sake only + if self.num_iter != opt_res.nit: + logger.info( + "The number of iterations counted {} " + " does not match the number reported {} " + "by {}".format(self.num_iter, opt_res.nit, self.method) + ) + result.num_iter = opt_res.nit + + except errors.OptimizationTerminate as except_term: + self._interpret_term_exception(except_term, result) + + end_time = timeit.default_timer() + self._add_common_result_attribs(result, st_time, end_time) + + return result + + def _get_optim_var_vals(self): + """ + Generate the 1d array that holds the current variable values + of the function to be optimised + By default (as used in GRAPE) these are the control amplitudes + in each timeslot + """ + return self.dynamics.ctrl_amps.reshape([-1]) + + def _get_ctrl_amps(self, optim_var_vals): + """ + Get the control amplitudes from the current variable values + of the function to be optimised. + that is the 1d array that is passed from the optimisation method + Note for GRAPE these are the function optimiser parameters + (and this is the default) + + Returns + ------- + float array[dynamics.num_tslots, dynamics.num_ctrls] + """ + amps = optim_var_vals.reshape(self.dynamics.ctrl_amps.shape) + + return amps + + def fid_err_func_wrapper(self, *args): + """ + Get the fidelity error achieved using the ctrl amplitudes passed + in as the first argument. + + This is called by generic optimisation algorithm as the + func to the minimised. The argument is the current + variable values, i.e. control amplitudes, passed as + a flat array. Hence these are reshaped as [nTimeslots, n_ctrls] + and then used to update the stored ctrl values (if they have changed) + + The error is checked against the target, and the optimisation is + terminated if the target has been achieved. + """ + self.num_fid_func_calls += 1 + # *** update stats *** + if self.stats is not None: + self.stats.num_fidelity_func_calls = self.num_fid_func_calls + if self.log_level <= logging.DEBUG: + logger.debug( + "fidelity error call {}".format( + self.stats.num_fidelity_func_calls + ) + ) + + amps = self._get_ctrl_amps(args[0].copy()) + self.dynamics.update_ctrl_amps(amps) + + tc = self.termination_conditions + err = self.dynamics.fid_computer.get_fid_err() + + if self.iter_summary: + self.iter_summary.fid_func_call_num = self.num_fid_func_calls + self.iter_summary.fid_err = err + + if self.dump and self.dump.dump_fid_err: + self.dump.update_fid_err_log(err) + + if err <= tc.fid_err_targ: + raise errors.GoalAchievedTerminate(err) + + if self.num_fid_func_calls > tc.max_fid_func_calls: + raise errors.MaxFidFuncCallTerminate() + + return err + + def fid_err_grad_wrapper(self, *args): + """ + Get the gradient of the fidelity error with respect to all of the + variables, i.e. the ctrl amplidutes in each timeslot + + This is called by generic optimisation algorithm as the gradients of + func to the minimised wrt the variables. The argument is the current + variable values, i.e. control amplitudes, passed as + a flat array. Hence these are reshaped as [nTimeslots, n_ctrls] + and then used to update the stored ctrl values (if they have changed) + + Although the optimisation algorithms have a check within them for + function convergence, i.e. local minima, the sum of the squares + of the normalised gradient is checked explicitly, and the + optimisation is terminated if this is below the min_gradient_norm + condition + """ + # *** update stats *** + self.num_grad_func_calls += 1 + if self.stats is not None: + self.stats.num_grad_func_calls = self.num_grad_func_calls + if self.log_level <= logging.DEBUG: + logger.debug( + "gradient call {}".format(self.stats.num_grad_func_calls) + ) + amps = self._get_ctrl_amps(args[0].copy()) + self.dynamics.update_ctrl_amps(amps) + fid_comp = self.dynamics.fid_computer + # gradient_norm_func is a pointer to the function set in the config + # that returns the normalised gradients + grad = fid_comp.get_fid_err_gradient() + + if self.iter_summary: + self.iter_summary.grad_func_call_num = self.num_grad_func_calls + self.iter_summary.grad_norm = fid_comp.grad_norm + + if self.dump: + if self.dump.dump_grad_norm: + self.dump.update_grad_norm_log(fid_comp.grad_norm) + + if self.dump.dump_grad: + self.dump.update_grad_log(grad) + + tc = self.termination_conditions + if fid_comp.grad_norm < tc.min_gradient_norm: + raise errors.GradMinReachedTerminate(fid_comp.grad_norm) + return grad.flatten() + + def iter_step_callback_func(self, *args): + """ + Check the elapsed wall time for the optimisation run so far. + Terminate if this has exceeded the maximum allowed time + """ + self.num_iter += 1 + + if self.log_level <= logging.DEBUG: + logger.debug("Iteration callback {}".format(self.num_iter)) + + wall_time = timeit.default_timer() - self.wall_time_optimize_start + + if self.iter_summary: + self.iter_summary.iter_num = self.num_iter + self.iter_summary.wall_time = wall_time + + if self.dump and self.dump.dump_summary: + self.dump.add_iter_summary() + + tc = self.termination_conditions + + if wall_time > tc.max_wall_time: + raise errors.MaxWallTimeTerminate() + + # *** update stats *** + if self.stats is not None: + self.stats.num_iter = self.num_iter + + def _interpret_term_exception(self, except_term, result): + """ + Update the result object based on the exception that occurred + during the optimisation + """ + result.termination_reason = except_term.reason + if isinstance(except_term, errors.GoalAchievedTerminate): + result.goal_achieved = True + elif isinstance(except_term, errors.MaxWallTimeTerminate): + result.wall_time_limit_exceeded = True + elif isinstance(except_term, errors.GradMinReachedTerminate): + result.grad_norm_min_reached = True + elif isinstance(except_term, errors.MaxFidFuncCallTerminate): + result.max_fid_func_exceeded = True + + def _add_common_result_attribs(self, result, st_time, end_time): + """ + Update the result object attributes which are common to all + optimisers and outcomes + """ + dyn = self.dynamics + result.num_iter = self.num_iter + result.num_fid_func_calls = self.num_fid_func_calls + result.wall_time = end_time - st_time + result.fid_err = dyn.fid_computer.get_fid_err() + result.grad_norm_final = dyn.fid_computer.grad_norm + result.final_amps = dyn.ctrl_amps + final_evo = dyn.full_evo + if isinstance(final_evo, Qobj): + result.evo_full_final = final_evo + else: + result.evo_full_final = Qobj(final_evo, dims=dyn.sys_dims) + # *** update stats *** + if self.stats is not None: + self.stats.wall_time_optim_end = end_time + self.stats.calculate() + result.stats = copy.copy(self.stats) + + +class OptimizerBFGS(Optimizer): + """ + Implements the run_optimization method using the BFGS algorithm + """ + + def reset(self): + Optimizer.reset(self) + self.id_text = "BFGS" + + def run_optimization(self, term_conds=None): + """ + Optimise the control pulse amplitudes to minimise the fidelity error + using the BFGS (Broyden–Fletcher–Goldfarb–Shanno) algorithm + The optimisation end when one of the passed termination conditions + has been met, e.g. target achieved, gradient minimum met + (local minima), wall time / iteration count exceeded. + + Essentially this is wrapper to the: + scipy.optimize.fmin_bfgs + function + + If the parameter term_conds=None, then the termination_conditions + attribute must already be set. It will be overwritten if the + parameter is not None + + The result is returned in an OptimResult object, which includes + the final fidelity, time evolution, reason for termination etc + """ + self.init_optim(term_conds) + term_conds = self.termination_conditions + dyn = self.dynamics + self.optim_var_vals = self._get_optim_var_vals() + self._build_method_options() + + st_time = timeit.default_timer() + self.wall_time_optimize_start = st_time + + if self.stats is not None: + self.stats.wall_time_optim_start = st_time + self.stats.wall_time_optim_end = 0.0 + self.stats.num_iter = 1 + + if self.approx_grad: + fprime = None + else: + fprime = self.fid_err_grad_wrapper + + if self.log_level <= logging.INFO: + msg = ( + "Optimising pulse(s) using {} with " "'fmin_bfgs' method" + ).format(self.alg) + if self.approx_grad: + msg += " (approx grad)" + logger.info(msg) + + result = self._create_result() + try: + ( + optim_var_vals, + cost, + grad, + invHess, + nFCalls, + nGCalls, + warn, + ) = spopt.fmin_bfgs( + self.fid_err_func_wrapper, + self.optim_var_vals, + fprime=fprime, + callback=self.iter_step_callback_func, + gtol=term_conds.min_gradient_norm, + maxiter=term_conds.max_iterations, + full_output=True, + disp=True, + ) + + amps = self._get_ctrl_amps(optim_var_vals) + dyn.update_ctrl_amps(amps) + if warn == 1: + result.max_iter_exceeded = True + result.termination_reason = "Iteration count limit reached" + elif warn == 2: + result.grad_norm_min_reached = True + result.termination_reason = "Gradient normal minimum reached" + + except errors.OptimizationTerminate as except_term: + self._interpret_term_exception(except_term, result) + + end_time = timeit.default_timer() + self._add_common_result_attribs(result, st_time, end_time) + + return result + + +class OptimizerLBFGSB(Optimizer): + """ + Implements the run_optimization method using the L-BFGS-B algorithm + + Attributes + ---------- + max_metric_corr : integer + The maximum number of variable metric corrections used to define + the limited memory matrix. That is the number of previous + gradient values that are used to approximate the Hessian + see the scipy.optimize.fmin_l_bfgs_b documentation for description + of m argument + """ + + def reset(self): + Optimizer.reset(self) + self.id_text = "LBFGSB" + self.max_metric_corr = 10 + self.msg_level = None + + def init_optim(self, term_conds): + """ + Check optimiser attribute status and passed parameters before + running the optimisation. + This is called by run_optimization, but could called independently + to check the configuration. + """ + if term_conds is None: + term_conds = self.termination_conditions + + # AJGP 2015-04-21: + # These (copying from config) are here for backward compatibility + if hasattr(self.config, "max_metric_corr"): + if self.config.max_metric_corr: + self.max_metric_corr = self.config.max_metric_corr + if hasattr(self.config, "accuracy_factor"): + if self.config.accuracy_factor: + term_conds.accuracy_factor = self.config.accuracy_factor + + Optimizer.init_optim(self, term_conds) + + if not isinstance(self.msg_level, int): + if self.log_level < logging.DEBUG: + self.msg_level = 2 + elif self.log_level <= logging.DEBUG: + self.msg_level = 1 + else: + self.msg_level = 0 + + def run_optimization(self, term_conds=None): + """ + Optimise the control pulse amplitudes to minimise the fidelity error + using the L-BFGS-B algorithm, which is the constrained + (bounded amplitude values), limited memory, version of the + Broyden–Fletcher–Goldfarb–Shanno algorithm. + + The optimisation end when one of the passed termination conditions + has been met, e.g. target achieved, gradient minimum met + (local minima), wall time / iteration count exceeded. + + Essentially this is wrapper to the: + scipy.optimize.fmin_l_bfgs_b function + This in turn is a warpper for well established implementation of + the L-BFGS-B algorithm written in Fortran, which is therefore + very fast. See SciPy documentation for credit and details on + this function. + + If the parameter term_conds=None, then the termination_conditions + attribute must already be set. It will be overwritten if the + parameter is not None + + The result is returned in an OptimResult object, which includes + the final fidelity, time evolution, reason for termination etc + """ + self.init_optim(term_conds) + term_conds = self.termination_conditions + dyn = self.dynamics + cfg = self.config + self.optim_var_vals = self._get_optim_var_vals() + self._build_method_options() + + st_time = timeit.default_timer() + self.wall_time_optimize_start = st_time + + if self.stats is not None: + self.stats.wall_time_optim_start = st_time + self.stats.wall_time_optim_end = 0.0 + self.stats.num_iter = 1 + + bounds = self._build_bounds_list() + result = self._create_result() + + if self.approx_grad: + fprime = None + else: + fprime = self.fid_err_grad_wrapper + + if "accuracy_factor" in self.method_options: + factr = self.method_options["accuracy_factor"] + elif "ftol" in self.method_options: + factr = self.method_options["ftol"] + elif hasattr(term_conds, "accuracy_factor"): + factr = term_conds.accuracy_factor + else: + factr = 1e7 + + if "max_metric_corr" in self.method_options: + m = self.method_options["max_metric_corr"] + elif "maxcor" in self.method_options: + m = self.method_options["maxcor"] + elif hasattr(self, "max_metric_corr"): + m = self.max_metric_corr + else: + m = 10 + + if self.log_level <= logging.INFO: + msg = ( + "Optimising pulse(s) using {} with " "'fmin_l_bfgs_b' method" + ).format(self.alg) + if self.approx_grad: + msg += " (approx grad)" + logger.info(msg) + try: + optim_var_vals, fid, res_dict = fmin_l_bfgs_b( + self.fid_err_func_wrapper, + self.optim_var_vals, + fprime=fprime, + approx_grad=self.approx_grad, + callback=self.iter_step_callback_func, + bounds=self.bounds, + m=m, + factr=factr, + pgtol=term_conds.min_gradient_norm, + disp=self.msg_level, + maxfun=term_conds.max_fid_func_calls, + maxiter=term_conds.max_iterations, + ) + + amps = self._get_ctrl_amps(optim_var_vals) + dyn.update_ctrl_amps(amps) + warn = res_dict["warnflag"] + if warn == 0: + result.grad_norm_min_reached = True + result.termination_reason = "function converged" + elif warn == 1: + result.max_iter_exceeded = True + result.termination_reason = ( + "Iteration or fidelity " "function call limit reached" + ) + elif warn == 2: + result.termination_reason = res_dict["task"] + + result.num_iter = res_dict["nit"] + except errors.OptimizationTerminate as except_term: + self._interpret_term_exception(except_term, result) + + end_time = timeit.default_timer() + self._add_common_result_attribs(result, st_time, end_time) + + return result + + +class OptimizerCrab(Optimizer): + """ + Optimises the pulse using the CRAB algorithm [Caneva]_. + It uses the scipy.optimize.minimize function with the method specified + by the optim_method attribute. See Optimizer.run_optimization for details + It minimises the fidelity error function with respect to the CRAB + basis function coefficients. + + References + ---------- + .. [Caneva] T. Caneva, T. Calarco, and S. Montangero. Chopped random-basis quantum optimization, + Phys. Rev. A, 84:022326, 2011 (doi:10.1103/PhysRevA.84.022326) + """ + + def reset(self): + Optimizer.reset(self) + self.id_text = "CRAB" + self.num_optim_vars = 0 + + def init_optim(self, term_conds): + """ + Check optimiser attribute status and passed parameters before + running the optimisation. + This is called by run_optimization, but could called independently + to check the configuration. + """ + Optimizer.init_optim(self, term_conds) + dyn = self.dynamics + + self.num_optim_vars = 0 + pulse_gen_valid = True + # check the pulse generators match the ctrls + # (in terms of number) + # and count the number of parameters + if self.pulse_generator is None: + pulse_gen_valid = False + err_msg = "pulse_generator attribute is None" + elif not isinstance(self.pulse_generator, collections.abc.Iterable): + pulse_gen_valid = False + err_msg = "pulse_generator is not iterable" + + elif len(self.pulse_generator) != dyn.num_ctrls: + pulse_gen_valid = False + err_msg = ( + "the number of pulse generators {} does not equal " + "the number of controls {}".format( + len(self.pulse_generator), dyn.num_ctrls + ) + ) + + if pulse_gen_valid: + for p_gen in self.pulse_generator: + if not isinstance(p_gen, pulsegen.PulseGenCrab): + pulse_gen_valid = False + err_msg = ( + "pulse_generator contained object of type '{}'".format( + p_gen.__class__.__name__ + ) + ) + break + self.num_optim_vars += p_gen.num_optim_vars + + if not pulse_gen_valid: + raise errors.UsageError( + "The pulse_generator attribute must be set to a list of " + "PulseGenCrab - one for each control. Here " + err_msg + ) + + def _build_bounds_list(self): + """ + No bounds necessary here, as the bounds for the CRAB parameters + do not have much physical meaning. + This needs to override the default method, otherwise the shape + will be wrong + """ + return None + + def _get_optim_var_vals(self): + """ + Generate the 1d array that holds the current variable values + of the function to be optimised + For CRAB these are the basis coefficients + + Returns + ------- + ndarray (1d) of float + """ + pvals = [] + for pgen in self.pulse_generator: + pvals.extend(pgen.get_optim_var_vals()) + + return np.array(pvals) + + def _get_ctrl_amps(self, optim_var_vals): + """ + Get the control amplitudes from the current variable values + of the function to be optimised. + that is the 1d array that is passed from the optimisation method + For CRAB the amplitudes will need to calculated by expanding the + series + + Returns + ------- + float array[dynamics.num_tslots, dynamics.num_ctrls] + """ + dyn = self.dynamics + + if self.log_level <= logging.DEBUG: + changed_params = self.optim_var_vals != optim_var_vals + logger.debug( + "{} out of {} optimisation parameters changed".format( + changed_params.sum(), len(optim_var_vals) + ) + ) + + amps = np.empty([dyn.num_tslots, dyn.num_ctrls]) + j = 0 + param_idx_st = 0 + for p_gen in self.pulse_generator: + param_idx_end = param_idx_st + p_gen.num_optim_vars + pg_pvals = optim_var_vals[param_idx_st:param_idx_end] + p_gen.set_optim_var_vals(pg_pvals) + amps[:, j] = p_gen.gen_pulse() + param_idx_st = param_idx_end + j += 1 + + self.optim_var_vals = optim_var_vals + return amps + + +class OptimizerCrabFmin(OptimizerCrab): + """ + Optimises the pulse using the CRAB algorithm [Doria]_, [Caneva]_. + It uses the ``scipy.optimize.fmin`` function which is effectively a wrapper + for the Nelder-Mead method. It minimises the fidelity error function with + respect to the CRAB basis function coefficients. This is the default + Optimizer for CRAB. + + References + ---------- + .. [Doria] P. Doria, T. Calarco & S. Montangero. Phys. Rev. Lett. 106, 190501 + (2011). + .. [Caneva] T. Caneva, T. Calarco, & S. Montangero. Phys. Rev. A 84, 022326 + (2011). + """ + + def reset(self): + OptimizerCrab.reset(self) + self.id_text = "CRAB_FMIN" + self.xtol = 1e-4 + self.ftol = 1e-4 + + def run_optimization(self, term_conds=None): + """ + This function optimisation method is a wrapper to the + scipy.optimize.fmin function. + + It will attempt to minimise the fidelity error with respect to some + parameters, which are determined by _get_optim_var_vals which + in the case of CRAB are the basis function coefficients + + The optimisation end when one of the passed termination conditions + has been met, e.g. target achieved, wall time, or + function call or iteration count exceeded. Specifically to the fmin + method, the optimisation will stop when change parameter values + is less than xtol or the change in function value is below ftol. + + If the parameter term_conds=None, then the termination_conditions + attribute must already be set. It will be overwritten if the + parameter is not None + + The result is returned in an OptimResult object, which includes + the final fidelity, time evolution, reason for termination etc + """ + self.init_optim(term_conds) + term_conds = self.termination_conditions + dyn = self.dynamics + cfg = self.config + self.optim_var_vals = self._get_optim_var_vals() + self._build_method_options() + + st_time = timeit.default_timer() + self.wall_time_optimize_start = st_time + + if self.stats is not None: + self.stats.wall_time_optim_start = st_time + self.stats.wall_time_optim_end = 0.0 + self.stats.num_iter = 1 + + result = self._create_result() + + if self.log_level <= logging.INFO: + logger.info( + "Optimising pulse(s) using {} with " + "'fmin' (Nelder-Mead) method".format(self.alg) + ) + + try: + ret = spopt.fmin( + self.fid_err_func_wrapper, + self.optim_var_vals, + xtol=self.xtol, + ftol=self.ftol, + maxiter=term_conds.max_iterations, + maxfun=term_conds.max_fid_func_calls, + full_output=True, + disp=self.disp_conv_msg, + retall=self.record_iteration_steps, + callback=self.iter_step_callback_func, + ) + + final_param_vals = ret[0] + num_iter = ret[2] + warn_flag = ret[4] + if self.record_iteration_steps: + self.iteration_steps = ret[5] + amps = self._get_ctrl_amps(final_param_vals) + dyn.update_ctrl_amps(amps) + + # Note the iterations are counted in this object as well + # so there are compared here for interest sake only + if self.num_iter != num_iter: + logger.info( + "The number of iterations counted {} " + " does not match the number reported {} " + "by {}".format(self.num_iter, num_iter, self.method) + ) + result.num_iter = num_iter + if warn_flag == 0: + result.termination_reason = ( + "Function converged (within tolerance)" + ) + elif warn_flag == 1: + result.termination_reason = ( + "Maximum number of function evaluations reached" + ) + result.max_fid_func_exceeded = True + elif warn_flag == 2: + result.termination_reason = ( + "Maximum number of iterations reached" + ) + result.max_iter_exceeded = True + else: + result.termination_reason = "Unknown (warn_flag={})".format( + warn_flag + ) + + except errors.OptimizationTerminate as except_term: + self._interpret_term_exception(except_term, result) + + end_time = timeit.default_timer() + self._add_common_result_attribs(result, st_time, end_time) + + return result + + +class OptimIterSummary(qtrldump.DumpSummaryItem): + """ + A summary of the most recent iteration of the pulse optimisation + + Attributes + ---------- + iter_num : int + Iteration number of the pulse optimisation + + fid_func_call_num : int + Fidelity function call number of the pulse optimisation + + grad_func_call_num : int + Gradient function call number of the pulse optimisation + + fid_err : float + Fidelity error + + grad_norm : float + fidelity gradient (wrt the control parameters) vector norm + that is the magnitude of the gradient + + wall_time : float + Time spent computing the pulse optimisation so far + (in seconds of elapsed time) + """ + + # Note there is some duplication here with Optimizer attributes + # this exists solely to be copied into the summary dump + min_col_width = 11 + summary_property_names = ( + "idx", + "iter_num", + "fid_func_call_num", + "grad_func_call_num", + "fid_err", + "grad_norm", + "wall_time", + ) + + summary_property_fmt_type = ("d", "d", "d", "d", "g", "g", "g") + + summary_property_fmt_prec = (0, 0, 0, 0, 4, 4, 2) + + def __init__(self): + self.reset() + + def reset(self): + qtrldump.DumpSummaryItem.reset(self) + self.iter_num = None + self.fid_func_call_num = None + self.grad_func_call_num = None + self.fid_err = None + self.grad_norm = None + self.wall_time = 0.0 \ No newline at end of file diff --git a/src/qutip_qoc/q2/optimresult.py b/src/qutip_qoc/q2/optimresult.py new file mode 100644 index 0000000..b62139e --- /dev/null +++ b/src/qutip_qoc/q2/optimresult.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Class containing the results of the pulse optimisation +""" + +import numpy as np + + +class OptimResult(object): + """ + Attributes give the result of the pulse optimisation attempt + + Attributes + ---------- + termination_reason : string + Description of the reason for terminating the optimisation + + fidelity : float + final (normalised) fidelity that was achieved + + initial_fid_err : float + fidelity error before optimisation starting + + fid_err : float + final fidelity error that was achieved + + goal_achieved : boolean + True is the fidely error achieved was below the target + + grad_norm_final : float + Final value of the sum of the squares of the (normalised) fidelity + error gradients + + grad_norm_min_reached : float + True if the optimisation terminated due to the minimum value + of the gradient being reached + + num_iter : integer + Number of iterations of the optimisation algorithm completed + + max_iter_exceeded : boolean + True if the iteration limit was reached + + max_fid_func_exceeded : boolean + True if the fidelity function call limit was reached + + wall_time : float + time elapsed during the optimisation + + wall_time_limit_exceeded : boolean + True if the wall time limit was reached + + time : array[num_tslots+1] of float + Time are the start of each timeslot + with the final value being the total evolution time + + initial_amps : array[num_tslots, n_ctrls] + The amplitudes at the start of the optimisation + + final_amps : array[num_tslots, n_ctrls] + The amplitudes at the end of the optimisation + + evo_full_final : Qobj + The evolution operator from t=0 to t=T based on the final amps + + evo_full_initial : Qobj + The evolution operator from t=0 to t=T based on the initial amps + + stats : Stats + Object contaning the stats for the run (if any collected) + + optimizer : Optimizer + Instance of the Optimizer used to generate the result + """ + + def __init__(self): + self.reset() + + def reset(self): + self.fidelity = 0.0 + self.initial_fid_err = np.inf + self.fid_err = np.inf + self.goal_achieved = False + self.grad_norm_final = 0.0 + self.grad_norm_min_reached = False + self.num_iter = 0 + self.max_iter_exceeded = False + self.num_fid_func_calls = 0 + self.max_fid_func_exceeded = False + self.wall_time = 0.0 + self.wall_time_limit_exceeded = False + self.termination_reason = "not started yet" + self.time = None + self.initial_amps = None + self.final_amps = None + self.evo_full_final = None + self.evo_full_initial = None + self.stats = None + self.optimizer = None \ No newline at end of file diff --git a/src/qutip_qoc/q2/propcomp.py b/src/qutip_qoc/q2/propcomp.py new file mode 100644 index 0000000..7776157 --- /dev/null +++ b/src/qutip_qoc/q2/propcomp.py @@ -0,0 +1,430 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Propagator Computer +Classes used to calculate the propagators, +and also the propagator gradient when exact gradient methods are used + +Note the methods in the _Diag class was inspired by: +DYNAMO - Dynamic Framework for Quantum Optimal Control +See Machnes et.al., arXiv.1011.4874 +""" + +import warnings +import numpy as np +import scipy.linalg as la +import scipy.sparse as sp + +# QuTiP +from qutip import Qobj + +# QuTiP control modules +from qutip_qtrl import errors + +# QuTiP logging +import qutip_qtrl.logging_utils as logging + +logger = logging.get_logger() + + +def _func_deprecation(message, stacklevel=3): + """ + Issue deprecation warning + Using stacklevel=3 will ensure message refers the function + calling with the deprecated parameter, + """ + warnings.warn(message, FutureWarning, stacklevel=stacklevel) + + +class PropagatorComputer: + """ + Base for all Propagator Computer classes + that are used to calculate the propagators, + and also the propagator gradient when exact gradient methods are used + Note: they must be instantiated with a Dynamics object, that is the + container for the data that the functions operate on + This base class cannot be used directly. See subclass descriptions + and choose the appropriate one for the application + + Attributes + ---------- + log_level : integer + level of messaging output from the logger. + Options are attributes of qutip_utils.logging, + in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, + assuming everything runs as expected. + The default NOTSET implies that the level will be taken from + the QuTiP settings file, which by default is WARN + + grad_exact : boolean + indicates whether the computer class instance is capable + of computing propagator gradients. It is used to determine + whether to create the Dynamics prop_grad array + """ + + def __init__(self, dynamics, params=None): + self.parent = dynamics + self.params = params + self.reset() + + def reset(self): + """ + reset any configuration data + """ + self.id_text = "PROP_COMP_BASE" + self.log_level = self.parent.log_level + self._grad_exact = False + + def apply_params(self, params=None): + """ + Set object attributes based on the dictionary (if any) passed in the + instantiation, or passed as a parameter + This is called during the instantiation automatically. + The key value pairs are the attribute name and value + Note: attributes are created if they do not exist already, + and are overwritten if they do. + """ + if not params: + params = self.params + + if isinstance(params, dict): + self.params = params + for key in params: + setattr(self, key, params[key]) + + @property + def log_level(self): + return logger.level + + @log_level.setter + def log_level(self, lvl): + """ + Set the log_level attribute and set the level of the logger + that is call logger.setLevel(lvl) + """ + logger.setLevel(lvl) + + def grad_exact(self): + return self._grad_exact + + def compute_propagator(self, k): + _func_deprecation( + "'compute_propagator' has been replaced " + "by '_compute_propagator'" + ) + return self._compute_propagator(k) + + def _compute_propagator(self, k): + """ + calculate the progator between X(k) and X(k+1) + Uses matrix expm of the dyn_gen at that point (in time) + Assumes that the dyn_gen have been been calculated, + i.e. drift and ctrls combined + Return the propagator + """ + dyn = self.parent + dgt = dyn._get_phased_dyn_gen(k) * dyn.tau[k] + if dyn.oper_dtype == Qobj: + prop = dgt.expm() + else: + prop = la.expm(dgt) + return prop + + def compute_diff_prop(self, k, j, epsilon): + _func_deprecation( + "'compute_diff_prop' has been replaced " "by '_compute_diff_prop'" + ) + return self._compute_diff_prop(k, j, epsilon) + + def _compute_diff_prop(self, k, j, epsilon): + """ + Calculate the propagator from the current point to a trial point + a distance 'epsilon' (change in amplitude) + in the direction the given control j in timeslot k + Returns the propagator + """ + raise errors.UsageError( + "Not implemented in the baseclass." " Choose a subclass" + ) + + def compute_prop_grad(self, k, j, compute_prop=True): + _func_deprecation( + "'compute_prop_grad' has been replaced " "by '_compute_prop_grad'" + ) + return self._compute_prop_grad(self, k, j, compute_prop=compute_prop) + + def _compute_prop_grad(self, k, j, compute_prop=True): + """ + Calculate the gradient of propagator wrt the control amplitude + in the timeslot. + """ + raise errors.UsageError( + "Not implemented in the baseclass." " Choose a subclass" + ) + + +class PropCompApproxGrad(PropagatorComputer): + """ + This subclass can be used when the propagator is calculated simply + by expm of the dynamics generator, i.e. when gradients will be calculated + using approximate methods. + """ + + def reset(self): + """ + reset any configuration data + """ + PropagatorComputer.reset(self) + self.id_text = "APPROX" + self.grad_exact = False + self.apply_params() + + def _compute_diff_prop(self, k, j, epsilon): + """ + Calculate the propagator from the current point to a trial point + a distance 'epsilon' (change in amplitude) + in the direction the given control j in timeslot k + Returns the propagator + """ + dyn = self.parent + dgt_eps = dyn.tau[k] * ( + dyn._get_phased_dyn_gen(k) + + epsilon * dyn._get_phased_ctrl_dyn_gen(k, j) + ) + + if dyn.oper_dtype == Qobj: + prop_eps = dgt_eps.expm() + else: + prop_eps = la.expm(dgt_eps) + + return prop_eps + + +class PropCompDiag(PropagatorComputer): + """ + Coumputes the propagator exponentiation using diagonalisation of + of the dynamics generator + """ + + def reset(self): + """ + reset any configuration data + """ + PropagatorComputer.reset(self) + self.id_text = "DIAG" + self.grad_exact = True + self.apply_params() + + def _compute_propagator(self, k): + """ + Calculates the exponentiation of the dynamics generator (H) + As part of the calc the the eigen decomposition is required, which + is reused in the propagator gradient calculation + """ + dyn = self.parent + dyn._ensure_decomp_curr(k) + + if dyn.oper_dtype == Qobj: + prop = ( + dyn._dyn_gen_eigenvectors[k] + * dyn._prop_eigen[k] + * dyn._get_dyn_gen_eigenvectors_adj(k) + ) + else: + prop = ( + dyn._dyn_gen_eigenvectors[k] + .dot(dyn._prop_eigen[k]) + .dot(dyn._get_dyn_gen_eigenvectors_adj(k)) + ) + + return prop + + def _compute_prop_grad(self, k, j, compute_prop=True): + """ + Calculate the gradient of propagator wrt the control amplitude + in the timeslot. + + Returns: + [prop], prop_grad + """ + dyn = self.parent + dyn._ensure_decomp_curr(k) + + if compute_prop: + prop = self._compute_propagator(k) + + if dyn.oper_dtype == Qobj: + # put control dyn_gen in combined dg diagonal basis + cdg = ( + dyn._get_dyn_gen_eigenvectors_adj(k) + * dyn._get_phased_ctrl_dyn_gen(k, j) + * dyn._dyn_gen_eigenvectors[k] + ) + # multiply (elementwise) by timeslice and factor matrix + cdg = Qobj( + np.multiply( + cdg.full() * dyn.tau[k], dyn._dyn_gen_factormatrix[k] + ), + dims=dyn.dyn_dims, + ) + # Return to canonical basis + prop_grad = ( + dyn._dyn_gen_eigenvectors[k] + * cdg + * dyn._get_dyn_gen_eigenvectors_adj(k) + ) + else: + # put control dyn_gen in combined dg diagonal basis + cdg = ( + dyn._get_dyn_gen_eigenvectors_adj(k) + .dot(dyn._get_phased_ctrl_dyn_gen(k, j)) + .dot(dyn._dyn_gen_eigenvectors[k]) + ) + # multiply (elementwise) by timeslice and factor matrix + cdg = np.multiply(cdg * dyn.tau[k], dyn._dyn_gen_factormatrix[k]) + # Return to canonical basis + prop_grad = ( + dyn._dyn_gen_eigenvectors[k] + .dot(cdg) + .dot(dyn._get_dyn_gen_eigenvectors_adj(k)) + ) + + if compute_prop: + return prop, prop_grad + else: + return prop_grad + + +class PropCompAugMat(PropagatorComputer): + """ + Augmented Matrix (deprecated - see _Frechet) + + It should work for all systems, e.g. open, symplectic + There will be other PropagatorComputer subclasses that are more efficient + The _Frechet class should provide exactly the same functionality + more efficiently. + + Note the propagator gradient calculation using the augmented matrix + is taken from: + 'Robust quantum gates for open systems via optimal control: + Markovian versus non-Markovian dynamics' + Frederik F Floether, Pierre de Fouquieres, and Sophie G Schirmer + """ + + def reset(self): + PropagatorComputer.reset(self) + self.id_text = "AUG_MAT" + self.grad_exact = True + self.apply_params() + + def _get_aug_mat(self, k, j): + """ + Generate the matrix [[A, E], [0, A]] where + A is the overall dynamics generator + E is the control dynamics generator + for a given timeslot and control + returns this augmented matrix + """ + dyn = self.parent + dg = dyn._get_phased_dyn_gen(k) + + if dyn.oper_dtype == Qobj: + A = dg.data * dyn.tau[k] + E = dyn._get_phased_ctrl_dyn_gen(k, j).data * dyn.tau[k] + Z = sp.csr_matrix(dg.data.shape) + aug = Qobj(sp.vstack([sp.hstack([A, E]), sp.hstack([Z, A])])) + else: + A = dg * dyn.tau[k] + E = dyn._get_phased_ctrl_dyn_gen(k, j) * dyn.tau[k] + Z = np.zeros(dg.shape) + aug = np.vstack([np.hstack([A, E]), np.hstack([Z, A])]) + return aug + + def _compute_prop_grad(self, k, j, compute_prop=True): + """ + Calculate the gradient of propagator wrt the control amplitude + in the timeslot using the exponentiation of the the augmented + matrix. + The propagtor is calculated for 'free' in this method + and hence it is returned if compute_prop==True + Returns: + [prop], prop_grad + """ + dyn = self.parent + dg = dyn._get_phased_dyn_gen(k) + aug = self._get_aug_mat(k, j) + + if dyn.oper_dtype == Qobj: + aug_exp = aug.expm() + prop_grad = Qobj( + aug_exp[: dg.shape[0], dg.shape[1] :], dims=dyn.dyn_dims + ) + if compute_prop: + prop = Qobj( + aug_exp[: dg.shape[0], : dg.shape[1]], dims=dyn.dyn_dims + ) + else: + aug_exp = la.expm(aug) + prop_grad = aug_exp[: dg.shape[0], dg.shape[1] :] + if compute_prop: + prop = aug_exp[: dg.shape[0], : dg.shape[1]] + + if compute_prop: + return prop, prop_grad + else: + return prop_grad + + +class PropCompFrechet(PropagatorComputer): + """ + Frechet method for calculating the propagator: exponentiating the combined + dynamics generator and the propagator gradient. It should work for all + systems, e.g. unitary, open, symplectic. There are other + :obj:`PropagatorComputer` subclasses that may be more efficient. + """ + + def reset(self): + PropagatorComputer.reset(self) + self.id_text = "FRECHET" + self.grad_exact = True + self.apply_params() + + def _compute_prop_grad(self, k, j, compute_prop=True): + """ + Calculate the gradient of propagator wrt the control amplitude + in the timeslot using the expm_frechet method + The propagtor is calculated (almost) for 'free' in this method + and hence it is returned if compute_prop==True + Returns: + [prop], prop_grad + """ + dyn = self.parent + + if dyn.oper_dtype == Qobj: + A = dyn._get_phased_dyn_gen(k).full() * dyn.tau[k] + E = dyn._get_phased_ctrl_dyn_gen(k, j).full() * dyn.tau[k] + if compute_prop: + prop_dense, prop_grad_dense = la.expm_frechet(A, E) + prop = Qobj(prop_dense, dims=dyn.dyn_dims) + prop_grad = Qobj(prop_grad_dense, dims=dyn.dyn_dims) + else: + prop_grad_dense = la.expm_frechet(A, E, compute_expm=False) + prop_grad = Qobj(prop_grad_dense, dims=dyn.dyn_dims) + else: + A = dyn._get_phased_dyn_gen(k) * dyn.tau[k] + E = dyn._get_phased_ctrl_dyn_gen(k, j) * dyn.tau[k] + if compute_prop: + prop, prop_grad = la.expm_frechet(A, E) + else: + prop_grad = la.expm_frechet(A, E, compute_expm=False) + if compute_prop: + return prop, prop_grad + else: + return prop_grad \ No newline at end of file diff --git a/src/qutip_qoc/q2/pulsegen.py b/src/qutip_qoc/q2/pulsegen.py new file mode 100644 index 0000000..1db9919 --- /dev/null +++ b/src/qutip_qoc/q2/pulsegen.py @@ -0,0 +1,1323 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Pulse generator - Generate pulses for the timeslots +Each class defines a gen_pulse function that produces a float array of +size num_tslots. Each class produces a differ type of pulse. +See the class and gen_pulse function descriptions for details +""" + +import numpy as np + +import qutip_qtrl.dynamics as dynamics +import qutip_qtrl.errors as errors + +import qutip_qtrl.logging_utils as logging + +logger = logging.get_logger() + + +def create_pulse_gen(pulse_type="RND", dyn=None, pulse_params=None): + """ + Create and return a pulse generator object matching the given type. + The pulse generators each produce a different type of pulse, + see the gen_pulse function description for details. + These are the random pulse options: + + RND - Independent random value in each timeslot + RNDFOURIER - Fourier series with random coefficients + RNDWAVES - Summation of random waves + RNDWALK1 - Random change in amplitude each timeslot + RNDWALK2 - Random change in amp gradient each timeslot + + These are the other non-periodic options: + + LIN - Linear, i.e. contant gradient over the time + ZERO - special case of the LIN pulse, where the gradient is 0 + + These are the periodic options + + SINE - Sine wave + SQUARE - Square wave + SAW - Saw tooth wave + TRIANGLE - Triangular wave + + If a Dynamics object is passed in then this is used in instantiate + the PulseGen, meaning that some timeslot and amplitude properties + are copied over. + + """ + if pulse_type == "RND": + return PulseGenRandom(dyn, params=pulse_params) + if pulse_type == "RNDFOURIER": + return PulseGenRndFourier(dyn, params=pulse_params) + if pulse_type == "RNDWAVES": + return PulseGenRndWaves(dyn, params=pulse_params) + if pulse_type == "RNDWALK1": + return PulseGenRndWalk1(dyn, params=pulse_params) + if pulse_type == "RNDWALK2": + return PulseGenRndWalk2(dyn, params=pulse_params) + elif pulse_type == "LIN": + return PulseGenLinear(dyn, params=pulse_params) + elif pulse_type == "ZERO": + return PulseGenZero(dyn, params=pulse_params) + elif pulse_type == "SINE": + return PulseGenSine(dyn, params=pulse_params) + elif pulse_type == "SQUARE": + return PulseGenSquare(dyn, params=pulse_params) + elif pulse_type == "SAW": + return PulseGenSaw(dyn, params=pulse_params) + elif pulse_type == "TRIANGLE": + return PulseGenTriangle(dyn, params=pulse_params) + elif pulse_type == "GAUSSIAN": + return PulseGenGaussian(dyn, params=pulse_params) + elif pulse_type == "CRAB_FOURIER": + return PulseGenCrabFourier(dyn, params=pulse_params) + elif pulse_type == "GAUSSIAN_EDGE": + return PulseGenGaussianEdge(dyn, params=pulse_params) + else: + raise ValueError("No option for pulse_type '{}'".format(pulse_type)) + + +class PulseGen: + """ + Pulse generator + Base class for all Pulse generators + The object can optionally be instantiated with a Dynamics object, + in which case the timeslots and amplitude scaling and offset + are copied from that. + Otherwise the class can be used independently by setting: + tau (array of timeslot durations) + or + num_tslots and pulse_time for equally spaced timeslots + + Attributes + ---------- + num_tslots : integer + Number of timeslots, aka timeslices + (copied from Dynamics if given) + + pulse_time : float + total duration of the pulse + (copied from Dynamics.evo_time if given) + + scaling : float + linear scaling applied to the pulse + (copied from Dynamics.initial_ctrl_scaling if given) + + offset : float + linear offset applied to the pulse + (copied from Dynamics.initial_ctrl_offset if given) + + tau : array[num_tslots] of float + Duration of each timeslot + (copied from Dynamics if given) + + lbound : float + Lower boundary for the pulse amplitudes + Note that the scaling and offset attributes can be used to fully + bound the pulse for all generators except some of the random ones + This bound (if set) may result in additional shifting / scaling + Default is -Inf + + ubound : float + Upper boundary for the pulse amplitudes + Note that the scaling and offset attributes can be used to fully + bound the pulse for all generators except some of the random ones + This bound (if set) may result in additional shifting / scaling + Default is Inf + + periodic : boolean + True if the pulse generator produces periodic pulses + + random : boolean + True if the pulse generator produces random pulses + + log_level : integer + level of messaging output from the logger. + Options are attributes of qutip_qtrl.logging_utils, + in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, + assuming everything runs as expected. + The default NOTSET implies that the level will be taken from + the QuTiP settings file, which by default is WARN + """ + + def __init__(self, dyn=None, params=None): + self.parent = dyn + self.params = params + self.reset() + + def reset(self): + """ + reset attributes to default values + """ + if isinstance(self.parent, dynamics.Dynamics): + dyn = self.parent + self.num_tslots = dyn.num_tslots + self.pulse_time = dyn.evo_time + self.scaling = dyn.initial_ctrl_scaling + self.offset = dyn.initial_ctrl_offset + self.tau = dyn.tau + self.log_level = dyn.log_level + else: + self.num_tslots = 100 + self.pulse_time = 1.0 + self.scaling = 1.0 + self.tau = None + self.offset = 0.0 + + self._uses_time = False + self.time = None + self._pulse_initialised = False + self.periodic = False + self.random = False + self.lbound = None + self.ubound = None + self.ramping_pulse = None + + self.apply_params() + + def apply_params(self, params=None): + """ + Set object attributes based on the dictionary (if any) passed in the + instantiation, or passed as a parameter + This is called during the instantiation automatically. + The key value pairs are the attribute name and value + """ + if not params: + params = self.params + + if isinstance(params, dict): + self.params = params + for key in params: + setattr(self, key, params[key]) + + @property + def log_level(self): + return logger.level + + @log_level.setter + def log_level(self, lvl): + """ + Set the log_level attribute and set the level of the logger + that is call logger.setLevel(lvl) + """ + logger.setLevel(lvl) + + def gen_pulse(self): + """ + returns the pulse as an array of vales for each timeslot + Must be implemented by subclass + """ + # must be implemented by subclass + raise errors.UsageError( + "No method defined for generating a pulse. " + " Suspect base class was used where sub class should have been" + ) + + def init_pulse(self): + """ + Initialise the pulse parameters + """ + if self.tau is None: + self.tau = ( + np.ones(self.num_tslots, dtype="f") + * self.pulse_time + / self.num_tslots + ) + + if self._uses_time: + self.time = np.zeros(self.num_tslots, dtype=float) + for k in range(self.num_tslots - 1): + self.time[k + 1] = self.time[k] + self.tau[k] + + self._pulse_initialised = True + + if self.lbound is not None: + if np.isinf(self.lbound): + self.lbound = None + if self.ubound is not None: + if np.isinf(self.ubound): + self.ubound = None + + if self.ubound is not None and self.lbound is not None: + if self.ubound < self.lbound: + raise ValueError("ubound cannot be less the lbound") + + def _apply_bounds_and_offset(self, pulse): + """ + Ensure that the randomly generated pulse fits within the bounds + (after applying the offset) + Assumes that pulses passed are centered around zero (on average) + """ + if self.lbound is None and self.ubound is None: + return pulse + self.offset + + max_amp = max(pulse) + min_amp = min(pulse) + if (self.ubound is None or max_amp + self.offset <= self.ubound) and ( + self.lbound is None or min_amp + self.offset >= self.lbound + ): + return pulse + self.offset + + # Some shifting / scaling is required. + if self.ubound is None or self.lbound is None: + # One of the bounds is inf, so just shift the pulse + if self.lbound is None: + # max_amp + offset must exceed the ubound + return pulse + self.ubound - max_amp + else: + # min_amp + offset must exceed the lbound + return pulse + self.lbound - min_amp + else: + bound_range = self.ubound - self.lbound + amp_range = max_amp - min_amp + if max_amp - min_amp > bound_range: + # pulse range is too high, it must be scaled + pulse = pulse * bound_range / amp_range + + # otherwise the pulse should fit anyway + return pulse + self.lbound - min(pulse) + + def _apply_ramping_pulse(self, pulse, ramping_pulse=None): + if ramping_pulse is None: + ramping_pulse = self.ramping_pulse + if ramping_pulse is not None: + pulse = pulse * ramping_pulse + + return pulse + + +class PulseGenZero(PulseGen): + """ + Generates a flat pulse + """ + + def gen_pulse(self): + """ + Generate a pulse with the same value in every timeslot. + The value will be zero, unless the offset is not zero, + in which case it will be the offset + """ + pulse = np.zeros(self.num_tslots) + return self._apply_bounds_and_offset(pulse) + + +class PulseGenRandom(PulseGen): + """ + Generates random pulses as simply random values for each timeslot + """ + + def reset(self): + PulseGen.reset(self) + self.random = True + self.apply_params() + + def gen_pulse(self): + """ + Generate a pulse of random values between 1 and -1 + Values are scaled using the scaling property + and shifted using the offset property + Returns the pulse as an array of vales for each timeslot + """ + pulse = (2 * np.random.random(self.num_tslots) - 1) * self.scaling + + return self._apply_bounds_and_offset(pulse) + + +class PulseGenRndFourier(PulseGen): + """ + Generates pulses by summing sine waves as a Fourier series + with random coefficients + + Attributes + ---------- + scaling : float + The pulses should fit approximately within -/+scaling + (before the offset is applied) + as it is used to set a maximum for each component wave + Use bounds to be sure + (copied from Dynamics.initial_ctrl_scaling if given) + + min_wavelen : float + Minimum wavelength of any component wave + Set by default to 1/10th of the pulse time + """ + + def reset(self): + """ + reset attributes to default values + """ + PulseGen.reset(self) + self.random = True + self._uses_time = True + try: + self.min_wavelen = self.pulse_time / 10.0 + except AttributeError: + self.min_wavelen = 0.1 + self.apply_params() + + def gen_pulse(self, min_wavelen=None): + """ + Generate a random pulse based on a Fourier series with a minimum + wavelength + """ + if min_wavelen is not None: + self.min_wavelen = min_wavelen + min_wavelen = self.min_wavelen + + if min_wavelen > self.pulse_time: + raise ValueError( + "Minimum wavelength cannot be greater than " "the pulse time" + ) + if not self._pulse_initialised: + self.init_pulse() + + # use some phase to avoid the first pulse being always 0 + + sum_wave = np.zeros(self.tau.shape) + wavelen = 2.0 * self.pulse_time + + t = self.time + wl = [] + while wavelen > min_wavelen: + wl.append(wavelen) + wavelen = wavelen / 2.0 + + num_comp_waves = len(wl) + amp_scale = np.sqrt(8) * self.scaling / float(num_comp_waves) + + for wavelen in wl: + amp = amp_scale * (np.random.rand() * 2 - 1) + phase_off = np.random.rand() * np.pi / 2.0 + curr_wave = amp * np.sin(2 * np.pi * t / wavelen + phase_off) + sum_wave += curr_wave + + return self._apply_bounds_and_offset(sum_wave) + + +class PulseGenRndWaves(PulseGen): + """ + Generates pulses by summing sine waves with random frequencies + amplitudes and phase offset + + Attributes + ---------- + scaling : float + The pulses should fit approximately within -/+scaling + (before the offset is applied) + as it is used to set a maximum for each component wave + Use bounds to be sure + (copied from Dynamics.initial_ctrl_scaling if given) + + num_comp_waves : integer + Number of component waves. That is the number of waves that + are summed to make the pulse signal + Set to 20 by default. + + min_wavelen : float + Minimum wavelength of any component wave + Set by default to 1/10th of the pulse time + + max_wavelen : float + Maximum wavelength of any component wave + Set by default to twice the pulse time + """ + + def reset(self): + """ + reset attributes to default values + """ + PulseGen.reset(self) + self.random = True + self._uses_time = True + self.num_comp_waves = 20 + try: + self.min_wavelen = self.pulse_time / 10.0 + except AttributeError: + self.min_wavelen = 0.1 + try: + self.max_wavelen = 2 * self.pulse_time + except AttributeError: + self.max_wavelen = 10.0 + self.apply_params() + + def gen_pulse( + self, num_comp_waves=None, min_wavelen=None, max_wavelen=None + ): + """ + Generate a random pulse by summing sine waves with random freq, + amplitude and phase offset + """ + + if num_comp_waves is not None: + self.num_comp_waves = num_comp_waves + if min_wavelen is not None: + self.min_wavelen = min_wavelen + if max_wavelen is not None: + self.max_wavelen = max_wavelen + + num_comp_waves = self.num_comp_waves + min_wavelen = self.min_wavelen + max_wavelen = self.max_wavelen + + if min_wavelen > self.pulse_time: + raise ValueError( + "Minimum wavelength cannot be greater than " "the pulse time" + ) + if max_wavelen <= min_wavelen: + raise ValueError( + "Maximum wavelength must be greater than " + "the minimum wavelength" + ) + + if not self._pulse_initialised: + self.init_pulse() + + # use some phase to avoid the first pulse being always 0 + + sum_wave = np.zeros(self.tau.shape) + + t = self.time + wl_range = max_wavelen - min_wavelen + amp_scale = np.sqrt(8) * self.scaling / float(num_comp_waves) + for n in range(num_comp_waves): + amp = amp_scale * (np.random.rand() * 2 - 1) + phase_off = np.random.rand() * np.pi / 2.0 + wavelen = min_wavelen + np.random.rand() * wl_range + curr_wave = amp * np.sin(2 * np.pi * t / wavelen + phase_off) + sum_wave += curr_wave + + return self._apply_bounds_and_offset(sum_wave) + + +class PulseGenRndWalk1(PulseGen): + """ + Generates pulses by using a random walk algorithm + + Attributes + ---------- + scaling : float + Used as the range for the starting amplitude + Note must used bounds if values must be restricted. + Also scales the max_d_amp value + (copied from Dynamics.initial_ctrl_scaling if given) + + max_d_amp : float + Maximum amount amplitude will change between timeslots + Note this is also factored by the scaling attribute + """ + + def reset(self): + """ + reset attributes to default values + """ + PulseGen.reset(self) + self.random = True + self.max_d_amp = 0.1 + self.apply_params() + + def gen_pulse(self, max_d_amp=None): + """ + Generate a pulse by changing the amplitude a random amount between + -max_d_amp and +max_d_amp at each timeslot. The walk will start at + a random amplitude between -/+scaling. + """ + if max_d_amp is not None: + self.max_d_amp = max_d_amp + max_d_amp = self.max_d_amp * self.scaling + + if not self._pulse_initialised: + self.init_pulse() + + walk = np.zeros(self.tau.shape) + amp = self.scaling * (np.random.rand() * 2 - 1) + for k in range(len(walk)): + walk[k] = amp + amp += (np.random.rand() * 2 - 1) * max_d_amp + + return self._apply_bounds_and_offset(walk) + + +class PulseGenRndWalk2(PulseGen): + """ + Generates pulses by using a random walk algorithm + Note this is best used with bounds as the walks tend to wander far + + Attributes + ---------- + scaling : float + Used as the range for the starting amplitude + Note must used bounds if values must be restricted. + Also scales the max_d2_amp value + (copied from Dynamics.initial_ctrl_scaling if given) + + max_d2_amp : float + Maximum amount amplitude gradient will change between timeslots + Note this is also factored by the scaling attribute + """ + + def reset(self): + """ + reset attributes to default values + """ + PulseGen.reset(self) + self.random = True + self.max_d2_amp = 0.01 + self.apply_params() + + def gen_pulse(self, init_grad_range=None, max_d2_amp=None): + """ + Generate a pulse by changing the amplitude gradient a random amount + between -max_d2_amp and +max_d2_amp at each timeslot. + The walk will start at a random amplitude between -/+scaling. + The gradient will start at 0 + """ + if max_d2_amp is not None: + self.max_d2_amp = max_d2_amp + + max_d2_amp = self.max_d2_amp + + if not self._pulse_initialised: + self.init_pulse() + + walk = np.zeros(self.tau.shape) + amp = self.scaling * (np.random.rand() * 2 - 1) + print("Start amp {}".format(amp)) + grad = 0.0 + print("Start grad {}".format(grad)) + for k in range(len(walk)): + walk[k] = amp + grad += (np.random.rand() * 2 - 1) * max_d2_amp + amp += grad + # print("grad {}".format(grad)) + + return self._apply_bounds_and_offset(walk) + + +class PulseGenLinear(PulseGen): + """ + Generates linear pulses + + Attributes + ---------- + gradient : float + Gradient of the line. + Note this is calculated from the start_val and end_val if these + are given + + start_val : float + Start point of the line. That is the starting amplitude + + end_val : float + End point of the line. + That is the amplitude at the start of the last timeslot + """ + + def reset(self): + """ + reset attributes to default values + """ + PulseGen.reset(self) + + self.gradient = None + self.start_val = -1.0 + self.end_val = 1.0 + self.apply_params() + + def init_pulse(self, gradient=None, start_val=None, end_val=None): + """ + Calculate the gradient if pulse is defined by start and + end point values + """ + PulseGen.init_pulse(self) + if start_val is not None and end_val is not None: + self.start_val = start_val + self.end_val = end_val + + if self.start_val is not None and self.end_val is not None: + self.gradient = float(self.end_val - self.start_val) / ( + self.pulse_time - self.tau[-1] + ) + + def gen_pulse(self, gradient=None, start_val=None, end_val=None): + """ + Generate a linear pulse using either the gradient and start value + or using the end point to calulate the gradient + Note that the scaling and offset parameters are still applied, + so unless these values are the default 1.0 and 0.0, then the + actual gradient etc will be different + Returns the pulse as an array of vales for each timeslot + """ + if ( + gradient is not None + or start_val is not None + or end_val is not None + ): + self.init_pulse(gradient, start_val, end_val) + if not self._pulse_initialised: + self.init_pulse() + + pulse = np.empty(self.num_tslots) + t = 0.0 + for k in range(self.num_tslots): + y = self.gradient * t + self.start_val + pulse[k] = self.scaling * y + t = t + self.tau[k] + + return self._apply_bounds_and_offset(pulse) + + +class PulseGenPeriodic(PulseGen): + """ + Intermediate class for all periodic pulse generators + All of the periodic pulses range from -1 to 1 + All have a start phase that can be set between 0 and 2pi + + Attributes + ---------- + num_waves : float + Number of complete waves (cycles) that occur in the pulse. + wavelen and freq calculated from this if it is given + + wavelen : float + Wavelength of the pulse (assuming the speed is 1) + freq is calculated from this if it is given + + freq : float + Frequency of the pulse + + start_phase : float + Phase of the pulse signal when t=0 + """ + + def reset(self): + """ + reset attributes to default values + """ + PulseGen.reset(self) + self.periodic = True + self.num_waves = None + self.freq = 1.0 + self.wavelen = None + self.start_phase = 0.0 + self.apply_params() + + def init_pulse( + self, num_waves=None, wavelen=None, freq=None, start_phase=None + ): + """ + Calculate the wavelength, frequency, number of waves etc + from the each other and the other parameters + If num_waves is given then the other parameters are worked from this + Otherwise if the wavelength is given then it is the driver + Otherwise the frequency is used to calculate wavelength and num_waves + """ + PulseGen.init_pulse(self) + + if start_phase is not None: + self.start_phase = start_phase + + if num_waves is not None or wavelen is not None or freq is not None: + self.num_waves = num_waves + self.wavelen = wavelen + self.freq = freq + + if self.num_waves is not None: + self.freq = float(self.num_waves) / self.pulse_time + self.wavelen = 1.0 / self.freq + elif self.wavelen is not None: + self.freq = 1.0 / self.wavelen + self.num_waves = self.wavelen * self.pulse_time + else: + self.wavelen = 1.0 / self.freq + self.num_waves = self.wavelen * self.pulse_time + + +class PulseGenSine(PulseGenPeriodic): + """ + Generates sine wave pulses + """ + + def gen_pulse( + self, num_waves=None, wavelen=None, freq=None, start_phase=None + ): + """ + Generate a sine wave pulse + If no params are provided then the class object attributes are used. + If they are provided, then these will reinitialise the object attribs. + returns the pulse as an array of vales for each timeslot + """ + if start_phase is not None: + self.start_phase = start_phase + + if num_waves is not None or wavelen is not None or freq is not None: + self.init_pulse(num_waves, wavelen, freq, start_phase) + + if not self._pulse_initialised: + self.init_pulse() + + pulse = np.empty(self.num_tslots) + t = 0.0 + for k in range(self.num_tslots): + phase = 2 * np.pi * self.freq * t + self.start_phase + pulse[k] = self.scaling * np.sin(phase) + t = t + self.tau[k] + return self._apply_bounds_and_offset(pulse) + + +class PulseGenSquare(PulseGenPeriodic): + """ + Generates square wave pulses + """ + + def gen_pulse( + self, num_waves=None, wavelen=None, freq=None, start_phase=None + ): + """ + Generate a square wave pulse + If no parameters are pavided then the class object attributes are used. + If they are provided, then these will reinitialise the object attribs + """ + if start_phase is not None: + self.start_phase = start_phase + + if num_waves is not None or wavelen is not None or freq is not None: + self.init_pulse(num_waves, wavelen, freq, start_phase) + + if not self._pulse_initialised: + self.init_pulse() + + pulse = np.empty(self.num_tslots) + t = 0.0 + for k in range(self.num_tslots): + phase = 2 * np.pi * self.freq * t + self.start_phase + x = phase / (2 * np.pi) + y = 4 * np.floor(x) - 2 * np.floor(2 * x) + 1 + pulse[k] = self.scaling * y + t = t + self.tau[k] + return self._apply_bounds_and_offset(pulse) + + +class PulseGenSaw(PulseGenPeriodic): + """ + Generates saw tooth wave pulses + """ + + def gen_pulse( + self, num_waves=None, wavelen=None, freq=None, start_phase=None + ): + """ + Generate a saw tooth wave pulse + If no parameters are pavided then the class object attributes are used. + If they are provided, then these will reinitialise the object attribs + """ + if start_phase is not None: + self.start_phase = start_phase + + if num_waves is not None or wavelen is not None or freq is not None: + self.init_pulse(num_waves, wavelen, freq, start_phase) + + if not self._pulse_initialised: + self.init_pulse() + + pulse = np.empty(self.num_tslots) + t = 0.0 + for k in range(self.num_tslots): + phase = 2 * np.pi * self.freq * t + self.start_phase + x = phase / (2 * np.pi) + y = 2 * (x - np.floor(0.5 + x)) + pulse[k] = self.scaling * y + t = t + self.tau[k] + return self._apply_bounds_and_offset(pulse) + + +class PulseGenTriangle(PulseGenPeriodic): + """ + Generates triangular wave pulses + """ + + def gen_pulse( + self, num_waves=None, wavelen=None, freq=None, start_phase=None + ): + """ + Generate a triangular wave pulse + If no parameters are pavided then the class object attributes are used. + If they are provided, then these will reinitialise the object attribs + """ + if start_phase is not None: + self.start_phase = start_phase + + if num_waves is not None or wavelen is not None or freq is not None: + self.init_pulse(num_waves, wavelen, freq, start_phase) + + if not self._pulse_initialised: + self.init_pulse() + + pulse = np.empty(self.num_tslots) + t = 0.0 + for k in range(self.num_tslots): + phase = 2 * np.pi * self.freq * t + self.start_phase + np.pi / 2.0 + x = phase / (2 * np.pi) + y = 2 * np.abs(2 * (x - np.floor(0.5 + x))) - 1 + pulse[k] = self.scaling * y + t = t + self.tau[k] + + return self._apply_bounds_and_offset(pulse) + + +class PulseGenGaussian(PulseGen): + """ + Generates pulses with a Gaussian profile + """ + + def reset(self): + """ + reset attributes to default values + """ + PulseGen.reset(self) + self._uses_time = True + self.mean = 0.5 * self.pulse_time + self.variance = 0.5 * self.pulse_time + self.apply_params() + + def gen_pulse(self, mean=None, variance=None): + """ + Generate a pulse with Gaussian shape. The peak is centre around the + mean and the variance determines the breadth + The scaling and offset attributes are applied as an amplitude + and fixed linear offset. Note that the maximum amplitude will be + scaling + offset. + """ + if not self._pulse_initialised: + self.init_pulse() + + if mean: + Tm = mean + else: + Tm = self.mean + if variance: + Tv = variance + else: + Tv = self.variance + t = self.time + T = self.pulse_time + + pulse = self.scaling * np.exp(-((t - Tm) ** 2) / (2 * Tv)) + return self._apply_bounds_and_offset(pulse) + + +class PulseGenGaussianEdge(PulseGen): + """ + Generate pulses with inverted Gaussian ramping in and out + It's intended use for a ramping modulation, which is often required in + experimental setups. + + Attributes + ---------- + decay_time : float + Determines the ramping rate. It is approximately the time + required to bring the pulse to full amplitude + It is set to 1/10 of the pulse time by default + """ + + def reset(self): + """ + reset attributes to default values + """ + PulseGen.reset(self) + self._uses_time = True + self.decay_time = self.pulse_time / 10.0 + self.apply_params() + + def gen_pulse(self, decay_time=None): + """ + Generate a pulse that starts and ends at zero and 1.0 in between + then apply scaling and offset + The tailing in and out is an inverted Gaussian shape + """ + if not self._pulse_initialised: + self.init_pulse() + + t = self.time + if decay_time: + Td = decay_time + else: + Td = self.decay_time + T = self.pulse_time + pulse = 1.0 - np.exp(-(t**2) / Td) - np.exp(-((t - T) ** 2) / Td) + pulse = pulse * self.scaling + + return self._apply_bounds_and_offset(pulse) + + +### The following are pulse generators for the CRAB algorithm ### +# AJGP 2015-05-14: +# The intention is to have a more general base class that allows +# setting of general basis functions + + +class PulseGenCrab(PulseGen): + """ + Base class for all CRAB pulse generators + Note these are more involved in the optimisation process as they are + used to produce piecewise control amplitudes each time new optimisation + parameters are tried + + Attributes + ---------- + num_coeffs : integer + Number of coefficients used for each basis function + + num_basis_funcs : integer + Number of basis functions + In this case set at 2 and should not be changed + + coeffs : float array[num_coeffs, num_basis_funcs] + The basis coefficient values + + randomize_coeffs : bool + If True (default) then the coefficients are set to some random values + when initialised, otherwise they will all be equal to self.scaling + """ + + def __init__(self, dyn=None, num_coeffs=None, params=None): + self.parent = dyn + self.num_coeffs = num_coeffs + self.params = params + self.reset() + + def reset(self): + """ + reset attributes to default values + """ + PulseGen.reset(self) + self.NUM_COEFFS_WARN_LVL = 20 + self.DEF_NUM_COEFFS = 4 + self._BSC_ALL = 1 + self._BSC_GT_MEAN = 2 + self._BSC_LT_MEAN = 3 + + self._uses_time = True + self.time = None + self.num_basis_funcs = 2 + self.num_optim_vars = 0 + self.coeffs = None + self.randomize_coeffs = True + self._num_coeffs_estimated = False + self.guess_pulse_action = "MODULATE" + self.guess_pulse = None + self.guess_pulse_func = None + self.apply_params() + + def init_pulse(self, num_coeffs=None, init_coeffs=None): + """ + Set the initial freq and coefficient values + """ + PulseGen.init_pulse(self) + self.init_coeffs(num_coeffs=num_coeffs, init_coeffs=init_coeffs) + + if self.guess_pulse is not None: + self.init_guess_pulse() + self._init_bounds() + + if self.log_level <= logging.DEBUG and not self._num_coeffs_estimated: + logger.debug( + "CRAB pulse initialised with {} coefficients per basis " + "function, which means a total of {} " + "optimisation variables for this pulse".format( + self.num_coeffs, self.num_optim_vars + ) + ) + + # def generate_guess_pulse(self) + # if isinstance(self.guess_pulsegen, PulseGen): + # self.guess_pulse = self.guess_pulsegen.gen_pulse() + # return self.guess_pulse + + def init_coeffs(self, num_coeffs=None, init_coeffs=None): + """ + Generate or set the initial ceofficent values. + + Parameters + ---------- + num_coeffs : integer + Number of coefficients used for each basis function + If given this overides the default and sets the attribute + of the same name. + init_coeffs : float array[num_coeffs * num_basis_funcs] + Typically this will be the initial basis coefficients. + If set to `None` (the default), the initial coefficients will be automatically generated. + """ + if num_coeffs: + self.num_coeffs = num_coeffs + + self._num_coeffs_estimated = False + if not self.num_coeffs: + if isinstance(self.parent, dynamics.Dynamics): + dim = self.parent.get_drift_dim() + self.num_coeffs = self.estimate_num_coeffs(dim) + self._num_coeffs_estimated = True + else: + self.num_coeffs = self.DEF_NUM_COEFFS + self.num_optim_vars = self.num_coeffs * self.num_basis_funcs + + if self._num_coeffs_estimated: + if self.log_level <= logging.INFO: + logger.info( + "The number of CRAB coefficients per basis function " + "has been estimated as {}, which means a total of {} " + "optimisation variables for this pulse. Based on the " + "dimension ({}) of the system".format( + self.num_coeffs, self.num_optim_vars, dim + ) + ) + # Issue warning if beyond the recommended level + if self.log_level <= logging.WARN: + if self.num_coeffs > self.NUM_COEFFS_WARN_LVL: + logger.warn( + "The estimated number of coefficients {} exceeds " + "the amount ({}) recommended for efficient " + "optimisation. You can set this level explicitly " + "to suppress this message.".format( + self.num_coeffs, self.NUM_COEFFS_WARN_LVL + ) + ) + if init_coeffs is not None: + self.set_coeffs(init_coeffs) + elif self.randomize_coeffs: + r = np.random.random([self.num_coeffs, self.num_basis_funcs]) + self.coeffs = (2 * r - 1.0) * self.scaling + else: + self.coeffs = ( + np.ones([self.num_coeffs, self.num_basis_funcs]) * self.scaling + ) + + def estimate_num_coeffs(self, dim): + """ + Estimate the number coefficients based on the dimensionality of the + system. + Returns + ------- + num_coeffs : int + estimated number of coefficients + """ + num_coeffs = max(2, dim - 1) + return num_coeffs + + def get_optim_var_vals(self): + """ + Get the parameter values to be optimised + Returns + ------- + list (or 1d array) of floats + """ + return self.coeffs.ravel().tolist() + + def set_optim_var_vals(self, param_vals): + """ + Set the values of the any of the pulse generation parameters + based on new values from the optimisation method + Typically this will be the basis coefficients + """ + # Type and size checking avoided here as this is in the + # main optmisation call sequence + self.set_coeffs(param_vals) + + def set_coeffs(self, param_vals): + self.coeffs = param_vals.reshape( + [self.num_coeffs, self.num_basis_funcs] + ) + + def init_guess_pulse(self): + self.guess_pulse_func = None + if not self.guess_pulse_action: + logger.WARN("No guess pulse action given, hence ignored.") + elif self.guess_pulse_action.upper() == "MODULATE": + self.guess_pulse_func = self.guess_pulse_modulate + elif self.guess_pulse_action.upper() == "ADD": + self.guess_pulse_func = self.guess_pulse_add + else: + logger.WARN( + "No option for guess pulse action '{}' " + ", hence ignored.".format(self.guess_pulse_action) + ) + + def guess_pulse_add(self, pulse): + pulse = pulse + self.guess_pulse + return pulse + + def guess_pulse_modulate(self, pulse): + pulse = (1.0 + pulse) * self.guess_pulse + return pulse + + def _init_bounds(self): + add_guess_pulse_scale = False + if self.lbound is None and self.ubound is None: + # no bounds to apply + self._bound_scale_cond = None + elif self.lbound is None: + # only upper bound + if self.ubound > 0: + self._bound_mean = 0.0 + self._bound_scale = self.ubound + else: + add_guess_pulse_scale = True + self._bound_scale = ( + self.scaling * self.num_coeffs + + self.get_guess_pulse_scale() + ) + self._bound_mean = -abs(self._bound_scale) + self.ubound + self._bound_scale_cond = self._BSC_GT_MEAN + + elif self.ubound is None: + # only lower bound + if self.lbound < 0: + self._bound_mean = 0.0 + self._bound_scale = abs(self.lbound) + else: + self._bound_scale = ( + self.scaling * self.num_coeffs + + self.get_guess_pulse_scale() + ) + self._bound_mean = abs(self._bound_scale) + self.lbound + self._bound_scale_cond = self._BSC_LT_MEAN + + else: + # lower and upper bounds + self._bound_mean = 0.5 * (self.ubound + self.lbound) + self._bound_scale = 0.5 * (self.ubound - self.lbound) + self._bound_scale_cond = self._BSC_ALL + + def get_guess_pulse_scale(self): + scale = 0.0 + if self.guess_pulse is not None: + scale = max( + np.amax(self.guess_pulse) - np.amin(self.guess_pulse), + np.amax(self.guess_pulse), + ) + return scale + + def _apply_bounds(self, pulse): + """ + Scaling the amplitudes using the tanh function if there are bounds + """ + if self._bound_scale_cond == self._BSC_ALL: + pulse = np.tanh(pulse) * self._bound_scale + self._bound_mean + return pulse + elif self._bound_scale_cond == self._BSC_GT_MEAN: + scale_where = pulse > self._bound_mean + pulse[scale_where] = ( + np.tanh(pulse[scale_where]) * self._bound_scale + + self._bound_mean + ) + return pulse + elif self._bound_scale_cond == self._BSC_LT_MEAN: + scale_where = pulse < self._bound_mean + pulse[scale_where] = ( + np.tanh(pulse[scale_where]) * self._bound_scale + + self._bound_mean + ) + return pulse + else: + return pulse + + +class PulseGenCrabFourier(PulseGenCrab): + """ + Generates a pulse using the Fourier basis functions, i.e. sin and cos + + Attributes + ---------- + freqs : float array[num_coeffs] + Frequencies for the basis functions + randomize_freqs : bool + If True (default) the some random offset is applied to the frequencies + fix_freqs : bool + If True (default) then the frequencies of the basis functions are fixed + and the number of basis functions is set to 2 (sin and cos). + If False then the frequencies are also optimised, adding an additional + parameter for each pair of basis functions. + """ + + def __init__(self, dyn=None, num_coeffs=None, params=None, fix_freqs=True): + PulseGenCrab.__init__(self, dyn, num_coeffs, params) + self.fix_freqs = fix_freqs + if not self.fix_freqs: + # additional parameter for the frequency + self.num_basis_funcs += 1 + + def reset(self): + """ + reset attributes to default values + """ + PulseGenCrab.reset(self) + self.freqs = None + self.randomize_freqs = True + + def init_pulse(self, num_coeffs=None, init_coeffs=None): + """ + Set the initial freq and coefficient values + """ + PulseGenCrab.init_pulse( + self, num_coeffs=num_coeffs, init_coeffs=init_coeffs + ) + + self.init_freqs() + + def init_freqs(self): + """ + Generate the frequencies + These are the Fourier harmonics with a uniformly distributed + random offset + """ + self.freqs = np.empty(self.num_coeffs) + ff = 2 * np.pi / self.pulse_time + for i in range(self.num_coeffs): + self.freqs[i] = ff * (i + 1) + + if self.randomize_freqs: + self.freqs += np.random.random(self.num_coeffs) - 0.5 + + def gen_pulse(self, coeffs=None): + """ + Generate a pulse using the Fourier basis with the freqs and + coeffs attributes. + + Parameters + ---------- + coeffs : float array[num_coeffs, num_basis_funcs] + The basis coefficient values + If given this overides the default and sets the attribute + of the same name. + """ + if coeffs: + self.coeffs = coeffs + + if not self._pulse_initialised: + self.init_pulse() + + pulse = np.zeros(self.num_tslots) + + for i in range(self.num_coeffs): + if self.fix_freqs: # dont optimise frequencies + phase = self.freqs[i] * self.time + else: # optimise frequencies as part of the parameters + phase = self.coeffs[i, 2] * self.time + pulse += self.coeffs[i, 0] * np.sin(phase) + self.coeffs[ + i, 1 + ] * np.cos(phase) + + if self.guess_pulse_func: + pulse = self.guess_pulse_func(pulse) + if self.ramping_pulse is not None: + pulse = self._apply_ramping_pulse(pulse) + + return self._apply_bounds(pulse) \ No newline at end of file diff --git a/src/qutip_qoc/q2/pulseoptim.py b/src/qutip_qoc/q2/pulseoptim.py new file mode 100644 index 0000000..7d10f2a --- /dev/null +++ b/src/qutip_qoc/q2/pulseoptim.py @@ -0,0 +1,2119 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Wrapper functions that will manage the creation of the objects, +build the configuration, and execute the algorithm required to optimise +a set of ctrl pulses for a given (quantum) system. +The fidelity error is some measure of distance of the system evolution +from the given target evolution in the time allowed for the evolution. +The functions minimise this fidelity error wrt the piecewise control +amplitudes in the timeslots + +There are currently two quantum control pulse optmisations algorithms +implemented in this library. There are accessible through the methods +in this module. Both the algorithms use the scipy.optimize methods +to minimise the fidelity error with respect to to variables that define +the pulse. + +GRAPE +----- +The default algorithm (as it was implemented here first) is GRAPE +GRadient Ascent Pulse Engineering [1][2]. It uses a gradient based method such +as BFGS to minimise the fidelity error. This makes convergence very quick +when an exact gradient can be calculated, but this limits the factors that can +taken into account in the fidelity. + +CRAB +---- +The CRAB [3][4] algorithm was developed at the University of Ulm. +In full it is the Chopped RAndom Basis algorithm. +The main difference is that it reduces the number of optimisation variables +by defining the control pulses by expansions of basis functions, +where the variables are the coefficients. Typically a Fourier series is chosen, +i.e. the variables are the Fourier coefficients. +Therefore it does not need to compute an explicit gradient. +By default it uses the Nelder-Mead method for fidelity error minimisation. + +References +---------- +1. N Khaneja et. al. + Optimal control of coupled spin dynamics: Design of NMR pulse sequences + by gradient ascent algorithms. J. Magn. Reson. 172, 296–305 (2005). +2. Shai Machnes et.al + DYNAMO - Dynamic Framework for Quantum Optimal Control + arXiv.1011.4874 +3. Doria, P., Calarco, T. & Montangero, S. + Optimal Control Technique for Many-Body Quantum Dynamics. + Phys. Rev. Lett. 106, 1–4 (2011). +4. Caneva, T., Calarco, T. & Montangero, S. + Chopped random-basis quantum optimization. + Phys. Rev. A - At. Mol. Opt. Phys. 84, (2011). +""" + +import numpy as np +import warnings + +# QuTiP +from qutip import Qobj + +# QuTiP control modules +from . import optimconfig +from . import dynamics +from . import termcond +from . import optimizer +from . import stats +from . import errors +from . import fidcomp +from . import propcomp +from . import pulsegen +from . import logging_utils as logging +logger = logging.get_logger() + + +def _param_deprecation(message, stacklevel=3): + """ + Issue deprecation warning + Using stacklevel=3 will ensure message refers the function + calling with the deprecated parameter, + """ + warnings.warn(message, FutureWarning, stacklevel=stacklevel) + + +def _upper_safe(s): + try: + s = s.upper() + except AttributeError: + pass + return s + + +def optimize_pulse( + drift, + ctrls, + initial, + target, + num_tslots=None, + evo_time=None, + tau=None, + amp_lbound=None, + amp_ubound=None, + fid_err_targ=1e-10, + min_grad=1e-10, + max_iter=500, + max_wall_time=180, + alg="GRAPE", + alg_params=None, + optim_params=None, + optim_method="DEF", + method_params=None, + optim_alg=None, + max_metric_corr=None, + accuracy_factor=None, + dyn_type="GEN_MAT", + dyn_params=None, + prop_type="DEF", + prop_params=None, + fid_type="DEF", + fid_params=None, + phase_option=None, + fid_err_scale_factor=None, + tslot_type="DEF", + tslot_params=None, + amp_update_mode=None, + init_pulse_type="DEF", + init_pulse_params=None, + pulse_scaling=1.0, + pulse_offset=0.0, + ramping_pulse_type=None, + ramping_pulse_params=None, + log_level=logging.NOTSET, + out_file_ext=None, + gen_stats=False, +): + """ + Optimise a control pulse to minimise the fidelity error. The dynamics of + the system in any given timeslot are governed by the combined dynamics + generator, i.e. the sum of the ``drift + ctrl_amp[j]*ctrls[j]``. + + The control pulse is an ``[n_ts, n_ctrls]`` array of piecewise amplitudes + Starting from an initial (typically random) pulse, a multivariable + optimisation algorithm attempts to determines the optimal values for the + control pulse to minimise the fidelity error. The fidelity error is some + measure of distance of the system evolution from the given target evolution + in the time allowed for the evolution. + + Parameters + ---------- + drift : Qobj or list of Qobj + The underlying dynamics generator of the system can provide list (of + length ``num_tslots``) for time dependent drift. + + ctrls : List of Qobj or array like [num_tslots, evo_time] + A list of control dynamics generators. These are scaled by the + amplitudes to alter the overall dynamics. Array-like input can be + provided for time dependent control generators. + + initial : Qobj + Starting point for the evolution. Typically the identity matrix. + + target : Qobj + Target transformation, e.g. gate or state, for the time evolution. + + num_tslots : integer or None + Number of timeslots. ``None`` implies that timeslots will be given in + the tau array. + + evo_time : float or None + Total time for the evolution. ``None`` implies that timeslots will be + given in the tau array. + + tau : array[num_tslots] of floats or None + Durations for the timeslots. If this is given then ``num_tslots`` and + ``evo_time`` are derived from it. ``None`` implies that timeslot + durations will be equal and calculated as ``evo_time/num_tslots``. + + amp_lbound : float or list of floats + Lower boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + amp_ubound : float or list of floats + Upper boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + fid_err_targ : float + Fidelity error target. Pulse optimisation will terminate when the + fidelity error falls below this value. + + mim_grad : float + Minimum gradient. When the sum of the squares of the gradients wrt to + the control amplitudes falls below this value, the optimisation + terminates, assuming local minima. + + max_iter : integer + Maximum number of iterations of the optimisation algorithm. + + max_wall_time : float + Maximum allowed elapsed time for the optimisation algorithm. + + alg : string + Algorithm to use in pulse optimisation. Options are: + + - 'GRAPE' (default) - GRadient Ascent Pulse Engineering + - 'CRAB' - Chopped RAndom Basis + + alg_params : Dictionary + Options that are specific to the algorithm see above. + + optim_params : Dictionary + The key value pairs are the attribute name and value used to set + attribute values. Note: attributes are created if they do not exist + already, and are overwritten if they do. Note: ``method_params`` are + applied afterwards and so may override these. + + optim_method : string + A ``scipy.optimize.minimize`` method that will be used to optimise the + pulse for minimum fidelity error. Note that ``FMIN``, ``FMIN_BFGS`` & + ``FMIN_L_BFGS_B`` will all result in calling these specific + ``scipy.optimize methods``. Note the ``LBFGSB`` is equivalent to + ``FMIN_L_BFGS_B`` for backwards compatibility reasons. Supplying DEF + will given alg dependent result: + + - GRAPE - Default ``optim_method`` is ``FMIN_L_BFGS_B`` + - CRAB - Default ``optim_method`` is ``FMIN`` + + method_params : dict + Parameters for the ``optim_method``. Note that where there is an + attribute of the :obj:`~qutip.control.optimizer.Optimizer` object or + the termination_conditions matching the key that attribute. + Otherwise, and in some case also, they are assumed to be method_options + for the ``scipy.optimize.minimize`` method. + + optim_alg : string + Deprecated. Use ``optim_method``. + + max_metric_corr : integer + Deprecated. Use ``method_params`` instead. + + accuracy_factor : float + Deprecated. Use ``method_params`` instead. + + dyn_type : string + Dynamics type, i.e. the type of matrix used to describe the dynamics. + Options are ``UNIT``, ``GEN_MAT``, ``SYMPL`` + (see :obj:`~qutip.control.dynamics.Dynamics` classes for details). + + dyn_params : dict + Parameters for the :obj:`~qutip.control.dynamics.Dynamics` object. + The key value pairs are assumed to be attribute name value pairs. + They applied after the object is created. + + prop_type : string + Propagator type i.e. the method used to calculate the propagators and + propagator gradient for each timeslot options are DEF, APPROX, DIAG, + FRECHET, AUG_MAT. DEF will use the default for the specific + ``dyn_type`` (see :obj:`~qutip.control.propcomp.PropagatorComputer` + classes for details). + + prop_params : dict + Parameters for the :obj:`~qutip.control.propcomp.PropagatorComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + fid_type : string + Fidelity error (and fidelity error gradient) computation method. + Options are DEF, UNIT, TRACEDIFF, TD_APPROX. DEF will use the default + for the specific ``dyn_type`` + (See :obj:`~qutip.control.fidcomp.FidelityComputer` classes for + details). + + fid_params : dict + Parameters for the :obj:`~qutip.control.fidcomp.FidelityComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + phase_option : string + Deprecated. Pass in ``fid_params`` instead. + + fid_err_scale_factor : float + Deprecated. Use ``scale_factor`` key in ``fid_params`` instead. + + tslot_type : string + Method for computing the dynamics generators, propagators and evolution + in the timeslots. Options: DEF, UPDATE_ALL, DYNAMIC. UPDATE_ALL is + the only one that currently works. + (See :obj:`~qutip.control.tslotcomp.TimeslotComputer` classes for + details.) + + tslot_params : dict + Parameters for the :obj:`~qutip.control.tslotcomp.TimeslotComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + amp_update_mode : string + Deprecated. Use ``tslot_type`` instead. + + init_pulse_type : string + Type / shape of pulse(s) used to initialise the control amplitudes. + Options (GRAPE) include: RND, LIN, ZERO, SINE, SQUARE, TRIANGLE, SAW. + Default is RND. (see :obj:`~qutip.control.pulsegen.PulseGen` classes + for details). For the CRAB the this the ``guess_pulse_type``. + + init_pulse_params : dict + Parameters for the initial / guess pulse generator object. + The key value pairs are assumed to be attribute name value pairs. + They applied after the object is created. + + pulse_scaling : float + Linear scale factor for generated initial / guess pulses. By default + initial pulses are generated with amplitudes in the range (-1.0, 1.0). + These will be scaled by this parameter. + + pulse_offset : float + Linear offset for the pulse. That is this value will be added to any + initial / guess pulses generated. + + ramping_pulse_type : string + Type of pulse used to modulate the control pulse. It's intended use + for a ramping modulation, which is often required in experimental + setups. This is only currently implemented in CRAB. GAUSSIAN_EDGE was + added for this purpose. + + ramping_pulse_params : dict + Parameters for the ramping pulse generator object. The key value pairs + are assumed to be attribute name value pairs. They applied after the + object is created. + + log_level : integer + Level of messaging output from the logger. Options are attributes of + :obj:`qutip_qtrl.logging_utils`, in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL. + Anything WARN or above is effectively 'quiet' execution, assuming + everything runs as expected. The default NOTSET implies that the level + will be taken from the QuTiP settings file, which by default is WARN. + + out_file_ext : string or None + Files containing the initial and final control pulse amplitudes are + saved to the current directory. The default name will be postfixed + with this extension. Setting this to None will suppress the output of + files. + + gen_stats : boolean + If set to True then statistics for the optimisation run will be + generated - accessible through attributes of the stats object. + + Returns + ------- + opt : OptimResult + Returns instance of :obj:`~qutip.control.optimresult.OptimResult`, + which has attributes giving the reason for termination, final fidelity + error, final evolution final amplitudes, statistics etc. + """ + if log_level == logging.NOTSET: + log_level = logger.getEffectiveLevel() + else: + logger.setLevel(log_level) + + # The parameters types are checked in create_pulse_optimizer + # so no need to do so here + # However, the deprecation management is repeated here + # so that the stack level is correct + if optim_alg is not None: + optim_method = optim_alg + _param_deprecation( + "The 'optim_alg' parameter is deprecated. " + "Use 'optim_method' instead" + ) + + if max_metric_corr is not None: + if isinstance(method_params, dict): + if "max_metric_corr" not in method_params: + method_params["max_metric_corr"] = max_metric_corr + else: + method_params = {"max_metric_corr": max_metric_corr} + _param_deprecation( + "The 'max_metric_corr' parameter is deprecated. " + "Use 'max_metric_corr' in method_params instead" + ) + + if accuracy_factor is not None: + if isinstance(method_params, dict): + if "accuracy_factor" not in method_params: + method_params["accuracy_factor"] = accuracy_factor + else: + method_params = {"accuracy_factor": accuracy_factor} + _param_deprecation( + "The 'accuracy_factor' parameter is deprecated. " + "Use 'accuracy_factor' in method_params instead" + ) + + # phase_option + if phase_option is not None: + if isinstance(fid_params, dict): + if "phase_option" not in fid_params: + fid_params["phase_option"] = phase_option + else: + fid_params = {"phase_option": phase_option} + _param_deprecation( + "The 'phase_option' parameter is deprecated. " + "Use 'phase_option' in fid_params instead" + ) + + # fid_err_scale_factor + if fid_err_scale_factor is not None: + if isinstance(fid_params, dict): + if "fid_err_scale_factor" not in fid_params: + fid_params["scale_factor"] = fid_err_scale_factor + else: + fid_params = {"scale_factor": fid_err_scale_factor} + _param_deprecation( + "The 'fid_err_scale_factor' parameter is deprecated. " + "Use 'scale_factor' in fid_params instead" + ) + + # amp_update_mode + if amp_update_mode is not None: + amp_update_mode_up = _upper_safe(amp_update_mode) + if amp_update_mode_up == "ALL": + tslot_type = "UPDATE_ALL" + else: + tslot_type = amp_update_mode + _param_deprecation( + "The 'amp_update_mode' parameter is deprecated. " + "Use 'tslot_type' instead" + ) + + optim = create_pulse_optimizer( + drift, + ctrls, + initial, + target, + num_tslots=num_tslots, + evo_time=evo_time, + tau=tau, + amp_lbound=amp_lbound, + amp_ubound=amp_ubound, + fid_err_targ=fid_err_targ, + min_grad=min_grad, + max_iter=max_iter, + max_wall_time=max_wall_time, + alg=alg, + alg_params=alg_params, + optim_params=optim_params, + optim_method=optim_method, + method_params=method_params, + dyn_type=dyn_type, + dyn_params=dyn_params, + prop_type=prop_type, + prop_params=prop_params, + fid_type=fid_type, + fid_params=fid_params, + init_pulse_type=init_pulse_type, + init_pulse_params=init_pulse_params, + pulse_scaling=pulse_scaling, + pulse_offset=pulse_offset, + ramping_pulse_type=ramping_pulse_type, + ramping_pulse_params=ramping_pulse_params, + log_level=log_level, + gen_stats=gen_stats, + ) + + dyn = optim.dynamics + + dyn.init_timeslots() + # Generate initial pulses for each control + init_amps = np.zeros([dyn.num_tslots, dyn.num_ctrls]) + + if alg == "CRAB": + for j in range(dyn.num_ctrls): + pgen = optim.pulse_generator[j] + pgen.init_pulse() + init_amps[:, j] = pgen.gen_pulse() + else: + pgen = optim.pulse_generator + for j in range(dyn.num_ctrls): + init_amps[:, j] = pgen.gen_pulse() + + # Initialise the starting amplitudes + dyn.initialize_controls(init_amps) + + if log_level <= logging.INFO: + msg = "System configuration:\n" + dg_name = "dynamics generator" + if dyn_type == "UNIT": + dg_name = "Hamiltonian" + if dyn.time_depend_drift: + msg += "Initial drift {}:\n".format(dg_name) + msg += str(dyn.drift_dyn_gen[0]) + else: + msg += "Drift {}:\n".format(dg_name) + msg += str(dyn.drift_dyn_gen) + for j in range(dyn.num_ctrls): + msg += "\nControl {} {}:\n".format(j + 1, dg_name) + msg += str(dyn.ctrl_dyn_gen[j]) + msg += "\nInitial state / operator:\n" + msg += str(dyn.initial) + msg += "\nTarget state / operator:\n" + msg += str(dyn.target) + logger.info(msg) + + if out_file_ext is not None: + # Save initial amplitudes to a text file + pulsefile = "ctrl_amps_initial_" + out_file_ext + dyn.save_amps(pulsefile) + if log_level <= logging.INFO: + logger.info("Initial amplitudes output to file: " + pulsefile) + + # Start the optimisation + result = optim.run_optimization() + + if out_file_ext is not None: + # Save final amplitudes to a text file + pulsefile = "ctrl_amps_final_" + out_file_ext + dyn.save_amps(pulsefile) + if log_level <= logging.INFO: + logger.info("Final amplitudes output to file: " + pulsefile) + + return result + + +def optimize_pulse_unitary( + H_d, + H_c, + U_0, + U_targ, + num_tslots=None, + evo_time=None, + tau=None, + amp_lbound=None, + amp_ubound=None, + fid_err_targ=1e-10, + min_grad=1e-10, + max_iter=500, + max_wall_time=180, + alg="GRAPE", + alg_params=None, + optim_params=None, + optim_method="DEF", + method_params=None, + optim_alg=None, + max_metric_corr=None, + accuracy_factor=None, + phase_option="PSU", + dyn_params=None, + prop_params=None, + fid_params=None, + tslot_type="DEF", + tslot_params=None, + amp_update_mode=None, + init_pulse_type="DEF", + init_pulse_params=None, + pulse_scaling=1.0, + pulse_offset=0.0, + ramping_pulse_type=None, + ramping_pulse_params=None, + log_level=logging.NOTSET, + out_file_ext=None, + gen_stats=False, +): + """ + Optimise a control pulse to minimise the fidelity error, assuming that the + dynamics of the system are generated by unitary operators. This function + is simply a wrapper for optimize_pulse, where the appropriate options for + unitary dynamics are chosen and the parameter names are in the format + familiar to unitary dynamics The dynamics of the system in any given + timeslot are governed by the combined Hamiltonian, i.e. the sum of the + ``H_d + ctrl_amp[j]*H_c[j]`` The control pulse is an ``[n_ts, n_ctrls]`` + array of piecewise amplitudes Starting from an initial (typically random) + pulse, a multivariable optimisation algorithm attempts to determines the + optimal values for the control pulse to minimise the fidelity error The + maximum fidelity for a unitary system is 1, i.e. when the time evolution + resulting from the pulse is equivalent to the target. And therefore the + fidelity error is ``1 - fidelity``. + + Parameters + ---------- + H_d : Qobj or list of Qobj + Drift (aka system) the underlying Hamiltonian of the system can provide + list (of length ``num_tslots``) for time dependent drift. + + H_c : List of Qobj or array like [num_tslots, evo_time] + A list of control Hamiltonians. These are scaled by the amplitudes to + alter the overall dynamics. Array-like input can be provided for time + dependent control generators. + + U_0 : Qobj + Starting point for the evolution. Typically the identity matrix. + + U_targ : Qobj + Target transformation, e.g. gate or state, for the time evolution. + + num_tslots : integer or None + Number of timeslots. ``None`` implies that timeslots will be given in + the tau array. + + evo_time : float or None + Total time for the evolution. ``None`` implies that timeslots will be + given in the tau array. + + tau : array[num_tslots] of floats or None + Durations for the timeslots. If this is given then ``num_tslots`` and + ``evo_time`` are derived from it. ``None`` implies that timeslot + durations will be equal and calculated as ``evo_time/num_tslots``. + + amp_lbound : float or list of floats + Lower boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + amp_ubound : float or list of floats + Upper boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + fid_err_targ : float + Fidelity error target. Pulse optimisation will terminate when the + fidelity error falls below this value. + + mim_grad : float + Minimum gradient. When the sum of the squares of the gradients wrt to + the control amplitudes falls below this value, the optimisation + terminates, assuming local minima. + + max_iter : integer + Maximum number of iterations of the optimisation algorithm. + + max_wall_time : float + Maximum allowed elapsed time for the optimisation algorithm. + + alg : string + Algorithm to use in pulse optimisation. Options are: + + - 'GRAPE' (default) - GRadient Ascent Pulse Engineering + - 'CRAB' - Chopped RAndom Basis + + alg_params : Dictionary + options that are specific to the algorithm see above + + optim_params : Dictionary + The key value pairs are the attribute name and value used to set + attribute values. Note: attributes are created if they do not exist + already, and are overwritten if they do. Note: ``method_params`` are + applied afterwards and so may override these. + + optim_method : string + A ``scipy.optimize.minimize`` method that will be used to optimise the + pulse for minimum fidelity error Note that ``FMIN``, ``FMIN_BFGS`` & + ``FMIN_L_BFGS_B`` will all result in calling these specific + scipy.optimize methods Note the ``LBFGSB`` is equivalent to + ``FMIN_L_BFGS_B`` for backwards compatibility reasons. Supplying + ``DEF`` will given algorithm-dependent result: + + - GRAPE - Default ``optim_method`` is FMIN_L_BFGS_B + - CRAB - Default ``optim_method`` is FMIN + + method_params : dict + Parameters for the ``optim_method``. Note that where there is an + attribute of the :obj:`~qutip.control.optimizer.Optimizer` object or + the ``termination_conditions`` matching the key that attribute. + Otherwise, and in some case also, they are assumed to be + method_options for the ``scipy.optimize.minimize`` method. + + optim_alg : string + Deprecated. Use ``optim_method``. + + max_metric_corr : integer + Deprecated. Use ``method_params`` instead. + + accuracy_factor : float + Deprecated. Use ``method_params`` instead. + + phase_option : string + Determines how global phase is treated in fidelity calculations + (``fid_type='UNIT'`` only). Options: + + - PSU - global phase ignored + - SU - global phase included + + dyn_params : dict + Parameters for the :obj:`~qutip.control.dynamics.Dynamics` object. + The key value pairs are assumed to be attribute name value pairs. + They applied after the object is created. + + prop_params : dict + Parameters for the :obj:`~qutip.control.propcomp.PropagatorComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + fid_params : dict + Parameters for the :obj:`~qutip.control.fidcomp.FidelityComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + tslot_type : string + Method for computing the dynamics generators, propagators and evolution + in the timeslots. Options: ``DEF``, ``UPDATE_ALL``, ``DYNAMIC``. + ``UPDATE_ALL`` is the only one that currently works. (See + :obj:`~qutip.control.tslotcomp.TimeslotComputer` classes for details.) + + tslot_params : dict + Parameters for the :obj:`~qutip.control.tslotcomp.TimeslotComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + amp_update_mode : string + Deprecated. Use ``tslot_type`` instead. + + init_pulse_type : string + Type / shape of pulse(s) used to initialise the control amplitudes. + Options (GRAPE) include: RND, LIN, ZERO, SINE, SQUARE, TRIANGLE, SAW. + DEF is RND. (see :obj:`~qutip.control.pulsegen.PulseGen` classes for + details.) For the CRAB the this the guess_pulse_type. + + init_pulse_params : dict + Parameters for the initial / guess pulse generator object. The key + value pairs are assumed to be attribute name value pairs. They applied + after the object is created. + + pulse_scaling : float + Linear scale factor for generated initial / guess pulses. By default + initial pulses are generated with amplitudes in the range (-1.0, 1.0). + These will be scaled by this parameter. + + pulse_offset : float + Linear offset for the pulse. That is this value will be added to any + initial / guess pulses generated. + + ramping_pulse_type : string + Type of pulse used to modulate the control pulse. It's intended use + for a ramping modulation, which is often required in experimental + setups. This is only currently implemented in CRAB. GAUSSIAN_EDGE was + added for this purpose. + + ramping_pulse_params : dict + Parameters for the ramping pulse generator object. The key value pairs + are assumed to be attribute name value pairs. They applied after the + object is created. + + log_level : integer + Level of messaging output from the logger. Options are attributes of + :obj:`qutip_qtrl.logging_utils` in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, assuming + everything runs as expected. The default NOTSET implies that the level + will be taken from the QuTiP settings file, which by default is WARN. + + out_file_ext : string or None + Files containing the initial and final control pulse amplitudes are + saved to the current directory. The default name will be postfixed + with this extension. Setting this to ``None`` will suppress the output + of files. + + gen_stats : boolean + If set to ``True`` then statistics for the optimisation run will be + generated - accessible through attributes of the stats object. + + Returns + ------- + opt : OptimResult + Returns instance of :obj:`~qutip.control.optimresult.OptimResult`, + which has attributes giving the reason for termination, final fidelity + error, final evolution final amplitudes, statistics etc. + """ + + # parameters are checked in create pulse optimiser + + # The deprecation management is repeated here + # so that the stack level is correct + if optim_alg is not None: + optim_method = optim_alg + _param_deprecation( + "The 'optim_alg' parameter is deprecated. " + "Use 'optim_method' instead" + ) + + if max_metric_corr is not None: + if isinstance(method_params, dict): + if "max_metric_corr" not in method_params: + method_params["max_metric_corr"] = max_metric_corr + else: + method_params = {"max_metric_corr": max_metric_corr} + _param_deprecation( + "The 'max_metric_corr' parameter is deprecated. " + "Use 'max_metric_corr' in method_params instead" + ) + + if accuracy_factor is not None: + if isinstance(method_params, dict): + if "accuracy_factor" not in method_params: + method_params["accuracy_factor"] = accuracy_factor + else: + method_params = {"accuracy_factor": accuracy_factor} + _param_deprecation( + "The 'accuracy_factor' parameter is deprecated. " + "Use 'accuracy_factor' in method_params instead" + ) + + # amp_update_mode + if amp_update_mode is not None: + amp_update_mode_up = _upper_safe(amp_update_mode) + if amp_update_mode_up == "ALL": + tslot_type = "UPDATE_ALL" + else: + tslot_type = amp_update_mode + _param_deprecation( + "The 'amp_update_mode' parameter is deprecated. " + "Use 'tslot_type' instead" + ) + + # phase_option is still valid for this method + # pass it via the fid_params + if phase_option is not None: + if fid_params is None: + fid_params = {"phase_option": phase_option} + else: + if "phase_option" not in fid_params: + fid_params["phase_option"] = phase_option + + return optimize_pulse( + drift=H_d, + ctrls=H_c, + initial=U_0, + target=U_targ, + num_tslots=num_tslots, + evo_time=evo_time, + tau=tau, + amp_lbound=amp_lbound, + amp_ubound=amp_ubound, + fid_err_targ=fid_err_targ, + min_grad=min_grad, + max_iter=max_iter, + max_wall_time=max_wall_time, + alg=alg, + alg_params=alg_params, + optim_params=optim_params, + optim_method=optim_method, + method_params=method_params, + dyn_type="UNIT", + dyn_params=dyn_params, + prop_params=prop_params, + fid_params=fid_params, + init_pulse_type=init_pulse_type, + init_pulse_params=init_pulse_params, + pulse_scaling=pulse_scaling, + pulse_offset=pulse_offset, + ramping_pulse_type=ramping_pulse_type, + ramping_pulse_params=ramping_pulse_params, + log_level=log_level, + out_file_ext=out_file_ext, + gen_stats=gen_stats, + ) + + +def opt_pulse_crab( + drift, + ctrls, + initial, + target, + num_tslots=None, + evo_time=None, + tau=None, + amp_lbound=None, + amp_ubound=None, + fid_err_targ=1e-5, + max_iter=500, + max_wall_time=180, + alg_params=None, + num_coeffs=None, + init_coeff_scaling=1.0, + optim_params=None, + optim_method="fmin", + method_params=None, + dyn_type="GEN_MAT", + dyn_params=None, + prop_type="DEF", + prop_params=None, + fid_type="DEF", + fid_params=None, + tslot_type="DEF", + tslot_params=None, + guess_pulse_type=None, + guess_pulse_params=None, + guess_pulse_scaling=1.0, + guess_pulse_offset=0.0, + guess_pulse_action="MODULATE", + ramping_pulse_type=None, + ramping_pulse_params=None, + log_level=logging.NOTSET, + out_file_ext=None, + gen_stats=False, +): + """ + Optimise a control pulse to minimise the fidelity error. + The dynamics of the system in any given timeslot are governed + by the combined dynamics generator, + i.e. the sum of the drift+ctrl_amp[j]*ctrls[j] + The control pulse is an [n_ts, n_ctrls] array of piecewise amplitudes. + The CRAB algorithm uses basis function coefficents as the variables to + optimise. It does NOT use any gradient function. + A multivariable optimisation algorithm attempts to determines the + optimal values for the control pulse to minimise the fidelity error + The fidelity error is some measure of distance of the system evolution + from the given target evolution in the time allowed for the evolution. + + Parameters + ---------- + drift : Qobj or list of Qobj + the underlying dynamics generator of the system + can provide list (of length num_tslots) for time dependent drift + + ctrls : List of Qobj or array like [num_tslots, evo_time] + a list of control dynamics generators. These are scaled by + the amplitudes to alter the overall dynamics + Array like imput can be provided for time dependent control generators + + initial : Qobj + Starting point for the evolution. Typically the identity matrix. + + target : Qobj + Target transformation, e.g. gate or state, for the time evolution. + + num_tslots : integer or None + Number of timeslots. ``None`` implies that timeslots will be given in + the tau array. + + evo_time : float or None + Total time for the evolution. ``None`` implies that timeslots will be + given in the tau array. + + tau : array[num_tslots] of floats or None + Durations for the timeslots. If this is given then ``num_tslots`` and + ``evo_time`` are dervived from it. + ``None`` implies that timeslot durations will be equal and calculated + as ``evo_time/num_tslots``. + + amp_lbound : float or list of floats + Lower boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + amp_ubound : float or list of floats + Upper boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + fid_err_targ : float + Fidelity error target. Pulse optimisation will terminate when the + fidelity error falls below this value. + + max_iter : integer + Maximum number of iterations of the optimisation algorithm. + + max_wall_time : float + Maximum allowed elapsed time for the optimisation algorithm. + + alg_params : Dictionary + Options that are specific to the algorithm see above. + + optim_params : Dictionary + The key value pairs are the attribute name and value used to set + attribute values. Note: attributes are created if they do not exist + already, and are overwritten if they do. Note: method_params are + applied afterwards and so may override these. + + coeff_scaling : float + Linear scale factor for the random basis coefficients. By default + these range from -1.0 to 1.0. Note this is overridden by alg_params + (if given there). + + num_coeffs : integer + Number of coefficients used for each basis function. Note this is + calculated automatically based on the dimension of the dynamics if not + given. It is crucial to the performane of the algorithm that it is set + as low as possible, while still giving high enough frequencies. Note + this is overridden by alg_params (if given there). + + optim_method : string + Multi-variable optimisation method. The only tested options are 'fmin' + and 'Nelder-mead'. In theory any non-gradient method implemented in + scipy.optimize.mininize could be used. + + method_params : dict + Parameters for the optim_method. Note that where there is an attribute + of the :class:`~qutip.control.optimizer.Optimizer` object or the + termination_conditions matching the key that attribute. Otherwise, + and in some case also, they are assumed to be method_options for the + ``scipy.optimize.minimize`` method. The commonly used parameter are: + + - xtol - limit on variable change for convergence + - ftol - limit on fidelity error change for convergence + + dyn_type : string + Dynamics type, i.e. the type of matrix used to describe the dynamics. + Options are UNIT, GEN_MAT, SYMPL (see Dynamics classes for details). + + dyn_params : dict + Parameters for the :class:`qutip.control.dynamics.Dynamics` object. + The key value pairs are assumed to be attribute name value pairs. + They applied after the object is created. + + prop_type : string + Propagator type i.e. the method used to calculate the propagtors and + propagtor gradient for each timeslot options are DEF, APPROX, DIAG, + FRECHET, AUG_MAT DEF will use the default for the specific dyn_type + (see :obj:`~qutip.control.propcomp.PropagatorComputer` classes for + details). + + prop_params : dict + Parameters for the :obj:`~qutip.control.propcomp.PropagatorComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + fid_type : string + Fidelity error (and fidelity error gradient) computation method. + Options are DEF, UNIT, TRACEDIFF, TD_APPROX. DEF will use the default + for the specific dyn_type. + (See :obj:`~qutip.control.fidcomp.FidelityComputer` classes for + details). + + fid_params : dict + Parameters for the :obj:`~qutip.control.fidcomp.FidelityComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + tslot_type : string + Method for computing the dynamics generators, propagators and evolution + in the timeslots. Options: DEF, UPDATE_ALL, DYNAMIC UPDATE_ALL is the + only one that currently works. + (See :obj:`~qutip.control.tslotcomp.TimeslotComputer` classes + for details). + + tslot_params : dict + Parameters for the :obj:`~qutip.control.tslotcomp.TimeslotComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + guess_pulse_type : string, default None + Type / shape of pulse(s) used modulate the control amplitudes. + Options include: RND, LIN, ZERO, SINE, SQUARE, TRIANGLE, SAW, GAUSSIAN. + + guess_pulse_params : dict + Parameters for the guess pulse generator object. The key value pairs + are assumed to be attribute name value pairs. They applied after the + object is created. + + guess_pulse_action : string, default 'MODULATE' + Determines how the guess pulse is applied to the pulse generated by the + basis expansion. Options are: MODULATE, ADD. + + pulse_scaling : float + Linear scale factor for generated guess pulses. By default initial + pulses are generated with amplitudes in the range (-1.0, 1.0). These + will be scaled by this parameter. + + pulse_offset : float + Linear offset for the pulse. That is this value will be added to any + guess pulses generated. + + ramping_pulse_type : string + Type of pulse used to modulate the control pulse. It's intended use + for a ramping modulation, which is often required in experimental + setups. This is only currently implemented in CRAB. GAUSSIAN_EDGE was + added for this purpose. + + ramping_pulse_params : dict + Parameters for the ramping pulse generator object. The key value pairs + are assumed to be attribute name value pairs. They applied after the + object is created. + + log_level : integer + level of messaging output from the logger. Options are attributes of + :obj:`qutip_qtrl.logging_utils`, in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, assuming + everything runs as expected. The default NOTSET implies that the level + will be taken from the QuTiP settings file, which by default is WARN. + + out_file_ext : string or None + Files containing the initial and final control pulse. Amplitudes are + saved to the current directory. The default name will be postfixed + with this extension. Setting this to ``None`` will suppress the output + of files. + + gen_stats : boolean + If set to ``True`` then statistics for the optimisation run will be + generated - accessible through attributes of the stats object. + + Returns + ------- + opt : OptimResult + Returns instance of OptimResult, which has attributes giving the + reason for termination, final fidelity error, final evolution + final amplitudes, statistics etc + """ + + # The parameters are checked in create_pulse_optimizer + # so no need to do so here + + if log_level == logging.NOTSET: + log_level = logger.getEffectiveLevel() + else: + logger.setLevel(log_level) + + # build the algorithm options + if not isinstance(alg_params, dict): + alg_params = { + "num_coeffs": num_coeffs, + "init_coeff_scaling": init_coeff_scaling, + } + else: + if num_coeffs is not None and "num_coeffs" not in alg_params: + alg_params["num_coeffs"] = num_coeffs + if ( + init_coeff_scaling is not None + and "init_coeff_scaling" not in alg_params + ): + alg_params["init_coeff_scaling"] = init_coeff_scaling + + # Build the guess pulse options + # Any options passed in the guess_pulse_params take precedence + # over the parameter values. + if guess_pulse_type: + if not isinstance(guess_pulse_params, dict): + guess_pulse_params = {} + if ( + guess_pulse_scaling is not None + and "scaling" not in guess_pulse_params + ): + guess_pulse_params["scaling"] = guess_pulse_scaling + if ( + guess_pulse_offset is not None + and "offset" not in guess_pulse_params + ): + guess_pulse_params["offset"] = guess_pulse_offset + if ( + guess_pulse_action is not None + and "pulse_action" not in guess_pulse_params + ): + guess_pulse_params["pulse_action"] = guess_pulse_action + + return optimize_pulse( + drift, + ctrls, + initial, + target, + num_tslots=num_tslots, + evo_time=evo_time, + tau=tau, + amp_lbound=amp_lbound, + amp_ubound=amp_ubound, + fid_err_targ=fid_err_targ, + min_grad=0.0, + max_iter=max_iter, + max_wall_time=max_wall_time, + alg="CRAB", + alg_params=alg_params, + optim_params=optim_params, + optim_method=optim_method, + method_params=method_params, + dyn_type=dyn_type, + dyn_params=dyn_params, + prop_type=prop_type, + prop_params=prop_params, + fid_type=fid_type, + fid_params=fid_params, + tslot_type=tslot_type, + tslot_params=tslot_params, + init_pulse_type=guess_pulse_type, + init_pulse_params=guess_pulse_params, + ramping_pulse_type=ramping_pulse_type, + ramping_pulse_params=ramping_pulse_params, + log_level=log_level, + out_file_ext=out_file_ext, + gen_stats=gen_stats, + ) + + +def opt_pulse_crab_unitary( + H_d, + H_c, + U_0, + U_targ, + num_tslots=None, + evo_time=None, + tau=None, + amp_lbound=None, + amp_ubound=None, + fid_err_targ=1e-5, + max_iter=500, + max_wall_time=180, + alg_params=None, + num_coeffs=None, + init_coeff_scaling=1.0, + optim_params=None, + optim_method="fmin", + method_params=None, + phase_option="PSU", + dyn_params=None, + prop_params=None, + fid_params=None, + tslot_type="DEF", + tslot_params=None, + guess_pulse_type=None, + guess_pulse_params=None, + guess_pulse_scaling=1.0, + guess_pulse_offset=0.0, + guess_pulse_action="MODULATE", + ramping_pulse_type=None, + ramping_pulse_params=None, + log_level=logging.NOTSET, + out_file_ext=None, + gen_stats=False, +): + """ + Optimise a control pulse to minimise the fidelity error, assuming that the + dynamics of the system are generated by unitary operators. This function + is simply a wrapper for optimize_pulse, where the appropriate options for + unitary dynamics are chosen and the parameter names are in the format + familiar to unitary dynamics. The dynamics of the system in any given + timeslot are governed by the combined Hamiltonian, i.e. the sum of the + ``H_d + ctrl_amp[j]*H_c[j]`` The control pulse is an ``[n_ts, n_ctrls]`` + array of piecewise amplitudes. + + The CRAB algorithm uses basis function coefficents as the variables to + optimise. It does NOT use any gradient function. A multivariable + optimisation algorithm attempts to determines the optimal values for the + control pulse to minimise the fidelity error. The fidelity error is some + measure of distance of the system evolution from the given target evolution + in the time allowed for the evolution. + + Parameters + ---------- + + H_d : Qobj or list of Qobj + Drift (aka system) the underlying Hamiltonian of the system can provide + list (of length num_tslots) for time dependent drift. + + H_c : List of Qobj or array like [num_tslots, evo_time] + A list of control Hamiltonians. These are scaled by the amplitudes to + alter the overall dynamics. Array like imput can be provided for time + dependent control generators. + + U_0 : Qobj + Starting point for the evolution. Typically the identity matrix. + + U_targ : Qobj + Target transformation, e.g. gate or state, for the time evolution. + + num_tslots : integer or None + Number of timeslots. ``None`` implies that timeslots will be given in + the tau array. + + evo_time : float or None + Total time for the evolution. ``None`` implies that timeslots will be + given in the tau array. + + tau : array[num_tslots] of floats or None + Durations for the timeslots. If this is given then ``num_tslots`` and + ``evo_time`` are derived from it. ``None`` implies that timeslot + durations will be equal and calculated as ``evo_time/num_tslots``. + + amp_lbound : float or list of floats + Lower boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + amp_ubound : float or list of floats + Upper boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + fid_err_targ : float + Fidelity error target. Pulse optimisation will terminate when the + fidelity error falls below this value. + + max_iter : integer + Maximum number of iterations of the optimisation algorithm. + + max_wall_time : float + Maximum allowed elapsed time for the optimisation algorithm. + + alg_params : Dictionary + Options that are specific to the algorithm see above. + + optim_params : Dictionary + The key value pairs are the attribute name and value used to set + attribute values. Note: attributes are created if they do not exist + already, and are overwritten if they do. Note: ``method_params`` are + applied afterwards and so may override these. + + coeff_scaling : float + Linear scale factor for the random basis coefficients. By default + these range from -1.0 to 1.0. Note this is overridden by + ``alg_params`` (if given there). + + num_coeffs : integer + Number of coefficients used for each basis function. Note this is + calculated automatically based on the dimension of the dynamics if not + given. It is crucial to the performance of the algorithm that it is set + as low as possible, while still giving high enough frequencies. Note + this is overridden by ``alg_params`` (if given there). + + optim_method : string + Multi-variable optimisation method. The only tested options are 'fmin' + and 'Nelder-mead'. In theory any non-gradient method implemented in + ``scipy.optimize.minimize`` could be used. + + method_params : dict + Parameters for the ``optim_method``. Note that where there is an + attribute of the :obj:`~qutip.control.optimizer.Optimizer` object or + the termination_conditions matching the key that attribute. Otherwise, + and in some case also, they are assumed to be method_options for the + ``scipy.optimize.minimize`` method. The commonly used parameter are: + + - xtol - limit on variable change for convergence + - ftol - limit on fidelity error change for convergence + + phase_option : string + Determines how global phase is treated in fidelity calculations + (``fid_type='UNIT'`` only). Options: + + - PSU - global phase ignored + - SU - global phase included + + dyn_params : dict + Parameters for the :obj:`~qutip.control.dynamics.Dynamics` object. + The key value pairs are assumed to be attribute name value pairs. + They applied after the object is created. + + prop_params : dict + Parameters for the :obj:`~qutip.control.propcomp.PropagatorComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + fid_params : dict + Parameters for the :obj:`~qutip.control.fidcomp.FidelityComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + tslot_type : string + Method for computing the dynamics generators, propagators and evolution + in the timeslots. Options: DEF, UPDATE_ALL, DYNAMIC. UPDATE_ALL is + the only one that currently works. + (See :obj:`~qutip.control.tslotcomp.TimeslotComputer` classes for + details). + + tslot_params : dict + Parameters for the :obj:`~qutip.control.tslotcomp.TimeslotComputer` + object. The key value pairs are assumed to be attribute name value + pairs. They applied after the object is created. + + guess_pulse_type : string, optional + Type / shape of pulse(s) used modulate the control amplitudes. + Options include: RND, LIN, ZERO, SINE, SQUARE, TRIANGLE, SAW, GAUSSIAN. + + guess_pulse_params : dict + Parameters for the guess pulse generator object. The key value pairs + are assumed to be attribute name value pairs. They applied after the + object is created. + + guess_pulse_action : string, 'MODULATE' + Determines how the guess pulse is applied to the pulse generated by the + basis expansion. Options are: MODULATE, ADD. + + pulse_scaling : float + Linear scale factor for generated guess pulses. By default initial + pulses are generated with amplitudes in the range (-1.0, 1.0). These + will be scaled by this parameter. + + pulse_offset : float + Linear offset for the pulse. That is this value will be added to any + guess pulses generated. + + ramping_pulse_type : string + Type of pulse used to modulate the control pulse. It's intended use + for a ramping modulation, which is often required in experimental + setups. This is only currently implemented in CRAB. GAUSSIAN_EDGE was + added for this purpose. + + ramping_pulse_params : dict + Parameters for the ramping pulse generator object. The key value pairs + are assumed to be attribute name value pairs. They applied after the + object is created. + + log_level : integer + Level of messaging output from the logger. Options are attributes of + :obj:`qutip_qtrl.logging_utils`, in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL. + Anything WARN or above is effectively 'quiet' execution, assuming + everything runs as expected. The default NOTSET implies that the level + will be taken from the QuTiP settings file, which by default is WARN. + + out_file_ext : string or None + Files containing the initial and final control pulse amplitudes are + saved to the current directory. The default name will be postfixed + with this extension. Setting this to None will suppress the output of + files. + + gen_stats : boolean + If set to ``True`` then statistics for the optimisation run will be + generated - accessible through attributes of the stats object. + + Returns + ------- + opt : OptimResult + Returns instance of :obj:`~qutip.control.optimresult.OptimResult`, + which has attributes giving the reason for termination, final fidelity + error, final evolution final amplitudes, statistics etc. + """ + + # The parameters are checked in create_pulse_optimizer + # so no need to do so here + + if log_level == logging.NOTSET: + log_level = logger.getEffectiveLevel() + else: + logger.setLevel(log_level) + + # build the algorithm options + if not isinstance(alg_params, dict): + alg_params = { + "num_coeffs": num_coeffs, + "init_coeff_scaling": init_coeff_scaling, + } + else: + if num_coeffs is not None and "num_coeffs" not in alg_params: + alg_params["num_coeffs"] = num_coeffs + if ( + init_coeff_scaling is not None + and "init_coeff_scaling" not in alg_params + ): + alg_params["init_coeff_scaling"] = init_coeff_scaling + + # Build the guess pulse options + # Any options passed in the guess_pulse_params take precedence + # over the parameter values. + if guess_pulse_type: + if not isinstance(guess_pulse_params, dict): + guess_pulse_params = {} + if ( + guess_pulse_scaling is not None + and "scaling" not in guess_pulse_params + ): + guess_pulse_params["scaling"] = guess_pulse_scaling + if ( + guess_pulse_offset is not None + and "offset" not in guess_pulse_params + ): + guess_pulse_params["offset"] = guess_pulse_offset + if ( + guess_pulse_action is not None + and "pulse_action" not in guess_pulse_params + ): + guess_pulse_params["pulse_action"] = guess_pulse_action + + return optimize_pulse_unitary( + H_d, + H_c, + U_0, + U_targ, + num_tslots=num_tslots, + evo_time=evo_time, + tau=tau, + amp_lbound=amp_lbound, + amp_ubound=amp_ubound, + fid_err_targ=fid_err_targ, + min_grad=0.0, + max_iter=max_iter, + max_wall_time=max_wall_time, + alg="CRAB", + alg_params=alg_params, + optim_params=optim_params, + optim_method=optim_method, + method_params=method_params, + phase_option=phase_option, + dyn_params=dyn_params, + prop_params=prop_params, + fid_params=fid_params, + tslot_type=tslot_type, + tslot_params=tslot_params, + init_pulse_type=guess_pulse_type, + init_pulse_params=guess_pulse_params, + ramping_pulse_type=ramping_pulse_type, + ramping_pulse_params=ramping_pulse_params, + log_level=log_level, + out_file_ext=out_file_ext, + gen_stats=gen_stats, + ) + + +def create_pulse_optimizer( + drift, + ctrls, + initial, + target, + num_tslots=None, + evo_time=None, + tau=None, + amp_lbound=None, + amp_ubound=None, + fid_err_targ=1e-10, + min_grad=1e-10, + max_iter=500, + max_wall_time=180, + alg="GRAPE", + alg_params=None, + optim_params=None, + optim_method="DEF", + method_params=None, + optim_alg=None, + max_metric_corr=None, + accuracy_factor=None, + dyn_type="GEN_MAT", + dyn_params=None, + prop_type="DEF", + prop_params=None, + fid_type="DEF", + fid_params=None, + phase_option=None, + fid_err_scale_factor=None, + tslot_type="DEF", + tslot_params=None, + amp_update_mode=None, + init_pulse_type="DEF", + init_pulse_params=None, + pulse_scaling=1.0, + pulse_offset=0.0, + ramping_pulse_type=None, + ramping_pulse_params=None, + log_level=logging.NOTSET, + gen_stats=False, +): + """ + Generate the objects of the appropriate subclasses required for the pulse + optmisation based on the parameters given Note this method may be + preferable to calling optimize_pulse if more detailed configuration is + required before running the optmisation algorthim, or the algorithm will be + run many times, for instances when trying to finding global the optimum or + minimum time optimisation + + Parameters + ---------- + drift : Qobj or list of Qobj + The underlying dynamics generator of the system can provide list (of + length num_tslots) for time dependent drift. + + ctrls : List of Qobj or array like [num_tslots, evo_time] + A list of control dynamics generators. These are scaled by the + amplitudes to alter the overall dynamics. Array-like input can be + provided for time dependent control generators. + + initial : Qobj + Starting point for the evolution. Typically the identity matrix. + + target : Qobj + Target transformation, e.g. gate or state, for the time evolution. + + num_tslots : integer or None + Number of timeslots. ``None`` implies that timeslots will be given in + the tau array. + + evo_time : float or None + Total time for the evolution. ``None`` implies that timeslots will be + given in the tau array. + + tau : array[num_tslots] of floats or None + Durations for the timeslots. If this is given then ``num_tslots`` and + ``evo_time`` are dervived from it. ``None`` implies that timeslot + durations will be equal and calculated as ``evo_time/num_tslots``. + + amp_lbound : float or list of floats + Lower boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + amp_ubound : float or list of floats + Upper boundaries for the control amplitudes. Can be a scalar value + applied to all controls or a list of bounds for each control. + + fid_err_targ : float + Fidelity error target. Pulse optimisation will terminate when the + fidelity error falls below this value. + + mim_grad : float + Minimum gradient. When the sum of the squares of the gradients wrt to + the control amplitudes falls below this value, the optimisation + terminates, assuming local minima. + + max_iter : integer + Maximum number of iterations of the optimisation algorithm. + + max_wall_time : float + Maximum allowed elapsed time for the optimisation algorithm. + + alg : string + Algorithm to use in pulse optimisation. + Options are: + + - 'GRAPE' (default) - GRadient Ascent Pulse Engineering + - 'CRAB' - Chopped RAndom Basis + + alg_params : Dictionary + options that are specific to the algorithm see above + + optim_params : Dictionary + The key value pairs are the attribute name and value used to set + attribute values. Note: attributes are created if they do not exist + already, and are overwritten if they do. Note: method_params are + applied afterwards and so may override these. + + optim_method : string + a scipy.optimize.minimize method that will be used to optimise + the pulse for minimum fidelity error + Note that FMIN, FMIN_BFGS & FMIN_L_BFGS_B will all result + in calling these specific scipy.optimize methods + Note the LBFGSB is equivalent to FMIN_L_BFGS_B for backwards + capatibility reasons. + Supplying DEF will given alg dependent result: + + - GRAPE - Default optim_method is FMIN_L_BFGS_B + - CRAB - Default optim_method is Nelder-Mead + + method_params : dict + Parameters for the optim_method. + Note that where there is an attribute of the + :class:`~qutip.control.optimizer.Optimizer` object or the + termination_conditions matching the key that attribute. Otherwise, + and in some case also, they are assumed to be method_options + for the scipy.optimize.minimize method. + + optim_alg : string + Deprecated. Use optim_method. + + max_metric_corr : integer + Deprecated. Use method_params instead + + accuracy_factor : float + Deprecated. Use method_params instead + + dyn_type : string + Dynamics type, i.e. the type of matrix used to describe + the dynamics. Options are UNIT, GEN_MAT, SYMPL + (see Dynamics classes for details) + + dyn_params : dict + Parameters for the Dynamics object + The key value pairs are assumed to be attribute name value pairs + They applied after the object is created + + prop_type : string + Propagator type i.e. the method used to calculate the + propagtors and propagtor gradient for each timeslot + options are DEF, APPROX, DIAG, FRECHET, AUG_MAT + DEF will use the default for the specific dyn_type + (see PropagatorComputer classes for details) + + prop_params : dict + Parameters for the PropagatorComputer object + The key value pairs are assumed to be attribute name value pairs + They applied after the object is created + + fid_type : string + Fidelity error (and fidelity error gradient) computation method + Options are DEF, UNIT, TRACEDIFF, TD_APPROX + DEF will use the default for the specific dyn_type + (See FidelityComputer classes for details) + + fid_params : dict + Parameters for the FidelityComputer object + The key value pairs are assumed to be attribute name value pairs + They applied after the object is created + + phase_option : string + Deprecated. Pass in fid_params instead. + + fid_err_scale_factor : float + Deprecated. Use scale_factor key in fid_params instead. + + tslot_type : string + Method for computing the dynamics generators, propagators and + evolution in the timeslots. + Options: DEF, UPDATE_ALL, DYNAMIC + UPDATE_ALL is the only one that currently works + (See TimeslotComputer classes for details) + + tslot_params : dict + Parameters for the TimeslotComputer object. + The key value pairs are assumed to be attribute name value pairs. + They applied after the object is created. + + amp_update_mode : string + Deprecated. Use tslot_type instead. + + init_pulse_type : string + type / shape of pulse(s) used to initialise the + the control amplitudes. + Options (GRAPE) include: + + RND, LIN, ZERO, SINE, SQUARE, TRIANGLE, SAW + DEF is RND + + (see PulseGen classes for details) + For the CRAB the this the guess_pulse_type. + + init_pulse_params : dict + Parameters for the initial / guess pulse generator object. + The key value pairs are assumed to be attribute name value pairs. + They applied after the object is created. + + pulse_scaling : float + Linear scale factor for generated initial / guess pulses + By default initial pulses are generated with amplitudes in the + range (-1.0, 1.0). These will be scaled by this parameter + + pulse_offset : float + Linear offset for the pulse. That is this value will be added + to any initial / guess pulses generated. + + ramping_pulse_type : string + Type of pulse used to modulate the control pulse. + It's intended use for a ramping modulation, which is often required in + experimental setups. + This is only currently implemented in CRAB. + GAUSSIAN_EDGE was added for this purpose. + + ramping_pulse_params : dict + Parameters for the ramping pulse generator object. + The key value pairs are assumed to be attribute name value pairs. + They applied after the object is created + + log_level : integer + level of messaging output from the logger. + Options are attributes of qutip_qtrl.logging_utils, + in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, + assuming everything runs as expected. + The default NOTSET implies that the level will be taken from + the QuTiP settings file, which by default is WARN + + gen_stats : boolean + if set to True then statistics for the optimisation + run will be generated - accessible through attributes + of the stats object + + Returns + ------- + opt : Optimizer + Instance of an Optimizer, through which the + Config, Dynamics, PulseGen, and TerminationConditions objects + can be accessed as attributes. + The PropagatorComputer, FidelityComputer and TimeslotComputer objects + can be accessed as attributes of the Dynamics object, + e.g. optimizer.dynamics.fid_computer The optimisation can be run + through the optimizer.run_optimization + + """ + + # check parameters + ctrls = dynamics._check_ctrls_container(ctrls) + dynamics._check_drift_dyn_gen(drift) + + if not isinstance(initial, Qobj): + raise TypeError("initial must be a Qobj") + + if not isinstance(target, Qobj): + raise TypeError("target must be a Qobj") + + # Deprecated parameter management + if optim_alg is not None: + optim_method = optim_alg + _param_deprecation( + "The 'optim_alg' parameter is deprecated. " + "Use 'optim_method' instead" + ) + + if max_metric_corr is not None: + if isinstance(method_params, dict): + if "max_metric_corr" not in method_params: + method_params["max_metric_corr"] = max_metric_corr + else: + method_params = {"max_metric_corr": max_metric_corr} + _param_deprecation( + "The 'max_metric_corr' parameter is deprecated. " + "Use 'max_metric_corr' in method_params instead" + ) + + if accuracy_factor is not None: + if isinstance(method_params, dict): + if "accuracy_factor" not in method_params: + method_params["accuracy_factor"] = accuracy_factor + else: + method_params = {"accuracy_factor": accuracy_factor} + _param_deprecation( + "The 'accuracy_factor' parameter is deprecated. " + "Use 'accuracy_factor' in method_params instead" + ) + + # phase_option + if phase_option is not None: + if isinstance(fid_params, dict): + if "phase_option" not in fid_params: + fid_params["phase_option"] = phase_option + else: + fid_params = {"phase_option": phase_option} + _param_deprecation( + "The 'phase_option' parameter is deprecated. " + "Use 'phase_option' in fid_params instead" + ) + + # fid_err_scale_factor + if fid_err_scale_factor is not None: + if isinstance(fid_params, dict): + if "fid_err_scale_factor" not in fid_params: + fid_params["scale_factor"] = fid_err_scale_factor + else: + fid_params = {"scale_factor": fid_err_scale_factor} + _param_deprecation( + "The 'fid_err_scale_factor' parameter is deprecated. " + "Use 'scale_factor' in fid_params instead" + ) + + # amp_update_mode + if amp_update_mode is not None: + amp_update_mode_up = _upper_safe(amp_update_mode) + if amp_update_mode_up == "ALL": + tslot_type = "UPDATE_ALL" + else: + tslot_type = amp_update_mode + _param_deprecation( + "The 'amp_update_mode' parameter is deprecated. " + "Use 'tslot_type' instead" + ) + + # set algorithm defaults + alg_up = _upper_safe(alg) + if alg is None: + raise errors.UsageError( + "Optimisation algorithm must be specified through 'alg' parameter" + ) + elif alg_up == "GRAPE": + if optim_method is None or optim_method.upper() == "DEF": + optim_method = "FMIN_L_BFGS_B" + if init_pulse_type is None or init_pulse_type.upper() == "DEF": + init_pulse_type = "RND" + elif alg_up == "CRAB": + if optim_method is None or optim_method.upper() == "DEF": + optim_method = "FMIN" + if prop_type is None or prop_type.upper() == "DEF": + prop_type = "APPROX" + if init_pulse_type is None or init_pulse_type.upper() == "DEF": + init_pulse_type = None + else: + raise errors.UsageError( + "No option for pulse optimisation algorithm alg={}".format(alg) + ) + + cfg = optimconfig.OptimConfig() + cfg.optim_method = optim_method + cfg.dyn_type = dyn_type + cfg.prop_type = prop_type + cfg.fid_type = fid_type + cfg.init_pulse_type = init_pulse_type + + if log_level == logging.NOTSET: + log_level = logger.getEffectiveLevel() + else: + logger.setLevel(log_level) + + cfg.log_level = log_level + + # Create the Dynamics instance + if dyn_type == "GEN_MAT" or dyn_type is None or dyn_type == "": + dyn = dynamics.DynamicsGenMat(cfg) + elif dyn_type == "UNIT": + dyn = dynamics.DynamicsUnitary(cfg) + elif dyn_type == "SYMPL": + dyn = dynamics.DynamicsSymplectic(cfg) + else: + raise errors.UsageError("No option for dyn_type: " + dyn_type) + dyn.apply_params(dyn_params) + dyn._drift_dyn_gen_checked = True + dyn._ctrl_dyn_gen_checked = True + + # Create the PropagatorComputer instance + # The default will be typically be the best option + if prop_type == "DEF" or prop_type is None or prop_type == "": + # Do nothing use the default for the Dynamics + pass + elif prop_type == "APPROX": + if not isinstance(dyn.prop_computer, propcomp.PropCompApproxGrad): + dyn.prop_computer = propcomp.PropCompApproxGrad(dyn) + elif prop_type == "DIAG": + if not isinstance(dyn.prop_computer, propcomp.PropCompDiag): + dyn.prop_computer = propcomp.PropCompDiag(dyn) + elif prop_type == "AUG_MAT": + if not isinstance(dyn.prop_computer, propcomp.PropCompAugMat): + dyn.prop_computer = propcomp.PropCompAugMat(dyn) + elif prop_type == "FRECHET": + if not isinstance(dyn.prop_computer, propcomp.PropCompFrechet): + dyn.prop_computer = propcomp.PropCompFrechet(dyn) + else: + raise errors.UsageError("No option for prop_type: " + prop_type) + dyn.prop_computer.apply_params(prop_params) + + # Create the FidelityComputer instance + # The default will be typically be the best option + # Note: the FidCompTraceDiffApprox is a subclass of FidCompTraceDiff + # so need to check this type first + fid_type_up = _upper_safe(fid_type) + if fid_type_up == "DEF" or fid_type_up is None or fid_type_up == "": + # None given, use the default for the Dynamics + pass + elif fid_type_up == "TDAPPROX": + if not isinstance(dyn.fid_computer, fidcomp.FidCompTraceDiffApprox): + dyn.fid_computer = fidcomp.FidCompTraceDiffApprox(dyn) + elif fid_type_up == "TRACEDIFF": + if not isinstance(dyn.fid_computer, fidcomp.FidCompTraceDiff): + dyn.fid_computer = fidcomp.FidCompTraceDiff(dyn) + elif fid_type_up == "UNIT": + if not isinstance(dyn.fid_computer, fidcomp.FidCompUnitary): + dyn.fid_computer = fidcomp.FidCompUnitary(dyn) + else: + raise errors.UsageError("No option for fid_type: " + fid_type) + dyn.fid_computer.apply_params(fid_params) + + # Currently the only working option for tslot computer is + # TSlotCompUpdateAll. + # so just apply the parameters + dyn.tslot_computer.apply_params(tslot_params) + + # Create the Optimiser instance + optim_method_up = _upper_safe(optim_method) + if optim_method is None or optim_method_up == "": + raise errors.UsageError( + "Optimisation method must be specified " + "via 'optim_method' parameter" + ) + elif optim_method_up == "FMIN_BFGS": + optim = optimizer.OptimizerBFGS(cfg, dyn) + elif optim_method_up == "LBFGSB" or optim_method_up == "FMIN_L_BFGS_B": + optim = optimizer.OptimizerLBFGSB(cfg, dyn) + elif optim_method_up == "FMIN": + if alg_up == "CRAB": + optim = optimizer.OptimizerCrabFmin(cfg, dyn) + else: + raise errors.UsageError( + "Invalid optim_method '{}' for '{}' algorthim".format( + optim_method, alg + ) + ) + else: + # Assume that the optim_method is a valid + # scipy.optimize.minimize method + # Choose an optimiser based on the algorithm + if alg_up == "CRAB": + optim = optimizer.OptimizerCrab(cfg, dyn) + else: + optim = optimizer.Optimizer(cfg, dyn) + + optim.alg = alg + optim.method = optim_method + optim.amp_lbound = amp_lbound + optim.amp_ubound = amp_ubound + optim.apply_params(optim_params) + + # Create the TerminationConditions instance + tc = termcond.TerminationConditions() + tc.fid_err_targ = fid_err_targ + tc.min_gradient_norm = min_grad + tc.max_iterations = max_iter + tc.max_wall_time = max_wall_time + optim.termination_conditions = tc + + optim.apply_method_params(method_params) + + if gen_stats: + # Create a stats object + # Note that stats object is optional + # if the Dynamics and Optimizer stats attribute is not set + # then no stats will be collected, which could improve performance + if amp_update_mode == "DYNAMIC": + sts = stats.StatsDynTsUpdate() + else: + sts = stats.Stats() + + dyn.stats = sts + optim.stats = sts + + # Configure the dynamics + dyn.drift_dyn_gen = drift + dyn.ctrl_dyn_gen = ctrls + dyn.initial = initial + dyn.target = target + if tau is None: + # Check that parameters have been supplied to generate the + # timeslot durations + try: + evo_time / num_tslots + except Exception: + raise errors.UsageError( + "Either the timeslot durations should be supplied as an " + "array 'tau' or the number of timeslots 'num_tslots' " + "and the evolution time 'evo_time' must be given." + ) + + dyn.num_tslots = num_tslots + dyn.evo_time = evo_time + else: + dyn.tau = tau + + # this function is called, so that the num_ctrls attribute will be set + n_ctrls = dyn.num_ctrls + + ramping_pgen = None + if ramping_pulse_type: + ramping_pgen = pulsegen.create_pulse_gen( + pulse_type=ramping_pulse_type, + dyn=dyn, + pulse_params=ramping_pulse_params, + ) + if alg_up == "CRAB": + # Create a pulse generator for each ctrl + crab_pulse_params = None + num_coeffs = None + fix_freqs = True + init_coeff_scaling = None + if isinstance(alg_params, dict): + num_coeffs = alg_params.get("num_coeffs") + fix_freqs = alg_params.get("fix_frequency", True) + init_coeff_scaling = alg_params.get("init_coeff_scaling") + if "crab_pulse_params" in alg_params: + crab_pulse_params = alg_params.get("crab_pulse_params") + + guess_pulse_type = init_pulse_type + if guess_pulse_type: + guess_pulse_action = None + guess_pgen = pulsegen.create_pulse_gen( + pulse_type=guess_pulse_type, dyn=dyn + ) + guess_pgen.scaling = pulse_scaling + guess_pgen.offset = pulse_offset + if init_pulse_params is not None: + guess_pgen.apply_params(init_pulse_params) + guess_pulse_action = init_pulse_params.get("pulse_action") + + optim.pulse_generator = [] + for j in range(n_ctrls): + crab_pgen = pulsegen.PulseGenCrabFourier( + dyn=dyn, + num_coeffs=num_coeffs, + fix_freqs=fix_freqs, + ) + if init_coeff_scaling is not None: + crab_pgen.scaling = init_coeff_scaling + if isinstance(crab_pulse_params, dict): + crab_pgen.apply_params(crab_pulse_params) + + lb = None + if amp_lbound: + if isinstance(amp_lbound, list): + try: + lb = amp_lbound[j] + except Exception: + lb = amp_lbound[-1] + else: + lb = amp_lbound + ub = None + if amp_ubound: + if isinstance(amp_ubound, list): + try: + ub = amp_ubound[j] + except Exception: + ub = amp_ubound[-1] + else: + ub = amp_ubound + crab_pgen.lbound = lb + crab_pgen.ubound = ub + + if guess_pulse_type: + guess_pgen.lbound = lb + guess_pgen.ubound = ub + crab_pgen.guess_pulse = guess_pgen.gen_pulse() + if guess_pulse_action: + crab_pgen.guess_pulse_action = guess_pulse_action + + if ramping_pgen: + crab_pgen.ramping_pulse = ramping_pgen.gen_pulse() + + optim.pulse_generator.append(crab_pgen) + # This is just for the debug message now + pgen = optim.pulse_generator[0] + + else: + # Create a pulse generator of the type specified + pgen = pulsegen.create_pulse_gen( + pulse_type=init_pulse_type, dyn=dyn, pulse_params=init_pulse_params + ) + pgen.scaling = pulse_scaling + pgen.offset = pulse_offset + pgen.lbound = amp_lbound + pgen.ubound = amp_ubound + + optim.pulse_generator = pgen + + if log_level <= logging.DEBUG: + logger.debug( + "Optimisation config summary...\n" + " object classes:\n" + " optimizer: " + + optim.__class__.__name__ + + "\n dynamics: " + + dyn.__class__.__name__ + + "\n tslotcomp: " + + dyn.tslot_computer.__class__.__name__ + + "\n fidcomp: " + + dyn.fid_computer.__class__.__name__ + + "\n propcomp: " + + dyn.prop_computer.__class__.__name__ + + "\n pulsegen: " + + pgen.__class__.__name__ + ) + return optim \ No newline at end of file diff --git a/src/qutip_qoc/q2/stats.py b/src/qutip_qoc/q2/stats.py new file mode 100644 index 0000000..2b05a40 --- /dev/null +++ b/src/qutip_qoc/q2/stats.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Statistics for the optimisation +Note that some of the stats here are redundant copies from the optimiser +used here for calculations +""" +import numpy as np +import datetime + + +class Stats(object): + """ + Base class for all optimisation statistics + Used for configurations where all timeslots are updated each iteration + e.g. exact gradients + Note that all times are generated using timeit.default_timer() and are + in seconds + + Attributes + ---------- + dyn_gen_name : string + Text used in some report functions. + Makes sense to set it to 'Hamiltonian' when using unitary dynamics + Default is simply 'dynamics generator' + + num_iter : integer + Number of iterations of the optimisation algorithm + + wall_time_optim_start : float + Start time for the optimisation + + wall_time_optim_end : float + End time for the optimisation + + wall_time_optim : float + Time elasped during the optimisation + + wall_time_dyn_gen_compute : float + Total wall (elasped) time computing combined dynamics generator + (for example combining drift and control Hamiltonians) + + wall_time_prop_compute : float + Total wall (elasped) time computing propagators, that is the + time evolution from one timeslot to the next + Includes calculating the propagator gradient for exact gradients + + wall_time_fwd_prop_compute : float + Total wall (elasped) time computing combined forward propagation, + that is the time evolution from the start to a specific timeslot. + Excludes calculating the propagators themselves + + wall_time_onwd_prop_compute : float + Total wall (elasped) time computing combined onward propagation, + that is the time evolution from a specific timeslot to the end time. + Excludes calculating the propagators themselves + + wall_time_gradient_compute : float + Total wall (elasped) time computing the fidelity error gradient. + Excludes calculating the propagator gradients (in exact gradient + methods) + + num_fidelity_func_calls : integer + Number of calls to fidelity function by the optimisation algorithm + + num_grad_func_calls : integer + Number of calls to gradient function by the optimisation algorithm + + num_tslot_recompute : integer + Number of time the timeslot evolution is recomputed + (It is only computed if any amplitudes changed since the last call) + + num_fidelity_computes : integer + Number of time the fidelity is computed + (It is only computed if any amplitudes changed since the last call) + + num_grad_computes : integer + Number of time the gradient is computed + (It is only computed if any amplitudes changed since the last call) + + num_ctrl_amp_updates : integer + Number of times the control amplitudes are updated + + mean_num_ctrl_amp_updates_per_iter : float + Mean number of control amplitude updates per iteration + + num_timeslot_changes : integer + Number of times the amplitudes of a any control in a timeslot changes + + mean_num_timeslot_changes_per_update : float + Mean average number of timeslot amplitudes that are changed per update + + num_ctrl_amp_changes : integer + Number of times individual control amplitudes that are changed + + mean_num_ctrl_amp_changes_per_update : float + Mean average number of control amplitudes that are changed per update + """ + + def __init__(self): + self.reset() + + def reset(self): + self.dyn_gen_name = "dynamics generator" + self.clear() + + def clear(self): + self.num_iter = 0 + # Duration attributes + self.wall_time_optim_start = 0.0 + self.wall_time_optim_end = 0.0 + self.wall_time_optim = 0.0 + self.wall_time_dyn_gen_compute = 0.0 + self.wall_time_prop_compute = 0.0 + self.wall_time_fwd_prop_compute = 0.0 + self.wall_time_onwd_prop_compute = 0.0 + self.wall_time_gradient_compute = 0.0 + # Fidelity and gradient function calls and computes + self.num_fidelity_func_calls = 0 + self.num_grad_func_calls = 0 + self.num_tslot_recompute = 0 + self.num_fidelity_computes = 0 + self.num_grad_computes = 0 + # Control amplitudes + self.num_ctrl_amp_updates = 0 + self.mean_num_ctrl_amp_updates_per_iter = 0.0 + self.num_timeslot_changes = 0 + self.mean_num_timeslot_changes_per_update = 0.0 + self.num_ctrl_amp_changes = 0 + self.mean_num_ctrl_amp_changes_per_update = 0.0 + + def calculate(self): + """ + Perform the calculations (e.g. averages) that are required on the stats + Should be called before calling report + """ + # If the optimation is still running then the optimisation + # time is the time so far + if self.wall_time_optim_end > 0.0: + self.wall_time_optim = ( + self.wall_time_optim_end - self.wall_time_optim_start + ) + + try: + self.mean_num_ctrl_amp_updates_per_iter = ( + self.num_ctrl_amp_updates / float(self.num_iter) + ) + except Exception: + self.mean_num_ctrl_amp_updates_per_iter = np.nan + + try: + self.mean_num_timeslot_changes_per_update = ( + self.num_timeslot_changes / float(self.num_ctrl_amp_updates) + ) + except Exception: + self.mean_num_timeslot_changes_per_update = np.nan + + try: + self.mean_num_ctrl_amp_changes_per_update = ( + self.num_ctrl_amp_changes / float(self.num_ctrl_amp_updates) + ) + except Exception: + self.mean_num_ctrl_amp_changes_per_update = np.nan + + def _format_datetime(self, t, tot=0.0): + dtStr = str(datetime.timedelta(seconds=t)) + if tot > 0: + percent = 100 * t / tot + dtStr += " ({:03.2f}%)".format(percent) + return dtStr + + def report(self): + """ + Print a report of the stats to the console + """ + print( + "\n------------------------------------" + "\n---- Control optimisation stats ----" + ) + self.report_timings() + self.report_func_calls() + self.report_amp_updates() + print("------------------------------------") + + def report_timings(self): + print("**** Timings (HH:MM:SS.US) ****") + tot = self.wall_time_optim + print( + "Total wall time elapsed during optimisation: " + + self._format_datetime(tot) + ) + print( + "Wall time computing Hamiltonians: " + + self._format_datetime(self.wall_time_dyn_gen_compute, tot) + ) + print( + "Wall time computing propagators: " + + self._format_datetime(self.wall_time_prop_compute, tot) + ) + print( + "Wall time computing forward propagation: " + + self._format_datetime(self.wall_time_fwd_prop_compute, tot) + ) + print( + "Wall time computing onward propagation: " + + self._format_datetime(self.wall_time_onwd_prop_compute, tot) + ) + print( + "Wall time computing gradient: " + + self._format_datetime(self.wall_time_gradient_compute, tot) + ) + print("") + + def report_func_calls(self): + print("**** Iterations and function calls ****") + print("Number of iterations: {}".format(self.num_iter)) + print( + "Number of fidelity function calls: " + "{}".format(self.num_fidelity_func_calls) + ) + print( + "Number of times fidelity is computed: " + "{}".format(self.num_fidelity_computes) + ) + print( + "Number of gradient function calls: " + "{}".format(self.num_grad_func_calls) + ) + print( + "Number of times gradients are computed: " + "{}".format(self.num_grad_computes) + ) + print( + "Number of times timeslot evolution is recomputed: " + "{}".format(self.num_tslot_recompute) + ) + print("") + + def report_amp_updates(self): + print("**** Control amplitudes ****") + print( + "Number of control amplitude updates: " + "{}".format(self.num_ctrl_amp_updates) + ) + print( + "Mean number of updates per iteration: " + "{}".format(self.mean_num_ctrl_amp_updates_per_iter) + ) + print( + "Number of timeslot values changed: " + "{}".format(self.num_timeslot_changes) + ) + print( + "Mean number of timeslot changes per update: " + "{}".format(self.mean_num_timeslot_changes_per_update) + ) + print( + "Number of amplitude values changed: " + "{}".format(self.num_ctrl_amp_changes) + ) + print( + "Mean number of amplitude changes per update: " + "{}".format(self.mean_num_ctrl_amp_changes_per_update) + ) + + +class StatsDynTsUpdate(Stats): + """ + Optimisation stats class for configurations where all timeslots are not + necessarily updated at each iteration. In this case it may be interesting + to know how many Hamiltions etc are computed each ctrl amplitude update + + Attributes + ---------- + num_dyn_gen_computes : integer + Total number of dynamics generator (Hamiltonian) computations, + that is combining drift and control dynamics to calculate the + combined dynamics generator for the timeslot + + mean_num_dyn_gen_computes_per_update : float + # Mean average number of dynamics generator computations per update + + mean_wall_time_dyn_gen_compute : float + # Mean average time to compute a timeslot dynamics generator + + num_prop_computes : integer + Total number of propagator (and propagator gradient for exact + gradient types) computations + + mean_num_prop_computes_per_update : float + Mean average number of propagator computations per update + + mean_wall_time_prop_compute : float + Mean average time to compute a propagator (and its gradient) + + num_fwd_prop_step_computes : integer + Total number of steps (matrix product) computing forward propagation + + mean_num_fwd_prop_step_computes_per_update : float + Mean average number of steps computing forward propagation + + mean_wall_time_fwd_prop_compute : float + Mean average time to compute forward propagation + + num_onwd_prop_step_computes : integer + Total number of steps (matrix product) computing onward propagation + + mean_num_onwd_prop_step_computes_per_update : float + Mean average number of steps computing onward propagation + + mean_wall_time_onwd_prop_compute + Mean average time to compute onward propagation + """ + + def __init__(self): + self.reset() + + def reset(self): + Stats.reset(self) + # Dynamics generators (Hamiltonians) + self.num_dyn_gen_computes = 0 + self.mean_num_dyn_gen_computes_per_update = 0.0 + self.mean_wall_time_dyn_gen_compute = 0.0 + # **** Propagators ***** + self.num_prop_computes = 0 + self.mean_num_prop_computes_per_update = 0.0 + self.mean_wall_time_prop_compute = 0.0 + # **** Forward propagation **** + self.num_fwd_prop_step_computes = 0 + self.mean_num_fwd_prop_step_computes_per_update = 0.0 + self.mean_wall_time_fwd_prop_compute = 0.0 + # **** onward propagation **** + self.num_onwd_prop_step_computes = 0 + self.mean_num_onwd_prop_step_computes_per_update = 0.0 + self.mean_wall_time_onwd_prop_compute = 0.0 + + def calculate(self): + Stats.calculate(self) + self.mean_num_dyn_gen_computes_per_update = ( + self.num_dyn_gen_computes / float(self.num_ctrl_amp_updates) + ) + + self.mean_wall_time_dyn_gen_compute = ( + self.wall_time_dyn_gen_compute / float(self.num_dyn_gen_computes) + ) + + self.mean_num_prop_computes_per_update = ( + self.num_prop_computes / float(self.num_ctrl_amp_updates) + ) + + self.mean_wall_time_prop_compute = self.wall_time_prop_compute / float( + self.num_prop_computes + ) + + self.mean_num_fwd_prop_step_computes_per_update = ( + self.num_fwd_prop_step_computes / float(self.num_ctrl_amp_updates) + ) + + self.mean_wall_time_fwd_prop_compute = ( + self.wall_time_fwd_prop_compute + / float(self.num_fwd_prop_step_computes) + ) + + self.mean_num_onwd_prop_step_computes_per_update = ( + self.num_onwd_prop_step_computes / float(self.num_ctrl_amp_updates) + ) + + self.mean_wall_time_onwd_prop_compute = ( + self.wall_time_onwd_prop_compute + / float(self.num_onwd_prop_step_computes) + ) + + def report(self): + """ + Print a report of the stats to the console + """ + + print( + "\n------------------------------------" + "\n---- Control optimisation stats ----" + ) + self.report_timings() + self.report_func_calls() + self.report_amp_updates() + self.report_dyn_gen_comps() + self.report_fwd_prop() + self.report_onwd_prop() + print("------------------------------------") + + def report_dyn_gen_comps(self): + print("**** {} Computations ****".format(self.dyn_gen_name)) + print( + "Total number of {} computations: " + "{}".format(self.dyn_gen_name, self.num_dyn_gen_computes) + ) + print( + "Mean number of {} computations per update: " + "{}".format( + self.dyn_gen_name, self.mean_num_dyn_gen_computes_per_update + ) + ) + print( + "Mean wall time to compute {}s: " + "{} s".format( + self.dyn_gen_name, self.mean_wall_time_dyn_gen_compute + ) + ) + print("**** Propagator Computations ****") + print( + "Total number of propagator computations: " + "{}".format(self.num_prop_computes) + ) + print( + "Mean number of propagator computations per update: " + "{}".format(self.mean_num_prop_computes_per_update) + ) + print( + "Mean wall time to compute propagator " + "{} s".format(self.mean_wall_time_prop_compute) + ) + + def report_fwd_prop(self): + print("**** Forward Propagation ****") + print( + "Total number of forward propagation step computations: " + "{}".format(self.num_fwd_prop_step_computes) + ) + print( + "Mean number of forward propagation step computations" + " per update: " + "{}".format(self.mean_num_fwd_prop_step_computes_per_update) + ) + print( + "Mean wall time to compute forward propagation " + "{} s".format(self.mean_wall_time_fwd_prop_compute) + ) + + def report_onwd_prop(self): + print("**** Onward Propagation ****") + print( + "Total number of onward propagation step computations: " + "{}".format(self.num_onwd_prop_step_computes) + ) + print( + "Mean number of onward propagation step computations" + " per update: " + "{}".format(self.mean_num_onwd_prop_step_computes_per_update) + ) + print( + "Mean wall time to compute onward propagation " + "{} s".format(self.mean_wall_time_onwd_prop_compute) + ) \ No newline at end of file diff --git a/src/qutip_qoc/q2/termcond.py b/src/qutip_qoc/q2/termcond.py new file mode 100644 index 0000000..b87ebc7 --- /dev/null +++ b/src/qutip_qoc/q2/termcond.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Classes containing termination conditions for the control pulse optimisation +i.e. attributes that will be checked during the optimisation, that +will determine if the algorithm has completed its task / exceeded limits +""" + + +class TerminationConditions: + """ + Base class for all termination conditions + Used to determine when to stop the optimisation algorithm + Note different subclasses should be used to match the type of + optimisation being used + + Attributes + ---------- + fid_err_targ : float + Target fidelity error + + fid_goal : float + goal fidelity, e.g. 1 - self.fid_err_targ + It its typical to set this for unitary systems + + max_wall_time : float + # maximum time for optimisation (seconds) + + min_gradient_norm : float + Minimum normalised gradient after which optimisation will terminate + + max_iterations : integer + Maximum iterations of the optimisation algorithm + + max_fid_func_calls : integer + Maximum number of calls to the fidelity function during + the optimisation algorithm + + accuracy_factor : float + Determines the accuracy of the result. + Typical values for accuracy_factor are: 1e12 for low accuracy; + 1e7 for moderate accuracy; 10.0 for extremely high accuracy + scipy.optimize.fmin_l_bfgs_b factr argument. + Only set for specific methods (fmin_l_bfgs_b) that uses this + Otherwise the same thing is passed as method_option ftol + (although the scale is different) + Hence it is not defined here, but may be set by the user + """ + + def __init__(self): + self.reset() + + def reset(self): + self.fid_err_targ = 1e-5 + self.fid_goal = None + self.max_wall_time = 60 * 60.0 + self.min_gradient_norm = 1e-5 + self.max_iterations = 1e10 + self.max_fid_func_calls = 1e10 \ No newline at end of file diff --git a/src/qutip_qoc/q2/test_grape.py b/src/qutip_qoc/q2/test_grape.py new file mode 100644 index 0000000..f84de54 --- /dev/null +++ b/src/qutip_qoc/q2/test_grape.py @@ -0,0 +1,22 @@ +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from qutip_qoc.q2.grape import grape_unitary +from qutip import Qobj, sigmaz, sigmax +import numpy as np + +def test_grape_unitary(): + U_target = Qobj([[1, 1], [1, -1]]) / np.sqrt(2) + H0 = sigmaz() + H_ops = [sigmax()] + times = np.linspace(0, 10, 100) + + result = grape_unitary( + U=U_target, + H0=H0, + H_ops=H_ops, + R=5, # Reduced iterations for testing + times=times + ) + assert isinstance(result.U_f, Qobj) \ No newline at end of file From 53d118a5356101079b1920ac918569818439cafc Mon Sep 17 00:00:00 2001 From: Akhils777 Date: Fri, 18 Jul 2025 14:46:19 +0530 Subject: [PATCH 3/3] update pulse_optim.py, dynamics.py --- src/qutip_qoc/pulse_optim.py | 4 +- src/qutip_qoc/q2/dynamics.py | 4 +- src/qutip_qoc/q2/tslotcomp.py | 746 ++++++++++++++++++++++++++++++++++ 3 files changed, 750 insertions(+), 4 deletions(-) create mode 100644 src/qutip_qoc/q2/tslotcomp.py diff --git a/src/qutip_qoc/pulse_optim.py b/src/qutip_qoc/pulse_optim.py index 935bf18..18c1967 100644 --- a/src/qutip_qoc/pulse_optim.py +++ b/src/qutip_qoc/pulse_optim.py @@ -5,8 +5,8 @@ """ import numpy as np -import qutip_qtrl.logging_utils as logging -import qutip_qtrl.pulseoptim as cpo +import logging +from qutip_qoc.q2 import pulseoptim as cpo from qutip_qoc._optimizer import _global_local_optimization from qutip_qoc._time import _TimeInterval diff --git a/src/qutip_qoc/q2/dynamics.py b/src/qutip_qoc/q2/dynamics.py index 8d7d74a..61bf68e 100644 --- a/src/qutip_qoc/q2/dynamics.py +++ b/src/qutip_qoc/q2/dynamics.py @@ -36,9 +36,9 @@ # QuTiP control modules import qutip_qtrl.errors as errors -import qutip_qtrl.tslotcomp as tslotcomp +from . import tslotcomp import qutip_qtrl.fidcomp as fidcomp -import qutip_qtrl.propcomp as propcomp +from . import propcomp import qutip_qtrl.symplectic as sympl import qutip_qtrl.dump as qtrldump diff --git a/src/qutip_qoc/q2/tslotcomp.py b/src/qutip_qoc/q2/tslotcomp.py new file mode 100644 index 0000000..1382c4e --- /dev/null +++ b/src/qutip_qoc/q2/tslotcomp.py @@ -0,0 +1,746 @@ +# -*- coding: utf-8 -*- +# @author: Alexander Pitchford +# @email1: agp1@aber.ac.uk +# @email2: alex.pitchford@gmail.com +# @organization: Aberystwyth University +# @supervisor: Daniel Burgarth + +""" +Timeslot Computer +These classes determine which dynamics generators, propagators and evolutions +are recalculated when there is a control amplitude update. +The timeslot computer processes the lists held by the dynamics object + +The default (UpdateAll) updates all of these each amp update, on the +assumption that all amplitudes are changed each iteration. This is typical +when using optimisation methods like BFGS in the GRAPE algorithm + +The alternative (DynUpdate) assumes that only a subset of amplitudes +are updated each iteration and attempts to minimise the number of expensive +calculations accordingly. This would be the appropriate class for Krotov type +methods. Note that the Stats_DynTsUpdate class must be used for stats +in conjunction with this class. +NOTE: AJGP 2011-10-2014: This _DynUpdate class currently has some bug, +no pressing need to fix it presently + +If all amplitudes change at each update, then the behavior of the classes is +equivalent. _UpdateAll is easier to understand and potentially slightly faster +in this situation. + +Note the methods in the _DynUpdate class were inspired by: +DYNAMO - Dynamic Framework for Quantum Optimal Control +See Machnes et.al., arXiv.1011.4874 +""" + +import warnings +import numpy as np +import timeit + +# QuTiP +from qutip import Qobj + +# QuTiP control modules +from . import errors +from . import dump as qtrldump + +# QuTiP logging +from . import logging_utils as logging + +logger = logging.get_logger() + + +def _func_deprecation(message, stacklevel=3): + """ + Issue deprecation warning + Using stacklevel=3 will ensure message refers the function + calling with the deprecated parameter, + """ + warnings.warn(message, FutureWarning, stacklevel=stacklevel) + + +class TimeslotComputer: + """ + Base class for all Timeslot Computers + Note: this must be instantiated with a Dynamics object, that is the + container for the data that the methods operate on + + Attributes + ---------- + log_level : integer + level of messaging output from the logger. + Options are attributes of qutip_qtrl.logging_utils, + in decreasing levels of messaging, are: + DEBUG_INTENSE, DEBUG_VERBOSE, DEBUG, INFO, WARN, ERROR, CRITICAL + Anything WARN or above is effectively 'quiet' execution, + assuming everything runs as expected. + The default NOTSET implies that the level will be taken from + the QuTiP settings file, which by default is WARN + + evo_comp_summary : EvoCompSummary + A summary of the most recent evolution computation + Used in the stats and dump + Will be set to None if neither stats or dump are set + """ + + def __init__(self, dynamics, params=None): + from .dynamics import Dynamics + + if not isinstance(dynamics, Dynamics): + raise TypeError("Must instantiate with {} type".format(Dynamics)) + self.parent = dynamics + self.params = params + self.reset() + + def reset(self): + self.log_level = self.parent.log_level + self.id_text = "TS_COMP_BASE" + self.evo_comp_summary = None + + def apply_params(self, params=None): + """ + Set object attributes based on the dictionary (if any) passed in the + instantiation, or passed as a parameter + This is called during the instantiation automatically. + The key value pairs are the attribute name and value + Note: attributes are created if they do not exist already, + and are overwritten if they do. + """ + if not params: + params = self.params + + if isinstance(params, dict): + self.params = params + for key in params: + setattr(self, key, params[key]) + + def flag_all_calc_now(self): + pass + + def init_comp(self): + pass + + @property + def log_level(self): + return logger.level + + @log_level.setter + def log_level(self, lvl): + """ + Set the log_level attribute and set the level of the logger + that is call logger.setLevel(lvl) + """ + logger.setLevel(lvl) + + def dump_current(self): + """Store a copy of the current time evolution""" + dyn = self.parent + dump = dyn.dump + if not isinstance(dump, qtrldump.DynamicsDump): + raise RuntimeError( + "Cannot dump current evolution, " "as dynamics.dump is not set" + ) + + anything_dumped = False + item_idx = None + if dump.dump_any: + dump_item = dump.add_evo_dump() + item_idx = dump_item.idx + anything_dumped = True + + if dump.dump_summary: + dump.add_evo_comp_summary(dump_item_idx=item_idx) + anything_dumped = True + + if not anything_dumped: + logger.warning("Dump set, but nothing dumped, check dump config") + + +class TSlotCompUpdateAll(TimeslotComputer): + """ + Timeslot Computer - Update All + Updates all dynamics generators, propagators and evolutions when + ctrl amplitudes are updated + """ + + def reset(self): + TimeslotComputer.reset(self) + self.id_text = "ALL" + self.apply_params() + + def compare_amps(self, new_amps): + """ + Determine if any amplitudes have changed. If so, then mark the + timeslots as needing recalculation + Returns: True if amplitudes are the same, False if they have changed + """ + changed = False + dyn = self.parent + + if dyn.stats or dyn.dump: + if self.evo_comp_summary: + self.evo_comp_summary.reset() + else: + self.evo_comp_summary = EvoCompSummary() + ecs = self.evo_comp_summary + + if dyn.ctrl_amps is None: + # Flag fidelity and gradients as needing recalculation + changed = True + if ecs: + ecs.num_amps_changed = len(new_amps.flat) + ecs.num_timeslots_changed = new_amps.shape[0] + else: + # create boolean array with same shape as ctrl_amps + # True where value in new_amps differs, otherwise false + changed_amps = dyn.ctrl_amps != new_amps + if np.any(changed_amps): + # Flag fidelity and gradients as needing recalculation + changed = True + if self.log_level <= logging.DEBUG: + logger.debug( + "{} amplitudes changed".format(changed_amps.sum()) + ) + + if ecs: + ecs.num_amps_changed = changed_amps.sum() + ecs.num_timeslots_changed = np.any(changed_amps, 1).sum() + + else: + if self.log_level <= logging.DEBUG: + logger.debug("No amplitudes changed") + + # *** update stats *** + if dyn.stats: + dyn.stats.num_ctrl_amp_updates += bool(ecs.num_amps_changed) + dyn.stats.num_ctrl_amp_changes += ecs.num_amps_changed + dyn.stats.num_timeslot_changes += ecs.num_timeslots_changed + + if changed: + dyn.ctrl_amps = new_amps + dyn.flag_system_changed() + return False + else: + return True + + def recompute_evolution(self): + """ + Recalculates the evolution operators. + Dynamics generators (e.g. Hamiltonian) and + prop (propagators) are calculated as necessary + """ + + dyn = self.parent + prop_comp = dyn.prop_computer + n_ts = dyn.num_tslots + n_ctrls = dyn.num_ctrls + + # Clear the public lists + # These are only set if (external) users access them + dyn._dyn_gen_qobj = None + dyn._prop_qobj = None + dyn._prop_grad_qobj = None + dyn._fwd_evo_qobj = None + dyn._onwd_evo_qobj = None + dyn._onto_evo_qobj = None + + if (dyn.stats or dyn.dump) and not self.evo_comp_summary: + self.evo_comp_summary = EvoCompSummary() + ecs = self.evo_comp_summary + + if dyn.stats is not None: + dyn.stats.num_tslot_recompute += 1 + if self.log_level <= logging.DEBUG: + logger.log( + logging.DEBUG, + "recomputing evolution {} " + "(UpdateAll)".format(dyn.stats.num_tslot_recompute), + ) + + # calculate the Hamiltonians + if ecs: + time_start = timeit.default_timer() + for k in range(n_ts): + dyn._combine_dyn_gen(k) + if dyn._decomp_curr is not None: + dyn._decomp_curr[k] = False + + if ecs: + ecs.wall_time_dyn_gen_compute = timeit.default_timer() - time_start + + # calculate the propagators and the propagotor gradients + if ecs: + time_start = timeit.default_timer() + for k in range(n_ts): + if prop_comp.grad_exact and dyn.cache_prop_grad: + for j in range(n_ctrls): + if j == 0: + ( + dyn._prop[k], + dyn._prop_grad[k, j], + ) = prop_comp._compute_prop_grad(k, j) + if self.log_level <= logging.DEBUG_INTENSE: + logger.log( + logging.DEBUG_INTENSE, + "propagator {}:\n{:10.3g}".format( + k, self._prop[k] + ), + ) + else: + dyn._prop_grad[k, j] = prop_comp._compute_prop_grad( + k, j, compute_prop=False + ) + else: + dyn._prop[k] = prop_comp._compute_propagator(k) + + if ecs: + ecs.wall_time_prop_compute = timeit.default_timer() - time_start + + if ecs: + time_start = timeit.default_timer() + # compute the forward propagation + R = range(n_ts) + for k in R: + if dyn.oper_dtype == Qobj: + dyn._fwd_evo[k + 1] = dyn._prop[k] * dyn._fwd_evo[k] + else: + dyn._fwd_evo[k + 1] = dyn._prop[k].dot(dyn._fwd_evo[k]) + + if ecs: + ecs.wall_time_fwd_prop_compute = ( + timeit.default_timer() - time_start + ) + time_start = timeit.default_timer() + # compute the onward propagation + if dyn.fid_computer.uses_onwd_evo: + dyn._onwd_evo[n_ts - 1] = dyn._prop[n_ts - 1] + R = range(n_ts - 2, -1, -1) + for k in R: + if dyn.oper_dtype == Qobj: + dyn._onwd_evo[k] = dyn._onwd_evo[k + 1] * dyn._prop[k] + else: + dyn._onwd_evo[k] = dyn._onwd_evo[k + 1].dot(dyn._prop[k]) + + if dyn.fid_computer.uses_onto_evo: + R = range(n_ts - 1, -1, -1) + for k in R: + if dyn.oper_dtype == Qobj: + dyn._onto_evo[k] = dyn._onto_evo[k + 1] * dyn._prop[k] + else: + dyn._onto_evo[k] = dyn._onto_evo[k + 1].dot(dyn._prop[k]) + + if ecs: + ecs.wall_time_onwd_prop_compute = ( + timeit.default_timer() - time_start + ) + + if dyn.stats: + dyn.stats.wall_time_dyn_gen_compute += ( + ecs.wall_time_dyn_gen_compute + ) + dyn.stats.wall_time_prop_compute += ecs.wall_time_prop_compute + dyn.stats.wall_time_fwd_prop_compute += ( + ecs.wall_time_fwd_prop_compute + ) + dyn.stats.wall_time_onwd_prop_compute += ( + ecs.wall_time_onwd_prop_compute + ) + + if dyn.unitarity_check_level: + dyn.check_unitarity() + + if dyn.dump: + self.dump_current() + + def get_timeslot_for_fidelity_calc(self): + """ + Returns the timeslot index that will be used calculate current fidelity + value. + This (default) method simply returns the last timeslot + """ + _func_deprecation( + "'get_timeslot_for_fidelity_calc' is deprecated. " + "Use '_get_timeslot_for_fidelity_calc'" + ) + return self._get_timeslot_for_fidelity_calc + + def _get_timeslot_for_fidelity_calc(self): + """ + Returns the timeslot index that will be used calculate current fidelity + value. + This (default) method simply returns the last timeslot + """ + return self.parent.num_tslots + + +class TSlotCompDynUpdate(TimeslotComputer): + """ + Timeslot Computer - Dynamic Update + + .. warning:: + + CURRENTLY HAS ISSUES (AJGP 2014-10-02) + and is therefore not being maintained + i.e. changes made to _UpdateAll are not being implemented here + + Updates only the dynamics generators, propagators and evolutions as + required when a subset of the ctrl amplitudes are updated. + Will update all if all amps have changed. + """ + + def reset(self): + self.dyn_gen_recalc = None + self.prop_recalc = None + self.evo_init2t_recalc = None + self.evo_t2targ_recalc = None + self.dyn_gen_calc_now = None + self.prop_calc_now = None + self.evo_init2t_calc_now = None + self.evo_t2targ_calc_now = None + TimeslotComputer.reset(self) + self.id_text = "DYNAMIC" + self.apply_params() + + def init_comp(self): + """ + Initialise the flags + """ + #### + # These maps are used to determine what needs to be updated + #### + # Note _recalc means the value needs updating at some point + # e.g. here no values have been set, except the initial and final + # evolution operator vals (which never change) and hence all other + # values are set as requiring calculation. + n_ts = self.parent.num_tslots + self.dyn_gen_recalc = np.ones(n_ts, dtype=bool) + # np.ones(n_ts, dtype=bool) + self.prop_recalc = np.ones(n_ts, dtype=bool) + self.evo_init2t_recalc = np.ones(n_ts + 1, dtype=bool) + self.evo_init2t_recalc[0] = False + self.evo_t2targ_recalc = np.ones(n_ts + 1, dtype=bool) + self.evo_t2targ_recalc[-1] = False + + # The _calc_now map is used to during the calcs to specify + # which values need updating immediately + self.dyn_gen_calc_now = np.zeros(n_ts, dtype=bool) + self.prop_calc_now = np.zeros(n_ts, dtype=bool) + self.evo_init2t_calc_now = np.zeros(n_ts + 1, dtype=bool) + self.evo_t2targ_calc_now = np.zeros(n_ts + 1, dtype=bool) + + def compare_amps(self, new_amps): + """ + Determine which timeslots will have changed Hamiltonians + i.e. any where control amplitudes have changed for that slot + and mark (using masks) them and corresponding exponentiations and + time evo operators for update + Returns: True if amplitudes are the same, False if they have changed + """ + dyn = self.parent + n_ts = dyn.num_tslots + # create boolean array with same shape as ctrl_amps + # True where value in New_amps differs, otherwise false + if self.parent.ctrl_amps is None: + changed_amps = np.ones(new_amps.shape, dtype=bool) + else: + changed_amps = self.parent.ctrl_amps != new_amps + + if self.log_level <= logging.DEBUG_VERBOSE: + logger.log( + logging.DEBUG_VERBOSE, "changed_amps:\n{}".format(changed_amps) + ) + # create Boolean vector with same length as number of timeslots + # True where any of the amplitudes have changed, otherwise false + changed_ts_mask = np.any(changed_amps, 1) + # if any of the amplidudes have changed then mark for recalc + if np.any(changed_ts_mask): + self.dyn_gen_recalc[changed_ts_mask] = True + self.prop_recalc[changed_ts_mask] = True + dyn.ctrl_amps = new_amps + if self.log_level <= logging.DEBUG: + logger.debug("Control amplitudes updated") + # find first and last changed dynamics generators + first_changed = None + for i in range(n_ts): + if changed_ts_mask[i]: + last_changed = i + if first_changed is None: + first_changed = i + + # set all fwd evo ops after first changed Ham to be recalculated + self.evo_init2t_recalc[first_changed + 1 :] = True + # set all bkwd evo ops up to (incl) last changed Ham to be + # recalculated + self.evo_t2targ_recalc[: last_changed + 1] = True + + # Flag fidelity and gradients as needing recalculation + dyn.flag_system_changed() + + # *** update stats *** + if dyn.stats is not None: + dyn.stats.num_ctrl_amp_updates += 1 + dyn.stats.num_ctrl_amp_changes += changed_amps.sum() + dyn.stats.num_timeslot_changes += changed_ts_mask.sum() + + return False + else: + return True + + def flag_all_calc_now(self): + """ + Flags all Hamiltonians, propagators and propagations to be + calculated now + """ + # set flags for calculations + self.dyn_gen_calc_now[:] = True + self.prop_calc_now[:] = True + self.evo_init2t_calc_now[:-1] = True + self.evo_t2targ_calc_now[1:] = True + + def recompute_evolution(self): + """ + Recalculates the evo_init2t (forward) and evo_t2targ (onward) time + evolution operators + DynGen (Hamiltonians etc) and prop (propagator) are calculated + as necessary + """ + if self.log_level <= logging.DEBUG_VERBOSE: + logger.log( + logging.DEBUG_VERBOSE, "recomputing evolution " "(DynUpdate)" + ) + + dyn = self.parent + n_ts = dyn.num_tslots + # find the op slots that have been marked for update now + # and need recalculation + evo_init2t_recomp_now = ( + self.evo_init2t_calc_now & self.evo_init2t_recalc + ) + evo_t2targ_recomp_now = ( + self.evo_t2targ_calc_now & self.evo_t2targ_recalc + ) + + # to recomupte evo_init2t, will need to start + # at a cell that has been computed + if np.any(evo_init2t_recomp_now): + for k in range(n_ts, 0, -1): + if evo_init2t_recomp_now[k] and self.evo_init2t_recalc[k - 1]: + evo_init2t_recomp_now[k - 1] = True + + # for evo_t2targ, will also need to start + # at a cell that has been computed + if np.any(evo_t2targ_recomp_now): + for k in range(0, n_ts): + if evo_t2targ_recomp_now[k] and self.evo_t2targ_recalc[k + 1]: + evo_t2targ_recomp_now[k + 1] = True + + # determine which dyn gen and prop need recalculating now in order to + # calculate the forwrd and onward evolutions + prop_recomp_now = ( + evo_init2t_recomp_now[1:] + | evo_t2targ_recomp_now[:-1] + | self.prop_calc_now[:] + ) & self.prop_recalc[:] + dyn_gen_recomp_now = ( + prop_recomp_now[:] | self.dyn_gen_calc_now[:] + ) & self.dyn_gen_recalc[:] + + if np.any(dyn_gen_recomp_now): + time_start = timeit.default_timer() + for k in range(n_ts): + if dyn_gen_recomp_now[k]: + # calculate the dynamics generators + dyn.dyn_gen[k] = dyn.compute_dyn_gen(k) + self.dyn_gen_recalc[k] = False + if dyn.stats is not None: + dyn.stats.num_dyn_gen_computes += dyn_gen_recomp_now.sum() + dyn.stats.wall_time_dyn_gen_compute += ( + timeit.default_timer() - time_start + ) + + if np.any(prop_recomp_now): + time_start = timeit.default_timer() + for k in range(n_ts): + if prop_recomp_now[k]: + # calculate exp(H) and other per H computations needed for + # the gradient function + dyn.prop[k] = dyn._compute_propagator(k) + self.prop_recalc[k] = False + if dyn.stats is not None: + dyn.stats.num_prop_computes += prop_recomp_now.sum() + dyn.stats.wall_time_prop_compute += ( + timeit.default_timer() - time_start + ) + + # compute the forward propagation + if np.any(evo_init2t_recomp_now): + time_start = timeit.default_timer() + R = range(1, n_ts + 1) + for k in R: + if evo_init2t_recomp_now[k]: + dyn.evo_init2t[k] = dyn.prop[k - 1].dot( + dyn.evo_init2t[k - 1] + ) + self.evo_init2t_recalc[k] = False + if dyn.stats is not None: + dyn.stats.num_fwd_prop_step_computes += ( + evo_init2t_recomp_now.sum() + ) + dyn.stats.wall_time_fwd_prop_compute += ( + timeit.default_timer() - time_start + ) + + if np.any(evo_t2targ_recomp_now): + time_start = timeit.default_timer() + # compute the onward propagation + R = range(n_ts - 1, -1, -1) + for k in R: + if evo_t2targ_recomp_now[k]: + dyn.evo_t2targ[k] = dyn.evo_t2targ[k + 1].dot(dyn.prop[k]) + self.evo_t2targ_recalc[k] = False + if dyn.stats is not None: + dyn.stats.num_onwd_prop_step_computes += ( + evo_t2targ_recomp_now.sum() + ) + dyn.stats.wall_time_onwd_prop_compute += ( + timeit.default_timer() - time_start + ) + + # Clear calc now flags + self.dyn_gen_calc_now[:] = False + self.prop_calc_now[:] = False + self.evo_init2t_calc_now[:] = False + self.evo_t2targ_calc_now[:] = False + + def get_timeslot_for_fidelity_calc(self): + """ + Returns the timeslot index that will be used calculate current fidelity + value. Attempts to find a timeslot where the least number of propagator + calculations will be required. + Flags the associated evolution operators for calculation now + """ + dyn = self.parent + n_ts = dyn.num_tslots + kBothEvoCurrent = -1 + kFwdEvoCurrent = -1 + kUse = -1 + # If no specific timeslot set in config, then determine dynamically + if kUse < 0: + for k in range(n_ts): + # find first timeslot where both evo_init2t and + # evo_t2targ are current + if not self.evo_init2t_recalc[k]: + kFwdEvoCurrent = k + if not self.evo_t2targ_recalc[k]: + kBothEvoCurrent = k + break + + if kBothEvoCurrent >= 0: + kUse = kBothEvoCurrent + elif kFwdEvoCurrent >= 0: + kUse = kFwdEvoCurrent + else: + raise errors.FunctionalError( + "No timeslot found matching " "criteria" + ) + + self.evo_init2t_calc_now[kUse] = True + self.evo_t2targ_calc_now[kUse] = True + return kUse + + +class EvoCompSummary(qtrldump.DumpSummaryItem): + """ + A summary of the most recent time evolution computation + Used in stats calculations and for data dumping + + Attributes + ---------- + evo_dump_idx : int + Index of the linked :class:`dump.EvoCompDumpItem` + None if no linked item + + iter_num : int + Iteration number of the pulse optimisation + None if evolution compute outside of a pulse optimisation + + fid_func_call_num : int + Fidelity function call number of the pulse optimisation + None if evolution compute outside of a pulse optimisation + + grad_func_call_num : int + Gradient function call number of the pulse optimisation + None if evolution compute outside of a pulse optimisation + + num_amps_changed : int + Number of control timeslot amplitudes changed since previous + evolution calculation + + num_timeslots_changed : int + Number of timeslots in which any amplitudes changed since previous + evolution calculation + + wall_time_dyn_gen_compute : float + Time spent computing dynamics generators + (in seconds of elapsed time) + + wall_time_prop_compute : float + Time spent computing propagators (including and propagator gradients) + (in seconds of elapsed time) + + wall_time_fwd_prop_compute : float + Time spent computing the forward evolution of the system + see ``dynamics.fwd_evo`` + (in seconds of elapsed time) + + wall_time_onwd_prop_compute : float + Time spent computing the 'backward' evolution of the system + see ``dynamics.onwd_evo`` and ``dynamics.onto_evo`` + (in seconds of elapsed time) + """ + + min_col_width = 11 + summary_property_names = ( + "idx", + "evo_dump_idx", + "iter_num", + "fid_func_call_num", + "grad_func_call_num", + "num_amps_changed", + "num_timeslots_changed", + "wall_time_dyn_gen_compute", + "wall_time_prop_compute", + "wall_time_fwd_prop_compute", + "wall_time_onwd_prop_compute", + ) + + summary_property_fmt_type = ( + "d", + "d", + "d", + "d", + "d", + "d", + "d", + "g", + "g", + "g", + "g", + ) + + summary_property_fmt_prec = (0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3) + + def __init__(self): + self.reset() + + def reset(self): + qtrldump.DumpSummaryItem.reset(self) + self.evo_dump_idx = None + self.iter_num = None + self.fid_func_call_num = None + self.grad_func_call_num = None + self.num_amps_changed = 0 + self.num_timeslots_changed = 0 + self.wall_time_dyn_gen_compute = 0.0 + self.wall_time_prop_compute = 0.0 + self.wall_time_fwd_prop_compute = 0.0 + self.wall_time_onwd_prop_compute = 0.0 \ No newline at end of file