Repository: Qiskit/qiskit

Issue:
[RFC] Limit on amplitude of Gaussian (and other parametric pulses)
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues -->

### Information

- **Qiskit Terra version**: 0.17.0.dev0+49f415f
- **Python version**: 3.8
- **Operating system**: Windows

### What is the current behavior?

If a `Gaussian` pulse with amplitude > 1 is generated an error is generated.

### Steps to reproduce the problem

A minimal example:
```
from qiskit.pulse.library.parametric_pulses import Gaussian
g=Gaussian(100, 1.2, 10, 'test')
```


### What is the expected behavior?

The pulses should be independent of the hardware backend, so there should be no arbitrary limitations on the amplitude. For spin-qubit systems the pulses are used to define gate voltages, which are natural to define in terms of milivolts with typical ranges -1000 to +1000 mV.

On https://qiskit.org/documentation/tutorials/circuits_advanced/06_building_pulse_schedules.html the limit of 1 for the amplitude is mentioned, but without a reason why the limit is required at all.

There are other pulses, e.g. the `GaussianSquare`, which have similar limits.

### Suggested solutions

There are several options:

* Remove the limitation on pulse amplitude. Users who want to restrict the pulse amplitude should implement that in a backend
* Make the maximum pulse amplitude configurable (through a class variable or similar)
* Create new a new class for a gaussian pulse without the limitation of amplitude 1.

I would prefer the first option, but perhaps there are other considerations.







Discussion:
@taalexander Could you comment on whether a PR removing the hard-coded limit on the amplitude would be acceptable?
Hi @peendebak, we were having an internal discussion weighing the pros and cons this afternoon. We didn't come to a conclusion, but I'll get back to you early next week. 
@taalexander Any update on the issue? I would be interested in the discussion as well.
The current approach to pulse generation is designed to model a black box Hamiltonian with a control term of the form $\hat{H}_c = \sum \omega_k \nu_k(t) \hat{H}_k $ where $|\nu_k(t)| \leq 1$. This plays nicely with numerical optimal control techniques, maintaining consistent scaled calibrations as hardware changes/is recalibrated and the reality that it is often very difficult to truly understand the signal that the qubit is observing due to a myriad of transfer functions and externalities in hardware setups (I do understand that with some more technologies or additional experimental effort this is less of an issue).

For these reasons I am in favour of the current approach remaining. However, I do understand your needs and am open to ways we could work around them. Some ideas that come to mind are.

1. Adding a flag to pulse constructors to disable these checks. (your first suggestion)
2. Adding a unit/scaling value to the backend configuration that experimental providers could implement. Backend aware plotting/display methods could make use of these.
3. Adding the above unit/scaling value as a pulse attribute that may be used, eg., in your own example all would be fine if V was used instead of mV. (your second suggestion).

I would encourage others that have this issue/thoughts to comment on this issue.







I am also in favor of keeping the current unitless approach. That being said it would be good if the backends could communicate in the configuration the value and unit of the maximum pulse amplitude.
The modelelling of the Hamiltonian gives some insight into the reasons of the limit.

For spin-systems we use pulses for microwave channels and for gate channels. For the former the transfer functions are indeed hard to determine and we often work in dimensionless units. For the gate channels our AWG channels are connected to the quantum device via a bias-T. The bias-T combines the AWG pulses (fast timescale) with a DAC (slow timescale). This allows us to determine the amplitude of the AWG pulses in milivolts on the gates at the device level. (the impact of the pulses on the device gates on the qubit is more complex, although this can also be measured)

I agree with the remarks above that we should keep the unit out of the pulses. For our system the Gaussian pulse would have unit mV for the amplitude, and seconds (or nanoseconds) for the sigma, but this will strongly depend on the application. The scaling is another discussion (although they are related).

About the suggestions:

1. Adding a flag means basically a global variable in the system. Possible, but not very nice.

1b. An alternative option would be to subclass the Gaussian pulse to remove the check (or remove the check on GaussianPulse and subclass to GaussianPulseOptimalControl). 

2. Adding a scaling to the backend is certainly possible. I guess this already happens in the backends. (Most hardware we use have an output range that is not exactly +- 1 V, so there is some scaling involved)
But it would mean that many components should become aware of this scaling. In particular I would like the default qiskit pulse plotting to plot in the desired scale. Adding a backend or scale parameter (per channel) to the plotting routines might be quite some work and clutter the interfaces. 
3. Adding a scaling attribute to the pulse is backwards compatible, which is nice. But if would require all components using the scaling unit (plotting, backends, tooling) to be aware of the attribute.
4. Removing the bound of 1 for the Gaussian pulse seems like an option. This still allows interpretation in terms of optimal control. (but it would become the responsibility of the user to respect the limit of 1)

Adding a maximum pulse amplitude to the backend is a good idea. I guess these maximum values are already in the backends and it is a matter of exposing the values through an interface.

One more consideration. We might want to use some form of pulse arithmetic, e.g. adding two pulses:
```
d0=DriveChannel(0)
Gaussian(10, .4, .2, 'gauss') + Constant(10, .2, d0)
```
If pulses are constructed like this, then the checks for the amplitude should be about the combined pulse, not the individual pulses. (pulse artithmetic is not possible in qiskit as far as I am aware, not sure it is on the roadmap either)












Regarding the plotter, the v2 drawer available in next release will help you to modify vertical axes. Basically you can customize almost all appearance of the graph without codebase modification, i.e. You can create a callback function that can take some backend option and generate an axis object in the predetermined data format. Then you can create a new stylesheet with the callback. By setting the stylesheet, the graph is updated accordingly (I think having real-unit stylesheet is nice to have in qiskit).

Because qiskit pulse is a kind of high-level programming language, these backend details should be abstracted at least in the program representation level and properly handled on the back end side. However, I think having builder utility function for unit-unit less value conversion is still worth having, see implementation of `qiskit.pulse.builder.seconds_to_samples`. With this, you can write as follows

```python
with pulse.build(backend=my_spin_backend_with_scaling_info) as sched:
   chan = drive_channel(0)

   # assert hasattr(backend.defaults(), 'channel_scaling')
   amp = pulse.si_to_unitless(100, unit='mV')
   play(Gaussian(duration=160, amp=amp, sigma=40), chan)
```

I think pulse arithmetic is possible (also worth having for some special cases). Qiskit pulse doesn't allow time overlap of instructions, but these pulses are just an operand of it. So the pulse arithmetic doesn't violate this overlap constraint in principle. The downside of this is conversion of `ParametricPulses` into `Waveforms`, which drastically increases data volume of the qobj to be submitted. 
@taalexander The discussion above did not reach a conclusion yet. Although the various options for scaling and plotting could help, we feel that we really should define our pulses in the coordinate frame that is natural for us.

We decided on subclassing the pulses, e.g.
```
class Constant(qiskit.pulse.library.Constant):

    def validate_parameters(self) -> None:
        pass
```
That works well, except for one other location where we encountered the hard-coded limit:

https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/pulse/library/waveform.py#L93. 

This is harder to subclass, since the `Waveform` is used in all kinds of places.

Although we are still in favor of removing the hard-coded limit, an alternative would be to make a class attribute to disable the check, e.g.
```
class Waveform(Pulse):
    """A pulse specified completely by complex-valued samples; each sample is played for the
    duration of the backend cycle-time, dt.
    """
    
    limit_amplitude = True
    
    ...
    
        if np.any(samples_norm > 1.) and Waveform.limit_amplitude:
            raise PulseError('Pulse contains sample with norm greater than 1+epsilon.')

    ...
```
If that is an acceptable solution I can make a PR for that. I created #6345 to check the impact of removing the amplitude limit on the various unit tests.


Hi, @peendebak I believe this is an acceptable solution. My apologies for the delay in my response, my focus has been lower in the software stack recently.

> One more consideration. We might want to use some form of pulse arithmetic, e.g. adding two pulses:

Completely agree, please see #5977 where we're starting to put some of this machinery in place.

Resolving patch:
diff --git a/qiskit/pulse/library/parametric_pulses.py b/qiskit/pulse/library/parametric_pulses.py
--- a/qiskit/pulse/library/parametric_pulses.py
+++ b/qiskit/pulse/library/parametric_pulses.py
@@ -171,8 +171,11 @@ def get_waveform(self) -> Waveform:
         return gaussian(duration=self.duration, amp=self.amp, sigma=self.sigma, zero_ends=True)
 
     def validate_parameters(self) -> None:
-        if not _is_parameterized(self.amp) and abs(self.amp) > 1.0:
-            raise PulseError("The amplitude norm must be <= 1, " "found: {}".format(abs(self.amp)))
+        if not _is_parameterized(self.amp) and abs(self.amp) > 1.0 and self.limit_amplitude:
+            raise PulseError(
+                f"The amplitude norm must be <= 1, found: {abs(self.amp)}"
+                + "This can be overruled by setting Pulse.limit_amplitude."
+            )
         if not _is_parameterized(self.sigma) and self.sigma <= 0:
             raise PulseError("Sigma must be greater than 0.")
 
@@ -278,9 +281,11 @@ def get_waveform(self) -> Waveform:
         )
 
     def validate_parameters(self) -> None:
-
-        if not _is_parameterized(self.amp) and abs(self.amp) > 1.0:
-            raise PulseError("The amplitude norm must be <= 1, " "found: {}".format(abs(self.amp)))
+        if not _is_parameterized(self.amp) and abs(self.amp) > 1.0 and self.limit_amplitude:
+            raise PulseError(
+                f"The amplitude norm must be <= 1, found: {abs(self.amp)}"
+                + "This can be overruled by setting Pulse.limit_amplitude."
+            )
         if not _is_parameterized(self.sigma) and self.sigma <= 0:
             raise PulseError("Sigma must be greater than 0.")
         if self.width is not None and self.risefall_sigma_ratio is not None:
@@ -416,8 +421,11 @@ def get_waveform(self) -> Waveform:
         )
 
     def validate_parameters(self) -> None:
-        if not _is_parameterized(self.amp) and abs(self.amp) > 1.0:
-            raise PulseError("The amplitude norm must be <= 1, " "found: {}".format(abs(self.amp)))
+        if not _is_parameterized(self.amp) and abs(self.amp) > 1.0 and self.limit_amplitude:
+            raise PulseError(
+                f"The amplitude norm must be <= 1, found: {abs(self.amp)}"
+                + "This can be overruled by setting Pulse.limit_amplitude."
+            )
         if not _is_parameterized(self.sigma) and self.sigma <= 0:
             raise PulseError("Sigma must be greater than 0.")
         if not _is_parameterized(self.beta) and isinstance(self.beta, complex):
@@ -506,8 +514,11 @@ def get_waveform(self) -> Waveform:
         return constant(duration=self.duration, amp=self.amp)
 
     def validate_parameters(self) -> None:
-        if not _is_parameterized(self.amp) and abs(self.amp) > 1.0:
-            raise PulseError("The amplitude norm must be <= 1, " "found: {}".format(abs(self.amp)))
+        if not _is_parameterized(self.amp) and abs(self.amp) > 1.0 and self.limit_amplitude:
+            raise PulseError(
+                f"The amplitude norm must be <= 1, found: {abs(self.amp)}"
+                + "This can be overruled by setting Pulse.limit_amplitude."
+            )
 
     @property
     def parameters(self) -> Dict[str, Any]:
diff --git a/qiskit/pulse/library/pulse.py b/qiskit/pulse/library/pulse.py
--- a/qiskit/pulse/library/pulse.py
+++ b/qiskit/pulse/library/pulse.py
@@ -26,10 +26,30 @@ class Pulse(ABC):
     modulation phase and frequency are specified separately from ``Pulse``s.
     """
 
+    limit_amplitude = True
+
     @abstractmethod
-    def __init__(self, duration: Union[int, ParameterExpression], name: Optional[str] = None):
+    def __init__(
+        self,
+        duration: Union[int, ParameterExpression],
+        name: Optional[str] = None,
+        limit_amplitude: Optional[bool] = None,
+    ):
+        """Abstract base class for pulses
+        Args:
+            duration: Duration of the pulse
+            name: Optional name for the pulse
+            limit_amplitude: If ``True``, then limit the amplitude of the waveform to 1.
+                             The default value of ``None`` causes the flag value to be
+                             derived from :py:attr:`~limit_amplitude` which is ``True``
+                             by default but may be set by the user to disable amplitude
+                             checks globally.
+        """
+
         self.duration = duration
         self.name = name
+        if limit_amplitude is not None:
+            self.limit_amplitude = limit_amplitude
 
     @property
     def id(self) -> int:  # pylint: disable=invalid-name
diff --git a/qiskit/pulse/library/waveform.py b/qiskit/pulse/library/waveform.py
--- a/qiskit/pulse/library/waveform.py
+++ b/qiskit/pulse/library/waveform.py
@@ -31,6 +31,7 @@ def __init__(
         samples: Union[np.ndarray, List[complex]],
         name: Optional[str] = None,
         epsilon: float = 1e-7,
+        limit_amplitude: Optional[bool] = None,
     ):
         """Create new sample pulse command.
 
@@ -41,12 +42,13 @@ def __init__(
                 If any sample's norm exceeds unity by less than or equal to epsilon
                 it will be clipped to unit norm. If the sample
                 norm is greater than 1+epsilon an error will be raised.
+            limit_amplitude: Passed to parent Pulse
         """
 
+        super().__init__(duration=len(samples), name=name, limit_amplitude=limit_amplitude)
         samples = np.asarray(samples, dtype=np.complex_)
         self.epsilon = epsilon
         self._samples = self._clip(samples, epsilon=epsilon)
-        super().__init__(duration=len(samples), name=name)
 
     @property
     def samples(self) -> np.ndarray:
@@ -95,8 +97,12 @@ def _clip(self, samples: np.ndarray, epsilon: float = 1e-7) -> np.ndarray:
             samples[clip_where] = clipped_samples
             samples_norm[clip_where] = np.abs(clipped_samples)
 
-        if np.any(samples_norm > 1.0):
-            raise PulseError("Pulse contains sample with norm greater than 1+epsilon.")
+        if np.any(samples_norm > 1.0) and self.limit_amplitude:
+            amp = np.max(samples_norm)
+            raise PulseError(
+                f"Pulse contains sample with norm {amp} greater than 1+epsilon."
+                " This can be overruled by setting Pulse.limit_amplitude."
+            )
 
         return samples
 


Test patch:
diff --git a/test/python/pulse/test_pulse_lib.py b/test/python/pulse/test_pulse_lib.py
--- a/test/python/pulse/test_pulse_lib.py
+++ b/test/python/pulse/test_pulse_lib.py
@@ -13,8 +13,10 @@
 """Unit tests for pulse waveforms."""
 
 import unittest
+from unittest.mock import patch
 import numpy as np
 
+import qiskit
 from qiskit.pulse.library import (
     Waveform,
     Constant,
@@ -79,6 +81,17 @@ def test_pulse_limits(self):
         with self.assertRaises(PulseError):
             Waveform(invalid_const * np.exp(1j * 2 * np.pi * np.linspace(0, 1, 1000)))
 
+        invalid_const = 1.1
+        Waveform.limit_amplitude = False
+        wave = Waveform(invalid_const * np.exp(1j * 2 * np.pi * np.linspace(0, 1, 1000)))
+        self.assertGreater(np.max(np.abs(wave.samples)), 1.0)
+        with self.assertRaises(PulseError):
+            wave = Waveform(
+                invalid_const * np.exp(1j * 2 * np.pi * np.linspace(0, 1, 1000)),
+                limit_amplitude=True,
+            )
+        Waveform.limit_amplitude = True
+
         # Test case where data is converted to python types with complex as a list
         # with form [re, im] and back to a numpy array.
         # This is how the transport layer handles samples in the qobj so it is important
@@ -199,6 +212,12 @@ def test_constant_samples(self):
         self.assertEqual(const.get_waveform().samples[0], 0.1 + 0.4j)
         self.assertEqual(len(const.get_waveform().samples), 150)
 
+        with self.assertRaises(PulseError):
+            const = Constant(duration=150, amp=1.1 + 0.4j)
+
+        with patch("qiskit.pulse.library.parametric_pulses.Pulse.limit_amplitude", new=False):
+            const = qiskit.pulse.library.parametric_pulses.Constant(duration=150, amp=0.1 + 0.4j)
+
     def test_parameters(self):
         """Test that the parameters can be extracted as a dict through the `parameters`
         attribute."""