Repository: pasqal-io/Pulser

Issue:
Add Channel specific buffer times for retargeting and phase jumps
Realistically, hardware channels will require some buffer time to update their output. Two particular instances where that is relevant are:
- Retargeting: Right now, there is a variable retargeting time being enforced based on the time of the last retargeting. In addition to this, it is also possible that some channels take an intrinsic, fixed time to retarget. This time should be added in the presence of a target instruction.
- Phase jumps: The way phase is encoded in a pulse makes it seem like changing it comes for free, but this is not necessarily the case. In the presence of a phase shift, the channel might need some time to adjust. For two consequent pulses where the phase changes between them, this buffer time should be enforced whenever it is not already present.

Discussion:
HGSilveri: Thanks for the thorough review @Codoscope ! 

Patch:
diff --git a/pulser/channels.py b/pulser/channels.py
index 7e79e15..694779e 100644
--- a/pulser/channels.py
+++ b/pulser/channels.py
@@ -37,7 +37,11 @@ class Channel:
         max_abs_detuning: Maximum possible detuning (in rad/µs), in absolute
             value.
         max_amp: Maximum pulse amplitude (in rad/µs).
-        retarget_time: Maximum time to change the target (in ns).
+        phase_jump_time: Time taken to change the phase between consecutive
+            pulses (in ns).
+        min_retarget_interval: Minimum time required between the ends of two
+            target instructions (in ns).
+        fixed_retarget_t: Time taken to change the target (in ns).
         max_targets: How many qubits can be addressed at once by the same beam.
         clock_period: The duration of a clock cycle (in ns). The duration of a
             pulse or delay instruction is enforced to be a multiple of the
@@ -55,7 +59,9 @@ class Channel:
     addressing: str
     max_abs_detuning: float
     max_amp: float
-    retarget_time: Optional[int] = None
+    phase_jump_time: int = 0
+    min_retarget_interval: Optional[int] = None
+    fixed_retarget_t: Optional[int] = None
     max_targets: Optional[int] = None
     clock_period: int = 4  # ns
     min_duration: int = 16  # ns
@@ -66,7 +72,9 @@ class Channel:
         cls,
         max_abs_detuning: float,
         max_amp: float,
-        retarget_time: int = 220,
+        phase_jump_time: int = 0,
+        min_retarget_interval: int = 220,
+        fixed_retarget_t: int = 0,
         max_targets: int = 1,
         **kwargs: int,
     ) -> Channel:
@@ -76,7 +84,11 @@ class Channel:
             max_abs_detuning (float): Maximum possible detuning (in rad/µs), in
                 absolute value.
             max_amp(float): Maximum pulse amplitude (in rad/µs).
-            retarget_time (int): Maximum time to change the target (in ns).
+            phase_jump_time (int): Time taken to change the phase between
+                consecutive pulses (in ns).
+            min_retarget_interval (int): Minimum time required between two
+                target instructions (in ns).
+            fixed_retarget_t (int): Time taken to change the target (in ns).
             max_targets (int): Maximum number of atoms the channel can target
                 simultaneously.
         """
@@ -84,14 +96,20 @@ class Channel:
             "Local",
             max_abs_detuning,
             max_amp,
-            retarget_time,
+            phase_jump_time,
+            min_retarget_interval,
+            fixed_retarget_t,
             max_targets,
             **kwargs,
         )
 
     @classmethod
     def Global(
-        cls, max_abs_detuning: float, max_amp: float, **kwargs: int
+        cls,
+        max_abs_detuning: float,
+        max_amp: float,
+        phase_jump_time: int = 0,
+        **kwargs: int,
     ) -> Channel:
         """Initializes the channel with global addressing.
 
@@ -99,8 +117,12 @@ class Channel:
             max_abs_detuning (float): Maximum possible detuning (in rad/µs), in
                 absolute value.
             max_amp(float): Maximum pulse amplitude (in rad/µs).
+            phase_jump_time (int): Time taken to change the phase between
+                consecutive pulses (in ns).
         """
-        return cls("Global", max_abs_detuning, max_amp, **kwargs)
+        return cls(
+            "Global", max_abs_detuning, max_amp, phase_jump_time, **kwargs
+        )
 
     def validate_duration(self, duration: int) -> int:
         """Validates and adapts the duration of an instruction on this channel.
@@ -142,11 +164,14 @@ class Channel:
     def __repr__(self) -> str:
         config = (
             f".{self.addressing}(Max Absolute Detuning: "
-            f"{self.max_abs_detuning} rad/µs, Max Amplitude: "
-            f"{self.max_amp} rad/µs"
+            f"{self.max_abs_detuning} rad/µs, Max Amplitude: {self.max_amp}"
+            f" rad/µs, Phase Jump Time: {self.phase_jump_time} ns"
         )
         if self.addressing == "Local":
-            config += f", Target time: {self.retarget_time} ns"
+            config += (
+                f", Minimum retarget time: {self.min_retarget_interval} ns, "
+                f"Fixed retarget time: {self.fixed_retarget_t} ns"
+            )
             if cast(int, self.max_targets) > 1:
                 config += f", Max targets: {self.max_targets}"
         config += f", Basis: '{self.basis}'"
diff --git a/pulser/devices/_device_datacls.py b/pulser/devices/_device_datacls.py
index a1b4e09..e441eb5 100644
--- a/pulser/devices/_device_datacls.py
+++ b/pulser/devices/_device_datacls.py
@@ -231,10 +231,13 @@ class Device:
                         + r"- Maximum :math:`|\delta|`:"
                         + f" {ch.max_abs_detuning:.4g} rad/µs"
                     ),
+                    f"\t- Phase Jump Time: {ch.phase_jump_time} ns",
                 ]
                 if ch.addressing == "Local":
                     ch_lines += [
-                        f"\t- Maximum time to retarget: {ch.retarget_time} ns",
+                        "\t- Minimum time between retargets: "
+                        f"{ch.min_retarget_interval} ns",
+                        f"\t- Fixed retarget time: {ch.fixed_retarget_t} ns",
                         f"\t- Maximum simultaneous targets: {ch.max_targets}",
                     ]
                 ch_lines += [
diff --git a/pulser/devices/_mock_device.py b/pulser/devices/_mock_device.py
index ac4f8dc..0b6e022 100644
--- a/pulser/devices/_mock_device.py
+++ b/pulser/devices/_mock_device.py
@@ -31,7 +31,12 @@ MockDevice = Device(
         (
             "rydberg_local",
             Rydberg.Local(
-                1000, 200, 0, max_targets=2000, clock_period=1, min_duration=1
+                1000,
+                200,
+                min_retarget_interval=0,
+                max_targets=2000,
+                clock_period=1,
+                min_duration=1,
             ),
         ),
         (
@@ -41,7 +46,12 @@ MockDevice = Device(
         (
             "raman_local",
             Raman.Local(
-                1000, 200, 0, max_targets=2000, clock_period=1, min_duration=1
+                1000,
+                200,
+                min_retarget_interval=0,
+                max_targets=2000,
+                clock_period=1,
+                min_duration=1,
             ),
         ),
         (
diff --git a/pulser/sequence.py b/pulser/sequence.py
index 23a9dd5..b241b86 100644
--- a/pulser/sequence.py
+++ b/pulser/sequence.py
@@ -622,6 +622,25 @@ class Sequence:
         last = self._last(channel)
         t0 = last.tf  # Preliminary ti
         basis = channel_obj.basis
+
+        ph_refs = {self._phase_ref[basis][q].last_phase for q in last.targets}
+        if len(ph_refs) != 1:
+            raise ValueError(
+                "Cannot do a multiple-target pulse on qubits with different "
+                "phase references for the same basis."
+            )
+        else:
+            phase_ref = ph_refs.pop()
+
+        if phase_ref != 0:
+            # Has to recreate the original pulse with a new phase
+            pulse = Pulse(
+                pulse.amplitude,
+                pulse.detuning,
+                pulse.phase + phase_ref,
+                post_phase_shift=pulse.post_phase_shift,
+            )
+
         phase_barriers = [
             self._phase_ref[basis][q].last_time for q in last.targets
         ]
@@ -638,39 +657,27 @@ class Sequence:
                     if op.targets & last.targets or protocol == "wait-for-all":
                         current_max_t = op.tf
                         break
-        ti = current_max_t
-        if ti > t0:
-            # Insert a delay
-            delay_duration = ti - t0
 
-            # Delay must not be shorter than the min duration for this channel
-            min_duration = self._channels[channel].min_duration
-            if delay_duration < min_duration:
-                ti += min_duration - delay_duration
-                delay_duration = min_duration
+        delay_duration = current_max_t - t0
+        # Find last pulse and compare phase
+        for op in self._schedule[channel][::-1]:
+            if isinstance(op.type, Pulse):
+                if op.type.phase != pulse.phase:
+                    delay_duration = max(
+                        delay_duration,
+                        # Considers that the last pulse might not be at t0
+                        channel_obj.phase_jump_time - (t0 - op.tf),
+                    )
+                break
 
+        if delay_duration > 0:
+            # Delay must not be shorter than the min duration for this channel
+            delay_duration = max(delay_duration, channel_obj.min_duration)
             self._delay(delay_duration, channel)
 
+        ti = t0 + delay_duration
         tf = ti + pulse.duration
 
-        prs = {self._phase_ref[basis][q].last_phase for q in last.targets}
-        if len(prs) != 1:
-            raise ValueError(
-                "Cannot do a multiple-target pulse on qubits with different "
-                "phase references for the same basis."
-            )
-        else:
-            phase_ref = prs.pop()
-
-        if phase_ref != 0:
-            # Has to recriate the original pulse with a new phase
-            pulse = Pulse(
-                pulse.amplitude,
-                pulse.detuning,
-                pulse.phase + phase_ref,
-                post_phase_shift=pulse.post_phase_shift,
-            )
-
         self._add_to_schedule(channel, _TimeSlot(pulse, ti, tf, last.targets))
 
         for qubit in last.targets:
@@ -1042,7 +1049,7 @@ class Sequence:
             if last.targets == qubits_set:
                 return
             ti = last.tf
-            retarget = cast(int, self._channels[channel].retarget_time)
+            retarget = cast(int, self._channels[channel].min_retarget_interval)
             elapsed = ti - self._last_target[channel]
             delta = cast(int, np.clip(retarget - elapsed, 0, retarget))
             if delta != 0:
@@ -1051,7 +1058,9 @@ class Sequence:
                     delta = self._channels[channel].validate_duration(
                         16 if delta < 16 else delta
                     )
-            tf = ti + delta
+            tf = ti + max(
+                delta, cast(int, self._channels[channel].fixed_retarget_t)
+            )
 
         except ValueError:
             ti = -1


Test patch:
diff --git a/pulser/tests/test_channels.py b/pulser/tests/test_channels.py
index 9961208..ce56cf1 100644
--- a/pulser/tests/test_channels.py
+++ b/pulser/tests/test_channels.py
@@ -33,8 +33,10 @@ def test_device_channels():
             assert ch.clock_period >= 1
             assert ch.min_duration >= 1
             if ch.addressing == "Local":
-                assert ch.retarget_time >= 0
-                assert ch.retarget_time == int(ch.retarget_time)
+                assert ch.min_retarget_interval >= 0
+                assert ch.min_retarget_interval == int(
+                    ch.min_retarget_interval
+                )
                 assert ch.max_targets >= 1
                 assert ch.max_targets == int(ch.max_targets)
 
@@ -52,16 +54,20 @@ def test_validate_duration():
 
 
 def test_repr():
-    raman = Raman.Local(10, 2, retarget_time=1000, max_targets=4)
+    raman = Raman.Local(
+        10, 2, min_retarget_interval=1000, fixed_retarget_t=200, max_targets=4
+    )
     r1 = (
         "Raman.Local(Max Absolute Detuning: 10 rad/µs, Max Amplitude: "
-        "2 rad/µs, Target time: 1000 ns, Max targets: 4, Basis: 'digital')"
+        "2 rad/µs, Phase Jump Time: 0 ns, Minimum retarget time: 1000 ns, "
+        "Fixed retarget time: 200 ns, Max targets: 4, Basis: 'digital')"
     )
     assert raman.__str__() == r1
 
-    ryd = Rydberg.Global(50, 2.5)
+    ryd = Rydberg.Global(50, 2.5, phase_jump_time=300)
     r2 = (
         "Rydberg.Global(Max Absolute Detuning: 50 rad/µs, "
-        "Max Amplitude: 2.5 rad/µs, Basis: 'ground-rydberg')"
+        "Max Amplitude: 2.5 rad/µs, Phase Jump Time: 300 ns, "
+        "Basis: 'ground-rydberg')"
     )
     assert ryd.__str__() == r2
diff --git a/pulser/tests/test_devices.py b/pulser/tests/test_devices.py
index d718071..fcec79c 100644
--- a/pulser/tests/test_devices.py
+++ b/pulser/tests/test_devices.py
@@ -61,7 +61,7 @@ def test_mock():
         assert ch.max_abs_detuning >= 1000
         assert ch.max_amp >= 200
         if ch.addressing == "Local":
-            assert ch.retarget_time == 0
+            assert ch.min_retarget_interval == 0
             assert ch.max_targets > 1
             assert ch.max_targets == int(ch.max_targets)
 
diff --git a/pulser/tests/test_sequence.py b/pulser/tests/test_sequence.py
index 86a69ae..a3d0bfa 100644
--- a/pulser/tests/test_sequence.py
+++ b/pulser/tests/test_sequence.py
@@ -152,7 +152,7 @@ def test_target():
 
     assert seq._schedule["ch0"][-1] == _TimeSlot("target", -1, 0, {"q1"})
     seq.target("q4", "ch0")
-    retarget_t = seq.declared_channels["ch0"].retarget_time
+    retarget_t = seq.declared_channels["ch0"].min_retarget_interval
     assert seq._schedule["ch0"][-1] == _TimeSlot(
         "target", 0, retarget_t, {"q4"}
     )