Repository: pasqal-io/Pulser

Issue:
Adding the MW channel and specific functionality
Adding the MW channel type for implementation of the XY Hamiltonian. Note that, unlike with the Rydberg and Raman channels, the MW channel cannot coexist with other channel types on the same Sequence, as it requires a specific initialisation procedure and works on a basis that is incompatible with the other channels. This will demand changes to the `Sequence` and `Simulation` classes.

Discussion:

Patch:
diff --git a/pulser/channels.py b/pulser/channels.py
index fdfbac2..f704781 100644
--- a/pulser/channels.py
+++ b/pulser/channels.py
@@ -139,3 +139,14 @@ class Rydberg(Channel):
     """
     name: ClassVar[str] = 'Rydberg'
     basis: ClassVar[str] = 'ground-rydberg'
+
+
+@dataclass(init=True, repr=False, frozen=True)
+class Microwave(Channel):
+    """Microwave adressing channel.
+
+    Channel targeting the transition between two rydberg states, thus encoding
+    the 'XY' basis. See base class.
+    """
+    name: ClassVar[str] = 'Microwave'
+    basis: ClassVar[str] = 'XY'
diff --git a/pulser/devices/_mock_device.py b/pulser/devices/_mock_device.py
index c35edf7..5d8817f 100644
--- a/pulser/devices/_mock_device.py
+++ b/pulser/devices/_mock_device.py
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 from pulser.devices._device_datacls import Device
-from pulser.channels import Rydberg, Raman
+from pulser.channels import Rydberg, Raman, Microwave
 
 
 MockDevice = Device(
@@ -37,5 +37,8 @@ MockDevice = Device(
                                             max_targets=2000,
                                             clock_period=1,
                                             min_duration=1)),
+                ("mw_global", Microwave.Global(1000, 200,
+                                               clock_period=1,
+                                               min_duration=1))
                 )
             )
diff --git a/pulser/sequence.py b/pulser/sequence.py
index bc93576..7dab2bb 100644
--- a/pulser/sequence.py
+++ b/pulser/sequence.py
@@ -135,6 +135,7 @@ class Sequence:
 
         self._register = register
         self._device = device
+        self._in_xy = False
         self._calls = [_Call("__init__", (register, device), {})]
         self._channels = {}
         self._schedule = {}
@@ -166,9 +167,17 @@ class Sequence:
     @property
     def available_channels(self):
         """Channels still available for declaration."""
-        return {id: ch for id, ch in self._device.channels.items()
-                if id not in self._taken_channels.values()
-                or self._device == MockDevice}
+        # Show all channels if none are declared, otherwise filter depending
+        # on whether the sequence is working on XY mode
+        if not self._channels:
+            return dict(self._device.channels)
+        else:
+            # MockDevice channels can be declared multiple times
+            return {id: ch for id, ch in self._device.channels.items()
+                    if (id not in self._taken_channels.values()
+                    or self._device == MockDevice)
+                    and (ch.basis == 'XY' if self._in_xy else ch.basis != 'XY')
+                    }
 
     def is_parametrized(self):
         """States whether the sequence is parametrized.
@@ -211,6 +220,17 @@ class Sequence:
     def declare_channel(self, name, channel_id, initial_target=None):
         """Declares a new channel to the Sequence.
 
+        The first declared channel implicitly defines the sequence's mode of
+        operation (i.e. the underlying Hamiltonian). In particular, if the
+        first declared channel is of type ``Microwave``, the sequence will work
+        in "XY Mode" and will not allow declaration of channels that do not
+        address the 'XY' basis. Inversely, declaration of a channel of another
+        type will block the declaration of ``Microwave`` channels.
+
+        Note:
+            Regular devices only allow a channel to be declared once, but
+            ``MockDevice`` channels can be repeatedly declared if needed.
+
         Args:
             name (str): Unique name for the channel in the sequence.
             channel_id (str): How the channel is identified in the device.
@@ -231,10 +251,20 @@ class Sequence:
         if channel_id not in self._device.channels:
             raise ValueError("No channel %s in the device." % channel_id)
 
+        ch = self._device.channels[channel_id]
         if channel_id not in self.available_channels:
-            raise ValueError("Channel %s is not available." % channel_id)
+            if self._in_xy and ch.basis != 'XY':
+                raise ValueError(f"Channel '{ch}' cannot work simultaneously "
+                                 "with the declared 'Microwave' channel."
+                                 )
+            elif not self._in_xy and ch.basis == 'XY':
+                raise ValueError("Channel of type 'Microwave' cannot work "
+                                 "simultaneously with the declared channels.")
+            else:
+                raise ValueError(f"Channel {channel_id} is not available.")
 
-        ch = self._device.channels[channel_id]
+        if ch.basis == 'XY' and not self._in_xy:
+            self._in_xy = True
         self._channels[name] = ch
         self._taken_channels[name] = channel_id
         self._schedule[name] = []
@@ -430,15 +460,24 @@ class Sequence:
     def measure(self, basis='ground-rydberg'):
         """Measures in a valid basis.
 
+        Note:
+            In addition to the supported bases of the selected device, allowed
+            measurement bases will depend on the mode of operation. In
+            particular, if using ``Microwave`` channels (XY mode), only
+            measuring in the 'XY' basis is allowed. Inversely, it is not
+            possible to measure in the 'XY' basis outside of XY mode.
+
         Args:
             basis (str): Valid basis for measurement (consult the
                 ``supported_bases`` attribute of the selected device for
                 the available options).
         """
-        available = self._device.supported_bases
+        available = (self._device.supported_bases - {'XY'} if not self._in_xy
+                     else {'XY'})
         if basis not in available:
             raise ValueError(f"The basis '{basis}' is not supported by the "
-                             "selected device. The available options are: "
+                             "selected device and operation mode. The "
+                             "available options are: "
                              + ", ".join(list(available)))
 
         if hasattr(self, "_measurement"):
diff --git a/pulser/simulation.py b/pulser/simulation.py
index 0a3cf2e..c4af049 100644
--- a/pulser/simulation.py
+++ b/pulser/simulation.py
@@ -41,11 +41,17 @@ class Simulation:
 
     def __init__(self, sequence, sampling_rate=1.0):
         """Initialize the Simulation with a specific pulser.Sequence."""
+        supported_bases = {"ground-rydberg", "digital"}
         if not isinstance(sequence, Sequence):
             raise TypeError("The provided sequence has to be a valid "
                             "pulser.Sequence instance.")
         if not sequence._schedule:
             raise ValueError("The provided sequence has no declared channels.")
+        not_supported = (set(ch.basis for ch in sequence._channels.values())
+                         - supported_bases)
+        if not_supported:
+            raise NotImplementedError("Sequence with unsupported bases: "
+                                      + "".join(not_supported))
         if all(sequence._schedule[x][-1].tf == 0 for x in sequence._channels):
             raise ValueError("No instructions given for the channels in the "
                              "sequence.")


Test patch:
diff --git a/pulser/tests/test_devices.py b/pulser/tests/test_devices.py
index 458eaac..6494f7c 100644
--- a/pulser/tests/test_devices.py
+++ b/pulser/tests/test_devices.py
@@ -46,8 +46,8 @@ def test_mock():
     assert dev.max_atom_num > 1000
     assert dev.min_atom_distance <= 1
     assert dev.interaction_coeff == 5008713
-    names = ['Rydberg', 'Raman']
-    basis = ['ground-rydberg', 'digital']
+    names = ['Rydberg', 'Raman', 'Microwave']
+    basis = ['ground-rydberg', 'digital', 'XY']
     for ch in dev.channels.values():
         assert ch.name in names
         assert ch.basis == basis[names.index(ch.name)]
diff --git a/pulser/tests/test_sequence.py b/pulser/tests/test_sequence.py
index 44bf782..90d200b 100644
--- a/pulser/tests/test_sequence.py
+++ b/pulser/tests/test_sequence.py
@@ -64,11 +64,21 @@ def test_channel_declaration():
     seq2.declare_channel('ch0', 'raman_local', initial_target='q1')
     seq2.declare_channel('ch1', 'rydberg_global')
     seq2.declare_channel('ch2', 'rydberg_global')
-    assert set(seq2.available_channels) == available_channels
+    assert set(seq2.available_channels) == available_channels - {'mw_global'}
     assert seq2._taken_channels == {'ch0': 'raman_local',
                                     'ch1': 'rydberg_global',
                                     'ch2': 'rydberg_global'}
     assert seq2._taken_channels.keys() == seq2._channels.keys()
+    with pytest.raises(ValueError, match="type 'Microwave' cannot work "):
+        seq2.declare_channel('ch3', 'mw_global')
+
+    seq2 = Sequence(reg, MockDevice)
+    seq2.declare_channel('ch0', 'mw_global')
+    assert set(seq2.available_channels) == {'mw_global'}
+    with pytest.raises(
+            ValueError,
+            match="cannot work simultaneously with the declared 'Microwave'"):
+        seq2.declare_channel('ch3', 'rydberg_global')
 
 
 def test_target():
@@ -173,6 +183,27 @@ def test_align():
         seq.align('ch1')
 
 
+def test_measure():
+    pulse = Pulse.ConstantPulse(500, 2, -10, 0, post_phase_shift=np.pi)
+    seq = Sequence(reg, MockDevice)
+    seq.declare_channel('ch0', 'rydberg_global')
+    assert 'XY' in MockDevice.supported_bases
+    with pytest.raises(ValueError, match='not supported'):
+        seq.measure(basis='XY')
+    seq.measure()
+    with pytest.raises(SystemError, match='already been measured'):
+        seq.measure(basis='digital')
+    with pytest.raises(SystemError, match='Nothing more can be added.'):
+        seq.add(pulse, 'ch0')
+
+    seq = Sequence(reg, MockDevice)
+    seq.declare_channel('ch0', 'mw_global')
+    assert 'digital' in MockDevice.supported_bases
+    with pytest.raises(ValueError, match='not supported'):
+        seq.measure(basis='digital')
+    seq.measure(basis='XY')
+
+
 def test_str():
     seq = Sequence(reg, device)
     seq.declare_channel('ch0', 'raman_local', initial_target='q0')
@@ -259,13 +290,7 @@ def test_sequence():
 
     assert seq._total_duration == 4000
 
-    with pytest.raises(ValueError, match='not supported'):
-        seq.measure(basis='computational')
     seq.measure(basis='digital')
-    with pytest.raises(SystemError, match='already been measured'):
-        seq.measure(basis='digital')
-    with pytest.raises(SystemError, match='Nothing more can be added.'):
-        seq.add(pulse1, 'ch0')
 
     with patch('matplotlib.pyplot.show'):
         seq.draw()
diff --git a/pulser/tests/test_simulation.py b/pulser/tests/test_simulation.py
index b938096..0a6db14 100644
--- a/pulser/tests/test_simulation.py
+++ b/pulser/tests/test_simulation.py
@@ -18,7 +18,7 @@ import pytest
 import qutip
 
 from pulser import Sequence, Pulse, Register, Simulation
-from pulser.devices import Chadoq2
+from pulser.devices import Chadoq2, MockDevice
 from pulser.waveforms import BlackmanWaveform, RampWaveform, ConstantWaveform
 
 q_dict = {"control1": np.array([-4., 0.]),
@@ -187,12 +187,17 @@ def test_building_basis_and_projection_operators():
 
 
 def test_empty_sequences():
-    seq = Sequence(reg, Chadoq2)
+    seq = Sequence(reg, MockDevice)
     with pytest.raises(ValueError, match='no declared channels'):
         Simulation(seq)
+    seq.declare_channel("ch0", "mw_global")
+    with pytest.raises(NotImplementedError):
+        Simulation(seq)
+
+    seq = Sequence(reg, MockDevice)
+    seq.declare_channel('test', 'rydberg_local', 'target')
+    seq.declare_channel("test2", "rydberg_global")
     with pytest.raises(ValueError, match='No instructions given'):
-        seq.declare_channel('test', 'rydberg_local', 'target')
-        seq.declare_channel("test2", "rydberg_global")
         Simulation(seq)