Repository: pasqal-io/Pulser

Issue:
Making the restrictions on durations Device specific
Currently, the QM restrictions on durations of waveforms, pulses and delays are enforced every time. Since not all devices might necessarily need to obey such restrictions, we should move them to the `Device` class. In this way, the default duration restrictions will just be that they are `int`, seeing as we don't plan on going below 1 ns precision. 

Discussion:
HGSilveri: > LGTM!
> I like a lot the change from multiple of 4 ns to simply integer, it's way cleaner this way.
> What abaout the max duration of 67108864 ns? Is it just an arbitrary large number or is there anything deeper about it?

Thanks for the review! That max duration is the value enforced by the Quantum Machines controller.

Patch:
diff --git a/pulser/channels.py b/pulser/channels.py
index 42bd714..fdfbac2 100644
--- a/pulser/channels.py
+++ b/pulser/channels.py
@@ -14,6 +14,7 @@
 
 from dataclasses import dataclass
 from typing import ClassVar
+import warnings
 
 
 @dataclass(init=False, repr=False, frozen=True)
@@ -23,6 +24,21 @@ class Channel:
     Not to be initialized itself, but rather through a child class and the
     ``Local`` or ``Global`` classmethods.
 
+    Attributes:
+        name: The name of channel.
+        basis: The addressed basis name.
+        addressing: "Local" or "Global".
+        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).
+        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
+            clock cycle.
+        min_duration: The shortest duration an instruction can take.
+        max_duration: The longest duration an instruction can take.
+
     Example:
         To create a channel targeting the 'ground-rydberg' transition globally,
         call ``Rydberg.Global(...)``.
@@ -35,10 +51,12 @@ class Channel:
     max_amp: float
     retarget_time: int = None
     max_targets: int = 1
+    clock_period: int = 4       # ns
+    min_duration: int = 16      # ns
+    max_duration: int = 67108864        # ns
 
     @classmethod
-    def Local(cls, max_abs_detuning, max_amp, retarget_time=220,
-              max_targets=1):
+    def Local(cls, max_abs_detuning, max_amp, retarget_time=220, **kwargs):
         """Initializes the channel with local addressing.
 
         Args:
@@ -46,17 +64,13 @@ class Channel:
                 absolute value.
             max_amp(float): Maximum pulse amplitude (in rad/µs).
             retarget_time (int): Maximum time to change the target (in ns).
-
-        Keyword Args:
-            max_targets (int, default=1): How many qubits can be addressed at
-                once by the same beam.
         """
 
-        return cls('Local', max_abs_detuning, max_amp, max_targets=max_targets,
-                   retarget_time=retarget_time)
+        return cls('Local', max_abs_detuning, max_amp,
+                   retarget_time=retarget_time, **kwargs)
 
     @classmethod
-    def Global(cls, max_abs_detuning, max_amp):
+    def Global(cls, max_abs_detuning, max_amp, **kwargs):
         """Initializes the channel with global addressing.
 
         Args:
@@ -65,7 +79,34 @@ class Channel:
             max_amp(float): Maximum pulse amplitude (in rad/µs).
         """
 
-        return cls('Global', max_abs_detuning, max_amp)
+        return cls('Global', max_abs_detuning, max_amp, **kwargs)
+
+    def validate_duration(self, duration):
+        """Validates and adapts the duration of an instruction on this channel.
+
+        Args:
+            duration (int): The duration to validate.
+        """
+        try:
+            _duration = int(duration)
+        except (TypeError, ValueError):
+            raise TypeError("duration needs to be castable to an int but "
+                            "type %s was provided" % type(duration))
+
+        if duration < self.min_duration:
+            raise ValueError("duration has to be at least "
+                             + f"{self.min_duration} ns.")
+
+        if duration > self.max_duration:
+            raise ValueError("duration can be at most "
+                             + f"{self.max_duration} ns.")
+
+        if duration % self.clock_period != 0:
+            _duration += self.clock_period - _duration % self.clock_period
+            warnings.warn(f"A duration of {duration} ns is not a multiple of "
+                          f"the channel's clock period ({self.clock_period} "
+                          f"ns). It was rounded up to {_duration} ns.")
+        return _duration
 
     def __repr__(self):
         s = ".{}(Max Absolute Detuning: {} rad/µs, Max Amplitude: {} rad/µs"
diff --git a/pulser/devices/_device_datacls.py b/pulser/devices/_device_datacls.py
index 3334c39..e7b31e9 100644
--- a/pulser/devices/_device_datacls.py
+++ b/pulser/devices/_device_datacls.py
@@ -18,9 +18,9 @@ from typing import Tuple
 import numpy as np
 from scipy.spatial.distance import pdist
 
+from pulser import Register
 from pulser.channels import Channel
-from pulser.register import Register
-from pulser.utils import obj_to_dict
+from pulser.json.utils import obj_to_dict
 
 
 @dataclass(frozen=True, repr=False)
@@ -122,6 +122,23 @@ class Device:
                              "center of the array.".format(
                                                     self.max_radial_distance))
 
+    def validate_pulse(self, pulse, channel_id):
+        """Checks if a pulse can be executed on a specific device channel.
+
+        Args:
+            pulse (Pulse): The pulse to validate.
+            channel_id (str): The channel ID used to index the chosen channel
+                on this device.
+        """
+        ch = self.channels[channel_id]
+        if np.any(pulse.amplitude.samples > ch.max_amp):
+            raise ValueError("The pulse's amplitude goes over the maximum "
+                             "value allowed for the chosen channel.")
+        if np.any(np.round(np.abs(pulse.detuning.samples),
+                           decimals=6) > ch.max_abs_detuning):
+            raise ValueError("The pulse's detuning values go out of the range "
+                             "allowed for the chosen channel.")
+
     def _specs(self, for_docs=False):
         lines = [
             "\nRegister requirements:",
diff --git a/pulser/devices/_mock_device.py b/pulser/devices/_mock_device.py
index a7ba940..c35edf7 100644
--- a/pulser/devices/_mock_device.py
+++ b/pulser/devices/_mock_device.py
@@ -23,9 +23,19 @@ MockDevice = Device(
             max_radial_distance=1000,
             min_atom_distance=1,
             _channels=(
-                ("rydberg_global", Rydberg.Global(1000, 200)),
-                ("rydberg_local", Rydberg.Local(1000, 200, 0, 2000)),
-                ("raman_global", Raman.Global(1000, 200)),
-                ("raman_local", Raman.Local(1000, 200, 0, 2000)),
+                ("rydberg_global", Rydberg.Global(1000, 200,
+                                                  clock_period=1,
+                                                  min_duration=1)),
+                ("rydberg_local", Rydberg.Local(1000, 200, 0,
+                                                max_targets=2000,
+                                                clock_period=1,
+                                                min_duration=1)),
+                ("raman_global", Raman.Global(1000, 200,
+                                              clock_period=1,
+                                              min_duration=1)),
+                ("raman_local", Raman.Local(1000, 200, 0,
+                                            max_targets=2000,
+                                            clock_period=1,
+                                            min_duration=1)),
                 )
             )
diff --git a/pulser/json/__init__.py b/pulser/json/__init__.py
new file mode 100644
index 0000000..2fea4df
--- /dev/null
+++ b/pulser/json/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2020 Pulser Development Team
+#
+# 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.
+"""Classes for JSON serialization."""
diff --git a/pulser/_json_coders.py b/pulser/json/coders.py
similarity index 98%
rename from pulser/_json_coders.py
rename to pulser/json/coders.py
index 5ef72f7..02a1348 100644
--- a/pulser/_json_coders.py
+++ b/pulser/json/coders.py
@@ -18,7 +18,7 @@ from json import JSONEncoder, JSONDecoder
 
 import numpy as np
 
-from pulser.utils import obj_to_dict
+from pulser.json.utils import obj_to_dict
 from pulser.parametrized import Variable
 
 
diff --git a/pulser/utils.py b/pulser/json/utils.py
similarity index 65%
rename from pulser/utils.py
rename to pulser/json/utils.py
index 36fb1f0..9c374a7 100644
--- a/pulser/utils.py
+++ b/pulser/json/utils.py
@@ -12,9 +12,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import warnings
-
-
 def obj_to_dict(obj, *args, _build=True, _module=None, _name=None,
                 _submodule=None, **kwargs):
     """Encodes an object in a dictionary for serialization.
@@ -48,29 +45,3 @@ def obj_to_dict(obj, *args, _build=True, _module=None, _name=None,
         d["__submodule__"] = _submodule
 
     return d
-
-
-def validate_duration(duration, min_duration=16, max_duration=67108864):
-    """Validates a time interval.
-
-    Returns:
-        int: The duration in multiples of 4 ns.
-    """
-    try:
-        _duration = int(duration)
-    except (TypeError, ValueError):
-        raise TypeError("duration needs to be castable to an int but "
-                        "type %s was provided" % type(duration))
-
-    if duration < min_duration:
-        raise ValueError(f"duration has to be at least {min_duration} ns.")
-
-    if duration > max_duration:
-        raise ValueError(f"duration can be at most {max_duration} ns.")
-
-    if duration % 4 != 0:
-        _duration -= _duration % 4
-        warnings.warn("The given duration is below the machine's precision"
-                      " of 4 ns time steps. It was rounded down to the"
-                      " nearest multiple of 4 ns.")
-    return _duration
diff --git a/pulser/parametrized/__init__.py b/pulser/parametrized/__init__.py
index de8608a..5bb0d5c 100644
--- a/pulser/parametrized/__init__.py
+++ b/pulser/parametrized/__init__.py
@@ -15,6 +15,6 @@
 
 from pulser.parametrized.paramabc import Parametrized
 
-from pulser.parametrized.paramobj import ParamObj
-
 from pulser.parametrized.variable import Variable
+
+from pulser.parametrized.paramobj import ParamObj
diff --git a/pulser/parametrized/paramobj.py b/pulser/parametrized/paramobj.py
index 0140a93..9eb182b 100644
--- a/pulser/parametrized/paramobj.py
+++ b/pulser/parametrized/paramobj.py
@@ -19,7 +19,7 @@ import operator
 import warnings
 
 from pulser.parametrized import Parametrized
-from pulser.utils import obj_to_dict
+from pulser.json.utils import obj_to_dict
 
 # Availabe operations on parameterized objects with OpSupport
 reversible_ops = [
diff --git a/pulser/parametrized/variable.py b/pulser/parametrized/variable.py
index 76aa526..e89c5b5 100644
--- a/pulser/parametrized/variable.py
+++ b/pulser/parametrized/variable.py
@@ -19,7 +19,7 @@ import numpy as np
 
 from pulser.parametrized import Parametrized
 from pulser.parametrized.paramobj import OpSupport
-from pulser.utils import obj_to_dict
+from pulser.json.utils import obj_to_dict
 
 
 @dataclasses.dataclass(frozen=True, eq=False)
diff --git a/pulser/pulse.py b/pulser/pulse.py
index a6329b1..9e8e665 100644
--- a/pulser/pulse.py
+++ b/pulser/pulse.py
@@ -21,7 +21,7 @@ import numpy as np
 from pulser.parametrized import Parametrized, ParamObj
 from pulser.parametrized.decorators import parametrize
 from pulser.waveforms import Waveform, ConstantWaveform
-from pulser.utils import obj_to_dict
+from pulser.json.utils import obj_to_dict
 
 
 class Pulse:
diff --git a/pulser/register.py b/pulser/register.py
index 37bb14a..550f5a8 100644
--- a/pulser/register.py
+++ b/pulser/register.py
@@ -17,7 +17,7 @@ from matplotlib import collections as mc
 import numpy as np
 from scipy.spatial import KDTree
 
-from pulser.utils import obj_to_dict
+from pulser.json.utils import obj_to_dict
 
 
 class Register:
diff --git a/pulser/sequence.py b/pulser/sequence.py
index 4810906..bc93576 100644
--- a/pulser/sequence.py
+++ b/pulser/sequence.py
@@ -26,10 +26,10 @@ import pulser
 from pulser.pulse import Pulse
 from pulser.devices import MockDevice
 from pulser.devices._device_datacls import Device
-from pulser._json_coders import PulserEncoder, PulserDecoder
+from pulser.json.coders import PulserEncoder, PulserDecoder
+from pulser.json.utils import obj_to_dict
 from pulser.parametrized import Parametrized, Variable
 from pulser._seq_drawer import draw_sequence
-from pulser.utils import validate_duration, obj_to_dict
 
 # Auxiliary class to store the information in the schedule
 _TimeSlot = namedtuple('_TimeSlot', ['type', 'ti', 'tf', 'targets'])
@@ -139,7 +139,7 @@ class Sequence:
         self._channels = {}
         self._schedule = {}
         self._phase_ref = {}  # The phase reference of each channel
-        # Stores the ids of selected channels and their declared names
+        # Stores the names and corresponding ids of declared channels
         self._taken_channels = {}
         self._qids = set(self.qubit_info.keys())  # IDs of all qubits in device
         self._last_used = {}    # Last time each qubit was used, by basis
@@ -167,7 +167,7 @@ class Sequence:
     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
+                if id not in self._taken_channels.values()
                 or self._device == MockDevice}
 
     def is_parametrized(self):
@@ -236,7 +236,7 @@ class Sequence:
 
         ch = self._device.channels[channel_id]
         self._channels[name] = ch
-        self._taken_channels[channel_id] = name
+        self._taken_channels[name] = channel_id
         self._schedule[name] = []
         self._last_target[name] = 0
 
@@ -338,10 +338,28 @@ class Sequence:
                 self._validate_pulse(pulse, channel)
             return
 
+        if not isinstance(pulse, Pulse):
+            raise TypeError("pulse input must be of type Pulse, not of type "
+                            "{}.".format(type(pulse)))
+
+        channel_obj = self._channels[channel]
+        _duration = channel_obj.validate_duration(pulse.duration)
+        if _duration != pulse.duration:
+            try:
+                pulse = Pulse(pulse.amplitude.change_duration(_duration),
+                              pulse.detuning.change_duration(_duration),
+                              pulse.phase,
+                              pulse.post_phase_shift)
+            except NotImplementedError:
+                raise TypeError("Failed to automatically adjust one of the "
+                                "pulse's waveforms to the channel duration "
+                                "constraints. Choose a duration that is a "
+                                f"multiple of {channel_obj.clock_period} ns.")
+
         self._validate_pulse(pulse, channel)
         last = self._last(channel)
         t0 = last.tf    # Preliminary ti
-        basis = self._channels[channel].basis
+        basis = channel_obj.basis
         phase_barriers = [self._phase_ref[basis][q].last_time
                           for q in last.targets]
         current_max_t = max(t0, *phase_barriers)
@@ -635,7 +653,9 @@ class Sequence:
             if delta != 0:
                 with warnings.catch_warnings():
                     warnings.simplefilter("ignore")
-                    delta = validate_duration(np.clip(delta, 16, np.inf))
+                    delta = self._channels[channel].validate_duration(
+                        np.clip(delta, 16, np.inf)
+                        )
             tf = ti + delta
 
         except ValueError:
@@ -652,7 +672,7 @@ class Sequence:
 
         last = self._last(channel)
         ti = last.tf
-        tf = ti + validate_duration(duration)
+        tf = ti + self._channels[channel].validate_duration(duration)
         self._add_to_schedule(channel,
                               _TimeSlot('delay', ti, tf, last.targets))
 
@@ -751,18 +771,7 @@ class Sequence:
             raise ValueError("Use the name of a declared channel.")
 
     def _validate_pulse(self, pulse, channel):
-        if not isinstance(pulse, Pulse):
-            raise TypeError("pulse input must be of type Pulse, not of type "
-                            "{}.".format(type(pulse)))
-
-        ch = self._channels[channel]
-        if np.any(pulse.amplitude.samples > ch.max_amp):
-            raise ValueError("The pulse's amplitude goes over the maximum "
-                             "value allowed for the chosen channel.")
-        if np.any(np.round(np.abs(pulse.detuning.samples),
-                           decimals=6) > ch.max_abs_detuning):
-            raise ValueError("The pulse's detuning values go out of the range "
-                             "allowed for the chosen channel.")
+        self._device.validate_pulse(pulse, self._taken_channels[channel])
 
     def _reset_parametrized(self):
         """Resets all attributes related to parametrization."""
diff --git a/pulser/waveforms.py b/pulser/waveforms.py
index 0c69de3..2dbc3bd 100644
--- a/pulser/waveforms.py
+++ b/pulser/waveforms.py
@@ -25,7 +25,7 @@ import numpy as np
 
 from pulser.parametrized import Parametrized, ParamObj
 from pulser.parametrized.decorators import parametrize
-from pulser.utils import validate_duration, obj_to_dict
+from pulser.json.utils import obj_to_dict
 
 
 class Waveform(ABC):
@@ -42,15 +42,27 @@ class Waveform(ABC):
         """Initializes a waveform with a given duration.
 
         Args:
-            duration (int): The waveforms duration (in multiples of 4 ns).
+            duration (int): The waveforms duration (in ns).
         """
-        self._duration = validate_duration(duration, min_duration=16,
-                                           max_duration=4194304)
+        try:
+            _duration = int(duration)
+        except (TypeError, ValueError):
+            raise TypeError("duration needs to be castable to an int but "
+                            "type %s was provided" % type(duration))
+        if _duration <= 0:
+            raise ValueError("A waveform has to have a positive duration, "
+                             + f"not {duration}.")
+        elif duration - _duration != 0:
+            warnings.warn(f"A waveform duration of {duration} ns is below the"
+                          " supported precision of 1 ns. It was rounded down "
+                          + f"to {_duration} ns.")
+
+        self._duration = _duration
 
     @property
     @abstractmethod
     def duration(self):
-        """The duration of the pulse (in multiples of 4 ns)."""
+        """The duration of the pulse (in ns)."""
         pass
 
     @property
@@ -86,6 +98,15 @@ class Waveform(ABC):
 
         plt.show()
 
+    def change_duration(self, new_duration):
+        """Returns a new waveform with modified duration.
+
+        Args:
+            new_duration(int): The duration of the new waveform.
+        """
+        raise NotImplementedError(f"{self.__class__.__name__} does not support"
+                                  " modifications to its duration.")
+
     @abstractmethod
     def _to_dict():
         pass
@@ -155,7 +176,7 @@ class CompositeWaveform(Waveform):
 
     @property
     def duration(self):
-        """The duration of the pulse (in multiples of 4 ns)."""
+        """The duration of the pulse (in ns)."""
         duration = 0
         for wf in self._waveforms:
             duration += wf.duration
@@ -211,26 +232,18 @@ class CustomWaveform(Waveform):
 
     Args:
         samples (array_like): The modulation values at each time step
-            (in rad/µs). The number of samples dictates the duration, so its
-            length has to be a multiple of 4.
+            (in rad/µs). The number of samples dictates the duration, in ns.
     """
 
     def __init__(self, samples):
         """Initializes a custom waveform."""
         samples_arr = np.array(samples, dtype=float)
         self._samples = samples_arr
-        with warnings.catch_warnings():
-            warnings.filterwarnings("error")
-            try:
-                super().__init__(len(samples))
-            except UserWarning:
-                raise ValueError("The provided samples correspond to a "
-                                 "waveform of invalid duration. Please give"
-                                 " samples whose size is a multiple of 4.")
+        super().__init__(len(samples))
 
     @property
     def duration(self):
-        """The duration of the pulse (in multiples of 4 ns)."""
+        """The duration of the pulse (in ns)."""
         return self._duration
 
     @property
@@ -259,7 +272,7 @@ class ConstantWaveform(Waveform):
     """A waveform of constant value.
 
     Args:
-        duration (int): The waveform duration (in multiples of 4 ns).
+        duration (int): The waveform duration (in ns).
         value (float): The modulation value (in rad/µs).
     """
 
@@ -270,7 +283,7 @@ class ConstantWaveform(Waveform):
 
     @property
     def duration(self):
-        """The duration of the pulse (in multiples of 4 ns)."""
+        """The duration of the pulse (in ns)."""
         return self._duration
 
     @property
@@ -292,6 +305,17 @@ class ConstantWaveform(Waveform):
         """The last value in the waveform."""
         return self._value
 
+    def change_duration(self, new_duration):
+        """Returns a new waveform with modified duration.
+
+        Args:
+            new_duration(int): The duration of the new waveform.
+
+        Returns:
+            ConstantWaveform: The new waveform with the given duration.
+        """
+        return ConstantWaveform(new_duration, self._value)
+
     def _to_dict(self):
         return obj_to_dict(self, self._duration, self._value)
 
@@ -310,7 +334,7 @@ class RampWaveform(Waveform):
     """A linear ramp waveform.
 
     Args:
-        duration (int): The waveform duration (in multiples of 4 ns).
+        duration (int): The waveform duration (in ns).
         start (float): The initial value (in rad/µs).
         stop (float): The final value (in rad/µs).
     """
@@ -323,7 +347,7 @@ class RampWaveform(Waveform):
 
     @property
     def duration(self):
-        """The duration of the pulse (in multiples of 4 ns)."""
+        """The duration of the pulse (in ns)."""
         return self._duration
 
     @property
@@ -350,6 +374,17 @@ class RampWaveform(Waveform):
         """The last value in the waveform."""
         return self._stop
 
+    def change_duration(self, new_duration):
+        """Returns a new waveform with modified duration.
+
+        Args:
+            new_duration(int): The duration of the new waveform.
+
+        Returns:
+            RampWaveform: The new waveform with the given duration.
+        """
+        return RampWaveform(new_duration, self._start, self._stop)
+
     def _to_dict(self):
         return obj_to_dict(self, self._duration, self._start, self._stop)
 
@@ -369,7 +404,7 @@ class BlackmanWaveform(Waveform):
     """A Blackman window of a specified duration and area.
 
     Args:
-        duration (int): The waveform duration (in multiples of 4 ns).
+        duration (int): The waveform duration (in ns).
         area (float): The integral of the waveform. Can be negative, in which
             case it takes the positive waveform and changes the sign of all its
             values.
@@ -401,18 +436,17 @@ class BlackmanWaveform(Waveform):
             raise ValueError("The maximum value and the area must have "
                              "matching signs.")
         # A normalized Blackman waveform has an area of 0.42 * duration
-        duration = int(area / (0.42 * max_val) * 1e3)    # in ns
-        duration = 16 if duration < 16 else duration + (4 - duration % 4)
+        duration = np.ceil(area / (0.42 * max_val) * 1e3)    # in ns
         wf = cls(duration, area)
         # Adjust for rounding errors to make sure max_val is not surpassed
         while np.abs(wf._scaling) > np.abs(max_val):
-            duration += 4
+            duration += 1
             wf = cls(duration, area)
         return wf
 
     @property
     def duration(self):
-        """The duration of the pulse (in multiples of 4 ns)."""
+        """The duration of the pulse (in ns)."""
         return self._duration
 
     @property
@@ -434,6 +468,18 @@ class BlackmanWaveform(Waveform):
         """The last value in the waveform."""
         return 0
 
+    def change_duration(self, new_duration):
+        """Returns a new waveform with modified duration.
+
+        Args:
+            new_duration(int): The duration of the new waveform.
+
+        Returns:
+            BlackmanWaveform: The new waveform with the same area but a new
+                duration.
+        """
+        return BlackmanWaveform(new_duration, self._area)
+
     def _to_dict(self):
         return obj_to_dict(self, self._duration, self._area)
 


Test patch:
diff --git a/pulser/tests/test_channels.py b/pulser/tests/test_channels.py
index 8ee5dba..a927d5f 100644
--- a/pulser/tests/test_channels.py
+++ b/pulser/tests/test_channels.py
@@ -12,6 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import pytest
+
 import pulser
 from pulser.channels import Raman, Rydberg
 
@@ -28,6 +30,8 @@ def test_device_channels():
             assert ch.addressing in ['Local', 'Global']
             assert ch.max_abs_detuning >= 0
             assert ch.max_amp > 0
+            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)
@@ -35,6 +39,18 @@ def test_device_channels():
                 assert ch.max_targets == int(ch.max_targets)
 
 
+def test_validate_duration():
+    ch = Rydberg.Local(20, 10, min_duration=16, max_duration=1000)
+    with pytest.raises(TypeError, match="castable to an int"):
+        ch.validate_duration("twenty")
+    with pytest.raises(ValueError, match="at least 16 ns"):
+        ch.validate_duration(10)
+    with pytest.raises(ValueError, match="at most 1000 ns"):
+        ch.validate_duration(1e5)
+    with pytest.warns(UserWarning, match="not a multiple"):
+        ch.validate_duration(31.4)
+
+
 def test_repr():
     raman = Raman.Local(10, 2, retarget_time=1000, max_targets=4)
     r1 = ("Raman.Local(Max Absolute Detuning: 10 rad/µs, Max Amplitude: "
diff --git a/pulser/tests/test_json.py b/pulser/tests/test_json.py
index 70e6513..53a666a 100644
--- a/pulser/tests/test_json.py
+++ b/pulser/tests/test_json.py
@@ -19,7 +19,7 @@ import pytest
 
 from pulser import Sequence, Register
 from pulser.devices import Chadoq2
-from pulser._json_coders import PulserEncoder, PulserDecoder
+from pulser.json.coders import PulserEncoder, PulserDecoder
 from pulser.parametrized.decorators import parametrize
 from pulser.waveforms import BlackmanWaveform
 
diff --git a/pulser/tests/test_sequence.py b/pulser/tests/test_sequence.py
index 70a934a..44bf782 100644
--- a/pulser/tests/test_sequence.py
+++ b/pulser/tests/test_sequence.py
@@ -22,7 +22,7 @@ from pulser import Sequence, Pulse, Register
 from pulser.devices import Chadoq2, MockDevice
 from pulser.devices._device_datacls import Device
 from pulser.sequence import _TimeSlot
-from pulser.waveforms import BlackmanWaveform
+from pulser.waveforms import BlackmanWaveform, CompositeWaveform, RampWaveform
 
 reg = Register.triangular_lattice(4, 7, spacing=5, prefix='q')
 device = Chadoq2
@@ -63,7 +63,12 @@ def test_channel_declaration():
     available_channels = set(seq2.available_channels)
     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 seq2._taken_channels == {'ch0': 'raman_local',
+                                    'ch1': 'rydberg_global',
+                                    'ch2': 'rydberg_global'}
+    assert seq2._taken_channels.keys() == seq2._channels.keys()
 
 
 def test_target():
@@ -210,7 +215,14 @@ def test_sequence():
     with pytest.raises(ValueError, match='Invalid protocol'):
         seq.add(pulse1, 'ch0', protocol='now')
 
-    seq.add(pulse1, 'ch0')
+    wf_ = CompositeWaveform(BlackmanWaveform(30, 1), RampWaveform(15, 0, 2))
+    with pytest.raises(TypeError, match="Failed to automatically adjust"):
+        with pytest.warns(UserWarning, match="rounded up to 48 ns"):
+            seq.add(Pulse.ConstantAmplitude(1, wf_, 0), 'ch0')
+
+    pulse1_ = Pulse.ConstantPulse(499, 2, -10, 0, post_phase_shift=np.pi)
+    with pytest.warns(UserWarning, match="rounded up to 500 ns"):
+        seq.add(pulse1_, 'ch0')
     seq.add(pulse1, 'ch1')
     seq.add(pulse2, 'ch2')
 
diff --git a/pulser/tests/test_waveforms.py b/pulser/tests/test_waveforms.py
index 50f98bb..cb6789a 100644
--- a/pulser/tests/test_waveforms.py
+++ b/pulser/tests/test_waveforms.py
@@ -18,7 +18,7 @@ from unittest.mock import patch
 import numpy as np
 import pytest
 
-from pulser._json_coders import PulserEncoder, PulserDecoder
+from pulser.json.coders import PulserEncoder, PulserDecoder
 from pulser.parametrized import Variable, ParamObj
 from pulser.waveforms import (ConstantWaveform, RampWaveform, BlackmanWaveform,
                               CustomWaveform, CompositeWaveform)
@@ -38,24 +38,35 @@ def test_duration():
         ConstantWaveform("s", -1)
         RampWaveform([0, 1, 3], 1, 0)
 
-    with pytest.raises(ValueError, match='at least 16 ns'):
+    with pytest.raises(ValueError, match='positive duration'):
         ConstantWaveform(15, -10)
         RampWaveform(-20, 3, 4)
 
-    with pytest.raises(ValueError, match='at most 4194304 ns'):
-        BlackmanWaveform(2**23, np.pi/2)
-
-    with pytest.raises(ValueError, match='waveform of invalid duration'):
-        CustomWaveform(np.random.random(50))
-
     with pytest.warns(UserWarning):
         wf = BlackmanWaveform(np.pi*10, 1)
 
-    assert wf.duration == 28
+    assert wf.duration == 31
     assert custom.duration == 52
     assert composite.duration == 192
 
 
+def test_change_duration():
+    with pytest.raises(NotImplementedError):
+        custom.change_duration(53)
+
+    new_cte = constant.change_duration(103)
+    assert constant.duration == 100
+    assert new_cte.duration == 103
+
+    new_blackman = blackman.change_duration(30)
+    assert np.isclose(new_blackman.integral, blackman.integral)
+    assert new_blackman != blackman
+
+    new_ramp = ramp.change_duration(100)
+    assert new_ramp.duration == 100
+    assert new_ramp != ramp
+
+
 def test_samples():
     assert np.all(constant.samples == -3)
     bm_samples = np.clip(np.blackman(40), 0, np.inf)