Repository: PennyLaneAI/pennylane
Pull request #5718 (2024-05-21 17:35:31)

`add_noise` transform for adding noise models
**Context:** Adds a transform for adding `NoiseModels`.

**Description of the Change:** Adds `add_noise.py` under `pennylane/transforms` that gives the said method for inserting operations according to a provided noise model.

**Benefits:** We support noise models. 

**Possible Drawbacks:** None.

**Related GitHub Issues:** [sc-64843]


github-actions[bot]: Hello. You may have forgotten to update the changelog!
Please edit [`doc/releases/changelog-dev.md`](/PennyLaneAI/pennylane/blob/master/doc/releases/changelog-dev.md) with:
* A one-to-two sentence description of the change. You may include a small working example for new features.
* A link back to this PR.
* Your name (or GitHub username) in the contributors section.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5718?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
Attention: Patch coverage is `27.90698%` with `31 lines` in your changes are missing coverage. Please review.
> Project coverage is 99.59%. Comparing base [(`5a9446b`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/5a9446b68bb061c0acede51b3066ede2b481c42a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`8cd6f24`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5718?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 2 commits behind head on noise-models-2.

| [Files](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5718?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) | Patch % | Lines |
|---|---|---|
| [pennylane/transforms/add\_noise.py](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5718?src=pr&el=tree&filepath=pennylane%2Ftransforms%2Fadd_noise.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL3RyYW5zZm9ybXMvYWRkX25vaXNlLnB5) | 18.42% | [31 Missing :warning: ](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5718?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) |

<details><summary>Additional details and impacted files</summary>


```diff
@@                Coverage Diff                 @@
##           noise-models-2    #5718      +/-   ##
==================================================
- Coverage           99.67%   99.59%   -0.08%     
==================================================
  Files                 418      419       +1     
  Lines               38793    38832      +39     
==================================================
+ Hits                38668    38676       +8     
- Misses                125      156      +31     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5718?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).


Patch:
diff --git a/doc/code/qml_noise.rst b/doc/code/qml_noise.rst
index 74254e1a2..29e73e17a 100644
--- a/doc/code/qml_noise.rst
+++ b/doc/code/qml_noise.rst
@@ -4,6 +4,8 @@ qml.noise
 This module contains the functionality for building and manipulating insertion-based noise models,
 where noisy gates and channels are inserted based on the target operations.
 
+.. _intro_noise_model:
+
 Overview
 --------
 
@@ -22,7 +24,13 @@ noise-related metadata can also be supplied to construct a noise model using:
 Each conditional in the ``model_map`` evaluates the gate operations in the quantum circuit based on
 some condition of its attributes (e.g., type, parameters, wires, etc.) and use the corresponding
 callable to apply the noise operations, using the user-provided metadata (e.g., hardware topologies
-or relaxation times), whenever the condition results true.
+or relaxation times), whenever the condition results true. A noise model once built can be attached
+to a circuit or device via the following transform:
+
+.. autosummary::
+    :toctree: api
+
+    ~add_noise
 
 .. _intro_boolean_fn:
 
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index c0ea2eb06..e3641f9a5 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -47,20 +47,41 @@
 * The `default.tensor` device is introduced to perform tensor network simulations of quantum circuits using the `mps` (Matrix Product State) method.
   [(#5699)](https://github.com/PennyLaneAI/pennylane/pull/5699)
 
-* A new `qml.noise` module which contains utililty functions for building `NoiseModels`.
+* A new `qml.noise` module which contains utility function for building `NoiseModels` 
+  and an `add_noise` tranform for addding it to quantum circuits.
   [(#5674)](https://github.com/PennyLaneAI/pennylane/pull/5674)
   [(#5684)](https://github.com/PennyLaneAI/pennylane/pull/5684)
+  [(#5718)](https://github.com/PennyLaneAI/pennylane/pull/5718)
 
-  ```python
-  fcond = qml.noise.op_eq(qml.X) | qml.noise.op_eq(qml.Y)
-  noise = qml.noise.partial_wires(qml.AmplitudeDamping, 0.4)
+  ```pycon
+  >>> fcond1 = qml.noise.op_eq(qml.RX) & qml.noise.wires_in([0, 1])
+  >>> noise1 = qml.noise.partial_wires(qml.PhaseDamping, 0.4)
+  >>> fcond2 = qml.noise.op_in([qml.RY, qml.RZ])
+  >>> def noise2(op, **kwargs):
+  ...     qml.ThermalRelaxationError(op.parameters[0] * 0.05, kwargs["t1"], 0.2, 0.6, op.wires)
+  >>> noise_model = qml.NoiseModel({fcond1: noise1, fcond2: noise2}, t1=2.0)
+  >>> noise_model
+  NoiseModel({
+      OpEq(RX) & WiresIn([0, 1]) = PhaseDamping(gamma=0.4)
+      OpIn(['RY', 'RZ']) = noise2
+  }, t1 = 2.0)
   ```
 
   ```pycon
-  >>> qml.NoiseModel({fcond: noise}, t1=0.04)
-  NoiseModel({
-    OpEq(PauliX) | OpEq(PauliY) = AmplitudeDamping(gamma=0.4)
-  }, t1 = 0.04)
+  >>> @partial(qml.transforms.add_noise, noise_model=noise_model)
+  ... @qml.qnode(dev)
+  ... def circuit(w, x, y, z):
+  ...    qml.RX(w, wires=0)
+  ...    qml.RY(x, wires=1)
+  ...    qml.CNOT(wires=[0, 1])
+  ...    qml.RY(y, wires=0)
+  ...    qml.RX(z, wires=1)
+  ...    return qml.expval(qml.Z(0) @ qml.Z(1))
+  >>> print(qml.draw(circuit)(0.9, 0.4, 0.5, 0.6))
+  0: ──RX(0.90)──PhaseDamping(0.40)──────────────────────────╭●──RY(0.50)
+  1: ──RY(0.40)──ThermalRelaxationError(0.02,2.00,0.20,0.60)─╰X──RX(0.60)
+  ───ThermalRelaxationError(0.03,2.00,0.20,0.60)─┤ ╭<Z@Z>
+  ───PhaseDamping(0.40)──────────────────────────┤ ╰<Z@Z>
   ```
 
 * The ``from_openfermion`` and ``to_openfermion`` functions are added to convert between 
@@ -72,8 +93,8 @@
   of_op = openfermion.FermionOperator('0^ 2')
   pl_op = qml.from_openfermion(of_op)
   of_op_new = qml.to_openfermion(pl_op)
-
   ```
+
   ```pycon
   >>> print(pl_op)
   a⁺(0) a(2)
diff --git a/pennylane/__init__.py b/pennylane/__init__.py
index 61285764f..998b355ce 100644
--- a/pennylane/__init__.py
+++ b/pennylane/__init__.py
@@ -105,6 +105,7 @@ from pennylane.transforms import (
     pattern_matching,
     pattern_matching_optimization,
     clifford_t_decomposition,
+    add_noise,
 )
 from pennylane.ops.functions import (
     dot,
diff --git a/pennylane/noise/noise_model.py b/pennylane/noise/noise_model.py
index 12616c1d1..792f93e24 100644
--- a/pennylane/noise/noise_model.py
+++ b/pennylane/noise/noise_model.py
@@ -36,7 +36,7 @@ class NoiseModel:
 
         - The ``conditional`` should be either a function decorated with :class:`~.BooleanFn`,
           a callable object built via :ref:`constructor functions <intro_boolean_fn>` in
-          the ``noise`` module, or their bit-wise combination.
+          the ``qml.noise`` module, or their bit-wise combination.
         - The definition of ``noise_fn(op, **kwargs)`` should have the operations in same the order
           in which they are to be queued for an operation ``op``, whenever the corresponding
           ``conditional`` evaluates to ``True``.
@@ -129,7 +129,7 @@ class NoiseModel:
         for condition, noise in model.items():
             if not isinstance(condition, qml.BooleanFn):
                 raise ValueError(
-                    f"{condition} must be a boolean conditional, i.e., an instance of"
+                    f"{condition} must be a boolean conditional, i.e., an instance of "
                     "BooleanFn or one of its subclasses."
                 )
 
diff --git a/pennylane/transforms/__init__.py b/pennylane/transforms/__init__.py
index f69d97804..9781cd4b4 100644
--- a/pennylane/transforms/__init__.py
+++ b/pennylane/transforms/__init__.py
@@ -107,6 +107,7 @@ preprocessing, get information from a circuit and more.
     ~batch_params
     ~batch_input
     ~transforms.insert
+    ~transforms.add_noise
     ~defer_measurements
     ~transforms.split_non_commuting
     ~transforms.broadcast_expand
@@ -284,7 +285,7 @@ from .batch_input import batch_input
 from .batch_partial import batch_partial
 from .convert_to_numpy_parameters import convert_to_numpy_parameters
 from .compile import compile
-
+from .add_noise import add_noise
 
 from .decompositions import clifford_t_decomposition
 from .defer_measurements import defer_measurements
diff --git a/pennylane/transforms/add_noise.py b/pennylane/transforms/add_noise.py
new file mode 100644
index 000000000..6b2caa3e8
--- /dev/null
+++ b/pennylane/transforms/add_noise.py
@@ -0,0 +1,232 @@
+# Copyright 2018-2024 Xanadu Quantum Technologies Inc.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Transform for adding a noise model to a quantum circuit or device"""
+from copy import copy
+from functools import lru_cache
+
+import pennylane as qml
+from pennylane.transforms.core import TransformContainer, transform
+
+
+@transform
+def add_noise(tape, noise_model, level=None):
+    """Insert operations according to a provided noise model.
+
+    Circuits passed through this transform will be updated to apply the
+    insertion-based :class:`~.NoiseModel`, which contains a mapping
+    ``{BooleanFn: Callable}`` from conditions to the corresponding noise
+    gates. Each condition of the noise model will be evaluated on the
+    operations contained within the given circuit. For conditions that
+    evaluate to ``True``, the noisy gates contained within the ``Callable``
+    will be inserted after the operation under consideration.
+
+    Args:
+        tape (QNode or QuantumTape or Callable or pennylane.devices.Device): the input circuit or
+            device to be transformed
+        noise_model (~pennylane.NoiseModel): noise model according to which noise has to be inserted
+        level (None, str, int, slice): An indication of which stage in the transform program the
+            noise model should be applied to. Only relevant when transforming a ``QNode``. More details
+            on the following permissible values can be found in the :func:`~.workflow.get_transform_program` -
+
+            * ``None``: expands the tape to have no ``Adjoint`` and ``Templates``.
+            * ``str``: acceptable keys are ``"top"``, ``"user"``, ``"device"``, and ``"gradient"``
+            * ``int``: how many transforms to include, starting from the front of the program
+            * ``slice``: a slice to select out components of the transform program.
+
+    Returns:
+        qnode (QNode) or quantum function (Callable) or tuple[List[.QuantumTape], function] or device (pennylane.devices.Device):
+        Transformed circuit as described in :func:`qml.transform <pennylane.transform>`.
+
+    Raises:
+        ValueError: argument ``noise_model`` is not a valid noise model.
+
+    .. note::
+
+        For a given ``model_map`` within a ``NoiseModel``, if multiple conditionals in the ``model_map``
+        evaluate to ``True`` for an operation, then the noise operations defined via their respective
+        noisy quantum functions will be added in the same order in which the conditionals appear in the
+        ``model_map``.
+
+    **Example:**
+
+    The following QNode can be transformed to add noise to the circuit:
+
+    .. code-block:: python3
+
+        from functools import partial
+
+        dev = qml.device("default.mixed", wires=2)
+
+        fcond1 = qml.noise.op_eq(qml.RX) & qml.noise.wires_in([0, 1])
+        noise1 = qml.noise.partial_wires(qml.PhaseDamping, 0.4)
+
+        fcond2 = qml.noise.op_in([qml.RX, qml.RZ])
+        def noise2(op, **kwargs):
+            qml.ThermalRelaxationError(op.parameters[0] * 0.5, kwargs["t1"],  kwargs["t2"], 0.6, op.wires)
+
+        noise_model = qml.NoiseModel({fcond1: noise1, fcond2: noise2}, t1=2.0, t2=0.2)
+
+        @partial(qml.transforms.add_noise, noise_model=noise_model)
+        @qml.qnode(dev)
+        def circuit(w, x, y, z):
+            qml.RX(w, wires=0)
+            qml.RY(x, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(y, wires=0)
+            qml.RX(z, wires=1)
+            return qml.expval(qml.Z(0) @ qml.Z(1))
+
+    Executions of this circuit will differ from the noise-free value:
+
+    >>> circuit(0.9, 0.4, 0.5, 0.6)
+    tensor(0.60722291, requires_grad=True)
+    >>> print(qml.draw(f)(0.9, 0.4, 0.5, 0.6))
+    0: ──RX(0.9)──PhaseDamping(0.4)───────────────────────╭●──RY(0.5)───ThermalRelaxationError(0.2,2.0,0.2,0.6)─┤ ╭<Z@Z>
+    1: ──RY(0.4)──ThermalRelaxationError(0.2,2.0,0.2,0.6)─╰X──RX(0.6)───PhaseDamping(0.4)───────────────────────┤ ╰<Z@Z>
+
+    .. details::
+        :title: Tranform Levels
+        :href: add-noise-levels
+
+        When transforming an already constructed ``QNode``, the ``add_noise`` transform will be
+        added at the end of the "user" transforms by default, i.e., after all the transforms
+        that have been manually applied to the QNode until that point.
+
+        .. code-block:: python3
+
+            dev = qml.device("default.mixed", wires=2)
+
+            @qml.metric_tensor
+            @qml.transforms.undo_swaps
+            @qml.transforms.merge_rotations
+            @qml.transforms.cancel_inverses
+            @qml.qnode(dev)
+            def circuit(w, x, y, z):
+                qml.RX(w, wires=0)
+                qml.RY(x, wires=1)
+                qml.CNOT(wires=[0, 1])
+                qml.RY(y, wires=0)
+                qml.RX(z, wires=1)
+                return qml.expval(qml.Z(0) @ qml.Z(1))
+
+            noisy_circuit = qml.transforms.add_noise(circuit, noise_model)
+
+        >>> qml.workflow.get_transform_program(circuit)
+        TransformProgram(cancel_inverses, merge_rotations, undo_swaps, _expand_metric_tensor, batch_transform, expand_fn, metric_tensor)
+        >>> qml.workflow.get_transform_program(noisy_circuit)
+        TransformProgram(cancel_inverses, merge_rotations, undo_swaps, _expand_metric_tensor, add_noise, batch_transform, expand_fn, metric_tensor)
+
+        However, one can request inserting it at any specific point of the transform program. Specifying the ``level`` keyword argument while
+        transforming a ``QNode``, will allow addition of the transform at the end of the transform program extracted at a designated level via
+        :func:`get_transform_program <pennylane.workflow.get_transform_program>`. For example, one could specify ``None`` to add it at the end,
+        which will also ensure that the tape is expanded to have no ``Adjoint`` and ``Templates``:
+
+        >>> qml.transforms.add_noise(circuit, noise_model, level=None).transform_program
+        TransformProgram(cancel_inverses, merge_rotations, undo_swaps, _expand_metric_tensor, batch_transform, expand_fn, add_noise, metric_tensor)
+
+        Other, acceptable values for the level are ``"top"``, ``"user"``, ``"device"``, and ``"gradient"``. Among these, `"top"` will allow addition
+        to an empty transform program, `"user"` will allow addition at the end of user specified transforms, `"device"` will allow addition at the
+        end of device-specific transforms, and `"gradient"` will allow addition at the end of transform that expands trainable operations. For example:
+
+        >>> qml.transforms.add_noise(circuit, noise_model, level="top").transform_program
+        TransformProgram(add_noise)
+        >>> qml.transforms.add_noise(circuit, noise_model, level="user").transform_program
+        TransformProgram(cancel_inverses, merge_rotations, undo_swaps, _expand_metric_tensor, add_noise, metric_tensor)
+        >>> qml.transforms.add_noise(circuit, noise_model, level="device").transform_program
+        TransformProgram(cancel_inverses, merge_rotations, undo_swaps, _expand_metric_tensor, batch_transform, expand_fn, add_noise, metric_tensor)
+
+        Finally, more precise control over the insertion of the transform can be achieved by specifying
+        an integer or slice for indexing for extracting the transform program. For example, one can do:
+
+        >>> qml.transforms.add_noise(circuit, noise_model, level=2).transform_program
+        TransformProgram(cancel_inverses, merge_rotations, add_noise)
+        >>> qml.transforms.add_noise(circuit, noise_model, level=slice(1,3)).transform_program
+        TransformProgram(merge_rotations, undo_swaps, add_noise)
+
+    """
+    if not hasattr(noise_model, "model_map") or not hasattr(noise_model, "metadata"):
+        raise ValueError(
+            f"Provided noise model object must define model_map and metatadata attributes, got {noise_model}."
+        )
+
+    if level is None or level == "user":  # decompose templates and their adjoints
+
+        def stop_at(obj):
+            if not isinstance(obj, qml.operation.Operator):
+                return True
+            if not obj.has_decomposition:
+                return True
+            return not (hasattr(qml.templates, obj.name) or isinstance(obj, qml.ops.Adjoint))
+
+        error_type = (qml.operation.DecompositionUndefinedError,)
+        decompose = qml.devices.preprocess.decompose
+        [tape], _ = decompose(tape, stopping_condition=stop_at, name="add_noise", error=error_type)
+
+    conditions, noises = [], []
+    metadata = noise_model.metadata
+    for condition, noise in noise_model.model_map.items():
+        conditions.append(lru_cache(maxsize=512)(condition))
+        noises.append(qml.tape.make_qscript(noise))
+
+    new_operations = []
+    for operation in tape.operations:
+        curr_ops = [operation]
+        for condition, noise in zip(conditions, noises):
+            if condition(operation):
+                noise_ops = noise(operation, **metadata).operations
+                if operation in noise_ops and _check_queue_op(operation, noise, metadata):
+                    ops_indx = noise_ops.index(operation)
+                    curr_ops = noise_ops[:ops_indx] + curr_ops + noise_ops[ops_indx + 1 :]
+                else:
+                    curr_ops.extend(noise_ops)
+        new_operations.extend(curr_ops)
+
+    new_tape = type(tape)(new_operations, tape.measurements, shots=tape.shots)
+    post_processing_fn = qml.devices.preprocess.null_postprocessing
+
+    return [new_tape], post_processing_fn
+
+
+def _check_queue_op(operation, noise_func, metadata):
+    """Performs a secondary check for existence of an operation in the queue using a randomized ID"""
+
+    test_id = "f49968bfc4W0H86df3A733bf6e92904d21a_!$-T-@!_c131S549b169b061I25b85398bfd8ec1S3c"
+    test_queue = noise_func(
+        qml.noise.partial_wires(operation, id=test_id)(operation.wires), **metadata
+    ).operations
+
+    return any(test_id == getattr(o, "id", "") for o in test_queue)
+
+
+# pylint:disable = protected-access
+@add_noise.custom_qnode_transform
+def custom_qnode_wrapper(self, qnode, targs, tkwargs):
+    """QNode execution wrapper for supporting ``add_noise`` with levels"""
+    cqnode = copy(qnode)
+    level = tkwargs.get("level", "user")
+
+    transform_program = qml.workflow.get_transform_program(qnode, level=level)
+
+    cqnode._transform_program = transform_program
+    cqnode.add_transform(
+        TransformContainer(
+            self._transform,
+            targs,
+            {**tkwargs},
+            self._classical_cotransform,
+            self._is_informative,
+            self._final_transform,
+        )
+    )
+
+    return cqnode


Test patch:
diff --git a/tests/transforms/test_add_noise.py b/tests/transforms/test_add_noise.py
new file mode 100644
index 000000000..4a9fd4d5f
--- /dev/null
+++ b/tests/transforms/test_add_noise.py
@@ -0,0 +1,451 @@
+# Copyright 2024 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Tests for the add_noise transform.
+"""
+from functools import partial
+
+import numpy as np
+import pytest
+
+import pennylane as qml
+from pennylane.measurements import Expectation
+from pennylane.tape import QuantumScript
+from pennylane.transforms.add_noise import add_noise
+
+# pylint:disable = no-member
+
+
+class TestAddNoise:
+    """Tests for the add_noise transform using input tapes"""
+
+    with qml.queuing.AnnotatedQueue() as q_tape:
+        qml.RX(0.9, wires=0)
+        qml.RY(0.4, wires=1)
+        qml.CNOT(wires=[0, 1])
+        qml.RY(0.5, wires=0)
+        qml.RX(0.6, wires=1)
+        qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+    tape = QuantumScript.from_queue(q_tape)
+
+    with qml.queuing.AnnotatedQueue() as q_tape_with_prep:
+        qml.StatePrep([1, 0], wires=0)
+        qml.RX(0.9, wires=0)
+        qml.RY(0.4, wires=1)
+        qml.CNOT(wires=[0, 1])
+        qml.RY(0.5, wires=0)
+        qml.RX(0.6, wires=1)
+        qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+    tape_with_prep = QuantumScript.from_queue(q_tape_with_prep)
+
+    # conditionals
+    c0 = qml.noise.op_eq(qml.RX)
+    c1 = qml.noise.op_in([qml.RY, qml.RZ])
+    c2 = qml.noise.op_eq("StatePrep")
+
+    # callables
+    @staticmethod
+    def n0(op, **kwargs):  # pylint: disable=unused-argument
+        """Mapped callable for c0"""
+        qml.RZ(op.parameters[0] * 0.05, op.wires)
+        qml.apply(op)
+        qml.RZ(-op.parameters[0] * 0.05, op.wires)
+
+    n1 = qml.noise.partial_wires(qml.AmplitudeDamping, 0.4)
+
+    @staticmethod
+    def n2(op, **kwargs):
+        """Mapped callable for c2"""
+        qml.ThermalRelaxationError(0.4, kwargs["t1"], 0.2, 0.6, op.wires)
+
+    noise_model = qml.NoiseModel({c0: n0.__func__, c1: n1})
+    noise_model_with_prep = noise_model + qml.NoiseModel({c2: n2.__func__}, t1=0.4)
+
+    def test_noise_model_error(self):
+        """Tests if a ValueError is raised when noise model is not given"""
+        with pytest.raises(
+            ValueError,
+            match="Provided noise model object must define model_map and metatadata attributes",
+        ):
+            add_noise(self.tape, {})
+
+    def test_noise_tape(self):
+        """Test if the expected tape is returned with the transform"""
+        [tape], _ = add_noise(self.tape, self.noise_model)
+
+        with qml.queuing.AnnotatedQueue() as q_tape_exp:
+            qml.RZ(0.045, wires=0)
+            qml.RX(0.9, wires=0)
+            qml.RZ(-0.045, wires=0)
+            qml.RY(0.4, wires=1)
+            qml.AmplitudeDamping(0.4, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(0.5, wires=0)
+            qml.AmplitudeDamping(0.4, wires=0)
+            qml.RZ(0.03, wires=1)
+            qml.RX(0.6, wires=1)
+            qml.RZ(-0.03, wires=1)
+            qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+        tape_exp = QuantumScript.from_queue(q_tape_exp)
+
+        assert all(o1.name == o2.name for o1, o2 in zip(tape.operations, tape_exp.operations))
+        assert all(o1.wires == o2.wires for o1, o2 in zip(tape.operations, tape_exp.operations))
+        assert all(
+            np.allclose(o1.parameters, o2.parameters)
+            for o1, o2 in zip(tape.operations, tape_exp.operations)
+        )
+        assert len(tape.measurements) == 1
+        assert (
+            tape.observables[0].name == "Prod"
+            if qml.operation.active_new_opmath()
+            else ["PauliZ", "PauliZ"]
+        )
+        assert tape.observables[0].wires.tolist() == [0, 1]
+        assert tape.measurements[0].return_type is Expectation
+
+    def test_noise_tape_with_state_prep(self):
+        """Test if the expected tape is returned with the transform"""
+        [tape], _ = add_noise(self.tape_with_prep, self.noise_model_with_prep)
+
+        with qml.queuing.AnnotatedQueue() as q_tape_exp:
+            qml.StatePrep([1, 0], wires=0)
+            qml.ThermalRelaxationError(0.4, 0.4, 0.2, 0.6, wires=0)
+            qml.RZ(0.045, wires=0)
+            qml.RX(0.9, wires=0)
+            qml.RZ(-0.045, wires=0)
+            qml.RY(0.4, wires=1)
+            qml.AmplitudeDamping(0.4, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(0.5, wires=0)
+            qml.AmplitudeDamping(0.4, wires=0)
+            qml.RZ(0.03, wires=1)
+            qml.RX(0.6, wires=1)
+            qml.RZ(-0.03, wires=1)
+            qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+        tape_exp = QuantumScript.from_queue(q_tape_exp)
+
+        assert all(o1.name == o2.name for o1, o2 in zip(tape.operations, tape_exp.operations))
+        assert all(o1.wires == o2.wires for o1, o2 in zip(tape.operations, tape_exp.operations))
+        assert all(
+            np.allclose(o1.parameters, o2.parameters)
+            for o1, o2 in zip(tape.operations, tape_exp.operations)
+        )
+        assert len(tape.measurements) == 1
+        assert (
+            tape.observables[0].name == "Prod"
+            if qml.operation.active_new_opmath()
+            else ["PauliZ", "PauliZ"]
+        )
+        assert tape.observables[0].wires.tolist() == [0, 1]
+        assert tape.measurements[0].return_type is Expectation
+
+
+class TestAddNoiseInterface:
+    """Tests for the add_noise transform using input qnode and devices"""
+
+    def test_add_noise_qnode(self):
+        """Test that a QNode with add_noise decorator gives a different result."""
+        dev = qml.device("default.mixed", wires=2)
+
+        c, n = qml.noise.op_in([qml.RY, qml.RZ]), qml.noise.partial_wires(qml.AmplitudeDamping, 0.4)
+
+        @partial(add_noise, noise_model=qml.NoiseModel({c: n}))
+        @qml.qnode(dev)
+        def f_noisy(w, x, y, z):
+            qml.RX(w, wires=0)
+            qml.RY(x, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(y, wires=0)
+            qml.RX(z, wires=1)
+            return qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+
+        @qml.qnode(dev)
+        def f(w, x, y, z):
+            qml.RX(w, wires=0)
+            qml.RY(x, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(y, wires=0)
+            qml.RX(z, wires=1)
+            return qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+
+        args = [0.1, 0.2, 0.3, 0.4]
+
+        assert not np.isclose(f_noisy(*args), f(*args))
+
+    def test_add_noise_dev(self):
+        """Test if an device transformed by the add_noise transform does successfully add noise to
+        subsequent circuit executions"""
+        with qml.queuing.AnnotatedQueue() as q_in_tape:
+            qml.RX(0.9, wires=0)
+            qml.RY(0.4, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(0.5, wires=0)
+            qml.RX(0.6, wires=1)
+            qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+            qml.expval(qml.PauliZ(0))
+
+        in_tape = QuantumScript.from_queue(q_in_tape)
+        dev = qml.device("default.qubit", wires=2)
+        program, _ = dev.preprocess()
+        res_without_noise = qml.execute(
+            [in_tape], dev, qml.gradients.param_shift, transform_program=program
+        )
+
+        c, n = qml.noise.op_in([qml.RX, qml.RY]), qml.noise.partial_wires(qml.PhaseShift, 0.4)
+        new_dev = add_noise(dev, noise_model=qml.NoiseModel({c: n}))
+        new_program, _ = new_dev.preprocess()
+        [tape], _ = new_program([in_tape])
+        res_with_noise = qml.execute(
+            [in_tape], new_dev, qml.gradients, transform_program=new_program
+        )
+
+        with qml.queuing.AnnotatedQueue() as q_tape_exp:
+            qml.RX(0.9, wires=0)
+            qml.PhaseShift(0.4, wires=0)
+            qml.RY(0.4, wires=1)
+            qml.PhaseShift(0.4, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(0.5, wires=0)
+            qml.PhaseShift(0.4, wires=0)
+            qml.RX(0.6, wires=1)
+            qml.PhaseShift(0.4, wires=1)
+            qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+            qml.expval(qml.PauliZ(0))
+
+        tape_exp = QuantumScript.from_queue(q_tape_exp)
+        assert all(o1.name == o2.name for o1, o2 in zip(tape.operations, tape_exp.operations))
+        assert all(o1.wires == o2.wires for o1, o2 in zip(tape.operations, tape_exp.operations))
+        assert all(
+            np.allclose(o1.parameters, o2.parameters)
+            for o1, o2 in zip(tape.operations, tape_exp.operations)
+        )
+        assert len(tape.measurements) == 2
+        assert (
+            tape.observables[0].name == "Prod"
+            if qml.operation.active_new_opmath()
+            else ["PauliZ", "PauliZ"]
+        )
+        assert tape.observables[0].wires.tolist() == [0, 1]
+        assert tape.measurements[0].return_type is Expectation
+        assert tape.observables[1].name == "PauliZ"
+        assert tape.observables[1].wires.tolist() == [0]
+        assert tape.measurements[1].return_type is Expectation
+
+        assert not np.allclose(res_without_noise, res_with_noise)
+
+    def test_add_noise_old_dev(self, mocker):
+        """Test if a old device transformed by the add_noise function does successfully add noise to
+        subsequent circuit executions"""
+        with qml.queuing.AnnotatedQueue() as q_in_tape:
+            qml.RX(0.9, wires=0)
+            qml.RY(0.4, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(0.5, wires=0)
+            qml.RX(0.6, wires=1)
+            qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+            qml.expval(qml.PauliZ(0))
+
+        in_tape = QuantumScript.from_queue(q_in_tape)
+        dev = qml.device("default.mixed", wires=2)
+        res_without_noise = qml.execute([in_tape], dev, qml.gradients.param_shift)
+
+        c, n = qml.noise.op_in([qml.RX, qml.RY]), qml.noise.partial_wires(qml.PhaseDamping, 0.4)
+        new_dev = add_noise(dev, noise_model=qml.NoiseModel({c: n}))
+        spy = mocker.spy(new_dev, "default_expand_fn")
+
+        res_with_noise = qml.execute([in_tape], new_dev, qml.gradients.param_shift)
+        tape = spy.call_args[0][0]
+
+        with qml.queuing.AnnotatedQueue() as q_tape_exp:
+            qml.RX(0.9, wires=0)
+            qml.PhaseDamping(0.4, wires=0)
+            qml.RY(0.4, wires=1)
+            qml.PhaseDamping(0.4, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(0.5, wires=0)
+            qml.PhaseDamping(0.4, wires=0)
+            qml.RX(0.6, wires=1)
+            qml.PhaseDamping(0.4, wires=1)
+            qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+            qml.expval(qml.PauliZ(0))
+        tape_exp = QuantumScript.from_queue(q_tape_exp)
+
+        assert all(o1.name == o2.name for o1, o2 in zip(tape.operations, tape_exp.operations))
+        assert all(o1.wires == o2.wires for o1, o2 in zip(tape.operations, tape_exp.operations))
+        assert all(
+            np.allclose(o1.parameters, o2.parameters)
+            for o1, o2 in zip(tape.operations, tape_exp.operations)
+        )
+        assert len(tape.measurements) == 2
+        assert (
+            tape.observables[0].name == "Prod"
+            if qml.operation.active_new_opmath()
+            else ["PauliZ", "PauliZ"]
+        )
+        assert tape.observables[0].wires.tolist() == [0, 1]
+        assert tape.measurements[0].return_type is Expectation
+        assert tape.observables[1].name == "PauliZ"
+        assert tape.observables[1].wires.tolist() == [0]
+        assert tape.measurements[1].return_type is Expectation
+
+        assert not np.allclose(res_without_noise, res_with_noise)
+
+    def test_add_noise_template(self):
+        """Test that noisy ops are inserted correctly into a decomposed template"""
+        dev = qml.device("default.mixed", wires=2)
+
+        c, n = qml.noise.op_in([qml.RX, qml.RY]), qml.noise.partial_wires(qml.PhaseDamping, 0.3)
+
+        @partial(add_noise, noise_model=qml.NoiseModel({c: n}))
+        @qml.qnode(dev)
+        def f1(w1, w2):
+            qml.SimplifiedTwoDesign(w1, w2, wires=[0, 1])
+            return qml.expval(qml.PauliZ(0))
+
+        @qml.qnode(dev)
+        def f2(w1, w2):
+            qml.RY(w1[0], wires=0)
+            qml.PhaseDamping(0.3, wires=0)
+            qml.RY(w1[1], wires=1)
+            qml.PhaseDamping(0.3, wires=1)
+            qml.CZ(wires=[0, 1])
+            qml.RY(w2[0][0][0], wires=0)
+            qml.PhaseDamping(0.3, wires=0)
+            qml.RY(w2[0][0][1], wires=1)
+            qml.PhaseDamping(0.3, wires=1)
+            return qml.expval(qml.PauliZ(0))
+
+        w1 = np.random.random(2)
+        w2 = np.random.random((1, 1, 2))
+
+        assert np.allclose(f1(w1, w2), f2(w1, w2))
+
+    # pylint: disable=unused-argument
+    def test_add_noise_with_non_qwc_obs_and_mid_meas(self):
+        """Test that the add_noise transform catches and reports errors from the enclosed function."""
+
+        dev = qml.device("default.qubit", wires=3)
+
+        fcond = qml.noise.wires_in([0, 1])
+
+        def noise(op, **kwargs):
+            qml.CNOT(wires=[1, 0])
+            qml.CRX(kwargs["noise_param"], wires=[0, 1])
+
+        @qml.qnode(dev)
+        @partial(add_noise, noise_model=qml.NoiseModel({fcond: noise}, noise_param=0.3))
+        def noisy_circuit(circuit_param):
+            qml.RY(circuit_param, wires=0)
+            qml.Hadamard(wires=0)
+            qml.T(wires=0)
+            m0 = qml.measure(0)
+            m1 = qml.measure(1)
+            qml.cond(~m0 & m1 == 0, qml.X)(wires=2)
+            return qml.expval(qml.PauliX(0)), qml.expval(qml.PauliY(0)), qml.expval(qml.PauliZ(0))
+
+        @qml.qnode(dev)
+        def explicit_circuit(circuit_param):
+            qml.RY(circuit_param, wires=0)
+            noise(op=None, noise_param=0.3)
+            qml.Hadamard(wires=0)
+            noise(op=None, noise_param=0.3)
+            qml.T(wires=0)
+            noise(op=None, noise_param=0.3)
+            m0 = qml.measure(0)
+            noise(op=None, noise_param=0.3)
+            m1 = qml.measure(1)
+            noise(op=None, noise_param=0.3)
+            qml.cond(~m0 & m1 == 0, qml.X)(wires=2)
+            return qml.expval(qml.PauliX(0)), qml.expval(qml.PauliY(0)), qml.expval(qml.PauliZ(0))
+
+        assert np.allclose(noisy_circuit(0.4), explicit_circuit(0.4))
+
+
+class TestAddNoiseLevels:
+    """Tests for custom insertion of add_noise transform at correct level."""
+
+    @pytest.mark.parametrize(
+        "level1, level2",
+        [
+            ("top", 0),
+            (0, slice(0, 0)),
+            ("user", 4),
+            ("user", slice(0, 4)),
+            (None, slice(0, None)),
+            (-1, slice(0, -1)),
+            ("device", slice(0, None)),
+        ],
+    )
+    def test_add_noise_level(self, level1, level2):
+        """Test that add_noise can be inserted to correct level in the TransformProgram"""
+        dev = qml.device("default.mixed", wires=2)
+
+        @qml.metric_tensor
+        @qml.transforms.undo_swaps
+        @qml.transforms.merge_rotations
+        @qml.transforms.cancel_inverses
+        @qml.qnode(dev, diff_method="parameter-shift", shifts=np.pi / 4)
+        def f(w, x, y, z):
+            qml.RX(w, wires=0)
+            qml.RY(x, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(y, wires=0)
+            qml.RX(z, wires=1)
+            return qml.expval(qml.Z(0) @ qml.Z(1))
+
+        fcond = qml.noise.op_eq(qml.RX)
+        fcall = qml.noise.partial_wires(qml.PhaseDamping, 0.4)
+        noise_model = qml.NoiseModel({fcond: fcall})
+
+        noisy_qnode = add_noise(f, noise_model=noise_model, level=level1)
+
+        transform_level1 = noisy_qnode.transform_program
+        transform_level2 = qml.workflow.get_transform_program(f, level=level2)
+        transform_level2.add_transform(add_noise, noise_model=noise_model, level=level1)
+
+        assert len(transform_level1) == len(transform_level2) + bool(level1 == "user")
+        for t1, t2 in zip(transform_level1, transform_level2):
+            if t1.transform.__name__ == t2.transform.__name__ == "expand_fn":
+                continue
+            assert t1 == t2
+
+    def test_add_noise_level_with_final(self):
+        """Test that add_noise can be inserted in the TransformProgram with a final transform"""
+        dev = qml.device("default.mixed", wires=2)
+
+        @qml.metric_tensor
+        @qml.transforms.undo_swaps
+        @qml.transforms.merge_rotations
+        @qml.transforms.cancel_inverses
+        @qml.qnode(dev, diff_method="parameter-shift", shifts=np.pi / 4)
+        def f(w, x, y, z):
+            qml.RX(w, wires=0)
+            qml.RY(x, wires=1)
+            qml.CNOT(wires=[0, 1])
+            qml.RY(y, wires=0)
+            qml.RX(z, wires=1)
+            return qml.expval(qml.Z(0) @ qml.Z(1))
+
+        fcond = qml.noise.op_eq(qml.RX)
+        fcall = qml.noise.partial_wires(qml.PhaseDamping, 0.4)
+        noise_model = qml.NoiseModel({fcond: fcall})
+
+        noisy_qnode = add_noise(f, noise_model=noise_model)
+
+        transform_level1 = qml.workflow.get_transform_program(f)
+        transform_level2 = qml.workflow.get_transform_program(noisy_qnode)
+
+        assert len(transform_level1) == len(transform_level2) - 1
+        assert transform_level2[4].transform == add_noise.transform
+        assert transform_level2[-1].transform == qml.metric_tensor.transform