# 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.
"""The Sequence class, where a pulse sequence is defined."""

from __future__ import annotations

import copy
import json
import os
import warnings
from collections.abc import Iterable, Mapping
from typing import (
    Any,
    Generic,
    Literal,
    Optional,
    Tuple,
    TypeVar,
    Union,
    cast,
    get_args,
    overload,
)

import jsonschema
import matplotlib.pyplot as plt
import numpy as np
from numpy.typing import ArrayLike

import pulser
import pulser.sequence._decorators as seq_decorators
from pulser.channels.base_channel import Channel
from pulser.channels.dmm import DMM, _dmm_id_from_name, _get_dmm_name
from pulser.channels.eom import RydbergEOM
from pulser.devices._device_datacls import BaseDevice
from pulser.json.abstract_repr.deserializer import (
    deserialize_abstract_sequence,
)
from pulser.json.abstract_repr.serializer import serialize_abstract_sequence
from pulser.json.coders import PulserDecoder, PulserEncoder
from pulser.json.exceptions import AbstractReprError
from pulser.json.utils import obj_to_dict
from pulser.parametrized import Parametrized, Variable
from pulser.parametrized.variable import VariableItem
from pulser.pulse import Pulse
from pulser.register.base_register import BaseRegister, QubitId
from pulser.register.mappable_reg import MappableRegister
from pulser.register.weight_maps import DetuningMap
from pulser.sequence._basis_ref import _QubitRef
from pulser.sequence._call import _Call
from pulser.sequence._schedule import (
    _ChannelSchedule,
    _DMMSchedule,
    _PhaseDriftParams,
    _Schedule,
    _TimeSlot,
)
from pulser.sequence._seq_drawer import Figure, draw_sequence
from pulser.sequence._seq_str import seq_to_str
from pulser.waveforms import Waveform

DeviceType = TypeVar("DeviceType", bound=BaseDevice)

PROTOCOLS = Literal["min-delay", "no-delay", "wait-for-all"]


class Sequence(Generic[DeviceType]):
    """A sequence of operations on a device.

    A sequence is composed by

        - The device in which we want to implement it
        - The register of qubits on which to act
        - The device's channels that are used
        - The schedule of operations on each channel


    A Sequence also supports variable parameters, which have to be obtained
    through ``Sequence.declare_variable()``. From the moment a variable is
    declared, a ``Sequence`` becomes **parametrized** and stops being built on
    the fly, instead storing the sequence building calls for later execution.
    This forgoes some specific functionalities of a "regular" ``Sequence``,
    like the ability to validate a ``Pulse`` or to draw the sequence as it is
    being built. Instead, all validation happens upon building (through
    ``Sequence.build()``), where values for all declared variables have to be
    specified and a "regular" ``Sequence`` is created and returned. By
    changing the values given to the variables, multiple sequences can be
    generated from a single "parametrized" ``Sequence``.

    Args:
        register: The atom register on which to apply the pulses. If given as
            a MappableRegister instance, the traps corrresponding to each
            qubit ID must be given when building the sequence.
        device: A valid device in which to execute the Sequence (import
            it from ``pulser.devices``).

    Note:
        The register and device do not support variable parameters. As such,
        they are the same for all Sequences built from a parametrized Sequence.
    """

    def __init__(
        self,
        register: Union[BaseRegister, MappableRegister],
        device: DeviceType,
    ):
        """Initializes a new pulse sequence."""
        if not isinstance(device, BaseDevice):
            raise TypeError(
                f"'device' must be of type 'BaseDevice', not {type(device)}."
            )

        # Checks if register is compatible with the device
        if isinstance(register, MappableRegister):
            device.validate_layout(register.layout)
            device.validate_layout_filling(register)
        else:
            device.validate_register(register)

        self._register: Union[BaseRegister, MappableRegister] = register
        self._device = device
        self._in_xy: bool = False
        self._in_ising_value: bool = False
        self._mag_field: Optional[tuple[float, float, float]] = None
        self._calls: list[_Call] = [
            _Call("__init__", (), {"register": register, "device": device})
        ]
        self._schedule: _Schedule = _Schedule(
            max_duration=device.max_sequence_duration
        )
        self._basis_ref: dict[str, dict[QubitId, _QubitRef]] = {}
        # IDs of all qubits in device
        self._qids: set[QubitId] = set(self._register.qubit_ids)
        # Last time each qubit was used, by basis
        self._variables: dict[str, Variable] = {}
        self._to_build_calls: list[_Call] = []
        self._building: bool = True
        # Marks the sequence as empty until the first pulse is added
        self._empty_sequence: bool = True
        # SLM mask targets and on/off times
        self._slm_mask_targets: set[QubitId] = set()
        self._slm_mask_dmm: str | None = None
        # Initializes all parametrized Sequence related attributes
        self._reset_parametrized()

    @property
    def _slm_mask_time(self) -> list[int]:
        """The initial and final time when the SLM mask is on."""
        if (
            self._in_ising
            and self._slm_mask_dmm
            and not cast(
                _DMMSchedule, self._schedule[self._slm_mask_dmm]
            )._waiting_for_first_pulse
        ):
            slm_slot = self._schedule[self._slm_mask_dmm].slots[1]
            return [slm_slot.ti, slm_slot.tf]
        return (
            []
            if not self._slm_mask_targets
            else self._schedule.find_slm_mask_times()
        )

    @property
    def _in_ising(self) -> bool:
        return self._in_ising_value

    @_in_ising.setter
    def _in_ising(self, value: bool) -> None:
        if not isinstance(value, bool):
            raise TypeError("_in_ising must be a bool.")
        if self._in_ising == value:
            # If the value doesn't change, do nothing
            return
        if self._in_ising:  # ie value = False
            # Trying to switch off ising
            raise ValueError("Cannot quit ising.")
        # At this point, value = True
        if self._in_xy:
            raise ValueError("Cannot be in ising if in xy.")
        self._in_ising_value = True
        if self._slm_mask_dmm:
            self._set_slm_mask_dmm(self._slm_mask_dmm, self._slm_mask_targets)

    @property
    def qubit_info(self) -> dict[QubitId, np.ndarray]:
        """Dictionary with the qubit's IDs and positions."""
        if self.is_register_mappable():
            raise RuntimeError(
                "Can't access the qubit information when the register is "
                "mappable."
            )
        return cast(BaseRegister, self._register).qubits

    @property
    def device(self) -> DeviceType:
        """Device that the sequence is using."""
        return self._device

    @property
    def register(self) -> BaseRegister:
        """Register with the qubit's IDs and positions."""
        if self.is_register_mappable():
            raise RuntimeError(
                "Can't access the sequence's register because the register "
                "is mappable."
            )
        return cast(BaseRegister, self._register)

    @overload
    def get_register(self, include_mappable: Literal[False]) -> BaseRegister:
        pass

    @overload
    def get_register(
        self, include_mappable: Literal[True]
    ) -> BaseRegister | MappableRegister:
        pass

    def get_register(
        self, include_mappable: bool = True
    ) -> BaseRegister | MappableRegister:
        """The atom register on which to apply the pulses."""
        return self._register if include_mappable else self.register

    @property
    def declared_channels(self) -> dict[str, Channel]:
        """Channels declared in this Sequence."""
        all_declared_channels = {
            name: cs.channel_obj for name, cs in self._schedule.items()
        }
        # Add DMM and SLM whose configuration is stored
        for call in self._to_build_calls:
            if (
                call.name == "config_slm_mask"
                or call.name == "config_detuning_map"
            ):
                dmm_id: str
                if "dmm_id" in call.kwargs:
                    dmm_id = call.kwargs["dmm_id"]
                elif len(call.args) > 1:
                    dmm_id = call.args[1]
                else:
                    dmm_id = "dmm_0"
                dmm_name = _get_dmm_name(
                    dmm_id, list(all_declared_channels.keys())
                )
                all_declared_channels[dmm_name] = self.device.dmm_channels[
                    dmm_id
                ]
        return all_declared_channels

    @property
    def declared_variables(self) -> dict[str, Variable]:
        """Variables declared in this Sequence."""
        return dict(self._variables)

    @property
    def available_channels(self) -> dict[str, Channel]:
        """Channels still available for declaration."""
        all_channels = {**self._device.channels, **self._device.dmm_channels}
        if not self._in_xy and not self._in_ising:
            # If no channel has been declared nor any DMM configured, and if
            # device is physical, don't show the DMM used for the SLM Mask
            if (
                self._slm_mask_dmm is not None
                and not self._device.reusable_channels
            ):
                return {
                    id: ch
                    for id, ch in all_channels.items()
                    if id != self._slm_mask_dmm
                }
            return all_channels
        else:
            occupied_ch_ids = [
                self._schedule[ch_name].channel_id
                if ch_name in self._schedule
                else _dmm_id_from_name(ch_name)
                for ch_name in self.declared_channels.keys()
            ]
            return {
                id: ch
                for id, ch in all_channels.items()
                if (
                    # MockDevice channels can be declared multiple times
                    (
                        id not in occupied_ch_ids
                        or self._device.reusable_channels
                    )
                    and (
                        # If we are in XY mode, the dmm channels are available
                        # to configure a SLM mask if no slm mask was defined
                        ch.basis == "XY"
                        or (isinstance(ch, DMM) and self._slm_mask_dmm is None)
                        if self._in_xy
                        else ch.basis != "XY"
                    )
                )
            }

    @property
    def magnetic_field(self) -> np.ndarray:
        """The magnetic field acting on the array of atoms.

        The magnetic field vector is defined on the reference frame of the
        atoms in the Register (with the z-axis coming outside of the plane).

        Note:
            Only defined in "XY Mode", the default value being (0, 0, 30) G.
        """
        if not self._in_xy:
            raise AttributeError(
                "The magnetic field is only defined when the "
                "sequence is in 'XY Mode'."
            )
        return np.array(self._mag_field)

    def is_parametrized(self) -> bool:
        """States whether the sequence is parametrized.

        A parametrized sequence is one that depends on the values assigned to
        variables declared within it. Sequence-building calls are not executed
        right away, but rather stored for deferred execution when all variables
        are given a value (when ``Sequence.build()`` is called).

        Returns:
            Whether the sequence is parametrized.
        """
        return not self._building

    def is_in_eom_mode(self, channel: str) -> bool:
        """States whether a channel is currently in EOM mode.

        Args:
            channel: The name of the declared channel to inspect.

        Returns:
            Whether the channel is in EOM mode.

        """
        self._validate_channel(channel)
        if not self.is_parametrized():
            return self._schedule[channel].in_eom_mode()

        # Look for the latest stored EOM mode enable/disable
        for call in reversed(self._calls + self._to_build_calls):
            if call.name not in ("enable_eom_mode", "disable_eom_mode"):
                continue
            # Channel is the first positional arg in both methods
            ch_arg = call.args[0] if call.args else call.kwargs["channel"]
            if ch_arg == channel:
                # If it's not "enable_eom_mode", then it's "disable_eom_mode"
                return cast(bool, call.name == "enable_eom_mode")
        # If it reaches here, there were no EOM calls found
        return False

    def is_register_mappable(self) -> bool:
        """States whether the sequence's register is mappable.

        A sequence with a mappable register will require its qubit Id's to be
        mapped to trap Ids of its associated RegisterLayout through the
        `Sequence.build()` call.

        Returns:
            Whether the register is a MappableRegister.
        """
        return isinstance(self._register, MappableRegister)

    def is_measured(self) -> bool:
        """States whether the sequence has been measured."""
        return (
            bool(self._param_measurement)
            if self.is_parametrized()
            else hasattr(self, "_measurement")
        )

    def get_measurement_basis(self) -> str:
        """Gets the sequence's measurement basis.

        Raises:
            RuntimeError: When the sequence has not been measured.
        """
        if not self.is_measured():
            raise RuntimeError("The sequence has not been measured.")
        return (
            self._param_measurement
            if self.is_parametrized()
            else self._measurement
        )

    @seq_decorators.screen
    def get_duration(
        self, channel: Optional[str] = None, include_fall_time: bool = False
    ) -> int:
        """Returns the current duration of a channel or the whole sequence.

        Args:
            channel: A specific channel to return the duration of. If left as
                None, it will return the duration of the whole sequence.
            include_fall_time: Whether to include in the duration the
                extra time needed by the last pulse to finish, if there is
                modulation.

        Returns:
            The duration of the channel or sequence, in ns.
        """
        if channel is not None:
            self._validate_channel(channel)

        return self._schedule.get_duration(channel, include_fall_time)

    def get_addressed_bases(self) -> tuple[str, ...]:
        """Returns the bases addressed by the declared channels."""
        return tuple(self._basis_ref)

    @seq_decorators.screen
    def current_phase_ref(
        self, qubit: QubitId, basis: str = "digital"
    ) -> float:
        """Current phase reference of a specific qubit for a given basis.

        Args:
            qubit: The id of the qubit whose phase shift is desired.
            basis: The basis (i.e. electronic transition) the phase
                reference is associated with. Must correspond to the basis of a
                declared channel.

        Returns:
            Current phase reference of 'qubit' in 'basis'.
        """
        if qubit not in self._qids:
            raise ValueError(
                "'qubit' must be the id of a qubit declared in "
                "this sequence's register."
            )

        if basis not in self._basis_ref:
            raise ValueError(
                f"No declared channel targets the given 'basis' ('{basis}')."
            )

        return self._basis_ref[basis][qubit].phase.last_phase

    def set_magnetic_field(
        self, bx: float = 0.0, by: float = 0.0, bz: float = 30.0
    ) -> None:
        """Sets the magnetic field acting on the entire array.

        The magnetic field vector is defined on the reference frame of the
        atoms in the Register (with the z-axis coming outside of the plane).
        Can only be defined before there are pulses added to the sequence.

        Note:
            The magnetic field only work in the "XY Mode". If not already
            defined through the declaration of a Microwave channel, calling
            this function will enable the "XY Mode".

        Args:
            bx: The magnetic field in the x direction (in Gauss).
            by: The magnetic field in the y direction (in Gauss).
            bz: The magnetic field in the z direction (in Gauss).
        """
        if not self._in_xy:
            if self._schedule:
                raise ValueError(
                    "The magnetic field can only be set in 'XY Mode'."
                )
            # No channels declared yet
            self._in_xy = True
        elif not self._empty_sequence:
            # Not all channels are empty
            raise ValueError(
                "The magnetic field can only be set on an empty sequence."
            )

        mag_vector = (bx, by, bz)
        if np.linalg.norm(mag_vector) == 0.0:
            raise ValueError(
                "The magnetic field must have a magnitude greater than 0."
            )
        self._mag_field = mag_vector

        # No parametrization -> Always stored as a regular call
        self._calls.append(_Call("set_magnetic_field", mag_vector, {}))

    def _set_slm_mask_dmm(self, dmm_id: str, targets: set[QubitId]) -> None:
        ntargets = len(targets)
        detuning_map = self.register.define_detuning_map(
            {
                qubit: (1 / ntargets if qubit in targets else 0)
                for qubit in self.register.qubit_ids
            }
        )
        self._config_detuning_map(detuning_map, dmm_id)
        # Find the name of the dmm in the declared channels.
        for key in reversed(self.declared_channels.keys()):
            if dmm_id == _dmm_id_from_name(key):
                self._slm_mask_dmm = key
                break
        # Modulate the dmm if pulses have already been added to Global Channels
        slm_mask_times = self._schedule.find_slm_mask_times()
        if slm_mask_times:
            max_amp = max(
                [
                    np.max(ch_schedule.get_samples().amp[: slm_mask_times[1]])
                    for ch_schedule in self._schedule.values()
                    if not isinstance(ch_schedule, _DMMSchedule)
                    and ch_schedule.channel_obj.addressing == "Global"
                ]
            )
            self._modulate_slm_mask_dmm(slm_mask_times[1], max_amp)
        else:
            # Block the modulation of this dmm
            cast(
                _DMMSchedule, self._schedule[key]
            )._waiting_for_first_pulse = True

    @seq_decorators.store
    def config_slm_mask(
        self, qubits: Iterable[QubitId], dmm_id: str = "dmm_0"
    ) -> None:
        """Setup an SLM mask by specifying the qubits it targets.

        If the sequence is in XY mode, masked qubits don't interact with
        the incoming pulses until the end of the first pulse of the global
        channel starting the earliest in the schedule.

        If the sequence is in Ising, the SLM Mask is a DetuningMap where
        the detuning of each masked qubit is the same. DMM "dmm_id" is
        configured using this Detuning Map, and modulated by a pulse having
        a large negative detuning and either a duration defined from pulses
        already present in the sequence (same as in XY mode) or by the first
        pulse added after this operation.

        Args:
            qubits: Iterable of qubit ID's to mask during the first global
                pulse of the sequence.
            dmm_id: Id of the DMM channel to use in the device.
        """
        if not self._device.supports_slm_mask:
            raise ValueError(
                f"The '{self._device}' device does not have an SLM mask."
            )

        if self.is_register_mappable():
            raise RuntimeError(
                "The SLM mask can't be combined with a mappable register."
            )

        try:
            targets = set(qubits)
        except TypeError:
            raise TypeError("The SLM targets must be castable to set.")

        if not targets.issubset(self._qids):
            raise ValueError("SLM mask targets must exist in the register.")

        # If sequence is parametrized slm is configured at build
        if self.is_parametrized():
            return

        if self._slm_mask_targets:
            raise ValueError("SLM mask can be configured only once.")

        if self._in_xy or (not self._in_xy and not self._in_ising):
            if dmm_id not in self._device.dmm_channels:
                raise ValueError(f"No DMM {dmm_id} in the device.")
            self._slm_mask_dmm = dmm_id
        if not self._in_xy and self._in_ising:
            self._set_slm_mask_dmm(dmm_id, targets)
        self._slm_mask_targets = targets

    @seq_decorators.store
    @seq_decorators.block_if_measured
    def config_detuning_map(
        self,
        detuning_map: DetuningMap,
        dmm_id: str,
    ) -> None:
        """Declares a new DMM channel to the Sequence.

        Associates a DetuningMap to a DMM channel of the Device.

        Note:
            Regular devices only allow a DMM to be declared once, but
            ``MockDevice`` DMM can be repeatedly declared if needed.

        Args:
            detuning_map: A DetuningMap defining atoms to act on and bottom
                detuning to modulate.
            dmm_id: How the channel is identified in the device.
                See in ``Sequence.available_channels`` which DMM IDs are still
                available (start by "dmm" ) and the associated description.
        """
        self._config_detuning_map(detuning_map, dmm_id)

    def _config_detuning_map(
        self,
        detuning_map: DetuningMap,
        dmm_id: str,
    ) -> None:
        if dmm_id not in self._device.dmm_channels:
            raise ValueError(f"No DMM {dmm_id} in the device.")

        dmm_ch = self._device.dmm_channels[dmm_id]
        if self._in_xy:
            raise ValueError(
                f"DMM '{dmm_ch}' cannot work simultaneously "
                "with the declared 'Microwave' channel."
            )
        if dmm_id not in self.available_channels:
            raise ValueError(f"DMM {dmm_id} is not available.")

        # Configures the DMM implementing an SLM mask if configured before
        self._in_ising = True

        if self.is_parametrized():
            return
        # Add a suffix to the DMM id if repetition in the declared channels
        dmm_name = dmm_id
        if dmm_id in self.declared_channels:
            assert self._device.reusable_channels
            dmm_name += (
                f"_{''.join(self.declared_channels.keys()).count(dmm_id)}"
            )

        self._schedule[dmm_name] = _DMMSchedule(
            dmm_id, dmm_ch, detuning_map=detuning_map
        )
        if "ground-rydberg" not in self._basis_ref:
            self._basis_ref["ground-rydberg"] = {
                q: _QubitRef() for q in self._qids
            }

        # DMM has Global addressing
        self._add_to_schedule(dmm_name, _TimeSlot("target", -1, 0, self._qids))

    def switch_device(
        self, new_device: DeviceType, strict: bool = False
    ) -> Sequence:
        """Replicate the sequence with a different device.

        This method is designed to replicate the sequence with as few changes
        to the original contents as possible.
        If the `strict` option is chosen, the device switch will fail whenever
        it cannot guarantee that the new sequence's contents will not be
        modified in the process.

        Args:
            new_device: The target device instance.
            strict: Enforce a strict match between devices and channels to
                guarantee the pulse sequence is left unchanged.

        Returns:
            The sequence on the new device, using the match channels of
            the former device declared in the sequence.
        """
        # Check if the device is new or not

        if self._device == new_device:
            warnings.warn(
                "Switching a sequence to the same device"
                + " returns the sequence unchanged.",
                stacklevel=2,
            )
            return self

        if self._in_xy:
            interaction_param = "interaction_coeff_xy"
            name_in_msg = "XY interaction coefficient"
        else:
            interaction_param = "rydberg_level"
            name_in_msg = "Rydberg level"

        if getattr(new_device, interaction_param) != getattr(
            self._device, interaction_param
        ):
            if strict:
                raise ValueError(
                    "Strict device match failed because the"
                    f" devices have different {name_in_msg}s."
                )
            warnings.warn(
                f"Switching to a device with a different {name_in_msg},"
                " check that the expected interactions still hold.",
                stacklevel=2,
            )

        def check_retarget(ch_obj: Channel) -> bool:
            # Check the min_retarget_interval when it is is not
            # fully covered by the fixed_retarget_t
            return ch_obj.addressing == "Local" and cast(
                int, ch_obj.fixed_retarget_t
            ) < cast(int, ch_obj.min_retarget_interval)

        # Channel match
        channel_match: dict[str, Any] = {}
        strict_error_message = ""
        ch_match_err = ""
        active_eom_channels = [
            {**dict(zip(("channel",), call.args)), **call.kwargs}["channel"]
            for call in self._calls + self._to_build_calls
            if call.name == "enable_eom_mode"
        ]
        all_channels_new_device = {
            **new_device.channels,
            **new_device.dmm_channels,
        }

        for old_ch_name, old_ch_obj in self.declared_channels.items():
            channel_match[old_ch_name] = None
            base_msg = f"No match for channel {old_ch_name}"
            # Find the corresponding channel on the new device
            for new_ch_id, new_ch_obj in all_channels_new_device.items():
                if (
                    not new_device.reusable_channels
                    and new_ch_id in channel_match.values()
                ):
                    # Channel already matched and can't be reused
                    continue

                # We verify the channel class then
                # check whether the addressing is Global or Local
                type_match = type(old_ch_obj) is type(new_ch_obj)
                basis_match = old_ch_obj.basis == new_ch_obj.basis
                addressing_match = (
                    old_ch_obj.addressing == new_ch_obj.addressing
                )
                if not (type_match and basis_match and addressing_match):
                    # If there already is a message, keeps it
                    ch_match_err = ch_match_err or (
                        base_msg
                        + " with the right type, basis and addressing."
                    )
                    continue
                if old_ch_name in active_eom_channels:
                    # Uses EOM mode, so the new device needs a matching
                    # EOM configuration
                    if new_ch_obj.eom_config is None:
                        ch_match_err = base_msg + " with an EOM configuration."
                        continue
                    if (
                        # TODO: Improvements to this check:
                        # 1. multiple_beam_control doesn't matter when there
                        # is only one beam
                        # 2. custom_buffer_time doesn't have to match as long
                        # as `Channel_eom_buffer_time`` does
                        new_ch_obj.eom_config != old_ch_obj.eom_config
                        and strict
                    ):
                        strict_error_message = (
                            base_msg + " with the same EOM configuration."
                        )
                        continue
                if not strict:
                    channel_match[old_ch_name] = new_ch_id
                    # Found a match, clear match error msg for this channel
                    if ch_match_err.startswith(base_msg):
                        ch_match_err = ""
                    break

                params_to_check = [
                    "mod_bandwidth",
                    "fixed_retarget_t",
                    "clock_period",
                ]
                if isinstance(old_ch_obj, DMM):
                    params_to_check.append("bottom_detuning")
                if check_retarget(old_ch_obj) or check_retarget(new_ch_obj):
                    params_to_check.append("min_retarget_interval")
                for param_ in params_to_check:
                    if getattr(new_ch_obj, param_) != getattr(
                        old_ch_obj, param_
                    ):
                        strict_error_message = (
                            base_msg + f" with the same {param_}."
                        )
                        break
                else:
                    # Only reached if all checks passed
                    channel_match[old_ch_name] = new_ch_id
                    # Found a match, clear match error msgs for this channel
                    if ch_match_err.startswith(base_msg):
                        ch_match_err = ""
                    if strict_error_message.startswith(base_msg):
                        strict_error_message = ""
                    break

        if None in channel_match.values():
            if strict_error_message:
                raise ValueError(strict_error_message)
            else:
                raise TypeError(ch_match_err)
        # Initialize the new sequence (works for Sequence subclasses too)
        new_seq = type(self)(register=self._register, device=new_device)
        dmm_calls: list[str] = []
        # Copy the variables to the new sequence
        new_seq._variables = self.declared_variables
        for call in self._calls[1:] + self._to_build_calls:
            # Switch the old id with the correct id
            sw_channel_args = list(call.args)
            sw_channel_kw_args = call.kwargs.copy()
            if not (
                call.name == "declare_channel"
                or call.name == "config_detuning_map"
                or call.name == "config_slm_mask"
            ):
                pass
            elif "name" in sw_channel_kw_args:  # pragma: no cover
                sw_channel_kw_args["channel_id"] = channel_match[
                    sw_channel_kw_args["name"]
                ]
            elif "channel_id" in sw_channel_kw_args:  # pragma: no cover
                sw_channel_kw_args["channel_id"] = channel_match[
                    sw_channel_args[0]
                ]
            elif "dmm_id" in sw_channel_kw_args:  # pragma: no cover
                sw_channel_kw_args["dmm_id"] = channel_match[
                    _get_dmm_name(sw_channel_kw_args["dmm_id"], dmm_calls)
                ]
                dmm_calls.append(sw_channel_kw_args["dmm_id"])
            elif call.name == "declare_channel":
                sw_channel_args[1] = channel_match[sw_channel_args[0]]
            else:
                sw_channel_args[1] = channel_match[
                    _get_dmm_name(sw_channel_args[1], dmm_calls)
                ]
                dmm_calls.append(sw_channel_args[1])
            getattr(new_seq, call.name)(*sw_channel_args, **sw_channel_kw_args)
        return new_seq

    @seq_decorators.block_if_measured
    def declare_channel(
        self,
        name: str,
        channel_id: str,
        initial_target: Optional[Union[QubitId, Iterable[QubitId]]] = None,
    ) -> 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: Unique name for the channel in the sequence.
            channel_id: How the channel is identified in the device.
                Consult ``Sequence.available_channels`` to see which channel
                ID's are still available and the associated channel's
                description.
            initial_target: For 'Local' addressing channels only. Declares the
                initial target of the channel. If left as None, the initial
                target will have to be set manually as the first addition
                to this channel.
        """
        if name.startswith("dmm_"):
            raise ValueError(
                "Name starting by 'dmm_' are reserved for DMM channels."
            )
        if name in self._schedule:
            raise ValueError("The given name is already in use.")

        if channel_id not in self._device.channels:
            raise ValueError(f"No channel {channel_id} in the device.")

        ch = self._device.channels[channel_id]
        if channel_id not in self.available_channels:
            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.")

        if initial_target is not None:
            try:
                cond = any(
                    isinstance(t, Parametrized)
                    for t in cast(Iterable, initial_target)
                )
            except TypeError:
                cond = isinstance(initial_target, Parametrized)
            if cond:
                raise TypeError("The initial_target cannot be parametrized")

        if ch.basis == "XY":
            if not self._in_xy:
                self.set_magnetic_field()
                self._in_xy = True
        else:
            self._in_ising = True
        self._schedule[name] = _ChannelSchedule(channel_id, ch)

        if ch.basis not in self._basis_ref:
            self._basis_ref[ch.basis] = {q: _QubitRef() for q in self._qids}

        if ch.addressing == "Global":
            self._add_to_schedule(name, _TimeSlot("target", -1, 0, self._qids))
        elif initial_target is not None:
            if self.is_parametrized():
                # Do not store "initial_target" in a _call when parametrized
                # It is stored as a _to_build_call when target is called
                self.target(initial_target, name)
                initial_target = None
            else:
                # "_target" call is not saved
                self._target(
                    cast(Union[Iterable, QubitId], initial_target), name
                )

        # Manually store the channel declaration as a regular call
        self._calls.append(
            _Call(
                "declare_channel",
                (name, channel_id),
                {"initial_target": initial_target},
            )
        )

    @overload
    def declare_variable(
        self,
        name: str,
        *,
        dtype: Union[type[int], type[float]] = float,
    ) -> VariableItem:
        pass

    @overload
    def declare_variable(
        self,
        name: str,
        *,
        size: int,
        dtype: Union[type[int], type[float]] = float,
    ) -> Variable:
        pass

    def declare_variable(
        self,
        name: str,
        size: Optional[int] = None,
        dtype: Union[type[int], type[float]] = float,
    ) -> Union[Variable, VariableItem]:
        """Declare a new variable within this Sequence.

        The declared variables can be used to create parametrized versions of
        ``Waveform`` and ``Pulse`` objects, which in turn can be added to the
        ``Sequence``. Additionally, simple arithmetic operations involving
        variables are also supported and will return parametrized objects that
        are dependent on the involved variables.

        Args:
            name: The name for the variable. Must be unique within a
                Sequence.

        Args:
            size: The number of entries stored in the variable. If defined,
                returns an array of variables with the given size. If left
                as ``None``, returns a single variable.
            dtype: The type of the data that will be assigned
                to the variable. Must be ``float`` or ``int``.

        Returns:
            The declared Variable instance.

        Note:
            To avoid confusion, it is recommended to store the returned
            Variable instance in a Python variable with the same name.
        """
        if name in ("qubits", "seq_name", "json_dumps_options"):
            raise ValueError(
                f"'{name}' is a protected name. Please choose a different name"
                " for the variable."
            )

        if name in self._variables:
            raise ValueError("Name for variable is already being used.")

        if size is None:
            var = self.declare_variable(name, size=1, dtype=dtype)
            return var[0]
        else:
            var = Variable(name, dtype, size=size)
            self._variables[name] = var
            return var

    @seq_decorators.verify_parametrization
    @seq_decorators.block_if_measured
    def enable_eom_mode(
        self,
        channel: str,
        amp_on: Union[float, Parametrized],
        detuning_on: Union[float, Parametrized],
        optimal_detuning_off: Union[float, Parametrized] = 0.0,
        correct_phase_drift: bool = False,
    ) -> None:
        """Puts a channel in EOM mode operation.

        For channels with a finite modulation bandwidth and an EOM, operation
        in EOM mode allows for the execution of square pulses with a higher
        bandwidth than that which is tipically available. It can be turned on
        and off through the `Sequence.enable_eom_mode()` and
        `Sequence.disable_eom_mode()` methods.
        A channel in EOM mode can only execute square pulses with a given
        amplitude (`amp_on`) and detuning (`detuning_on`), which are
        chosen at the moment the EOM mode is enabled. Furthermore, the
        detuning when there is no pulse being played (`detuning_off`) is
        restricted to a set of values that depends on `amp_on` and
        `detuning_on`.
        While in EOM mode, one can only add pulses of variable duration
        (through `Sequence.add_eom_pulse()`) or delays.

        Note:
            Enabling the EOM mode will automatically enforce a buffer unless
            the channel is empty. The detuning will go to the `detuning_off`
            value during this buffer. This buffer will not wait for pulses
            on other channels to finish, so calling `Sequence.align()` or
            `Sequence.delay()` before enabling the EOM mode is necessary to
            avoid eventual conflicts.

        Args:
            channel: The name of the channel to put in EOM mode.
            amp_on: The amplitude of the EOM pulses (in rad/µs).
            detuning_on: The detuning of the EOM pulses (in rad/µs).
            optimal_detuning_off: The optimal value of detuning (in rad/µs)
                when there is no pulse being played. It will choose the closest
                value among the existing options.
            correct_phase_drift: Performs a phase shift to correct for the
                phase drift incurred while turning on the EOM mode.
        """
        if self.is_in_eom_mode(channel):
            raise RuntimeError(
                f"The '{channel}' channel is already in EOM mode."
            )
        channel_obj = self.declared_channels[channel]
        if not channel_obj.supports_eom():
            raise TypeError(f"Channel '{channel}' does not have an EOM.")

        on_pulse = Pulse.ConstantPulse(
            channel_obj.min_duration, amp_on, detuning_on, 0.0
        )
        stored_opt_detuning_off = optimal_detuning_off
        if not isinstance(on_pulse, Parametrized):
            channel_obj.validate_pulse(on_pulse)
            amp_on = cast(float, amp_on)
            detuning_on = cast(float, detuning_on)
            eom_config = cast(RydbergEOM, channel_obj.eom_config)
            if not isinstance(optimal_detuning_off, Parametrized):
                detuning_off = eom_config.calculate_detuning_off(
                    amp_on, detuning_on, optimal_detuning_off
                )
                off_pulse = Pulse.ConstantPulse(
                    channel_obj.min_duration, 0.0, detuning_off, 0.0
                )
                channel_obj.validate_pulse(off_pulse)
                # Update optimal_detuning_off to match the chosen detuning_off
                # This minimizes the changes to the sequence when the device
                # is switched
                stored_opt_detuning_off = detuning_off

            if not self.is_parametrized():
                phase_drift_params = _PhaseDriftParams(
                    drift_rate=-detuning_off, ti=self.get_duration(channel)
                )
                self._schedule.enable_eom(
                    channel, amp_on, detuning_on, detuning_off
                )
                if correct_phase_drift:
                    buffer_slot = self._last(channel)
                    drift = phase_drift_params.calc_phase_drift(buffer_slot.tf)
                    self._phase_shift(
                        -drift, *buffer_slot.targets, basis=channel_obj.basis
                    )

        # Manually store the call to "enable_eom_mode" so that the updated
        # 'optimal_detuning_off' is stored
        call_container = (
            self._to_build_calls if self.is_parametrized() else self._calls
        )
        call_container.append(
            _Call(
                "enable_eom_mode",
                (),
                dict(
                    channel=channel,
                    amp_on=amp_on,
                    detuning_on=detuning_on,
                    optimal_detuning_off=stored_opt_detuning_off,
                    correct_phase_drift=correct_phase_drift,
                ),
            )
        )

    @seq_decorators.store
    @seq_decorators.block_if_measured
    def disable_eom_mode(
        self, channel: str, correct_phase_drift: bool = False
    ) -> None:
        """Takes a channel out of EOM mode operation.

        For channels with a finite modulation bandwidth and an EOM, operation
        in EOM mode allows for the execution of square pulses with a higher
        bandwidth than that which is tipically available. It can be turned on
        and off through the `Sequence.enable_eom_mode()` and
        `Sequence.disable_eom_mode()` methods.
        A channel in EOM mode can only execute square pulses with a given
        amplitude (`amp_on`) and detuning (`detuning_on`), which are
        chosen at the moment the EOM mode is enabled. Furthermore, the
        detuning when there is no pulse being played (`detuning_off`) is
        restricted to a set of values that depends on `amp_on` and
        `detuning_on`.
        While in EOM mode, one can only add pulses of variable duration
        (through `Sequence.add_eom_pulse()`) or delays.

        Note:
            Disabling the EOM mode will automatically enforce a buffer time
            from the moment it is turned off.

        Args:
            channel: The name of the channel to take out of EOM mode.
            correct_phase_drift: Performs a phase shift to correct for the
                phase drift that occured since the last pulse (or the start of
                the EOM mode, if no pulse was added).
        """
        if not self.is_in_eom_mode(channel):
            raise RuntimeError(f"The '{channel}' channel is not in EOM mode.")
        if not self.is_parametrized():
            self._schedule.disable_eom(channel)
            if correct_phase_drift:
                ch_schedule = self._schedule[channel]
                # EOM mode has just been disabled, so tf is defined
                last_eom_block_tf = cast(int, ch_schedule.eom_blocks[-1].tf)
                drift_params = self._get_last_eom_pulse_phase_drift(channel)
                self._phase_shift(
                    -drift_params.calc_phase_drift(last_eom_block_tf),
                    *ch_schedule[-1].targets,
                    basis=ch_schedule.channel_obj.basis,
                )

    @seq_decorators.store
    @seq_decorators.mark_non_empty
    @seq_decorators.block_if_measured
    def add_eom_pulse(
        self,
        channel: str,
        duration: Union[int, Parametrized],
        phase: Union[float, Parametrized],
        post_phase_shift: Union[float, Parametrized] = 0.0,
        protocol: PROTOCOLS = "min-delay",
        correct_phase_drift: bool = False,
    ) -> None:
        """Adds a square pulse to a channel in EOM mode.

        For channels with a finite modulation bandwidth and an EOM, operation
        in EOM mode allows for the execution of square pulses with a higher
        bandwidth than that which is tipically available. It can be turned on
        and off through the `Sequence.enable_eom_mode()` and
        `Sequence.disable_eom_mode()` methods.
        A channel in EOM mode can only execute square pulses with a given
        amplitude (`amp_on`) and detuning (`detuning_on`), which are
        chosen at the moment the EOM mode is enabled. Furthermore, the
        detuning when there is no pulse being played (`detuning_off`) is
        restricted to a set of values that depends on `amp_on` and
        `detuning_on`.
        While in EOM mode, one can only add pulses of variable duration
        (through `Sequence.add_eom_pulse()`) or delays.

        Note:
            When the phase between pulses is changed, the necessary buffer
            time for a phase jump will still be enforced (unless
            ``protocol='no-delay'``).

        Args:
            channel: The name of the channel to add the pulse to.
            duration: The duration of the pulse (in ns).
            phase: The pulse phase (in radians).
            post_phase_shift: Optionally lets you add a phase shift (in rads)
                immediately after the end of the pulse.
            protocol: Stipulates how to deal with eventual conflicts with
                other channels (see `Sequence.add()` for more details).
            correct_phase_drift: Adjusts the phase to correct for the phase
                drift that occured since the last pulse (or the start of the
                EOM mode, if adding the first pulse). This effectively
                changes the phase of the EOM pulse, so an extra delay might
                be added to enforce the phase jump time.
        """
        if not self.is_in_eom_mode(channel):
            raise RuntimeError(f"Channel '{channel}' must be in EOM mode.")

        if self.is_parametrized():
            self._validate_add_protocol(protocol)
            # Test the parameters
            if not isinstance(duration, Parametrized):
                channel_obj = self.declared_channels[channel]
                channel_obj.validate_duration(duration)
            for arg in (phase, post_phase_shift):
                if not isinstance(arg, (Parametrized, float, int)):
                    raise TypeError("Phase values must be a numeric value.")
            return

        eom_settings = self._schedule[channel].eom_blocks[-1]
        eom_pulse = Pulse.ConstantPulse(
            duration,
            eom_settings.rabi_freq,
            eom_settings.detuning_on,
            phase,
            post_phase_shift=post_phase_shift,
        )
        phase_drift_params = (
            self._get_last_eom_pulse_phase_drift(channel)
            if correct_phase_drift
            else None
        )
        self._add(
            eom_pulse, channel, protocol, phase_drift_params=phase_drift_params
        )

    @seq_decorators.store
    @seq_decorators.mark_non_empty
    @seq_decorators.block_if_measured
    def add(
        self,
        pulse: Union[Pulse, Parametrized],
        channel: str,
        protocol: PROTOCOLS = "min-delay",
    ) -> None:
        """Adds a pulse to a channel.

        Args:
            pulse: The pulse object to add to the channel.
            channel: The channel's name provided when declared.
            protocol: Stipulates how to deal with
                eventual conflicts with other channels, specifically in terms
                of having multiple channels act on the same target
                simultaneously.

                - ``'min-delay'``: Before adding the pulse, introduces the
                  smallest possible delay that avoids all exisiting conflicts.
                - ``'no-delay'``: Adds the pulse to the channel, regardless of
                  existing conflicts.
                - ``'wait-for-all'``: Before adding the pulse, adds a delay
                  that idles the channel until the end of the other channels'
                  latest pulse.

        Note:
            When the phase of the pulse to add is different than the phase of
            the previous pulse on the channel, a delay between the two pulses
            might be automatically added to ensure the channel's
            `phase_jump_time` is respected. To override this behaviour, use
            the ``'no-delay'`` protocol.
        """
        self._validate_channel(
            channel,
            block_eom_mode=True,
            block_if_slm=channel.startswith("dmm_"),
        )
        self._add(pulse, channel, protocol)

    @seq_decorators.store
    @seq_decorators.mark_non_empty
    @seq_decorators.block_if_measured
    def add_dmm_detuning(
        self,
        waveform: Union[Waveform, Parametrized],
        dmm_name: str,
        protocol: PROTOCOLS = "no-delay",
    ) -> None:
        """Add a waveform to the detuning of a dmm.

        Args:
            waveform: The waveform to add to the detuning of the dmm.
            dmm_name: The id of the dmm to modulate.
            protocol: Stipulates how to deal with
                eventual conflicts with other channels, specifically in terms
                of having multiple channels act on the same target
                simultaneously (defaults to "no-delay").

                - ``'min-delay'``: Before adding the pulse, introduces the
                  smallest possible delay that avoids all exisiting conflicts.
                - ``'no-delay'``: Adds the pulse to the channel, regardless of
                  existing conflicts.
                - ``'wait-for-all'``: Before adding the pulse, adds a delay
                  that idles the channel until the end of the other channels'
                  latest pulse.
        """
        self._validate_channel(dmm_name, block_if_slm=True)
        self._add(
            Pulse.ConstantAmplitude(0, waveform, 0),
            dmm_name,
            protocol,
        )

    @seq_decorators.store
    def target(
        self,
        qubits: Union[QubitId, Iterable[QubitId]],
        channel: str,
    ) -> None:
        """Changes the target qubit of a 'Local' channel.

        Args:
            qubits: The new target for this channel. Must correspond to a
                qubit ID in device or an iterable of qubit IDs, when
                multi-qubit addressing is possible.
            channel: The channel's name provided when declared. Must be
                a channel with 'Local' addressing.
        """
        self._target(qubits, channel)

    @seq_decorators.store
    def target_index(
        self,
        qubits: Union[int, Iterable[int], Parametrized],
        channel: str,
    ) -> None:
        """Changes the target qubit of a 'Local' channel.

        Args:
            qubits: The new target for this channel. Must correspond to a
                qubit index or an iterable of qubit indices, when multi-qubit
                addressing is possible.
                A qubit index is a number between 0 and the number of qubits.
                It is then converted to a Qubit ID using the order in which
                they were declared when instantiating the ``Register``
                or ``MappableRegister``.
            channel: The channel's name provided when declared. Must be
                a channel with 'Local' addressing.

        Note:
            Cannot be used on non-parametrized sequences using a mappable
            register.
        """
        self._target(qubits, channel, _index=True)

    @seq_decorators.store
    def delay(
        self,
        duration: Union[int, Parametrized],
        channel: str,
    ) -> None:
        """Idles a given channel for a specific duration.

        Args:
            duration: Time to delay (in ns).
            channel: The channel's name provided when declared.
        """
        self._delay(duration, channel)

    @seq_decorators.store
    @seq_decorators.block_if_measured
    def measure(self, basis: str = "ground-rydberg") -> None:
        """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: Valid basis for measurement (consult the
                ``supported_bases`` attribute of the selected device for
                the available options).
        """
        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 and operation mode. The "
                "available options are: " + ", ".join(list(available))
            )
        elif basis not in self.get_addressed_bases():
            warnings.warn(
                f"The desired measurement basis '{basis}' is not being "
                "addressed by any channel in the sequence.",
                stacklevel=2,
            )

        if self.is_parametrized():
            self._param_measurement = basis
        else:
            self._measurement = basis

    @seq_decorators.store
    def phase_shift(
        self,
        phi: Union[float, Parametrized],
        *targets: QubitId,
        basis: str = "digital",
    ) -> None:
        r"""Shifts the phase of a qubit's reference by 'phi', on a given basis.

        This is equivalent to an :math:`R_z(\phi)` gate (i.e. a rotation of the
        target qubit's state by an angle :math:`\phi` around the z-axis of the
        Bloch sphere).

        Args:
            phi: The intended phase shift (in rads).
            targets: The ids of the qubits to apply the phase shift to.
            basis: The basis (i.e. electronic transition) to associate
                the phase shift to. Must correspond to the basis of a declared
                channel.
        """
        self._phase_shift(phi, *targets, basis=basis)

    @seq_decorators.store
    def phase_shift_index(
        self,
        phi: Union[float, Parametrized],
        *targets: Union[int, Parametrized],
        basis: str = "digital",
    ) -> None:
        r"""Shifts the phase of a qubit's reference by 'phi', on a given basis.

        This is equivalent to an :math:`R_z(\phi)` gate (i.e. a rotation of the
        target qubit's state by an angle :math:`\phi` around the z-axis of the
        Bloch sphere).

        Args:
            phi: The intended phase shift (in rads).
            targets: The indices of the qubits to apply the phase shift to.
                A qubit index is a number between 0 and the number of qubits.
                It is then converted to a Qubit ID using the order in which
                they were declared when instantiating the ``Register``
                or ``MappableRegister``.
            basis: The basis (i.e. electronic transition) to associate
                the phase shift to. Must correspond to the basis of a declared
                channel.

        Note:
            Cannot be used on non-parametrized sequences using a mappable
            register.
        """
        self._phase_shift(phi, *targets, basis=basis, _index=True)

    @seq_decorators.store
    @seq_decorators.block_if_measured
    def align(self, *channels: str) -> None:
        """Aligns multiple channels in time.

        Introduces delays that align the provided channels with the one that
        finished the latest, such that the next action added to any of them
        will start right after the latest channel has finished.

        Args:
            channels: The names of the channels to align, as given upon
                declaration.
        """
        ch_set = set(channels)
        # channels have to be a subset of the declared channels
        if not ch_set <= set(self._schedule):
            raise ValueError(
                "All channel names must correspond to declared channels."
            )
        if len(channels) != len(ch_set):
            raise ValueError("The same channel was provided more than once.")

        if len(channels) < 2:
            raise ValueError("Needs at least two channels for alignment.")

        if self.is_parametrized():
            return

        last_ts = {
            id: self.get_duration(id, include_fall_time=True)
            for id in channels
        }
        tf = max(last_ts.values())

        for id in channels:
            delta = tf - last_ts[id]
            if delta > 0:
                self._delay(
                    self._schedule[id].adjust_duration(delta),
                    id,
                )

    def build(
        self,
        *,
        qubits: Optional[Mapping[QubitId, int]] = None,
        **vars: Union[ArrayLike, float, int],
    ) -> Sequence:
        """Builds a sequence from the programmed instructions.

        Args:
            qubits: A mapping between qubit IDs and trap IDs used to define
                the register. Must only be provided when the sequence is
                initialized with a MappableRegister.
            vars: The values for all the variables declared in this Sequence
                instance, indexed by the name given upon declaration. Check
                ``Sequence.declared_variables`` to see all the variables.

        Returns:
            The Sequence built with the given variable values.

        Example:
            ::

                # Check which variables are declared
                >>> print(seq.declared_variables)
                {'x': Variable(name='x', dtype=<class 'float'>, size=1),
                 'y': Variable(name='y', dtype=<class 'int'>, size=3)}
                # Build a sequence with specific values for both variables
                >>> seq1 = seq.build(x=0.5, y=[1, 2, 3])
        """
        if self.is_register_mappable():
            if qubits is None:
                raise ValueError(
                    "'qubits' must be specified when the sequence is created "
                    "with a MappableRegister."
                )

        elif qubits is not None:
            raise ValueError(
                "'qubits' must not be specified when the sequence already has "
                "a concrete register."
            )

        self._cross_check_vars(vars)

        # Shallow copy with stored parametrized objects (if any)
        # NOTE: While seq is a shallow copy, be extra careful with changes to
        # attributes of seq pointing to mutable objects, as they might be
        # inadvertedly done to self too
        seq = copy.copy(self)

        # Eliminates the source of recursiveness errors
        seq._reset_parametrized()

        # Deepcopy the base sequence (what remains)
        seq = copy.deepcopy(seq)
        # NOTE: Changes to seq are now safe to do

        if not (self.is_parametrized() or self.is_register_mappable()):
            warnings.warn(
                "Building a non-parametrized sequence simply returns"
                " a copy of itself.",
                stacklevel=2,
            )
            return seq

        for name, value in vars.items():
            self._variables[name]._assign(value)

        if qubits:
            reg = cast(MappableRegister, self._register).build_register(qubits)
            self._set_register(seq, reg)

        for call in self._to_build_calls:
            args_ = [
                arg.build() if isinstance(arg, Parametrized) else arg
                for arg in call.args
            ]
            kwargs_ = {
                key: val.build() if isinstance(val, Parametrized) else val
                for key, val in call.kwargs.items()
            }
            getattr(seq, call.name)(*args_, **kwargs_)

        return seq

    def serialize(self, **kwargs: Any) -> str:
        """Serializes the Sequence into a JSON formatted string.

        Other Parameters:
            kwargs: Valid keyword-arguments for ``json.dumps()``, except for
                ``cls``.

        Returns:
            The sequence encoded in a JSON formatted string.

        Warning:
            This method has been deprecated and is scheduled for removal
            in Pulser v1.0.0. For sequence serialization and deserialization,
            use ``Sequence.to_abstract_repr()`` and
            ``Sequence.from_abstract_repr()`` instead.

        See Also:
            ``json.dumps``: Built-in function for serialization to a JSON
            formatted string.
        """
        with warnings.catch_warnings():
            warnings.simplefilter("always")
            warnings.warn(
                DeprecationWarning(
                    "`Sequence.serialize()` and `Sequence.deserialize()` have "
                    "been deprecated and will be removed in Pulser v1.0.0. "
                    "Use `Sequence.to_abstract_repr()` and "
                    "`Sequence.from_abstract_repr()` instead."
                )
            )

        return self._serialize(**kwargs)

    def _serialize(self, **kwargs: Any) -> str:
        """Serializes the Sequence into a JSON formatted string.

        Other Parameters:
            kwargs: Valid keyword-arguments for ``json.dumps()``, except for
                ``cls``.

        Returns:
            The sequence encoded in a JSON formatted string.

        See Also:
            ``json.dumps``: Built-in function for serialization to a JSON
            formatted string.
        """
        return json.dumps(self, cls=PulserEncoder, **kwargs)

    def to_abstract_repr(
        self,
        seq_name: str = "pulser-exported",
        json_dumps_options: dict[str, Any] = {},
        **defaults: Any,
    ) -> str:
        """Serializes the Sequence into an abstract JSON object.

        Keyword Args:
            seq_name (str): A name for the sequence. If not defined, defaults
                to "pulser-exported".
            json_dumps_options: A mapping between optional parameters of
                ``json.dumps()`` (as string) and their value (parameter cannot
                be "cls").
            defaults: The default values for all the variables declared in this
                Sequence instance, indexed by the name given upon declaration.
                Check ``Sequence.declared_variables`` to see all the variables.
                When using a MappableRegister, the Qubit IDs to trap IDs
                mapping must also be provided under the `qubits` keyword.

        Note:
            Providing the `defaults` is optional but, when done, it is
            mandatory to give default values for all the expected parameters.

        Returns:
            str: The sequence encoded as an abstract JSON object.
        """
        try:
            return serialize_abstract_sequence(
                self, seq_name, json_dumps_options, **defaults
            )
        except jsonschema.exceptions.ValidationError as e:
            if self.is_parametrized():
                raise AbstractReprError(
                    "The serialization of the parametrized sequence failed, "
                    "potentially due to an error that only appears at build "
                    "time. Check that no errors appear when building with "
                    "`Sequence.build()` or when providing the `defaults` to "
                    "`Sequence.to_abstract_repr()`."
                ) from e
            raise e  # pragma: no cover

    @staticmethod
    def deserialize(obj: str, **kwargs: Any) -> Sequence:
        """Deserializes a JSON formatted string.

        Args:
            obj: The JSON formatted string to deserialize, coming from
                the serialization of a ``Sequence`` through
                ``Sequence.serialize()``.

        Other Parameters:
            kwargs: Valid keyword-arguments for ``json.loads()``, except for
                ``cls`` and ``object_hook``.

        Returns:
            The deserialized Sequence object.

        Warning:
            This method has been deprecated and is scheduled for removal
            in Pulser v1.0.0. For sequence serialization and deserialization,
            use ``Sequence.to_abstract_repr()`` and
            ``Sequence.from_abstract_repr()`` instead.

        See Also:
            ``json.loads``: Built-in function for deserialization from a JSON
            formatted string.
        """
        with warnings.catch_warnings():
            warnings.simplefilter("always")
            warnings.warn(
                DeprecationWarning(
                    "`Sequence.serialize()` and `Sequence.deserialize()` have "
                    "been deprecated and will be removed in Pulser v1.0.0. "
                    "Use `Sequence.to_abstract_repr()` and "
                    "`Sequence.from_abstract_repr()` instead."
                )
            )
        return Sequence._deserialize(obj, **kwargs)

    @staticmethod
    def _deserialize(obj: str, **kwargs: Any) -> Sequence:
        """Deserializes a JSON formatted string.

        Args:
            obj: The JSON formatted string to deserialize, coming from
                the serialization of a ``Sequence`` through
                ``Sequence.serialize()``.

        Other Parameters:
            kwargs: Valid keyword-arguments for ``json.loads()``, except for
                ``cls`` and ``object_hook``.

        Returns:
            The deserialized Sequence object.

        See Also:
            ``json.loads``: Built-in function for deserialization from a JSON
            formatted string.
        """
        if not isinstance(obj, str):
            raise TypeError(
                "The serialized sequence must be given as a string. "
                f"Instead, got object of type {type(obj)}."
            )
        if "Sequence" not in obj:
            raise ValueError(
                "The given JSON formatted string does not encode a Sequence."
            )

        return cast(Sequence, json.loads(obj, cls=PulserDecoder, **kwargs))

    @staticmethod
    def from_abstract_repr(obj_str: str) -> Sequence:
        """Deserialize a sequence from an abstract JSON object.

        Args:
            obj_str (str): the JSON string representing the sequence encoded
                in the abstract JSON format.

        Returns:
            Sequence: The Pulser sequence.
        """
        if not isinstance(obj_str, str):
            raise TypeError(
                "The serialized sequence must be given as a string. "
                f"Instead, got object of type {type(obj_str)}."
            )
        return deserialize_abstract_sequence(obj_str)

    @seq_decorators.screen
    def draw(
        self,
        mode: str = "input+output",
        draw_phase_area: bool = False,
        draw_interp_pts: bool = True,
        draw_phase_shifts: bool = False,
        draw_register: bool = False,
        draw_phase_curve: bool = False,
        draw_detuning_maps: bool = False,
        draw_qubit_amp: bool = False,
        draw_qubit_det: bool = False,
        fig_name: str | None = None,
        kwargs_savefig: dict = {},
        show: bool = True,
    ) -> None:
        """Draws the sequence in its current state.

        Args:
            mode: The curves to draw. 'input'
                draws only the programmed curves, 'output' the excepted curves
                after modulation. 'input+output' will draw both curves except
                for channels without a defined modulation bandwidth, in which
                case only the input is drawn.
            draw_phase_area: Whether phase and area values need to be
                shown as text on the plot, defaults to False. Doesn't work in
                'output' mode. If `draw_phase_curve=True`, phase values are
                ommited.
            draw_interp_pts: When the sequence has pulses with waveforms
                of type InterpolatedWaveform, draws the points of interpolation
                on top of the respective input waveforms (defaults to True).
                Doesn't work in 'output' mode.
            draw_phase_shifts: Whether phase shift and reference
                information should be added to the plot, defaults to False.
            draw_register: Whether to draw the register before the pulse
                sequence, with a visual indication (square halo) around the
                qubits masked by the SLM, defaults to False. Can't be set to
                True if the sequence is defined with a mappable register.
            draw_phase_curve: Draws the changes in phase in its own curve
                (ignored if the phase doesn't change throughout the channel).
            draw_detuning_maps: Whether to draw the detuning maps applied on
                the qubits of the register of the sequence. Shown before the
                pulse sequence, defaults to False.
            draw_qubit_amp: Draws the amplitude seen by the qubits locally
                after the drawing of the sequence.
            draw_qubit_det: Draws the detuning seen by the qubits locally after
                the drawing of the sequence.
            fig_name: The name on which to save the figures. Figures are saved
                if `fig_name` is not None. If `draw_register`, `draw_qubit_amp`
                and `draw_qubit_det` are False, only the pulses are saved, with
                no suffix. If one of them is True, the pulses will be saved
                with a suffix ``_pulses``. If draw_register is True, the
                register is saved in another figure, with a suffix
                ``_register`` in the file name. If `draw_qubit_amp` or
                `draw_qubit_det` is True, the evolution of the quantities along
                time for group of qubits is saved in another figure with the
                prefix '_per_qubit', and the group of qubits having same
                evolution of quantities along time are saved in a figure with
                suffix '_per_qubit_legend'.
            kwargs_savefig: Keywords arguments for
                ``matplotlib.pyplot.savefig``. Not applicable if `fig_name`
                is ``None``.
            show: Whether or not to call `plt.show()` before returning. When
                combining this plot with other ones in a single figure, one may
                need to set this flag to False.

        See Also:
            Simulation.draw(): Draws the provided sequence and the one used by
            the solver.
        """
        valid_modes = ("input", "output", "input+output")
        if mode not in valid_modes:
            raise ValueError(
                f"'mode' must be one of {valid_modes}, not '{mode}'."
            )
        if mode == "output":
            if draw_phase_area:
                warnings.warn(
                    "'draw_phase_area' doesn't work in 'output' mode, so it "
                    "will default to 'False'.",
                    stacklevel=2,
                )
                draw_phase_area = False
            if draw_interp_pts:
                warnings.warn(
                    "'draw_interp_pts' doesn't work in 'output' mode, so it "
                    "will default to 'False'.",
                    stacklevel=2,
                )
                draw_interp_pts = False
        if draw_register and self.is_register_mappable():
            raise ValueError(
                "Can't draw the register for a sequence without a defined "
                "register."
            )
        fig_reg, fig, fig_qubit, fig_legend = self._plot(
            draw_phase_area=draw_phase_area,
            draw_interp_pts=draw_interp_pts,
            draw_phase_shifts=draw_phase_shifts,
            draw_register=draw_register,
            draw_input="input" in mode,
            draw_modulation="output" in mode,
            draw_phase_curve=draw_phase_curve,
            draw_detuning_maps=draw_detuning_maps,
            draw_qubit_amp=draw_qubit_amp,
            draw_qubit_det=draw_qubit_det,
        )
        if fig_name is not None:
            name, ext = os.path.splitext(fig_name)
            suffix = (
                "_pulses"
                if all(fig is None for fig in (fig_reg, fig_qubit, fig_legend))
                else ""
            )
            fig.savefig(name + suffix + ext, **kwargs_savefig)
            if fig_reg is not None:
                fig_reg.savefig(name + "_register" + ext, **kwargs_savefig)
            if fig_qubit is not None:
                fig_qubit.savefig(name + "_per_qubit" + ext, **kwargs_savefig)
                if fig_legend is not None:
                    fig_qubit.savefig(
                        name + "_per_qubit_legend" + ext, **kwargs_savefig
                    )

        if show:
            plt.show()

    def _plot(
        self, **draw_options: bool
    ) -> tuple[Figure | None, Figure, Figure | None, Figure | None]:
        return draw_sequence(self, **draw_options)

    def _modulate_slm_mask_dmm(self, duration: int, max_amp: float) -> None:
        if self._slm_mask_dmm is not None:
            bottom_detuning = cast(
                DMM, self.declared_channels[self._slm_mask_dmm]
            ).bottom_detuning
            min_det = -10 * max_amp
            min_det = (
                bottom_detuning
                if (bottom_detuning and min_det < bottom_detuning)
                else min_det
            )
            cast(
                _DMMSchedule, self._schedule[self._slm_mask_dmm]
            )._waiting_for_first_pulse = False
            self._add(
                Pulse.ConstantPulse(duration, 0, min_det, 0),
                self._slm_mask_dmm,
                "no-delay",
            )

    def _add(
        self,
        pulse: Union[Pulse, Parametrized],
        channel: str,
        protocol: PROTOCOLS,
        phase_drift_params: _PhaseDriftParams | None = None,
    ) -> None:
        self._validate_add_protocol(protocol)
        if self.is_parametrized():
            if not isinstance(pulse, Parametrized):
                self._validate_and_adjust_pulse(pulse, channel)
            return

        pulse = cast(Pulse, pulse)
        channel_obj = self._schedule[channel].channel_obj
        last = self._last(channel)
        basis = channel_obj.basis

        ph_refs = {
            self._basis_ref[basis][q].phase.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()

        pulse = self._validate_and_adjust_pulse(pulse, channel, phase_ref)

        phase_barriers = [
            self._basis_ref[basis][q].phase.last_time for q in last.targets
        ]

        self._schedule.add_pulse(
            pulse,
            channel,
            phase_barriers,
            protocol,
            phase_drift_params=phase_drift_params,
        )

        true_finish = self._last(channel).tf + pulse.fall_time(
            channel_obj, in_eom_mode=self.is_in_eom_mode(channel)
        )
        for qubit in last.targets:
            self._basis_ref[basis][qubit].update_last_used(true_finish)

        if pulse.post_phase_shift:
            self._phase_shift(
                pulse.post_phase_shift, *last.targets, basis=basis
            )
        if (
            self._in_ising
            and self._slm_mask_dmm
            and cast(
                _DMMSchedule, self._schedule[self._slm_mask_dmm]
            )._waiting_for_first_pulse
            and channel_obj.addressing == "Global"
            and not _ChannelSchedule.is_detuned_delay(pulse)
            and not isinstance(channel_obj, DMM)
        ):
            self._modulate_slm_mask_dmm(
                self._schedule[channel].get_duration(),
                np.max(pulse.amplitude.samples),
            )

    @seq_decorators.block_if_measured
    def _target(
        self,
        qubits: Union[Iterable[QubitId], QubitId, Parametrized],
        channel: str,
        _index: bool = False,
    ) -> None:
        self._validate_channel(channel, block_eom_mode=True)
        channel_obj = self._schedule[channel].channel_obj
        try:
            qubits_set = (
                set(cast(Iterable, qubits))
                if not isinstance(qubits, str)
                else {qubits}
            )
        except TypeError:
            qubits_set = {qubits}

        if channel_obj.addressing != "Local":
            raise ValueError("Can only choose target of 'Local' channels.")
        elif (
            channel_obj.max_targets is not None
            and len(qubits_set) > channel_obj.max_targets
        ):
            raise ValueError(
                f"This channel can target at most {channel_obj.max_targets} "
                "qubits at a time."
            )
        qubit_ids_set = self._check_qubits_give_ids(*qubits_set, _index=_index)

        if not self.is_parametrized():
            basis = channel_obj.basis
            phase_refs = {
                self._basis_ref[basis][q].phase.last_phase
                for q in qubit_ids_set
            }
            if len(phase_refs) != 1:
                raise ValueError(
                    "Cannot target multiple qubits with different "
                    "phase references for the same basis."
                )
            self._schedule.add_target(qubit_ids_set, channel)

    def _check_qubits_give_ids(
        self, *qubits: Union[QubitId, Parametrized], _index: bool = False
    ) -> set[QubitId]:
        if _index:
            if self.is_parametrized():
                nb_of_indices = len(self._register.qubit_ids)
                allowed_indices = range(nb_of_indices)
                for i in qubits:
                    if i not in allowed_indices and not isinstance(
                        i, Parametrized
                    ):
                        raise ValueError(
                            f"All non-variable targets must be indices valid "
                            f"for the register, between 0 and "
                            f"{nb_of_indices - 1}. Wrong index: {i!r}."
                        )
                return set()
            else:
                qubits = cast(Tuple[int, ...], qubits)
                try:
                    return {
                        self._register.qubit_ids[index] for index in qubits
                    }
                except IndexError:
                    raise IndexError("Indices must exist for the register.")
        ids = set(cast(Tuple[QubitId, ...], qubits))
        if not ids <= self._qids:
            raise ValueError(
                "All given ids have to be qubit ids declared"
                " in this sequence's register."
            )
        return ids

    @seq_decorators.block_if_measured
    def _delay(self, duration: Union[int, Parametrized], channel: str) -> None:
        self._validate_channel(channel, block_if_slm=True)
        if self.is_parametrized():
            return
        self._schedule.add_delay(cast(int, duration), channel)

    def _phase_shift(
        self,
        phi: Union[float, Parametrized],
        *targets: Union[QubitId, Parametrized],
        basis: str,
        _index: bool = False,
    ) -> None:
        if basis not in self._basis_ref:
            raise ValueError(
                f"No declared channel targets the given 'basis' ('{basis}')."
            )
        target_ids = self._check_qubits_give_ids(*targets, _index=_index)

        if not self.is_parametrized():
            phi = cast(float, phi)
            if phi % (2 * np.pi) == 0:
                return

            for qubit in target_ids:
                self._basis_ref[basis][qubit].increment_phase(phi)

    def _get_last_eom_pulse_phase_drift(
        self, channel: str
    ) -> _PhaseDriftParams:
        eom_settings = self._schedule[channel].eom_blocks[-1]
        try:
            last_pulse_tf = (
                self._schedule[channel]
                .last_pulse_slot(ignore_detuned_delay=True)
                .tf
            )
        except RuntimeError:
            # There is no previous pulse
            last_pulse_tf = 0
        return _PhaseDriftParams(
            drift_rate=-eom_settings.detuning_off,
            ti=max(eom_settings.ti, last_pulse_tf),
        )

    def _to_dict(self, _module: str = "pulser.sequence") -> dict[str, Any]:
        d = obj_to_dict(
            self,
            *self._calls[0].args,
            _module=_module,
            **self._calls[0].kwargs,
        )
        d["__version__"] = pulser.__version__
        d["calls"] = self._calls[1:]
        d["vars"] = self._variables
        d["to_build_calls"] = self._to_build_calls
        return d

    def __str__(self) -> str:
        return seq_to_str(self)

    def _add_to_schedule(self, channel: str, timeslot: _TimeSlot) -> None:
        # Maybe get rid of this
        self._schedule[channel].slots.append(timeslot)

    def _last(self, channel: str) -> _TimeSlot:
        """Shortcut to last element in the channel's schedule."""
        return self._schedule[channel][-1]

    def _validate_channel(
        self,
        channel: str,
        block_eom_mode: bool = False,
        block_if_slm: bool = False,
    ) -> None:
        if isinstance(channel, Parametrized):
            raise NotImplementedError(
                "Using parametrized objects or variables to refer to channels "
                "is not supported."
            )
        if channel not in self.declared_channels:
            raise ValueError("Use the name of a declared channel.")
        if block_eom_mode and self.is_in_eom_mode(channel):
            raise RuntimeError("The chosen channel is in EOM mode.")
        if (
            block_if_slm
            and channel == self._slm_mask_dmm
            and cast(
                _DMMSchedule, self._schedule[self._slm_mask_dmm]
            )._waiting_for_first_pulse
        ):
            raise ValueError(
                "You should add a Pulse to a Global Channel prior to"
                " modulating the DMM used for the SLM Mask."
            )

    def _validate_and_adjust_pulse(
        self, pulse: Pulse, channel: str, phase_ref: Optional[float] = None
    ) -> Pulse:
        channel_obj: Channel
        if channel in self._schedule:
            channel_obj = self._schedule[channel].channel_obj
        else:
            # Sequence is parametrized and channel is a dmm_name
            channel_obj = self.device.dmm_channels[_dmm_id_from_name(channel)]
        channel_obj.validate_pulse(pulse)
        _duration = channel_obj.validate_duration(pulse.duration)
        new_phase = pulse.phase + (phase_ref if phase_ref else 0)
        if _duration != pulse.duration:
            try:
                new_amp = pulse.amplitude.change_duration(_duration)
                new_det = pulse.detuning.change_duration(_duration)
            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 multiple of "
                    f"{channel_obj.clock_period} ns."
                )
        else:
            new_amp = pulse.amplitude
            new_det = pulse.detuning

        return Pulse(new_amp, new_det, new_phase, pulse.post_phase_shift)

    def _validate_add_protocol(self, protocol: str) -> None:
        valid_protocols = get_args(PROTOCOLS)
        if protocol not in valid_protocols:
            raise ValueError(
                f"Invalid protocol '{protocol}', only accepts protocols: "
                + ", ".join(valid_protocols)
            )

    def _reset_parametrized(self) -> None:
        """Resets all attributes related to parametrization."""
        # Signals the sequence as actively "building" ie not parametrized
        self._building = True
        self._param_measurement = ""
        self._variables = {}
        self._to_build_calls = []

    def _set_register(self, seq: Sequence, reg: BaseRegister) -> None:
        """Sets the register on a sequence who had a mappable register."""
        self._device.validate_register(reg)
        qids = set(reg.qubit_ids)
        used_qubits = set()
        for ch, ch_schedule in self._schedule.items():
            # Correct the targets of global channels
            if ch_schedule.channel_obj.addressing == "Global":
                for i, slot in enumerate(self._schedule[ch]):
                    stored_values = slot._asdict()
                    stored_values["targets"] = qids
                    seq._schedule[ch].slots[i] = _TimeSlot(**stored_values)
            else:
                # Make sure all explicit targets are in the register
                for slot in self._schedule[ch]:
                    used_qubits.update(slot.targets)

        if not used_qubits <= qids:
            raise ValueError(
                f"Qubits {used_qubits - qids} are being targeted but"
                " have not been assigned a trap."
            )
        seq._register = reg
        seq._qids = qids
        seq._calls[0] = _Call("__init__", (seq._register, seq._device), {})

    def _cross_check_vars(self, vars: dict[str, Any]) -> None:
        """Checks if values are given to all and only declared vars."""
        all_keys, given_keys = self._variables.keys(), vars.keys()
        if given_keys != all_keys:
            invalid_vars = given_keys - all_keys
            if invalid_vars:
                warnings.warn(
                    "No declared variables named: " + ", ".join(invalid_vars),
                    stacklevel=3,
                )
                for k in invalid_vars:
                    vars.pop(k, None)
            missing_vars = all_keys - given_keys
            if missing_vars:
                raise TypeError(
                    "Did not receive values for variables: "
                    + ", ".join(missing_vars)
                )
