Repository: quantumlib/Cirq

Issue:
Apply noise model to circuit
I expected to be able to take a `NoiseModel` and apply it to a circuit.

```noise = cirq.ConstantQubitNoiseModel(cirq.depolarize(0.2))```
Then I'd like to be able to
```circuit.add_noise(noise)```
or 
```noise.apply_noise(circuit)```


Discussion:
Of course there is some vagueness on the resulting circuit, for example when doing this for noisy_moment into which moments do you add the resulting op tree. 
Currently I do this via code like
```
noise = cirq.ConstantQubitNoiseModel(cirq.depolarize(0.2))
circuit = cirq.Circuit.from_ops(cirq.H(a), cirq.CNOT(a, b), cirq.measure(a, b))
print('Circuit with no noise:\n{}\n'.format(circuit))

noisy_circuit = cirq.Circuit()
for moment in circuit:
    noisy_circuit.append(noise.noisy_moment(moment, sorted(circuit.all_qubits())))
print('Circuit with noise:\n{}'.format(noisy_circuit))
```

```
Circuit with no noise:
a: ───H───@───M───
          │   │
b: ───────X───M───

Circuit with noise:
a: ───H────────D(0.2)───@───D(0.2)───M───D(0.2)───
                        │            │
b: ───D(0.2)────────────X───D(0.2)───M───D(0.2)───
```
one way to handle the vagueness could be to define `noise.apply_noise` with a conditional function kwarg to determine where the noise goes, e.g.
```
def apply_noise(self, circuit, cond = lambda x: True):
    noisy_circuit = cirq.Circuit()
    if hasattr(self, noisy_moment):
         for moment in circuit:
             if cond(moment):
                 noisy_circuit.append(noise.noisy_moment(moment, sorted(circuit.all_qubits())))
  . . . 
```
then you might do something like
```
noisy_moments_list = circuit.moments[3:5]
noise.apply_noise(circuit, lambda x: x in noisy_moments_list)
```
Alternatively, this might belong to a completely different class of NoiseModel where the user overrides `noisy_moment` with that conditional built in...
This vagueness stems at least in part from a lack of default behavior in `NoiseModel`. We have a clear hierarchy of potential methods to use:
`noisy_circuit` which can access the circuits sequence of moments and device
`noisy_moments` which only accesses a sequence of moments
`noisy_moment` which accesses a single moment (which can be thought of as a sequence of operations)
`noisy_operation` which accesses a single operation
Defining at least one of these is essential, but it seems like there should be a default behavior for what happens if you call a method higher up in the hierarchy than the highest defined method.
If `noisy_circuit` is called but not defined, it falls back to `noisy_moments` on the circuits sequence of moments. If `noisy_moments` isn't defined, it falls back to applying `noisy_moment` on each moment in the sequence. If `noisy_moment` isn't defined, fall back to applying `noisy_operation` on each operation in the moment.
This doesn't resolve problems such as if only `noisy_moment` is defined and `noisy_operation` is called, but this behavior seems to be the least surprising default.

Resolving patch:
diff --git a/cirq/circuits/circuit.py b/cirq/circuits/circuit.py
--- a/cirq/circuits/circuit.py
+++ b/cirq/circuits/circuit.py
@@ -1626,6 +1626,25 @@ def moments(self):
     def _json_dict_(self):
         return protocols.obj_to_dict_helper(self, ['moments', 'device'])
 
+    def with_noise(self, noise: devices.NoiseModel) -> 'cirq.Circuit':
+        """Make a noisy version of the circuit.
+
+        Args:
+            noise: The noise model to use.  This describes the kind of noise to
+                add to the circuit.
+
+        Returns:
+            A new circuit with the same moment structure but with new moments
+            inserted where needed when more than one noisy operation is
+            generated for an input operation.  Emptied moments are removed.
+        """
+        qubits = sorted(self.all_qubits())
+        c_noisy = Circuit()
+        for op_tree in noise.noisy_moments(self, qubits):
+            # Keep moments aligned
+            c_noisy += Circuit.from_ops(op_tree)
+        return c_noisy
+
 
 def _resolve_operations(operations: Iterable['cirq.Operation'],
                         param_resolver: study.ParamResolver


Test patch:
diff --git a/cirq/circuits/circuit_test.py b/cirq/circuits/circuit_test.py
--- a/cirq/circuits/circuit_test.py
+++ b/cirq/circuits/circuit_test.py
@@ -3238,3 +3238,41 @@ def test_json_dict():
         'moments': [cirq.Moment([cirq.CNOT(q0, q1)])],
         'device': cirq.UNCONSTRAINED_DEVICE,
     }
+
+
+def test_with_noise():
+
+    class Noise(cirq.NoiseModel):
+
+        def noisy_operation(self, operation):
+            yield operation
+            if cirq.LineQubit(0) in operation.qubits:
+                yield cirq.H(cirq.LineQubit(0))
+
+    q0, q1 = cirq.LineQubit.range(2)
+    c = cirq.Circuit.from_ops(
+        cirq.X(q0),
+        cirq.Y(q1),
+        cirq.Z(q1),
+        cirq.Moment([cirq.X(q0)]),
+    )
+    c_expected = cirq.Circuit([
+        cirq.Moment([
+            cirq.X(q0),
+            cirq.Y(q1),
+        ]),
+        cirq.Moment([
+            cirq.H(q0),
+        ]),
+        cirq.Moment([
+            cirq.Z(q1),
+        ]),
+        cirq.Moment([
+            cirq.X(q0),
+        ]),
+        cirq.Moment([
+            cirq.H(q0),
+        ]),
+    ])
+    c_noisy = c.with_noise(Noise())
+    assert c_noisy == c_expected