Repository: PennyLaneAI/pennylane

Issue:
[BUG] `sum` not working with hardware Hamiltonians (transmons and rydberg)
### Expected behavior

Working with hardware Hamiltonians, we add them together with similar syntax to `H = H_interaction + H_drive`. Often, we repeatedly add terms manually, e.g., `H_drive += qml.pulse.transmon_drive(amplitude, phase, freq, wires)`.

I would expect to be able to use `sum()` on a list of terms, such as:
`H_drive = sum([qml.pulse.transmon_drive(amplitude, phase, freq, q) for q in wires])`
Similar to how we can add other operators in PennyLane `sum([qml.PauliX(i) for i in range(wires)])`.

### Actual behavior

We can't do it.

### Additional information

I have tested this with a few `HardwareHamiltonian` instances using `qml.pulse.transmon_interaction`, `qml.pulse.transmon_drive`, `qml.pulse.rydberg_interaction` and `qml.pulse.rydberg_drive`.

We can manipulate all of them with stuff such as:
```python
H = qml.pulse.transmon_interaction(qubit_freqs, connection, couplings, wires)
for q in wires:
    H += qml.pulse.transmon_drive(amplitude, phase, freq, q)
```
Or even:
```python
H_int = qml.pulse.transmon_interaction(qubit_freqs, connection, couplings, wires)
drive_terms = [qml.pulse.transmon_drive(amplitude, phase, freq, q) for q in range(2)]
H_drive = drive_terms[0] + drive_terms[1]
H = H_int + H_drive
```

But we wouldn't be able to use `sum()` on any iterable containing `HardwareHamiltonian`s. I also tried changing the input types, e.g., setting `amplitude`, `phase`, and `freq` to be all callables, floats, or ints, or a combination of those.

### Source code

```shell
import pennylane as qml
sum([qml.pulse.transmon_drive(0., 0., 0., q) for q in range(2)])
```


### Tracebacks

```shell
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[1], line 2
      1 import pennylane as qml
----> 2 sum([qml.pulse.transmon_drive(0., 0., 0., q) for q in range(2)])

TypeError: unsupported operand type(s) for +: 'int' and 'HardwareHamiltonian'
```


### System information

```shell
Latest master

Name: PennyLane
Version: 0.32.0.dev0
Summary: PennyLane is a Python quantum machine learning library by Xanadu Inc.
Home-page: https://github.com/PennyLaneAI/pennylane
Author: 
Author-email: 
License: Apache License 2.0
Location: /home/borja.requena/.conda/envs/pl_dev/lib/python3.10/site-packages
Editable project location: /home/borja.requena/GitHub/pennylane
Requires: appdirs, autograd, autoray, cachetools, networkx, numpy, pennylane-lightning, requests, rustworkx, scipy, semantic-version, toml
Required-by: PennyLane-Lightning

Platform info:           Linux-5.10.102.1-microsoft-standard-WSL2-x86_64-with-glibc2.31
Python version:          3.10.11
Numpy version:           1.23.5
Scipy version:           1.10.0
Installed devices:
- default.gaussian (PennyLane-0.32.0.dev0)
- default.mixed (PennyLane-0.32.0.dev0)
- default.qubit (PennyLane-0.32.0.dev0)
- default.qubit.autograd (PennyLane-0.32.0.dev0)
- default.qubit.jax (PennyLane-0.32.0.dev0)
- default.qubit.tf (PennyLane-0.32.0.dev0)
- default.qubit.torch (PennyLane-0.32.0.dev0)
- default.qutrit (PennyLane-0.32.0.dev0)
- null.qubit (PennyLane-0.32.0.dev0)
- lightning.qubit (PennyLane-Lightning-0.31.0)
```


### Existing GitHub issues

- [X] I have searched existing GitHub issues to make sure the issue does not already exist.


Discussion:
This bug is due to the addition of `HardwareHamiltonian` and `int` not being defined. `sum` initializes the output to the integer `0` and subsequently adds the elements of the iterable, so that this bug occurs.
In my opinion we have the following options:

1.  We take the current behaviour to be intended and require users to manually sum terms, or to provide a `start` value as in
```python
hamiltonians = [...]
H = sum(hamiltonians[1:], start=hamiltonians[0])
```
2. We support addition between "empty" hardware Hamiltonians (`HardwareHamiltonian([], [])`) and other instances by ignoring the empty instance. This would allow users to do
```python
H = sum(hamiltonians, start=qml.pulse.HardwareHamiltonian([], []))
```
Note, however, that this basically is as cumbersome/convenient as the first option, while requiring a modification of the source code.
3. We implement addition between `HardwareHamiltonian` and `int` (and `float`) by adding a constant term to the Hamiltonian. Optionally, we could skip this added term if the number that is being added is close to zero. Note, however, that this skipping step may break JIT-compatibility.
4. We only implement addition with the integer `0` as a special case. Note however, that this would fix this very specific use case in a rather arbitrary place of the codebase.
5. We make `qml.sum` support `HardwareHamiltonian` and defer users to use `qml.sum` over `sum`.


What do you think, @Qottmann , @lillian542 , @trbromley ? :) What other options are there?
> 3. We implement addition between HardwareHamiltonian and int (and float) by adding a constant term to the Hamiltonian.
> 5. We make qml.sum support HardwareHamiltonian and defer users to use qml.sum over sum.

Options 3 without the addition and/or 5 sound best to me, thanks for the summary @dwierichs !
I agree with @Qottmann on this and would go for the third option above, without adding the special treatment of 0. This would, however, add a mild overhead unless users create their sum of `HardwareHamiltonians` using the syntax shown in point 1 above, or by using the `+=` syntax shown by @BorjaRequena.
@trbromley what is your opinion on this one? :) 
Option 3 makes the most sense to me. @dwierichs what is the overhead that would be added?
For a minimal Hamiltonian from the example above, computing `qml.matrix(H)` takes 1.1 µs instead of 900ns.
**edit**: I misread. The Hamiltonian created with `sum` actually computes _faster_ o.O
Executing a simple QNode took 333ms instead of 360ms.

These are very small examples, so that the overhead kicks in the most and it should be less relevant for bigger examples.
Still, it's not nothing.

```python
import pennylane as qml
H1 = sum([qml.pulse.transmon_drive(0., qml.pulse.constant, 0., q) for q in range(2)])
H2 = qml.pulse.transmon_drive(0., qml.pulse.constant, 0., 0) + qml.pulse.transmon_drive(0., qml.pulse.constant, 0., 1)
print(H1.ops)
print(H2.ops)
%timeit qml.matrix(H1)
%timeit qml.matrix(H2)


dev = qml.device("default.qubit.jax", wires=2)

@qml.qnode(dev, interface="jax")
def node1(params):
    qml.evolve(H1)(params, 0.3)
    return qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))

@qml.qnode(dev, interface="jax")
def node2(params):
    qml.evolve(H2)(params, 0.3)
    return qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))

params = jnp.array([0.4, -0.9])
%timeit node1(params)
%timeit node2(params)
```

Resolving patch:
diff --git a/pennylane/pulse/hardware_hamiltonian.py b/pennylane/pulse/hardware_hamiltonian.py
--- a/pennylane/pulse/hardware_hamiltonian.py
+++ b/pennylane/pulse/hardware_hamiltonian.py
@@ -26,9 +26,6 @@
 
 from .parametrized_hamiltonian import ParametrizedHamiltonian
 
-# from .transmon import TransmonSettings
-# from .rydberg import RydbergSettings
-
 
 def drive(amplitude, phase, wires):
     r"""Returns a :class:`ParametrizedHamiltonian` representing the action of a driving electromagnetic
@@ -325,11 +322,14 @@ def __call__(self, params, t):
     def __repr__(self):
         return f"HardwareHamiltonian: terms={qml.math.shape(self.coeffs)[0]}"
 
-    def __add__(self, other):
+    def __add__(self, other):  # pylint: disable=too-many-return-statements
         if isinstance(other, HardwareHamiltonian):
             if not self.reorder_fn == other.reorder_fn:
                 raise ValueError(
-                    f"Cannot add two HardwareHamiltonians with different reorder functions. Received reorder_fns {self.reorder_fn} and {other.reorder_fn}. This is likely due to an attempt to add hardware compatible Hamiltonians for different target systems."
+                    "Cannot add two HardwareHamiltonians with different reorder functions. "
+                    f"Received reorder_fns {self.reorder_fn} and {other.reorder_fn}. This is "
+                    "likely due to an attempt to add hardware compatible Hamiltonians for "
+                    "different target systems."
                 )
             if self.settings is None and other.settings is None:
                 new_settings = None
@@ -374,6 +374,19 @@ def __add__(self, other):
                 new_coeffs, new_ops, reorder_fn=self.reorder_fn, settings=settings, pulses=pulses
             )
 
+        if isinstance(other, (int, float)):
+            if other in (0, 0.0):
+                return HardwareHamiltonian(
+                    coeffs, ops, reorder_fn=self.reorder_fn, settings=settings, pulses=pulses
+                )
+            new_coeffs = coeffs + [other]
+            with qml.queuing.QueuingManager.stop_recording():
+                new_ops = ops + [qml.Identity(self.wires[0])]
+
+            return HardwareHamiltonian(
+                new_coeffs, new_ops, reorder_fn=self.reorder_fn, settings=settings, pulses=pulses
+            )
+
         return NotImplemented
 
     def __radd__(self, other):


Test patch:
diff --git a/tests/pulse/test_hardware_hamiltonian.py b/tests/pulse/test_hardware_hamiltonian.py
--- a/tests/pulse/test_hardware_hamiltonian.py
+++ b/tests/pulse/test_hardware_hamiltonian.py
@@ -166,6 +166,55 @@ def test_add_parametrized_hamiltonian(self):
         assert res2.ops_parametrized == []
         assert res2.wires == qml.wires.Wires(h_wires)
 
+    @pytest.mark.parametrize("scalars", [(0.2, 1), (0.9, 0.2), (1, 5), (3, 0.5)])
+    def test_add_scalar(self, scalars):
+        """Test that adding a scalar/number to a `HardwareHamiltonian` works as expected."""
+        coeffs = [2, 3]
+        ops = [qml.PauliZ(2), qml.PauliX(0)]
+
+        H = HardwareHamiltonian(
+            coeffs=coeffs,
+            observables=ops,
+            pulses=[HardwarePulse(qml.pulse.constant, 6, 7, 8)],
+        )
+        orig_matrix = qml.matrix(H([0.3], 0.1))
+        H1 = H + scalars[0]
+        assert len(H1.ops) == len(H1.coeffs) == 3
+        assert qml.equal(H1.ops[-1], qml.Identity(2))
+        assert qml.math.allclose(qml.matrix(H1([0.3], 0.1)), orig_matrix + np.eye(4) * scalars[0])
+        H2 = scalars[1] + H1
+        assert len(H2.ops) == len(H2.coeffs) == 4
+        assert qml.equal(H2.ops[-2], qml.Identity(2))
+        assert qml.equal(H2.ops[-1], qml.Identity(2))
+        assert qml.math.allclose(
+            qml.matrix(H2([0.3], 0.1)), orig_matrix + np.eye(4) * (scalars[0] + scalars[1])
+        )
+        H += scalars[0]
+        assert len(H.ops) == len(H.coeffs) == 3
+        assert qml.equal(H.ops[-1], qml.Identity(2))
+        assert qml.math.allclose(qml.matrix(H([0.3], 0.1)), orig_matrix + np.eye(4) * scalars[0])
+
+    def test_add_zero(self):
+        """Test that adding an int or a float that is zero to a `HardwareHamiltonian`
+        returns an unchanged copy."""
+        coeffs = [2, 3]
+        ops = [qml.PauliZ(2), qml.PauliX(0)]
+
+        H = HardwareHamiltonian(
+            coeffs=coeffs,
+            observables=ops,
+            pulses=[HardwarePulse(qml.pulse.constant, 6, 7, 8)],
+        )
+        orig_matrix = qml.matrix(H([0.3], 0.1))
+        H1 = H + 0
+        assert len(H1.ops) == len(H1.coeffs) == 2
+        assert qml.math.allclose(qml.matrix(H1([0.3], 0.1)), orig_matrix)
+        assert H1 is not H
+        H2 = 0.0 + H
+        assert len(H2.ops) == len(H2.coeffs) == 2
+        assert qml.math.allclose(qml.matrix(H2([0.3], 0.1)), orig_matrix)
+        assert H2 is not H
+
     @pytest.mark.xfail
     def test_add_raises_warning(self):
         """Test that an error is raised when adding two HardwareHamiltonians where one Hamiltonian
@@ -296,11 +345,18 @@ def test_add_other_operators(self, op):
         assert qml.equal(new_pH.H_fixed(), qml.s_prod(1, op))
         new_pH(params, 2)  # confirm calling does not raise error
 
+    def test_adding_scalar_does_not_queue_id(self):
+        """Test that no additional Identity operation is queued when adding a scalar."""
+        R = drive(amplitude=f1, phase=0, wires=[0, 1])
+        with qml.queuing.AnnotatedQueue() as q:
+            R += 3
+        assert len(q) == 0
+
     def test_unknown_type_raises_error(self):
         """Test that adding an invalid object to a HardwareHamiltonian raises an error."""
         R = drive(amplitude=f1, phase=0, wires=[0, 1])
         with pytest.raises(TypeError, match="unsupported operand type"):
-            R += 3
+            R += 3j
 
 
 class TestDrive: