Repository: dwavesystems/dwave-system
Pull request #570 (2025-05-09 21:23:31)

Generate energy scales for a custom annealing schedule 
For DOC-759, will need to be referenced from [Varying the Global Anneal Schedule](https://docs.dwavequantum.com/en/latest/quantum_research/annealing.html#varying-the-global-anneal-schedule) page once in the SDK.


Patch:
diff --git a/docs/utilities.rst b/docs/utilities.rst
index 3eac69e..59a9649 100644
--- a/docs/utilities.rst
+++ b/docs/utilities.rst
@@ -13,6 +13,7 @@ Utilities
 
    anneal_schedule_with_offset
    common_working_graph
+   energy_scales_custom_schedule
 
 .. currentmodule:: dwave.system.coupling_groups
 
diff --git a/dwave/system/utilities.py b/dwave/system/utilities.py
index 6e6989b..006e6ad 100644
--- a/dwave/system/utilities.py
+++ b/dwave/system/utilities.py
@@ -26,6 +26,7 @@ __all__ = [
     'anneal_schedule_with_offset',
     'classproperty',
     'common_working_graph',
+    'energy_scales_custom_schedule',
     ]
 
 
@@ -228,3 +229,190 @@ def anneal_schedule_with_offset(
     c_offset= c + anneal_offset
 
     return np.column_stack((s, A_offset, B_offset, c_offset))
+
+
+def energy_scales_custom_schedule(
+        default_schedule: Union[np.typing.ArrayLike, list[list[float]], None] = None,
+        s: Union[np.typing.ArrayLike, list[float], None] = None,
+        A: Union[np.typing.ArrayLike, list[float], None] = None,
+        B: Union[np.typing.ArrayLike, list[float], None] = None,
+        c: Union[np.typing.ArrayLike, list[float], None] = None,
+        custom_schedule: Union[np.typing.ArrayLike, list[list[float]], None] = None,
+        custom_t: Union[np.typing.ArrayLike, list[float], None] = None,
+        custom_s: Union[np.typing.ArrayLike, list[float], None] = None,
+    ) -> np.ndarray:
+    r"""Generates the energy scales for a custom anneal schedule.
+
+    The standard annealing trajectory, published for each quantum computer on
+    :ref:`this <qpu_solver_properties_specific>` page, lowers :math:`A(s)`, the
+    tunneling energy, and raises :math:`B(s)`, the problem energy, according to
+    a  default schedule. You can customize that schedule as described in the
+    :ref:`qpu_qa_anneal_sched` section of the :ref:`qpu_annealing` page.
+
+    This function accepts a quantum computer's default anneal schedule and your
+    custom schedule (as defined by the :ref:`parameter_qpu_anneal_schedule`
+    parameter), and returns the energy scales as a function of time.
+
+    Args:
+        default_schedule:
+            Anneal schedule, as a 4-column |array-like|_, with column values for
+            :math:`s, A, B, c` as provided by (and typically taken from) the
+            spreadsheet columns of the published
+            :ref:`Per-QPU Solver Properties and Schedules <qpu_solver_properties_specific>`
+            page. If set, do not set parameters ``s``, ``A``, ``B``, and ``c``.
+        s: Normalized anneal fraction, :math:`\frac{t}{t_a}`, which ranges from
+            0 to 1, where :math:`t_a` is the annealing duration, as a
+            1-dimensional |array-like|_. If set ``anneal_schedule`` must be
+            ``None`` and values must be provided for ``A``, ``B``, and ``c``.
+        A: Transverse or tunneling energy, :math:`A(s)`, as a 1-dimensional
+            |array-like|_. If set ``anneal_schedule`` must be ``None`` and
+            values must be provided for ``s``, ``B``, and ``c``.
+        B: Energy applied to the problem Hamiltonian, :math:`B(s)`, as a
+            1-dimensional |array-like|_. If set ``anneal_schedule`` must be
+            ``None`` and values must be provided for ``s``, ``A``, and ``c``.
+        c: Normalized annealing bias, :math:`c(s)`, as a 1-dimensional
+            |array-like|_. If set ``anneal_schedule`` must be ``None`` and
+            values must be provided for ``s``, ``A``, and ``B``.
+        custom_schedule:
+            Your custom anneal schedule, as a 2-column |array-like|_, with
+            column values for time, :math:`t`, and the normalized anneal
+            fraction, :math:`s`. Must meet the rules described for the
+            :ref:`parameter_qpu_anneal_schedule` parameter. If set, do not set
+            parameters ``custom_t`` or ``custom_s``.
+        custom_t: Time, :math:`t`, as a 1-dimensional |array-like|_, compliant
+            with the rules described for the
+            :ref:`parameter_qpu_anneal_schedule` parameter. If set
+            ``anneal_schedule`` must be ``None`` and ``custom_s`` must be
+            provided too.
+        custom_s: Normalized anneal fraction, :math:`\frac{t}{t_a}`, as a
+            1-dimensional |array-like|_, compliant with the rules described for
+            the :ref:`parameter_qpu_anneal_schedule` parameter.. If set
+            ``anneal_schedule`` must be ``None`` and ``custom_t`` must be
+            provided too.
+
+    Returns:
+        Energy scales :math:`A(s), B(s)`, and :math:`c(s)`, as a
+        :std:doc:`NumPy <numpy:index>` array with columns :math:`t, s, A, B, c`.
+
+    Note:
+        You can prepare the input schedule by downloading the schedule for your
+        selected quantum computer on the
+        :ref:`Per-QPU Solver Properties and Schedules <qpu_solver_properties_specific>`
+        page, saving the schedule tab in CSV format, and using NumPy's
+        :func:`~numpy.loadtxt` function, as here:
+        .. doctest::
+            :skipif: True
+            >>> import numpy as np
+            >>> schedule = np.loadtxt(schedule_csv_filename, delimiter=",", skiprows=1)
+
+    Examples:
+        For a default schedule provided as array :code:`schedule_qpu3`, this example
+        returns the energy scales for a reverse anneal.
+
+        >>> from dwave.system import energy_scales_custom_schedule
+        ...
+        >>> anneal_schedule = [[0.0, 1.0], [5, 0.45], [99, 0.45], [100, 1.0]]
+        >>> energy_schedule = energy_scales_custom_schedule(
+        ...     schedule_qpu3,
+        ...     custom_schedule=anneal_schedule)  # doctest: +SKIP
+    """
+    if default_schedule is not None and (
+        s is not None or A is not None or B is not None or c is not None):
+
+            raise ValueError("Either `default_schedule` or `s, A, B, c`"
+                f" can be specified. Got both inputs.")
+
+    if default_schedule is None and (
+        s is None or A is None or B is None or c is None):
+
+            raise ValueError("If `default_schedule` is unspecified, you must"
+                f" specify all of `s, A, B, c`. Not all were specified.")
+
+    if custom_schedule is not None and (
+        custom_t is not None or custom_s is not None):
+
+            raise ValueError("Either `custom_schedule` or `custom_t, custom_s`"
+                f" can be specified. Got both inputs.")
+
+    if custom_schedule is None and (
+        custom_t is None or custom_s is None):
+
+            raise ValueError("If `custom_schedule` is unspecified, you must"
+                f" specify `custom_t and custom_s`. Both were not specified.")
+
+    if default_schedule is not None:
+
+        schedule = _asarray('default_schedule', default_schedule, 4)
+
+        s = schedule[:, 0]
+        A = schedule[:, 1]
+        B = schedule[:, 2]
+        c = schedule[:, 3]
+
+    else:
+
+        s = _asarray('s', s, 1)
+        A = _asarray('A', A, 1)
+        B = _asarray('B', B, 1)
+        c = _asarray('c', c, 1)
+
+    if custom_schedule is not None:
+
+        schedule = _asarray('custom_schedule', custom_schedule, 2)
+
+        custom_t = schedule[:, 0]
+        custom_s = schedule[:, 1]
+
+    else:
+
+        custom_t = _asarray('custom_t', custom_t, 1)
+        custom_s = _asarray('custom_s', custom_s, 1)
+
+    precision_s = -np.log10(np.median(np.diff(s)))
+    custom_s = np.round(custom_s, decimals=int(precision_s))
+
+    out = np.empty((0, 5))
+
+    for index in range(1, len(custom_s)):
+
+        if custom_s[index] == custom_s[index - 1]:  # This is a pause interval
+
+            s_index = np.where(s == custom_s[index])
+            out_interval = np.vstack((
+                custom_t[index - 1],
+                s[s_index],
+                A[s_index],
+                B[s_index],
+                c[s_index])).T
+
+        else:   # This is a sloped interval
+
+            forward_anneal = custom_s[index] > custom_s[index - 1]
+
+            if forward_anneal:
+                interval = (s <= custom_s[index]) & (s >= custom_s[index - 1])
+            else:
+                interval = (s >= custom_s[index]) & (s <= custom_s[index - 1])
+
+            t_interp = np.interp(
+                s[interval],
+                sorted([custom_s[index - 1], custom_s[index]]),
+                [custom_t[index - 1], custom_t[index]])
+
+            s_scales = np.stack((
+                s[interval],
+                A[interval],
+                B[interval],
+                c[interval]))
+
+            out_interval = np.vstack((
+                t_interp,
+                s_scales if forward_anneal else np.flip(s_scales, axis=1))).T
+
+            # Cut overlapped interval seams (except last interval)
+            if index < len(custom_s) - 1:
+                out_interval = out_interval[:-1,:]
+
+        out = np.append(out, out_interval, axis=0)
+
+    return out
\ No newline at end of file


Test patch:
diff --git a/tests/test_utilities.py b/tests/test_utilities.py
index 5ff7ba8..1b236f3 100644
--- a/tests/test_utilities.py
+++ b/tests/test_utilities.py
@@ -21,7 +21,8 @@ import dwave_networkx as dnx
 
 from dwave.cloud.testing import isolated_environ
 
-from dwave.system import anneal_schedule_with_offset, common_working_graph
+from dwave.system import (anneal_schedule_with_offset, common_working_graph,
+    energy_scales_custom_schedule)
 from dwave.system.utilities import FeatureFlags
 
 
@@ -206,4 +207,170 @@ class TestAnnealScheduleWithOffset(unittest.TestCase):
                 A = np.asarray(anneal_schedule)[:,1:3],
                 B = np.asarray(anneal_schedule)[:,2],
                 c = np.asarray(anneal_schedule)[:,3],
-                )
\ No newline at end of file
+                )
+
+
+class TestEnergyScalesCustomSchedule(unittest.TestCase):
+
+    schedule_default = [
+        [ 0. ,  9.3,  0. ,  0.1],
+        [ 0.1,  9. ,  0.5,  0.2],
+        [ 0.2,  8. ,  1.2,  0.4],
+        [ 0.3,  7. ,  1.7,  0.5],
+        [ 0.4,  6. ,  3. ,  0.7],
+        [ 0.5,  5. ,  5. ,  0.8],
+        [ 0.6,  4. ,  7. ,  1. ],
+        [ 0.7,  3. ,  9. ,  2. ],
+        [ 0.8,  2. , 12. ,  3. ],
+        [ 0.9,  1. , 14. ,  4. ],
+        [ 1. ,  0. , 16. ,  8. ]]
+
+    def test_input_verification(self):
+
+        simple_forward_schedule = [
+            [0, 0],
+            [20, 0.3],
+            [100, 1]]
+
+        # Schedules for both, as lists
+        out= energy_scales_custom_schedule(
+            default_schedule=self.schedule_default,
+            custom_schedule=simple_forward_schedule)
+        self.assertEqual(len(out), len(self.schedule_default))
+
+        # Schedule for one, vectors for the other, as lists
+        out= energy_scales_custom_schedule(
+            s=[s/10 for s in range(11)],
+            A=[a for a in range(10,-1,-1)],
+            B=[b for b in range(11)],
+            c=[c/8 for c in range(11)],
+            custom_schedule=simple_forward_schedule)
+
+        # Numpy schedule and mix of Numpy and list for vectors
+        out= energy_scales_custom_schedule(
+            s=[s/10 for s in range(11)],
+            A=np.asarray([a for a in range(10,-1,-1)]),
+            B=[b for b in range(11)],
+            c=np.asarray([c/8 for c in range(11)]),
+            custom_schedule=np.asarray(simple_forward_schedule))
+
+        # Both schedule and vector
+        with self.assertRaises(ValueError):
+            energy_scales_custom_schedule(
+                default_schedule=self.schedule_default,
+                s=[s/10 for s in range(11)],
+                custom_schedule=simple_forward_schedule)
+
+        # Both schedule and vector
+        with self.assertRaises(ValueError):
+            energy_scales_custom_schedule(
+                default_schedule=self.schedule_default,
+                s=[s/10 for s in range(11)],
+                custom_schedule=simple_forward_schedule)
+
+        # Missing schedule
+        with self.assertRaises(ValueError):
+            energy_scales_custom_schedule(
+                default_schedule=self.schedule_default)
+
+        # Missing vector
+        with self.assertRaises(ValueError):
+            energy_scales_custom_schedule(
+                s=[s/10 for s in range(11)],
+                A=np.asarray([a for a in range(10,-1,-1)]),
+                B=[b for b in range(11)],
+                custom_schedule=simple_forward_schedule)
+
+    def test_various_schedules(self):
+
+        # Simple forward schedule that coincides with default schedule's s intervals
+        schedule = [
+            [0, 0],
+            [20, 0.3],
+            [100, 1]]
+        out= energy_scales_custom_schedule(
+            default_schedule=self.schedule_default,
+            custom_schedule=schedule)
+        self.assertEqual(np.shape(out), (11,5))
+
+        # Simple forward schedule with noisy s intervals
+        schedule = np.asarray([
+            [0, 0],
+            [20, 0.31],
+            [50, 0.69],
+            [80, 0.9],
+            [100, 1]])
+        out= energy_scales_custom_schedule(
+            default_schedule=self.schedule_default,
+            custom_schedule=schedule)
+        self.assertEqual(np.shape(out), (11,5))
+
+        # Test that at the interval seams, the interpolated time is closest
+        np.testing.assert_equal(
+            [np.argmin(np.abs(out[:,1] - s)) for s in np.asarray(schedule)[:,1]],
+            [np.argmin(np.abs(out[:,0] - t)) for t in np.asarray(schedule)[:,0]])
+
+        # Forward schedule with pauses
+        schedule = np.asarray([
+            [0, 0],
+            [20, 0.3],
+            [50, 0.3],
+            [80, 0.8],
+            [90, 0.8],
+            [100, 0.8],
+            [200, 1]])
+        out= energy_scales_custom_schedule(
+            default_schedule=self.schedule_default,
+            custom_schedule=schedule)
+        self.assertEqual(np.shape(out), (11 + 3,5))    #Three pauses
+
+        # Forward schedule with pauses and noisy intervals
+        schedule = np.asarray([
+            [0, 0],
+            [20, 0.31],
+            [50, 0.31],
+            [80, 0.78],
+            [90, 0.78],
+            [100, 0.78],
+            [200, 1]])
+        out= energy_scales_custom_schedule(
+            default_schedule=self.schedule_default,
+            custom_schedule=schedule)
+        self.assertEqual(np.shape(out), (11 + 3,5))    #Three pauses
+        # Where interpolated time is closest to custom time, custom s is closest to s
+        min_t_inx = [np.argmin(np.abs(out[:,0] - t)) for t in np.asarray(schedule)[:,0]]
+        np.testing.assert_allclose(
+            out[min_t_inx][:,1],
+            schedule[:,1],
+            atol=0.1)
+
+        # Simple reverse schedule that coincides with default schedule's s intervals
+        schedule = [
+            [0, 1],
+            [20, 0.3],
+            [100, 1]]
+        out= energy_scales_custom_schedule(
+            default_schedule=self.schedule_default,
+            custom_schedule=schedule)
+        self.assertEqual(np.shape(out), (2*0.7*10 + 1,5))
+
+        # Messy reverse anneal schedule
+        t_expected = np.asarray(
+            [0, 6.66, 13.33, 20, 35, 50, 80, 83.33, 86.6, 90, 100, 150, 170, 200])
+        B_expected = np.asarray(
+            [16., 14., 12., 9.,  7.,  5.,  5.,  7.,  9., 12., 12., 12., 14., 16.])
+        schedule = np.asarray([
+            [0, 1],
+            [20, 0.7],
+            [50, 0.51],
+            [80, 0.51],
+            [90, 0.78],
+            [100, 0.78],
+            [150, 0.78],
+            [170, 0.9],
+            [200, 1]])
+        out= energy_scales_custom_schedule(
+            default_schedule=self.schedule_default,
+            custom_schedule=schedule)
+        np.testing.assert_allclose(out[:,0], t_expected, atol=1)
+        np.testing.assert_allclose(out[:,3], B_expected, atol=1)
\ No newline at end of file