Repository: pasqal-io/Pulser (language: python)

Issue:
Release/v0.5.0
Main Pulser features:
- Addition of buffer times for retargeting and phase jumps (#305)
- Output modulation (#312)
- `RegisterLayout` (#333)
- `MappableRegister` (#334)

Bump to v0.3.2.dev



Discussion / hints:

Fix patch:
diff --git a/.flake8 b/.flake8
index ce68d0eac..29fee19f4 100644
--- a/.flake8
+++ b/.flake8
@@ -1,6 +1,11 @@
 [flake8]
-exclude = ./build, ./docs
 docstring-convention = google
+exclude = ./build, ./docs
+extend-ignore =
+    # D105 Missing docstring in magic method
+    D105,
+    # E203 whitespace before ':' (for compliance with black)
+    E203,
 per-file-ignores =
   # D100 Missing docstring in public module
   # D103 Missing docstring in public function
@@ -8,8 +13,3 @@ per-file-ignores =
   pulser/tests/*: D100, D103
   __init__.py: F401
   setup.py: D100
-extend-ignore =
-    # D105 Missing docstring in magic method
-    D105,
-    # E203 whitespace before ':' (for compliance with black)
-    E203,
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index edac902b6..073b63888 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,8 +32,33 @@ jobs:
     steps:
       - name: Check out Pulser
         uses: actions/checkout@v2
+      - name: Set up Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.8
+      - name: Install black
+        run: |
+          python -m pip install --upgrade pip
+          pip install black
+          pip install 'black[jupyter]'
       - name: Check formatting with black
-        uses: psf/black@stable
+        run: black --check --diff .
+  isort:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out Pulser
+        uses: actions/checkout@v2
+      - name: Set up Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.8
+      - name: Install Python dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -e .
+          pip install -r requirements.txt
+      - name: Check import sorting with isort
+        run: isort --check-only --diff .
   typing:
     runs-on: ubuntu-latest
     steps:
@@ -55,7 +80,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest]
-        python-version: [3.7, 3.8]
+        python-version: [3.7, 3.8, 3.9, "3.10"]
     steps:
       - name: Check out Pulser
         uses: actions/checkout@v2
diff --git a/.mypy_script b/.mypy_script
new file mode 100755
index 000000000..69889334b
--- /dev/null
+++ b/.mypy_script
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+mypy --config-file .mypy.ini
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 000000000..0afee19c3
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,26 @@
+repos:
+  - repo: https://github.com/psf/black
+    rev: 22.1.0
+    hooks:
+      - id: black-jupyter
+
+  - repo: https://github.com/PyCQA/flake8
+    rev: 4.0.1
+    hooks:
+      - id: flake8
+
+  - repo: https://github.com/pycqa/isort
+    rev: 5.10.1
+    hooks:
+      - id: isort
+        name: isort (python)
+
+  # Calling mypy from a bash script, as I cannot figure out how to pass the
+  # .mypy.ini config file when using the hook at
+  # https://github.com/pre-commit/mirrors-mypy
+  - repo: local
+    hooks:
+      - id: mypy
+        name: mypy
+        entry: ./.mypy_script
+        language: script
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bd8bc8131..f1d15872c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -8,10 +8,10 @@ The steps to take will depend on what you want to do, but generally you'll want
 
 1. Do a quick search for keywords over the existing issues to ensure yours has not been added yet.
 2. If you can't find your issue already listed, create a new one. Please try to be as clear and detailed as possible in your description.
+
 - If you just want to give a suggestion or report a bug, that's already excellent and we thank you for it! Your issue will be listed and, hopefully, someone will take care of it at some point.
 - However, you may also want to be the one solving your issue, which would be even better! In these cases, you would proceed by preparing a [Pull Request](#making-a-pull-request).
 
-
 ## Making a Pull Request
 
 We're thrilled that you want to contribute to Pulser! For general contributions, we use a combination of two Git workflows: the [Forking workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow) and the [Gitflow workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow). If you don't know what any of this means, don't worry, you should still be able to make your contribution just by following the instructions detailed below. Nonetheless, in a nutshell, this workflow will have you making a fork from the main Pulser repository and working off a branch from `develop` (**not** `master`). Thus, you'll start your branch from `develop` and end with a pull request that merges your branch back to `develop`. The only exception to this rule is when making a `hotfix`, but in these cases the Pulser development team will take care of it for you.
@@ -19,12 +19,15 @@ We're thrilled that you want to contribute to Pulser! For general contributions,
 Here are the steps you should follow to make your contribution:
 
 0. Fork the Pulser repository and add the main Pulser repository as the `upstream`. You only have to do this once and you do so by clicking the "Fork" button at the upper right corner of the [repo page](https://github.com/pasqal-io/Pulser). This will create a new GitHub repo at `https://github.com/USERNAME/Pulser`, where `USERNAME` is your GitHub ID. Then, `cd` into the folder where you would like to place your new fork and clone it by doing:
+
     ```bash
     git clone https://github.com/USERNAME/Pulser.git
     ```
+
     **Note**: `USERNAME` should be replaced by your own GitHub ID.
 
    Then, you'll want to go into the directory of your brand new Pulser fork and add the main Pulser repository as the `upstream` by running:
+
    ```bash
    git remote add upstream https://github.com/pasqal-io/Pulser.git
    ```
@@ -32,10 +35,12 @@ Here are the steps you should follow to make your contribution:
 1. Have the related issue assigned to you. We suggest that you work only on issues that have been assigned to you; by doing this, you make sure to be the only one working on this and we prevent everyone from doing duplicate work. If a related issue does not exist yet, consult the [section above](#reporting-a-bug-or-suggesting-a-feature) to see how to proceed.
 
 2. You'll want to create a new branch where you will do your changes. The starting point will be `upstream/develop`, which is where you'll ultimately merge your changes. Inside your fork's root folder, run:
+
     ```bash
     git fetch upstream
     git checkout -b branch-name-here upstream/develop
     ```
+
     This will create and checkout the new branch, where you will do your changes.
 
     **Note**: `branch-name-here` should be replaced by the name you'll give your branch. Try to be descriptive, pick a name that identifies your new feature.
@@ -43,12 +48,15 @@ Here are the steps you should follow to make your contribution:
 3. Do your work and commit the changes to this new branch. Try to make the first line of your commit messages short but informative; in case you want to go into more detail, you have the option to do so in the next lines.
 
 4. At this point, your branch might have drifted out of sync with Pulser's `develop` branch (the `upstream`). By running
+
     ```shell
     git pull upstream develop
     ```
+
    you will fetch the latest changes in `upstream/develop` and merge them with your working branch, at which point you'll have to solve any merge conflicts that may    arise. This will keep your working branch in sync with `upstream/develop`.
 
 5. Finally, you push your code to your local branch:
+
     ```bash
     git push origin branch-name-here
     ```
@@ -66,30 +74,64 @@ pip install -r requirements.txt
 ```
 
 - **Tests**: We use [`pytest`](https://docs.pytest.org/en/latest/) to run unit tests on our code. If your changes break existing tests, you'll have to update these tests accordingly. Additionally, we aim for 100% coverage over our code. Try to cover all the new lines of code with simple tests, which should be placed in the `Pulser/pulser/tests` folder. To run all tests and check coverage, run:
+
     ```bash
     pytest --cov pulser
     ```
+
     All lines that are not meant to be tested must be tagged with `# pragma: no cover`. Use it sparingly,
     every decision to leave a line uncovered must be well justified.
 
 - **Style**: We use [`flake8`](https://flake8.pycqa.org/en/latest/) and the `flake8-docstrings` extension to enforce PEP8 style guidelines. To lint your code with `flake8`, simply run:
+
     ```bash
     flake8 .
     ```
+
     To help you keep your code compliant with PEP8 guidelines effortlessly, we suggest you look into installing a linter for your text editor of choice.
 
-- **Format**: We use the [`black`](https://black.readthedocs.io/en/stable/index.html) auto-formatter to enforce a consistent style throughout the entire code base. It will also ensure your code is compliant with the formatting enforced by `flake8` for you. To automatically format your code with black, just run:
+- **Format**: We use the [`black`](https://black.readthedocs.io/en/stable/index.html) auto-formatter to enforce a consistent style throughout the entire code base, including the Jupyter notebooks (so make sure to install `black[jupyter]`). It will also ensure your code is compliant with the formatting enforced by `flake8` for you. To automatically format your code with black, just run:
+
     ```bash
     black .
     ```
+
     Note that some IDE's and text editors support plug-ins which auto-format your code with `black` upon saving, so you don't have to worry about code format at all.
 
-- **Type hints**: We use [mypy](http://mypy-lang.org/) to type check the code. Your code should have type
+- **Import sorting**: We use [`isort`](https://pycqa.github.io/isort/) to automatically sort all library imports. You can do the same by running:
+
+    ```bash
+    isort .
+    ```
+
+- **Type hints**: We use [`mypy`](http://mypy-lang.org/) to type check the code. Your code should have type
 annotations and pass the type checks from running:
+
     ```bash
     mypy
     ```
+
     In case `mypy` produces a false positive, you can ignore the respective line by adding the `# type: ignore` annotation.
 
     **Note**: Type hints for `numpy` have only been added in version 1.20. Make sure you have `numpy >= 1.20`
     installed before running the type checks.
+
+### Use `pre-commit` to automate CI checks
+
+[`pre-commit`](https://pre-commit.com/) is a tool to easily setup and manage [git hooks](https://git-scm.com/docs/githooks).
+
+Run
+
+```bash
+pre-commit install --hook-type pre-push
+```
+
+to install `black`, `isort`, `flake8` and `mypy` hooks in your local repository (at `.git/hooks/` by defaults)
+and run them automatically before any push to a remote git repository.
+If an issue is found by these tools, the git hook will abort the push. `black` and `isort` hooks may reformat guilty files.
+
+Disable the hooks with
+
+```bash
+pre-commit uninstall --hook-type pre-push
+```
diff --git a/README.md b/README.md
index ebd2dc48e..eb6cceed2 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
 
 Pulser is a framework for composing, simulating and executing **pulse** sequences for neutral-atom quantum devices.
 
-**Documentation** for the [latest release](https://pypi.org/project/pulser/) of `pulser` is available at https://pulser.readthedocs.io (for the docs tracking the `develop` branch of this repository, visit https://pulser.readthedocs.io/en/latest instead).
+**Documentation** for the [latest release](https://pypi.org/project/pulser/) of `pulser` is available at <https://pulser.readthedocs.io> (for the docs tracking the `develop` branch of this repository, visit <https://pulser.readthedocs.io/en/latest> instead).
 
-The source code can be found at https://github.com/pasqal-io/Pulser.
+The source code can be found at <https://github.com/pasqal-io/Pulser>.
 
 ## Overview of Pulser
 
@@ -51,6 +51,7 @@ Then, you can do the following to run the test suite and report test coverage:
 ```bash
 pytest --cov pulser
 ```
+
 ## Contributing
 
 Want to contribute to Pulser? Great! See [How to Contribute][contributing] for information on how you can do so.
diff --git a/docs/source/intro_rydberg_blockade.ipynb b/docs/source/intro_rydberg_blockade.ipynb
index 2e91174e5..ba7eb8459 100644
--- a/docs/source/intro_rydberg_blockade.ipynb
+++ b/docs/source/intro_rydberg_blockade.ipynb
@@ -88,8 +88,10 @@
     "from pulser import Pulse\n",
     "from pulser.waveforms import RampWaveform, BlackmanWaveform\n",
     "\n",
-    "duration = 1000 # Typical: ~1 µsec\n",
-    "pulse = Pulse(BlackmanWaveform(duration, np.pi), RampWaveform(duration, -5., 10.), 0)\n",
+    "duration = 1000  # Typical: ~1 µsec\n",
+    "pulse = Pulse(\n",
+    "    BlackmanWaveform(duration, np.pi), RampWaveform(duration, -5.0, 10.0), 0\n",
+    ")\n",
     "pulse.draw()"
    ]
   },
@@ -131,18 +133,18 @@
    "source": [
     "from pulser import Sequence\n",
     "\n",
-    "reg = Register.rectangle(1, 2, spacing=8, prefix='atom')\n",
+    "reg = Register.rectangle(1, 2, spacing=8, prefix=\"atom\")\n",
     "reg.draw()\n",
     "\n",
-    "pi_pulse = Pulse.ConstantDetuning(BlackmanWaveform(duration, np.pi), 0., 0.)\n",
+    "pi_pulse = Pulse.ConstantDetuning(BlackmanWaveform(duration, np.pi), 0.0, 0.0)\n",
     "\n",
     "seq = Sequence(reg, Chadoq2)\n",
     "\n",
-    "seq.declare_channel('ryd','rydberg_local','atom0')\n",
+    "seq.declare_channel(\"ryd\", \"rydberg_local\", \"atom0\")\n",
     "\n",
-    "seq.add(pi_pulse,'ryd')\n",
-    "seq.target('atom1', 'ryd')\n",
-    "seq.add(pi_pulse,'ryd')\n",
+    "seq.add(pi_pulse, \"ryd\")\n",
+    "seq.target(\"atom1\", \"ryd\")\n",
+    "seq.add(pi_pulse, \"ryd\")\n",
     "\n",
     "seq.draw()"
    ]
@@ -199,25 +201,27 @@
     "data = []\n",
     "distances = np.linspace(6.5, 14, 7)\n",
     "\n",
-    "r = [1,0] # |r>\n",
-    "rr = np.kron(r,r) # |rr>\n",
+    "r = [1, 0]  # |r>\n",
+    "rr = np.kron(r, r)  # |rr>\n",
     "occup = [np.outer(rr, np.conj(rr))]  # |rr><rr|\n",
     "\n",
-    "for i,R in enumerate(distances):\n",
+    "for i, R in enumerate(distances):\n",
     "    # Atom Register and Device\n",
-    "    reg = Register.rectangle(1,2,spacing=R, prefix='atom')\n",
+    "    reg = Register.rectangle(1, 2, spacing=R, prefix=\"atom\")\n",
     "\n",
     "    # Pulse Sequence\n",
     "    seq = Sequence(reg, Chadoq2)\n",
-    "    seq.declare_channel('ryd','rydberg_local','atom0')\n",
-    "    seq.add(pi_pulse,'ryd')\n",
-    "    seq.target('atom1', 'ryd')\n",
-    "    seq.add(pi_pulse,'ryd')\n",
+    "    seq.declare_channel(\"ryd\", \"rydberg_local\", \"atom0\")\n",
+    "    seq.add(pi_pulse, \"ryd\")\n",
+    "    seq.target(\"atom1\", \"ryd\")\n",
+    "    seq.add(pi_pulse, \"ryd\")\n",
     "\n",
     "    sim = Simulation(seq)\n",
-    "    \n",
+    "\n",
     "    res = sim.run()  # Returns a SimulationResults instance\n",
-    "    data.append(res.expect(occup)[0]) # Get expectation value for the occupation operator\n",
+    "    data.append(\n",
+    "        res.expect(occup)[0]\n",
+    "    )  # Get expectation value for the occupation operator\n",
     "print(\"...Simulation Complete!\")"
    ]
   },
@@ -247,10 +251,10 @@
     }
    ],
    "source": [
-    "for i,R in enumerate(distances):\n",
-    "    plt.plot(data[i], label=f'R={R}')\n",
-    "    plt.xlabel('Time (ns)', fontsize=14)\n",
-    "    plt.ylabel(r'Occupation of $|rr\\rangle$', fontsize=14)\n",
+    "for i, R in enumerate(distances):\n",
+    "    plt.plot(data[i], label=f\"R={R}\")\n",
+    "    plt.xlabel(\"Time (ns)\", fontsize=14)\n",
+    "    plt.ylabel(r\"Occupation of $|rr\\rangle$\", fontsize=14)\n",
     "    plt.legend()"
    ]
   },
diff --git a/pulser/__init__.py b/pulser/__init__.py
index fbebcfedf..eb8ea3b5a 100644
--- a/pulser/__init__.py
+++ b/pulser/__init__.py
@@ -15,11 +15,7 @@
 """A pulse-level composer for neutral-atom quantum devices."""
 
 from pulser._version import __version__
-
 from pulser.pulse import Pulse
-
 from pulser.register import Register, Register3D
-
 from pulser.sequence import Sequence
-
 from pulser.simulation import Simulation
diff --git a/pulser/_seq_drawer.py b/pulser/_seq_drawer.py
index d8541ab4c..b87d26eaf 100644
--- a/pulser/_seq_drawer.py
+++ b/pulser/_seq_drawer.py
@@ -15,18 +15,18 @@
 from __future__ import annotations
 
 from collections import defaultdict
-from typing import Any, cast, Optional, Union
+from itertools import combinations
+from typing import Any, Optional, Union, cast
 
 import matplotlib.pyplot as plt
-from matplotlib.figure import Figure
 import numpy as np
+from matplotlib.figure import Figure
 from scipy.interpolate import CubicSpline
-from itertools import combinations
 
 import pulser
-from pulser.waveforms import ConstantWaveform, InterpolatedWaveform
-from pulser.pulse import Pulse
 from pulser import Register, Register3D
+from pulser.pulse import Pulse
+from pulser.waveforms import ConstantWaveform, InterpolatedWaveform
 
 
 def gather_data(seq: pulser.sequence.Sequence) -> dict:
@@ -40,7 +40,7 @@ def gather_data(seq: pulser.sequence.Sequence) -> dict:
     """
     # The minimum time axis length is 100 ns
     total_duration = max(seq.get_duration(), 100)
-    data = {}
+    data: dict[str, Any] = {}
     for ch, sch in seq._schedule.items():
         time = [-1]  # To not break the "time[-1]" later on
         amp = []
@@ -111,6 +111,8 @@ def draw_sequence(
     draw_interp_pts: bool = True,
     draw_phase_shifts: bool = False,
     draw_register: bool = False,
+    draw_input: bool = True,
+    draw_modulation: bool = False,
 ) -> tuple[Figure, Figure]:
     """Draws the entire sequence.
 
@@ -129,6 +131,11 @@ def draw_sequence(
         draw_register (bool): 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.
+        draw_input(bool): Draws the programmed pulses on the channels, defaults
+            to True.
+        draw_modulation(bool): Draws the expected channel output, defaults to
+            False. If the channel does not have a defined 'mod_bandwidth', this
+            is skipped unless 'draw_input=False'.
     """
 
     def phase_str(phi: float) -> str:
@@ -154,13 +161,12 @@ def phase_str(phi: float) -> str:
     area_ph_box = dict(boxstyle="round", facecolor="ghostwhite", alpha=0.7)
     slm_box = dict(boxstyle="round", alpha=0.4, facecolor="grey", hatch="//")
 
-    pos = np.array(seq._register._coords)
-
     # Draw masked register
     if draw_register:
-        if isinstance(seq._register, Register3D):
+        pos = np.array(seq.register._coords)
+        if isinstance(seq.register, Register3D):
             labels = "xyz"
-            fig_reg, axes_reg = seq._register._initialize_fig_axes_projection(
+            fig_reg, axes_reg = seq.register._initialize_fig_axes_projection(
                 pos,
                 blockade_radius=35,
                 draw_half_radius=True,
@@ -170,10 +176,10 @@ def phase_str(phi: float) -> str:
             for ax_reg, (ix, iy) in zip(
                 axes_reg, combinations(np.arange(3), 2)
             ):
-                seq._register._draw_2D(
+                seq.register._draw_2D(
                     ax=ax_reg,
                     pos=pos,
-                    ids=seq._register._ids,
+                    ids=seq.register._ids,
                     plane=(ix, iy),
                     masked_qubits=seq._slm_mask_targets,
                 )
@@ -184,16 +190,16 @@ def phase_str(phi: float) -> str:
                     + "-plane"
                 )
 
-        elif isinstance(seq._register, Register):
-            fig_reg, ax_reg = seq._register._initialize_fig_axes(
+        elif isinstance(seq.register, Register):
+            fig_reg, ax_reg = seq.register._initialize_fig_axes(
                 pos,
                 blockade_radius=35,
                 draw_half_radius=True,
             )
-            seq._register._draw_2D(
+            seq.register._draw_2D(
                 ax=ax_reg,
                 pos=pos,
-                ids=seq._register._ids,
+                ids=seq.register._ids,
                 masked_qubits=seq._slm_mask_targets,
             )
             ax_reg.set_title("Masked register", pad=10)
@@ -256,9 +262,22 @@ def phase_str(phi: float) -> str:
         # Compare pulse with an interpolated pulse with 100 times more samples
         teff = np.arange(0, max(solver_time), delta_t / 100)
 
+    # Make sure the time axis of all channels are aligned
+    final_t = total_duration / time_scale
+    if draw_modulation:
+        for ch, ch_obj in seq._channels.items():
+            final_t = max(
+                final_t,
+                (seq.get_duration(ch) + 2 * ch_obj.rise_time) / time_scale,
+            )
+    t_min = -final_t * 0.03
+    t_max = final_t * 1.05
+
     for ch, (a, b) in ch_axes.items():
-        basis = seq._channels[ch].basis
-        t = np.array(data[ch]["time"]) / time_scale
+        ch_obj = seq._channels[ch]
+        basis = ch_obj.basis
+        times = np.array(data[ch]["time"])
+        t = times / time_scale
         ya = data[ch]["amp"]
         yb = data[ch]["detuning"]
         if sampling_rate:
@@ -276,8 +295,17 @@ def phase_str(phi: float) -> str:
             yaeff = cs_amp(teff)
             ybeff = cs_detuning(teff)
 
-        t_min = -t[-1] * 0.03
-        t_max = t[-1] * 1.05
+        draw_output = draw_modulation and (
+            ch_obj.mod_bandwidth or not draw_input
+        )
+        if draw_output:
+            t_diffs = np.diff(times)
+            input_a = np.repeat(ya[1:], t_diffs)
+            input_b = np.repeat(yb[1:], t_diffs)
+            end_index = int(final_t * time_scale)
+            ya_mod = ch_obj.modulate(input_a)[:end_index]
+            yb_mod = ch_obj.modulate(input_b, keep_ends=True)[:end_index]
+
         a.set_xlim(t_min, t_max)
         b.set_xlim(t_min, t_max)
 
@@ -294,16 +322,26 @@ def phase_str(phi: float) -> str:
         det_bottom = det_min - det_range * 0.05
         b.set_ylim(det_bottom, det_top)
 
-        a.plot(t, ya, color="darkgreen", linewidth=0.8)
-        b.plot(t, yb, color="indigo", linewidth=0.8)
+        if draw_input:
+            a.plot(t, ya, color="darkgreen", linewidth=0.8)
+            b.plot(t, yb, color="indigo", linewidth=0.8)
         if sampling_rate:
             a.plot(teff, yaeff, color="darkgreen", linewidth=0.8)
             b.plot(teff, ybeff, color="indigo", linewidth=0.8, ls="-")
             a.fill_between(teff, 0, yaeff, color="darkgreen", alpha=0.3)
             b.fill_between(teff, 0, ybeff, color="indigo", alpha=0.3)
-        else:
+        elif draw_input:
             a.fill_between(t, 0, ya, color="darkgreen", alpha=0.3)
             b.fill_between(t, 0, yb, color="indigo", alpha=0.3)
+        if draw_output:
+            a.plot(ya_mod, color="darkred", linewidth=0.8)
+            b.plot(yb_mod, color="gold", linewidth=0.8)
+            a.fill_between(
+                np.arange(ya_mod.size), 0, ya_mod, color="darkred", alpha=0.3
+            )
+            b.fill_between(
+                np.arange(yb_mod.size), 0, yb_mod, color="gold", alpha=0.3
+            )
         a.set_ylabel(r"$\Omega$ (rad/µs)", fontsize=14, labelpad=10)
         b.set_ylabel(r"$\delta$ (rad/µs)", fontsize=14)
 
@@ -367,7 +405,7 @@ def phase_str(phi: float) -> str:
             tgt_txt_y = max_amp * 1.1 - 0.25 * (len(targets) - 1)
             tgt_str = "\n".join(tgt_strs)
             if coords == "initial":
-                x = t_min + t[-1] * 0.005
+                x = t_min + final_t * 0.005
                 target_regions.append([0, targets])
                 if seq._channels[ch].addressing == "Global":
                     a.text(
@@ -410,7 +448,7 @@ def phase_str(phi: float) -> str:
                 a.axvspan(ti, tf, alpha=0.4, color="grey", hatch="//")
                 b.axvspan(ti, tf, alpha=0.4, color="grey", hatch="//")
                 a.text(
-                    tf + t[-1] * 5e-3,
+                    tf + final_t * 5e-3,
                     tgt_txt_y,
                     tgt_str,
                     ha="left",
@@ -420,7 +458,7 @@ def phase_str(phi: float) -> str:
                 if phase and draw_phase_shifts:
                     msg = r"$\phi=$" + phase_str(phase)
                     wrd_len = len(max(tgt_strs, key=len))
-                    x = tf + t[-1] * 0.01 * (wrd_len + 1)
+                    x = tf + final_t * 0.01 * (wrd_len + 1)
                     a.text(
                         x,
                         max_amp * 1.1,
@@ -431,7 +469,7 @@ def phase_str(phi: float) -> str:
                     )
         # Terminate the last open regions
         if target_regions:
-            target_regions[-1].append(t[-1])
+            target_regions[-1].append(final_t)
         for start, targets_, end in (
             target_regions if draw_phase_shifts else []
         ):
@@ -449,7 +487,7 @@ def phase_str(phi: float) -> str:
                 b.axvline(t_, **conf)
                 msg = "\u27F2 " + phase_str(delta)
                 a.text(
-                    t_ - t[-1] * 8e-3,
+                    t_ - final_t * 8e-3,
                     max_amp * 1.1,
                     msg,
                     ha="right",
@@ -463,7 +501,7 @@ def phase_str(phi: float) -> str:
             a.axvspan(0, tf_m, color="black", alpha=0.1, zorder=-100)
             b.axvspan(0, tf_m, color="black", alpha=0.1, zorder=-100)
             tgt_strs = [str(q) for q in seq._slm_mask_targets]
-            tgt_txt_x = t[-1] * 0.005
+            tgt_txt_x = final_t * 0.005
             tgt_txt_y = b.get_ylim()[0]
             tgt_str = "\n".join(tgt_strs)
             b.text(
@@ -478,7 +516,7 @@ def phase_str(phi: float) -> str:
         if "measurement" in data[ch]:
             msg = f"Basis: {data[ch]['measurement']}"
             b.text(
-                t[-1] * 1.025,
+                final_t * 1.025,
                 det_top,
                 msg,
                 ha="center",
@@ -487,8 +525,8 @@ def phase_str(phi: float) -> str:
                 color="white",
                 rotation=90,
             )
-            a.axvspan(t[-1], t_max, color="midnightblue", alpha=1)
-            b.axvspan(t[-1], t_max, color="midnightblue", alpha=1)
+            a.axvspan(final_t, t_max, color="midnightblue", alpha=1)
+            b.axvspan(final_t, t_max, color="midnightblue", alpha=1)
             a.axhline(0, xmax=0.95, linestyle="-", linewidth=0.5, color="grey")
             b.axhline(0, xmax=0.95, linestyle=":", linewidth=0.5, color="grey")
         else:
diff --git a/pulser/_version.py b/pulser/_version.py
index d132a8478..efb8f89bb 100644
--- a/pulser/_version.py
+++ b/pulser/_version.py
@@ -12,4 +12,4 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-__version__ = "0.4.2"
+__version__ = "0.5.0"
diff --git a/pulser/channels.py b/pulser/channels.py
index 7e79e15f7..1dd230b62 100644
--- a/pulser/channels.py
+++ b/pulser/channels.py
@@ -15,13 +15,21 @@
 
 from __future__ import annotations
 
-from dataclasses import dataclass
-from typing import cast, ClassVar, Optional
 import warnings
+from dataclasses import dataclass
+from typing import ClassVar, Optional, cast
+
+import numpy as np
+from numpy.typing import ArrayLike
+from scipy.fft import fft, fftfreq, ifft
 
 # Warnings of adjusted waveform duration appear just once
 warnings.filterwarnings("once", "A duration of")
 
+# Conversion factor from modulation bandwith to rise time
+# For more info, see https://tinyurl.com/bdeumc8k
+MODBW_TO_TR = 0.48
+
 
 @dataclass(init=True, repr=False, frozen=True)
 class Channel:
@@ -37,13 +45,18 @@ class Channel:
         max_abs_detuning: Maximum possible detuning (in rad/µs), in absolute
             value.
         max_amp: Maximum pulse amplitude (in rad/µs).
-        retarget_time: Maximum time to change the target (in ns).
+        phase_jump_time: Time taken to change the phase between consecutive
+            pulses (in ns).
+        min_retarget_interval: Minimum time required between the ends of two
+            target instructions (in ns).
+        fixed_retarget_t: Time taken to change the target (in ns).
         max_targets: How many qubits can be addressed at once by the same beam.
         clock_period: The duration of a clock cycle (in ns). The duration of a
             pulse or delay instruction is enforced to be a multiple of the
             clock cycle.
         min_duration: The shortest duration an instruction can take.
         max_duration: The longest duration an instruction can take.
+        mod_bandwidth: The modulation bandwidth at -3dB (50% redution), in MHz.
 
     Example:
         To create a channel targeting the 'ground-rydberg' transition globally,
@@ -55,18 +68,35 @@ class Channel:
     addressing: str
     max_abs_detuning: float
     max_amp: float
-    retarget_time: Optional[int] = None
+    phase_jump_time: int = 0
+    min_retarget_interval: Optional[int] = None
+    fixed_retarget_t: Optional[int] = None
     max_targets: Optional[int] = None
     clock_period: int = 4  # ns
     min_duration: int = 16  # ns
     max_duration: int = 67108864  # ns
+    mod_bandwidth: Optional[float] = None  # MHz
+
+    @property
+    def rise_time(self) -> int:
+        """The rise time (in ns).
+
+        Defined as the time taken to go from 10% to 90% output in response to
+        a step change in the input.
+        """
+        if self.mod_bandwidth:
+            return int(MODBW_TO_TR / self.mod_bandwidth * 1e3)
+        else:
+            return 0
 
     @classmethod
     def Local(
         cls,
         max_abs_detuning: float,
         max_amp: float,
-        retarget_time: int = 220,
+        phase_jump_time: int = 0,
+        min_retarget_interval: int = 220,
+        fixed_retarget_t: int = 0,
         max_targets: int = 1,
         **kwargs: int,
     ) -> Channel:
@@ -76,7 +106,11 @@ def Local(
             max_abs_detuning (float): Maximum possible detuning (in rad/µs), in
                 absolute value.
             max_amp(float): Maximum pulse amplitude (in rad/µs).
-            retarget_time (int): Maximum time to change the target (in ns).
+            phase_jump_time (int): Time taken to change the phase between
+                consecutive pulses (in ns).
+            min_retarget_interval (int): Minimum time required between two
+                target instructions (in ns).
+            fixed_retarget_t (int): Time taken to change the target (in ns).
             max_targets (int): Maximum number of atoms the channel can target
                 simultaneously.
         """
@@ -84,14 +118,20 @@ def Local(
             "Local",
             max_abs_detuning,
             max_amp,
-            retarget_time,
+            phase_jump_time,
+            min_retarget_interval,
+            fixed_retarget_t,
             max_targets,
             **kwargs,
         )
 
     @classmethod
     def Global(
-        cls, max_abs_detuning: float, max_amp: float, **kwargs: int
+        cls,
+        max_abs_detuning: float,
+        max_amp: float,
+        phase_jump_time: int = 0,
+        **kwargs: int,
     ) -> Channel:
         """Initializes the channel with global addressing.
 
@@ -99,8 +139,12 @@ def Global(
             max_abs_detuning (float): Maximum possible detuning (in rad/µs), in
                 absolute value.
             max_amp(float): Maximum pulse amplitude (in rad/µs).
+            phase_jump_time (int): Time taken to change the phase between
+                consecutive pulses (in ns).
         """
-        return cls("Global", max_abs_detuning, max_amp, **kwargs)
+        return cls(
+            "Global", max_abs_detuning, max_amp, phase_jump_time, **kwargs
+        )
 
     def validate_duration(self, duration: int) -> int:
         """Validates and adapts the duration of an instruction on this channel.
@@ -139,17 +183,104 @@ def validate_duration(self, duration: int) -> int:
             )
         return _duration
 
+    def modulate(
+        self, input_samples: np.ndarray, keep_ends: bool = False
+    ) -> np.ndarray:
+        """Modulates the input according to the channel's modulation bandwidth.
+
+        Args:
+            input_samples (np.ndarray): The samples to modulate.
+            keep_ends (bool): Assume the end values of the samples were kept
+                constant (i.e. there is no ramp from zero on the ends).
+
+        Returns:
+            np.ndarray: The modulated output signal.
+        """
+        if not self.mod_bandwidth:
+            warnings.warn(
+                f"No modulation bandwidth defined for channel '{self}',"
+                " 'Channel.modulate()' returns the 'input_samples' unchanged.",
+                stacklevel=2,
+            )
+            return input_samples
+
+        # The cutoff frequency (fc) and the modulation transfer function
+        # are defined in https://tinyurl.com/bdeumc8k
+        fc = self.mod_bandwidth * 1e-3 / np.sqrt(np.log(2))
+        if keep_ends:
+            samples = np.pad(input_samples, 2 * self.rise_time, mode="edge")
+        else:
+            samples = np.pad(input_samples, self.rise_time)
+        freqs = fftfreq(samples.size)
+        modulation = np.exp(-(freqs**2) / fc**2)
+        mod_samples = ifft(fft(samples) * modulation).real
+        if keep_ends:
+            # Cut off the extra ends
+            return cast(
+                np.ndarray, mod_samples[self.rise_time : -self.rise_time]
+            )
+        return cast(np.ndarray, mod_samples)
+
+    def calc_modulation_buffer(
+        self,
+        input_samples: ArrayLike,
+        mod_samples: ArrayLike,
+        max_allowed_diff: float = 1e-2,
+    ) -> tuple[int, int]:
+        """Calculates the minimal buffers needed around a modulated waveform.
+
+        Args:
+            input_samples (ArrayLike): The input samples.
+            mod_samples (ArrayLike): The modulated samples. Must be of size
+                ``len(input_samples) + 2 * self.rise_time``.
+            max_allowed_diff (float): The maximum allowed difference between
+                the input and modulated samples at the end points.
+
+        Returns:
+            tuple[int, int]: The minimum buffer times at the start and end of
+            the samples, in ns.
+        """
+        if not self.mod_bandwidth:
+            raise TypeError(
+                f"The channel {self} doesn't have a modulation bandwidth."
+            )
+
+        tr = self.rise_time
+        samples = np.pad(input_samples, tr)
+        diffs = np.abs(samples - mod_samples) <= max_allowed_diff
+        try:
+            # Finds the last index in the start buffer that's below the max
+            # allowed diff. Considers that the waveform could start at the next
+            # indice (hence the -1, since we are subtracting from tr)
+            start = tr - np.argwhere(diffs[:tr])[-1][0] - 1
+        except IndexError:
+            start = tr
+        try:
+            # Finds the first index in the end buffer that's below the max
+            # allowed diff. The index value found matches the minimum length
+            # for this end buffer.
+            end = np.argwhere(diffs[-tr:])[0][0]
+        except IndexError:
+            end = tr
+
+        return start, end
+
     def __repr__(self) -> str:
         config = (
             f".{self.addressing}(Max Absolute Detuning: "
-            f"{self.max_abs_detuning} rad/µs, Max Amplitude: "
-            f"{self.max_amp} rad/µs"
+            f"{self.max_abs_detuning} rad/µs, Max Amplitude: {self.max_amp}"
+            f" rad/µs, Phase Jump Time: {self.phase_jump_time} ns"
         )
         if self.addressing == "Local":
-            config += f", Target time: {self.retarget_time} ns"
+            config += (
+                f", Minimum retarget time: {self.min_retarget_interval} ns, "
+                f"Fixed retarget time: {self.fixed_retarget_t} ns"
+            )
             if cast(int, self.max_targets) > 1:
                 config += f", Max targets: {self.max_targets}"
         config += f", Basis: '{self.basis}'"
+        if self.mod_bandwidth:
+            config += f", Modulation Bandwidth: {self.mod_bandwidth} MHz"
         return self.name + config + ")"
 
 
diff --git a/pulser/devices/__init__.py b/pulser/devices/__init__.py
index 40c88be5a..066250083 100644
--- a/pulser/devices/__init__.py
+++ b/pulser/devices/__init__.py
@@ -13,12 +13,14 @@
 # limitations under the License.
 """Valid devices for Pulser Sequence execution."""
 
-from pulser.devices._devices import (
-    Chadoq2,
-)
+from __future__ import annotations
 
+from typing import TYPE_CHECKING
+
+from pulser.devices._device_datacls import Device
+from pulser.devices._devices import Chadoq2, IroiseMVP
 from pulser.devices._mock_device import MockDevice
 
 # Registers which devices can be used to avoid definition of custom devices
-_mock_devices = (MockDevice,)
-_valid_devices = (Chadoq2,)
+_mock_devices: tuple[Device, ...] = (MockDevice,)
+_valid_devices: tuple[Device, ...] = (Chadoq2, IroiseMVP)
diff --git a/pulser/devices/_device_datacls.py b/pulser/devices/_device_datacls.py
index 20697ec6c..382c575db 100644
--- a/pulser/devices/_device_datacls.py
+++ b/pulser/devices/_device_datacls.py
@@ -14,17 +14,18 @@
 
 from __future__ import annotations
 
-from dataclasses import dataclass
-from typing import Any, cast
+from dataclasses import dataclass, field
+from typing import Any
 
 import numpy as np
 from scipy.spatial.distance import pdist, squareform
 
 from pulser import Pulse
-from pulser.register import BaseRegister
 from pulser.channels import Channel
-from pulser.json.utils import obj_to_dict
 from pulser.devices.interaction_coefficients import c6_dict
+from pulser.json.utils import obj_to_dict
+from pulser.register.base_register import BaseRegister, QubitId
+from pulser.register.register_layout import COORD_PRECISION, RegisterLayout
 
 
 @dataclass(frozen=True, repr=False)
@@ -55,10 +56,15 @@ class Device:
     _channels: tuple[tuple[str, Channel], ...]
     # Ising interaction coeff
     interaction_coeff_xy: float = 3700.0
+    pre_calibrated_layouts: tuple[RegisterLayout, ...] = field(
+        default_factory=tuple
+    )
 
     def __post_init__(self) -> None:
         # Hack to override the docstring of an instance
         object.__setattr__(self, "__doc__", self._specs(for_docs=True))
+        for layout in self.pre_calibrated_layouts:
+            self.validate_layout(layout)
 
     @property
     def channels(self) -> dict[str, Channel]:
@@ -75,6 +81,11 @@ def interaction_coeff(self) -> float:
         r""":math:`C_6/\hbar` coefficient of chosen Rydberg level."""
         return float(c6_dict[self.rydberg_level])
 
+    @property
+    def calibrated_register_layouts(self) -> dict[str, RegisterLayout]:
+        """Register layouts already calibrated on this device."""
+        return {str(layout): layout for layout in self.pre_calibrated_layouts}
+
     def print_specs(self) -> None:
         """Prints the device specifications."""
         title = f"{self.name} Specifications"
@@ -111,9 +122,7 @@ def rydberg_blockade_radius(self, rabi_frequency: float) -> float:
         Returns:
             float: The rydberg blockade radius, in μm.
         """
-        return cast(
-            float, (self.interaction_coeff / rabi_frequency) ** (1 / 6)
-        )
+        return (self.interaction_coeff / rabi_frequency) ** (1 / 6)
 
     def rabi_from_blockade(self, blockade_radius: float) -> float:
         """The maximum Rabi frequency value to enforce a given blockade radius.
@@ -130,53 +139,47 @@ def validate_register(self, register: BaseRegister) -> None:
         """Checks if 'register' is compatible with this device.
 
         Args:
-            register(pulser.Register): The Register to validate.
+            register(BaseRegister): The Register to validate.
         """
-        if not (isinstance(register, BaseRegister)):
+        if not isinstance(register, BaseRegister):
             raise TypeError(
-                "register has to be a pulser.Register or "
+                "'register' must be a pulser.Register or "
                 "a pulser.Register3D instance."
             )
 
-        ids = list(register.qubits.keys())
-        atoms = list(register.qubits.values())
-        if len(atoms) > self.max_atom_num:
-            raise ValueError(
-                f"The number of atoms ({len(atoms)})"
-                " must be less than or equal to the maximum"
-                " number of atoms supported by this device"
-                f" ({self.max_atom_num})."
-            )
-
         if register._dim > self.dimensions:
             raise ValueError(
                 f"All qubit positions must be at most {self.dimensions}D "
                 "vectors."
             )
+        self._validate_coords(register.qubits, kind="atoms")
 
-        if len(atoms) > 1:
-            distances = pdist(atoms)  # Pairwise distance between atoms
-            if np.any(distances < self.min_atom_distance):
-                sq_dists = squareform(distances)
-                mask = np.triu(np.ones(len(atoms), dtype=bool), k=1)
-                bad_pairs = np.argwhere(
-                    np.logical_and(sq_dists < self.min_atom_distance, mask)
-                )
-                bad_qbt_pairs = [(ids[i], ids[j]) for i, j in bad_pairs]
+        if register._layout_info is not None:
+            try:
+                self.validate_layout(register._layout_info.layout)
+            except (ValueError, TypeError):
                 raise ValueError(
-                    "The minimal distance between atoms in this device "
-                    f"({self.min_atom_distance} µm) is not respected for the "
-                    f"pairs: {bad_qbt_pairs}"
+                    "The 'register' is associated with an incompatible "
+                    "register layout."
                 )
 
-        too_far = np.linalg.norm(atoms, axis=1) > self.max_radial_distance
-        if np.any(too_far):
+    def validate_layout(self, layout: RegisterLayout) -> None:
+        """Checks if a register layout is compatible with this device.
+
+        Args:
+            layout(RegisterLayout): The RegisterLayout to validate.
+        """
+        if not isinstance(layout, RegisterLayout):
+            raise TypeError("'layout' must be a RegisterLayout instance.")
+
+        if layout.dimensionality > self.dimensions:
             raise ValueError(
-                f"All qubits must be at most {self.max_radial_distance} μm "
-                f"away from the center of the array, which is not the case "
-                f"for: {[ids[int(i)] for i in np.where(too_far)[0]]}"
+                "The device supports register layouts of at most "
+                f"{self.dimensions} dimensions."
             )
 
+        self._validate_coords(layout.traps_dict, kind="traps")
+
     def validate_pulse(self, pulse: Pulse, channel_id: str) -> None:
         """Checks if a pulse can be executed on a specific device channel.
 
@@ -231,10 +234,13 @@ def _specs(self, for_docs: bool = False) -> str:
                         + r"- Maximum :math:`|\delta|`:"
                         + f" {ch.max_abs_detuning:.4g} rad/µs"
                     ),
+                    f"\t- Phase Jump Time: {ch.phase_jump_time} ns",
                 ]
                 if ch.addressing == "Local":
                     ch_lines += [
-                        f"\t- Maximum time to retarget: {ch.retarget_time} ns",
+                        "\t- Minimum time between retargets: "
+                        f"{ch.min_retarget_interval} ns",
+                        f"\t- Fixed retarget time: {ch.fixed_retarget_t} ns",
                         f"\t- Maximum simultaneous targets: {ch.max_targets}",
                     ]
                 ch_lines += [
@@ -246,6 +252,46 @@ def _specs(self, for_docs: bool = False) -> str:
 
         return "\n".join(lines + ch_lines)
 
+    def _validate_coords(
+        self, coords_dict: dict[QubitId, np.ndarray], kind: str = "atoms"
+    ) -> None:
+        ids = list(coords_dict.keys())
+        coords = list(coords_dict.values())
+        max_number = self.max_atom_num * (2 if kind == "traps" else 1)
+        if len(coords) > max_number:
+            raise ValueError(
+                f"The number of {kind} ({len(coords)})"
+                " must be less than or equal to the maximum"
+                f" number of {kind} supported by this device"
+                f" ({max_number})."
+            )
+
+        if len(coords) > 1:
+            distances = pdist(coords)  # Pairwise distance between atoms
+            if np.any(
+                distances - self.min_atom_distance
+                < -(10 ** (-COORD_PRECISION))
+            ):
+                sq_dists = squareform(distances)
+                mask = np.triu(np.ones(len(coords), dtype=bool), k=1)
+                bad_pairs = np.argwhere(
+                    np.logical_and(sq_dists < self.min_atom_distance, mask)
+                )
+                bad_qbt_pairs = [(ids[i], ids[j]) for i, j in bad_pairs]
+                raise ValueError(
+                    f"The minimal distance between {kind} in this device "
+                    f"({self.min_atom_distance} µm) is not respected for the "
+                    f"pairs: {bad_qbt_pairs}"
+                )
+
+        too_far = np.linalg.norm(coords, axis=1) > self.max_radial_distance
+        if np.any(too_far):
+            raise ValueError(
+                f"All {kind} must be at most {self.max_radial_distance} μm "
+                f"away from the center of the array, which is not the case "
+                f"for: {[ids[int(i)] for i in np.where(too_far)[0]]}"
+            )
+
     def _to_dict(self) -> dict[str, Any]:
         return obj_to_dict(
             self, _build=False, _module="pulser.devices", _name=self.name
diff --git a/pulser/devices/_devices.py b/pulser/devices/_devices.py
index 0fa9e50e0..0c9dd81c6 100644
--- a/pulser/devices/_devices.py
+++ b/pulser/devices/_devices.py
@@ -14,9 +14,8 @@
 """Definitions of real devices."""
 import numpy as np
 
-from pulser.devices._device_datacls import Device
 from pulser.channels import Raman, Rydberg
-
+from pulser.devices._device_datacls import Device
 
 Chadoq2 = Device(
     name="Chadoq2",
@@ -31,3 +30,22 @@
         ("raman_local", Raman.Local(2 * np.pi * 20, 2 * np.pi * 10)),
     ),
 )
+
+IroiseMVP = Device(
+    name="IroiseMVP",
+    dimensions=2,
+    rydberg_level=60,
+    max_atom_num=100,
+    max_radial_distance=60,
+    min_atom_distance=5,
+    _channels=(
+        (
+            "rydberg_global",
+            Rydberg.Global(
+                max_abs_detuning=2 * np.pi * 4,
+                max_amp=2 * np.pi * 3,
+                phase_jump_time=500,
+            ),
+        ),
+    ),
+)
diff --git a/pulser/devices/_mock_device.py b/pulser/devices/_mock_device.py
index ac4f8dcef..01bf385e5 100644
--- a/pulser/devices/_mock_device.py
+++ b/pulser/devices/_mock_device.py
@@ -12,9 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from pulser.channels import Microwave, Raman, Rydberg
 from pulser.devices._device_datacls import Device
-from pulser.channels import Rydberg, Raman, Microwave
-
 
 MockDevice = Device(
     name="MockDevice",
@@ -31,7 +30,12 @@
         (
             "rydberg_local",
             Rydberg.Local(
-                1000, 200, 0, max_targets=2000, clock_period=1, min_duration=1
+                1000,
+                200,
+                min_retarget_interval=0,
+                max_targets=2000,
+                clock_period=1,
+                min_duration=1,
             ),
         ),
         (
@@ -41,7 +45,12 @@
         (
             "raman_local",
             Raman.Local(
-                1000, 200, 0, max_targets=2000, clock_period=1, min_duration=1
+                1000,
+                200,
+                min_retarget_interval=0,
+                max_targets=2000,
+                clock_period=1,
+                min_duration=1,
             ),
         ),
         (
diff --git a/pulser/json/coders.py b/pulser/json/coders.py
index d4a79a999..53cdf5a49 100644
--- a/pulser/json/coders.py
+++ b/pulser/json/coders.py
@@ -17,11 +17,12 @@
 
 import importlib
 import inspect
-from json import JSONEncoder, JSONDecoder
+from json import JSONDecoder, JSONEncoder
 from typing import Any, cast
 
 import numpy as np
 
+from pulser.json.supported import validate_serialization
 from pulser.json.utils import obj_to_dict
 from pulser.parametrized import Variable
 
@@ -61,6 +62,8 @@ def object_hook(self, obj: dict[str, Any]) -> Any:
         except KeyError:
             return obj
 
+        validate_serialization(obj)
+
         if (
             obj_name == "Variable"
             and module_str == "pulser.parametrized.variable"
diff --git a/pulser/json/supported.py b/pulser/json/supported.py
new file mode 100644
index 000000000..f8793a892
--- /dev/null
+++ b/pulser/json/supported.py
@@ -0,0 +1,97 @@
+# 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.
+"""Supported modules and objects for JSON deserialization."""
+
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+import pulser
+
+SUPPORTED_BUILTINS = ("float", "int", "str", "set")
+
+SUPPORTED_OPERATORS = (
+    "neg",
+    "abs",
+    "getitem",
+    "add",
+    "sub",
+    "mul",
+    "truediv",
+    "floordiv",
+    "pow",
+    "mod",
+)
+
+SUPPORTS_SUBMODULE = ("Pulse", "BlackmanWaveform", "KaiserWaveform")
+
+SUPPORTED_MODULES = {
+    "builtins": SUPPORTED_BUILTINS,
+    "_operator": SUPPORTED_OPERATORS,
+    "operator": SUPPORTED_OPERATORS,
+    "numpy": ("array",),
+    "pulser.register.register": ("Register",),
+    "pulser.register.register3d": ("Register3D",),
+    "pulser.register.register_layout": ("RegisterLayout",),
+    "pulser.register.special_layouts": (
+        "SquareLatticeLayout",
+        "TriangularLatticeLayout",
+    ),
+    "pulser.register.mappable_reg": ("MappableRegister",),
+    "pulser.devices": tuple(
+        [dev.name for dev in pulser.devices._valid_devices] + ["MockDevice"]
+    ),
+    "pulser.pulse": ("Pulse",),
+    "pulser.waveforms": (
+        "CompositeWaveform",
+        "CustomWaveform",
+        "ConstantWaveform",
+        "RampWaveform",
+        "BlackmanWaveform",
+        "InterpolatedWaveform",
+        "KaiserWaveform",
+    ),
+    "pulser.sequence": ("Sequence",),
+    "pulser.parametrized.variable": ("Variable",),
+    "pulser.parametrized.paramobj": ("ParamObj",),
+}
+
+
+def validate_serialization(obj_dict: Mapping[str, Any]) -> None:
+    """Checks if 'obj_dict' can be serialized."""
+    try:
+        obj_dict["_build"]
+        obj_str = obj_dict["__name__"]
+        module_str = obj_dict["__module__"]
+    except KeyError:
+        raise TypeError("Invalid 'obj_dict'.")
+
+    if module_str not in SUPPORTED_MODULES:
+        raise SystemError(
+            f"No serialization support for module '{module_str}'."
+        )
+
+    if "__submodule__" in obj_dict:
+        submodule_str = obj_dict["__submodule__"]
+        if submodule_str not in SUPPORTS_SUBMODULE:
+            raise SystemError(
+                "No serialization support for attributes of "
+                f"'{module_str}.{submodule_str}'."
+            )
+        obj_str = submodule_str
+
+    if obj_str not in SUPPORTED_MODULES[module_str]:
+        raise SystemError(
+            f"No serialization support for '{module_str}.{obj_str}'."
+        )
diff --git a/pulser/json/utils.py b/pulser/json/utils.py
index 1dbd43742..dfd4e395a 100644
--- a/pulser/json/utils.py
+++ b/pulser/json/utils.py
@@ -17,6 +17,8 @@
 
 from typing import Any, Optional
 
+import pulser
+
 
 def obj_to_dict(
     obj: object,
@@ -56,4 +58,5 @@ def obj_to_dict(
     if _submodule:
         d["__submodule__"] = _submodule
 
+    pulser.json.supported.validate_serialization(d)
     return d
diff --git a/pulser/parametrized/__init__.py b/pulser/parametrized/__init__.py
index 5bb0d5cd5..6f0775cf3 100644
--- a/pulser/parametrized/__init__.py
+++ b/pulser/parametrized/__init__.py
@@ -14,7 +14,5 @@
 """Classes for parametrized pulse-sequence building."""
 
 from pulser.parametrized.paramabc import Parametrized
-
-from pulser.parametrized.variable import Variable
-
 from pulser.parametrized.paramobj import ParamObj
+from pulser.parametrized.variable import Variable
diff --git a/pulser/parametrized/decorators.py b/pulser/parametrized/decorators.py
index 273e2a7b7..a77df039a 100644
--- a/pulser/parametrized/decorators.py
+++ b/pulser/parametrized/decorators.py
@@ -18,12 +18,14 @@
 from collections.abc import Callable
 from functools import wraps
 from itertools import chain
-from typing import Any
+from typing import Any, TypeVar, cast
 
 from pulser.parametrized import Parametrized, ParamObj
 
+F = TypeVar("F", bound=Callable)
 
-def parametrize(func: Callable) -> Callable:
+
+def parametrize(func: F) -> F:
     """Makes a function support parametrized arguments.
 
     Note:
@@ -38,4 +40,4 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
                 return ParamObj(func, *args, **kwargs)
         return func(*args, **kwargs)
 
-    return wrapper
+    return cast(F, wrapper)
diff --git a/pulser/parametrized/paramabc.py b/pulser/parametrized/paramabc.py
index fff36e656..f050ab736 100644
--- a/pulser/parametrized/paramabc.py
+++ b/pulser/parametrized/paramabc.py
@@ -16,7 +16,7 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from typing import Any, TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
     from pulser.parametrized import Variable  # pragma: no cover
diff --git a/pulser/parametrized/paramobj.py b/pulser/parametrized/paramobj.py
index 33d173f73..eda835f1e 100644
--- a/pulser/parametrized/paramobj.py
+++ b/pulser/parametrized/paramobj.py
@@ -15,13 +15,12 @@
 
 from __future__ import annotations
 
-from collections.abc import Callable
-from functools import partialmethod
-from itertools import chain
 import inspect
 import operator
 import warnings
-from typing import Any, Union, TYPE_CHECKING
+from collections.abc import Callable
+from itertools import chain
+from typing import TYPE_CHECKING, Any, Union
 
 from pulser.json.utils import obj_to_dict
 from pulser.parametrized import Parametrized
@@ -29,39 +28,57 @@
 if TYPE_CHECKING:
     from pulser.parametrized import Variable  # pragma: no cover
 
-# Available operations on parametrized objects with OpSupport
-reversible_ops = [
-    "__add__",
-    "__sub__",
-    "__mul__",
-    "__truediv__",
-    "__floordiv__",
-    "__pow__",
-    "__mod__",
-]
-
 
 class OpSupport:
     """Methods for supporting operators on parametrized objects."""
 
-    def _do_op(self, op_name: str, other: Union[int, float]) -> ParamObj:
-        return ParamObj(getattr(operator, op_name), self, other)
-
-    def _do_rop(self, op_name: str, other: Union[int, float]) -> ParamObj:
-        return ParamObj(getattr(operator, op_name), other, self)
-
     def __neg__(self) -> ParamObj:
         return ParamObj(operator.neg, self)
 
     def __abs__(self) -> ParamObj:
         return ParamObj(operator.abs, self)
 
+    def __add__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__add__, self, other)
+
+    def __radd__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__add__, other, self)
+
+    def __sub__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__sub__, self, other)
+
+    def __rsub__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__sub__, other, self)
+
+    def __mul__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__mul__, self, other)
 
-# Inject operator magic methods into OpSupport
-for method in reversible_ops:
-    rmethod = "__r" + method[2:]
-    setattr(OpSupport, method, partialmethod(OpSupport._do_op, method))
-    setattr(OpSupport, rmethod, partialmethod(OpSupport._do_rop, method))
+    def __rmul__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__mul__, other, self)
+
+    def __truediv__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__truediv__, self, other)
+
+    def __rtruediv__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__truediv__, other, self)
+
+    def __floordiv__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__floordiv__, self, other)
+
+    def __rfloordiv__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__floordiv__, other, self)
+
+    def __pow__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__pow__, self, other)
+
+    def __rpow__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__pow__, other, self)
+
+    def __mod__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__mod__, self, other)
+
+    def __rmod__(self, other: Union[int, float]) -> ParamObj:
+        return ParamObj(operator.__mod__, other, self)
 
 
 class ParamObj(Parametrized, OpSupport):
@@ -124,7 +141,10 @@ def class_to_dict(cls: Callable) -> dict[str, Any]:
 
         args = list(self.args)
         if isinstance(self.cls, Parametrized):
-            cls_dict = self.cls._to_dict()
+            raise ValueError(
+                "Serialization of calls to parametrized objects is not "
+                "supported."
+            )
         elif hasattr(args[0], self.cls.__name__) and inspect.isfunction(
             self.cls
         ):
@@ -164,6 +184,11 @@ def __call__(self, *args: Any, **kwargs: Any) -> ParamObj:
 
     def __getattr__(self, name: str) -> ParamObj:
         if hasattr(self.cls, name):
+            warnings.warn(
+                "Serialization of 'getattr' calls to parametrized "
+                "objects is not supported, so this object can't be serialied.",
+                stacklevel=2,
+            )
             return ParamObj(getattr, self, name)
         else:
             raise AttributeError(f"No attribute named '{name}' in {self}.")
diff --git a/pulser/parametrized/variable.py b/pulser/parametrized/variable.py
index 43bcd2914..8238d1e64 100644
--- a/pulser/parametrized/variable.py
+++ b/pulser/parametrized/variable.py
@@ -16,16 +16,16 @@
 from __future__ import annotations
 
 import collections.abc  # To use collections.abc.Sequence
-from collections.abc import Iterable
 import dataclasses
-from typing import Union, Any, cast
+from collections.abc import Iterable
+from typing import Any, Iterator, Optional, Union, cast
 
 import numpy as np
 from numpy.typing import ArrayLike
 
+from pulser.json.utils import obj_to_dict
 from pulser.parametrized import Parametrized
 from pulser.parametrized.paramobj import OpSupport
-from pulser.json.utils import obj_to_dict
 
 
 @dataclasses.dataclass(frozen=True, eq=False)
@@ -63,7 +63,7 @@ def variables(self) -> dict[str, Variable]:
         return {self.name: self}
 
     def _clear(self) -> None:
-        object.__setattr__(self, "value", None)
+        object.__setattr__(self, "value", None)  # TODO rename _value?
         object.__setattr__(self, "_count", self._count + 1)
 
     def _assign(self, value: Union[ArrayLike, str, float, int]) -> None:
@@ -78,22 +78,19 @@ def _assign(self, value: Union[ArrayLike, str, float, int]) -> None:
                     "must be of type 'str'."
                 )
 
-        val = np.array(value, dtype=self.dtype)
+        val = np.array(value, dtype=self.dtype, ndmin=1)
         if val.size != self.size:
             raise ValueError(
                 f"Can't assign array of size {val.size} to "
                 + f"variable of size {self.size}."
             )
 
-        if self.size == 1:
-            object.__setattr__(self, "value", self.dtype(val))
-        else:
-            object.__setattr__(self, "value", val)
+        object.__setattr__(self, "value", val)
         object.__setattr__(self, "_count", self._count + 1)
 
-    def build(self) -> Union[ArrayLike, str, float, int]:
+    def build(self) -> ArrayLike:
         """Returns the variable's current value."""
-        self.value: Union[ArrayLike, str, float, int]
+        self.value: Optional[ArrayLike]
         if self.value is None:
             raise ValueError(f"No value assigned to variable '{self.name}'.")
         return self.value
@@ -109,20 +106,22 @@ def __str__(self) -> str:
     def __len__(self) -> int:
         return self.size
 
-    def __getitem__(self, key: Union[int, slice]) -> _VariableItem:
+    def __getitem__(self, key: Union[int, slice]) -> VariableItem:
         if not isinstance(key, (int, slice)):
             raise TypeError(f"Invalid key type {type(key)} for '{self.name}'.")
-        if self.size == 1:
-            raise TypeError(f"Variable '{self.name}' is not subscriptable.")
         if isinstance(key, int):
             if not -self.size <= key < self.size:
                 raise IndexError(f"{key} outside of range for '{self.name}'.")
 
-        return _VariableItem(self, key)
+        return VariableItem(self, key)
+
+    def __iter__(self) -> Iterator[VariableItem]:
+        for i in range(len(self)):
+            yield self[i]
 
 
 @dataclasses.dataclass(frozen=True)
-class _VariableItem(Parametrized, OpSupport):
+class VariableItem(Parametrized, OpSupport):
     """Stores access to items of a variable with multiple values."""
 
     var: Variable
@@ -130,6 +129,7 @@ class _VariableItem(Parametrized, OpSupport):
 
     @property
     def variables(self) -> dict[str, Variable]:
+        """All the variables involved with this object."""
         return self.var.variables
 
     def build(self) -> Union[ArrayLike, str, float, int]:
diff --git a/pulser/pulse.py b/pulser/pulse.py
index 81d57e15c..e800bc7a5 100644
--- a/pulser/pulse.py
+++ b/pulser/pulse.py
@@ -15,21 +15,22 @@
 
 from __future__ import annotations
 
-from dataclasses import dataclass
 import functools
 import itertools
-from typing import Any
+from dataclasses import dataclass, field
+from typing import Any, Union, cast
 
 import matplotlib.pyplot as plt
 import numpy as np
 
+from pulser.channels import Channel
+from pulser.json.utils import obj_to_dict
 from pulser.parametrized import Parametrized, ParamObj
 from pulser.parametrized.decorators import parametrize
-from pulser.waveforms import Waveform, ConstantWaveform
-from pulser.json.utils import obj_to_dict
+from pulser.waveforms import ConstantWaveform, Waveform
 
 
-@dataclass(repr=False, frozen=True)
+@dataclass(init=False, repr=False, frozen=True)
 class Pulse:
     r"""A generic pulse.
 
@@ -56,10 +57,11 @@ class Pulse:
             for enconding of arbitrary single-qubit gates into a single pulse
             (see ``Sequence.phase_shift()`` for more information).
     """
-    amplitude: Waveform
-    detuning: Waveform
-    phase: float
-    post_phase_shift: float = 0.0
+
+    amplitude: Waveform = field(init=False)
+    detuning: Waveform = field(init=False)
+    phase: float = field(init=False)
+    post_phase_shift: float = field(default=0.0, init=False)
 
     def __new__(cls, *args, **kwargs):  # type: ignore
         """Creates a Pulse instance or a ParamObj depending on the input."""
@@ -69,27 +71,35 @@ def __new__(cls, *args, **kwargs):  # type: ignore
         else:
             return object.__new__(cls)
 
-    def __post_init__(self) -> None:
+    def __init__(
+        self,
+        amplitude: Union[Waveform, Parametrized],
+        detuning: Union[Waveform, Parametrized],
+        phase: Union[float, Parametrized],
+        post_phase_shift: Union[float, Parametrized] = 0.0,
+    ):
         """Initializes a new Pulse."""
         if not (
-            isinstance(self.amplitude, Waveform)
-            and isinstance(self.detuning, Waveform)
+            isinstance(amplitude, Waveform) and isinstance(detuning, Waveform)
         ):
             raise TypeError("'amplitude' and 'detuning' have to be waveforms.")
 
-        if self.detuning.duration != self.amplitude.duration:
+        if detuning.duration != amplitude.duration:
             raise ValueError(
                 "The duration of detuning and amplitude waveforms must match."
             )
-        if np.any(self.amplitude.samples < 0):
+        if np.any(amplitude.samples < 0):
             raise ValueError(
                 "All samples of an amplitude waveform must be "
                 "greater than or equal to zero."
             )
-
-        object.__setattr__(self, "phase", self.phase % (2 * np.pi))
+        object.__setattr__(self, "amplitude", amplitude)
+        object.__setattr__(self, "detuning", detuning)
+        phase = cast(float, phase)
+        object.__setattr__(self, "phase", float(phase) % (2 * np.pi))
+        post_phase_shift = cast(float, post_phase_shift)
         object.__setattr__(
-            self, "post_phase_shift", self.post_phase_shift % (2 * np.pi)
+            self, "post_phase_shift", float(post_phase_shift) % (2 * np.pi)
         )
 
     @property
@@ -101,10 +111,10 @@ def duration(self) -> int:
     @parametrize
     def ConstantDetuning(
         cls,
-        amplitude: Waveform,
-        detuning: float,
-        phase: float,
-        post_phase_shift: float = 0.0,
+        amplitude: Union[Waveform, Parametrized],
+        detuning: Union[float, Parametrized],
+        phase: Union[float, Parametrized],
+        post_phase_shift: Union[float, Parametrized] = 0.0,
     ) -> Pulse:
         """Creates a Pulse with an amplitude waveform and a constant detuning.
 
@@ -115,17 +125,19 @@ def ConstantDetuning(
             post_phase_shift (float, default=0.): Optionally lets you add a
                 phase shift (in rads) immediately after the end of the pulse.
         """
-        detuning_wf = ConstantWaveform(amplitude.duration, detuning)
+        detuning_wf = ConstantWaveform(
+            cast(Waveform, amplitude).duration, detuning
+        )
         return cls(amplitude, detuning_wf, phase, post_phase_shift)
 
     @classmethod
     @parametrize
     def ConstantAmplitude(
         cls,
-        amplitude: float,
-        detuning: Waveform,
-        phase: float,
-        post_phase_shift: float = 0.0,
+        amplitude: Union[float, Parametrized],
+        detuning: Union[Waveform, Parametrized],
+        phase: Union[float, Parametrized],
+        post_phase_shift: Union[float, Parametrized] = 0.0,
     ) -> Pulse:
         """Pulse with a constant amplitude and a detuning waveform.
 
@@ -136,18 +148,20 @@ def ConstantAmplitude(
             post_phase_shift (float, default=0.): Optionally lets you add a
                 phase shift (in rads) immediately after the end of the pulse.
         """
-        amplitude_wf = ConstantWaveform(detuning.duration, amplitude)
+        amplitude_wf = ConstantWaveform(
+            cast(Waveform, detuning).duration, amplitude
+        )
         return cls(amplitude_wf, detuning, phase, post_phase_shift)
 
     @classmethod
     @parametrize
     def ConstantPulse(
         cls,
-        duration: int,
-        amplitude: float,
-        detuning: float,
-        phase: float,
-        post_phase_shift: float = 0.0,
+        duration: Union[int, Parametrized],
+        amplitude: Union[float, Parametrized],
+        detuning: Union[float, Parametrized],
+        phase: Union[float, Parametrized],
+        post_phase_shift: Union[float, Parametrized] = 0.0,
     ) -> Pulse:
         """Pulse with a constant amplitude and a constant detuning.
 
@@ -174,6 +188,15 @@ def draw(self) -> None:
         fig.tight_layout()
         plt.show()
 
+    def fall_time(self, channel: Channel) -> int:
+        """Calculates the extra time needed to ramp down to zero."""
+        aligned_start_extra_time = channel.rise_time
+        end_extra_time = max(
+            self.amplitude.modulation_buffers(channel)[1],
+            self.detuning.modulation_buffers(channel)[1],
+        )
+        return aligned_start_extra_time + end_extra_time
+
     def _to_dict(self) -> dict[str, Any]:
         return obj_to_dict(
             self,
diff --git a/pulser/py.typed b/pulser/py.typed
new file mode 100644
index 000000000..e69de29bb
diff --git a/pulser/register.py b/pulser/register.py
deleted file mode 100644
index 405b2ae2d..000000000
--- a/pulser/register.py
+++ /dev/null
@@ -1,1079 +0,0 @@
-# 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.
-"""Defines the configuration of an array of neutral atoms."""
-
-from __future__ import annotations
-
-from abc import ABC, abstractmethod
-from collections.abc import Mapping, Iterable
-from collections.abc import Sequence as abcSequence
-from typing import Any, cast, Optional, Union, TypeVar, Type
-from itertools import combinations
-
-import matplotlib.pyplot as plt
-from matplotlib import collections as mc
-import numpy as np
-from numpy.typing import ArrayLike
-from scipy.spatial import KDTree
-
-import pulser
-from pulser.json.utils import obj_to_dict
-
-QubitId = Union[int, str]
-
-T = TypeVar("T", bound="BaseRegister")
-
-
-class BaseRegister(ABC):
-    """The abstract class for a register."""
-
-    @abstractmethod
-    def __init__(self, qubits: Mapping[Any, ArrayLike]):
-        """Initializes a custom Register."""
-        if not isinstance(qubits, dict):
-            raise TypeError(
-                "The qubits have to be stored in a dictionary "
-                "matching qubit ids to position coordinates."
-            )
-        if not qubits:
-            raise ValueError(
-                "Cannot create a Register with an empty qubit " "dictionary."
-            )
-        self._ids = list(qubits.keys())
-        self._coords = [np.array(v, dtype=float) for v in qubits.values()]
-        self._dim = 0
-
-    @property
-    def qubits(self) -> dict[QubitId, np.ndarray]:
-        """Dictionary of the qubit names and their position coordinates."""
-        return dict(zip(self._ids, self._coords))
-
-    @classmethod
-    def from_coordinates(
-        cls: Type[T],
-        coords: np.ndarray,
-        center: bool = True,
-        prefix: Optional[str] = None,
-        labels: Optional[abcSequence[QubitId]] = None,
-    ) -> T:
-        """Creates the register from an array of coordinates.
-
-        Args:
-            coords (ndarray): The coordinates of each qubit to include in the
-                register.
-
-        Keyword args:
-            center(defaut=True): Whether or not to center the entire array
-                around the origin.
-            prefix (str): The prefix for the qubit ids. If defined, each qubit
-                id starts with the prefix, followed by an int from 0 to N-1
-                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
-            labels (ArrayLike): The list of qubit ids. If defined, each qubit
-                id will be set to the corresponding value.
-
-        Returns:
-            Register: A register with qubits placed on the given coordinates.
-        """
-        if center:
-            coords = coords - np.mean(coords, axis=0)  # Centers the array
-        if prefix is not None:
-            pre = str(prefix)
-            qubits = {pre + str(i): pos for i, pos in enumerate(coords)}
-            if labels is not None:
-                raise NotImplementedError(
-                    "It is impossible to specify a prefix and "
-                    "a set of labels at the same time"
-                )
-
-        elif labels is not None:
-            if len(coords) != len(labels):
-                raise ValueError(
-                    f"Label length ({len(labels)}) does not"
-                    f"match number of coordinates ({len(coords)})"
-                )
-            qubits = dict(zip(cast(Iterable, labels), coords))
-        else:
-            qubits = dict(cast(Iterable, enumerate(coords)))
-        return cls(qubits)
-
-    @staticmethod
-    def _draw_2D(
-        ax: plt.axes._subplots.AxesSubplot,
-        pos: np.ndarray,
-        ids: list,
-        plane: tuple = (0, 1),
-        with_labels: bool = True,
-        blockade_radius: Optional[float] = None,
-        draw_graph: bool = True,
-        draw_half_radius: bool = False,
-        masked_qubits: set[QubitId] = set(),
-    ) -> None:
-        ix, iy = plane
-
-        ax.scatter(pos[:, ix], pos[:, iy], s=30, alpha=0.7, c="darkgreen")
-
-        # Draw square halo around masked qubits
-        if masked_qubits:
-            mask_pos = []
-            for i, c in zip(ids, pos):
-                if i in masked_qubits:
-                    mask_pos.append(c)
-            mask_arr = np.array(mask_pos)
-            ax.scatter(
-                mask_arr[:, ix],
-                mask_arr[:, iy],
-                marker="s",
-                s=1200,
-                alpha=0.2,
-                c="black",
-            )
-
-        axes = "xyz"
-
-        ax.set_xlabel(axes[ix] + " (µm)")
-        ax.set_ylabel(axes[iy] + " (µm)")
-        ax.axis("equal")
-        ax.spines["right"].set_color("none")
-        ax.spines["top"].set_color("none")
-
-        if with_labels:
-            # Determine which labels would overlap and merge those
-            plot_pos = list(pos[:, (ix, iy)])
-            plot_ids: list[Union[list, str]] = [[f"{i}"] for i in ids]
-            # Threshold distance between points
-            epsilon = 1.0e-2 * np.diff(ax.get_xlim())[0]
-
-            i = 0
-            bbs = {}
-            while i < len(plot_ids):
-                r = plot_pos[i]
-                j = i + 1
-                overlap = False
-                # Put in a list all qubits that overlap at position plot_pos[i]
-                while j < len(plot_ids):
-                    r2 = plot_pos[j]
-                    if np.max(np.abs(r - r2)) < epsilon:
-                        plot_ids[i] = plot_ids[i] + plot_ids.pop(j)
-                        plot_pos.pop(j)
-                        overlap = True
-                    else:
-                        j += 1
-                # Sort qubits in plot_ids[i] according to masked status
-                plot_ids[i] = sorted(
-                    plot_ids[i],
-                    key=lambda s: s in [str(q) for q in masked_qubits],
-                )
-                # Merge all masked qubits
-                has_masked = False
-                for j in range(len(plot_ids[i])):
-                    if plot_ids[i][j] in [str(q) for q in masked_qubits]:
-                        plot_ids[i][j:] = [", ".join(plot_ids[i][j:])]
-                        has_masked = True
-                        break
-                # Add a square bracket that encloses all masked qubits
-                if has_masked:
-                    plot_ids[i][-1] = "[" + plot_ids[i][-1] + "]"
-                # Merge what remains
-                plot_ids[i] = ", ".join(plot_ids[i])
-                bbs[plot_ids[i]] = overlap
-                i += 1
-
-            for q, coords in zip(plot_ids, plot_pos):
-                bb = (
-                    dict(boxstyle="square", fill=False, ec="gray", ls="--")
-                    if bbs[q]
-                    else None
-                )
-                v_al = "center" if bbs[q] else "bottom"
-                txt = ax.text(
-                    coords[0],
-                    coords[1],
-                    q,
-                    ha="left",
-                    va=v_al,
-                    wrap=True,
-                    bbox=bb,
-                )
-                txt._get_wrap_line_width = lambda: 50.0
-
-        if draw_half_radius and blockade_radius is not None:
-            for p in pos:
-                circle = plt.Circle(
-                    tuple(p[[ix, iy]]),
-                    blockade_radius / 2,
-                    alpha=0.1,
-                    color="darkgreen",
-                )
-                ax.add_patch(circle)
-                ax.autoscale()
-        if draw_graph and blockade_radius is not None:
-            epsilon = 1e-9  # Accounts for rounding errors
-            edges = KDTree(pos).query_pairs(blockade_radius * (1 + epsilon))
-            bonds = pos[(tuple(edges),)]
-            if len(bonds) > 0:
-                lines = bonds[:, :, (ix, iy)]
-            else:
-                lines = []
-            lc = mc.LineCollection(lines, linewidths=0.6, colors="grey")
-            ax.add_collection(lc)
-
-        else:
-            # Only draw central axis lines when not drawing the graph
-            ax.axvline(0, c="grey", alpha=0.5, linestyle=":")
-            ax.axhline(0, c="grey", alpha=0.5, linestyle=":")
-
-    @staticmethod
-    def _register_dims(
-        pos: np.ndarray,
-        blockade_radius: Optional[float] = None,
-        draw_half_radius: bool = False,
-    ) -> np.ndarray:
-        """Returns the dimensions of the register to be drawn."""
-        diffs = np.ptp(pos, axis=0)
-        diffs[diffs < 9] *= 1.5
-        diffs[diffs < 9] += 2
-        if blockade_radius and draw_half_radius:
-            diffs[diffs < blockade_radius] = blockade_radius
-
-        return np.array(diffs)
-
-    def _draw_checks(
-        self,
-        blockade_radius: Optional[float] = None,
-        draw_graph: bool = True,
-        draw_half_radius: bool = False,
-    ) -> None:
-        """Checks common in all register drawings.
-
-        Keyword Args:
-            blockade_radius(float, default=None): The distance (in μm) between
-                atoms below the Rydberg blockade effect occurs.
-            draw_half_radius(bool, default=False): Whether or not to draw the
-                half the blockade radius surrounding each atoms. If `True`,
-                requires `blockade_radius` to be defined.
-            draw_graph(bool, default=True): Whether or not to draw the
-                interaction between atoms as edges in a graph. Will only draw
-                if the `blockade_radius` is defined.
-        """
-        # Check spacing
-        if blockade_radius is not None and blockade_radius <= 0.0:
-            raise ValueError(
-                "Blockade radius (`blockade_radius` ="
-                f" {blockade_radius})"
-                " must be greater than 0."
-            )
-
-        if draw_half_radius:
-            if blockade_radius is None:
-                raise ValueError("Define 'blockade_radius' to draw.")
-            if len(self._ids) == 1:
-                raise NotImplementedError(
-                    "Needs more than one atom to draw " "the blockade radius."
-                )
-
-
-class Register(BaseRegister):
-    """A 2D quantum register containing a set of qubits.
-
-    Args:
-        qubits (dict): Dictionary with the qubit names as keys and their
-            position coordinates (in μm) as values
-            (e.g. {'q0':(2, -1, 0), 'q1':(-5, 10, 0), ...}).
-    """
-
-    def __init__(self, qubits: Mapping[Any, ArrayLike]):
-        """Initializes a custom Register."""
-        super().__init__(qubits)
-        self._dim = self._coords[0].size
-        if any(c.shape != (self._dim,) for c in self._coords) or (
-            self._dim != 2
-        ):
-            raise ValueError(
-                "All coordinates must be specified as vectors of size 2."
-            )
-
-    @classmethod
-    def square(
-        cls, side: int, spacing: float = 4.0, prefix: Optional[str] = None
-    ) -> Register:
-        """Initializes the register with the qubits in a square array.
-
-        Args:
-            side (int): Side of the square in number of qubits.
-
-        Keyword args:
-            spacing(float): The distance between neighbouring qubits in μm.
-            prefix (str): The prefix for the qubit ids. If defined, each qubit
-                id starts with the prefix, followed by an int from 0 to N-1
-                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
-
-        Returns:
-            Register: A register with qubits placed in a square array.
-        """
-        # Check side
-        if side < 1:
-            raise ValueError(
-                f"The number of atoms per side (`side` = {side})"
-                " must be greater than or equal to 1."
-            )
-
-        return cls.rectangle(side, side, spacing=spacing, prefix=prefix)
-
-    @classmethod
-    def rectangle(
-        cls,
-        rows: int,
-        columns: int,
-        spacing: float = 4.0,
-        prefix: Optional[str] = None,
-    ) -> Register:
-        """Initializes the register with the qubits in a rectangular array.
-
-        Args:
-            rows (int): Number of rows.
-            columns (int): Number of columns.
-
-        Keyword args:
-            spacing(float): The distance between neighbouring qubits in μm.
-            prefix (str): The prefix for the qubit ids. If defined, each qubit
-                id starts with the prefix, followed by an int from 0 to N-1
-                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...)
-
-        Returns:
-            Register: A register with qubits placed in a rectangular array.
-        """
-        # Check rows
-        if rows < 1:
-            raise ValueError(
-                f"The number of rows (`rows` = {rows})"
-                " must be greater than or equal to 1."
-            )
-
-        # Check columns
-        if columns < 1:
-            raise ValueError(
-                f"The number of columns (`columns` = {columns})"
-                " must be greater than or equal to 1."
-            )
-
-        # Check spacing
-        if spacing <= 0.0:
-            raise ValueError(
-                f"Spacing between atoms (`spacing` = {spacing})"
-                " must be greater than 0."
-            )
-
-        coords = (
-            np.array(
-                [(x, y) for y in range(rows) for x in range(columns)],
-                dtype=float,
-            )
-            * spacing
-        )
-
-        return cls.from_coordinates(coords, center=True, prefix=prefix)
-
-    @classmethod
-    def triangular_lattice(
-        cls,
-        rows: int,
-        atoms_per_row: int,
-        spacing: float = 4.0,
-        prefix: Optional[str] = None,
-    ) -> Register:
-        """Initializes the register with the qubits in a triangular lattice.
-
-        Initializes the qubits in a triangular lattice pattern, more
-        specifically a triangular lattice with horizontal rows, meaning the
-        triangles are pointing up and down.
-
-        Args:
-            rows (int): Number of rows.
-            atoms_per_row (int): Number of atoms per row.
-
-        Keyword args:
-            spacing(float): The distance between neighbouring qubits in μm.
-            prefix (str): The prefix for the qubit ids. If defined, each qubit
-                id starts with the prefix, followed by an int from 0 to N-1
-                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
-
-        Returns:
-            Register: A register with qubits placed in a triangular lattice.
-        """
-        # Check rows
-        if rows < 1:
-            raise ValueError(
-                f"The number of rows (`rows` = {rows})"
-                " must be greater than or equal to 1."
-            )
-
-        # Check atoms per row
-        if atoms_per_row < 1:
-            raise ValueError(
-                "The number of atoms per row"
-                f" (`atoms_per_row` = {atoms_per_row})"
-                " must be greater than or equal to 1."
-            )
-
-        # Check spacing
-        if spacing <= 0.0:
-            raise ValueError(
-                f"Spacing between atoms (`spacing` = {spacing})"
-                " must be greater than 0."
-            )
-
-        coords = np.array(
-            [(x, y) for y in range(rows) for x in range(atoms_per_row)],
-            dtype=float,
-        )
-        coords[:, 0] += 0.5 * np.mod(coords[:, 1], 2)
-        coords[:, 1] *= np.sqrt(3) / 2
-        coords *= spacing
-
-        return cls.from_coordinates(coords, center=True, prefix=prefix)
-
-    @classmethod
-    def _hexagon_helper(
-        cls,
-        layers: int,
-        atoms_left: int,
-        spacing: float,
-        prefix: Optional[str] = None,
-    ) -> Register:
-        """Helper function for building hexagonal arrays.
-
-        Args:
-            layers (int): Number of full layers around a central atom.
-            atoms_left (int): Number of atoms on the external layer.
-
-        Keyword args:
-            spacing(float): The distance between neighbouring qubits in μm.
-            prefix (str): The prefix for the qubit ids. If defined, each qubit
-                id starts with the prefix, followed by an int from 0 to N-1
-                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
-
-        Returns:
-            Register: A register with qubits placed in a hexagonal layout
-                with extra atoms on the outermost layer if needed.
-        """
-        # y coordinates of the top vertex of a triangle
-        crest_y = np.sqrt(3) / 2.0
-
-        # Coordinates of vertices
-        start_x = [-1.0, -0.5, 0.5, 1.0, 0.5, -0.5]
-        start_y = [0.0, crest_y, crest_y, 0, -crest_y, -crest_y]
-
-        # Steps to place atoms, starting from a vertex
-        delta_x = [0.5, 1.0, 0.5, -0.5, -1.0, -0.5]
-        delta_y = [crest_y, 0.0, -crest_y, -crest_y, 0.0, crest_y]
-
-        coords = np.array(
-            [
-                (
-                    start_x[side] * layer + atom * delta_x[side],
-                    start_y[side] * layer + atom * delta_y[side],
-                )
-                for layer in range(1, layers + 1)
-                for side in range(6)
-                for atom in range(1, layer + 1)
-            ],
-            dtype=float,
-        )
-
-        if atoms_left > 0:
-            layer = layers + 1
-            min_atoms_per_side = atoms_left // 6
-            # Extra atoms after balancing all sides
-            atoms_left %= 6
-
-            # Order for placing left atoms
-            # Top-Left, Top-Right, Bottom (C3 symmetry)...
-            # ...Top, Bottom-Right, Bottom-Left (C6 symmetry)
-            sides_order = [0, 3, 1, 4, 2, 5]
-
-            coords2 = np.array(
-                [
-                    (
-                        start_x[side] * layer + atom * delta_x[side],
-                        start_y[side] * layer + atom * delta_y[side],
-                    )
-                    for side in range(6)
-                    for atom in range(
-                        1,
-                        min_atoms_per_side + 2
-                        if atoms_left > sides_order[side]
-                        else min_atoms_per_side + 1,
-                    )
-                ],
-                dtype=float,
-            )
-
-            coords = np.concatenate((coords, coords2))
-
-        coords *= spacing
-        coords = np.concatenate(([(0.0, 0.0)], coords))
-
-        return cls.from_coordinates(coords, center=False, prefix=prefix)
-
-    @classmethod
-    def hexagon(
-        cls, layers: int, spacing: float = 4.0, prefix: Optional[str] = None
-    ) -> Register:
-        """Initializes the register with the qubits in a hexagonal layout.
-
-        Args:
-            layers (int): Number of layers around a central atom.
-
-        Keyword args:
-            spacing(float): The distance between neighbouring qubits in μm.
-            prefix (str): The prefix for the qubit ids. If defined, each qubit
-                id starts with the prefix, followed by an int from 0 to N-1
-                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
-
-        Returns:
-            Register: A register with qubits placed in a hexagonal layout.
-        """
-        # Check layers
-        if layers < 1:
-            raise ValueError(
-                f"The number of layers (`layers` = {layers})"
-                " must be greater than or equal to 1."
-            )
-
-        # Check spacing
-        if spacing <= 0.0:
-            raise ValueError(
-                f"Spacing between atoms (`spacing` = {spacing})"
-                " must be greater than 0."
-            )
-
-        return cls._hexagon_helper(layers, 0, spacing, prefix)
-
-    @classmethod
-    def max_connectivity(
-        cls,
-        n_qubits: int,
-        device: pulser.devices._device_datacls.Device,
-        spacing: float = None,
-        prefix: str = None,
-    ) -> Register:
-        """Initializes the register with maximum connectivity for a given device.
-
-        In order to maximize connectivity, the basic pattern is the triangle.
-        Atoms are first arranged as layers of hexagons around a central atom.
-        Extra atoms are placed in such a manner that C3 and C6 rotational
-        symmetries are enforced as often as possible.
-
-        Args:
-            n_qubits (int): Number of qubits.
-            device (Device): The device whose constraints must be obeyed.
-
-        Keyword args:
-            spacing(float): The distance between neighbouring qubits in μm.
-                If omitted, the minimal distance for the device is used.
-            prefix (str): The prefix for the qubit ids. If defined, each qubit
-                id starts with the prefix, followed by an int from 0 to N-1
-                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
-
-        Returns:
-            Register: A register with qubits placed for maximum connectivity.
-        """
-        # Check device
-        if not isinstance(device, pulser.devices._device_datacls.Device):
-            raise TypeError(
-                "'device' must be of type 'Device'. Import a valid"
-                " device from 'pulser.devices'."
-            )
-
-        # Check number of qubits (1 or above)
-        if n_qubits < 1:
-            raise ValueError(
-                f"The number of qubits (`n_qubits` = {n_qubits})"
-                " must be greater than or equal to 1."
-            )
-
-        # Check number of qubits (less than the max number of atoms)
-        if n_qubits > device.max_atom_num:
-            raise ValueError(
-                f"The number of qubits (`n_qubits` = {n_qubits})"
-                " must be less than or equal to the maximum"
-                " number of atoms supported by this device"
-                f" ({device.max_atom_num})."
-            )
-
-        # Default spacing or check minimal distance
-        if spacing is None:
-            spacing = device.min_atom_distance
-        elif spacing < device.min_atom_distance:
-            raise ValueError(
-                f"Spacing between atoms (`spacing = `{spacing})"
-                " must be greater than or equal to the minimal"
-                " distance supported by this device"
-                f" ({device.min_atom_distance})."
-            )
-
-        if n_qubits < 7:
-            crest_y = np.sqrt(3) / 2.0
-            hex_coords = np.array(
-                [
-                    (0.0, 0.0),
-                    (-0.5, crest_y),
-                    (0.5, crest_y),
-                    (1.0, 0.0),
-                    (0.5, -crest_y),
-                    (-0.5, -crest_y),
-                ]
-            )
-            return cls.from_coordinates(
-                spacing * hex_coords[:n_qubits], prefix=prefix, center=False
-            )
-
-        full_layers = int((-3.0 + np.sqrt(9 + 12 * (n_qubits - 1))) / 6.0)
-        atoms_left = n_qubits - 1 - (full_layers**2 + full_layers) * 3
-
-        return cls._hexagon_helper(full_layers, atoms_left, spacing, prefix)
-
-    def rotate(self, degrees: float) -> None:
-        """Rotates the array around the origin by the given angle.
-
-        Args:
-            degrees (float): The angle of rotation in degrees.
-        """
-        theta = np.deg2rad(degrees)
-        rot = np.array(
-            [[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]
-        )
-        self._coords = [rot @ v for v in self._coords]
-
-    def _initialize_fig_axes(
-        self,
-        pos: np.ndarray,
-        blockade_radius: Optional[float] = None,
-        draw_half_radius: bool = False,
-    ) -> tuple[plt.figure.Figure, plt.axes.Axes]:
-        """Creates the Figure and Axes for drawing the register."""
-        diffs = super()._register_dims(
-            pos,
-            blockade_radius=blockade_radius,
-            draw_half_radius=draw_half_radius,
-        )
-        big_side = max(diffs)
-        proportions = diffs / big_side
-        Ls = proportions * min(
-            big_side / 4, 10
-        )  # Figsize is, at most, (10,10)
-        fig, axes = plt.subplots(figsize=Ls)
-
-        return (fig, axes)
-
-    def draw(
-        self,
-        with_labels: bool = True,
-        blockade_radius: Optional[float] = None,
-        draw_graph: bool = True,
-        draw_half_radius: bool = False,
-        fig_name: str = None,
-        kwargs_savefig: dict = {},
-    ) -> None:
-        """Draws the entire register.
-
-        Keyword Args:
-            with_labels(bool, default=True): If True, writes the qubit ID's
-                next to each qubit.
-            blockade_radius(float, default=None): The distance (in μm) between
-                atoms below the Rydberg blockade effect occurs.
-            draw_half_radius(bool, default=False): Whether or not to draw the
-                half the blockade radius surrounding each atoms. If `True`,
-                requires `blockade_radius` to be defined.
-            draw_graph(bool, default=True): Whether or not to draw the
-                interaction between atoms as edges in a graph. Will only draw
-                if the `blockade_radius` is defined.
-            fig_name(str, default=None): The name on which to save the figure.
-                If None the figure will not be saved.
-            kwargs_savefig(dict, default={}): Keywords arguments for
-                ``matplotlib.pyplot.savefig``. Not applicable if `fig_name`
-                is ``None``.
-
-        Note:
-            When drawing half the blockade radius, we say there is a blockade
-            effect between atoms whenever their respective circles overlap.
-            This representation is preferred over drawing the full Rydberg
-            radius because it helps in seeing the interactions between atoms.
-        """
-        super()._draw_checks(
-            blockade_radius=blockade_radius,
-            draw_graph=draw_graph,
-            draw_half_radius=draw_half_radius,
-        )
-        pos = np.array(self._coords)
-        fig, ax = self._initialize_fig_axes(
-            pos,
-            blockade_radius=blockade_radius,
-            draw_half_radius=draw_half_radius,
-        )
-        super()._draw_2D(
-            ax,
-            pos,
-            self._ids,
-            with_labels=with_labels,
-            blockade_radius=blockade_radius,
-            draw_graph=draw_graph,
-            draw_half_radius=draw_half_radius,
-        )
-        if fig_name is not None:
-            plt.savefig(fig_name, **kwargs_savefig)
-        plt.show()
-
-    def _to_dict(self) -> dict[str, Any]:
-        qs = dict(zip(self._ids, map(np.ndarray.tolist, self._coords)))
-        return obj_to_dict(self, qs)
-
-
-class Register3D(BaseRegister):
-    """A 3D quantum register containing a set of qubits.
-
-    Args:
-        qubits (dict): Dictionary with the qubit names as keys and their
-            position coordinates (in μm) as values
-            (e.g. {'q0':(2, -1, 0), 'q1':(-5, 10, 0), ...}).
-    """
-
-    def __init__(self, qubits: Mapping[Any, ArrayLike]):
-        """Initializes a custom Register."""
-        super().__init__(qubits)
-        coords = [np.array(v, dtype=float) for v in qubits.values()]
-        self._dim = coords[0].size
-        if any(c.shape != (self._dim,) for c in coords) or (self._dim != 3):
-            raise ValueError(
-                "All coordinates must be specified as vectors of size 3."
-            )
-        self._coords = coords
-
-    @classmethod
-    def cubic(
-        cls, side: int, spacing: float = 4.0, prefix: Optional[str] = None
-    ) -> Register3D:
-        """Initializes the register with the qubits in a cubic array.
-
-        Args:
-            side (int): Side of the cube in number of qubits.
-
-        Keyword args:
-            spacing(float): The distance between neighbouring qubits in μm.
-            prefix (str): The prefix for the qubit ids. If defined, each qubit
-                id starts with the prefix, followed by an int from 0 to N-1
-                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
-
-        Returns:
-            Register3D : A 3D register with qubits placed in a cubic array.
-        """
-        # Check side
-        if side < 1:
-            raise ValueError(
-                f"The number of atoms per side (`side` = {side})"
-                " must be greater than or equal to 1."
-            )
-
-        return cls.cuboid(side, side, side, spacing=spacing, prefix=prefix)
-
-    @classmethod
-    def cuboid(
-        cls,
-        rows: int,
-        columns: int,
-        layers: int,
-        spacing: float = 4.0,
-        prefix: Optional[str] = None,
-    ) -> Register3D:
-        """Initializes the register with the qubits in a cuboid array.
-
-        Args:
-            rows (int): Number of rows.
-            columns (int): Number of columns.
-            layers (int): Number of layers.
-
-        Keyword args:
-            spacing(float): The distance between neighbouring qubits in μm.
-            prefix (str): The prefix for the qubit ids. If defined, each qubit
-                id starts with the prefix, followed by an int from 0 to N-1
-                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...)
-
-        Returns:
-            Register3D : A 3D register with qubits placed in a cuboid array.
-        """
-        # Check rows
-        if rows < 1:
-            raise ValueError(
-                f"The number of rows (`rows` = {rows})"
-                " must be greater than or equal to 1."
-            )
-
-        # Check columns
-        if columns < 1:
-            raise ValueError(
-                f"The number of columns (`columns` = {columns})"
-                " must be greater than or equal to 1."
-            )
-
-        # Check layers
-        if layers < 1:
-            raise ValueError(
-                f"The number of layers (`layers` = {layers})"
-                " must be greater than or equal to 1."
-            )
-
-        # Check spacing
-        if spacing <= 0.0:
-            raise ValueError(
-                f"Spacing between atoms (`spacing` = {spacing})"
-                " must be greater than 0."
-            )
-
-        coords = (
-            np.array(
-                [
-                    (x, y, z)
-                    for z in range(layers)
-                    for y in range(rows)
-                    for x in range(columns)
-                ],
-                dtype=float,
-            )
-            * spacing
-        )
-
-        return cls.from_coordinates(coords, center=True, prefix=prefix)
-
-    def to_2D(self, tol_width: float = 0.0) -> Register:
-        """Converts a Register3D into a Register (if possible).
-
-        Args:
-            tol_width (float): The allowed transverse width of
-            the register to be projected.
-
-        Returns:
-            Register: Returns a 2D register with the coordinates of the atoms
-                in a plane, if they are coplanar.
-
-        Raises:
-            ValueError: If the atoms are not coplanar.
-        """
-        coords = np.array(self._coords)
-
-        barycenter = coords.sum(axis=0) / coords.shape[0]
-        # run SVD
-        u, s, vh = np.linalg.svd(coords - barycenter)
-        e_z = vh[2, :]
-        perp_extent = [e_z.dot(r) for r in coords]
-        width = np.ptp(perp_extent)
-        # A set of vector is coplanar if one of the Singular values is 0
-        if width > tol_width:
-            raise ValueError(
-                f"Atoms are not coplanar (`width` = {width:#.2f} µm)"
-            )
-        else:
-            e_x = vh[0, :]
-            e_y = vh[1, :]
-            coords_2D = np.array(
-                [np.array([e_x.dot(r), e_y.dot(r)]) for r in coords]
-            )
-            return Register.from_coordinates(coords_2D, labels=self._ids)
-
-    def _initialize_fig_axes_projection(
-        self,
-        pos: np.ndarray,
-        blockade_radius: Optional[float] = None,
-        draw_half_radius: bool = False,
-    ) -> tuple[plt.figure.Figure, plt.axes.Axes]:
-        """Creates the Figure and Axes for drawing the register projections."""
-        diffs = super()._register_dims(
-            pos,
-            blockade_radius=blockade_radius,
-            draw_half_radius=draw_half_radius,
-        )
-
-        proportions = []
-        for (ix, iy) in combinations(np.arange(3), 2):
-            big_side = max(diffs[[ix, iy]])
-            Ls = diffs[[ix, iy]] / big_side
-            Ls *= max(
-                min(big_side / 4, 10), 4
-            )  # Figsize is, at most, (10,10), and, at least (4,*) or (*,4)
-            proportions.append(Ls)
-
-        fig_height = np.max([Ls[1] for Ls in proportions])
-
-        max_width = 0
-        for i, (width, height) in enumerate(proportions):
-            proportions[i] = (width * fig_height / height, fig_height)
-            max_width = max(max_width, proportions[i][0])
-        widths = [max(Ls[0], max_width / 5) for Ls in proportions]
-        fig_width = min(np.sum(widths), fig_height * 4)
-
-        rescaling = 20 / max(max(fig_width, fig_height), 20)
-        figsize = (rescaling * fig_width, rescaling * fig_height)
-
-        fig, axes = plt.subplots(
-            ncols=3,
-            figsize=figsize,
-            gridspec_kw=dict(width_ratios=widths),
-        )
-
-        return (fig, axes)
-
-    def draw(
-        self,
-        with_labels: bool = False,
-        blockade_radius: Optional[float] = None,
-        draw_graph: bool = True,
-        draw_half_radius: bool = False,
-        projection: bool = False,
-        fig_name: str = None,
-        kwargs_savefig: dict = {},
-    ) -> None:
-        """Draws the entire register.
-
-        Keyword Args:
-            with_labels(bool, default=True): If True, writes the qubit ID's
-                next to each qubit.
-            blockade_radius(float, default=None): The distance (in μm) between
-                atoms below the Rydberg blockade effect occurs.
-            draw_half_radius(bool, default=False): Whether or not to draw the
-                half the blockade radius surrounding each atoms. If `True`,
-                requires `blockade_radius` to be defined.
-            draw_graph(bool, default=True): Whether or not to draw the
-                interaction between atoms as edges in a graph. Will only draw
-                if the `blockade_radius` is defined.
-            projection(bool, default=False): Whether to draw a 2D projection
-                instead of a perspective view.
-            fig_name(str, default=None): The name on which to save the figure.
-                If None the figure will not be saved.
-            kwargs_savefig(dict, default={}): Keywords arguments for
-                ``matplotlib.pyplot.savefig``. Not applicable if `fig_name`
-                is ``None``.
-
-        Note:
-            When drawing half the blockade radius, we say there is a blockade
-            effect between atoms whenever their respective circles overlap.
-            This representation is preferred over drawing the full Rydberg
-            radius because it helps in seeing the interactions between atoms.
-        """
-        super()._draw_checks(
-            blockade_radius=blockade_radius,
-            draw_graph=draw_graph,
-            draw_half_radius=draw_half_radius,
-        )
-
-        pos = np.array(self._coords)
-
-        if draw_graph and blockade_radius is not None:
-            epsilon = 1e-9  # Accounts for rounding errors
-            edges = KDTree(pos).query_pairs(blockade_radius * (1 + epsilon))
-
-        if projection:
-            labels = "xyz"
-            fig, axes = self._initialize_fig_axes_projection(
-                pos,
-                blockade_radius=blockade_radius,
-                draw_half_radius=draw_half_radius,
-            )
-            fig.tight_layout(w_pad=6.5)
-
-            for ax, (ix, iy) in zip(axes, combinations(np.arange(3), 2)):
-                super()._draw_2D(
-                    ax,
-                    pos,
-                    self._ids,
-                    plane=(
-                        ix,
-                        iy,
-                    ),
-                    with_labels=with_labels,
-                    blockade_radius=blockade_radius,
-                    draw_graph=draw_graph,
-                    draw_half_radius=draw_half_radius,
-                )
-                ax.set_title(
-                    "Projection onto\n the "
-                    + labels[ix]
-                    + labels[iy]
-                    + "-plane"
-                )
-
-        else:
-            fig = plt.figure(figsize=2 * plt.figaspect(0.5))
-
-            if draw_graph and blockade_radius is not None:
-                bonds = {}
-                for i, j in edges:
-                    xi, yi, zi = pos[i]
-                    xj, yj, zj = pos[j]
-                    bonds[(i, j)] = [[xi, xj], [yi, yj], [zi, zj]]
-
-            for i in range(1, 3):
-                ax = fig.add_subplot(
-                    1, 2, i, projection="3d", azim=-60 * (-1) ** i, elev=15
-                )
-
-                ax.scatter(
-                    pos[:, 0],
-                    pos[:, 1],
-                    pos[:, 2],
-                    s=30,
-                    alpha=0.7,
-                    c="darkgreen",
-                )
-
-                if with_labels:
-                    for q, coords in zip(self._ids, self._coords):
-                        ax.text(
-                            coords[0],
-                            coords[1],
-                            coords[2],
-                            q,
-                            fontsize=12,
-                            ha="left",
-                            va="bottom",
-                        )
-
-                if draw_half_radius and blockade_radius is not None:
-                    mesh_num = 20 if len(self._ids) > 10 else 40
-                    for r in pos:
-                        x0, y0, z0 = r
-                        radius = blockade_radius / 2
-
-                        # Strange behavior pf mypy using "imaginary slice step"
-                        # u, v = np.pi * np.mgrid[0:2:50j, 0:1:50j]
-
-                        v, u = np.meshgrid(
-                            np.arccos(np.linspace(-1, 1, num=mesh_num)),
-                            np.linspace(0, 2 * np.pi, num=mesh_num),
-                        )
-                        x = radius * np.cos(u) * np.sin(v) + x0
-                        y = radius * np.sin(u) * np.sin(v) + y0
-                        z = radius * np.cos(v) + z0
-                        # alpha controls opacity
-                        ax.plot_surface(x, y, z, color="darkgreen", alpha=0.1)
-
-                if draw_graph and blockade_radius is not None:
-                    for x, y, z in bonds.values():
-                        ax.plot(x, y, z, linewidth=1.5, color="grey")
-
-                ax.set_xlabel("x (µm)")
-                ax.set_ylabel("y (µm)")
-                ax.set_zlabel("z (µm)")
-
-        if fig_name is not None:
-            plt.savefig(fig_name, **kwargs_savefig)
-        plt.show()
diff --git a/pulser/register/__init__.py b/pulser/register/__init__.py
new file mode 100644
index 000000000..084859366
--- /dev/null
+++ b/pulser/register/__init__.py
@@ -0,0 +1,18 @@
+# Copyright 2022 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 qubit register definition."""
+
+from pulser.register.base_register import QubitId
+from pulser.register.register import Register
+from pulser.register.register3d import Register3D
diff --git a/pulser/register/_patterns.py b/pulser/register/_patterns.py
new file mode 100644
index 000000000..afb45025f
--- /dev/null
+++ b/pulser/register/_patterns.py
@@ -0,0 +1,132 @@
+# Copyright 2022 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.
+from __future__ import annotations
+
+import numpy as np
+
+
+def square_rect(rows: int, columns: int) -> np.ndarray:
+    """A square lattice pattern in a rectangular shape.
+
+    Args:
+        rows(int): Number of rows.
+        columns(int): Number of columns.
+
+    Returns:
+        np.ndarray: The coordinates of the points in the pattern.
+    """
+    points = np.mgrid[:columns, :rows].transpose().reshape(-1, 2)
+    # Centering
+    points = points - np.ceil([columns / 2, rows / 2]) + 1
+    return points
+
+
+def triangular_rect(rows: int, columns: int) -> np.ndarray:
+    """A triangular lattice pattern in a rectangular shape.
+
+    Args:
+        rows(int): Number of rows.
+        columns(int): Number of columns.
+
+    Returns:
+        np.ndarray: The coordinates of the points in the pattern.
+    """
+    points = square_rect(rows, columns)
+    points[:, 0] += 0.5 * np.mod(points[:, 1], 2)
+    points[:, 1] *= np.sqrt(3) / 2
+    return points
+
+
+def triangular_hex(n_points: int) -> np.ndarray:
+    """A triangular lattice pattern in an hexagonal shape.
+
+    Args:
+        n_points(int): The number of points in the pattern.
+
+
+    Returns:
+        np.ndarray: The coordinates of the points in the pattern.
+    """
+    # y coordinates of the top vertex of a triangle
+    crest_y = np.sqrt(3) / 2.0
+
+    if n_points < 7:
+        hex_coords = np.array(
+            [
+                (0.0, 0.0),
+                (-0.5, crest_y),
+                (0.5, crest_y),
+                (1.0, 0.0),
+                (0.5, -crest_y),
+                (-0.5, -crest_y),
+            ]
+        )
+        return hex_coords[:n_points]
+
+    layers = int((-3.0 + np.sqrt(9 + 12 * (n_points - 1))) / 6.0)
+    points_left = n_points - 1 - (layers**2 + layers) * 3
+
+    # Coordinates of vertices
+    start_x = [-1.0, -0.5, 0.5, 1.0, 0.5, -0.5]
+    start_y = [0.0, crest_y, crest_y, 0, -crest_y, -crest_y]
+
+    # Steps to place atoms, starting from a vertex
+    delta_x = [0.5, 1.0, 0.5, -0.5, -1.0, -0.5]
+    delta_y = [crest_y, 0.0, -crest_y, -crest_y, 0.0, crest_y]
+
+    coords = np.array(
+        [
+            (
+                start_x[side] * layer + atom * delta_x[side],
+                start_y[side] * layer + atom * delta_y[side],
+            )
+            for layer in range(1, layers + 1)
+            for side in range(6)
+            for atom in range(1, layer + 1)
+        ],
+        dtype=float,
+    )
+
+    if points_left > 0:
+        layer = layers + 1
+        min_atoms_per_side = points_left // 6
+        # Extra atoms after balancing all sides
+        points_left %= 6
+
+        # Order for placing left atoms
+        # Top-Left, Top-Right, Bottom (C3 symmetry)...
+        # ...Top, Bottom-Right, Bottom-Left (C6 symmetry)
+        sides_order = [0, 3, 1, 4, 2, 5]
+
+        coords2 = np.array(
+            [
+                (
+                    start_x[side] * layer + atom * delta_x[side],
+                    start_y[side] * layer + atom * delta_y[side],
+                )
+                for side in range(6)
+                for atom in range(
+                    1,
+                    min_atoms_per_side + 2
+                    if points_left > sides_order[side]
+                    else min_atoms_per_side + 1,
+                )
+            ],
+            dtype=float,
+        )
+
+        coords = np.concatenate((coords, coords2))
+
+    coords = np.concatenate((np.zeros((1, 2)), coords))
+    return coords
diff --git a/pulser/register/_reg_drawer.py b/pulser/register/_reg_drawer.py
new file mode 100644
index 000000000..e8aeaa67c
--- /dev/null
+++ b/pulser/register/_reg_drawer.py
@@ -0,0 +1,388 @@
+# Copyright 2022 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.
+
+from __future__ import annotations
+
+from collections.abc import Sequence as abcSequence
+from itertools import combinations
+from typing import Optional
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib import collections as mc
+from scipy.spatial import KDTree
+
+from pulser.register.base_register import QubitId
+
+
+class RegDrawer:
+    """Helper functions for Register drawing."""
+
+    @staticmethod
+    def _draw_2D(
+        ax: plt.axes._subplots.AxesSubplot,
+        pos: np.ndarray,
+        ids: abcSequence[QubitId],
+        plane: tuple = (0, 1),
+        with_labels: bool = True,
+        blockade_radius: Optional[float] = None,
+        draw_graph: bool = True,
+        draw_half_radius: bool = False,
+        masked_qubits: set[QubitId] = set(),
+        are_traps: bool = False,
+    ) -> None:
+        ix, iy = plane
+
+        if are_traps:
+            params = dict(s=50, edgecolors="black", facecolors="none")
+        else:
+            params = dict(s=30, c="darkgreen")
+
+        ax.scatter(pos[:, ix], pos[:, iy], alpha=0.7, **params)
+
+        # Draw square halo around masked qubits
+        if masked_qubits:
+            mask_pos = []
+            for i, c in zip(ids, pos):
+                if i in masked_qubits:
+                    mask_pos.append(c)
+            mask_arr = np.array(mask_pos)
+            ax.scatter(
+                mask_arr[:, ix],
+                mask_arr[:, iy],
+                marker="s",
+                s=1200,
+                alpha=0.2,
+                c="black",
+            )
+
+        axes = "xyz"
+
+        ax.set_xlabel(axes[ix] + " (µm)")
+        ax.set_ylabel(axes[iy] + " (µm)")
+        ax.axis("equal")
+        ax.spines["right"].set_color("none")
+        ax.spines["top"].set_color("none")
+
+        if with_labels:
+            # Determine which labels would overlap and merge those
+            plot_pos = list(pos[:, (ix, iy)])
+            plot_ids: list[list[str]] = [[f"{i}"] for i in ids]
+            # Threshold distance between points
+            epsilon = 1.0e-2 * np.diff(ax.get_xlim())[0]
+
+            i = 0
+            bbs = {}
+            final_plot_ids: list[str] = []
+            while i < len(plot_ids):
+                r = plot_pos[i]
+                j = i + 1
+                overlap = False
+                # Put in a list all qubits that overlap at position plot_pos[i]
+                while j < len(plot_ids):
+                    r2 = plot_pos[j]
+                    if np.max(np.abs(r - r2)) < epsilon:
+                        plot_ids[i] = plot_ids[i] + plot_ids.pop(j)
+                        plot_pos.pop(j)
+                        overlap = True
+                    else:
+                        j += 1
+                # Sort qubits in plot_ids[i] according to masked status
+                plot_ids[i] = sorted(
+                    plot_ids[i],
+                    key=lambda s: s in [str(q) for q in masked_qubits],
+                )
+                # Merge all masked qubits
+                has_masked = False
+                for j in range(len(plot_ids[i])):
+                    if plot_ids[i][j] in [str(q) for q in masked_qubits]:
+                        plot_ids[i][j:] = [", ".join(plot_ids[i][j:])]
+                        has_masked = True
+                        break
+                # Add a square bracket that encloses all masked qubits
+                if has_masked:
+                    plot_ids[i][-1] = "[" + plot_ids[i][-1] + "]"
+                # Merge what remains
+                final_plot_ids.append(", ".join(plot_ids[i]))
+                bbs[final_plot_ids[i]] = overlap
+                i += 1
+
+            for q, coords in zip(final_plot_ids, plot_pos):
+                bb = (
+                    dict(boxstyle="square", fill=False, ec="gray", ls="--")
+                    if bbs[q]
+                    else None
+                )
+                v_al = "center" if bbs[q] else "bottom"
+                txt = ax.text(
+                    coords[0],
+                    coords[1],
+                    q,
+                    ha="left",
+                    va=v_al,
+                    wrap=True,
+                    bbox=bb,
+                    fontsize=12,
+                    multialignment="right",
+                )
+                txt._get_wrap_line_width = lambda: 50.0
+
+        if draw_half_radius and blockade_radius is not None:
+            for p in pos:
+                circle = plt.Circle(
+                    tuple(p[[ix, iy]]),
+                    blockade_radius / 2,
+                    alpha=0.1,
+                    color="darkgreen",
+                )
+                ax.add_patch(circle)
+                ax.autoscale()
+        if draw_graph and blockade_radius is not None:
+            epsilon = 1e-9  # Accounts for rounding errors
+            edges = KDTree(pos).query_pairs(blockade_radius * (1 + epsilon))
+            bonds = pos[(tuple(edges),)]
+            if len(bonds) > 0:
+                lines = bonds[:, :, (ix, iy)]
+            else:
+                lines = []
+            lc = mc.LineCollection(lines, linewidths=0.6, colors="grey")
+            ax.add_collection(lc)
+
+        else:
+            # Only draw central axis lines when not drawing the graph
+            ax.axvline(0, c="grey", alpha=0.5, linestyle=":")
+            ax.axhline(0, c="grey", alpha=0.5, linestyle=":")
+
+    @staticmethod
+    def _draw_3D(
+        pos: np.ndarray,
+        ids: abcSequence[QubitId],
+        projection: bool = False,
+        with_labels: bool = True,
+        blockade_radius: Optional[float] = None,
+        draw_graph: bool = True,
+        draw_half_radius: bool = False,
+        are_traps: bool = False,
+    ) -> None:
+        if draw_graph and blockade_radius is not None:
+            epsilon = 1e-9  # Accounts for rounding errors
+            edges = KDTree(pos).query_pairs(blockade_radius * (1 + epsilon))
+
+        if projection:
+            labels = "xyz"
+            fig, axes = RegDrawer._initialize_fig_axes_projection(
+                pos,
+                blockade_radius=blockade_radius,
+                draw_half_radius=draw_half_radius,
+            )
+            fig.tight_layout(w_pad=6.5)
+
+            for ax, (ix, iy) in zip(axes, combinations(np.arange(3), 2)):
+                RegDrawer._draw_2D(
+                    ax,
+                    pos,
+                    ids,
+                    plane=(
+                        ix,
+                        iy,
+                    ),
+                    with_labels=with_labels,
+                    blockade_radius=blockade_radius,
+                    draw_graph=draw_graph,
+                    draw_half_radius=draw_half_radius,
+                    are_traps=are_traps,
+                )
+                ax.set_title(
+                    "Projection onto\n the "
+                    + labels[ix]
+                    + labels[iy]
+                    + "-plane"
+                )
+
+        else:
+            fig = plt.figure(figsize=2 * plt.figaspect(0.5))
+
+            if draw_graph and blockade_radius is not None:
+                bonds = {}
+                for i, j in edges:
+                    xi, yi, zi = pos[i]
+                    xj, yj, zj = pos[j]
+                    bonds[(i, j)] = [[xi, xj], [yi, yj], [zi, zj]]
+
+            if are_traps:
+                params = dict(s=50, c="white", edgecolors="black")
+            else:
+                params = dict(s=30, c="darkgreen")
+
+            for i in range(1, 3):
+                ax = fig.add_subplot(
+                    1, 2, i, projection="3d", azim=-60 * (-1) ** i, elev=15
+                )
+
+                ax.scatter(
+                    pos[:, 0], pos[:, 1], pos[:, 2], alpha=0.7, **params
+                )
+
+                if with_labels:
+                    for q, coords in zip(ids, pos):
+                        ax.text(
+                            coords[0],
+                            coords[1],
+                            coords[2],
+                            q,
+                            fontsize=12,
+                            ha="left",
+                            va="bottom",
+                        )
+
+                if draw_half_radius and blockade_radius is not None:
+                    mesh_num = 20 if len(ids) > 10 else 40
+                    for r in pos:
+                        x0, y0, z0 = r
+                        radius = blockade_radius / 2
+
+                        # Strange behavior pf mypy using "imaginary slice step"
+                        # u, v = np.pi * np.mgrid[0:2:50j, 0:1:50j]
+
+                        v, u = np.meshgrid(
+                            np.arccos(np.linspace(-1, 1, num=mesh_num)),
+                            np.linspace(0, 2 * np.pi, num=mesh_num),
+                        )
+                        x = radius * np.cos(u) * np.sin(v) + x0
+                        y = radius * np.sin(u) * np.sin(v) + y0
+                        z = radius * np.cos(v) + z0
+                        # alpha controls opacity
+                        ax.plot_surface(x, y, z, color="darkgreen", alpha=0.1)
+
+                if draw_graph and blockade_radius is not None:
+                    for x, y, z in bonds.values():
+                        ax.plot(x, y, z, linewidth=1.5, color="grey")
+
+                ax.set_xlabel("x (µm)")
+                ax.set_ylabel("y (µm)")
+                ax.set_zlabel("z (µm)")
+
+    @staticmethod
+    def _register_dims(
+        pos: np.ndarray,
+        blockade_radius: Optional[float] = None,
+        draw_half_radius: bool = False,
+    ) -> np.ndarray:
+        """Returns the dimensions of the register to be drawn."""
+        diffs = np.ptp(pos, axis=0)
+        diffs[diffs < 9] *= 1.5
+        diffs[diffs < 9] += 2
+        if blockade_radius and draw_half_radius:
+            diffs[diffs < blockade_radius] = blockade_radius
+
+        return np.array(diffs)
+
+    @staticmethod
+    def _initialize_fig_axes(
+        pos: np.ndarray,
+        blockade_radius: Optional[float] = None,
+        draw_half_radius: bool = False,
+    ) -> tuple[plt.figure.Figure, plt.axes.Axes]:
+        """Creates the Figure and Axes for drawing the register."""
+        diffs = RegDrawer._register_dims(
+            pos,
+            blockade_radius=blockade_radius,
+            draw_half_radius=draw_half_radius,
+        )
+        big_side = max(diffs)
+        proportions = diffs / big_side
+        Ls = proportions * min(
+            big_side / 4, 10
+        )  # Figsize is, at most, (10,10)
+        fig, axes = plt.subplots(figsize=Ls)
+
+        return (fig, axes)
+
+    @staticmethod
+    def _initialize_fig_axes_projection(
+        pos: np.ndarray,
+        blockade_radius: Optional[float] = None,
+        draw_half_radius: bool = False,
+    ) -> tuple[plt.figure.Figure, plt.axes.Axes]:
+        """Creates the Figure and Axes for drawing the register projections."""
+        diffs = RegDrawer._register_dims(
+            pos,
+            blockade_radius=blockade_radius,
+            draw_half_radius=draw_half_radius,
+        )
+
+        proportions = []
+        for (ix, iy) in combinations(np.arange(3), 2):
+            big_side = max(diffs[[ix, iy]])
+            Ls = diffs[[ix, iy]] / big_side
+            Ls *= max(
+                min(big_side / 4, 10), 4
+            )  # Figsize is, at most, (10,10), and, at least (4,*) or (*,4)
+            proportions.append(Ls)
+
+        fig_height = np.max([Ls[1] for Ls in proportions])
+
+        max_width = 0
+        for i, (width, height) in enumerate(proportions):
+            proportions[i] = (width * fig_height / height, fig_height)
+            max_width = max(max_width, proportions[i][0])
+        widths = [max(Ls[0], max_width / 5) for Ls in proportions]
+        fig_width = min(np.sum(widths), fig_height * 4)
+
+        rescaling = 20 / max(max(fig_width, fig_height), 20)
+        figsize = (rescaling * fig_width, rescaling * fig_height)
+
+        fig, axes = plt.subplots(
+            ncols=3,
+            figsize=figsize,
+            gridspec_kw=dict(width_ratios=widths),
+        )
+
+        return (fig, axes)
+
+    @staticmethod
+    def _draw_checks(
+        n_atoms: int,
+        blockade_radius: Optional[float] = None,
+        draw_graph: bool = True,
+        draw_half_radius: bool = False,
+    ) -> None:
+        """Checks common in all register drawings.
+
+        Args:
+            n_atoms(int): Number of atoms in the register.
+            blockade_radius(float, default=None): The distance (in μm) between
+                atoms below the Rydberg blockade effect occurs.
+            draw_half_radius(bool, default=False): Whether or not to draw the
+                half the blockade radius surrounding each atoms. If `True`,
+                requires `blockade_radius` to be defined.
+            draw_graph(bool, default=True): Whether or not to draw the
+                interaction between atoms as edges in a graph. Will only draw
+                if the `blockade_radius` is defined.
+        """
+        # Check spacing
+        if blockade_radius is not None and blockade_radius <= 0.0:
+            raise ValueError(
+                "Blockade radius (`blockade_radius` ="
+                f" {blockade_radius})"
+                " must be greater than 0."
+            )
+
+        if draw_half_radius:
+            if blockade_radius is None:
+                raise ValueError("Define 'blockade_radius' to draw.")
+            if n_atoms < 2:
+                raise NotImplementedError(
+                    "Needs more than one atom to draw the blockade radius."
+                )
diff --git a/pulser/register/base_register.py b/pulser/register/base_register.py
new file mode 100644
index 000000000..b486d3503
--- /dev/null
+++ b/pulser/register/base_register.py
@@ -0,0 +1,184 @@
+# Copyright 2021 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.
+"""Defines the abstract register class."""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections.abc import Iterable, Mapping
+from collections.abc import Sequence as abcSequence
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    NamedTuple,
+    Optional,
+    Type,
+    TypeVar,
+    Union,
+    cast,
+)
+
+import numpy as np
+from numpy.typing import ArrayLike
+
+from pulser.json.utils import obj_to_dict
+
+if TYPE_CHECKING:  # pragma: no cover
+    from pulser.register.register_layout import RegisterLayout
+
+T = TypeVar("T", bound="BaseRegister")
+QubitId = Union[int, str]
+
+
+class _LayoutInfo(NamedTuple):
+    """Auxiliary class to store the register layout information."""
+
+    layout: RegisterLayout
+    trap_ids: tuple[int, ...]
+
+
+class BaseRegister(ABC):
+    """The abstract class for a register."""
+
+    @abstractmethod
+    def __init__(self, qubits: Mapping[Any, ArrayLike], **kwargs: Any):
+        """Initializes a custom Register."""
+        if not isinstance(qubits, dict):
+            raise TypeError(
+                "The qubits have to be stored in a dictionary "
+                "matching qubit ids to position coordinates."
+            )
+        if not qubits:
+            raise ValueError(
+                "Cannot create a Register with an empty qubit " "dictionary."
+            )
+        self._ids: tuple[QubitId, ...] = tuple(qubits.keys())
+        self._coords = [np.array(v, dtype=float) for v in qubits.values()]
+        self._dim = self._coords[0].size
+        self._layout_info: Optional[_LayoutInfo] = None
+        if kwargs:
+            if kwargs.keys() != {"layout", "trap_ids"}:
+                raise ValueError(
+                    "If specifying 'kwargs', they must only be 'layout' and "
+                    "'trap_ids'."
+                )
+            layout: RegisterLayout = kwargs["layout"]
+            trap_ids: tuple[int, ...] = tuple(kwargs["trap_ids"])
+            self._validate_layout(layout, trap_ids)
+            self._layout_info = _LayoutInfo(layout, trap_ids)
+
+    @property
+    def qubits(self) -> dict[QubitId, np.ndarray]:
+        """Dictionary of the qubit names and their position coordinates."""
+        return dict(zip(self._ids, self._coords))
+
+    @property
+    def qubit_ids(self) -> tuple[QubitId, ...]:
+        """The qubit IDs of this register."""
+        return self._ids
+
+    @classmethod
+    def from_coordinates(
+        cls: Type[T],
+        coords: np.ndarray,
+        center: bool = True,
+        prefix: Optional[str] = None,
+        labels: Optional[abcSequence[QubitId]] = None,
+    ) -> T:
+        """Creates the register from an array of coordinates.
+
+        Args:
+            coords (ndarray): The coordinates of each qubit to include in the
+                register.
+
+        Keyword args:
+            center(defaut=True): Whether or not to center the entire array
+                around the origin.
+            prefix (str): The prefix for the qubit ids. If defined, each qubit
+                id starts with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+            labels (ArrayLike): The list of qubit ids. If defined, each qubit
+                id will be set to the corresponding value.
+
+        Returns:
+            Register: A register with qubits placed on the given coordinates.
+        """
+        if center:
+            coords = coords - np.mean(coords, axis=0)  # Centers the array
+        if prefix is not None:
+            pre = str(prefix)
+            qubits = {pre + str(i): pos for i, pos in enumerate(coords)}
+            if labels is not None:
+                raise NotImplementedError(
+                    "It is impossible to specify a prefix and "
+                    "a set of labels at the same time"
+                )
+
+        elif labels is not None:
+            if len(coords) != len(labels):
+                raise ValueError(
+                    f"Label length ({len(labels)}) does not"
+                    f"match number of coordinates ({len(coords)})"
+                )
+            qubits = dict(zip(cast(Iterable, labels), coords))
+        else:
+            qubits = dict(cast(Iterable, enumerate(coords)))
+        return cls(qubits)
+
+    def _validate_layout(
+        self, register_layout: RegisterLayout, trap_ids: tuple[int, ...]
+    ) -> None:
+        """Sets the RegisterLayout that originated this register."""
+        trap_coords = register_layout.coords
+        if register_layout.dimensionality != self._dim:
+            raise ValueError(
+                "The RegisterLayout dimensionality is not the same as this "
+                "register's."
+            )
+        if len(set(trap_ids)) != len(trap_ids):
+            raise ValueError("Every 'trap_id' must be a unique integer.")
+
+        if len(trap_ids) != len(self._ids):
+            raise ValueError(
+                "The amount of 'trap_ids' must be equal to the number of atoms"
+                " in the register."
+            )
+
+        for reg_coord, trap_id in zip(self._coords, trap_ids):
+            if np.any(reg_coord != trap_coords[trap_id]):
+                raise ValueError(
+                    "The chosen traps from the RegisterLayout don't match this"
+                    " register's coordinates."
+                )
+
+    @abstractmethod
+    def _to_dict(self) -> dict[str, Any]:
+        qs = dict(zip(self._ids, map(np.ndarray.tolist, self._coords)))
+        if self._layout_info is not None:
+            return obj_to_dict(self, qs, **(self._layout_info._asdict()))
+        return obj_to_dict(self, qs)
+
+    def __eq__(self, other: Any) -> bool:
+        if type(other) is not type(self):
+            return False
+
+        return set(self._ids) == set(other._ids) and all(
+            (
+                np.allclose(  # Accounts for rounding errors
+                    self._coords[i],
+                    other._coords[other._ids.index(id)],
+                )
+                for i, id in enumerate(self._ids)
+            )
+        )
diff --git a/pulser/register/mappable_reg.py b/pulser/register/mappable_reg.py
new file mode 100644
index 000000000..58ef9e052
--- /dev/null
+++ b/pulser/register/mappable_reg.py
@@ -0,0 +1,75 @@
+# Copyright 2022 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.
+"""Allows for a temporary register to exist, when associated with a layout."""
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Any
+
+from pulser.json.utils import obj_to_dict
+
+if TYPE_CHECKING:  # pragma: no cover
+    from pulser.register.base_register import BaseRegister, QubitId
+    from pulser.register.register_layout import RegisterLayout
+
+
+class MappableRegister:
+    """A register with the traps of each qubit still to be defined.
+
+    Args:
+        register_layout (RegisterLayout): The register layout on which this
+            register will be defined.
+        qubit_ids (QubitId): The Ids for the qubits to pre-declare on this
+            register.
+    """
+
+    def __init__(self, register_layout: RegisterLayout, *qubit_ids: QubitId):
+        """Initializes the mappable register."""
+        self._layout = register_layout
+        if len(qubit_ids) > self._layout.max_atom_num:
+            raise ValueError(
+                "The number of required traps is greater than the maximum "
+                "number of qubits allowed for this layout "
+                f"({self._layout.max_atom_num})."
+            )
+        self._qubit_ids = qubit_ids
+
+    @property
+    def qubit_ids(self) -> tuple[QubitId, ...]:
+        """The qubit IDs of this mappable register."""
+        return self._qubit_ids
+
+    def build_register(self, qubits: Mapping[QubitId, int]) -> BaseRegister:
+        """Builds an actual register.
+
+        Args:
+            qubits (Mapping[QubitId, int]): A map between the qubit IDs to use
+                and the layout traps where the qubits will be placed. Qubit IDs
+                declared in the MappableRegister but not defined here will
+                simply be left out of the final register.
+
+        Returns:
+            BaseRegister: The resulting register.
+        """
+        chosen_ids = tuple(qubits.keys())
+        if not set(chosen_ids) <= set(self._qubit_ids):
+            raise ValueError(
+                "All qubits must be labeled with pre-declared qubit IDs."
+            )
+        return self._layout.define_register(
+            *tuple(qubits.values()), qubit_ids=chosen_ids
+        )
+
+    def _to_dict(self) -> dict[str, Any]:
+        return obj_to_dict(self, self._layout, *self._qubit_ids)
diff --git a/pulser/register/register.py b/pulser/register/register.py
new file mode 100644
index 000000000..fe99544d3
--- /dev/null
+++ b/pulser/register/register.py
@@ -0,0 +1,358 @@
+# 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.
+"""Defines the configuration of an array of neutral atoms in 2D."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, Optional
+
+import matplotlib.pyplot as plt
+import numpy as np
+from numpy.typing import ArrayLike
+
+import pulser
+import pulser.register._patterns as patterns
+from pulser.register._reg_drawer import RegDrawer
+from pulser.register.base_register import BaseRegister
+
+
+class Register(BaseRegister, RegDrawer):
+    """A 2D quantum register containing a set of qubits.
+
+    Args:
+        qubits (dict): Dictionary with the qubit names as keys and their
+            position coordinates (in μm) as values
+            (e.g. {'q0':(2, -1, 0), 'q1':(-5, 10, 0), ...}).
+    """
+
+    def __init__(self, qubits: Mapping[Any, ArrayLike], **kwargs: Any):
+        """Initializes a custom Register."""
+        super().__init__(qubits, **kwargs)
+        if any(c.shape != (self._dim,) for c in self._coords) or (
+            self._dim != 2
+        ):
+            raise ValueError(
+                "All coordinates must be specified as vectors of size 2."
+            )
+
+    @classmethod
+    def square(
+        cls, side: int, spacing: float = 4.0, prefix: Optional[str] = None
+    ) -> Register:
+        """Initializes the register with the qubits in a square array.
+
+        Args:
+            side (int): Side of the square in number of qubits.
+
+        Keyword args:
+            spacing(float): The distance between neighbouring qubits in μm.
+            prefix (str): The prefix for the qubit ids. If defined, each qubit
+                id starts with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+
+        Returns:
+            Register: A register with qubits placed in a square array.
+        """
+        # Check side
+        if side < 1:
+            raise ValueError(
+                f"The number of atoms per side (`side` = {side})"
+                " must be greater than or equal to 1."
+            )
+
+        return cls.rectangle(side, side, spacing=spacing, prefix=prefix)
+
+    @classmethod
+    def rectangle(
+        cls,
+        rows: int,
+        columns: int,
+        spacing: float = 4.0,
+        prefix: Optional[str] = None,
+    ) -> Register:
+        """Initializes the register with the qubits in a rectangular array.
+
+        Args:
+            rows (int): Number of rows.
+            columns (int): Number of columns.
+
+        Keyword args:
+            spacing(float): The distance between neighbouring qubits in μm.
+            prefix (str): The prefix for the qubit ids. If defined, each qubit
+                id starts with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...)
+
+        Returns:
+            Register: A register with qubits placed in a rectangular array.
+        """
+        # Check rows
+        if rows < 1:
+            raise ValueError(
+                f"The number of rows (`rows` = {rows})"
+                " must be greater than or equal to 1."
+            )
+
+        # Check columns
+        if columns < 1:
+            raise ValueError(
+                f"The number of columns (`columns` = {columns})"
+                " must be greater than or equal to 1."
+            )
+
+        # Check spacing
+        if spacing <= 0.0:
+            raise ValueError(
+                f"Spacing between atoms (`spacing` = {spacing})"
+                " must be greater than 0."
+            )
+
+        coords = patterns.square_rect(rows, columns) * spacing
+
+        return cls.from_coordinates(coords, center=True, prefix=prefix)
+
+    @classmethod
+    def triangular_lattice(
+        cls,
+        rows: int,
+        atoms_per_row: int,
+        spacing: float = 4.0,
+        prefix: Optional[str] = None,
+    ) -> Register:
+        """Initializes the register with the qubits in a triangular lattice.
+
+        Initializes the qubits in a triangular lattice pattern, more
+        specifically a triangular lattice with horizontal rows, meaning the
+        triangles are pointing up and down.
+
+        Args:
+            rows (int): Number of rows.
+            atoms_per_row (int): Number of atoms per row.
+
+        Keyword args:
+            spacing(float): The distance between neighbouring qubits in μm.
+            prefix (str): The prefix for the qubit ids. If defined, each qubit
+                id starts with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+
+        Returns:
+            Register: A register with qubits placed in a triangular lattice.
+        """
+        # Check rows
+        if rows < 1:
+            raise ValueError(
+                f"The number of rows (`rows` = {rows})"
+                " must be greater than or equal to 1."
+            )
+
+        # Check atoms per row
+        if atoms_per_row < 1:
+            raise ValueError(
+                "The number of atoms per row"
+                f" (`atoms_per_row` = {atoms_per_row})"
+                " must be greater than or equal to 1."
+            )
+
+        # Check spacing
+        if spacing <= 0.0:
+            raise ValueError(
+                f"Spacing between atoms (`spacing` = {spacing})"
+                " must be greater than 0."
+            )
+
+        coords = patterns.triangular_rect(rows, atoms_per_row) * spacing
+
+        return cls.from_coordinates(coords, center=True, prefix=prefix)
+
+    @classmethod
+    def hexagon(
+        cls, layers: int, spacing: float = 4.0, prefix: Optional[str] = None
+    ) -> Register:
+        """Initializes the register with the qubits in a hexagonal layout.
+
+        Args:
+            layers (int): Number of layers around a central atom.
+
+        Keyword args:
+            spacing(float): The distance between neighbouring qubits in μm.
+            prefix (str): The prefix for the qubit ids. If defined, each qubit
+                id starts with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+
+        Returns:
+            Register: A register with qubits placed in a hexagonal layout.
+        """
+        # Check layers
+        if layers < 1:
+            raise ValueError(
+                f"The number of layers (`layers` = {layers})"
+                " must be greater than or equal to 1."
+            )
+
+        # Check spacing
+        if spacing <= 0.0:
+            raise ValueError(
+                f"Spacing between atoms (`spacing` = {spacing})"
+                " must be greater than 0."
+            )
+
+        n_atoms = 1 + 3 * (layers**2 + layers)
+        coords = patterns.triangular_hex(n_atoms) * spacing
+
+        return cls.from_coordinates(coords, center=False, prefix=prefix)
+
+    @classmethod
+    def max_connectivity(
+        cls,
+        n_qubits: int,
+        device: pulser.devices._device_datacls.Device,
+        spacing: float = None,
+        prefix: str = None,
+    ) -> Register:
+        """Initializes the register with maximum connectivity for a given device.
+
+        In order to maximize connectivity, the basic pattern is the triangle.
+        Atoms are first arranged as layers of hexagons around a central atom.
+        Extra atoms are placed in such a manner that C3 and C6 rotational
+        symmetries are enforced as often as possible.
+
+        Args:
+            n_qubits (int): Number of qubits.
+            device (Device): The device whose constraints must be obeyed.
+
+        Keyword args:
+            spacing(float): The distance between neighbouring qubits in μm.
+                If omitted, the minimal distance for the device is used.
+            prefix (str): The prefix for the qubit ids. If defined, each qubit
+                id starts with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+
+        Returns:
+            Register: A register with qubits placed for maximum connectivity.
+        """
+        # Check device
+        if not isinstance(device, pulser.devices._device_datacls.Device):
+            raise TypeError(
+                "'device' must be of type 'Device'. Import a valid"
+                " device from 'pulser.devices'."
+            )
+
+        # Check number of qubits (1 or above)
+        if n_qubits < 1:
+            raise ValueError(
+                f"The number of qubits (`n_qubits` = {n_qubits})"
+                " must be greater than or equal to 1."
+            )
+
+        # Check number of qubits (less than the max number of atoms)
+        if n_qubits > device.max_atom_num:
+            raise ValueError(
+                f"The number of qubits (`n_qubits` = {n_qubits})"
+                " must be less than or equal to the maximum"
+                " number of atoms supported by this device"
+                f" ({device.max_atom_num})."
+            )
+
+        # Default spacing or check minimal distance
+        if spacing is None:
+            spacing = device.min_atom_distance
+        elif spacing < device.min_atom_distance:
+            raise ValueError(
+                f"Spacing between atoms (`spacing = `{spacing})"
+                " must be greater than or equal to the minimal"
+                " distance supported by this device"
+                f" ({device.min_atom_distance})."
+            )
+
+        coords = patterns.triangular_hex(n_qubits) * spacing
+
+        return cls.from_coordinates(coords, center=False, prefix=prefix)
+
+    def rotate(self, degrees: float) -> None:
+        """Rotates the array around the origin by the given angle.
+
+        Args:
+            degrees (float): The angle of rotation in degrees.
+        """
+        if self._layout_info is not None:
+            raise TypeError(
+                "A register defined from a RegisterLayout cannot be rotated."
+            )
+        theta = np.deg2rad(degrees)
+        rot = np.array(
+            [[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]
+        )
+        self._coords = [rot @ v for v in self._coords]
+
+    def draw(
+        self,
+        with_labels: bool = True,
+        blockade_radius: Optional[float] = None,
+        draw_graph: bool = True,
+        draw_half_radius: bool = False,
+        fig_name: str = None,
+        kwargs_savefig: dict = {},
+    ) -> None:
+        """Draws the entire register.
+
+        Keyword Args:
+            with_labels(bool, default=True): If True, writes the qubit ID's
+                next to each qubit.
+            blockade_radius(float, default=None): The distance (in μm) between
+                atoms below the Rydberg blockade effect occurs.
+            draw_half_radius(bool, default=False): Whether or not to draw the
+                half the blockade radius surrounding each atoms. If `True`,
+                requires `blockade_radius` to be defined.
+            draw_graph(bool, default=True): Whether or not to draw the
+                interaction between atoms as edges in a graph. Will only draw
+                if the `blockade_radius` is defined.
+            fig_name(str, default=None): The name on which to save the figure.
+                If None the figure will not be saved.
+            kwargs_savefig(dict, default={}): Keywords arguments for
+                ``matplotlib.pyplot.savefig``. Not applicable if `fig_name`
+                is ``None``.
+
+        Note:
+            When drawing half the blockade radius, we say there is a blockade
+            effect between atoms whenever their respective circles overlap.
+            This representation is preferred over drawing the full Rydberg
+            radius because it helps in seeing the interactions between atoms.
+        """
+        super()._draw_checks(
+            len(self._ids),
+            blockade_radius=blockade_radius,
+            draw_graph=draw_graph,
+            draw_half_radius=draw_half_radius,
+        )
+        pos = np.array(self._coords)
+        fig, ax = self._initialize_fig_axes(
+            pos,
+            blockade_radius=blockade_radius,
+            draw_half_radius=draw_half_radius,
+        )
+        super()._draw_2D(
+            ax,
+            pos,
+            self._ids,
+            with_labels=with_labels,
+            blockade_radius=blockade_radius,
+            draw_graph=draw_graph,
+            draw_half_radius=draw_half_radius,
+        )
+        if fig_name is not None:
+            plt.savefig(fig_name, **kwargs_savefig)
+        plt.show()
+
+    def _to_dict(self) -> dict[str, Any]:
+        return super()._to_dict()
diff --git a/pulser/register/register3d.py b/pulser/register/register3d.py
new file mode 100644
index 000000000..746cb3b41
--- /dev/null
+++ b/pulser/register/register3d.py
@@ -0,0 +1,240 @@
+# Copyright 2022 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.
+"""Defines the configuration of an array of neutral atoms in 3D."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, Optional
+
+import matplotlib.pyplot as plt
+import numpy as np
+from numpy.typing import ArrayLike
+
+from pulser.register._reg_drawer import RegDrawer
+from pulser.register.base_register import BaseRegister
+from pulser.register.register import Register
+
+
+class Register3D(BaseRegister, RegDrawer):
+    """A 3D quantum register containing a set of qubits.
+
+    Args:
+        qubits (dict): Dictionary with the qubit names as keys and their
+            position coordinates (in μm) as values
+            (e.g. {'q0':(2, -1, 0), 'q1':(-5, 10, 0), ...}).
+    """
+
+    def __init__(self, qubits: Mapping[Any, ArrayLike], **kwargs: Any):
+        """Initializes a custom Register."""
+        super().__init__(qubits, **kwargs)
+        if any(c.shape != (self._dim,) for c in self._coords) or (
+            self._dim != 3
+        ):
+            raise ValueError(
+                "All coordinates must be specified as vectors of size 3."
+            )
+
+    @classmethod
+    def cubic(
+        cls, side: int, spacing: float = 4.0, prefix: Optional[str] = None
+    ) -> Register3D:
+        """Initializes the register with the qubits in a cubic array.
+
+        Args:
+            side (int): Side of the cube in number of qubits.
+
+        Keyword args:
+            spacing(float): The distance between neighbouring qubits in μm.
+            prefix (str): The prefix for the qubit ids. If defined, each qubit
+                id starts with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+
+        Returns:
+            Register3D : A 3D register with qubits placed in a cubic array.
+        """
+        # Check side
+        if side < 1:
+            raise ValueError(
+                f"The number of atoms per side (`side` = {side})"
+                " must be greater than or equal to 1."
+            )
+
+        return cls.cuboid(side, side, side, spacing=spacing, prefix=prefix)
+
+    @classmethod
+    def cuboid(
+        cls,
+        rows: int,
+        columns: int,
+        layers: int,
+        spacing: float = 4.0,
+        prefix: Optional[str] = None,
+    ) -> Register3D:
+        """Initializes the register with the qubits in a cuboid array.
+
+        Args:
+            rows (int): Number of rows.
+            columns (int): Number of columns.
+            layers (int): Number of layers.
+
+        Keyword args:
+            spacing(float): The distance between neighbouring qubits in μm.
+            prefix (str): The prefix for the qubit ids. If defined, each qubit
+                id starts with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...)
+
+        Returns:
+            Register3D : A 3D register with qubits placed in a cuboid array.
+        """
+        # Check rows
+        if rows < 1:
+            raise ValueError(
+                f"The number of rows (`rows` = {rows})"
+                " must be greater than or equal to 1."
+            )
+
+        # Check columns
+        if columns < 1:
+            raise ValueError(
+                f"The number of columns (`columns` = {columns})"
+                " must be greater than or equal to 1."
+            )
+
+        # Check layers
+        if layers < 1:
+            raise ValueError(
+                f"The number of layers (`layers` = {layers})"
+                " must be greater than or equal to 1."
+            )
+
+        # Check spacing
+        if spacing <= 0.0:
+            raise ValueError(
+                f"Spacing between atoms (`spacing` = {spacing})"
+                " must be greater than 0."
+            )
+
+        coords = (
+            np.array(
+                [
+                    (x, y, z)
+                    for z in range(layers)
+                    for y in range(rows)
+                    for x in range(columns)
+                ],
+                dtype=float,
+            )
+            * spacing
+        )
+
+        return cls.from_coordinates(coords, center=True, prefix=prefix)
+
+    def to_2D(self, tol_width: float = 0.0) -> Register:
+        """Converts a Register3D into a Register (if possible).
+
+        Args:
+            tol_width (float): The allowed transverse width of
+            the register to be projected.
+
+        Returns:
+            Register: Returns a 2D register with the coordinates of the atoms
+                in a plane, if they are coplanar.
+
+        Raises:
+            ValueError: If the atoms are not coplanar.
+        """
+        coords = np.array(self._coords)
+
+        barycenter = coords.sum(axis=0) / coords.shape[0]
+        # run SVD
+        u, s, vh = np.linalg.svd(coords - barycenter)
+        e_z = vh[2, :]
+        perp_extent = [e_z.dot(r) for r in coords]
+        width = np.ptp(perp_extent)
+        # A set of vector is coplanar if one of the Singular values is 0
+        if width > tol_width:
+            raise ValueError(
+                f"Atoms are not coplanar (`width` = {width:#.2f} µm)"
+            )
+        else:
+            e_x = vh[0, :]
+            e_y = vh[1, :]
+            coords_2D = np.array(
+                [np.array([e_x.dot(r), e_y.dot(r)]) for r in coords]
+            )
+            return Register.from_coordinates(coords_2D, labels=self._ids)
+
+    def draw(
+        self,
+        with_labels: bool = False,
+        blockade_radius: Optional[float] = None,
+        draw_graph: bool = True,
+        draw_half_radius: bool = False,
+        projection: bool = False,
+        fig_name: str = None,
+        kwargs_savefig: dict = {},
+    ) -> None:
+        """Draws the entire register.
+
+        Keyword Args:
+            with_labels(bool, default=True): If True, writes the qubit ID's
+                next to each qubit.
+            blockade_radius(float, default=None): The distance (in μm) between
+                atoms below the Rydberg blockade effect occurs.
+            draw_half_radius(bool, default=False): Whether or not to draw the
+                half the blockade radius surrounding each atoms. If `True`,
+                requires `blockade_radius` to be defined.
+            draw_graph(bool, default=True): Whether or not to draw the
+                interaction between atoms as edges in a graph. Will only draw
+                if the `blockade_radius` is defined.
+            projection(bool, default=False): Whether to draw a 2D projection
+                instead of a perspective view.
+            fig_name(str, default=None): The name on which to save the figure.
+                If None the figure will not be saved.
+            kwargs_savefig(dict, default={}): Keywords arguments for
+                ``matplotlib.pyplot.savefig``. Not applicable if `fig_name`
+                is ``None``.
+
+        Note:
+            When drawing half the blockade radius, we say there is a blockade
+            effect between atoms whenever their respective circles overlap.
+            This representation is preferred over drawing the full Rydberg
+            radius because it helps in seeing the interactions between atoms.
+        """
+        super()._draw_checks(
+            len(self._ids),
+            blockade_radius=blockade_radius,
+            draw_graph=draw_graph,
+            draw_half_radius=draw_half_radius,
+        )
+
+        pos = np.array(self._coords)
+
+        self._draw_3D(
+            pos,
+            self._ids,
+            projection=projection,
+            with_labels=with_labels,
+            blockade_radius=blockade_radius,
+            draw_graph=draw_graph,
+            draw_half_radius=draw_half_radius,
+        )
+
+        if fig_name is not None:
+            plt.savefig(fig_name, **kwargs_savefig)
+        plt.show()
+
+    def _to_dict(self) -> dict[str, Any]:
+        return super()._to_dict()
diff --git a/pulser/register/register_layout.py b/pulser/register/register_layout.py
new file mode 100644
index 000000000..f51b8b5d8
--- /dev/null
+++ b/pulser/register/register_layout.py
@@ -0,0 +1,299 @@
+# Copyright 2022 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.
+"""Defines a generic Register layout, from which a Register can be created."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence as abcSequence
+from dataclasses import dataclass
+from hashlib import sha256
+from sys import version_info
+from typing import Any, Optional, cast
+
+import matplotlib.pyplot as plt
+import numpy as np
+from numpy.typing import ArrayLike
+
+from pulser.json.utils import obj_to_dict
+from pulser.register._reg_drawer import RegDrawer
+from pulser.register.base_register import BaseRegister, QubitId
+from pulser.register.mappable_reg import MappableRegister
+from pulser.register.register import Register
+from pulser.register.register3d import Register3D
+
+if version_info[:2] >= (3, 8):  # pragma: no cover
+    from functools import cached_property
+else:  # pragma: no cover
+    try:
+        from backports.cached_property import cached_property  # type: ignore
+    except ImportError:
+        raise ImportError(
+            "Using pulser with Python version 3.7 requires the"
+            " `backports.cached-property` module. Install it by running"
+            " `pip install backports.cached-property`."
+        )
+
+COORD_PRECISION = 6
+
+
+@dataclass(repr=False, eq=False, frozen=True)
+class RegisterLayout(RegDrawer):
+    """A layout of traps out of which registers can be defined.
+
+    The traps are always sorted under the same convention: ascending order
+    along x, then along y, then along z (if applicable). Respecting this order,
+    the traps are then numbered starting from 0.
+
+    Args:
+        trap_coordinates(ArrayLike): The trap coordinates defining the layout.
+    """
+
+    trap_coordinates: ArrayLike
+
+    def __post_init__(self) -> None:
+        shape = np.array(self.trap_coordinates).shape
+        if len(shape) != 2:
+            raise ValueError(
+                "'trap_coordinates' must be an array or list of coordinates."
+            )
+        if shape[1] not in (2, 3):
+            raise ValueError(
+                f"Each coordinate must be of size 2 or 3, not {shape[1]}."
+            )
+
+    @property
+    def traps_dict(self) -> dict:
+        """Mapping between trap IDs and coordinates."""
+        return dict(enumerate(self.coords))
+
+    @cached_property  # Acts as an attribute in a frozen dataclass
+    def _coords(self) -> np.ndarray:
+        coords = np.array(self.trap_coordinates, dtype=float)
+        # Sorting the coordinates 1st left to right, 2nd bottom to top
+        rounded_coords = np.round(coords, decimals=COORD_PRECISION)
+        dims = rounded_coords.shape[1]
+        sorter = [rounded_coords[:, i] for i in range(dims - 1, -1, -1)]
+        sorting = np.lexsort(tuple(sorter))
+        return cast(np.ndarray, rounded_coords[sorting])
+
+    @cached_property  # Acts as an attribute in a frozen dataclass
+    def _coords_to_traps(self) -> dict[tuple[float, ...], int]:
+        return {tuple(coord): id for id, coord in self.traps_dict.items()}
+
+    @property
+    def coords(self) -> np.ndarray:
+        """The sorted trap coordinates."""
+        # Copies to prevent direct access to self._coords
+        return self._coords.copy()
+
+    @property
+    def number_of_traps(self) -> int:
+        """The number of traps in the layout."""
+        return len(self._coords)
+
+    @property
+    def max_atom_num(self) -> int:
+        """Maximum number of atoms that can be trapped to form a Register."""
+        return self.number_of_traps // 2
+
+    @property
+    def dimensionality(self) -> int:
+        """The dimensionality of the layout (2 or 3)."""
+        return self._coords.shape[1]
+
+    def get_traps_from_coordinates(self, *coordinates: ArrayLike) -> list[int]:
+        """Finds the trap ID for a given set of trap coordinates.
+
+        Args:
+            *coordinates (ArrayLike): The coordinates to return the trap IDs.
+
+        Returns
+            list[int]: The list of trap IDs corresponding to the coordinates.
+        """
+        traps = []
+        rounded_coords = np.round(
+            cast(ArrayLike, coordinates), decimals=COORD_PRECISION
+        )
+        for coord, rounded in zip(coordinates, rounded_coords):
+            key = tuple(rounded)
+            if key not in self._coords_to_traps:
+                raise ValueError(
+                    f"The coordinate '{coord!s}' is not a part of the "
+                    "RegisterLayout."
+                )
+            traps.append(self._coords_to_traps[key])
+        return traps
+
+    def define_register(
+        self, *trap_ids: int, qubit_ids: Optional[abcSequence[QubitId]] = None
+    ) -> BaseRegister:
+        """Defines a register from selected traps.
+
+        Args:
+            *trap_ids (int): The trap IDs selected to form the Register.
+            qubit_ids (Optional[abcSequence[QubitId]] = None): A sequence of
+                unique qubit IDs to associated to the selected traps. Must be
+                of the same length as the selected traps.
+
+        Returns:
+            BaseRegister: The respective register instance.
+        """
+        trap_ids_set = set(trap_ids)
+
+        if len(trap_ids_set) != len(trap_ids):
+            raise ValueError("Every 'trap_id' must be a unique integer.")
+
+        if not trap_ids_set.issubset(self.traps_dict):
+            raise ValueError(
+                "All 'trap_ids' must correspond to the ID of a trap."
+            )
+
+        if qubit_ids:
+            if len(set(qubit_ids)) != len(qubit_ids):
+                raise ValueError(
+                    "'qubit_ids' must be a sequence of unique IDs."
+                )
+            if len(qubit_ids) != len(trap_ids):
+                raise ValueError(
+                    "'qubit_ids' must have the same size as the number of "
+                    f"provided 'trap_ids' ({len(trap_ids)})."
+                )
+
+        if len(trap_ids) > self.max_atom_num:
+            raise ValueError(
+                "The number of required traps is greater than the maximum "
+                "number of qubits allowed for this layout "
+                f"({self.max_atom_num})."
+            )
+        ids = (
+            qubit_ids if qubit_ids else [f"q{i}" for i in range(len(trap_ids))]
+        )
+        coords = self._coords[list(trap_ids)]
+        qubits = dict(zip(ids, coords))
+
+        reg_class = Register3D if self.dimensionality == 3 else Register
+        reg = reg_class(qubits, layout=self, trap_ids=trap_ids)
+        return reg
+
+    def draw(
+        self,
+        blockade_radius: Optional[float] = None,
+        draw_graph: bool = False,
+        draw_half_radius: bool = False,
+        projection: bool = True,
+    ) -> None:
+        """Draws the entire register layout.
+
+        Keyword Args:
+            blockade_radius(float, default=None): The distance (in μm) between
+                atoms below which the Rydberg blockade effect occurs.
+            draw_half_radius(bool, default=False): Whether or not to draw
+                half the blockade radius surrounding each trap. If `True`,
+                requires `blockade_radius` to be defined.
+            draw_graph(bool, default=True): Whether or not to draw the
+                interaction between atoms as edges in a graph. Will only draw
+                if the `blockade_radius` is defined.
+            projection(bool, default=True): If the layout is in 3D, draws it
+                as projections on different planes.
+
+        Note:
+            When drawing half the blockade radius, we say there is a blockade
+            effect between atoms whenever their respective circles overlap.
+            This representation is preferred over drawing the full Rydberg
+            radius because it helps in seeing the interactions between atoms.
+        """
+        coords = self.coords
+        self._draw_checks(
+            self.number_of_traps,
+            blockade_radius=blockade_radius,
+            draw_graph=draw_graph,
+            draw_half_radius=draw_half_radius,
+        )
+        ids = list(range(self.number_of_traps))
+        if self.dimensionality == 2:
+            fig, ax = self._initialize_fig_axes(
+                coords,
+                blockade_radius=blockade_radius,
+                draw_half_radius=draw_half_radius,
+            )
+            self._draw_2D(
+                ax,
+                coords,
+                ids,
+                blockade_radius=blockade_radius,
+                draw_graph=draw_graph,
+                draw_half_radius=draw_half_radius,
+                are_traps=True,
+            )
+        elif self.dimensionality == 3:
+            self._draw_3D(
+                coords,
+                ids,
+                projection=projection,
+                with_labels=True,
+                blockade_radius=blockade_radius,
+                draw_graph=draw_graph,
+                draw_half_radius=draw_half_radius,
+                are_traps=True,
+            )
+        plt.show()
+
+    def make_mappable_register(
+        self, n_qubits: int, prefix: str = "q"
+    ) -> MappableRegister:
+        """Creates a mappable register associated with this layout.
+
+        A mappable register is a register whose atoms' positions have not yet
+        been defined. It can be used to create a sequence whose register is
+        only defined when it is built. Note that not all the qubits 'reserved'
+        in a MappableRegister need to be in the final Register, as qubits not
+        associated with trap IDs won't be included. If you intend on defining
+        registers of different sizes from the same mappable register, reserve
+        as many qubits as you need for your largest register.
+
+        Args:
+            n_qubits(int): The number of qubits to reserve in the mappable
+                register.
+            prefix (str): The prefix for the qubit ids. Each qubit ID starts
+                with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+        """
+        qubit_ids = [f"{prefix}{i}" for i in range(n_qubits)]
+        return MappableRegister(self, *qubit_ids)
+
+    def _safe_hash(self) -> bytes:
+        # Include dimensionality because the array is flattened with tobytes()
+        hash = sha256(bytes(self.dimensionality))
+        hash.update(self.coords.tobytes())
+        return hash.digest()
+
+    def __eq__(self, other: Any) -> bool:
+        if not isinstance(other, RegisterLayout):
+            return False
+        return self._safe_hash() == other._safe_hash()
+
+    def __hash__(self) -> int:
+        return hash(self._safe_hash())
+
+    def __repr__(self) -> str:
+        return f"RegisterLayout_{self._safe_hash().hex()}"
+
+    def _to_dict(self) -> dict[str, Any]:
+        # Allows for serialization of subclasses without a special _to_dict()
+        return obj_to_dict(
+            self,
+            self.trap_coordinates,
+            _module=__name__,
+            _name="RegisterLayout",
+        )
diff --git a/pulser/register/special_layouts.py b/pulser/register/special_layouts.py
new file mode 100644
index 000000000..27f60ee45
--- /dev/null
+++ b/pulser/register/special_layouts.py
@@ -0,0 +1,175 @@
+# Copyright 2022 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.
+"""Special register layouts defined for convenience."""
+
+from __future__ import annotations
+
+from typing import Any, cast
+
+import pulser.register._patterns as patterns
+from pulser.json.utils import obj_to_dict
+from pulser.register import Register
+from pulser.register.register_layout import RegisterLayout
+
+
+class SquareLatticeLayout(RegisterLayout):
+    """A RegisterLayout with a square lattice pattern in a rectangular shape.
+
+    Args:
+        rows (int): The number of rows of traps.
+        columns (int): The number of columns of traps.
+        spacing (int): The distance between neighbouring traps (in µm).
+    """
+
+    def __init__(self, rows: int, columns: int, spacing: int):
+        """Initializes a SquareLatticeLayout."""
+        self._rows = int(rows)
+        self._columns = int(columns)
+        self._spacing = int(spacing)
+        super().__init__(
+            patterns.square_rect(self._rows, self._columns) * self._spacing
+        )
+
+    def square_register(self, side: int, prefix: str = "q") -> Register:
+        """Defines a register with a square shape.
+
+        Args:
+            side (int): The length of the square's side, in number of atoms.
+            prefix (str): The prefix for the qubit ids. Each qubit ID starts
+                with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+
+        Returns:
+            Register: The register instance created from this layout.
+        """
+        return self.rectangular_register(side, side, prefix=prefix)
+
+    def rectangular_register(
+        self,
+        rows: int,
+        columns: int,
+        prefix: str = "q",
+    ) -> Register:
+        """Defines a register with a rectangular shape.
+
+        Args:
+            rows (int): The number of rows in the register.
+            columns (int): The number of columns in the register.
+            prefix (str): The prefix for the qubit ids. Each qubit ID starts
+                with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+
+        Returns:
+            Register: The register instance created from this layout.
+        """
+        if rows * columns > self.max_atom_num:
+            raise ValueError(
+                f"A '{rows} x {columns}' array has more atoms than those "
+                f"available in this SquareLatticeLayout ({self.max_atom_num})."
+            )
+        if rows > self._rows or columns > self._columns:
+            raise ValueError(
+                f"A '{rows} x {columns}' array doesn't fit a "
+                f"{self._rows}x{self._columns} SquareLatticeLayout."
+            )
+        points = patterns.square_rect(rows, columns) * self._spacing
+        trap_ids = self.get_traps_from_coordinates(*points)
+        qubit_ids = [f"{prefix}{i}" for i in range(len(trap_ids))]
+        return cast(
+            Register, self.define_register(*trap_ids, qubit_ids=qubit_ids)
+        )
+
+    def __str__(self) -> str:
+        return (
+            f"SquareLatticeLayout({self._rows}x{self._columns}, "
+            f"{self._spacing}µm)"
+        )
+
+    def _to_dict(self) -> dict[str, Any]:
+        return obj_to_dict(self, self._rows, self._columns, self._spacing)
+
+
+class TriangularLatticeLayout(RegisterLayout):
+    """A RegisterLayout with a triangular lattice pattern in an hexagonal shape.
+
+    Args:
+        n_traps (int): The number of traps in the layout.
+        spacing (int): The distance between neighbouring traps (in µm).
+    """
+
+    def __init__(self, n_traps: int, spacing: int):
+        """Initializes a TriangularLatticeLayout."""
+        self._spacing = int(spacing)
+        super().__init__(patterns.triangular_hex(int(n_traps)) * self._spacing)
+
+    def hexagonal_register(self, n_atoms: int, prefix: str = "q") -> Register:
+        """Defines a register with an hexagonal shape.
+
+        Args:
+            n_atoms (int): The number of atoms in the register.
+            prefix (str): The prefix for the qubit ids. Each qubit ID starts
+                with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+
+        Returns:
+            Register: The register instance created from this layout.
+        """
+        if n_atoms > self.max_atom_num:
+            raise ValueError(
+                f"This RegisterLayout can hold at most {self.max_atom_num} "
+                f"atoms, not '{n_atoms}'."
+            )
+        points = patterns.triangular_hex(n_atoms) * self._spacing
+        trap_ids = self.get_traps_from_coordinates(*points)
+        qubit_ids = [f"{prefix}{i}" for i in range(len(trap_ids))]
+        return cast(
+            Register, self.define_register(*trap_ids, qubit_ids=qubit_ids)
+        )
+
+    def rectangular_register(
+        self, rows: int, atoms_per_row: int, prefix: str = "q"
+    ) -> Register:
+        """Defines a register with a rectangular shape.
+
+        Args:
+            rows (int): The number of rows in the register.
+            atoms_per_row (int): The number of atoms in each row.
+            prefix (str): The prefix for the qubit ids. Each qubit ID starts
+                with the prefix, followed by an int from 0 to N-1
+                (e.g. prefix='q' -> IDs: 'q0', 'q1', 'q2', ...).
+
+        Returns:
+            Register: The register instance created from this layout.
+        """
+        if rows * atoms_per_row > self.max_atom_num:
+            raise ValueError(
+                f"A '{rows} x {atoms_per_row}' rectangular subset of a "
+                "triangular lattice has more atoms than those available in "
+                f"this TriangularLatticeLayout ({self.max_atom_num})."
+            )
+        points = patterns.triangular_rect(rows, atoms_per_row) * self._spacing
+        trap_ids = self.get_traps_from_coordinates(*points)
+        qubit_ids = [f"{prefix}{i}" for i in range(len(trap_ids))]
+        return cast(
+            Register, self.define_register(*trap_ids, qubit_ids=qubit_ids)
+        )
+
+    def __str__(self) -> str:
+        return (
+            f"TriangularLatticeLayout({self.number_of_traps}, "
+            f"{self._spacing}µm)"
+        )
+
+    def _to_dict(self) -> dict[str, Any]:
+        return obj_to_dict(self, self.number_of_traps, self._spacing)
diff --git a/pulser/sequence.py b/pulser/sequence.py
index 23a9dd53a..429263265 100644
--- a/pulser/sequence.py
+++ b/pulser/sequence.py
@@ -15,31 +15,42 @@
 
 from __future__ import annotations
 
-from collections import namedtuple
-from collections.abc import Callable, Generator, Iterable
 import copy
+import json
+import os
+import warnings
+from collections import namedtuple
+from collections.abc import Callable, Generator, Iterable, Mapping
 from functools import wraps
 from itertools import chain
-import json
 from sys import version_info
-from typing import Any, cast, NamedTuple, Optional, Tuple, Union
-import warnings
-import os
+from typing import (
+    Any,
+    NamedTuple,
+    Optional,
+    Tuple,
+    TypeVar,
+    Union,
+    cast,
+    overload,
+)
 
 import matplotlib.pyplot as plt
 import numpy as np
 from numpy.typing import ArrayLike
 
 import pulser
+from pulser._seq_drawer import draw_sequence
 from pulser.channels import Channel
 from pulser.devices import MockDevice
 from pulser.devices._device_datacls import Device
-from pulser.json.coders import PulserEncoder, PulserDecoder
+from pulser.json.coders import PulserDecoder, PulserEncoder
 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 import BaseRegister
-from pulser._seq_drawer import draw_sequence
+from pulser.register.base_register import BaseRegister
+from pulser.register.mappable_reg import MappableRegister
 
 if version_info[:2] >= (3, 8):  # pragma: no cover
     from typing import Literal, get_args
@@ -56,6 +67,7 @@
 
 QubitId = Union[int, str]
 PROTOCOLS = Literal["min-delay", "no-delay", "wait-for-all"]
+F = TypeVar("F", bound=Callable)
 
 
 class _TimeSlot(NamedTuple):
@@ -71,7 +83,7 @@ class _TimeSlot(NamedTuple):
 _Call = namedtuple("_Call", ["name", "args", "kwargs"])
 
 
-def _screen(func: Callable) -> Callable:
+def _screen(func: F) -> F:
     """Blocks the call to a function if the Sequence is parametrized."""
 
     @wraps(func)
@@ -83,10 +95,10 @@ def wrapper(self: Sequence, *args: Any, **kwargs: Any) -> Any:
             )
         return func(self, *args, **kwargs)
 
-    return wrapper
+    return cast(F, wrapper)
 
 
-def _store(func: Callable) -> Callable:
+def _store(func: F) -> F:
     """Stores any Sequence building call for deferred execution."""
 
     @wraps(func)
@@ -122,7 +134,7 @@ def verify_variable(x: Any) -> None:
         func(self, *args, **kwargs)
         storage.append(_Call(func.__name__, args, kwargs))
 
-    return wrapper
+    return cast(F, wrapper)
 
 
 class Sequence:
@@ -149,7 +161,10 @@ class Sequence:
     generated from a single "parametrized" ``Sequence``.
 
     Args:
-        register(BaseRegister): The atom register on which to apply the pulses.
+        register(Union[BaseRegister, MappableRegister]): 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(Device): A valid device in which to execute the Sequence (import
             it from ``pulser.devices``).
 
@@ -158,7 +173,9 @@ class Sequence:
         they are the same for all Sequences built from a parametrized Sequence.
     """
 
-    def __init__(self, register: BaseRegister, device: Device):
+    def __init__(
+        self, register: Union[BaseRegister, MappableRegister], device: Device
+    ):
         """Initializes a new pulse sequence."""
         if not isinstance(device, Device):
             raise TypeError(
@@ -179,9 +196,12 @@ def __init__(self, register: BaseRegister, device: Device):
             warnings.warn(warns_msg, stacklevel=2)
 
         # Checks if register is compatible with the device
-        device.validate_register(register)
+        if isinstance(register, MappableRegister):
+            device.validate_layout(register._layout)
+        else:
+            device.validate_register(register)
 
-        self._register: BaseRegister = register
+        self._register: Union[BaseRegister, MappableRegister] = register
         self._device: Device = device
         self._in_xy: bool = False
         self._mag_field: Optional[tuple[float, float, float]] = None
@@ -193,7 +213,7 @@ def __init__(self, register: BaseRegister, device: Device):
         # Stores the names and dict ids of declared channels
         self._taken_channels: dict[str, str] = {}
         # IDs of all qubits in device
-        self._qids: set[QubitId] = set(self.qubit_info.keys())
+        self._qids: set[QubitId] = set(self._register.qubit_ids)
         # Last time each qubit was used, by basis
         self._last_used: dict[str, dict[QubitId, int]] = {}
         # Last time a target happened, by channel
@@ -213,7 +233,22 @@ def __init__(self, register: BaseRegister, device: Device):
     @property
     def qubit_info(self) -> dict[QubitId, np.ndarray]:
         """Dictionary with the qubit's IDs and positions."""
-        return self._register.qubits
+        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 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)
 
     @property
     def declared_channels(self) -> dict[str, Channel]:
@@ -275,28 +310,63 @@ def is_parametrized(self) -> bool:
         """
         return not self._building
 
+    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:
+            bool: Whether the register is a MappableRegister.
+        """
+        return isinstance(self._register, MappableRegister)
+
     @_screen
-    def get_duration(self, channel: Optional[str] = None) -> int:
+    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.
 
         Keyword Args:
             channel (Optional[str]): A specific channel to return the duration
                 of. If left as None, it will return the duration of the whole
                 sequence.
+            include_fall_time (bool): Whether to include in the duration the
+                extra time needed by the last pulse to finish, if there is
+                modulation.
 
         Returns:
             int: The duration of the channel or sequence, in ns.
         """
         if channel is None:
-            durations = [
-                self._last(ch).tf
-                for ch in self._schedule
-                if self._schedule[ch]
-            ]
-            return 0 if not durations else max(durations)
+            channels = tuple(self._channels.keys())
+            if not channels:
+                return 0
+        else:
+            self._validate_channel(channel)
+            channels = (channel,)
+        last_ts = {}
+        for id in channels:
+            this_chobj = self._channels[id]
+            temp_tf = 0
+            for i, op in enumerate(self._schedule[id][::-1]):
+                if i == 0:
+                    # Start with the last slot found
+                    temp_tf = op.tf
+                    if not include_fall_time:
+                        break
+                if isinstance(op.type, Pulse):
+                    temp_tf = max(
+                        temp_tf, op.tf + op.type.fall_time(this_chobj)
+                    )
+                    break
+                elif temp_tf - op.tf >= 2 * this_chobj.rise_time:
+                    # No pulse behind 'op' with a long enough fall time
+                    break
+            last_ts[id] = temp_tf
 
-        self._validate_channel(channel)
-        return self._last(channel).tf if self._schedule[channel] else 0
+        return max(last_ts.values())
 
     @_screen
     def current_phase_ref(
@@ -317,7 +387,7 @@ def current_phase_ref(
         if qubit not in self._qids:
             raise ValueError(
                 "'qubit' must be the id of a qubit declared in "
-                "this sequence's device."
+                "this sequence's register."
             )
 
         if basis not in self._phase_ref:
@@ -414,10 +484,7 @@ def declare_channel(
         name: str,
         channel_id: str,
         initial_target: Optional[
-            Union[
-                Iterable[Union[QubitId, Parametrized]],
-                Union[QubitId, Parametrized],
-            ]
+            Union[QubitId, Iterable[QubitId], Parametrized]
         ] = None,
     ) -> None:
         """Declares a new channel to the Sequence.
@@ -512,12 +579,31 @@ def declare_channel(
             )
         )
 
+    @overload
+    def declare_variable(
+        self,
+        name: str,
+        *,
+        dtype: Union[type[int], type[float], type[str]] = float,
+    ) -> VariableItem:
+        pass
+
+    @overload
     def declare_variable(
         self,
         name: str,
-        size: int = 1,
+        *,
+        size: int,
         dtype: Union[type[int], type[float], type[str]] = float,
     ) -> Variable:
+        pass
+
+    def declare_variable(
+        self,
+        name: str,
+        size: Optional[int] = None,
+        dtype: Union[type[int], type[float], type[str]] = float,
+    ) -> Union[Variable, VariableItem]:
         """Declare a new variable within this Sequence.
 
         The declared variables can be used to create parametrized versions of
@@ -542,11 +628,23 @@ def declare_variable(
             To avoid confusion, it is recommended to store the returned
             Variable instance in a Python variable with the same name.
         """
+        if name == "qubits":
+            # Necessary because 'qubits' is a keyword arg in self.build()
+            raise ValueError(
+                "'qubits' 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.")
-        var = Variable(name, dtype, size=size)
-        self._variables[name] = var
-        return var
+
+        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
 
     @_store
     def add(
@@ -622,6 +720,25 @@ def add(
         last = self._last(channel)
         t0 = last.tf  # Preliminary ti
         basis = channel_obj.basis
+
+        ph_refs = {self._phase_ref[basis][q].last_phase for q in last.targets}
+        if len(ph_refs) != 1:
+            raise ValueError(
+                "Cannot do a multiple-target pulse on qubits with different "
+                "phase references for the same basis."
+            )
+        else:
+            phase_ref = ph_refs.pop()
+
+        if phase_ref != 0:
+            # Has to recreate the original pulse with a new phase
+            pulse = Pulse(
+                pulse.amplitude,
+                pulse.detuning,
+                pulse.phase + phase_ref,
+                post_phase_shift=pulse.post_phase_shift,
+            )
+
         phase_barriers = [
             self._phase_ref[basis][q].last_time for q in last.targets
         ]
@@ -630,52 +747,53 @@ def add(
             for ch, seq in self._schedule.items():
                 if ch == channel:
                     continue
+                this_chobj = self._channels[ch]
                 for op in self._schedule[ch][::-1]:
-                    if op.tf <= current_max_t:
-                        break
                     if not isinstance(op.type, Pulse):
-                        continue
-                    if op.targets & last.targets or protocol == "wait-for-all":
-                        current_max_t = op.tf
+                        if op.tf + 2 * this_chobj.rise_time <= current_max_t:
+                            # No pulse behind 'op' needing a delay
+                            break
+                    elif (
+                        op.tf + op.type.fall_time(this_chobj) <= current_max_t
+                    ):
+                        break
+                    elif (
+                        op.targets & last.targets or protocol == "wait-for-all"
+                    ):
+                        current_max_t = op.tf + op.type.fall_time(this_chobj)
                         break
-        ti = current_max_t
-        if ti > t0:
-            # Insert a delay
-            delay_duration = ti - t0
 
-            # Delay must not be shorter than the min duration for this channel
-            min_duration = self._channels[channel].min_duration
-            if delay_duration < min_duration:
-                ti += min_duration - delay_duration
-                delay_duration = min_duration
+        delay_duration = current_max_t - t0
+        # Find last pulse and compare phase
+        for op in self._schedule[channel][::-1]:
+            if isinstance(op.type, Pulse):
+                if op.type.phase != pulse.phase:
+                    delay_duration = max(
+                        delay_duration,
+                        # Considers that the last pulse might not be at t0
+                        channel_obj.phase_jump_time - (t0 - op.tf),
+                    )
+                break
 
+        if delay_duration > 0:
+            # Delay must not be shorter than the min duration of this channel
+            # and a multiple of the clock period (forced by validate_duration)
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore")
+                delay_duration = channel_obj.validate_duration(
+                    max(delay_duration, channel_obj.min_duration)
+                )
             self._delay(delay_duration, channel)
 
+        ti = t0 + delay_duration
         tf = ti + pulse.duration
 
-        prs = {self._phase_ref[basis][q].last_phase for q in last.targets}
-        if len(prs) != 1:
-            raise ValueError(
-                "Cannot do a multiple-target pulse on qubits with different "
-                "phase references for the same basis."
-            )
-        else:
-            phase_ref = prs.pop()
-
-        if phase_ref != 0:
-            # Has to recriate the original pulse with a new phase
-            pulse = Pulse(
-                pulse.amplitude,
-                pulse.detuning,
-                pulse.phase + phase_ref,
-                post_phase_shift=pulse.post_phase_shift,
-            )
-
         self._add_to_schedule(channel, _TimeSlot(pulse, ti, tf, last.targets))
 
+        true_finish = tf + pulse.fall_time(channel_obj)
         for qubit in last.targets:
-            if self._last_used[basis][qubit] < tf:
-                self._last_used[basis][qubit] = tf
+            if self._last_used[basis][qubit] < true_finish:
+                self._last_used[basis][qubit] = true_finish
 
         if pulse.post_phase_shift:
             self._phase_shift(
@@ -824,18 +942,37 @@ def align(self, *channels: Union[str, Parametrized]) -> None:
         if self.is_parametrized():
             return
 
-        last_ts = {id: self._last(cast(str, id)).tf for id in channels}
+        channels = cast(Tuple[str], channels)
+        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(delta, cast(str, id))
+                channel_obj = self._channels[id]
+                with warnings.catch_warnings():
+                    warnings.simplefilter("ignore")
+                    delta = channel_obj.validate_duration(
+                        max(delta, channel_obj.min_duration)
+                    )
+                self._delay(delta, id)
 
-    def build(self, **vars: Union[ArrayLike, float, int, str]) -> Sequence:
+    def build(
+        self,
+        *,
+        qubits: Optional[Mapping[QubitId, int]] = None,
+        **vars: Union[ArrayLike, float, int, str],
+    ) -> Sequence:
         """Builds a sequence from the programmed instructions.
 
         Keyword Args:
+            qubits (Optional[Mapping[QubitId, int]]): 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.
@@ -853,13 +990,33 @@ def build(self, **vars: Union[ArrayLike, float, int, str]) -> Sequence:
                 # Build a sequence with specific values for both variables
                 >>> seq1 = seq.build(x=0.5, y=[1, 2, 3])
         """
-        if not self.is_parametrized():
-            warnings.warn(
-                "Building a non-parametrized sequence simply returns"
-                " a copy of itself.",
-                stacklevel=2,
+        # Shallow copy with stored parametrized objects (if any)
+        seq = copy.copy(self)
+
+        if self.is_register_mappable():
+            if qubits is None:
+                raise ValueError(
+                    "'qubits' must be specified when the sequence is created "
+                    "with a MappableRegister."
+                )
+            reg = cast(MappableRegister, self._register).build_register(qubits)
+            self._set_register(seq, reg)
+
+        elif qubits is not None:
+            raise ValueError(
+                "'qubits' must not be specified when the sequence already has "
+                "a concrete register."
             )
-            return copy.copy(self)
+
+        if not self.is_parametrized():
+            if not self.is_register_mappable():
+                warnings.warn(
+                    "Building a non-parametrized sequence simply returns"
+                    " a copy of itself.",
+                    stacklevel=2,
+                )
+            return seq
+
         all_keys, given_keys = self._variables.keys(), vars.keys()
         if given_keys != all_keys:
             invalid_vars = given_keys - all_keys
@@ -880,8 +1037,6 @@ def build(self, **vars: Union[ArrayLike, float, int, str]) -> Sequence:
         for name, value in vars.items():
             self._variables[name]._assign(value)
 
-        # Shallow copy with stored parametrized objects
-        seq = copy.copy(self)
         # Eliminates the source of recursiveness errors
         seq._reset_parametrized()
         # Deepcopy the base sequence (what remains)
@@ -937,9 +1092,8 @@ def deserialize(obj: str, **kwargs: Any) -> Sequence:
             formatted string.
         """
         if "Sequence" not in obj:
-            warnings.warn(
-                "The given JSON formatted string does not encode a Sequence.",
-                stacklevel=2,
+            raise ValueError(
+                "The given JSON formatted string does not encode a Sequence."
             )
 
         return cast(Sequence, json.loads(obj, cls=PulserDecoder, **kwargs))
@@ -947,6 +1101,7 @@ def deserialize(obj: str, **kwargs: Any) -> Sequence:
     @_screen
     def draw(
         self,
+        mode: str = "input+output",
         draw_phase_area: bool = False,
         draw_interp_pts: bool = True,
         draw_phase_shifts: bool = False,
@@ -957,16 +1112,24 @@ def draw(
         """Draws the sequence in its current state.
 
         Keyword Args:
+            mode (str, default="input+output"): 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 (bool): Whether phase and area values need to be
-                shown as text on the plot, defaults to False.
+                shown as text on the plot, defaults to False. Doesn't work in
+                'output' mode.
             draw_interp_pts (bool): When the sequence has pulses with waveforms
                 of type InterpolatedWaveform, draws the points of interpolation
-                on top of the respective waveforms (defaults to True).
+                on top of the respective input waveforms (defaults to True).
+                Doesn't work in 'output' mode.
             draw_phase_shifts (bool): Whether phase shift and reference
                 information should be added to the plot, defaults to False.
             draw_register (bool): 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.
+                qubits masked by the SLM, defaults to False. Can't be set to
+                True if the sequence is defined with a mappable register.
             fig_name(str, default=None): The name on which to save the
                 figure. If `draw_register` is True, both pulses and register
                 will be saved as figures, with a suffix ``_pulses`` and
@@ -981,12 +1144,39 @@ def draw(
             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 = draw_sequence(
             self,
             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,
         )
         if fig_name is not None and draw_register:
             name, ext = os.path.splitext(fig_name)
@@ -1000,7 +1190,7 @@ def _target(
         self, qubits: Union[Iterable[QubitId], QubitId], channel: str
     ) -> None:
         self._validate_channel(channel)
-
+        channel_obj = self._channels[channel]
         try:
             qubits_set = (
                 set(cast(Iterable, qubits))
@@ -1010,12 +1200,12 @@ def _target(
         except TypeError:
             qubits_set = {qubits}
 
-        if self._channels[channel].addressing != "Local":
+        if channel_obj.addressing != "Local":
             raise ValueError("Can only choose target of 'Local' channels.")
-        elif len(qubits_set) > cast(int, self._channels[channel].max_targets):
+        elif len(qubits_set) > cast(int, channel_obj.max_targets):
             raise ValueError(
-                "This channel can target at most "
-                f"{self._channels[channel].max_targets} qubits at a time"
+                f"This channel can target at most {channel_obj.max_targets} "
+                "qubits at a time."
             )
 
         if self.is_parametrized():
@@ -1029,7 +1219,7 @@ def _target(
         elif not qubits_set.issubset(self._qids):
             raise ValueError("All given qubits must belong to the register.")
 
-        basis = self._channels[channel].basis
+        basis = channel_obj.basis
         phase_refs = {self._phase_ref[basis][q].last_phase for q in qubits_set}
         if len(phase_refs) != 1:
             raise ValueError(
@@ -1038,18 +1228,31 @@ def _target(
             )
 
         try:
+            fall_time = self.get_duration(
+                channel, include_fall_time=True
+            ) - self.get_duration(channel)
+            if fall_time > 0:
+                with warnings.catch_warnings():
+                    warnings.simplefilter("ignore")
+                    self.delay(
+                        max(fall_time, channel_obj.min_duration),
+                        channel,
+                    )
+
             last = self._last(channel)
             if last.targets == qubits_set:
                 return
             ti = last.tf
-            retarget = cast(int, self._channels[channel].retarget_time)
+            retarget = cast(int, channel_obj.min_retarget_interval)
             elapsed = ti - self._last_target[channel]
             delta = cast(int, np.clip(retarget - elapsed, 0, retarget))
+            if channel_obj.fixed_retarget_t:
+                delta = max(delta, channel_obj.fixed_retarget_t)
             if delta != 0:
                 with warnings.catch_warnings():
                     warnings.simplefilter("ignore")
-                    delta = self._channels[channel].validate_duration(
-                        16 if delta < 16 else delta
+                    delta = channel_obj.validate_duration(
+                        max(delta, channel_obj.min_duration)
                     )
             tf = ti + delta
 
@@ -1194,6 +1397,30 @@ def _reset_parametrized(self) -> None:
         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)
+        seq._register = reg
+        seq._qids = set(seq.register.qubit_ids)
+        used_qubits = set()
+        for ch, ch_obj in self._channels.items():
+            # Correct the targets of global channels
+            if ch_obj.addressing == "Global":
+                for i, slot in enumerate(self._schedule[ch]):
+                    stored_values = slot._asdict()
+                    stored_values["targets"] = seq._qids
+                    seq._schedule[ch][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 <= seq._qids:
+            raise ValueError(
+                f"Qubits {used_qubits - seq._qids} are being targeted but"
+                " have not been assigned a trap."
+            )
+
 
 class _PhaseTracker:
     """Tracks a phase reference over time."""
diff --git a/pulser/simulation/__init__.py b/pulser/simulation/__init__.py
index c32b17714..3d2ae5021 100644
--- a/pulser/simulation/__init__.py
+++ b/pulser/simulation/__init__.py
@@ -13,5 +13,5 @@
 # limitations under the License.
 """Classes for classical emulation of a Sequence."""
 
-from pulser.simulation.simulation import Simulation
 from pulser.simulation.simconfig import SimConfig
+from pulser.simulation.simulation import Simulation
diff --git a/pulser/simulation/simconfig.py b/pulser/simulation/simconfig.py
index 0222fd9f1..8d175541f 100644
--- a/pulser/simulation/simconfig.py
+++ b/pulser/simulation/simconfig.py
@@ -15,9 +15,9 @@
 
 from __future__ import annotations
 
-from sys import version_info
 from dataclasses import dataclass, field
-from typing import Union, Any
+from sys import version_info
+from typing import Any, Union
 
 import numpy as np
 import qutip
diff --git a/pulser/simulation/simresults.py b/pulser/simulation/simresults.py
index fc30c0079..f8c689462 100644
--- a/pulser/simulation/simresults.py
+++ b/pulser/simulation/simresults.py
@@ -15,17 +15,17 @@
 
 from __future__ import annotations
 
-from collections import Counter
 import collections.abc
 from abc import ABC, abstractmethod
+from collections import Counter
 from functools import lru_cache
-from typing import Optional, Union, cast, Tuple, Mapping
+from typing import Mapping, Optional, Tuple, Union, cast
 
 import matplotlib.pyplot as plt
-import qutip
-from qutip.piqs import isdiagonal
 import numpy as np
+import qutip
 from numpy.typing import ArrayLike
+from qutip.piqs import isdiagonal
 
 
 class SimulationResults(ABC):
diff --git a/pulser/simulation/simulation.py b/pulser/simulation/simulation.py
index aca3ecb19..ba611c016 100644
--- a/pulser/simulation/simulation.py
+++ b/pulser/simulation/simulation.py
@@ -15,30 +15,29 @@
 
 from __future__ import annotations
 
-from typing import Optional, Union, cast, Any
-from collections.abc import Mapping
 import itertools
+import warnings
 from collections import Counter
+from collections.abc import Mapping
 from copy import deepcopy
 from dataclasses import asdict
-import warnings
+from typing import Any, Optional, Union, cast
 
-import qutip
+import matplotlib.pyplot as plt
 import numpy as np
+import qutip
 from numpy.typing import ArrayLike
-import matplotlib.pyplot as plt
 
 from pulser import Pulse, Sequence
+from pulser._seq_drawer import draw_sequence
 from pulser.register import QubitId
+from pulser.sequence import _TimeSlot
+from pulser.simulation.simconfig import SimConfig
 from pulser.simulation.simresults import (
-    SimulationResults,
     CoherentResults,
     NoisyResults,
+    SimulationResults,
 )
-from pulser.simulation.simconfig import SimConfig
-from pulser._seq_drawer import draw_sequence
-from pulser.sequence import _TimeSlot
-
 
 SUPPORTED_NOISE = {
     "ising": {"dephasing", "doppler", "amplitude", "SPAM"},
@@ -83,6 +82,11 @@ def __init__(
                 "The provided sequence has to be a valid "
                 "pulser.Sequence instance."
             )
+        if sequence.is_parametrized() or sequence.is_register_mappable():
+            raise ValueError(
+                "The provided sequence needs to be built to be simulated. Call"
+                " `Sequence.build()` with the necessary parameters."
+            )
         if not sequence._schedule:
             raise ValueError("The provided sequence has no declared channels.")
         if all(sequence._schedule[x][-1].tf == 0 for x in sequence._channels):
@@ -94,6 +98,15 @@ def __init__(
         self._qdict = self._seq.qubit_info
         self._size = len(self._qdict)
         self._tot_duration = self._seq.get_duration()
+
+        # Type hints for attributes defined outside of __init__
+        self.basis_name: str
+        self._config: SimConfig
+        self.op_matrix: dict[str, qutip.Qobj]
+        self.basis: dict[str, qutip.Qobj]
+        self.dim: int
+        self._eval_times_array: np.ndarray
+
         if not (0 < sampling_rate <= 1.0):
             raise ValueError(
                 "The sampling rate (`sampling_rate` = "
@@ -875,6 +888,7 @@ def run(
         def _run_solver() -> CoherentResults:
             """Returns CoherentResults: Object containing evolution results."""
             # Decide if progress bar will be fed to QuTiP solver
+            p_bar: Optional[bool]
             if progress_bar is True:
                 p_bar = True
             elif (progress_bar is False) or (progress_bar is None):
diff --git a/pulser/waveforms.py b/pulser/waveforms.py
index b21ce7759..ccf5ef0c3 100644
--- a/pulser/waveforms.py
+++ b/pulser/waveforms.py
@@ -15,25 +15,26 @@
 
 from __future__ import annotations
 
-from abc import ABC, abstractmethod
 import functools
 import inspect
 import itertools
 import sys
+import warnings
+from abc import ABC, abstractmethod
 from sys import version_info
 from types import FunctionType
-from typing import Any, cast, Optional, Tuple, Union
-import warnings
+from typing import Any, Optional, Tuple, Union, cast
 
-from matplotlib.axes import Axes
 import matplotlib.pyplot as plt
 import numpy as np
-from numpy.typing import ArrayLike
 import scipy.interpolate as interpolate
+from matplotlib.axes import Axes
+from numpy.typing import ArrayLike
 
+from pulser.channels import Channel
+from pulser.json.utils import obj_to_dict
 from pulser.parametrized import Parametrized, ParamObj
 from pulser.parametrized.decorators import parametrize
-from pulser.json.utils import obj_to_dict
 
 if version_info[:2] >= (3, 8):  # pragma: no cover
     from functools import cached_property
@@ -123,11 +124,28 @@ def integral(self) -> float:
         """Integral of the waveform (time in ns, value in rad/µs)."""
         return float(np.sum(self.samples)) * 1e-3  # ns * rad/µs = 1e-3
 
-    def draw(self) -> None:
-        """Draws the waveform."""
-        fig, ax = plt.subplots()
-        self._plot(ax, "rad/µs")
+    def draw(self, output_channel: Optional[Channel] = None) -> None:
+        """Draws the waveform.
 
+        Args:
+            output_channel: The output channel. If given, will draw the
+                modulated waveform on top of the input one.
+        """
+        fig, ax = plt.subplots()
+        if not output_channel:
+            self._plot(ax, "rad/µs")
+        else:
+            self._plot(
+                ax,
+                "rad/µs",
+                label="Input",
+                start_t=self.modulation_buffers(output_channel)[0],
+            )
+            self._plot(
+                ax,
+                channel=output_channel,
+                label="Output",
+            )
         plt.show()
 
     def change_duration(self, new_duration: int) -> Waveform:
@@ -141,6 +159,56 @@ def change_duration(self, new_duration: int) -> Waveform:
             " modifications to its duration."
         )
 
+    def modulated_samples(self, channel: Channel) -> np.ndarray:
+        """The waveform samples as output of a given channel.
+
+        This duration is adjusted according to the minimal buffer times.
+
+        Args:
+            channel (Channel): The channel modulating the waveform.
+
+        Returns:
+            numpy.ndarray: The array of samples after modulation.
+        """
+        start, end = self.modulation_buffers(channel)
+        mod_samples = self._modulated_samples(channel)
+        tr = channel.rise_time
+        trim = slice(tr - start, len(mod_samples) - tr + end)
+        return mod_samples[trim]
+
+    @functools.lru_cache()
+    def modulation_buffers(self, channel: Channel) -> tuple[int, int]:
+        """The minimal buffers needed around a modulated waveform.
+
+        Args:
+            channel (Channel): The channel modulating the waveform.
+
+        Returns:
+            tuple[int, int]: The minimum buffer times at the start and end of
+            the samples, in ns.
+        """
+        if not channel.mod_bandwidth:
+            return 0, 0
+
+        return channel.calc_modulation_buffer(
+            self._samples, self._modulated_samples(channel)
+        )
+
+    @functools.lru_cache()
+    def _modulated_samples(self, channel: Channel) -> np.ndarray:
+        """The waveform samples as output of a given channel.
+
+        This is not adjusted to the minimal buffer times. Use
+        ``Waveform.modulated_samples()`` to get the output already truncated.
+
+        Args:
+            channel (Channel): The channel modulating the waveform.
+
+        Returns:
+            numpy.ndarray: The array of samples after modulation.
+        """
+        return channel.modulate(self._samples)
+
     @abstractmethod
     def _to_dict(self) -> dict[str, Any]:
         pass
@@ -158,7 +226,7 @@ def __getitem__(
     ) -> Union[float, np.ndarray]:
         if isinstance(index_or_slice, slice):
             s: slice = self._check_slice(index_or_slice)
-            return cast(np.ndarray, self._samples[s])
+            return self._samples[s]
         else:
             index: int = self._check_index(index_or_slice)
             return cast(float, self._samples[index])
@@ -229,19 +297,42 @@ def __hash__(self) -> int:
         return hash(tuple(self.samples))
 
     def _plot(
-        self, ax: Axes, ylabel: str, color: Optional[str] = None
+        self,
+        ax: Axes,
+        ylabel: Optional[str] = None,
+        color: Optional[str] = None,
+        channel: Optional[Channel] = None,
+        label: str = "",
+        start_t: int = 0,
     ) -> None:
         ax.set_xlabel("t (ns)")
-        ts = np.arange(self.duration)
+        samples = (
+            self.samples
+            if channel is None
+            else self.modulated_samples(channel)
+        )
+        ts = np.arange(len(samples)) + start_t
+        if not channel and start_t:
+            # Adds zero on both ends to show rise and fall
+            samples = np.pad(samples, 1)
+            # Repeats the times on the edges once
+            ts = np.pad(ts, 1, mode="edge")
+
         if color:
-            ax.set_ylabel(ylabel, color=color, fontsize=14)
-            ax.plot(ts, self.samples, color=color)
+            color_dict = {"color": color}
+            hline_color = color
             ax.tick_params(axis="y", labelcolor=color)
-            ax.axhline(0, color=color, linestyle=":", linewidth=0.5)
         else:
-            ax.set_ylabel(ylabel, fontsize=14)
-            ax.plot(ts, self.samples)
-            ax.axhline(0, color="black", linestyle=":", linewidth=0.5)
+            color_dict = {}
+            hline_color = "black"
+
+        if ylabel:
+            ax.set_ylabel(ylabel, fontsize=14, **color_dict)
+        ax.plot(ts, samples, label=label, **color_dict)
+        ax.axhline(0, color=hline_color, linestyle=":", linewidth=0.5)
+
+        if label:
+            plt.legend()
 
 
 class CompositeWaveform(Waveform):
@@ -635,6 +726,7 @@ def __init__(
         super().__init__(duration)
         self._values = np.array(values, dtype=float)
         if times is not None:
+            times = cast(ArrayLike, times)
             times_ = np.array(times, dtype=float)
             if len(times_) != len(self._values):
                 raise ValueError(
@@ -723,10 +815,26 @@ def change_duration(self, new_duration: int) -> InterpolatedWaveform:
         return InterpolatedWaveform(new_duration, self._values, **self._kwargs)
 
     def _plot(
-        self, ax: Axes, ylabel: str, color: Optional[str] = None
+        self,
+        ax: Axes,
+        ylabel: Optional[str] = None,
+        color: Optional[str] = None,
+        channel: Optional[Channel] = None,
+        label: str = "",
+        start_t: int = 0,
     ) -> None:
-        super()._plot(ax, ylabel, color=color)
-        ax.scatter(self._data_pts[:, 0], self._data_pts[:, 1], c=color)
+        super()._plot(
+            ax,
+            ylabel,
+            color=color,
+            channel=channel,
+            label=label,
+            start_t=start_t,
+        )
+        if not channel:
+            ax.scatter(
+                self._data_pts[:, 0] + start_t, self._data_pts[:, 1], c=color
+            )
 
     def _to_dict(self) -> dict[str, Any]:
         return obj_to_dict(self, self._duration, self._values, **self._kwargs)
@@ -825,6 +933,7 @@ def from_max_val(
         """
         max_val = cast(float, max_val)
         area = cast(float, area)
+        beta = cast(float, beta)
 
         if np.sign(max_val) != np.sign(area):
             raise ValueError(
diff --git a/pyproject.toml b/pyproject.toml
index a8f43fefd..d84cc51b8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,2 +1,6 @@
 [tool.black]
 line-length = 79
+
+[tool.isort]
+profile = "black"
+line_length = 79
diff --git a/requirements.txt b/requirements.txt
index 558fda947..0ba7b5d10 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,21 +1,26 @@
 matplotlib
-numpy >= 1.20, < 1.22
-qutip
+numpy >= 1.20
+qutip >= 4.6.3
 scipy
 
 # version specific
-typing-extensions; python_version == '3.7'
 backports.cached-property; python_version == '3.7'
+typing-extensions; python_version == '3.7'
 
 # tests
-pytest
-pytest-cov
+black
+black[jupyter]
 flake8
 flake8-docstrings
-mypy
-black
+isort
+mypy == 0.921
+pytest
+pytest-cov
+
+# CI
+pre-commit
 
 # tutorials
 notebook
-scikit-optimize
 python-igraph
+scikit-optimize
diff --git a/setup.py b/setup.py
index 378950d0b..5caa815e3 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from setuptools import setup, find_packages
+from setuptools import find_packages, setup
 
 __version__ = ""
 exec(open("pulser/_version.py").read())
@@ -22,9 +22,9 @@
     version=__version__,
     install_requires=[
         "matplotlib",
-        "numpy>=1.20, <1.22",
+        "numpy>=1.20",
         "scipy",
-        "qutip",
+        "qutip>=4.6.3",
     ],
     extras_require={
         ":python_version == '3.7'": [
@@ -33,6 +33,7 @@
         ],
     },
     packages=find_packages(),
+    package_data={"pulser": ["py.typed"]},
     include_package_data=True,
     description="A pulse-level composer for neutral-atom quantum devices.",
     long_description=open("README.md").read(),
@@ -45,4 +46,5 @@
         "Programming Language :: Python :: 3",
     ],
     url="https://github.com/pasqal-io/Pulser",
+    zip_safe=False,
 )
diff --git a/tutorials/advanced_features/Composite Waveforms.ipynb b/tutorials/advanced_features/Composite Waveforms.ipynb
index 3cb38b815..fa26b46b3 100644
--- a/tutorials/advanced_features/Composite Waveforms.ipynb
+++ b/tutorials/advanced_features/Composite Waveforms.ipynb
@@ -16,7 +16,12 @@
     "import numpy as np\n",
     "\n",
     "from pulser import Pulse\n",
-    "from pulser.waveforms import BlackmanWaveform, RampWaveform, CompositeWaveform, ConstantWaveform"
+    "from pulser.waveforms import (\n",
+    "    BlackmanWaveform,\n",
+    "    RampWaveform,\n",
+    "    CompositeWaveform,\n",
+    "    ConstantWaveform,\n",
+    ")"
    ]
   },
   {
@@ -37,7 +42,7 @@
    "outputs": [],
    "source": [
     "# Defining simple waveforms\n",
-    "pi_pulse = BlackmanWaveform(1000, np.pi)   # Blackman pi-pulse of 1us\n",
+    "pi_pulse = BlackmanWaveform(1000, np.pi)  # Blackman pi-pulse of 1us\n",
     "up = RampWaveform(500, 0, 5)\n",
     "down = RampWaveform(500, 5, 0)\n",
     "\n",
diff --git a/tutorials/advanced_features/Interpolated Waveforms.ipynb b/tutorials/advanced_features/Interpolated Waveforms.ipynb
index fdf8ea3f6..c3987ceb2 100644
--- a/tutorials/advanced_features/Interpolated Waveforms.ipynb
+++ b/tutorials/advanced_features/Interpolated Waveforms.ipynb
@@ -64,7 +64,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "ts = np.r_[np.linspace(0., 0.5, num=len(values) - 1), 1]\n",
+    "ts = np.r_[np.linspace(0.0, 0.5, num=len(values) - 1), 1]\n",
     "int_wf_t = InterpolatedWaveform(duration, values, times=ts)\n",
     "int_wf_t.draw()"
    ]
@@ -103,7 +103,9 @@
    },
    "outputs": [],
    "source": [
-    "int_wf3 = InterpolatedWaveform(duration, values, interpolator=\"interp1d\", kind=\"cubic\")\n",
+    "int_wf3 = InterpolatedWaveform(\n",
+    "    duration, values, interpolator=\"interp1d\", kind=\"cubic\"\n",
+    ")\n",
     "int_wf3.draw()"
    ]
   },
@@ -163,7 +165,9 @@
     "det_vals = param_seq.declare_variable(\"det_vals\", size=4, dtype=float)\n",
     "\n",
     "amp_wf = InterpolatedWaveform(1000, amp_vals)\n",
-    "det_wf = InterpolatedWaveform(1000, det_vals, interpolator=\"interp1d\", kind=\"cubic\")\n",
+    "det_wf = InterpolatedWaveform(\n",
+    "    1000, det_vals, interpolator=\"interp1d\", kind=\"cubic\"\n",
+    ")\n",
     "pls = Pulse(amp_wf, det_wf, 0)\n",
     "\n",
     "param_seq.add(pls, \"rydberg_global\")"
@@ -182,7 +186,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq1 = param_seq.build(amp_vals=[0, 2, 1, 2, 0], det_vals = [0, -5, 5, -5])\n",
+    "seq1 = param_seq.build(amp_vals=[0, 2, 1, 2, 0], det_vals=[0, -5, 5, -5])\n",
     "seq1.draw()"
    ]
   },
diff --git a/tutorials/advanced_features/Parametrized Sequences.ipynb b/tutorials/advanced_features/Parametrized Sequences.ipynb
index df69949b5..e0c2273b1 100644
--- a/tutorials/advanced_features/Parametrized Sequences.ipynb
+++ b/tutorials/advanced_features/Parametrized Sequences.ipynb
@@ -35,10 +35,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "reg = Register.square(2, prefix='q')\n",
+    "reg = Register.square(2, prefix=\"q\")\n",
     "seq = Sequence(reg, Chadoq2)\n",
-    "seq.declare_channel('rydberg', 'rydberg_global')\n",
-    "seq.declare_channel('raman', 'raman_local')"
+    "seq.declare_channel(\"rydberg\", \"rydberg_global\")\n",
+    "seq.declare_channel(\"raman\", \"raman_local\")"
    ]
   },
   {
@@ -61,9 +61,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "Omega_max = seq.declare_variable('Omega_max')\n",
-    "ts = seq.declare_variable('ts', size=2, dtype=int)\n",
-    "last_target = seq.declare_variable('last_target', dtype=str)"
+    "Omega_max = seq.declare_variable(\"Omega_max\")\n",
+    "ts = seq.declare_variable(\"ts\", size=2, dtype=int)\n",
+    "last_target = seq.declare_variable(\"last_target\", dtype=str)"
    ]
   },
   {
@@ -85,7 +85,7 @@
     "U = Omega_max / 2.3\n",
     "delta_0 = -6 * U\n",
     "delta_f = 2 * U\n",
-    "t_sweep = (delta_f - delta_0)/(2 * np.pi * 10) * 1000"
+    "t_sweep = (delta_f - delta_0) / (2 * np.pi * 10) * 1000"
    ]
   },
   {
@@ -177,7 +177,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "generic_pulse = Pulse.ConstantPulse(100, 2*np.pi, 2, 0.)\n",
+    "generic_pulse = Pulse.ConstantPulse(100, 2 * np.pi, 2, 0.0)\n",
     "seq.add(generic_pulse, \"rydberg\")\n",
     "seq.target(\"q0\", \"raman\")\n",
     "seq.add(generic_pulse, \"raman\")\n",
@@ -256,7 +256,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "built_seq = seq.build(Omega_max = 2.3 * 2*np.pi,  ts = [200, 500], last_target=\"q3\")\n",
+    "built_seq = seq.build(\n",
+    "    Omega_max=2.3 * 2 * np.pi, ts=[200, 500], last_target=\"q3\"\n",
+    ")\n",
     "built_seq.draw()"
    ]
   },
@@ -273,7 +275,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "alt_seq = seq.build(Omega_max = 2*np.pi,  ts = [400, 100], last_target=\"q2\")\n",
+    "alt_seq = seq.build(Omega_max=2 * np.pi, ts=[400, 100], last_target=\"q2\")\n",
     "alt_seq.draw()"
    ]
   }
diff --git a/tutorials/advanced_features/Phase Shifts and Virtual Z gates.ipynb b/tutorials/advanced_features/Phase Shifts and Virtual Z gates.ipynb
index ff724dcec..3422c18e6 100644
--- a/tutorials/advanced_features/Phase Shifts and Virtual Z gates.ipynb
+++ b/tutorials/advanced_features/Phase Shifts and Virtual Z gates.ipynb
@@ -124,7 +124,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "reg = Register({'q0': (0, 0)})\n",
+    "reg = Register({\"q0\": (0, 0)})\n",
     "device = MockDevice\n",
     "seq = Sequence(reg, device)\n",
     "seq.available_channels"
@@ -136,7 +136,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.declare_channel('ch0', 'raman_local', initial_target = 'q0')"
+    "seq.declare_channel(\"ch0\", \"raman_local\", initial_target=\"q0\")"
    ]
   },
   {
@@ -147,7 +147,8 @@
    "source": [
     "# Defining the waveform for a pi/2 pulse\n",
     "from pulser.waveforms import BlackmanWaveform\n",
-    "pi2_wf = BlackmanWaveform(1000, np.pi/2)   # Duration: 1us, Area: pi/2\n",
+    "\n",
+    "pi2_wf = BlackmanWaveform(1000, np.pi / 2)  # Duration: 1us, Area: pi/2\n",
     "pi2_wf.draw()"
    ]
   },
@@ -160,7 +161,7 @@
    "outputs": [],
    "source": [
     "# 2. Create the pi/2 pulse\n",
-    "pi_2 = Pulse.ConstantDetuning(pi2_wf, detuning=0, phase=np.pi/2)\n",
+    "pi_2 = Pulse.ConstantDetuning(pi2_wf, detuning=0, phase=np.pi / 2)\n",
     "pi_2.draw()"
    ]
   },
@@ -172,11 +173,11 @@
    },
    "outputs": [],
    "source": [
-    "#3. Applying the H gate\n",
+    "# 3. Applying the H gate\n",
     "\n",
-    "seq.add(pi_2, 'ch0')   # The first pi/2-pulse\n",
+    "seq.add(pi_2, \"ch0\")  # The first pi/2-pulse\n",
     "# Now the phase shift of pi on 'q0', for the 'digital' basis, which is usually where phase shifts are useful\n",
-    "seq.phase_shift(np.pi, 'q0', basis='digital') \n",
+    "seq.phase_shift(np.pi, \"q0\", basis=\"digital\")\n",
     "\n",
     "seq.draw(draw_phase_shifts=True)"
    ]
@@ -198,9 +199,11 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "h = Pulse.ConstantDetuning(pi2_wf, detuning=0, phase=np.pi/2, post_phase_shift=np.pi)\n",
+    "h = Pulse.ConstantDetuning(\n",
+    "    pi2_wf, detuning=0, phase=np.pi / 2, post_phase_shift=np.pi\n",
+    ")\n",
     "\n",
-    "seq.add(h, 'ch0')\n",
+    "seq.add(h, \"ch0\")\n",
     "seq.draw(draw_phase_shifts=True)"
    ]
   },
@@ -233,7 +236,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.add(h, 'ch0')\n",
+    "seq.add(h, \"ch0\")\n",
     "print(seq)"
    ]
   },
@@ -264,12 +267,12 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "reg = Register({'q0': (0, 0), 'q1': (5, 5)})\n",
+    "reg = Register({\"q0\": (0, 0), \"q1\": (5, 5)})\n",
     "device = MockDevice\n",
     "seq = Sequence(reg, device)\n",
-    "seq.declare_channel('raman', 'raman_local', initial_target = 'q0')\n",
-    "seq.declare_channel('ryd1', 'rydberg_local', initial_target = 'q0')\n",
-    "seq.declare_channel('ryd2', 'rydberg_local', initial_target = 'q0')\n",
+    "seq.declare_channel(\"raman\", \"raman_local\", initial_target=\"q0\")\n",
+    "seq.declare_channel(\"ryd1\", \"rydberg_local\", initial_target=\"q0\")\n",
+    "seq.declare_channel(\"ryd2\", \"rydberg_local\", initial_target=\"q0\")\n",
     "seq.declared_channels"
    ]
   },
@@ -286,8 +289,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.add(h, 'raman')\n",
-    "seq.add(h, 'ryd1')\n",
+    "seq.add(h, \"raman\")\n",
+    "seq.add(h, \"ryd1\")\n",
     "seq.draw(draw_phase_shifts=True)"
    ]
   },
@@ -320,7 +323,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.add(pi_2, 'ryd2')\n",
+    "seq.add(pi_2, \"ryd2\")\n",
     "print(seq)"
    ]
   },
@@ -339,10 +342,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.target('q1', 'raman')\n",
-    "seq.add(h, 'raman')\n",
-    "seq.target('q1', 'ryd1')\n",
-    "seq.add(h, 'ryd1')\n",
+    "seq.target(\"q1\", \"raman\")\n",
+    "seq.add(h, \"raman\")\n",
+    "seq.target(\"q1\", \"ryd1\")\n",
+    "seq.add(h, \"ryd1\")\n",
     "print(seq)\n",
     "seq.draw(draw_phase_shifts=True)"
    ]
@@ -362,7 +365,7 @@
    },
    "outputs": [],
    "source": [
-    "seq.target('q1', 'ryd2')\n",
+    "seq.target(\"q1\", \"ryd2\")\n",
     "seq.draw(draw_phase_shifts=True)"
    ]
   },
diff --git a/tutorials/advanced_features/Serialization.ipynb b/tutorials/advanced_features/Serialization.ipynb
index 295ed5082..c9883f621 100644
--- a/tutorials/advanced_features/Serialization.ipynb
+++ b/tutorials/advanced_features/Serialization.ipynb
@@ -42,10 +42,12 @@
     "seq.declare_channel(\"digital\", \"raman_local\", initial_target=\"control\")\n",
     "seq.declare_channel(\"rydberg\", \"rydberg_local\", initial_target=\"control\")\n",
     "\n",
-    "half_pi_wf = BlackmanWaveform(200, np.pi/2)\n",
+    "half_pi_wf = BlackmanWaveform(200, np.pi / 2)\n",
     "\n",
-    "ry = Pulse.ConstantDetuning(amplitude=half_pi_wf,detuning=0,phase=-np.pi/2)\n",
-    "ry_dag = Pulse.ConstantDetuning(amplitude=half_pi_wf,detuning=0,phase=np.pi/2)\n",
+    "ry = Pulse.ConstantDetuning(amplitude=half_pi_wf, detuning=0, phase=-np.pi / 2)\n",
+    "ry_dag = Pulse.ConstantDetuning(\n",
+    "    amplitude=half_pi_wf, detuning=0, phase=np.pi / 2\n",
+    ")\n",
     "\n",
     "seq.add(ry, \"digital\")\n",
     "seq.target(\"target\", \"digital\")\n",
@@ -55,7 +57,7 @@
     "pi_pulse = Pulse.ConstantDetuning(pi_wf, 0, 0)\n",
     "\n",
     "max_val = Chadoq2.rabi_from_blockade(8)\n",
-    "two_pi_wf = BlackmanWaveform.from_max_val(max_val, 2*np.pi)\n",
+    "two_pi_wf = BlackmanWaveform.from_max_val(max_val, 2 * np.pi)\n",
     "two_pi_pulse = Pulse.ConstantDetuning(two_pi_wf, 0, 0)\n",
     "\n",
     "seq.align(\"digital\", \"rydberg\")\n",
diff --git a/tutorials/advanced_features/Simulating Sequences with Errors and Noise.ipynb b/tutorials/advanced_features/Simulating Sequences with Errors and Noise.ipynb
index fecbad9e7..155822b7f 100644
--- a/tutorials/advanced_features/Simulating Sequences with Errors and Noise.ipynb
+++ b/tutorials/advanced_features/Simulating Sequences with Errors and Noise.ipynb
@@ -69,7 +69,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "reg = Register.from_coordinates([(0,0)], prefix='q')"
+    "reg = Register.from_coordinates([(0, 0)], prefix=\"q\")"
    ]
   },
   {
@@ -99,10 +99,10 @@
    ],
    "source": [
     "seq = Sequence(reg, Chadoq2)\n",
-    "seq.declare_channel('ch0', 'rydberg_global')\n",
+    "seq.declare_channel(\"ch0\", \"rydberg_global\")\n",
     "duration = 2500\n",
-    "pulse = Pulse.ConstantPulse(duration, 2*np.pi, 0., 0.)\n",
-    "seq.add(pulse, 'ch0')\n",
+    "pulse = Pulse.ConstantPulse(duration, 2 * np.pi, 0.0, 0.0)\n",
+    "seq.add(pulse, \"ch0\")\n",
     "seq.draw()"
    ]
   },
@@ -136,7 +136,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "obs = qutip.basis(2,0).proj()"
+    "obs = qutip.basis(2, 0).proj()"
    ]
   },
   {
@@ -216,7 +216,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "config_spam = SimConfig(noise=('SPAM'), runs = 30, samples_per_run = 5)\n",
+    "config_spam = SimConfig(noise=(\"SPAM\"), runs=30, samples_per_run=5)\n",
     "sim.set_config(config_spam)"
    ]
   },
@@ -284,7 +284,12 @@
     }
    ],
    "source": [
-    "cfg2 = SimConfig(noise=('SPAM', 'dephasing', 'doppler'), eta=0.8, temperature=1000, runs=10000)\n",
+    "cfg2 = SimConfig(\n",
+    "    noise=(\"SPAM\", \"dephasing\", \"doppler\"),\n",
+    "    eta=0.8,\n",
+    "    temperature=1000,\n",
+    "    runs=10000,\n",
+    ")\n",
     "sim.add_config(cfg2)\n",
     "sim.show_config()"
    ]
@@ -316,7 +321,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sim.evaluation_times = .8"
+    "sim.evaluation_times = 0.8"
    ]
   },
   {
@@ -401,7 +406,7 @@
     }
    ],
    "source": [
-    "res.plot(obs, fmt='.')\n",
+    "res.plot(obs, fmt=\".\")\n",
     "plt.show()"
    ]
   },
@@ -424,7 +429,7 @@
     }
    ],
    "source": [
-    "res.plot(obs, error_bars=False, fmt='.')"
+    "res.plot(obs, error_bars=False, fmt=\".\")"
    ]
   },
   {
@@ -467,7 +472,7 @@
     "res_spam = sim.run()\n",
     "res_spam.plot(obs)\n",
     "sim.reset_config()\n",
-    "sim.eval_times = 'Full'\n",
+    "sim.eval_times = \"Full\"\n",
     "res_clean = sim.run()\n",
     "res_clean.plot(obs)\n",
     "plt.show()"
@@ -499,7 +504,7 @@
     }
    ],
    "source": [
-    "config_spam_mod = SimConfig(noise=('SPAM'), eta=0.4, runs = 100)\n",
+    "config_spam_mod = SimConfig(noise=(\"SPAM\"), eta=0.4, runs=100)\n",
     "sim.set_config(config_spam_mod)\n",
     "sim.evaluation_times = 0.5\n",
     "res_large_eta = sim.run()\n",
@@ -546,12 +551,14 @@
     }
    ],
    "source": [
-    "plt.figure(figsize=(10,5))\n",
+    "plt.figure(figsize=(10, 5))\n",
     "res_clean.plot(obs)\n",
-    "for eta in np.linspace(0,0.99,4):\n",
-    "    config_spam_eta = SimConfig(noise = 'SPAM', eta=eta, runs = 50, epsilon=0, epsilon_prime=0)\n",
+    "for eta in np.linspace(0, 0.99, 4):\n",
+    "    config_spam_eta = SimConfig(\n",
+    "        noise=\"SPAM\", eta=eta, runs=50, epsilon=0, epsilon_prime=0\n",
+    "    )\n",
     "    sim.set_config(config_spam_eta)\n",
-    "    sim.run().plot(obs, label=f'eta = {eta}')\n",
+    "    sim.run().plot(obs, label=f\"eta = {eta}\")\n",
     "plt.legend()\n",
     "plt.show()"
    ]
@@ -596,12 +603,14 @@
     }
    ],
    "source": [
-    "plt.figure(figsize=(10,5))\n",
+    "plt.figure(figsize=(10, 5))\n",
     "res_clean.plot(obs)\n",
-    "for eps in np.linspace(0,.99,4):\n",
-    "    config_spam_eps = SimConfig(noise = 'SPAM', eta=0, runs = 50, epsilon=eps, epsilon_prime=0)\n",
+    "for eps in np.linspace(0, 0.99, 4):\n",
+    "    config_spam_eps = SimConfig(\n",
+    "        noise=\"SPAM\", eta=0, runs=50, epsilon=eps, epsilon_prime=0\n",
+    "    )\n",
     "    sim.set_config(config_spam_eps)\n",
-    "    sim.run().plot(obs, label=f'epsilon = {eps}')\n",
+    "    sim.run().plot(obs, label=f\"epsilon = {eps}\")\n",
     "plt.legend()\n",
     "plt.show()"
    ]
@@ -646,12 +655,14 @@
     }
    ],
    "source": [
-    "plt.figure(figsize=(10,5))\n",
+    "plt.figure(figsize=(10, 5))\n",
     "res_clean.plot(obs)\n",
-    "for eps_p in np.linspace(0,.99,4):\n",
-    "    config_spam_eps_p = SimConfig(noise = 'SPAM', eta=0, runs = 50, epsilon=0, epsilon_prime=eps_p)\n",
+    "for eps_p in np.linspace(0, 0.99, 4):\n",
+    "    config_spam_eps_p = SimConfig(\n",
+    "        noise=\"SPAM\", eta=0, runs=50, epsilon=0, epsilon_prime=eps_p\n",
+    "    )\n",
     "    sim.set_config(config_spam_eps_p)\n",
-    "    sim.run().plot(obs, label=f'epsilon = {eps_p}')\n",
+    "    sim.run().plot(obs, label=f\"epsilon = {eps_p}\")\n",
     "plt.legend()\n",
     "plt.show()"
    ]
@@ -703,7 +714,9 @@
     }
    ],
    "source": [
-    "config_doppler = SimConfig(noise='doppler', runs=100, temperature = 5000, samples_per_run=1)\n",
+    "config_doppler = SimConfig(\n",
+    "    noise=\"doppler\", runs=100, temperature=5000, samples_per_run=1\n",
+    ")\n",
     "sim.set_config(config_doppler)\n",
     "sim.show_config()"
    ]
@@ -763,28 +776,34 @@
    "outputs": [],
    "source": [
     "# Parameters in rad/µs and ns\n",
-    "Omega_max = 2.3 * 2*np.pi \n",
+    "Omega_max = 2.3 * 2 * np.pi\n",
     "U = Omega_max / 2.3\n",
     "delta_0 = -6 * U\n",
     "delta_f = 2 * U\n",
     "t_rise = 252\n",
     "t_fall = 500\n",
-    "t_sweep = (delta_f - delta_0)/(2 * np.pi * 10) * 1000\n",
+    "t_sweep = (delta_f - delta_0) / (2 * np.pi * 10) * 1000\n",
     "R_interatomic = Chadoq2.rydberg_blockade_radius(U)\n",
     "\n",
     "N_side = 3\n",
-    "reg = Register.rectangle(N_side, N_side, R_interatomic, prefix='q')\n",
+    "reg = Register.rectangle(N_side, N_side, R_interatomic, prefix=\"q\")\n",
     "\n",
-    "rise = Pulse.ConstantDetuning(RampWaveform(t_rise, 0., Omega_max), delta_0, 0.)\n",
-    "sweep = Pulse.ConstantAmplitude(Omega_max, RampWaveform(t_sweep, delta_0, delta_f), 0.)\n",
-    "fall = Pulse.ConstantDetuning(RampWaveform(t_fall, Omega_max, 0.), delta_f, 0.)\n",
+    "rise = Pulse.ConstantDetuning(\n",
+    "    RampWaveform(t_rise, 0.0, Omega_max), delta_0, 0.0\n",
+    ")\n",
+    "sweep = Pulse.ConstantAmplitude(\n",
+    "    Omega_max, RampWaveform(t_sweep, delta_0, delta_f), 0.0\n",
+    ")\n",
+    "fall = Pulse.ConstantDetuning(\n",
+    "    RampWaveform(t_fall, Omega_max, 0.0), delta_f, 0.0\n",
+    ")\n",
     "\n",
     "seq = Sequence(reg, Chadoq2)\n",
-    "seq.declare_channel('ising', 'rydberg_global')\n",
+    "seq.declare_channel(\"ising\", \"rydberg_global\")\n",
     "\n",
-    "seq.add(rise, 'ising')\n",
-    "seq.add(sweep, 'ising')\n",
-    "seq.add(fall, 'ising')"
+    "seq.add(rise, \"ising\")\n",
+    "seq.add(sweep, \"ising\")\n",
+    "seq.add(fall, \"ising\")"
    ]
   },
   {
@@ -793,9 +812,12 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "config_all_noise = SimConfig(noise=('SPAM', 'doppler', 'amplitude'),\n",
-    "                             runs=100, samples_per_run=10)\n",
-    "simul = Simulation(seq, sampling_rate=0.05, evaluation_times=0.2, config=config_all_noise)\n",
+    "config_all_noise = SimConfig(\n",
+    "    noise=(\"SPAM\", \"doppler\", \"amplitude\"), runs=100, samples_per_run=10\n",
+    ")\n",
+    "simul = Simulation(\n",
+    "    seq, sampling_rate=0.05, evaluation_times=0.2, config=config_all_noise\n",
+    ")\n",
     "spam_results = simul.run()\n",
     "simul.reset_config()\n",
     "clean_results = simul.run()"
@@ -827,17 +849,19 @@
     }
    ],
    "source": [
-    "plt.figure(figsize=(20,5))\n",
+    "plt.figure(figsize=(20, 5))\n",
     "spam_count = spam_results.sample_final_state(N_samples=1e5)\n",
     "clean_count = clean_results.sample_final_state(N_samples=1e5)\n",
     "\n",
-    "clean_most_freq = {k:v for k,v in clean_count.items() if v>500}\n",
-    "spam_most_freq = {k:v for k,v in spam_count.items() if v>500}\n",
+    "clean_most_freq = {k: v for k, v in clean_count.items() if v > 500}\n",
+    "spam_most_freq = {k: v for k, v in spam_count.items() if v > 500}\n",
     "\n",
-    "plt.bar(list(clean_most_freq.keys()), list(clean_most_freq.values()), width=0.9)\n",
+    "plt.bar(\n",
+    "    list(clean_most_freq.keys()), list(clean_most_freq.values()), width=0.9\n",
+    ")\n",
     "plt.bar(list(spam_most_freq.keys()), list(spam_most_freq.values()), width=0.5)\n",
     "\n",
-    "plt.xticks(rotation='vertical')\n",
+    "plt.xticks(rotation=\"vertical\")\n",
     "plt.show()"
    ]
   },
diff --git a/tutorials/advanced_features/State Preparation with the SLM Mask.ipynb b/tutorials/advanced_features/State Preparation with the SLM Mask.ipynb
index cbe7cb6e8..6f32fd09a 100644
--- a/tutorials/advanced_features/State Preparation with the SLM Mask.ipynb
+++ b/tutorials/advanced_features/State Preparation with the SLM Mask.ipynb
@@ -36,7 +36,7 @@
     "from pulser.simulation import Simulation\n",
     "\n",
     "# Qubit register\n",
-    "qubits = {\"q0\": (-5,0), \"q1\": (0,0), \"q2\": (5,0)}\n",
+    "qubits = {\"q0\": (-5, 0), \"q1\": (0, 0), \"q2\": (5, 0)}\n",
     "reg = Register(qubits)\n",
     "reg.draw()"
    ]
@@ -58,9 +58,9 @@
     "seq = Sequence(reg, MockDevice)\n",
     "\n",
     "# Declare a global XY channel and add the pi pulse\n",
-    "seq.declare_channel('ch', 'mw_global')\n",
+    "seq.declare_channel(\"ch\", \"mw_global\")\n",
     "pulse = Pulse.ConstantDetuning(BlackmanWaveform(200, np.pi), 0, 0)\n",
-    "seq.add(pulse, 'ch')"
+    "seq.add(pulse, \"ch\")"
    ]
   },
   {
@@ -193,7 +193,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.add(pulse, 'ch')"
+    "seq.add(pulse, \"ch\")"
    ]
   },
   {
diff --git a/tutorials/applications/Control-Z Gate Sequence.ipynb b/tutorials/applications/Control-Z Gate Sequence.ipynb
index 438a2812f..8e0aaad92 100644
--- a/tutorials/applications/Control-Z Gate Sequence.ipynb
+++ b/tutorials/applications/Control-Z Gate Sequence.ipynb
@@ -58,7 +58,7 @@
     "from pulser import Pulse, Sequence, Register\n",
     "from pulser.devices import Chadoq2\n",
     "from pulser.simulation import Simulation\n",
-    "from pulser.waveforms import BlackmanWaveform,ConstantWaveform"
+    "from pulser.waveforms import BlackmanWaveform, ConstantWaveform"
    ]
   },
   {
@@ -86,10 +86,12 @@
    "outputs": [],
    "source": [
     "Rabi = np.linspace(1, 10, 10)\n",
-    "R_blockade = [Chadoq2.rydberg_blockade_radius(2.*np.pi*rabi) for rabi in Rabi]\n",
+    "R_blockade = [\n",
+    "    Chadoq2.rydberg_blockade_radius(2.0 * np.pi * rabi) for rabi in Rabi\n",
+    "]\n",
     "\n",
     "plt.figure()\n",
-    "plt.plot(Rabi, R_blockade,'--o')\n",
+    "plt.plot(Rabi, R_blockade, \"--o\")\n",
     "plt.xlabel(r\"$\\Omega/(2\\pi)$ [MHz]\", fontsize=14)\n",
     "plt.ylabel(r\"$R_b$ [$\\mu\\.m$]\", fontsize=14)\n",
     "plt.show()"
@@ -109,9 +111,10 @@
    "outputs": [],
    "source": [
     "# Atom Register and Device\n",
-    "q_dict = {\"control\":np.array([-2,0.]),\n",
-    "          \"target\": np.array([2,0.]),\n",
-    "          }\n",
+    "q_dict = {\n",
+    "    \"control\": np.array([-2, 0.0]),\n",
+    "    \"target\": np.array([2, 0.0]),\n",
+    "}\n",
     "reg = Register(q_dict)\n",
     "reg.draw()"
    ]
@@ -144,22 +147,24 @@
    "outputs": [],
    "source": [
     "def build_state_from_id(s_id, basis_name):\n",
-    "    if len(s_id) not in {2,3}:\n",
+    "    if len(s_id) not in {2, 3}:\n",
     "        raise ValueError(\"Not a valid state ID string\")\n",
-    "        \n",
-    "    ids = {'digital': 'gh', 'ground-rydberg': 'rg', 'all': 'rgh'}\n",
+    "\n",
+    "    ids = {\"digital\": \"gh\", \"ground-rydberg\": \"rg\", \"all\": \"rgh\"}\n",
     "    if basis_name not in ids:\n",
-    "        raise ValueError('Not a valid basis')\n",
-    "        \n",
-    "    pool = {''.join(x) for x in product(ids[basis_name], repeat=len(s_id))}\n",
+    "        raise ValueError(\"Not a valid basis\")\n",
+    "\n",
+    "    pool = {\"\".join(x) for x in product(ids[basis_name], repeat=len(s_id))}\n",
     "    if s_id not in pool:\n",
-    "        raise ValueError('Not a valid state id for the given basis.')\n",
+    "        raise ValueError(\"Not a valid state id for the given basis.\")\n",
     "\n",
-    "    ket = {op: qutip.basis(len(ids[basis_name]), i) \n",
-    "           for i, op in enumerate(ids[basis_name])}\n",
+    "    ket = {\n",
+    "        op: qutip.basis(len(ids[basis_name]), i)\n",
+    "        for i, op in enumerate(ids[basis_name])\n",
+    "    }\n",
     "    if len(s_id) == 3:\n",
-    "        #Recall that s_id = 'C1'+'C2'+'T' while in the register reg_id = 'C1'+'T'+'C2'.\n",
-    "        reg_id = s_id[0]+s_id[2]+s_id[1] \n",
+    "        # Recall that s_id = 'C1'+'C2'+'T' while in the register reg_id = 'C1'+'T'+'C2'.\n",
+    "        reg_id = s_id[0] + s_id[2] + s_id[1]\n",
     "        return qutip.tensor([ket[x] for x in reg_id])\n",
     "    else:\n",
     "        return qutip.tensor([ket[x] for x in s_id])"
@@ -178,7 +183,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "build_state_from_id('hg','digital')"
+    "build_state_from_id(\"hg\", \"digital\")"
    ]
   },
   {
@@ -194,8 +199,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "duration = 300 \n",
-    "pi_Y = Pulse.ConstantDetuning(BlackmanWaveform(duration, np.pi), 0., -np.pi/2)\n",
+    "duration = 300\n",
+    "pi_Y = Pulse.ConstantDetuning(\n",
+    "    BlackmanWaveform(duration, np.pi), 0.0, -np.pi / 2\n",
+    ")\n",
     "pi_Y.draw()"
    ]
   },
@@ -214,31 +221,37 @@
    "source": [
     "def preparation_sequence(state_id, reg):\n",
     "    global seq\n",
-    "    \n",
-    "    if not set(state_id) <= {'g','h'} or len(state_id) != len(reg.qubits):\n",
-    "        raise ValueError('Not a valid state ID')\n",
+    "\n",
+    "    if not set(state_id) <= {\"g\", \"h\"} or len(state_id) != len(reg.qubits):\n",
+    "        raise ValueError(\"Not a valid state ID\")\n",
     "\n",
     "    if len(reg.qubits) == 2:\n",
-    "        seq_dict = {'1':'target', '0':'control'}\n",
+    "        seq_dict = {\"1\": \"target\", \"0\": \"control\"}\n",
     "    elif len(reg.qubits) == 3:\n",
-    "        seq_dict = {'2':'target', '1':'control2', '0':'control1'}\n",
+    "        seq_dict = {\"2\": \"target\", \"1\": \"control2\", \"0\": \"control1\"}\n",
     "\n",
     "    seq = Sequence(reg, Chadoq2)\n",
-    "    if set(state_id) == {'g'}:\n",
-    "        basis = 'ground-rydberg'\n",
-    "        print(f'Warning: {state_id} state does not require a preparation sequence.')\n",
+    "    if set(state_id) == {\"g\"}:\n",
+    "        basis = \"ground-rydberg\"\n",
+    "        print(\n",
+    "            f\"Warning: {state_id} state does not require a preparation sequence.\"\n",
+    "        )\n",
     "    else:\n",
-    "        basis = 'all'\n",
+    "        basis = \"all\"\n",
     "        for k in range(len(reg.qubits)):\n",
-    "            if state_id[k] == 'h':\n",
-    "                if 'raman' not in seq.declared_channels:\n",
-    "                    seq.declare_channel('raman','raman_local', seq_dict[str(k)])\n",
+    "            if state_id[k] == \"h\":\n",
+    "                if \"raman\" not in seq.declared_channels:\n",
+    "                    seq.declare_channel(\n",
+    "                        \"raman\", \"raman_local\", seq_dict[str(k)]\n",
+    "                    )\n",
     "                else:\n",
-    "                    seq.target(seq_dict[str(k)],'raman')\n",
-    "                seq.add(pi_Y,'raman')\n",
+    "                    seq.target(seq_dict[str(k)], \"raman\")\n",
+    "                seq.add(pi_Y, \"raman\")\n",
+    "\n",
+    "    prep_state = build_state_from_id(\n",
+    "        state_id, basis\n",
+    "    )  # Raises error if not a valid `state_id` for the register\n",
     "\n",
-    "    prep_state = build_state_from_id(state_id, basis)  # Raises error if not a valid `state_id` for the register\n",
-    "    \n",
     "    return prep_state"
    ]
   },
@@ -256,7 +269,7 @@
    "outputs": [],
    "source": [
     "# Define sequence and Set channels\n",
-    "prep_state = preparation_sequence('hh', reg)\n",
+    "prep_state = preparation_sequence(\"hh\", reg)\n",
     "seq.draw(draw_phase_area=True)"
    ]
   },
@@ -280,8 +293,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "pi_pulse = Pulse.ConstantDetuning(BlackmanWaveform(duration, np.pi), 0., 0)\n",
-    "twopi_pulse = Pulse.ConstantDetuning(BlackmanWaveform(duration, 2*np.pi), 0., 0)"
+    "pi_pulse = Pulse.ConstantDetuning(BlackmanWaveform(duration, np.pi), 0.0, 0)\n",
+    "twopi_pulse = Pulse.ConstantDetuning(\n",
+    "    BlackmanWaveform(duration, 2 * np.pi), 0.0, 0\n",
+    ")"
    ]
   },
   {
@@ -291,21 +306,25 @@
    "outputs": [],
    "source": [
     "def CZ_sequence(initial_id):\n",
-    "    \n",
+    "\n",
     "    # Prepare State\n",
-    "    prep_state = preparation_sequence(initial_id, reg) \n",
-    "    prep_time = max((seq._last(ch).tf for ch in seq.declared_channels), default=0)\n",
-    "    \n",
+    "    prep_state = preparation_sequence(initial_id, reg)\n",
+    "    prep_time = max(\n",
+    "        (seq._last(ch).tf for ch in seq.declared_channels), default=0\n",
+    "    )\n",
+    "\n",
     "    # Declare Rydberg channel\n",
-    "    seq.declare_channel('ryd', 'rydberg_local', 'control')\n",
-    "    \n",
+    "    seq.declare_channel(\"ryd\", \"rydberg_local\", \"control\")\n",
+    "\n",
     "    # Write CZ sequence:\n",
-    "    seq.add(pi_pulse, 'ryd', 'wait-for-all')  # Wait for state preparation to finish.\n",
-    "    seq.target('target', 'ryd')    # Changes to target qubit\n",
-    "    seq.add(twopi_pulse, 'ryd')\n",
-    "    seq.target('control', 'ryd')     # Changes back to control qubit\n",
-    "    seq.add(pi_pulse, 'ryd') \n",
-    "    \n",
+    "    seq.add(\n",
+    "        pi_pulse, \"ryd\", \"wait-for-all\"\n",
+    "    )  # Wait for state preparation to finish.\n",
+    "    seq.target(\"target\", \"ryd\")  # Changes to target qubit\n",
+    "    seq.add(twopi_pulse, \"ryd\")\n",
+    "    seq.target(\"control\", \"ryd\")  # Changes back to control qubit\n",
+    "    seq.add(pi_pulse, \"ryd\")\n",
+    "\n",
     "    return prep_state, prep_time"
    ]
   },
@@ -315,10 +334,12 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "prep_state, prep_time = CZ_sequence('gh') # constructs seq, prep_state and prep_time\n",
+    "prep_state, prep_time = CZ_sequence(\n",
+    "    \"gh\"\n",
+    ")  # constructs seq, prep_state and prep_time\n",
     "seq.draw(draw_phase_area=True)\n",
-    "print(f'Prepared state: {prep_state}')\n",
-    "print(f'Preparation time: {prep_time}ns')"
+    "print(f\"Prepared state: {prep_state}\")\n",
+    "print(f\"Preparation time: {prep_time}ns\")"
    ]
   },
   {
@@ -335,25 +356,27 @@
    "outputs": [],
    "source": [
     "CZ = {}\n",
-    "for state_id in {'gg','hg','gh','hh'}:\n",
+    "for state_id in {\"gg\", \"hg\", \"gh\", \"hh\"}:\n",
     "    # Get CZ sequence\n",
-    "    prep_state, prep_time = CZ_sequence(state_id) # constructs seq, prep_state and prep_time\n",
-    "    \n",
+    "    prep_state, prep_time = CZ_sequence(\n",
+    "        state_id\n",
+    "    )  # constructs seq, prep_state and prep_time\n",
+    "\n",
     "    # Construct Simulation instance\n",
     "    simul = Simulation(seq)\n",
     "    res = simul.run()\n",
-    "    \n",
-    "    data=[st.overlap(prep_state) for st in res.states]\n",
-    "    \n",
+    "\n",
+    "    data = [st.overlap(prep_state) for st in res.states]\n",
+    "\n",
     "    final_st = res.states[-1]\n",
     "    CZ[state_id] = final_st.overlap(prep_state)\n",
-    "    \n",
+    "\n",
     "    plt.figure()\n",
     "    plt.plot(np.real(data))\n",
     "    plt.xlabel(r\"Time [ns]\")\n",
-    "    plt.ylabel(fr'$ \\langle\\,{state_id} |\\, \\psi(t)\\rangle$')\n",
-    "    plt.axvspan(0, prep_time, alpha=0.06, color='royalblue')\n",
-    "    plt.title(fr\"Action of gate on state $|${state_id}$\\rangle$\")"
+    "    plt.ylabel(rf\"$ \\langle\\,{state_id} |\\, \\psi(t)\\rangle$\")\n",
+    "    plt.axvspan(0, prep_time, alpha=0.06, color=\"royalblue\")\n",
+    "    plt.title(rf\"Action of gate on state $|${state_id}$\\rangle$\")"
    ]
   },
   {
@@ -386,9 +409,11 @@
    "outputs": [],
    "source": [
     "# Atom Register and Device\n",
-    "q_dict = {\"control1\":np.array([-2.0, 0.]),\n",
-    "              \"target\": np.array([0., 2*np.sqrt(3.001)]),\n",
-    "              \"control2\": np.array([2.0, 0.])}\n",
+    "q_dict = {\n",
+    "    \"control1\": np.array([-2.0, 0.0]),\n",
+    "    \"target\": np.array([0.0, 2 * np.sqrt(3.001)]),\n",
+    "    \"control2\": np.array([2.0, 0.0]),\n",
+    "}\n",
     "reg = Register(q_dict)\n",
     "reg.draw()"
    ]
@@ -399,7 +424,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "preparation_sequence('hhh', reg)\n",
+    "preparation_sequence(\"hhh\", reg)\n",
     "seq.draw(draw_phase_area=True)"
    ]
   },
@@ -412,22 +437,26 @@
     "def CCZ_sequence(initial_id):\n",
     "    # Prepare State\n",
     "    prep_state = preparation_sequence(initial_id, reg)\n",
-    "    prep_time = max((seq._last(ch).tf for ch in seq.declared_channels), default=0)\n",
-    "    \n",
+    "    prep_time = max(\n",
+    "        (seq._last(ch).tf for ch in seq.declared_channels), default=0\n",
+    "    )\n",
+    "\n",
     "    # Declare Rydberg channel\n",
-    "    seq.declare_channel('ryd', 'rydberg_local', 'control1')\n",
-    "    \n",
+    "    seq.declare_channel(\"ryd\", \"rydberg_local\", \"control1\")\n",
+    "\n",
     "    # Write CCZ sequence:\n",
-    "    seq.add(pi_pulse, 'ryd', protocol='wait-for-all')  # Wait for state preparation to finish.\n",
-    "    seq.target('control2', 'ryd')\n",
-    "    seq.add(pi_pulse, 'ryd')\n",
-    "    seq.target('target','ryd')\n",
-    "    seq.add(twopi_pulse, 'ryd')\n",
-    "    seq.target('control2','ryd')\n",
-    "    seq.add(pi_pulse, 'ryd')\n",
-    "    seq.target('control1','ryd')\n",
-    "    seq.add(pi_pulse,'ryd')\n",
-    "    \n",
+    "    seq.add(\n",
+    "        pi_pulse, \"ryd\", protocol=\"wait-for-all\"\n",
+    "    )  # Wait for state preparation to finish.\n",
+    "    seq.target(\"control2\", \"ryd\")\n",
+    "    seq.add(pi_pulse, \"ryd\")\n",
+    "    seq.target(\"target\", \"ryd\")\n",
+    "    seq.add(twopi_pulse, \"ryd\")\n",
+    "    seq.target(\"control2\", \"ryd\")\n",
+    "    seq.add(pi_pulse, \"ryd\")\n",
+    "    seq.target(\"control1\", \"ryd\")\n",
+    "    seq.add(pi_pulse, \"ryd\")\n",
+    "\n",
     "    return prep_state, prep_time"
    ]
   },
@@ -437,7 +466,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "CCZ_sequence('hhh')\n",
+    "CCZ_sequence(\"hhh\")\n",
     "seq.draw(draw_phase_area=True)"
    ]
   },
@@ -450,25 +479,25 @@
    "outputs": [],
    "source": [
     "CCZ = {}\n",
-    "for state_id in {''.join(x) for x in product('gh', repeat=3)}:\n",
+    "for state_id in {\"\".join(x) for x in product(\"gh\", repeat=3)}:\n",
     "    # Get CCZ sequence\n",
     "    prep_state, prep_time = CCZ_sequence(state_id)\n",
-    "    \n",
+    "\n",
     "    # Construct Simulation instance\n",
     "    simul = Simulation(seq)\n",
-    "    \n",
+    "\n",
     "    res = simul.run()\n",
-    "    \n",
-    "    data=[st.overlap(prep_state) for st in res.states]\n",
+    "\n",
+    "    data = [st.overlap(prep_state) for st in res.states]\n",
     "    final_st = res.states[-1]\n",
     "    CCZ[state_id] = final_st.overlap(prep_state)\n",
-    "    \n",
+    "\n",
     "    plt.figure()\n",
     "    plt.plot(np.real(data))\n",
     "    plt.xlabel(r\"Time [ns]\")\n",
-    "    plt.ylabel(fr'$ \\langle\\,{state_id} | \\psi(t)\\rangle$')\n",
-    "    plt.axvspan(0, prep_time, alpha=0.06, color='royalblue')\n",
-    "    plt.title(fr\"Action of gate on state $|${state_id}$\\rangle$\")"
+    "    plt.ylabel(rf\"$ \\langle\\,{state_id} | \\psi(t)\\rangle$\")\n",
+    "    plt.axvspan(0, prep_time, alpha=0.06, color=\"royalblue\")\n",
+    "    plt.title(rf\"Action of gate on state $|${state_id}$\\rangle$\")"
    ]
   },
   {
diff --git a/tutorials/applications/Using QAOA to solve a MIS problem.ipynb b/tutorials/applications/Using QAOA to solve a MIS problem.ipynb
index abc08e98d..e5cc7fe06 100644
--- a/tutorials/applications/Using QAOA to solve a MIS problem.ipynb
+++ b/tutorials/applications/Using QAOA to solve a MIS problem.ipynb
@@ -39,7 +39,7 @@
    "source": [
     "In this tutorial, we illustrate how to solve the Maximum Independent Set (MIS) problem using the Quantum Approximate Optimization Algorithm procedure on a platform of Rydberg atoms in analog mode, using Pulser. \n",
     "\n",
-    "For more details about this problem and how to encode it on a Rydberg atom quantum processor, see [Pichler, et al., 2018](https://arxiv.org/abs/1808.10816), [Henriet, 2020]( https://journals.aps.org/pra/abstract/10.1103/PhysRevA.101.012335) and [Dalyac, et al., 2020]( https://arxiv.org/abs/2012.14859])."
+    "For more details about this problem and how to encode it on a Rydberg atom quantum processor, see [Pichler, et al., 2018](https://arxiv.org/abs/1808.10816), [Henriet, 2020]( https://journals.aps.org/pra/abstract/10.1103/PhysRevA.101.012335) and [Dalyac, et al., 2020]( https://arxiv.org/abs/2012.14859)."
    ]
   },
   {
@@ -61,7 +61,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "For example, assume an ensemble of identical radio transmitters over French cities that each have the same radius of transmission. It was quickly realized that two transmitters with close or equal frequencies could interfere with one another, hence the necessity to assign non-interfering frequencies to overlapping transmiting towers. Because of the limited amount of bandwith space, some towers have to be assigned the same or close frequencies. The MIS of a graph of towers indicate the maximum number of towers that can have close or equal given frequency (red points). \n",
+    "For example, assume an ensemble of identical radio transmitters over French cities that each have the same radius of transmission. It was quickly realized that two transmitters with close or equal frequencies could interfere with one another, hence the necessity to assign non-interfering frequencies to overlapping transmiting towers. Because of the limited amount of bandwidth space, some towers have to be assigned the same or close frequencies. The MIS of a graph of towers indicate the maximum number of towers that can have close or equal given frequency (red points). \n",
     "\n",
     "<center>\n",
     "<img src=\"attachment:France_MIS.png\" alt=\"MIS map France\" width=\"650\">\n",
@@ -126,10 +126,14 @@
    "outputs": [],
    "source": [
     "def pos_to_graph(pos):\n",
-    "    rb = Chadoq2.rydberg_blockade_radius(1.)\n",
+    "    rb = Chadoq2.rydberg_blockade_radius(1.0)\n",
     "    g = igraph.Graph()\n",
     "    N = len(pos)\n",
-    "    edges = [[m,n] for m,n in combinations(range(N), r=2) if np.linalg.norm(pos[m] - pos[n]) < rb]           \n",
+    "    edges = [\n",
+    "        [m, n]\n",
+    "        for m, n in combinations(range(N), r=2)\n",
+    "        if np.linalg.norm(pos[m] - pos[n]) < rb\n",
+    "    ]\n",
     "    g.add_vertices(N)\n",
     "    g.add_edges(edges)\n",
     "    return g"
@@ -161,13 +165,17 @@
     }
    ],
    "source": [
-    "pos = np.array([[0., 0.], [-4, -7], [4,-7], [8,6], [-8,6]])\n",
+    "pos = np.array([[0.0, 0.0], [-4, -7], [4, -7], [8, 6], [-8, 6]])\n",
     "\n",
-    "G = pos_to_graph(pos) \n",
+    "G = pos_to_graph(pos)\n",
     "qubits = dict(enumerate(pos))\n",
     "\n",
     "reg = Register(qubits)\n",
-    "reg.draw(blockade_radius=Chadoq2.rydberg_blockade_radius(1.), draw_graph=True, draw_half_radius=True)"
+    "reg.draw(\n",
+    "    blockade_radius=Chadoq2.rydberg_blockade_radius(1.0),\n",
+    "    draw_graph=True,\n",
+    "    draw_half_radius=True,\n",
+    ")"
    ]
   },
   {
@@ -203,23 +211,23 @@
     "\n",
     "# Parametrized sequence\n",
     "seq = Sequence(reg, Chadoq2)\n",
-    "seq.declare_channel('ch0','rydberg_global')\n",
+    "seq.declare_channel(\"ch0\", \"rydberg_global\")\n",
     "\n",
-    "t_list = seq.declare_variable('t_list', size=LAYERS)\n",
-    "s_list = seq.declare_variable('s_list', size=LAYERS)\n",
+    "t_list = seq.declare_variable(\"t_list\", size=LAYERS)\n",
+    "s_list = seq.declare_variable(\"s_list\", size=LAYERS)\n",
     "\n",
     "if LAYERS == 1:\n",
     "    t_list = [t_list]\n",
     "    s_list = [s_list]\n",
-    "    \n",
-    "for t, s in zip(t_list, s_list): \n",
-    "    pulse_1 = Pulse.ConstantPulse(1000*t, 1., 0., 0) \n",
-    "    pulse_2 = Pulse.ConstantPulse(1000*s, 1., 1., 0)\n",
     "\n",
-    "    seq.add(pulse_1, 'ch0')\n",
-    "    seq.add(pulse_2, 'ch0')\n",
-    "    \n",
-    "seq.measure('ground-rydberg')"
+    "for t, s in zip(t_list, s_list):\n",
+    "    pulse_1 = Pulse.ConstantPulse(1000 * t, 1.0, 0.0, 0)\n",
+    "    pulse_2 = Pulse.ConstantPulse(1000 * s, 1.0, 1.0, 0)\n",
+    "\n",
+    "    seq.add(pulse_1, \"ch0\")\n",
+    "    seq.add(pulse_2, \"ch0\")\n",
+    "\n",
+    "seq.measure(\"ground-rydberg\")"
    ]
   },
   {
@@ -246,10 +254,10 @@
     "    params = np.array(parameters)\n",
     "    t_params, s_params = np.reshape(params.astype(int), (2, LAYERS))\n",
     "    assigned_seq = seq.build(t_list=t_params, s_list=s_params)\n",
-    "    simul = Simulation(assigned_seq, sampling_rate=.01)\n",
+    "    simul = Simulation(assigned_seq, sampling_rate=0.01)\n",
     "    results = simul.run()\n",
-    "    count_dict = results.sample_final_state() #sample from the state vector \n",
-    "    return count_dict "
+    "    count_dict = results.sample_final_state()  # sample from the state vector\n",
+    "    return count_dict"
    ]
   },
   {
@@ -258,8 +266,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "guess = {'t': np.random.uniform(8, 10, LAYERS),\n",
-    "         's': np.random.uniform(1, 3, LAYERS)}"
+    "guess = {\n",
+    "    \"t\": np.random.uniform(8, 10, LAYERS),\n",
+    "    \"s\": np.random.uniform(1, 3, LAYERS),\n",
+    "}"
    ]
   },
   {
@@ -268,7 +278,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "example_dict = quantum_loop(np.r_[guess['t'], guess['s']])"
+    "example_dict = quantum_loop(np.r_[guess[\"t\"], guess[\"s\"]])"
    ]
   },
   {
@@ -286,13 +296,13 @@
    "source": [
     "def plot_distribution(C):\n",
     "    C = dict(sorted(C.items(), key=lambda item: item[1], reverse=True))\n",
-    "    indexes = ['01011', '00111']  # MIS indexes\n",
-    "    color_dict = {key:'r' if key in indexes else 'g' for key in C}\n",
-    "    plt.figure(figsize=(12,6))\n",
+    "    indexes = [\"01011\", \"00111\"]  # MIS indexes\n",
+    "    color_dict = {key: \"r\" if key in indexes else \"g\" for key in C}\n",
+    "    plt.figure(figsize=(12, 6))\n",
     "    plt.xlabel(\"bitstrings\")\n",
     "    plt.ylabel(\"counts\")\n",
-    "    plt.bar(C.keys(), C.values(), width=0.5, color = color_dict.values())\n",
-    "    plt.xticks(rotation='vertical')\n",
+    "    plt.bar(C.keys(), C.values(), width=0.5, color=color_dict.values())\n",
+    "    plt.xticks(rotation=\"vertical\")\n",
     "    plt.show()"
    ]
   },
@@ -357,12 +367,13 @@
     "    z = np.array(list(bitstring), dtype=int)\n",
     "    A = np.array(G.get_adjacency().data)\n",
     "    # Add penalty and bias:\n",
-    "    cost = penalty*(z.T @ np.triu(A) @ z) - np.sum(z)\n",
-    "    return cost \n",
+    "    cost = penalty * (z.T @ np.triu(A) @ z) - np.sum(z)\n",
+    "    return cost\n",
+    "\n",
     "\n",
-    "def get_cost(counter,G):\n",
-    "    cost = sum(counter[key] * get_cost_colouring(key,G) for key in counter) \n",
-    "    return cost / sum(counter.values()) # Divide by total samples"
+    "def get_cost(counter, G):\n",
+    "    cost = sum(counter[key] * get_cost_colouring(key, G) for key in counter)\n",
+    "    return cost / sum(counter.values())  # Divide by total samples"
    ]
   },
   {
@@ -384,7 +395,7 @@
     }
    ],
    "source": [
-    "get_cost_colouring('00111', G)"
+    "get_cost_colouring(\"00111\", G)"
    ]
   },
   {
@@ -413,10 +424,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "def func(param,*args):\n",
+    "def func(param, *args):\n",
     "    G = args[0]\n",
     "    C = quantum_loop(param)\n",
-    "    cost = get_cost(C,G)\n",
+    "    cost = get_cost(C, G)\n",
     "    return cost"
    ]
   },
@@ -440,13 +451,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "res = minimize(func, \n",
-    "               args=G,\n",
-    "               x0=np.r_[guess['t'], guess['s']],\n",
-    "               method='Nelder-Mead',\n",
-    "               tol=1e-5,\n",
-    "               options = {'maxiter': 100}\n",
-    "              )"
+    "res = minimize(\n",
+    "    func,\n",
+    "    args=G,\n",
+    "    x0=np.r_[guess[\"t\"], guess[\"s\"]],\n",
+    "    method=\"Nelder-Mead\",\n",
+    "    tol=1e-5,\n",
+    "    options={\"maxiter\": 100},\n",
+    ")"
    ]
   },
   {
diff --git a/tutorials/creating_sequences.ipynb b/tutorials/creating_sequences.ipynb
index 923b79958..206d8b048 100644
--- a/tutorials/creating_sequences.ipynb
+++ b/tutorials/creating_sequences.ipynb
@@ -66,7 +66,7 @@
     "reg1 = Register(qubits)  # Copy of 'reg' to keep the original intact\n",
     "print(\"The original array:\")\n",
     "reg1.draw()\n",
-    "reg1.rotate(45)   # Rotate by 45 degrees\n",
+    "reg1.rotate(45)  # Rotate by 45 degrees\n",
     "print(\"The rotated array:\")\n",
     "reg1.draw()"
    ]
@@ -84,7 +84,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "reg2 = Register.from_coordinates(square, prefix='q')   # All qubit IDs will start with 'q'\n",
+    "reg2 = Register.from_coordinates(\n",
+    "    square, prefix=\"q\"\n",
+    ")  # All qubit IDs will start with 'q'\n",
     "reg2.draw()"
    ]
   },
@@ -105,7 +107,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "reg3 = Register.square(4, spacing=5)   # 4x4 array with atoms 5 um apart\n",
+    "reg3 = Register.square(4, spacing=5)  # 4x4 array with atoms 5 um apart\n",
     "reg3.draw()"
    ]
   },
@@ -174,11 +176,11 @@
    },
    "outputs": [],
    "source": [
-    "seq.declare_channel('ch0', 'raman_local')\n",
+    "seq.declare_channel(\"ch0\", \"raman_local\")\n",
     "print(\"Available channels after declaring 'ch0':\")\n",
     "pprint(seq.available_channels)\n",
     "\n",
-    "seq.declare_channel('ch1', 'rydberg_local', initial_target=4)\n",
+    "seq.declare_channel(\"ch1\", \"rydberg_local\", initial_target=4)\n",
     "print(\"\\nAvailable channels after declaring 'ch1':\")\n",
     "pprint(seq.available_channels)"
    ]
@@ -221,7 +223,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.target(1, 'ch0')"
+    "seq.target(1, \"ch0\")"
    ]
   },
   {
@@ -253,7 +255,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.add(simple_pulse, 'ch0')"
+    "seq.add(simple_pulse, \"ch0\")"
    ]
   },
   {
@@ -269,7 +271,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.delay(100, 'ch1')"
+    "seq.delay(100, \"ch1\")"
    ]
   },
   {
@@ -288,8 +290,10 @@
     "from pulser.waveforms import RampWaveform, BlackmanWaveform\n",
     "\n",
     "duration = 1000\n",
-    "amp_wf = BlackmanWaveform(duration, np.pi/2)   # Duration: 1000 ns, Area: pi/2\n",
-    "detuning_wf = RampWaveform(duration, -20, 20)  # Duration: 1000ns, linear sweep from -20 to 20 rad/µs"
+    "amp_wf = BlackmanWaveform(duration, np.pi / 2)  # Duration: 1000 ns, Area: pi/2\n",
+    "detuning_wf = RampWaveform(\n",
+    "    duration, -20, 20\n",
+    ")  # Duration: 1000ns, linear sweep from -20 to 20 rad/µs"
    ]
   },
   {
@@ -323,7 +327,7 @@
    },
    "outputs": [],
    "source": [
-    "amp_wf.integral   # dimensionless"
+    "amp_wf.integral  # dimensionless"
    ]
   },
   {
@@ -356,7 +360,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.add(complex_pulse, 'ch1')"
+    "seq.add(complex_pulse, \"ch1\")"
    ]
   },
   {
@@ -406,8 +410,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.target(4, 'ch0')\n",
-    "seq.add(complex_pulse, 'ch0')\n",
+    "seq.target(4, \"ch0\")\n",
+    "seq.add(complex_pulse, \"ch0\")\n",
     "\n",
     "print(\"Current Schedule:\")\n",
     "print(seq)\n",
@@ -436,9 +440,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.target(0, 'ch1')\n",
-    "seq.add(simple_pulse, 'ch1', protocol='min-delay')\n",
-    "seq.add(simple_pulse, 'ch1', protocol='wait-for-all')\n",
+    "seq.target(0, \"ch1\")\n",
+    "seq.add(simple_pulse, \"ch1\", protocol=\"min-delay\")\n",
+    "seq.add(simple_pulse, \"ch1\", protocol=\"wait-for-all\")\n",
     "\n",
     "print(\"Current Schedule:\")\n",
     "print(seq)\n",
@@ -465,8 +469,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.target(0, 'ch0')\n",
-    "seq.add(complex_pulse, 'ch0', protocol='no-delay')\n",
+    "seq.target(0, \"ch0\")\n",
+    "seq.add(complex_pulse, \"ch0\", protocol=\"no-delay\")\n",
     "\n",
     "print(\"Current Schedule:\")\n",
     "print(seq)\n",
@@ -500,7 +504,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "seq.measure(basis='ground-rydberg')"
+    "seq.measure(basis=\"ground-rydberg\")"
    ]
   },
   {
diff --git a/tutorials/quantum_simulation/Bayesian Optimisation for antiferromagnetic state preparation.ipynb b/tutorials/quantum_simulation/Bayesian Optimisation for antiferromagnetic state preparation.ipynb
index 85a792908..551633ded 100644
--- a/tutorials/quantum_simulation/Bayesian Optimisation for antiferromagnetic state preparation.ipynb
+++ b/tutorials/quantum_simulation/Bayesian Optimisation for antiferromagnetic state preparation.ipynb
@@ -87,26 +87,29 @@
    "source": [
     "# Parameters in rad/µs and ns\n",
     "\n",
-    "T = 1000    # duration\n",
+    "T = 1000  # duration\n",
     "U = 2 * np.pi * 5.0\n",
     "\n",
-    "Omega_max = 0.5 * U \n",
+    "Omega_max = 0.5 * U\n",
     "\n",
-    "delta_0 = -1.0 * U \n",
-    "delta_f = 1.0 * U \n",
+    "delta_0 = -1.0 * U\n",
+    "delta_f = 1.0 * U\n",
     "\n",
     "R_interatomic = Chadoq2.rydberg_blockade_radius(Omega_max) / 1.2\n",
-    "print(f'Interatomic Radius is: {R_interatomic}µm.')\n",
+    "print(f\"Interatomic Radius is: {R_interatomic}µm.\")\n",
     "\n",
     "N_side = 4\n",
     "coords = (\n",
-    "    [R_interatomic * np.r_[x,0] for x in range(N_side-1)] \n",
-    "    + [R_interatomic * np.r_[N_side-1,y] for y in range(N_side-1)]\n",
-    "    + [R_interatomic * np.r_[N_side-1-x,N_side-1] for x in range(N_side-1)] \n",
-    "    + [R_interatomic * np.r_[0,N_side-1-y] for y in range(N_side-1)]\n",
+    "    [R_interatomic * np.r_[x, 0] for x in range(N_side - 1)]\n",
+    "    + [R_interatomic * np.r_[N_side - 1, y] for y in range(N_side - 1)]\n",
+    "    + [\n",
+    "        R_interatomic * np.r_[N_side - 1 - x, N_side - 1]\n",
+    "        for x in range(N_side - 1)\n",
+    "    ]\n",
+    "    + [R_interatomic * np.r_[0, N_side - 1 - y] for y in range(N_side - 1)]\n",
     ")\n",
-    "reg = Register.from_coordinates(coords, prefix='q')\n",
-    "N=len(coords)\n",
+    "reg = Register.from_coordinates(coords, prefix=\"q\")\n",
+    "N = len(coords)\n",
     "N_samples = 1000\n",
     "reg.draw()"
    ]
@@ -133,15 +136,15 @@
    ],
    "source": [
     "seq = Sequence(reg, Chadoq2)\n",
-    "seq.declare_channel('ising', 'rydberg_global')\n",
+    "seq.declare_channel(\"ising\", \"rydberg_global\")\n",
     "\n",
     "tol = 1e-6\n",
-    "max_amp = seq.declared_channels['ising'].max_amp * (1-tol)\n",
-    "max_det = seq.declared_channels['ising'].max_abs_detuning * (1-tol)\n",
-    "Omega_max=min(max_amp, Omega_max)\n",
-    "delta_0=np.sign(delta_0)*min(max_det, abs(delta_0))\n",
-    "delta_f=np.sign(delta_f)*min(max_det, abs(delta_f))\n",
-    "print(Omega_max/U, np.round(delta_0/U,2), delta_f/U)"
+    "max_amp = seq.declared_channels[\"ising\"].max_amp * (1 - tol)\n",
+    "max_det = seq.declared_channels[\"ising\"].max_abs_detuning * (1 - tol)\n",
+    "Omega_max = min(max_amp, Omega_max)\n",
+    "delta_0 = np.sign(delta_0) * min(max_det, abs(delta_0))\n",
+    "delta_f = np.sign(delta_f) * min(max_det, abs(delta_f))\n",
+    "print(Omega_max / U, np.round(delta_0 / U, 2), delta_f / U)"
    ]
   },
   {
@@ -178,8 +181,8 @@
     "m = 3\n",
     "\n",
     "# Random instance of the parameter space\n",
-    "amp_params = np.random.uniform(0,Omega_max,m)\n",
-    "det_params = np.random.uniform(delta_0,delta_f,m)"
+    "amp_params = np.random.uniform(0, Omega_max, m)\n",
+    "det_params = np.random.uniform(delta_0, delta_f, m)"
    ]
   },
   {
@@ -197,7 +200,7 @@
    "source": [
     "def create_interp_pulse(amp_params, det_params):\n",
     "    return Pulse(\n",
-    "        InterpolatedWaveform(T, [1e-9, *amp_params, 1e-9]), \n",
+    "        InterpolatedWaveform(T, [1e-9, *amp_params, 1e-9]),\n",
     "        InterpolatedWaveform(T, [delta_0, *det_params, delta_f]),\n",
     "        0,\n",
     "    )"
@@ -228,8 +231,8 @@
    ],
    "source": [
     "seq = Sequence(reg, Chadoq2)\n",
-    "seq.declare_channel('ising', 'rydberg_global')\n",
-    "seq.add(create_interp_pulse(amp_params, det_params),'ising')\n",
+    "seq.declare_channel(\"ising\", \"rydberg_global\")\n",
+    "seq.add(create_interp_pulse(amp_params, det_params), \"ising\")\n",
     "seq.draw()"
    ]
   },
@@ -279,37 +282,43 @@
    "outputs": [],
    "source": [
     "def occupation(j, N):\n",
-    "    up = qutip.basis(2,0)\n",
+    "    up = qutip.basis(2, 0)\n",
     "    prod = [qutip.qeye(2) for _ in range(N)]\n",
     "    prod[j] = up * up.dag()\n",
     "    return qutip.tensor(prod)\n",
     "\n",
+    "\n",
     "def get_corr_pairs(k, N):\n",
-    "    corr_pairs = [[i,(i+k)%N] for i in range(N)]\n",
+    "    corr_pairs = [[i, (i + k) % N] for i in range(N)]\n",
     "    return corr_pairs\n",
     "\n",
+    "\n",
     "def get_corr_function(k, N, state):\n",
     "    corr_pairs = get_corr_pairs(k, N)\n",
     "    operators = [occupation(j, N) for j in range(N)]\n",
     "    covariance = 0\n",
     "    for qi, qj in corr_pairs:\n",
-    "        covariance += qutip.expect(operators[qi]*operators[qj], state)\n",
-    "        covariance -= qutip.expect(operators[qi], state)*qutip.expect(operators[qj], state)\n",
-    "    return covariance/len(corr_pairs)   \n",
+    "        covariance += qutip.expect(operators[qi] * operators[qj], state)\n",
+    "        covariance -= qutip.expect(operators[qi], state) * qutip.expect(\n",
+    "            operators[qj], state\n",
+    "        )\n",
+    "    return covariance / len(corr_pairs)\n",
+    "\n",
     "\n",
     "def get_full_corr_function(reg, state):\n",
     "    N = len(reg.qubits)\n",
     "    correlation_function = {}\n",
-    "    for k in range(-N//2, N//2+1):\n",
+    "    for k in range(-N // 2, N // 2 + 1):\n",
     "        correlation_function[k] = get_corr_function(k, N, state)\n",
     "    return correlation_function\n",
     "\n",
+    "\n",
     "def get_neel_structure_factor(reg, state):\n",
     "    N = len(reg.qubits)\n",
     "    st_fac = 0\n",
-    "    for k in range(-N//2, N//2+1):\n",
+    "    for k in range(-N // 2, N // 2 + 1):\n",
     "        kk = np.abs(k)\n",
-    "        st_fac += 4 * (-1)**kk * get_corr_function(k, N, state)\n",
+    "        st_fac += 4 * (-1) ** kk * get_corr_function(k, N, state)\n",
     "    return st_fac"
    ]
   },
@@ -328,7 +337,11 @@
    "source": [
     "def proba_from_state(results, min_p=0.1):\n",
     "    sampling = results.sample_final_state(N_samples=N_samples)\n",
-    "    return {k: f'{100*v/N_samples}%' for k, v in sampling.items() if v/N_samples > min_p}"
+    "    return {\n",
+    "        k: f\"{100*v/N_samples}%\"\n",
+    "        for k, v in sampling.items()\n",
+    "        if v / N_samples > min_p\n",
+    "    }"
    ]
   },
   {
@@ -358,11 +371,11 @@
     "AF2 = qutip.tensor([qutip.basis(2, (k + 1) % 2) for k in range(N)])\n",
     "AF_state = (AF1 + AF2).unit()\n",
     "\n",
-    "t1=time.process_time()\n",
+    "t1 = time.process_time()\n",
     "S_max = get_neel_structure_factor(reg, AF_state)\n",
-    "print('S_Neel(AF state) =', S_max)\n",
-    "t2=time.process_time()\n",
-    "print('computed in', (t2-t1),'sec')"
+    "print(\"S_Neel(AF state) =\", S_max)\n",
+    "t2 = time.process_time()\n",
+    "print(\"computed in\", (t2 - t1), \"sec\")"
    ]
   },
   {
@@ -387,16 +400,21 @@
    "source": [
     "def score(params):\n",
     "    seq = Sequence(reg, Chadoq2)\n",
-    "    seq.declare_channel('ising', 'rydberg_global')\n",
-    "    seq.add(create_interp_pulse(params[:m], params[m:]),'ising')\n",
-    "    \n",
+    "    seq.declare_channel(\"ising\", \"rydberg_global\")\n",
+    "    seq.add(create_interp_pulse(params[:m], params[m:]), \"ising\")\n",
+    "\n",
     "    simul = Simulation(seq, sampling_rate=0.5)\n",
     "    results = simul.run()\n",
     "\n",
     "    sampling = results.sample_final_state(N_samples=N_samples)\n",
-    "    sampled_state = sum([np.sqrt(sampling[k]/N_samples)*qutip.ket(k) for k in sampling.keys()])\n",
+    "    sampled_state = sum(\n",
+    "        [\n",
+    "            np.sqrt(sampling[k] / N_samples) * qutip.ket(k)\n",
+    "            for k in sampling.keys()\n",
+    "        ]\n",
+    "    )\n",
     "\n",
-    "    F = get_neel_structure_factor(reg, sampled_state)/S_max\n",
+    "    F = get_neel_structure_factor(reg, sampled_state) / S_max\n",
     "\n",
     "    return 1 - F"
    ]
@@ -472,7 +490,9 @@
     "n_r = 30\n",
     "n_c = 120\n",
     "\n",
-    "RESULT = gp_minimize(score, bounds, n_random_starts=n_r, n_calls=n_c, verbose=False)"
+    "RESULT = gp_minimize(\n",
+    "    score, bounds, n_random_starts=n_r, n_calls=n_c, verbose=False\n",
+    ")"
    ]
   },
   {
@@ -498,10 +518,10 @@
     "def sort_improv(RESULT):\n",
     "    score_vals = RESULT.func_vals\n",
     "    min = score_vals[0]\n",
-    "    score_list=[]\n",
+    "    score_list = []\n",
     "    for s in score_vals:\n",
-    "        if s<min:\n",
-    "            min=s\n",
+    "        if s < min:\n",
+    "            min = s\n",
     "            score_list.append(min)\n",
     "        else:\n",
     "            score_list.append(min)\n",
@@ -528,11 +548,11 @@
    ],
    "source": [
     "fig = plt.figure()\n",
-    "plt.semilogy(range(n_c), sort_improv(RESULT), 'r-')\n",
-    "plt.xlabel(r'$n_{calls}$', fontsize=14)\n",
-    "plt.ylabel('Score reached', fontsize=14)\n",
-    "plt.legend(['1 run'],fontsize=12)\n",
-    "plt.xlim(0,n_c)\n",
+    "plt.semilogy(range(n_c), sort_improv(RESULT), \"r-\")\n",
+    "plt.xlabel(r\"$n_{calls}$\", fontsize=14)\n",
+    "plt.ylabel(\"Score reached\", fontsize=14)\n",
+    "plt.legend([\"1 run\"], fontsize=12)\n",
+    "plt.xlim(0, n_c)\n",
     "plt.show()"
    ]
   },
@@ -579,9 +599,9 @@
    ],
    "source": [
     "seq = Sequence(reg, Chadoq2)\n",
-    "seq.declare_channel('ising', 'rydberg_global')\n",
+    "seq.declare_channel(\"ising\", \"rydberg_global\")\n",
     "P = create_interp_pulse(RESULT.x[:m], RESULT.x[m:])\n",
-    "seq.add(P,'ising')\n",
+    "seq.add(P, \"ising\")\n",
     "seq.draw()"
    ]
   },
@@ -613,21 +633,21 @@
    "source": [
     "omega = P.amplitude.samples / U\n",
     "delta = P.detuning.samples / U\n",
-    "        \n",
+    "\n",
     "fig, ax = plt.subplots()\n",
-    "ax.grid(True, which='both')\n",
+    "ax.grid(True, which=\"both\")\n",
     "\n",
     "ax.set_ylabel(r\"$\\hbar\\delta(t)/U$\", fontsize=14)\n",
     "ax.set_xlabel(r\"$\\hbar\\Omega(t)/U$\", fontsize=14)\n",
-    "ax.set_xlim(0, Omega_max/U*1.1)\n",
-    "ax.axhline(y=0, color='k')\n",
-    "ax.axvline(x=0, color='k')\n",
+    "ax.set_xlim(0, Omega_max / U * 1.1)\n",
+    "ax.axhline(y=0, color=\"k\")\n",
+    "ax.axvline(x=0, color=\"k\")\n",
     "\n",
     "y = np.arange(0.0, 3, 0.01)\n",
-    "x = 0.5 * (1 - 1.0 * (y - 1)**2)\n",
+    "x = 0.5 * (1 - 1.0 * (y - 1) ** 2)\n",
     "ax.fill_between(x, y, alpha=0.4)\n",
     "\n",
-    "ax.plot(omega,delta, 'red', lw=2)\n",
+    "ax.plot(omega, delta, \"red\", lw=2)\n",
     "plt.show()"
    ]
   },
@@ -648,13 +668,15 @@
    "source": [
     "simul = Simulation(seq)\n",
     "results = simul.run()\n",
-    "print('final =', proba_from_state(results, min_p=0.05))\n",
+    "print(\"final =\", proba_from_state(results, min_p=0.05))\n",
     "\n",
     "sampling = results.sample_final_state(N_samples=1000)\n",
-    "sampled_state = sum([np.sqrt(sampling[k]/1000) *qutip.ket(k)for k in sampling.keys()])\n",
+    "sampled_state = sum(\n",
+    "    [np.sqrt(sampling[k] / 1000) * qutip.ket(k) for k in sampling.keys()]\n",
+    ")\n",
     "\n",
-    "s_neel_sampled = np.round(get_neel_structure_factor(reg, sampled_state),3)\n",
-    "print(f'S_Neel (final_sampled) = {s_neel_sampled}')"
+    "s_neel_sampled = np.round(get_neel_structure_factor(reg, sampled_state), 3)\n",
+    "print(f\"S_Neel (final_sampled) = {s_neel_sampled}\")"
    ]
   },
   {
@@ -673,7 +695,10 @@
     "# Skip parameter. Only 1 out of 10 pts are kept to enhance the speed.\n",
     "a = 10\n",
     "time_domain_reduced = np.arange(T)[::a]\n",
-    "S_Neel_states=[get_neel_structure_factor(reg, results.states[t]) for t in time_domain_reduced]"
+    "S_Neel_states = [\n",
+    "    get_neel_structure_factor(reg, results.states[t])\n",
+    "    for t in time_domain_reduced\n",
+    "]"
    ]
   },
   {
@@ -696,9 +721,9 @@
    ],
    "source": [
     "plt.figure()\n",
-    "plt.plot(time_domain_reduced,S_Neel_states)\n",
-    "plt.xlabel('Time (ns)',fontsize=12)\n",
-    "plt.ylabel(r'$S_{Néel}$',fontsize=14)\n",
+    "plt.plot(time_domain_reduced, S_Neel_states)\n",
+    "plt.xlabel(\"Time (ns)\", fontsize=12)\n",
+    "plt.ylabel(r\"$S_{Néel}$\", fontsize=14)\n",
     "\n",
     "plt.show()"
    ]
@@ -733,14 +758,16 @@
     "final = results.get_final_state()\n",
     "correlation_function = get_full_corr_function(reg, final)\n",
     "\n",
-    "A = np.reshape(list(correlation_function.values()), (1, N+1))\n",
-    "A[0, N//2] = None\n",
+    "A = np.reshape(list(correlation_function.values()), (1, N + 1))\n",
+    "A[0, N // 2] = None\n",
     "\n",
-    "plt.figure(figsize=(10,5))\n",
-    "plt.imshow(A, cmap='coolwarm')\n",
-    "plt.xlabel('k',fontsize=12)\n",
-    "plt.xticks(range(A.shape[1]), ['{}'.format(i) for i in range(-N//2 , N//2+1)])\n",
-    "plt.title(r'$g^{(2)}(k)$ after simulation', fontsize=14)\n",
+    "plt.figure(figsize=(10, 5))\n",
+    "plt.imshow(A, cmap=\"coolwarm\")\n",
+    "plt.xlabel(\"k\", fontsize=12)\n",
+    "plt.xticks(\n",
+    "    range(A.shape[1]), [\"{}\".format(i) for i in range(-N // 2, N // 2 + 1)]\n",
+    ")\n",
+    "plt.title(r\"$g^{(2)}(k)$ after simulation\", fontsize=14)\n",
     "plt.yticks([])\n",
     "plt.colorbar()\n",
     "plt.show()"
diff --git a/tutorials/quantum_simulation/Building 1D Rydberg Crystals.ipynb b/tutorials/quantum_simulation/Building 1D Rydberg Crystals.ipynb
index c3cecfa19..87c5c4bc8 100644
--- a/tutorials/quantum_simulation/Building 1D Rydberg Crystals.ipynb
+++ b/tutorials/quantum_simulation/Building 1D Rydberg Crystals.ipynb
@@ -51,10 +51,10 @@
    "outputs": [],
    "source": [
     "def occupation(reg, j):\n",
-    "    r = qutip.basis(2,0)\n",
+    "    r = qutip.basis(2, 0)\n",
     "    N = len(reg.qubits)\n",
     "    prod = [qutip.qeye(2) for _ in range(N)]\n",
-    "    prod[j] = r*r.dag()\n",
+    "    prod[j] = r * r.dag()\n",
     "    return qutip.tensor(prod)"
    ]
   },
@@ -79,30 +79,39 @@
     }
    ],
    "source": [
-    "Omega_max = 2 * 2*np.pi\n",
+    "Omega_max = 2 * 2 * np.pi\n",
     "R_blockade = MockDevice.rydberg_blockade_radius(Omega_max)\n",
-    "print(f'Blockade Radius is: {R_blockade}µm.')\n",
+    "print(f\"Blockade Radius is: {R_blockade}µm.\")\n",
     "groups = 3\n",
     "\n",
+    "\n",
     "def blockade_cluster(N):\n",
-    "    \n",
-    "    atom_coords = [((R_blockade/N)*x+40*group,0) for group in range(groups) for x in range(1,N+1)]\n",
-    "    reg = Register.from_coordinates(atom_coords, prefix='q')\n",
-    "    reg.draw(blockade_radius=R_blockade, draw_half_radius=True, draw_graph=False)\n",
-    "    \n",
-    "    resonant_pulse = Pulse.ConstantPulse(1500, Omega_max, 0., 0.)\n",
-    "    \n",
+    "\n",
+    "    atom_coords = [\n",
+    "        ((R_blockade / N) * x + 40 * group, 0)\n",
+    "        for group in range(groups)\n",
+    "        for x in range(1, N + 1)\n",
+    "    ]\n",
+    "    reg = Register.from_coordinates(atom_coords, prefix=\"q\")\n",
+    "    reg.draw(\n",
+    "        blockade_radius=R_blockade, draw_half_radius=True, draw_graph=False\n",
+    "    )\n",
+    "\n",
+    "    resonant_pulse = Pulse.ConstantPulse(1500, Omega_max, 0.0, 0.0)\n",
+    "\n",
     "    seq = Sequence(reg, MockDevice)\n",
-    "    seq.declare_channel('ising', 'rydberg_global')\n",
-    "    seq.add(resonant_pulse, 'ising')\n",
-    "    \n",
-    "    simul = Simulation(seq, sampling_rate=.7)\n",
-    "    \n",
-    "    obs = [sum(occupation(reg, j) for j in range(i,i+N)) for i in range(0,groups*N,N)]    \n",
-    "    \n",
-    "    res = simul.run(progress_bar=True, method='bdf')\n",
-    "    return res.expect(obs)\n",
-    "    "
+    "    seq.declare_channel(\"ising\", \"rydberg_global\")\n",
+    "    seq.add(resonant_pulse, \"ising\")\n",
+    "\n",
+    "    simul = Simulation(seq, sampling_rate=0.7)\n",
+    "\n",
+    "    obs = [\n",
+    "        sum(occupation(reg, j) for j in range(i, i + N))\n",
+    "        for i in range(0, groups * N, N)\n",
+    "    ]\n",
+    "\n",
+    "    res = simul.run(progress_bar=True, method=\"bdf\")\n",
+    "    return res.expect(obs)"
    ]
   },
   {
@@ -203,7 +212,7 @@
     }
    ],
    "source": [
-    "data = [blockade_cluster(N) for N in [1,2,3]]  "
+    "data = [blockade_cluster(N) for N in [1, 2, 3]]"
    ]
   },
   {
@@ -257,11 +266,11 @@
    ],
    "source": [
     "for N, expectation in enumerate(data):\n",
-    "    plt.xlabel('Time (µs)', fontsize=14)\n",
-    "    plt.ylabel('Probability of Rydberg state', fontsize=14)\n",
-    "    plt.title(f'Atoms per cluster N = {N+1}', fontsize = 15)\n",
-    "    avg = sum(expectation)/groups\n",
-    "    plt.plot(np.arange(len(avg))/1000, avg)\n",
+    "    plt.xlabel(\"Time (µs)\", fontsize=14)\n",
+    "    plt.ylabel(\"Probability of Rydberg state\", fontsize=14)\n",
+    "    plt.title(f\"Atoms per cluster N = {N+1}\", fontsize=15)\n",
+    "    avg = sum(expectation) / groups\n",
+    "    plt.plot(np.arange(len(avg)) / 1000, avg)\n",
     "    plt.show()"
    ]
   },
@@ -293,9 +302,9 @@
    "outputs": [],
    "source": [
     "# Parameters in rad/µs and ns\n",
-    "delta_0 = -6 * 2*np.pi\n",
-    "delta_f = 10 * 2*np.pi\n",
-    "Omega_max = 2 * 2*np.pi  \n",
+    "delta_0 = -6 * 2 * np.pi\n",
+    "delta_f = 10 * 2 * np.pi\n",
+    "Omega_max = 2 * 2 * np.pi\n",
     "t_rise = 500\n",
     "t_stop = 4500\n",
     "t_end = 5000"
@@ -335,10 +344,10 @@
    ],
    "source": [
     "R_blockade = MockDevice.rydberg_blockade_radius(Omega_max)\n",
-    "a = 7.\n",
+    "a = 7.0\n",
     "\n",
-    "reg = Register.rectangle(1,11, spacing=a, prefix='q')\n",
-    "print(f'Blockade Radius is: {R_blockade}µm.')\n",
+    "reg = Register.rectangle(1, 11, spacing=a, prefix=\"q\")\n",
+    "print(f\"Blockade Radius is: {R_blockade}µm.\")\n",
     "reg.draw(blockade_radius=R_blockade, draw_half_radius=True)"
    ]
   },
@@ -357,8 +366,10 @@
    "source": [
     "hold = ConstantWaveform(t_rise, delta_0)\n",
     "excite = RampWaveform(t_stop - t_rise, delta_0, delta_f)\n",
-    "sweep = Pulse.ConstantAmplitude(Omega_max, CompositeWaveform(hold, excite), 0.)\n",
-    "stay = Pulse.ConstantPulse(t_end - t_stop, 0., delta_f, 0.)"
+    "sweep = Pulse.ConstantAmplitude(\n",
+    "    Omega_max, CompositeWaveform(hold, excite), 0.0\n",
+    ")\n",
+    "stay = Pulse.ConstantPulse(t_end - t_stop, 0.0, delta_f, 0.0)"
    ]
   },
   {
@@ -379,12 +390,12 @@
    ],
    "source": [
     "seq = Sequence(reg, MockDevice)\n",
-    "seq.declare_channel('ising', 'rydberg_global')\n",
+    "seq.declare_channel(\"ising\", \"rydberg_global\")\n",
     "\n",
-    "seq.add(sweep, 'ising')\n",
-    "seq.add(stay, 'ising')\n",
+    "seq.add(sweep, \"ising\")\n",
+    "seq.add(stay, \"ising\")\n",
     "\n",
-    "#print(seq)\n",
+    "# print(seq)\n",
     "seq.draw()"
    ]
   },
@@ -403,34 +414,33 @@
    "source": [
     "def phase_diagram(seq):\n",
     "    ratio = []\n",
-    "    for x in seq._schedule['ising']:\n",
-    "        if isinstance(x.type,Pulse):\n",
-    "            ratio += list(x.type.detuning.samples/Omega_max)\n",
+    "    for x in seq._schedule[\"ising\"]:\n",
+    "        if isinstance(x.type, Pulse):\n",
+    "            ratio += list(x.type.detuning.samples / Omega_max)\n",
     "\n",
     "    fig, ax = plt.subplots()\n",
-    "    ax.grid(True, which='both')\n",
+    "    ax.grid(True, which=\"both\")\n",
     "\n",
     "    ax.set_ylabel(r\"Interaction Range $R_b/a$\", fontsize=14)\n",
     "    ax.set_xlabel(r\"Detuning $\\delta/\\Omega$\", fontsize=14)\n",
-    "    ax.set_xlim(-4,6)\n",
-    "    ax.set_ylim(0,3.2)\n",
-    "    ax.axhline(y=0, color='k')\n",
-    "    ax.axvline(x=0, color='k')\n",
+    "    ax.set_xlim(-4, 6)\n",
+    "    ax.set_ylim(0, 3.2)\n",
+    "    ax.axhline(y=0, color=\"k\")\n",
+    "    ax.axvline(x=0, color=\"k\")\n",
     "\n",
     "    y = np.arange(0.0, 5, 0.01)\n",
-    "    x = 2*(0.6+8*(y-1.2)**2)\n",
+    "    x = 2 * (0.6 + 8 * (y - 1.2) ** 2)\n",
     "    ax.fill_between(x, y, alpha=0.4)\n",
     "\n",
     "    y = np.arange(0.0, 5, 0.01)\n",
-    "    x = 2*(0.8+50*(y-2.45)**2)\n",
+    "    x = 2 * (0.8 + 50 * (y - 2.45) ** 2)\n",
     "    ax.fill_between(x, y, alpha=0.4)\n",
     "\n",
     "    y = np.arange(0.0, 5, 0.01)\n",
-    "    x = 2*(1.+170*(y-3.06)**2)\n",
+    "    x = 2 * (1.0 + 170 * (y - 3.06) ** 2)\n",
     "    ax.fill_between(x, y, alpha=0.4)\n",
     "\n",
-    "\n",
-    "    ax.plot(np.array(ratio), np.full(len(ratio), R_blockade/a),'red',lw=2)\n",
+    "    ax.plot(np.array(ratio), np.full(len(ratio), R_blockade / a), \"red\", lw=2)\n",
     "    plt.show()"
    ]
   },
@@ -493,7 +503,7 @@
     }
    ],
    "source": [
-    "simul = Simulation(seq, sampling_rate=.1)\n",
+    "simul = Simulation(seq, sampling_rate=0.1)\n",
     "\n",
     "occup_list = [occupation(reg, j) for j in range(len(reg.qubits))]\n",
     "\n",
@@ -515,11 +525,11 @@
    "outputs": [],
    "source": [
     "def plot_evolution(results):\n",
-    "    plt.figure(figsize=(10,5))\n",
-    "    plt.xlabel('Time (µs)', fontsize=14)\n",
-    "    plt.ylabel('Rydberg Occupation Probabilty', fontsize=14)\n",
+    "    plt.figure(figsize=(10, 5))\n",
+    "    plt.xlabel(\"Time (µs)\", fontsize=14)\n",
+    "    plt.ylabel(\"Rydberg Occupation Probabilty\", fontsize=14)\n",
     "    for expv in results:\n",
-    "        plt.plot(np.arange(len(expv))/1000,expv)\n",
+    "        plt.plot(np.arange(len(expv)) / 1000, expv)\n",
     "\n",
     "    plt.show()"
    ]
@@ -559,29 +569,31 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "def heat_detuning(data,start,end):\n",
+    "def heat_detuning(data, start, end):\n",
     "    N = len(reg.qubits)\n",
     "    time_window = []\n",
-    "    x =[]\n",
-    "    detunings = simul.samples['Global']['ground-rydberg']['det'][[int(1000*t) for t in simul.sampling_times]]\n",
+    "    x = []\n",
+    "    detunings = simul.samples[\"Global\"][\"ground-rydberg\"][\"det\"][\n",
+    "        [int(1000 * t) for t in simul.sampling_times]\n",
+    "    ]\n",
     "\n",
-    "    for t,d in enumerate(detunings):\n",
+    "    for t, d in enumerate(detunings):\n",
     "        if start <= d <= end:\n",
     "            time_window.append(t)\n",
-    "            x.append(d/(2*np.pi))\n",
+    "            x.append(d / (2 * np.pi))\n",
     "\n",
-    "    y = np.arange(1,N+1)\n",
+    "    y = np.arange(1, N + 1)\n",
     "\n",
     "    X, Y = np.meshgrid(x, y)\n",
-    "    Z = np.array(data)[:,time_window]\n",
+    "    Z = np.array(data)[:, time_window]\n",
     "\n",
-    "    plt.figure(figsize=(14,3))\n",
-    "    plt.pcolormesh(X,Y,Z, cmap='hot', shading='auto')\n",
-    "    plt.xlabel('Detuning/2π (MHz)', fontsize=14)\n",
-    "    plt.ylabel('Atom in array', fontsize=14)\n",
-    "    plt.yticks(range(1,N+1), [f'q{i}' for i in range(N)], va='center')\n",
+    "    plt.figure(figsize=(14, 3))\n",
+    "    plt.pcolormesh(X, Y, Z, cmap=\"hot\", shading=\"auto\")\n",
+    "    plt.xlabel(\"Detuning/2π (MHz)\", fontsize=14)\n",
+    "    plt.ylabel(\"Atom in array\", fontsize=14)\n",
+    "    plt.yticks(range(1, N + 1), [f\"q{i}\" for i in range(N)], va=\"center\")\n",
     "    plt.colorbar(fraction=0.047, pad=0.015)\n",
-    "    \n",
+    "\n",
     "    plt.show()"
    ]
   },
@@ -693,11 +705,11 @@
    ],
    "source": [
     "a = 3.5\n",
-    "reg = Register.rectangle(1, 10, spacing=a, prefix='q')\n",
+    "reg = Register.rectangle(1, 10, spacing=a, prefix=\"q\")\n",
     "\n",
-    "delta_0 = -4 * 2*np.pi\n",
-    "delta_f = 10 * 2*np.pi\n",
-    "Omega_max = 2.0 * 2*np.pi  # btw 1.8-2 * 2pi MHz\n",
+    "delta_0 = -4 * 2 * np.pi\n",
+    "delta_f = 10 * 2 * np.pi\n",
+    "Omega_max = 2.0 * 2 * np.pi  # btw 1.8-2 * 2pi MHz\n",
     "t_rise = 600\n",
     "t_stop = 2500\n",
     "t_end = 3000\n",
@@ -707,25 +719,27 @@
     "#\n",
     "hold = ConstantWaveform(t_rise, delta_0)\n",
     "excite = RampWaveform(t_stop - t_rise, delta_0, delta_f)\n",
-    "sweep = Pulse.ConstantAmplitude(Omega_max, CompositeWaveform(hold, excite), 0.)\n",
-    "stay = Pulse.ConstantPulse(t_end - t_stop, 0., delta_f, 0.)\n",
+    "sweep = Pulse.ConstantAmplitude(\n",
+    "    Omega_max, CompositeWaveform(hold, excite), 0.0\n",
+    ")\n",
+    "stay = Pulse.ConstantPulse(t_end - t_stop, 0.0, delta_f, 0.0)\n",
     "\n",
     "#\n",
     "seq = Sequence(reg, MockDevice)\n",
-    "seq.declare_channel('ising', 'rydberg_global')\n",
+    "seq.declare_channel(\"ising\", \"rydberg_global\")\n",
     "\n",
-    "seq.add(sweep, 'ising')\n",
-    "seq.add(stay, 'ising')\n",
+    "seq.add(sweep, \"ising\")\n",
+    "seq.add(stay, \"ising\")\n",
     "\n",
-    "#print(seq)\n",
-    "#seq.draw()\n",
+    "# print(seq)\n",
+    "# seq.draw()\n",
     "phase_diagram(seq)\n",
     "\n",
     "simul = Simulation(seq, sampling_rate=0.1)\n",
     "\n",
     "occup_list = [occupation(reg, j) for j in range(simul._size)]\n",
     "\n",
-    "res = simul.run(progress_bar=True, method='bdf')\n",
+    "res = simul.run(progress_bar=True, method=\"bdf\")\n",
     "\n",
     "occupations = res.expect(occup_list)\n",
     "\n",
@@ -819,13 +833,13 @@
    ],
    "source": [
     "a = 2.8\n",
-    "reg = Register.rectangle(1,9, spacing=a, prefix='q')\n",
+    "reg = Register.rectangle(1, 9, spacing=a, prefix=\"q\")\n",
     "\n",
     "# Parameters in rad/µs and ns\n",
     "\n",
-    "delta_0 = -4 * 2*np.pi\n",
-    "delta_f = 10 * 2*np.pi\n",
-    "Omega_max = 2.0 * 2*np.pi  # btw 1.8-2 2pi*MHz\n",
+    "delta_0 = -4 * 2 * np.pi\n",
+    "delta_f = 10 * 2 * np.pi\n",
+    "Omega_max = 2.0 * 2 * np.pi  # btw 1.8-2 2pi*MHz\n",
     "t_rise = 600\n",
     "t_stop = 2500\n",
     "t_end = 3000\n",
@@ -835,28 +849,30 @@
     "#\n",
     "hold = ConstantWaveform(t_rise, delta_0)\n",
     "excite = RampWaveform(t_stop - t_rise, delta_0, delta_f)\n",
-    "sweep = Pulse.ConstantAmplitude(Omega_max, CompositeWaveform(hold, excite), 0.)\n",
-    "stay = Pulse.ConstantPulse(t_end - t_stop, 0., delta_f, 0.)\n",
+    "sweep = Pulse.ConstantAmplitude(\n",
+    "    Omega_max, CompositeWaveform(hold, excite), 0.0\n",
+    ")\n",
+    "stay = Pulse.ConstantPulse(t_end - t_stop, 0.0, delta_f, 0.0)\n",
     "\n",
     "#\n",
     "seq = Sequence(reg, MockDevice)\n",
-    "seq.declare_channel('ising', 'rydberg_global')\n",
+    "seq.declare_channel(\"ising\", \"rydberg_global\")\n",
     "\n",
-    "seq.add(sweep, 'ising')\n",
-    "seq.add(stay, 'ising')\n",
+    "seq.add(sweep, \"ising\")\n",
+    "seq.add(stay, \"ising\")\n",
     "\n",
     "phase_diagram(seq)\n",
     "\n",
     "simul = Simulation(seq, sampling_rate=0.6)\n",
-    "    \n",
+    "\n",
     "occup_list = [occupation(reg, j) for j in range(simul._size)]\n",
     "\n",
     "#\n",
-    "res = simul.run(progress_bar=True, method='bdf')\n",
+    "res = simul.run(progress_bar=True, method=\"bdf\")\n",
     "occupations = res.expect(occup_list)\n",
     "\n",
-    "plot_evolution(occupations)        \n",
-    "heat_detuning(occupations,delta_0,delta_f)\n"
+    "plot_evolution(occupations)\n",
+    "heat_detuning(occupations, delta_0, delta_f)"
    ]
   }
  ],
diff --git a/tutorials/quantum_simulation/Microwave-engineering of programmable XXZ Hamiltonians in arrays of Rydberg atoms.ipynb b/tutorials/quantum_simulation/Microwave-engineering of programmable XXZ Hamiltonians in arrays of Rydberg atoms.ipynb
index 0fec7863c..45e0ae741 100644
--- a/tutorials/quantum_simulation/Microwave-engineering of programmable XXZ Hamiltonians in arrays of Rydberg atoms.ipynb
+++ b/tutorials/quantum_simulation/Microwave-engineering of programmable XXZ Hamiltonians in arrays of Rydberg atoms.ipynb
@@ -52,10 +52,16 @@
     "# Times are in ns\n",
     "t_pulse = 26\n",
     "\n",
-    "X_pulse = Pulse.ConstantDetuning(BlackmanWaveform(t_pulse, np.pi/2.), 0, 0)\n",
-    "Y_pulse = Pulse.ConstantDetuning(BlackmanWaveform(t_pulse, np.pi/2.), 0, -np.pi/2)\n",
-    "mX_pulse = Pulse.ConstantDetuning(BlackmanWaveform(t_pulse, np.pi/2.), 0, np.pi)\n",
-    "mY_pulse = Pulse.ConstantDetuning(BlackmanWaveform(t_pulse, np.pi/2.), 0, np.pi/2)"
+    "X_pulse = Pulse.ConstantDetuning(BlackmanWaveform(t_pulse, np.pi / 2.0), 0, 0)\n",
+    "Y_pulse = Pulse.ConstantDetuning(\n",
+    "    BlackmanWaveform(t_pulse, np.pi / 2.0), 0, -np.pi / 2\n",
+    ")\n",
+    "mX_pulse = Pulse.ConstantDetuning(\n",
+    "    BlackmanWaveform(t_pulse, np.pi / 2.0), 0, np.pi\n",
+    ")\n",
+    "mY_pulse = Pulse.ConstantDetuning(\n",
+    "    BlackmanWaveform(t_pulse, np.pi / 2.0), 0, np.pi / 2\n",
+    ")"
    ]
   },
   {
@@ -72,19 +78,19 @@
    "outputs": [],
    "source": [
     "def Floquet_XXZ_cycles(n_cycles, tau_1, tau_2, t_pulse):\n",
-    "    t_half = t_pulse/2.\n",
-    "    tau_3 = tau_2 \n",
-    "    tc = 4*tau_2 + 2*tau_1\n",
+    "    t_half = t_pulse / 2.0\n",
+    "    tau_3 = tau_2\n",
+    "    tc = 4 * tau_2 + 2 * tau_1\n",
     "    for _ in range(n_cycles):\n",
-    "        seq.delay(tau_1-t_half, 'MW')\n",
-    "        seq.add(X_pulse, 'MW')\n",
-    "        seq.delay(tau_2-2*t_half, 'MW')\n",
-    "        seq.add(mY_pulse, 'MW')\n",
-    "        seq.delay(2*tau_3-2*t_half, 'MW')\n",
-    "        seq.add(Y_pulse, 'MW')\n",
-    "        seq.delay(tau_2-2*t_half, 'MW')\n",
-    "        seq.add(mX_pulse, 'MW')\n",
-    "        seq.delay(tau_1-t_half, 'MW')"
+    "        seq.delay(tau_1 - t_half, \"MW\")\n",
+    "        seq.add(X_pulse, \"MW\")\n",
+    "        seq.delay(tau_2 - 2 * t_half, \"MW\")\n",
+    "        seq.add(mY_pulse, \"MW\")\n",
+    "        seq.delay(2 * tau_3 - 2 * t_half, \"MW\")\n",
+    "        seq.add(Y_pulse, \"MW\")\n",
+    "        seq.delay(tau_2 - 2 * t_half, \"MW\")\n",
+    "        seq.add(mX_pulse, \"MW\")\n",
+    "        seq.delay(tau_1 - t_half, \"MW\")"
    ]
   },
   {
@@ -106,18 +112,18 @@
     "reg = Register(qubits)\n",
     "\n",
     "seq = Sequence(reg, MockDevice)\n",
-    "seq.declare_channel('MW', 'mw_global')\n",
-    "seq.set_magnetic_field(0., 0., 1.)\n",
+    "seq.declare_channel(\"MW\", \"mw_global\")\n",
+    "seq.set_magnetic_field(0.0, 0.0, 1.0)\n",
     "\n",
     "tc = 300\n",
-    "seq.delay(3 * tc, 'MW')\n",
-    "Floquet_XXZ_cycles(4, tc/6., tc/6., t_pulse)\n",
-    "seq.delay(6 * tc, 'MW')  \n",
+    "seq.delay(3 * tc, \"MW\")\n",
+    "Floquet_XXZ_cycles(4, tc / 6.0, tc / 6.0, t_pulse)\n",
+    "seq.delay(6 * tc, \"MW\")\n",
     "\n",
     "# Here are our evaluation times\n",
-    "t_list= []\n",
+    "t_list = []\n",
     "for p in range(13):\n",
-    "    t_list.append(tc/1000.*p)"
+    "    t_list.append(tc / 1000.0 * p)"
    ]
   },
   {
@@ -154,7 +160,7 @@
    "outputs": [],
    "source": [
     "sim = Simulation(seq, sampling_rate=1.0, config=None, evaluation_times=t_list)\n",
-    "psi_y = (qutip.basis(2, 0)+1j*qutip.basis(2, 1)).unit()\n",
+    "psi_y = (qutip.basis(2, 0) + 1j * qutip.basis(2, 1)).unit()\n",
     "sim.initial_state = qutip.tensor(psi_y, psi_y)\n",
     "res = sim.run()"
    ]
@@ -167,7 +173,7 @@
    "source": [
     "sy = qutip.sigmay()\n",
     "Id = qutip.qeye(2)\n",
-    "Sigma_y = (qutip.tensor(sy, Id)+qutip.tensor(Id, sy))/2.\n",
+    "Sigma_y = (qutip.tensor(sy, Id) + qutip.tensor(Id, sy)) / 2.0\n",
     "Sigma_y_res = res.expect([Sigma_y])"
    ]
   },
@@ -194,13 +200,13 @@
     "\n",
     "# Showing the Hamiltonian engineering period.\n",
     "line1 = 0.9\n",
-    "line2 = 2.1 \n",
-    "plt.axvspan(line1, line2, alpha=.1, color='grey')\n",
-    "plt.text(1., 0.5, r\"$H_{XX} \\to H_{XXX}$\", fontsize=14)\n",
+    "line2 = 2.1\n",
+    "plt.axvspan(line1, line2, alpha=0.1, color=\"grey\")\n",
+    "plt.text(1.0, 0.5, r\"$H_{XX} \\to H_{XXX}$\", fontsize=14)\n",
     "\n",
-    "plt.plot(sim.evaluation_times, Sigma_y_res[0], 'o')\n",
+    "plt.plot(sim.evaluation_times, Sigma_y_res[0], \"o\")\n",
     "plt.xlabel(r\"Time [µs]\", fontsize=16)\n",
-    "plt.ylabel(fr'$ (\\langle \\sigma_1^y + \\sigma_2^y \\rangle)/2$', fontsize=16)\n",
+    "plt.ylabel(rf\"$ (\\langle \\sigma_1^y + \\sigma_2^y \\rangle)/2$\", fontsize=16)\n",
     "plt.show()"
    ]
   },
@@ -227,14 +233,14 @@
    "outputs": [],
    "source": [
     "def Floquet_XX2Z_cycles(n_cycles, t_pulse):\n",
-    "    t_half = t_pulse/2.\n",
-    "    tau_3 = tau_2 = tc/4.\n",
+    "    t_half = t_pulse / 2.0\n",
+    "    tau_3 = tau_2 = tc / 4.0\n",
     "    for _ in range(n_cycles):\n",
-    "        seq.delay(tau_2-t_half, 'MW')\n",
-    "        seq.add(mY_pulse, 'MW')\n",
-    "        seq.delay(2*tau_3-2*t_half, 'MW')\n",
-    "        seq.add(Y_pulse, 'MW')\n",
-    "        seq.delay(tau_2-t_half, 'MW')"
+    "        seq.delay(tau_2 - t_half, \"MW\")\n",
+    "        seq.add(mY_pulse, \"MW\")\n",
+    "        seq.delay(2 * tau_3 - 2 * t_half, \"MW\")\n",
+    "        seq.add(Y_pulse, \"MW\")\n",
+    "        seq.delay(tau_2 - t_half, \"MW\")"
    ]
   },
   {
@@ -244,10 +250,10 @@
    "outputs": [],
    "source": [
     "N_at = 10\n",
-    "# Number of Floquet cycles \n",
-    "N_cycles = 20 \n",
+    "# Number of Floquet cycles\n",
+    "N_cycles = 20\n",
     "# In the following, we will take 1000 projective measurements of the system at the final time.\n",
-    "N_samples = 1000 "
+    "N_samples = 1000"
    ]
   },
   {
@@ -265,8 +271,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "initial_pi_pulse = Pulse.ConstantDetuning(BlackmanWaveform(2*t_pulse, np.pi), 0, 0)\n",
-    "masked_indices = np.arange(N_at//2)"
+    "initial_pi_pulse = Pulse.ConstantDetuning(\n",
+    "    BlackmanWaveform(2 * t_pulse, np.pi), 0, 0\n",
+    ")\n",
+    "masked_indices = np.arange(N_at // 2)"
    ]
   },
   {
@@ -279,25 +287,32 @@
     "reg = Register.rectangle(1, N_at, 10)\n",
     "magnetizations_obc = np.zeros((N_at, N_cycles), dtype=float)\n",
     "correl_obc = np.zeros(N_cycles, dtype=float)\n",
-    "for m in range(N_cycles):     # Runtime close to 2 min!\n",
+    "for m in range(N_cycles):  # Runtime close to 2 min!\n",
     "    seq = Sequence(reg, MockDevice)\n",
-    "    seq.declare_channel('MW', 'mw_global')\n",
-    "    seq.set_magnetic_field(0., 0., 1.)\n",
+    "    seq.declare_channel(\"MW\", \"mw_global\")\n",
+    "    seq.set_magnetic_field(0.0, 0.0, 1.0)\n",
     "    # Configure the SLM mask, that will prevent the masked qubits from interacting with the first global \\pi pulse\n",
     "    seq.config_slm_mask(masked_indices)\n",
-    "    seq.add(initial_pi_pulse, 'MW')\n",
-    "    seq.add(X_pulse, 'MW')\n",
+    "    seq.add(initial_pi_pulse, \"MW\")\n",
+    "    seq.add(X_pulse, \"MW\")\n",
     "    Floquet_XX2Z_cycles(m, t_pulse)\n",
-    "    seq.add(mX_pulse, 'MW')\n",
+    "    seq.add(mX_pulse, \"MW\")\n",
     "    sim = Simulation(seq)\n",
     "    res = sim.run()\n",
     "    samples = res.sample_final_state(N_samples)\n",
-    "    correl = 0.\n",
+    "    correl = 0.0\n",
     "    for key, value in samples.items():\n",
     "        for j in range(N_at):\n",
-    "            correl -= (2*float(key[j])-1)*(2*float(key[(j+1)%N_at])-1)*value/N_samples \n",
-    "            magnetizations_obc[j][m] += (2*float(key[j])-1)*value/N_samples\n",
-    "    correl_obc[m] = N_at/2+correl/2"
+    "            correl -= (\n",
+    "                (2 * float(key[j]) - 1)\n",
+    "                * (2 * float(key[(j + 1) % N_at]) - 1)\n",
+    "                * value\n",
+    "                / N_samples\n",
+    "            )\n",
+    "            magnetizations_obc[j][m] += (\n",
+    "                (2 * float(key[j]) - 1) * value / N_samples\n",
+    "            )\n",
+    "    correl_obc[m] = N_at / 2 + correl / 2"
    ]
   },
   {
@@ -307,29 +322,49 @@
    "outputs": [],
    "source": [
     "# Circular geometry\n",
-    "coords = 10.*N_at/(2*np.pi)*np.array([(np.cos(theta*2*np.pi/N_at), np.sin(theta*2*np.pi/N_at)) for theta in range(N_at)])\n",
+    "coords = (\n",
+    "    10.0\n",
+    "    * N_at\n",
+    "    / (2 * np.pi)\n",
+    "    * np.array(\n",
+    "        [\n",
+    "            (\n",
+    "                np.cos(theta * 2 * np.pi / N_at),\n",
+    "                np.sin(theta * 2 * np.pi / N_at),\n",
+    "            )\n",
+    "            for theta in range(N_at)\n",
+    "        ]\n",
+    "    )\n",
+    ")\n",
     "reg = Register.from_coordinates(coords)\n",
     "\n",
     "magnetizations_pbc = np.zeros((N_at, N_cycles), dtype=float)\n",
     "correl_pbc = np.zeros(N_cycles, dtype=float)\n",
-    "for m in range(N_cycles):     # Runtime close to 2 min!\n",
+    "for m in range(N_cycles):  # Runtime close to 2 min!\n",
     "    seq = Sequence(reg, MockDevice)\n",
-    "    seq.declare_channel('MW', 'mw_global')\n",
-    "    seq.set_magnetic_field(0., 0., 1.)\n",
+    "    seq.declare_channel(\"MW\", \"mw_global\")\n",
+    "    seq.set_magnetic_field(0.0, 0.0, 1.0)\n",
     "    seq.config_slm_mask(masked_indices)\n",
-    "    seq.add(initial_pi_pulse, 'MW')\n",
-    "    seq.add(X_pulse, 'MW')\n",
+    "    seq.add(initial_pi_pulse, \"MW\")\n",
+    "    seq.add(X_pulse, \"MW\")\n",
     "    Floquet_XX2Z_cycles(m, t_pulse)\n",
-    "    seq.add(mX_pulse, 'MW')\n",
+    "    seq.add(mX_pulse, \"MW\")\n",
     "    sim = Simulation(seq)\n",
     "    res = sim.run()\n",
     "    samples = res.sample_final_state(N_samples)\n",
-    "    correl = 0.\n",
+    "    correl = 0.0\n",
     "    for key, value in samples.items():\n",
     "        for j in range(N_at):\n",
-    "            correl -= (2*float(key[j])-1)*(2*float(key[(j+1)%N_at])-1)*value/N_samples \n",
-    "            magnetizations_pbc[j][m] += (2*float(key[j])-1)*value/N_samples\n",
-    "    correl_pbc[m] = N_at/2+correl/2"
+    "            correl -= (\n",
+    "                (2 * float(key[j]) - 1)\n",
+    "                * (2 * float(key[(j + 1) % N_at]) - 1)\n",
+    "                * value\n",
+    "                / N_samples\n",
+    "            )\n",
+    "            magnetizations_pbc[j][m] += (\n",
+    "                (2 * float(key[j]) - 1) * value / N_samples\n",
+    "            )\n",
+    "    correl_pbc[m] = N_at / 2 + correl / 2"
    ]
   },
   {
@@ -370,22 +405,22 @@
     }
    ],
    "source": [
-    "fig, ax = plt.subplots(1,1)\n",
-    "img = ax.imshow(magnetizations_obc, cmap=plt.get_cmap('RdBu'))\n",
-    "plt.title('OBC',fontsize=16)\n",
-    "ax.set_xlabel('Cycle',fontsize=16)\n",
-    "ax.set_ylabel('Atom number',fontsize=16)\n",
+    "fig, ax = plt.subplots(1, 1)\n",
+    "img = ax.imshow(magnetizations_obc, cmap=plt.get_cmap(\"RdBu\"))\n",
+    "plt.title(\"OBC\", fontsize=16)\n",
+    "ax.set_xlabel(\"Cycle\", fontsize=16)\n",
+    "ax.set_ylabel(\"Atom number\", fontsize=16)\n",
     "cbar = fig.colorbar(img, shrink=0.7)\n",
-    "cbar.set_label(r'$\\langle \\sigma^z \\rangle$', fontsize=16)\n",
+    "cbar.set_label(r\"$\\langle \\sigma^z \\rangle$\", fontsize=16)\n",
     "\n",
     "\n",
-    "fig, ax = plt.subplots(1,1)\n",
-    "img = ax.imshow(magnetizations_pbc, cmap=plt.get_cmap('RdBu'))\n",
-    "plt.title('PBC',fontsize=16)\n",
-    "ax.set_xlabel('Cycle',fontsize=16)\n",
-    "ax.set_ylabel('Atom number',fontsize=16)\n",
+    "fig, ax = plt.subplots(1, 1)\n",
+    "img = ax.imshow(magnetizations_pbc, cmap=plt.get_cmap(\"RdBu\"))\n",
+    "plt.title(\"PBC\", fontsize=16)\n",
+    "ax.set_xlabel(\"Cycle\", fontsize=16)\n",
+    "ax.set_ylabel(\"Atom number\", fontsize=16)\n",
     "cbar = fig.colorbar(img, shrink=0.7)\n",
-    "cbar.set_label(r'$\\langle \\sigma^z \\rangle$', fontsize=16)"
+    "cbar.set_label(r\"$\\langle \\sigma^z \\rangle$\", fontsize=16)"
    ]
   },
   {
@@ -424,12 +459,15 @@
     }
    ],
    "source": [
-    "fig, ax = plt.subplots(1,1)\n",
-    "plt.title(r'Evolution of $\\langle N_{flip} \\rangle$ in time for OBC (red) and PBC (blue).', fontsize=16)\n",
-    "ax.set_xlabel('Cycle',fontsize=16)\n",
-    "ax.set_ylabel(r'$\\langle N_{flip} \\rangle$',fontsize=14)\n",
-    "ax.plot(correl_pbc,'--o',color='blue')\n",
-    "ax.plot(correl_obc,'--o',color='red')"
+    "fig, ax = plt.subplots(1, 1)\n",
+    "plt.title(\n",
+    "    r\"Evolution of $\\langle N_{flip} \\rangle$ in time for OBC (red) and PBC (blue).\",\n",
+    "    fontsize=16,\n",
+    ")\n",
+    "ax.set_xlabel(\"Cycle\", fontsize=16)\n",
+    "ax.set_ylabel(r\"$\\langle N_{flip} \\rangle$\", fontsize=14)\n",
+    "ax.plot(correl_pbc, \"--o\", color=\"blue\")\n",
+    "ax.plot(correl_obc, \"--o\", color=\"red\")"
    ]
   },
   {
@@ -445,27 +483,29 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "N_cycles = 26  \n",
+    "N_cycles = 26\n",
     "magnetizations_pbc = np.zeros((N_at, N_cycles), dtype=float)\n",
     "samples_evol = []\n",
     "masked_indices = [0, 1, 2]\n",
-    "for m in range(N_cycles): # Runtime close to 4 min!\n",
+    "for m in range(N_cycles):  # Runtime close to 4 min!\n",
     "    seq = Sequence(reg, MockDevice)\n",
-    "    seq.set_magnetic_field(0., 0., 1.)\n",
-    "    seq.declare_channel('MW', 'mw_global')\n",
+    "    seq.set_magnetic_field(0.0, 0.0, 1.0)\n",
+    "    seq.declare_channel(\"MW\", \"mw_global\")\n",
     "    seq.config_slm_mask(masked_indices)\n",
-    "    seq.add(initial_pi_pulse, 'MW')\n",
-    "    seq.add(X_pulse, 'MW')\n",
+    "    seq.add(initial_pi_pulse, \"MW\")\n",
+    "    seq.add(X_pulse, \"MW\")\n",
     "    Floquet_XX2Z_cycles(m, t_pulse)\n",
-    "    seq.add(mX_pulse, 'MW')\n",
+    "    seq.add(mX_pulse, \"MW\")\n",
     "    sim = Simulation(seq)\n",
     "    res = sim.run()\n",
     "    samples = res.sample_final_state(N_samples)\n",
     "    samples_evol.append(samples)\n",
-    "    correl = 0.\n",
+    "    correl = 0.0\n",
     "    for key, value in samples.items():\n",
     "        for j in range(N_at):\n",
-    "            magnetizations_pbc[j][m] += (2*float(key[j])-1)*value/N_samples"
+    "            magnetizations_pbc[j][m] += (\n",
+    "                (2 * float(key[j]) - 1) * value / N_samples\n",
+    "            )"
    ]
   },
   {
@@ -487,12 +527,12 @@
     }
    ],
    "source": [
-    "fig, ax = plt.subplots(1,1)\n",
-    "img = ax.imshow(magnetizations_pbc, cmap=plt.get_cmap('RdBu'))\n",
-    "ax.set_xlabel('Cycle', fontsize=16)\n",
-    "ax.set_ylabel('Atom number', fontsize=16)\n",
+    "fig, ax = plt.subplots(1, 1)\n",
+    "img = ax.imshow(magnetizations_pbc, cmap=plt.get_cmap(\"RdBu\"))\n",
+    "ax.set_xlabel(\"Cycle\", fontsize=16)\n",
+    "ax.set_ylabel(\"Atom number\", fontsize=16)\n",
     "cbar = fig.colorbar(img)\n",
-    "cbar.set_label(r'$\\langle \\sigma^z \\rangle$', fontsize=16)"
+    "cbar.set_label(r\"$\\langle \\sigma^z \\rangle$\", fontsize=16)"
    ]
   },
   {
@@ -665,14 +705,33 @@
     }
    ],
    "source": [
-    "dw_preserved = ['0001111111', '1000111111', '1100011111', '1110001111', \n",
-    "                 '1111000111', '1111100011', '1111110001', '1111111000', '0111111100', '0011111110']\n",
+    "dw_preserved = [\n",
+    "    \"0001111111\",\n",
+    "    \"1000111111\",\n",
+    "    \"1100011111\",\n",
+    "    \"1110001111\",\n",
+    "    \"1111000111\",\n",
+    "    \"1111100011\",\n",
+    "    \"1111110001\",\n",
+    "    \"1111111000\",\n",
+    "    \"0111111100\",\n",
+    "    \"0011111110\",\n",
+    "]\n",
     "\n",
-    "for n_cycle in [2*k for k in range(int(N_cycles/2))]:    # Runtime close to 2 min !\n",
-    "    color_dict = {key: 'red' if key in dw_preserved else 'black' for key in samples_evol[n_cycle]}\n",
+    "for n_cycle in [\n",
+    "    2 * k for k in range(int(N_cycles / 2))\n",
+    "]:  # Runtime close to 2 min !\n",
+    "    color_dict = {\n",
+    "        key: \"red\" if key in dw_preserved else \"black\"\n",
+    "        for key in samples_evol[n_cycle]\n",
+    "    }\n",
     "    plt.figure(figsize=(16, 5))\n",
-    "    plt.title(r'Cycle $= {}$'.format(n_cycle), fontsize=18)\n",
-    "    plt.bar(samples_evol[n_cycle].keys(), samples_evol[n_cycle].values(), color=color_dict.values())\n",
+    "    plt.title(r\"Cycle $= {}$\".format(n_cycle), fontsize=18)\n",
+    "    plt.bar(\n",
+    "        samples_evol[n_cycle].keys(),\n",
+    "        samples_evol[n_cycle].values(),\n",
+    "        color=color_dict.values(),\n",
+    "    )\n",
     "    plt.xlabel(\"bitstrings\", fontsize=16)\n",
     "    plt.ylabel(\"counts\", fontsize=16)\n",
     "    plt.xticks(rotation=90)\n",
diff --git a/tutorials/quantum_simulation/Preparing state with antiferromagnetic order in the Ising model.ipynb b/tutorials/quantum_simulation/Preparing state with antiferromagnetic order in the Ising model.ipynb
index 6700ab7b0..e1a2f0803 100644
--- a/tutorials/quantum_simulation/Preparing state with antiferromagnetic order in the Ising model.ipynb
+++ b/tutorials/quantum_simulation/Preparing state with antiferromagnetic order in the Ising model.ipynb
@@ -59,7 +59,7 @@
    "outputs": [],
    "source": [
     "# Parameters in rad/µs and ns\n",
-    "Omega_max = 2.3 * 2*np.pi \n",
+    "Omega_max = 2.3 * 2 * np.pi\n",
     "U = Omega_max / 2.3\n",
     "\n",
     "delta_0 = -6 * U\n",
@@ -67,13 +67,13 @@
     "\n",
     "t_rise = 252\n",
     "t_fall = 500\n",
-    "t_sweep = (delta_f - delta_0)/(2 * np.pi * 10) * 1000\n",
+    "t_sweep = (delta_f - delta_0) / (2 * np.pi * 10) * 1000\n",
     "\n",
     "R_interatomic = Chadoq2.rydberg_blockade_radius(U)\n",
     "\n",
     "N_side = 3\n",
-    "reg = Register.square(N_side, R_interatomic, prefix='q')\n",
-    "print(f'Interatomic Radius is: {R_interatomic}µm.')\n",
+    "reg = Register.square(N_side, R_interatomic, prefix=\"q\")\n",
+    "print(f\"Interatomic Radius is: {R_interatomic}µm.\")\n",
     "reg.draw()"
    ]
   },
@@ -97,9 +97,15 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "rise = Pulse.ConstantDetuning(RampWaveform(t_rise, 0., Omega_max), delta_0, 0.)\n",
-    "sweep = Pulse.ConstantAmplitude(Omega_max, RampWaveform(t_sweep, delta_0, delta_f), 0.)\n",
-    "fall = Pulse.ConstantDetuning(RampWaveform(t_fall, Omega_max, 0.), delta_f, 0.)"
+    "rise = Pulse.ConstantDetuning(\n",
+    "    RampWaveform(t_rise, 0.0, Omega_max), delta_0, 0.0\n",
+    ")\n",
+    "sweep = Pulse.ConstantAmplitude(\n",
+    "    Omega_max, RampWaveform(t_sweep, delta_0, delta_f), 0.0\n",
+    ")\n",
+    "fall = Pulse.ConstantDetuning(\n",
+    "    RampWaveform(t_fall, Omega_max, 0.0), delta_f, 0.0\n",
+    ")"
    ]
   },
   {
@@ -109,11 +115,11 @@
    "outputs": [],
    "source": [
     "seq = Sequence(reg, Chadoq2)\n",
-    "seq.declare_channel('ising', 'rydberg_global')\n",
+    "seq.declare_channel(\"ising\", \"rydberg_global\")\n",
     "\n",
-    "seq.add(rise, 'ising')\n",
-    "seq.add(sweep, 'ising')\n",
-    "seq.add(fall, 'ising')\n",
+    "seq.add(rise, \"ising\")\n",
+    "seq.add(sweep, \"ising\")\n",
+    "seq.add(fall, \"ising\")\n",
     "\n",
     "seq.draw()"
    ]
@@ -140,25 +146,25 @@
    "source": [
     "delta = []\n",
     "omega = []\n",
-    "for x in seq._schedule['ising']:\n",
-    "    if isinstance(x.type,Pulse):\n",
+    "for x in seq._schedule[\"ising\"]:\n",
+    "    if isinstance(x.type, Pulse):\n",
     "        omega += list(x.type.amplitude.samples / U)\n",
     "        delta += list(x.type.detuning.samples / U)\n",
-    "        \n",
+    "\n",
     "fig, ax = plt.subplots()\n",
-    "ax.grid(True, which='both')\n",
+    "ax.grid(True, which=\"both\")\n",
     "\n",
     "ax.set_ylabel(r\"$\\hbar\\delta(t)/U$\", fontsize=16)\n",
     "ax.set_xlabel(r\"$\\hbar\\Omega(t)/U$\", fontsize=16)\n",
     "ax.set_xlim(0, 3)\n",
-    "ax.axhline(y=0, color='k')\n",
-    "ax.axvline(x=0, color='k')\n",
+    "ax.axhline(y=0, color=\"k\")\n",
+    "ax.axvline(x=0, color=\"k\")\n",
     "\n",
     "y = np.arange(0.0, 6, 0.01)\n",
-    "x = 1.522 * (1 - 0.25 * (y - 2)**2)\n",
+    "x = 1.522 * (1 - 0.25 * (y - 2) ** 2)\n",
     "ax.fill_between(x, y, alpha=0.4)\n",
     "\n",
-    "ax.plot(omega,delta, 'red', lw=2)\n",
+    "ax.plot(omega, delta, \"red\", lw=2)\n",
     "plt.show()"
    ]
   },
@@ -208,9 +214,9 @@
    "source": [
     "count = results.sample_final_state()\n",
     "\n",
-    "most_freq = {k:v for k,v in count.items() if v>10}\n",
+    "most_freq = {k: v for k, v in count.items() if v > 10}\n",
     "plt.bar(list(most_freq.keys()), list(most_freq.values()))\n",
-    "plt.xticks(rotation='vertical')\n",
+    "plt.xticks(rotation=\"vertical\")\n",
     "plt.show()"
    ]
   },
@@ -227,8 +233,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "def occupation(j,N):\n",
-    "    up = qutip.basis(2,0)\n",
+    "def occupation(j, N):\n",
+    "    up = qutip.basis(2, 0)\n",
     "    prod = [qutip.qeye(2) for _ in range(N)]\n",
     "    prod[j] = up * up.dag()\n",
     "    return qutip.tensor(prod)"
@@ -240,7 +246,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "occup_list = [occupation(j, N_side*N_side) for j in range(N_side*N_side)]"
+    "occup_list = [occupation(j, N_side * N_side) for j in range(N_side * N_side)]"
    ]
   },
   {
@@ -260,8 +266,8 @@
     "    corr_pairs = []\n",
     "    for i, qi in enumerate(register.qubits):\n",
     "        for j, qj in enumerate(register.qubits):\n",
-    "            r_ij = register.qubits[qi]-register.qubits[qj]\n",
-    "            distance = np.linalg.norm(r_ij - R_interatomic*np.array([k, l]))\n",
+    "            r_ij = register.qubits[qi] - register.qubits[qj]\n",
+    "            distance = np.linalg.norm(r_ij - R_interatomic * np.array([k, l]))\n",
     "            if distance < 1:\n",
     "                corr_pairs.append([i, j])\n",
     "    return corr_pairs"
@@ -283,22 +289,27 @@
     "def get_corr_function(k, l, reg, R_interatomic, state):\n",
     "    N_qubits = len(reg.qubits)\n",
     "    corr_pairs = get_corr_pairs(k, l, reg, R_interatomic)\n",
-    "    \n",
+    "\n",
     "    operators = [occupation(j, N_qubits) for j in range(N_qubits)]\n",
     "    covariance = 0\n",
     "    for qi, qj in corr_pairs:\n",
-    "        covariance += qutip.expect(operators[qi]*operators[qj], state)\n",
-    "        covariance -= qutip.expect(operators[qi], state)*qutip.expect(operators[qj], state)\n",
-    "    return covariance/len(corr_pairs)\n",
-    "        \n",
+    "        covariance += qutip.expect(operators[qi] * operators[qj], state)\n",
+    "        covariance -= qutip.expect(operators[qi], state) * qutip.expect(\n",
+    "            operators[qj], state\n",
+    "        )\n",
+    "    return covariance / len(corr_pairs)\n",
+    "\n",
+    "\n",
     "def get_full_corr_function(reg, state):\n",
     "    N_qubits = len(reg.qubits)\n",
-    "    \n",
+    "\n",
     "    correlation_function = {}\n",
     "    N_side = int(np.sqrt(N_qubits))\n",
-    "    for k in range(-N_side+1, N_side):\n",
-    "        for l in range(-N_side+1, N_side):\n",
-    "            correlation_function[(k, l)] = get_corr_function(k, l, reg, R_interatomic, state)\n",
+    "    for k in range(-N_side + 1, N_side):\n",
+    "        for l in range(-N_side + 1, N_side):\n",
+    "            correlation_function[(k, l)] = get_corr_function(\n",
+    "                k, l, reg, R_interatomic, state\n",
+    "            )\n",
     "    return correlation_function"
    ]
   },
@@ -326,12 +337,14 @@
    "outputs": [],
    "source": [
     "expected_corr_function = {}\n",
-    "xi = 1 # Estimated Correlation Length\n",
-    "for k in range(-N_side+1,N_side):\n",
-    "    for l in range(-N_side+1,N_side):\n",
+    "xi = 1  # Estimated Correlation Length\n",
+    "for k in range(-N_side + 1, N_side):\n",
+    "    for l in range(-N_side + 1, N_side):\n",
     "        kk = np.abs(k)\n",
     "        ll = np.abs(l)\n",
-    "        expected_corr_function[(k, l)] = (-1)**(kk + ll) * np.exp(-np.sqrt(k**2 + l**2)/xi)"
+    "        expected_corr_function[(k, l)] = (-1) ** (kk + ll) * np.exp(\n",
+    "            -np.sqrt(k**2 + l**2) / xi\n",
+    "        )"
    ]
   },
   {
@@ -342,22 +355,34 @@
    },
    "outputs": [],
    "source": [
-    "A = 4*np.reshape(list(correlation_function.values()), (2*N_side-1, 2*N_side-1))\n",
-    "A = A/np.max(A)\n",
-    "B = np.reshape(list(expected_corr_function.values()), (2*N_side-1, 2*N_side-1))\n",
-    "B = B*np.max(A)\n",
-    "\n",
-    "for i, M in enumerate([A.copy(),B.copy()]):\n",
-    "    M[N_side-1, N_side-1] = None\n",
-    "    plt.figure(figsize=(3.5,3.5))\n",
-    "    plt.imshow(M, cmap='coolwarm', vmin=-.6, vmax=.6)\n",
-    "    plt.xticks(range(len(M)), [f'{x}' for x in range(-N_side + 1, N_side)])\n",
-    "    plt.xlabel(r'$\\mathscr{k}$', fontsize=22)\n",
-    "    plt.yticks(range(len(M)), [f'{-y}' for y in range(-N_side + 1, N_side)])\n",
-    "    plt.ylabel(r'$\\mathscr{l}$', rotation=0, fontsize=22, labelpad=10)\n",
+    "A = 4 * np.reshape(\n",
+    "    list(correlation_function.values()), (2 * N_side - 1, 2 * N_side - 1)\n",
+    ")\n",
+    "A = A / np.max(A)\n",
+    "B = np.reshape(\n",
+    "    list(expected_corr_function.values()), (2 * N_side - 1, 2 * N_side - 1)\n",
+    ")\n",
+    "B = B * np.max(A)\n",
+    "\n",
+    "for i, M in enumerate([A.copy(), B.copy()]):\n",
+    "    M[N_side - 1, N_side - 1] = None\n",
+    "    plt.figure(figsize=(3.5, 3.5))\n",
+    "    plt.imshow(M, cmap=\"coolwarm\", vmin=-0.6, vmax=0.6)\n",
+    "    plt.xticks(range(len(M)), [f\"{x}\" for x in range(-N_side + 1, N_side)])\n",
+    "    plt.xlabel(r\"$\\mathscr{k}$\", fontsize=22)\n",
+    "    plt.yticks(range(len(M)), [f\"{-y}\" for y in range(-N_side + 1, N_side)])\n",
+    "    plt.ylabel(r\"$\\mathscr{l}$\", rotation=0, fontsize=22, labelpad=10)\n",
     "    plt.colorbar(fraction=0.047, pad=0.02)\n",
-    "    if i == 0 :plt.title(r'$4\\times\\.g^{(2)}(\\mathscr{k},\\mathscr{l})$ after simulation', fontsize=14)\n",
-    "    if i == 1 :plt.title(r'Exponential $g^{(2)}(\\mathscr{k},\\mathscr{l})$ expected', fontsize=14)\n",
+    "    if i == 0:\n",
+    "        plt.title(\n",
+    "            r\"$4\\times\\.g^{(2)}(\\mathscr{k},\\mathscr{l})$ after simulation\",\n",
+    "            fontsize=14,\n",
+    "        )\n",
+    "    if i == 1:\n",
+    "        plt.title(\n",
+    "            r\"Exponential $g^{(2)}(\\mathscr{k},\\mathscr{l})$ expected\",\n",
+    "            fontsize=14,\n",
+    "        )\n",
     "    plt.show()"
    ]
   },
@@ -411,13 +436,17 @@
     "    N_side = int(np.sqrt(N_qubits))\n",
     "\n",
     "    st_fac = 0\n",
-    "    for k in range(-N_side+1, N_side):\n",
-    "        for l in range(-N_side+1, N_side):\n",
+    "    for k in range(-N_side + 1, N_side):\n",
+    "        for l in range(-N_side + 1, N_side):\n",
     "            kk = np.abs(k)\n",
     "            ll = np.abs(l)\n",
     "            if not (k == 0 and l == 0):\n",
-    "                st_fac += 4 * (-1)**(kk + ll) * get_corr_function(k, l, reg, R_interatomic, state)\n",
-    "    return st_fac "
+    "                st_fac += (\n",
+    "                    4\n",
+    "                    * (-1) ** (kk + ll)\n",
+    "                    * get_corr_function(k, l, reg, R_interatomic, state)\n",
+    "                )\n",
+    "    return st_fac"
    ]
   },
   {
@@ -426,36 +455,44 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "def calculate_neel(det, N, Omega_max = 2.3 * 2 * np.pi):\n",
-    "    #Setup:\n",
+    "def calculate_neel(det, N, Omega_max=2.3 * 2 * np.pi):\n",
+    "    # Setup:\n",
     "    U = Omega_max / 2.3\n",
     "    delta_0 = -6 * U\n",
-    "    delta_f = det * U              \n",
-    "    \n",
+    "    delta_f = det * U\n",
+    "\n",
     "    t_rise = 252\n",
     "    t_fall = 500\n",
-    "    t_sweep = int((delta_f - delta_0)/(2 * np.pi * 10) * 1000)\n",
-    "    t_sweep += 4 - t_sweep % 4 # To be a multiple of the clock period of Chadoq2 (4ns)\n",
-    "    \n",
-    "    R_interatomic = Chadoq2.rydberg_blockade_radius(U) \n",
+    "    t_sweep = int((delta_f - delta_0) / (2 * np.pi * 10) * 1000)\n",
+    "    t_sweep += (\n",
+    "        4 - t_sweep % 4\n",
+    "    )  # To be a multiple of the clock period of Chadoq2 (4ns)\n",
+    "\n",
+    "    R_interatomic = Chadoq2.rydberg_blockade_radius(U)\n",
     "    reg = Register.rectangle(N, N, R_interatomic)\n",
     "\n",
-    "    #Pulse Sequence\n",
-    "    rise = Pulse.ConstantDetuning(RampWaveform(t_rise, 0., Omega_max), delta_0, 0.)\n",
-    "    sweep = Pulse.ConstantAmplitude(Omega_max, RampWaveform(t_sweep, delta_0, delta_f), 0.)\n",
-    "    fall = Pulse.ConstantDetuning(RampWaveform(t_fall, Omega_max, 0.), delta_f, 0.)\n",
+    "    # Pulse Sequence\n",
+    "    rise = Pulse.ConstantDetuning(\n",
+    "        RampWaveform(t_rise, 0.0, Omega_max), delta_0, 0.0\n",
+    "    )\n",
+    "    sweep = Pulse.ConstantAmplitude(\n",
+    "        Omega_max, RampWaveform(t_sweep, delta_0, delta_f), 0.0\n",
+    "    )\n",
+    "    fall = Pulse.ConstantDetuning(\n",
+    "        RampWaveform(t_fall, Omega_max, 0.0), delta_f, 0.0\n",
+    "    )\n",
     "\n",
     "    seq = Sequence(reg, Chadoq2)\n",
-    "    seq.declare_channel('ising', 'rydberg_global')\n",
-    "    seq.add(rise, 'ising')\n",
-    "    seq.add(sweep, 'ising')\n",
-    "    seq.add(fall, 'ising')\n",
+    "    seq.declare_channel(\"ising\", \"rydberg_global\")\n",
+    "    seq.add(rise, \"ising\")\n",
+    "    seq.add(sweep, \"ising\")\n",
+    "    seq.add(fall, \"ising\")\n",
     "\n",
     "    simul = Simulation(seq, sampling_rate=0.2)\n",
     "    results = simul.run()\n",
-    "    \n",
+    "\n",
     "    final = results.states[-1]\n",
-    "    return get_neel_structure_factor(reg, R_interatomic, final)                                                                                                                                                                      "
+    "    return get_neel_structure_factor(reg, R_interatomic, final)"
    ]
   },
   {
@@ -467,19 +504,21 @@
    "outputs": [],
    "source": [
     "N_side = 3\n",
-    "occup_list = [occupation(j, N_side*N_side) for j in range(N_side*N_side)]\n",
+    "occup_list = [occupation(j, N_side * N_side) for j in range(N_side * N_side)]\n",
     "\n",
     "detunings = np.linspace(-1, 5, 20)\n",
-    "results=[]\n",
+    "results = []\n",
     "for det in detunings:\n",
-    "    print(f'Detuning = {np.round(det,3)} x 2π Mhz.')\n",
+    "    print(f\"Detuning = {np.round(det,3)} x 2π Mhz.\")\n",
     "    results.append(calculate_neel(det, N_side))\n",
-    "plt.xlabel(r'$\\hbar\\delta_{final}/U$')\n",
-    "plt.ylabel(r'Néel Structure Factor $S_{Neel}$')\n",
-    "plt.plot(detunings, results, 'o', ls='solid')\n",
+    "plt.xlabel(r\"$\\hbar\\delta_{final}/U$\")\n",
+    "plt.ylabel(r\"Néel Structure Factor $S_{Neel}$\")\n",
+    "plt.plot(detunings, results, \"o\", ls=\"solid\")\n",
     "plt.show()\n",
     "max_index = results.index(max(results))\n",
-    "print(f'Max S_Neel {np.round(max(results),2)} at detuning = {np.round(detunings[max_index],2)} x 2π Mhz.')"
+    "print(\n",
+    "    f\"Max S_Neel {np.round(max(results),2)} at detuning = {np.round(detunings[max_index],2)} x 2π Mhz.\"\n",
+    ")"
    ]
   }
  ],
diff --git a/tutorials/quantum_simulation/Shadow estimation for VQS.ipynb b/tutorials/quantum_simulation/Shadow estimation for VQS.ipynb
index d8ad3c4f2..b7bd17a06 100644
--- a/tutorials/quantum_simulation/Shadow estimation for VQS.ipynb
+++ b/tutorials/quantum_simulation/Shadow estimation for VQS.ipynb
@@ -115,13 +115,13 @@
     "num_qubits = 4\n",
     "zero_state = qutip.basis(2, 0).proj()\n",
     "one_state = qutip.basis(2, 1).proj()\n",
-    "hadamard = 1/np.sqrt(2) * qutip.Qobj([[1., 1.], [1., -1.]])\n",
-    "h_mul_phase = qutip.Qobj(np.array([[1., 1], [1.j, -1.j]])) / np.sqrt(2)\n",
+    "hadamard = 1 / np.sqrt(2) * qutip.Qobj([[1.0, 1.0], [1.0, -1.0]])\n",
+    "h_mul_phase = qutip.Qobj(np.array([[1.0, 1], [1.0j, -1.0j]])) / np.sqrt(2)\n",
     "unitary_ensemble = [hadamard, h_mul_phase, qutip.qeye(2)]\n",
     "\n",
-    "g = qutip.basis(2,1)\n",
-    "r = qutip.basis(2,0)\n",
-    "n = r*r.dag()\n",
+    "g = qutip.basis(2, 1)\n",
+    "r = qutip.basis(2, 0)\n",
+    "n = r * r.dag()\n",
     "\n",
     "sx = qutip.sigmax()\n",
     "sy = qutip.sigmay()\n",
@@ -175,11 +175,11 @@
    "source": [
     "def compute_shadow_size(delta, epsilon, observables):\n",
     "    \"\"\"Helper function.\n",
-    "    \n",
-    "    Computes both the number of shadows needed as well as the size of blocks needed \n",
+    "\n",
+    "    Computes both the number of shadows needed as well as the size of blocks needed\n",
     "    for the median_of_means method in order to approximate the expectation value of M\n",
     "    (linear) observables with additive error epsilon and fail probability delta.\n",
-    "    \n",
+    "\n",
     "    Args:\n",
     "        delta (float): Failure probability.\n",
     "        epsilon (float): Additive error on expectation values.\n",
@@ -190,8 +190,9 @@
     "    shadow_norm = (\n",
     "        lambda op: np.linalg.norm(\n",
     "            op - np.trace(op) / 2 ** int(np.log2(op.shape[0])), ord=np.inf\n",
-    "            ) ** 2\n",
     "        )\n",
+    "        ** 2\n",
+    "    )\n",
     "    # Theoretical number of shadows per cluster in the median of means procedure :\n",
     "    # N = 34 * max(shadow_norm(o) for o in observables) / epsilon ** 2\n",
     "    # We use N = 20 here to allow for quick simulation\n",
@@ -225,7 +226,9 @@
     "    unitary_ids = np.random.randint(0, 3, size=(shadow_size, num_qubits))\n",
     "    outcomes = []\n",
     "    for ns in range(shadow_size):\n",
-    "        unitmat = qutip.tensor([unitary_ensemble[unitary_ids[ns, i]] for i in range(num_qubits)])\n",
+    "        unitmat = qutip.tensor(\n",
+    "            [unitary_ensemble[unitary_ids[ns, i]] for i in range(num_qubits)]\n",
+    "        )\n",
     "        outcomes.append(measure_bitstring(unitmat.dag() * rho * unitmat))\n",
     "\n",
     "    # combine the computational basis outcomes and the sampled unitaries\n",
@@ -257,18 +260,18 @@
     "\n",
     "    Args:\n",
     "        outcome_ns: Bitstring at ns\n",
-    "        unitary_ids_ns: Rotation applied at ns. \n",
+    "        unitary_ids_ns: Rotation applied at ns.\n",
     "\n",
     "    Returns:\n",
     "        Reconstructed snapshot.\n",
     "    \"\"\"\n",
     "    state_list = []\n",
-    "    \n",
+    "\n",
     "    for k in range(num_qubits):\n",
     "        op = unitary_ensemble[unitary_ids_ns[k]]\n",
-    "        b = zero_state if outcome_ns[k] == '0' else one_state\n",
+    "        b = zero_state if outcome_ns[k] == \"0\" else one_state\n",
     "        state_list.append(3 * op * b * op.dag() - qutip.qeye(2))\n",
-    "    \n",
+    "\n",
     "    return qutip.tensor(state_list)"
    ]
   },
@@ -294,7 +297,7 @@
     "    # computing and saving mean per block\n",
     "    means = []\n",
     "    for block in range(K):\n",
-    "        states = [snap_list[i] for i in np.where(indic==block)[0]]\n",
+    "        states = [snap_list[i] for i in np.where(indic == block)[0]]\n",
     "        exp = qutip.expect(obs, states)\n",
     "        means.append(np.mean(exp))\n",
     "    return np.median(means)"
@@ -351,11 +354,20 @@
    "source": [
     "num_qubits = 2\n",
     "shadow_size = 10000\n",
-    "rho_1 = (qutip.tensor([qutip.basis(2,0), qutip.basis(2,0)]) + qutip.tensor([qutip.basis(2,0), qutip.basis(2,1)])).proj().unit()\n",
+    "rho_1 = (\n",
+    "    (\n",
+    "        qutip.tensor([qutip.basis(2, 0), qutip.basis(2, 0)])\n",
+    "        + qutip.tensor([qutip.basis(2, 0), qutip.basis(2, 1)])\n",
+    "    )\n",
+    "    .proj()\n",
+    "    .unit()\n",
+    ")\n",
     "print(\"Original density matrix :\")\n",
     "print(rho_1.full())\n",
     "outcomes, unitary_ids = calculate_classical_shadow(rho_1, shadow_size)\n",
-    "snapshots = [snapshot_state(outcomes[ns], unitary_ids[ns]) for ns in range(shadow_size)]\n",
+    "snapshots = [\n",
+    "    snapshot_state(outcomes[ns], unitary_ids[ns]) for ns in range(shadow_size)\n",
+    "]\n",
     "print(\"Shadow reconstruction :\")\n",
     "print(np.around(state_reconstruction(snapshots).full(), 2))\n",
     "\n",
@@ -363,7 +375,10 @@
     "shadow_sizes = [100, 1000, 2000, 5000, 10000]\n",
     "for i, shadow_size in enumerate(shadow_sizes):\n",
     "    outcomes, unitary_ids = calculate_classical_shadow(rho_1, shadow_size)\n",
-    "    snapshots = [snapshot_state(outcomes[ns], unitary_ids[ns]) for ns in range(shadow_size)]\n",
+    "    snapshots = [\n",
+    "        snapshot_state(outcomes[ns], unitary_ids[ns])\n",
+    "        for ns in range(shadow_size)\n",
+    "    ]\n",
     "    dist[i] = qutip.tracedist(state_reconstruction(snapshots), rho_1)\n",
     "num_qubits = 4"
    ]
@@ -447,7 +462,7 @@
     "    if end != -1:\n",
     "        o = o[:end]\n",
     "    for i, x in enumerate(o):\n",
-    "        if not(x == p[i] or x == \"1\"):\n",
+    "        if not (x == p[i] or x == \"1\"):\n",
     "            return False\n",
     "    return True"
    ]
@@ -486,7 +501,7 @@
     "def cond_conf(o, P_sharp):\n",
     "    \"\"\"Returns the (modified) conditionned expectation value of the cost function depending\n",
     "    on already chosen Paulis in P_sharp.\n",
-    "    \n",
+    "\n",
     "    Args:\n",
     "        o (list[str]): list of Pauli strings to be measured\n",
     "        P_sharp (list[str]): list of already chosen Paulis\n",
@@ -495,14 +510,20 @@
     "    eta = 0.9\n",
     "    nu = 1 - np.exp(-eta / 2)\n",
     "    L = len(o)\n",
-    "    m = len(P_sharp) - 1 # index of last chosen Pauli string\n",
-    "    k = len(P_sharp[-1]) - 1 # index of last chosen Pauli matrix in mth Pauli string\n",
+    "    m = len(P_sharp) - 1  # index of last chosen Pauli string\n",
+    "    k = (\n",
+    "        len(P_sharp[-1]) - 1\n",
+    "    )  # index of last chosen Pauli matrix in mth Pauli string\n",
     "    result = 0\n",
     "    for l in range(0, L):\n",
     "        v = 0\n",
-    "        for m_prime in range(0,m):\n",
+    "        for m_prime in range(0, m):\n",
     "            v += (eta / 2) * int(hits(P_sharp[m_prime], o[l]))\n",
-    "        v -= np.log(1 - (nu / 3**(weight(o[l], start=k+1))) * hits(P_sharp[m], o[l], end=k+1))\n",
+    "        v -= np.log(\n",
+    "            1\n",
+    "            - (nu / 3 ** (weight(o[l], start=k + 1)))\n",
+    "            * hits(P_sharp[m], o[l], end=k + 1)\n",
+    "        )\n",
     "        result += np.exp(-v)\n",
     "    return result"
    ]
@@ -523,7 +544,7 @@
     "def derandomization(M, o):\n",
     "    \"\"\"Derandomization algorithm returning best Pauli indices according to a greedy algorithm\n",
     "    that aims at minimizing the cost function above.\n",
-    "    \n",
+    "\n",
     "    Args:\n",
     "        M (int): number of measurements\n",
     "        n (int): number of qubits (size of Pauli strings)\n",
@@ -593,7 +614,7 @@
     "    assuming the state is already rotated in the needed eigenbases of all single-qubit Paulis.\n",
     "\n",
     "    NB : Faster than using qutip.measure due to not returning the eigenstates...\n",
-    "    \n",
+    "\n",
     "    Args:\n",
     "        x (str): input bitstring\n",
     "        sigma (str): input Pauli string to be measured on |x>\n",
@@ -615,9 +636,9 @@
    "source": [
     "def classical_shadow_derand(rho, measurements):\n",
     "    \"\"\"Returns the n-strings of ±1 corresponding to measurements in the input list on state rho.\n",
-    "    \n",
+    "\n",
     "    Args:\n",
-    "        rho (qutip.Qobj): input state as a density matrix \n",
+    "        rho (qutip.Qobj): input state as a density matrix\n",
     "        measurements (list[str]): derandomized measurement bases in which to measure state rho\n",
     "\n",
     "    Returns:\n",
@@ -629,7 +650,12 @@
     "    outcomes = []\n",
     "    for ns in range(shadow_size):\n",
     "        # multi-qubit change of basis\n",
-    "        unitmat = qutip.tensor([unitary_ensemble[_pauli_index(measurements[ns][i])]for i in range(num_qubits)])\n",
+    "        unitmat = qutip.tensor(\n",
+    "            [\n",
+    "                unitary_ensemble[_pauli_index(measurements[ns][i])]\n",
+    "                for i in range(num_qubits)\n",
+    "            ]\n",
+    "        )\n",
     "        x = measure_bitstring(unitmat.dag() * rho * unitmat)\n",
     "        outcomes.append(pauli_string_value(x, measurements[ns]))\n",
     "    # ±1 strings\n",
@@ -658,7 +684,8 @@
     "                break\n",
     "            if pauli != \"1\":\n",
     "                product *= single_measurement[i][1]\n",
-    "        if not_match: continue\n",
+    "        if not_match:\n",
+    "            continue\n",
     "\n",
     "        sum_product += product\n",
     "        cnt_match += 1\n",
@@ -719,7 +746,10 @@
    "outputs": [],
    "source": [
     "def pauli(positions=[], operators=[]):\n",
-    "    op_list = [operators[positions.index(j)] if j in positions else qutip.qeye(2) for j in range(num_qubits)]\n",
+    "    op_list = [\n",
+    "        operators[positions.index(j)] if j in positions else qutip.qeye(2)\n",
+    "        for j in range(num_qubits)\n",
+    "    ]\n",
     "    return qutip.tensor(op_list)"
    ]
   },
@@ -729,24 +759,30 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "coeff_fact = [0.81261,\n",
-    "              0.171201,\n",
-    "              0.2227965,\n",
-    "              0.16862325,\n",
-    "              0.174349,\n",
-    "              0.12054625,\n",
-    "              0.165868,\n",
-    "              0.04532175]\n",
+    "coeff_fact = [\n",
+    "    0.81261,\n",
+    "    0.171201,\n",
+    "    0.2227965,\n",
+    "    0.16862325,\n",
+    "    0.174349,\n",
+    "    0.12054625,\n",
+    "    0.165868,\n",
+    "    0.04532175,\n",
+    "]\n",
     "\n",
-    "paulis = [pauli(),\n",
-    "          pauli([0], [sz]) + pauli([1], [sz]),\n",
-    "          pauli([2], [sz]) + pauli([3], [sz]),\n",
-    "          pauli([1, 0], [sz, sz]),\n",
-    "          pauli([3, 2], [sz, sz]),\n",
-    "          pauli([2, 0], [sz, sz]) + pauli([3, 1], [sz, sz]),\n",
-    "          pauli([2, 1], [sz, sz]) + pauli([3, 0], [sz, sz]),\n",
-    "          pauli([3, 2, 1, 0], [sx, sx, sy, sy]) + pauli([3, 2, 1, 0], [sy, sy, sx, sx]),\n",
-    "          pauli([3, 2, 1, 0], [sx, sy, sy, sx]) + pauli([3, 2, 1, 0], [sy, sx, sx, sy])]"
+    "paulis = [\n",
+    "    pauli(),\n",
+    "    pauli([0], [sz]) + pauli([1], [sz]),\n",
+    "    pauli([2], [sz]) + pauli([3], [sz]),\n",
+    "    pauli([1, 0], [sz, sz]),\n",
+    "    pauli([3, 2], [sz, sz]),\n",
+    "    pauli([2, 0], [sz, sz]) + pauli([3, 1], [sz, sz]),\n",
+    "    pauli([2, 1], [sz, sz]) + pauli([3, 0], [sz, sz]),\n",
+    "    pauli([3, 2, 1, 0], [sx, sx, sy, sy])\n",
+    "    + pauli([3, 2, 1, 0], [sy, sy, sx, sx]),\n",
+    "    pauli([3, 2, 1, 0], [sx, sy, sy, sx])\n",
+    "    + pauli([3, 2, 1, 0], [sy, sx, sx, sy]),\n",
+    "]"
    ]
   },
   {
@@ -772,7 +808,14 @@
    "source": [
     "# H2 Molecule : 4 qubits in Jordan-Wigner mapping of the Hamiltonian\n",
     "a = 10\n",
-    "reg = Register.from_coordinates([[0, 0], [a, 0], [0.5*a, a*np.sqrt(3)/2], [0.5*a, -a*np.sqrt(3)/2]])\n",
+    "reg = Register.from_coordinates(\n",
+    "    [\n",
+    "        [0, 0],\n",
+    "        [a, 0],\n",
+    "        [0.5 * a, a * np.sqrt(3) / 2],\n",
+    "        [0.5 * a, -a * np.sqrt(3) / 2],\n",
+    "    ]\n",
+    ")\n",
     "reg.draw()"
    ]
   },
@@ -798,17 +841,20 @@
    ],
    "source": [
     "def cost_hamiltonian_JW():\n",
-    "    H = - coeff_fact[0] * paulis[0] \\\n",
-    "        + coeff_fact[1] * paulis[1] \\\n",
-    "        - coeff_fact[2] * paulis[2] \\\n",
-    "        + coeff_fact[3] * paulis[3] \\\n",
-    "        + coeff_fact[4] * paulis[4] \\\n",
-    "        + coeff_fact[5] * paulis[5] \\\n",
-    "        + coeff_fact[6] * paulis[6] \\\n",
-    "        - coeff_fact[7] * paulis[7] \\\n",
+    "    H = (\n",
+    "        -coeff_fact[0] * paulis[0]\n",
+    "        + coeff_fact[1] * paulis[1]\n",
+    "        - coeff_fact[2] * paulis[2]\n",
+    "        + coeff_fact[3] * paulis[3]\n",
+    "        + coeff_fact[4] * paulis[4]\n",
+    "        + coeff_fact[5] * paulis[5]\n",
+    "        + coeff_fact[6] * paulis[6]\n",
+    "        - coeff_fact[7] * paulis[7]\n",
     "        + coeff_fact[7] * paulis[8]\n",
+    "    )\n",
     "    return H\n",
     "\n",
+    "\n",
     "global H\n",
     "H = cost_hamiltonian_JW()\n",
     "exact_energy, ground_state = cost_hamiltonian_JW().groundstate()\n",
@@ -851,25 +897,31 @@
     "        in_state (qubit.Qobj): initial state.\n",
     "    \"\"\"\n",
     "    seq = Sequence(r, Chadoq2)\n",
-    "    seq.declare_channel('ch0','rydberg_global')\n",
-    "    middle = len(param)//2\n",
-    "    \n",
+    "    seq.declare_channel(\"ch0\", \"rydberg_global\")\n",
+    "    middle = len(param) // 2\n",
+    "\n",
     "    for tau, t in zip(param[middle:], param[:middle]):\n",
-    "        pulse_1 = Pulse.ConstantPulse(tau, 1., 0, 0) \n",
-    "        pulse_2 = Pulse.ConstantPulse(t, 1., 1., 0) \n",
-    "        seq.add(pulse_1, 'ch0')\n",
-    "        seq.add(pulse_2, 'ch0')\n",
-    "    \n",
-    "    seq.measure('ground-rydberg')\n",
-    "    simul = Simulation(seq, sampling_rate=.05)\n",
+    "        pulse_1 = Pulse.ConstantPulse(tau, 1.0, 0, 0)\n",
+    "        pulse_2 = Pulse.ConstantPulse(t, 1.0, 1.0, 0)\n",
+    "        seq.add(pulse_1, \"ch0\")\n",
+    "        seq.add(pulse_2, \"ch0\")\n",
+    "\n",
+    "    seq.measure(\"ground-rydberg\")\n",
+    "    simul = Simulation(seq, sampling_rate=0.05)\n",
     "    simul.initial_state = in_state\n",
     "    results = simul.run()\n",
     "    return results.expect([H])[-1][-1]\n",
     "\n",
+    "\n",
     "def loop_JW(param, in_state):\n",
-    "    res = minimize(quantum_loop, param, method='Nelder-Mead', args=in_state,\n",
-    "                   options={'return_all':True, 'maxiter':200, 'adaptive':True})\n",
-    "    return(res)"
+    "    res = minimize(\n",
+    "        quantum_loop,\n",
+    "        param,\n",
+    "        method=\"Nelder-Mead\",\n",
+    "        args=in_state,\n",
+    "        options={\"return_all\": True, \"maxiter\": 200, \"adaptive\": True},\n",
+    "    )\n",
+    "    return res"
    ]
   },
   {
@@ -887,7 +939,7 @@
    "source": [
     "# Setup for VQS\n",
     "layers = 5\n",
-    "param = [2000]*layers + [4000]*layers"
+    "param = [2000] * layers + [4000] * layers"
    ]
   },
   {
@@ -912,6 +964,7 @@
    ],
    "source": [
     "import warnings\n",
+    "\n",
     "# Ignore the warnings\n",
     "warnings.filterwarnings(\"ignore\", category=UserWarning)\n",
     "\n",
@@ -957,8 +1010,10 @@
     }
    ],
    "source": [
-    "plt.plot([quantum_loop(pars, gggg) for pars in loop_ising_results.allvecs], 'k')\n",
-    "plt.axhline(exact_energy, color='red')"
+    "plt.plot(\n",
+    "    [quantum_loop(pars, gggg) for pars in loop_ising_results.allvecs], \"k\"\n",
+    ")\n",
+    "plt.axhline(exact_energy, color=\"red\")"
    ]
   },
   {
@@ -997,15 +1052,17 @@
    "outputs": [],
    "source": [
     "def exp_value_JW(exp_values):\n",
-    "    return (- coeff_fact[0] * exp_values[0] \\\n",
-    "            + coeff_fact[1] * exp_values[1] \\\n",
-    "            - coeff_fact[2] * exp_values[2] \\\n",
-    "            + coeff_fact[3] * exp_values[3] \\\n",
-    "            + coeff_fact[4] * exp_values[4] \\\n",
-    "            + coeff_fact[5] * exp_values[5] \\\n",
-    "            + coeff_fact[6] * exp_values[6] \\\n",
-    "            - coeff_fact[7] * exp_values[7] \\\n",
-    "            + coeff_fact[7] * exp_values[8])"
+    "    return (\n",
+    "        -coeff_fact[0] * exp_values[0]\n",
+    "        + coeff_fact[1] * exp_values[1]\n",
+    "        - coeff_fact[2] * exp_values[2]\n",
+    "        + coeff_fact[3] * exp_values[3]\n",
+    "        + coeff_fact[4] * exp_values[4]\n",
+    "        + coeff_fact[5] * exp_values[5]\n",
+    "        + coeff_fact[6] * exp_values[6]\n",
+    "        - coeff_fact[7] * exp_values[7]\n",
+    "        + coeff_fact[7] * exp_values[8]\n",
+    "    )"
    ]
   },
   {
@@ -1021,19 +1078,19 @@
     "        in_state (qubit.Qobj): initial state.\n",
     "    \"\"\"\n",
     "    seq = Sequence(r, Chadoq2)\n",
-    "    seq.declare_channel('ch0','rydberg_global')\n",
-    "    middle = len(param)//2\n",
-    "    \n",
+    "    seq.declare_channel(\"ch0\", \"rydberg_global\")\n",
+    "    middle = len(param) // 2\n",
+    "\n",
     "    for tau, t in zip(param[middle:], param[:middle]):\n",
-    "        pulse_1 = Pulse.ConstantPulse(tau, 1., 0, 0) \n",
-    "        pulse_2 = Pulse.ConstantPulse(t, 1., 1., 0)\n",
-    "        seq.add(pulse_1, 'ch0')\n",
-    "        seq.add(pulse_2, 'ch0')\n",
-    "    \n",
-    "    seq.measure('ground-rydberg')\n",
-    "    simul = Simulation(seq, sampling_rate=.01)\n",
+    "        pulse_1 = Pulse.ConstantPulse(tau, 1.0, 0, 0)\n",
+    "        pulse_2 = Pulse.ConstantPulse(t, 1.0, 1.0, 0)\n",
+    "        seq.add(pulse_1, \"ch0\")\n",
+    "        seq.add(pulse_2, \"ch0\")\n",
+    "\n",
+    "    seq.measure(\"ground-rydberg\")\n",
+    "    simul = Simulation(seq, sampling_rate=0.01)\n",
     "    simul.initial_state = in_state\n",
-    "    \n",
+    "\n",
     "    # Classical shadow estimation\n",
     "    # Theoretical shadow size and number of clusters :\n",
     "    # shadow_size, K = compute_shadow_size(0.1, 0.5, paulis)\n",
@@ -1041,14 +1098,23 @@
     "    K = 4\n",
     "    rho = simul.run().get_final_state().proj()\n",
     "    outcomes, unitary_ids = calculate_classical_shadow(rho, shadow_size)\n",
-    "    snapshots = [snapshot_state(outcomes[ns], unitary_ids[ns]) for ns in range(shadow_size)]\n",
+    "    snapshots = [\n",
+    "        snapshot_state(outcomes[ns], unitary_ids[ns])\n",
+    "        for ns in range(shadow_size)\n",
+    "    ]\n",
     "    meds = [_median_of_means(obs, snapshots, K) for obs in paulis]\n",
     "    return exp_value_JW(meds)\n",
     "\n",
+    "\n",
     "def loop_JW_shadows(param, in_state, shadow_size=20):\n",
-    "    res = minimize(quantum_loop_shadows, param, method='Nelder-Mead', args=(in_state, shadow_size),\n",
-    "                   options={'return_all':True, 'maxiter':100, 'adaptive':True})\n",
-    "    return(res)"
+    "    res = minimize(\n",
+    "        quantum_loop_shadows,\n",
+    "        param,\n",
+    "        method=\"Nelder-Mead\",\n",
+    "        args=(in_state, shadow_size),\n",
+    "        options={\"return_all\": True, \"maxiter\": 100, \"adaptive\": True},\n",
+    "    )\n",
+    "    return res"
    ]
   },
   {
@@ -1057,10 +1123,15 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "shadow_sizes = [10,20,40,60,80,100]\n",
+    "shadow_sizes = [10, 20, 40, 60, 80, 100]\n",
     "energies = []\n",
     "for shadow_size in shadow_sizes:\n",
-    "    energies.append(abs(loop_JW_shadows(param, gggg, shadow_size=shadow_size).fun - exact_energy))"
+    "    energies.append(\n",
+    "        abs(\n",
+    "            loop_JW_shadows(param, gggg, shadow_size=shadow_size).fun\n",
+    "            - exact_energy\n",
+    "        )\n",
+    "    )"
    ]
   },
   {
@@ -1092,10 +1163,10 @@
     }
    ],
    "source": [
-    "plt.figure(figsize=(8,5))\n",
+    "plt.figure(figsize=(8, 5))\n",
     "plt.xlabel(\"Shadow size\", fontsize=15)\n",
     "plt.ylabel(r\"$|\\frac{E - E_{ground}}{E_{ground}}|$\", fontsize=20)\n",
-    "plt.plot(shadow_sizes, [-e/exact_energy for e in energies])"
+    "plt.plot(shadow_sizes, [-e / exact_energy for e in energies])"
    ]
   },
   {
@@ -1125,24 +1196,41 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "coeff_non_fact = [-0.81261,\n",
-    "                  0.171201,\n",
-    "                  0.171201,\n",
-    "                  -0.2227965,\n",
-    "                  -0.2227965,\n",
-    "                  0.16862325,\n",
-    "                  0.174349,\n",
-    "                  0.12054625,\n",
-    "                  0.12054625,\n",
-    "                  0.165868,\n",
-    "                  0.165868,\n",
-    "                  -0.04532175,\n",
-    "                  -0.04532175,\n",
-    "                  0.04532175,\n",
-    "                  0.04532175]\n",
+    "coeff_non_fact = [\n",
+    "    -0.81261,\n",
+    "    0.171201,\n",
+    "    0.171201,\n",
+    "    -0.2227965,\n",
+    "    -0.2227965,\n",
+    "    0.16862325,\n",
+    "    0.174349,\n",
+    "    0.12054625,\n",
+    "    0.12054625,\n",
+    "    0.165868,\n",
+    "    0.165868,\n",
+    "    -0.04532175,\n",
+    "    -0.04532175,\n",
+    "    0.04532175,\n",
+    "    0.04532175,\n",
+    "]\n",
     "\n",
-    "paulis_str = [\"1111\", \"Z111\", \"1Z11\", \"11Z1\", \"111Z\", \"ZZ11\", \"11ZZ\", \"Z1Z1\", \"1Z1Z\", \"1ZZ1\",\n",
-    "              \"Z11Z\", \"YYXX\", \"XXYY\", \"XYYX\", \"YXXY\"]"
+    "paulis_str = [\n",
+    "    \"1111\",\n",
+    "    \"Z111\",\n",
+    "    \"1Z11\",\n",
+    "    \"11Z1\",\n",
+    "    \"111Z\",\n",
+    "    \"ZZ11\",\n",
+    "    \"11ZZ\",\n",
+    "    \"Z1Z1\",\n",
+    "    \"1Z1Z\",\n",
+    "    \"1ZZ1\",\n",
+    "    \"Z11Z\",\n",
+    "    \"YYXX\",\n",
+    "    \"XXYY\",\n",
+    "    \"XYYX\",\n",
+    "    \"YXXY\",\n",
+    "]"
    ]
   },
   {
@@ -1152,7 +1240,12 @@
    "outputs": [],
    "source": [
     "def exp_value_JW_non_fact(outcomes):\n",
-    "    return sum([c*exp_value(sigma, outcomes) for c, sigma in zip(coeff_non_fact, paulis_str)])"
+    "    return sum(\n",
+    "        [\n",
+    "            c * exp_value(sigma, outcomes)\n",
+    "            for c, sigma in zip(coeff_non_fact, paulis_str)\n",
+    "        ]\n",
+    "    )"
    ]
   },
   {
@@ -1177,9 +1270,11 @@
    ],
    "source": [
     "measurements = derandomization(60, paulis_str)\n",
-    "print(f\"ZZZZ measurements : {measurements.count('ZZZZ')}, XXYY measurements : {measurements.count('XXYY')}, \" +\n",
-    "      f\"YXXY measurements : {measurements.count('YXXY')}, XYYX measurements : {measurements.count('XYYX')}, \" +\n",
-    "      f\"YYXX measurements : {measurements.count('YYXX')} : total = 60 measurements\")"
+    "print(\n",
+    "    f\"ZZZZ measurements : {measurements.count('ZZZZ')}, XXYY measurements : {measurements.count('XXYY')}, \"\n",
+    "    + f\"YXXY measurements : {measurements.count('YXXY')}, XYYX measurements : {measurements.count('XYYX')}, \"\n",
+    "    + f\"YYXX measurements : {measurements.count('YYXX')} : total = 60 measurements\"\n",
+    ")"
    ]
   },
   {
@@ -1202,28 +1297,34 @@
     "        in_state (qubit.Qobj): initial state.\n",
     "    \"\"\"\n",
     "    seq = Sequence(r, Chadoq2)\n",
-    "    seq.declare_channel('ch0','rydberg_global')\n",
-    "    middle = len(param)//2\n",
-    "    \n",
+    "    seq.declare_channel(\"ch0\", \"rydberg_global\")\n",
+    "    middle = len(param) // 2\n",
+    "\n",
     "    for tau, t in zip(param[middle:], param[:middle]):\n",
-    "        pulse_1 = Pulse.ConstantPulse(tau, 1., 0, 0) \n",
-    "        pulse_2 = Pulse.ConstantPulse(t, 1., 1., 0)\n",
-    "        seq.add(pulse_1, 'ch0')\n",
-    "        seq.add(pulse_2, 'ch0')\n",
-    "    \n",
-    "    seq.measure('ground-rydberg')\n",
-    "    simul = Simulation(seq, sampling_rate=.05)\n",
+    "        pulse_1 = Pulse.ConstantPulse(tau, 1.0, 0, 0)\n",
+    "        pulse_2 = Pulse.ConstantPulse(t, 1.0, 1.0, 0)\n",
+    "        seq.add(pulse_1, \"ch0\")\n",
+    "        seq.add(pulse_2, \"ch0\")\n",
+    "\n",
+    "    seq.measure(\"ground-rydberg\")\n",
+    "    simul = Simulation(seq, sampling_rate=0.05)\n",
     "    simul.initial_state = in_state\n",
-    "    \n",
+    "\n",
     "    # Classical shadow estimation\n",
     "    rho = simul.run().get_final_state().proj()\n",
     "    outcomes = classical_shadow_derand(rho, measurements)\n",
     "    return exp_value_JW_non_fact(outcomes)\n",
     "\n",
+    "\n",
     "def loop_JW_derand(param, in_state):\n",
-    "    res = minimize(quantum_loop_derand, param, method='Nelder-Mead', args=in_state,\n",
-    "                   options={'return_all':True, 'maxiter':150, 'adaptive':True})\n",
-    "    return(res)"
+    "    res = minimize(\n",
+    "        quantum_loop_derand,\n",
+    "        param,\n",
+    "        method=\"Nelder-Mead\",\n",
+    "        args=in_state,\n",
+    "        options={\"return_all\": True, \"maxiter\": 150, \"adaptive\": True},\n",
+    "    )\n",
+    "    return res"
    ]
   },
   {
@@ -1232,11 +1333,13 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "measurement_sizes = [20,30,40,60,80,100]\n",
+    "measurement_sizes = [20, 30, 40, 60, 80, 100]\n",
     "energies_derand = []\n",
     "for meas_size in measurement_sizes:\n",
-    "    measurements=derandomization(meas_size, paulis_str)\n",
-    "    energies_derand.append(abs(loop_JW_derand(param, gggg).fun - exact_energy) / abs(exact_energy))"
+    "    measurements = derandomization(meas_size, paulis_str)\n",
+    "    energies_derand.append(\n",
+    "        abs(loop_JW_derand(param, gggg).fun - exact_energy) / abs(exact_energy)\n",
+    "    )"
    ]
   },
   {
@@ -1268,7 +1371,7 @@
     }
    ],
    "source": [
-    "plt.figure(figsize=(8,5))\n",
+    "plt.figure(figsize=(8, 5))\n",
     "plt.xlabel(\"Measurement size\", fontsize=15)\n",
     "plt.ylabel(r\"$|\\frac{E - E_{ground}}{E_{ground}}|$\", fontsize=20)\n",
     "plt.plot(measurement_sizes, energies_derand)"
diff --git a/tutorials/quantum_simulation/Spin chain of 3 atoms in XY mode.ipynb b/tutorials/quantum_simulation/Spin chain of 3 atoms in XY mode.ipynb
index e805f0b50..4c23f0381 100644
--- a/tutorials/quantum_simulation/Spin chain of 3 atoms in XY mode.ipynb
+++ b/tutorials/quantum_simulation/Spin chain of 3 atoms in XY mode.ipynb
@@ -74,7 +74,7 @@
     "\n",
     "reg = Register(qubits)\n",
     "seq = Sequence(reg, MockDevice)\n",
-    "seq.declare_channel('MW', 'mw_global')"
+    "seq.declare_channel(\"MW\", \"mw_global\")"
    ]
   },
   {
@@ -90,9 +90,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "simple_pulse = Pulse.ConstantPulse(4000, 2*np.pi*4.6, 0, 0)\n",
-    "seq.add(simple_pulse, 'MW')\n",
-    "seq.measure(basis='XY')\n",
+    "simple_pulse = Pulse.ConstantPulse(4000, 2 * np.pi * 4.6, 0, 0)\n",
+    "seq.add(simple_pulse, \"MW\")\n",
+    "seq.measure(basis=\"XY\")\n",
     "\n",
     "sim = Simulation(seq)\n",
     "\n",
@@ -117,11 +117,12 @@
     "    prod[j] = (qutip.sigmaz() + qutip.qeye(2)) / 2\n",
     "    return qutip.tensor(prod)\n",
     "\n",
+    "\n",
     "magn = magnetization(0, 1)\n",
     "plt.figure(figsize=[16, 6])\n",
     "results.plot(magn)\n",
-    "plt.xlabel('Pulse duration (ns)', fontsize='x-large')\n",
-    "plt.ylabel('Excitation of the atom', fontsize='x-large')\n",
+    "plt.xlabel(\"Pulse duration (ns)\", fontsize=\"x-large\")\n",
+    "plt.ylabel(\"Excitation of the atom\", fontsize=\"x-large\")\n",
     "plt.show()"
    ]
   },
@@ -145,24 +146,24 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "coords = np.array([[-8., 0], [0, 0], [8., 0]])\n",
+    "coords = np.array([[-8.0, 0], [0, 0], [8.0, 0]])\n",
     "qubits = dict(enumerate(coords))\n",
     "\n",
     "reg = Register(qubits)\n",
     "seq = Sequence(reg, MockDevice)\n",
-    "seq.declare_channel('ch0', 'mw_global')\n",
+    "seq.declare_channel(\"ch0\", \"mw_global\")\n",
     "reg.draw()\n",
     "\n",
     "# State preparation using SLM mask\n",
     "masked_qubits = [1, 2]\n",
     "seq.config_slm_mask(masked_qubits)\n",
     "masked_pulse = Pulse.ConstantDetuning(BlackmanWaveform(200, np.pi), 0, 0)\n",
-    "seq.add(masked_pulse, 'ch0')\n",
+    "seq.add(masked_pulse, \"ch0\")\n",
     "\n",
     "# Simulation pulse\n",
     "simple_pulse = Pulse.ConstantPulse(7000, 0, 0, 0)\n",
-    "seq.add(simple_pulse, 'ch0')\n",
-    "seq.measure(basis='XY')\n",
+    "seq.add(simple_pulse, \"ch0\")\n",
+    "seq.measure(basis=\"XY\")\n",
     "\n",
     "sim = Simulation(seq, sampling_rate=1)\n",
     "results = sim.run(nsteps=5000)"
@@ -181,17 +182,17 @@
     "plt.figure(figsize=[16, 18])\n",
     "plt.subplot(311)\n",
     "plt.plot(expectations[0])\n",
-    "plt.ylabel('Excitation of atom 0', fontsize='x-large')\n",
-    "plt.xlabel('Time (ns)', fontsize='x-large')\n",
+    "plt.ylabel(\"Excitation of atom 0\", fontsize=\"x-large\")\n",
+    "plt.xlabel(\"Time (ns)\", fontsize=\"x-large\")\n",
     "plt.subplot(312)\n",
     "plt.plot(expectations[1])\n",
-    "plt.ylabel('Excitation of atom 1', fontsize='x-large')\n",
-    "plt.xlabel('Time (ns)', fontsize='x-large')\n",
+    "plt.ylabel(\"Excitation of atom 1\", fontsize=\"x-large\")\n",
+    "plt.xlabel(\"Time (ns)\", fontsize=\"x-large\")\n",
     "plt.ylim([0, 1])\n",
     "plt.subplot(313)\n",
     "plt.plot(expectations[2])\n",
-    "plt.ylabel('Excitation of atom 2', fontsize='x-large')\n",
-    "plt.xlabel('Time (ns)', fontsize='x-large')\n",
+    "plt.ylabel(\"Excitation of atom 2\", fontsize=\"x-large\")\n",
+    "plt.xlabel(\"Time (ns)\", fontsize=\"x-large\")\n",
     "plt.show()"
    ]
   },
@@ -234,13 +235,13 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "coords = np.array([[-1., 0], [0, 0], [np.sqrt(2/3), np.sqrt(1/3)]]) * 8.\n",
+    "coords = np.array([[-1.0, 0], [0, 0], [np.sqrt(2 / 3), np.sqrt(1 / 3)]]) * 8.0\n",
     "qubits = dict(enumerate(coords))\n",
     "\n",
     "reg = Register(qubits)\n",
     "seq = Sequence(reg, MockDevice)\n",
-    "seq.declare_channel('ch0', 'mw_global')\n",
-    "seq.set_magnetic_field(0., 1., 0)\n",
+    "seq.declare_channel(\"ch0\", \"mw_global\")\n",
+    "seq.set_magnetic_field(0.0, 1.0, 0)\n",
     "reg.draw()"
    ]
   },
@@ -261,12 +262,12 @@
     "masked_qubits = [1, 2]\n",
     "seq.config_slm_mask(masked_qubits)\n",
     "masked_pulse = Pulse.ConstantDetuning(BlackmanWaveform(200, np.pi), 0, 0)\n",
-    "seq.add(masked_pulse, 'ch0')\n",
+    "seq.add(masked_pulse, \"ch0\")\n",
     "\n",
     "# Simulation pulse\n",
     "simple_pulse = Pulse.ConstantPulse(7000, 0, 0, 0)\n",
-    "seq.add(simple_pulse, 'ch0')\n",
-    "seq.measure(basis='XY')\n",
+    "seq.add(simple_pulse, \"ch0\")\n",
+    "seq.measure(basis=\"XY\")\n",
     "\n",
     "sim = Simulation(seq, sampling_rate=1)\n",
     "results = sim.run(progress_bar=True, nsteps=5000)"
@@ -285,18 +286,18 @@
     "plt.figure(figsize=[16, 18])\n",
     "plt.subplot(311)\n",
     "plt.plot(expectations[0])\n",
-    "plt.ylabel('Excitation of atom 0', fontsize='x-large')\n",
-    "plt.xlabel('Time ($\\mu$s)', fontsize='x-large')\n",
+    "plt.ylabel(\"Excitation of atom 0\", fontsize=\"x-large\")\n",
+    "plt.xlabel(\"Time ($\\mu$s)\", fontsize=\"x-large\")\n",
     "plt.ylim([0, 1])\n",
     "plt.subplot(312)\n",
     "plt.plot(expectations[1])\n",
-    "plt.ylabel('Excitation of atom 1', fontsize='x-large')\n",
-    "plt.xlabel('Time ($\\mu$s)', fontsize='x-large')\n",
+    "plt.ylabel(\"Excitation of atom 1\", fontsize=\"x-large\")\n",
+    "plt.xlabel(\"Time ($\\mu$s)\", fontsize=\"x-large\")\n",
     "plt.ylim([0, 1])\n",
     "plt.subplot(313)\n",
     "plt.plot(expectations[2])\n",
-    "plt.ylabel('Excitation of atom 2', fontsize='x-large')\n",
-    "plt.xlabel('Time ($\\mu$s)', fontsize='x-large')\n",
+    "plt.ylabel(\"Excitation of atom 2\", fontsize=\"x-large\")\n",
+    "plt.xlabel(\"Time ($\\mu$s)\", fontsize=\"x-large\")\n",
     "plt.ylim([0, 1])"
    ]
   },
diff --git a/tutorials/simulating_sequences.ipynb b/tutorials/simulating_sequences.ipynb
index 367c62590..03fa994e7 100644
--- a/tutorials/simulating_sequences.ipynb
+++ b/tutorials/simulating_sequences.ipynb
@@ -39,7 +39,7 @@
     "# Setup\n",
     "L = 14\n",
     "\n",
-    "Omega_max = 2.3 * 2*np.pi \n",
+    "Omega_max = 2.3 * 2 * np.pi\n",
     "U = Omega_max / 2.3\n",
     "\n",
     "delta_0 = -3 * U\n",
@@ -47,15 +47,24 @@
     "\n",
     "t_rise = 2000\n",
     "t_fall = 2000\n",
-    "t_sweep = (delta_f - delta_0)/(2 * np.pi * 10) * 5000\n",
+    "t_sweep = (delta_f - delta_0) / (2 * np.pi * 10) * 5000\n",
     "\n",
     "# Define a ring of atoms distanced by a blockade radius distance:\n",
     "R_interatomic = MockDevice.rydberg_blockade_radius(U)\n",
-    "coords = R_interatomic/(2*np.tan(np.pi/L)) * np.array([(np.cos(theta*2*np.pi/L), np.sin(theta*2*np.pi/L)) for theta in range(L)])\n",
-    "                                  \n",
-    "reg = Register.from_coordinates(coords, prefix='atom')\n",
+    "coords = (\n",
+    "    R_interatomic\n",
+    "    / (2 * np.tan(np.pi / L))\n",
+    "    * np.array(\n",
+    "        [\n",
+    "            (np.cos(theta * 2 * np.pi / L), np.sin(theta * 2 * np.pi / L))\n",
+    "            for theta in range(L)\n",
+    "        ]\n",
+    "    )\n",
+    ")\n",
     "\n",
-    "reg.draw(blockade_radius=R_interatomic, draw_half_radius=True, draw_graph = True)"
+    "reg = Register.from_coordinates(coords, prefix=\"atom\")\n",
+    "\n",
+    "reg.draw(blockade_radius=R_interatomic, draw_half_radius=True, draw_graph=True)"
    ]
   },
   {
@@ -78,16 +87,22 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "rise = Pulse.ConstantDetuning(RampWaveform(t_rise, 0., Omega_max), delta_0, 0.)\n",
-    "sweep = Pulse.ConstantAmplitude(Omega_max, RampWaveform(t_sweep, delta_0, delta_f), 0.)\n",
-    "fall = Pulse.ConstantDetuning(RampWaveform(t_fall, Omega_max, 0.), delta_f, 0.)\n",
+    "rise = Pulse.ConstantDetuning(\n",
+    "    RampWaveform(t_rise, 0.0, Omega_max), delta_0, 0.0\n",
+    ")\n",
+    "sweep = Pulse.ConstantAmplitude(\n",
+    "    Omega_max, RampWaveform(t_sweep, delta_0, delta_f), 0.0\n",
+    ")\n",
+    "fall = Pulse.ConstantDetuning(\n",
+    "    RampWaveform(t_fall, Omega_max, 0.0), delta_f, 0.0\n",
+    ")\n",
     "\n",
     "seq = Sequence(reg, MockDevice)\n",
-    "seq.declare_channel('ising', 'rydberg_global')\n",
+    "seq.declare_channel(\"ising\", \"rydberg_global\")\n",
     "\n",
-    "seq.add(rise, 'ising')\n",
-    "seq.add(sweep, 'ising')\n",
-    "seq.add(fall, 'ising')\n",
+    "seq.add(rise, \"ising\")\n",
+    "seq.add(sweep, \"ising\")\n",
+    "seq.add(fall, \"ising\")\n",
     "\n",
     "seq.draw()"
    ]
@@ -158,7 +173,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "results.states[23] # Given as a `qutip.Qobj` object"
+    "results.states[23]  # Given as a `qutip.Qobj` object"
    ]
   },
   {
@@ -176,9 +191,9 @@
    "source": [
     "counts = results.sample_final_state(N_samples=1000)\n",
     "\n",
-    "large_counts = {k:v for k,v in counts.items() if v > 5}\n",
+    "large_counts = {k: v for k, v in counts.items() if v > 5}\n",
     "\n",
-    "plt.figure(figsize=(15,4))\n",
+    "plt.figure(figsize=(15, 4))\n",
     "plt.xticks(rotation=90, fontsize=14)\n",
     "plt.title(\"Most frequent observations\")\n",
     "plt.bar(large_counts.keys(), large_counts.values())"
@@ -209,6 +224,7 @@
     "    prod[j] = qutip.sigmaz()\n",
     "    return qutip.tensor(prod)\n",
     "\n",
+    "\n",
     "magn_list = [magnetization(j, L) for j in range(L)]"
    ]
   },


Test patch:
diff --git a/pulser/tests/test_channels.py b/pulser/tests/test_channels.py
index 9961208c1..532be943f 100644
--- a/pulser/tests/test_channels.py
+++ b/pulser/tests/test_channels.py
@@ -12,10 +12,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import numpy as np
 import pytest
 
 import pulser
 from pulser.channels import Raman, Rydberg
+from pulser.waveforms import BlackmanWaveform, ConstantWaveform
 
 
 def test_device_channels():
@@ -33,8 +35,10 @@ def test_device_channels():
             assert ch.clock_period >= 1
             assert ch.min_duration >= 1
             if ch.addressing == "Local":
-                assert ch.retarget_time >= 0
-                assert ch.retarget_time == int(ch.retarget_time)
+                assert ch.min_retarget_interval >= 0
+                assert ch.min_retarget_interval == int(
+                    ch.min_retarget_interval
+                )
                 assert ch.max_targets >= 1
                 assert ch.max_targets == int(ch.max_targets)
 
@@ -52,16 +56,53 @@ def test_validate_duration():
 
 
 def test_repr():
-    raman = Raman.Local(10, 2, retarget_time=1000, max_targets=4)
+    raman = Raman.Local(
+        10, 2, min_retarget_interval=1000, fixed_retarget_t=200, max_targets=4
+    )
     r1 = (
         "Raman.Local(Max Absolute Detuning: 10 rad/µs, Max Amplitude: "
-        "2 rad/µs, Target time: 1000 ns, Max targets: 4, Basis: 'digital')"
+        "2 rad/µs, Phase Jump Time: 0 ns, Minimum retarget time: 1000 ns, "
+        "Fixed retarget time: 200 ns, Max targets: 4, Basis: 'digital')"
     )
     assert raman.__str__() == r1
 
-    ryd = Rydberg.Global(50, 2.5)
+    ryd = Rydberg.Global(50, 2.5, phase_jump_time=300, mod_bandwidth=4)
     r2 = (
         "Rydberg.Global(Max Absolute Detuning: 50 rad/µs, "
-        "Max Amplitude: 2.5 rad/µs, Basis: 'ground-rydberg')"
+        "Max Amplitude: 2.5 rad/µs, Phase Jump Time: 300 ns, "
+        "Basis: 'ground-rydberg', Modulation Bandwidth: 4 MHz)"
     )
     assert ryd.__str__() == r2
+
+
+def test_modulation():
+    rydberg_global = Rydberg.Global(2 * np.pi * 20, 2 * np.pi * 2.5)
+
+    raman_local = Raman.Local(
+        2 * np.pi * 20,
+        2 * np.pi * 10,
+        mod_bandwidth=4,  # MHz
+    )
+
+    wf = ConstantWaveform(100, 1)
+    assert rydberg_global.mod_bandwidth is None
+    with pytest.warns(UserWarning, match="No modulation bandwidth defined"):
+        out_samples = rydberg_global.modulate(wf.samples)
+    assert np.all(out_samples == wf.samples)
+
+    with pytest.raises(TypeError, match="doesn't have a modulation bandwidth"):
+        rydberg_global.calc_modulation_buffer(wf.samples, out_samples)
+
+    out_ = raman_local.modulate(wf.samples)
+    tr = raman_local.rise_time
+    assert len(out_) == wf.duration + 2 * tr
+    assert raman_local.calc_modulation_buffer(wf.samples, out_) == (tr, tr)
+
+    wf2 = BlackmanWaveform(800, np.pi)
+    side_buffer_len = 45
+    out_ = raman_local.modulate(wf2.samples)
+    assert len(out_) == wf2.duration + 2 * tr  # modulate() does not truncate
+    assert raman_local.calc_modulation_buffer(wf2.samples, out_) == (
+        side_buffer_len,
+        side_buffer_len,
+    )
diff --git a/pulser/tests/test_devices.py b/pulser/tests/test_devices.py
index d71807166..89c851bac 100644
--- a/pulser/tests/test_devices.py
+++ b/pulser/tests/test_devices.py
@@ -19,8 +19,10 @@
 import pytest
 
 import pulser
-from pulser.devices import Chadoq2
+from pulser.devices import Chadoq2, Device
 from pulser.register import Register, Register3D
+from pulser.register.register_layout import RegisterLayout
+from pulser.register.special_layouts import TriangularLatticeLayout
 
 
 def test_init():
@@ -61,7 +63,7 @@ def test_mock():
         assert ch.max_abs_detuning >= 1000
         assert ch.max_amp >= 200
         if ch.addressing == "Local":
-            assert ch.retarget_time == 0
+            assert ch.min_retarget_interval == 0
             assert ch.max_targets > 1
             assert ch.max_targets == int(ch.max_targets)
 
@@ -110,4 +112,72 @@ def test_validate_register():
             Register.triangular_lattice(3, 4, spacing=3.9)
         )
 
+    with pytest.raises(
+        ValueError, match="associated with an incompatible register layout"
+    ):
+        tri_layout = TriangularLatticeLayout(201, 5)
+        Chadoq2.validate_register(tri_layout.hexagonal_register(10))
+
     Chadoq2.validate_register(Register.rectangle(5, 10, spacing=5))
+
+
+def test_validate_layout():
+    with pytest.raises(ValueError, match="The number of traps"):
+        Chadoq2.validate_layout(RegisterLayout(Register.square(20)._coords))
+
+    coords = [(100, 0), (-100, 0)]
+    with pytest.raises(TypeError):
+        Chadoq2.validate_layout(Register.from_coordinates(coords))
+    with pytest.raises(ValueError, match="at most 50 μm away from the center"):
+        Chadoq2.validate_layout(RegisterLayout(coords))
+
+    with pytest.raises(ValueError, match="at most 2 dimensions"):
+        coords = [(-10, 4, 0), (0, 0, 0)]
+        Chadoq2.validate_layout(RegisterLayout(coords))
+
+    with pytest.raises(ValueError, match="The minimal distance between traps"):
+        Chadoq2.validate_layout(
+            TriangularLatticeLayout(12, Chadoq2.min_atom_distance - 1e-6)
+        )
+
+    valid_layout = RegisterLayout(
+        Register.square(int(np.sqrt(Chadoq2.max_atom_num * 2)))._coords
+    )
+    Chadoq2.validate_layout(valid_layout)
+
+    valid_tri_layout = TriangularLatticeLayout(
+        Chadoq2.max_atom_num * 2, Chadoq2.min_atom_distance
+    )
+    Chadoq2.validate_layout(valid_tri_layout)
+
+
+def test_calibrated_layouts():
+    with pytest.raises(ValueError, match="The number of traps"):
+        Device(
+            name="TestDevice",
+            dimensions=2,
+            rydberg_level=70,
+            max_atom_num=100,
+            max_radial_distance=50,
+            min_atom_distance=4,
+            _channels=(),
+            pre_calibrated_layouts=(TriangularLatticeLayout(201, 5),),
+        )
+
+    TestDevice = Device(
+        name="TestDevice",
+        dimensions=2,
+        rydberg_level=70,
+        max_atom_num=100,
+        max_radial_distance=50,
+        min_atom_distance=4,
+        _channels=(),
+        pre_calibrated_layouts=(
+            TriangularLatticeLayout(100, 6.8),  # Rounds down with int()
+            TriangularLatticeLayout(200, 5),
+        ),
+    )
+    assert TestDevice.calibrated_register_layouts.keys() == {
+        "TriangularLatticeLayout(100, 6µm)",
+        "TriangularLatticeLayout(200, 5µm)",
+    }
diff --git a/pulser/tests/test_json.py b/pulser/tests/test_json.py
index cf5468fe3..35a8cead7 100644
--- a/pulser/tests/test_json.py
+++ b/pulser/tests/test_json.py
@@ -17,10 +17,16 @@
 import numpy as np
 import pytest
 
-from pulser import Sequence, Register
-from pulser.devices import Chadoq2
-from pulser.json.coders import PulserEncoder, PulserDecoder
+from pulser import Register, Register3D, Sequence
+from pulser.devices import Chadoq2, MockDevice
+from pulser.json.coders import PulserDecoder, PulserEncoder
+from pulser.json.supported import validate_serialization
 from pulser.parametrized.decorators import parametrize
+from pulser.register.register_layout import RegisterLayout
+from pulser.register.special_layouts import (
+    SquareLatticeLayout,
+    TriangularLatticeLayout,
+)
 from pulser.waveforms import BlackmanWaveform
 
 
@@ -43,29 +49,117 @@ def test_encoder():
         encode(1j)
 
 
+def test_register_2d():
+    reg = Register({"c": (1, 2), "d": (8, 4)})
+    seq = Sequence(reg, device=Chadoq2)
+    assert reg == encode_decode(seq).register
+
+
+def test_register_3d():
+    reg = Register3D({"a": (1, 2, 3), "b": (8, 5, 6)})
+    seq = Sequence(reg, device=MockDevice)
+    assert reg == encode_decode(seq).register
+
+
+def test_layout():
+    custom_layout = RegisterLayout([[0, 0], [1, 1], [1, 0], [0, 1]])
+    new_custom_layout = encode_decode(custom_layout)
+    assert new_custom_layout == custom_layout
+    assert type(new_custom_layout) is RegisterLayout
+
+    tri_layout = TriangularLatticeLayout(100, 10)
+    new_tri_layout = encode_decode(tri_layout)
+    assert new_tri_layout == tri_layout
+    assert type(new_tri_layout) is TriangularLatticeLayout
+
+    square_layout = SquareLatticeLayout(8, 10, 6)
+    new_square_layout = encode_decode(square_layout)
+    assert new_square_layout == square_layout
+    assert type(new_square_layout) is SquareLatticeLayout
+
+
+def test_register_from_layout():
+    layout = RegisterLayout([[0, 0], [1, 1], [1, 0], [0, 1]])
+    reg = layout.define_register(1, 0)
+    assert reg == Register({"q0": [0, 1], "q1": [0, 0]})
+    seq = Sequence(reg, device=MockDevice)
+    new_reg = encode_decode(seq).register
+    assert reg == new_reg
+    assert new_reg._layout_info.layout == layout
+    assert new_reg._layout_info.trap_ids == (1, 0)
+
+
+def test_mappable_register():
+    layout = RegisterLayout([[0, 0], [1, 1], [1, 0], [0, 1]])
+    mapp_reg = layout.make_mappable_register(2)
+    new_mapp_reg = encode_decode(mapp_reg)
+    assert new_mapp_reg._layout == layout
+    assert new_mapp_reg.qubit_ids == ("q0", "q1")
+
+
 def test_rare_cases():
     reg = Register.square(4)
     seq = Sequence(reg, Chadoq2)
     var = seq.declare_variable("var")
 
-    wf = BlackmanWaveform(100, var)
-    with pytest.warns(
-        UserWarning, match="Calls to methods of parametrized " "objects"
+    wf = BlackmanWaveform(var * 100 // 10, var)
+    with pytest.raises(
+        ValueError, match="Serialization of calls to parametrized objects"
     ):
         s = encode(wf.draw())
+    s = encode(wf)
 
-    with pytest.warns(UserWarning, match="not encode a Sequence"):
+    with pytest.raises(ValueError, match="not encode a Sequence"):
         wf_ = Sequence.deserialize(s)
 
-    var._assign(-10)
+    wf_ = decode(s)
+    seq._variables["var"]._assign(-10)
     with pytest.raises(ValueError, match="No value assigned"):
         wf_.build()
 
     var_ = wf_._variables["var"]
-    var_._assign(-10)
+    var_._assign(10)
+    assert wf_.build() == BlackmanWaveform(100, 10)
+    with pytest.warns(UserWarning, match="Serialization of 'getattr'"):
+        draw_func = wf_.draw
     with patch("matplotlib.pyplot.show"):
-        wf_.build()
+        with pytest.warns(
+            UserWarning, match="Calls to methods of parametrized objects"
+        ):
+            draw_func().build()
 
     rotated_reg = parametrize(Register.rotate)(reg, var)
     with pytest.raises(NotImplementedError):
         encode(rotated_reg)
+
+
+def test_support():
+    seq = Sequence(Register.square(2), Chadoq2)
+    var = seq.declare_variable("var")
+
+    obj_dict = BlackmanWaveform.from_max_val(1, var)._to_dict()
+    del obj_dict["__module__"]
+    with pytest.raises(TypeError, match="Invalid 'obj_dict'."):
+        validate_serialization(obj_dict)
+
+    obj_dict["__module__"] = "pulser.fake"
+    with pytest.raises(
+        SystemError, match="No serialization support for module 'pulser.fake'."
+    ):
+        validate_serialization(obj_dict)
+
+    wf_obj_dict = obj_dict["__args__"][0]
+    wf_obj_dict["__submodule__"] = "RampWaveform"
+    with pytest.raises(
+        SystemError,
+        match="No serialization support for attributes of "
+        "'pulser.waveforms.RampWaveform'",
+    ):
+        validate_serialization(wf_obj_dict)
+
+    del wf_obj_dict["__submodule__"]
+    with pytest.raises(
+        SystemError,
+        match="No serialization support for 'pulser.waveforms.from_max_val'",
+    ):
+        validate_serialization(wf_obj_dict)
diff --git a/pulser/tests/test_parametrized.py b/pulser/tests/test_parametrized.py
index c0bee6d0f..041ef6313 100644
--- a/pulser/tests/test_parametrized.py
+++ b/pulser/tests/test_parametrized.py
@@ -21,11 +21,12 @@
 from pulser.parametrized import Variable
 from pulser.waveforms import BlackmanWaveform, CompositeWaveform
 
-
 a = Variable("a", float)
 b = Variable("b", int, size=2)
 b._assign([-1.5, 1.5])
 c = Variable("c", str)
+d = Variable("d", float, size=1)
+d._assign([0.5])
 t = Variable("t", int)
 bwf = BlackmanWaveform(t, a)
 pulse = Pulse.ConstantDetuning(bwf, b[0], b[1])
@@ -70,20 +71,23 @@ def test_var():
 
     with pytest.raises(TypeError, match="Invalid key type"):
         b[[0, 1]]
-    with pytest.raises(TypeError, match="not subscriptable"):
-        a[0]
     with pytest.raises(IndexError):
         b[2]
 
 
 def test_varitem():
+    a0 = a[0]
     b1 = b[1]
     b01 = b[100::-1]
+    d0 = d[0]
     assert b01.variables == {"b": b}
+    assert str(a0) == "a[0]"
     assert str(b1) == "b[1]"
     assert str(b01) == "b[100::-1]"
-    assert b1.build() == 1
+    assert str(d0) == "d[0]"
+    assert b1.build() == 1  # TODO should be 1.5
     assert np.all(b01.build() == np.array([1, -1]))
+    assert d0.build() == 0.5
     with pytest.raises(FrozenInstanceError):
         b1.key = 0
 
@@ -116,3 +120,17 @@ def test_opsupport():
     assert (a**a).build() == 0.25
     assert abs(a).build() == 2.0
     assert (3 % a).build() == -1.0
+    assert (-a).build() == 2.0
+
+    x = a + 11
+    assert x.build() == 9
+    x = x % 6
+    assert x.build() == 3
+    x = 2 - x
+    assert x.build() == -1
+    x = 4 / x
+    assert x.build() == -4
+    x = 9 // x
+    assert x.build() == -3
+    x = 2**x
+    assert x.build() == 0.125
diff --git a/pulser/tests/test_paramseq.py b/pulser/tests/test_paramseq.py
index f210cbedc..f81717745 100644
--- a/pulser/tests/test_paramseq.py
+++ b/pulser/tests/test_paramseq.py
@@ -17,9 +17,10 @@
 import numpy as np
 import pytest
 
-from pulser import Sequence, Register, Pulse
+from pulser import Pulse, Register, Sequence
 from pulser.devices import Chadoq2, MockDevice
 from pulser.parametrized import Variable
+from pulser.parametrized.variable import VariableItem
 from pulser.waveforms import BlackmanWaveform
 
 reg = Register.rectangle(4, 3)
@@ -29,7 +30,7 @@
 def test_var_declarations():
     sb = Sequence(reg, device)
     assert sb.declared_variables == {}
-    var = sb.declare_variable("var")
+    var = sb.declare_variable("var", size=1)
     assert sb.declared_variables == {"var": var}
     assert isinstance(var, Variable)
     assert var.dtype == float
@@ -39,6 +40,11 @@ def test_var_declarations():
     var2 = sb.declare_variable("var2", 4, str)
     assert var2.dtype == str
     assert len(var2) == 4
+    var3 = sb.declare_variable("var3")
+    assert sb.declared_variables["var3"] == var3.var
+    assert isinstance(var3, VariableItem)
+    with pytest.raises(ValueError, match="'qubits' is a protected name"):
+        sb.declare_variable("qubits", size=10, dtype=str)
 
 
 def test_stored_calls():
@@ -142,7 +148,7 @@ def test_build():
     sb.delay(var * 50, "ch1")
     sb.align("ch2", "ch1")
     sb.phase_shift(var, targ_var[0])
-    pls2 = Pulse.ConstantPulse(wf.duration, var, var, 0)
+    pls2 = Pulse.ConstantPulse(var * 100, var, var, 0)
     sb.add(pls2, "ch2")
     sb.measure()
     with pytest.warns(UserWarning, match="No declared variables"):
@@ -175,7 +181,8 @@ def test_str():
     sb.add(pls, "ch1")
     s = (
         f"Prelude\n-------\n{str(seq)}Stored calls\n------------\n\n"
-        + "1. add(Pulse.ConstantPulse(mul(var, 100), var, -1, var), ch1)"
+        + "1. add(Pulse.ConstantPulse(mul(var[0], 100), var[0],"
+        + " -1, var[0]), ch1)"
     )
     assert s == str(sb)
 
diff --git a/pulser/tests/test_pulse.py b/pulser/tests/test_pulse.py
index b63793d77..595ce469e 100644
--- a/pulser/tests/test_pulse.py
+++ b/pulser/tests/test_pulse.py
@@ -18,7 +18,7 @@
 import pytest
 
 from pulser import Pulse
-from pulser.waveforms import ConstantWaveform, BlackmanWaveform, RampWaveform
+from pulser.waveforms import BlackmanWaveform, ConstantWaveform, RampWaveform
 
 cwf = ConstantWaveform(100, -10)
 bwf = BlackmanWaveform(200, 3)
diff --git a/pulser/tests/test_register.py b/pulser/tests/test_register.py
index d6368bf5c..e7d560bed 100644
--- a/pulser/tests/test_register.py
+++ b/pulser/tests/test_register.py
@@ -27,7 +27,7 @@ def test_creation():
         Register(empty_dict)
 
     coords = [(0, 0), (1, 0)]
-    ids = ["q0", "q1"]
+    ids = ("q0", "q1")
     qubits = dict(zip(ids, coords))
     with pytest.raises(TypeError):
         Register(coords)
@@ -50,14 +50,14 @@ def test_creation():
     assert reg1._ids == reg2._ids
 
     reg2b = Register.from_coordinates(coords, center=False, labels=["a", "b"])
-    assert reg2b._ids == ["a", "b"]
+    assert reg2b._ids == ("a", "b")
 
     with pytest.raises(ValueError, match="Label length"):
         Register.from_coordinates(coords, center=False, labels=["a", "b", "c"])
 
     reg3 = Register.from_coordinates(np.array(coords), prefix="foo")
     coords_ = np.array([(-0.5, 0), (0.5, 0)])
-    assert reg3._ids == ["foo0", "foo1"]
+    assert reg3._ids == ("foo0", "foo1")
     assert np.all(reg3._coords == coords_)
     assert not np.all(coords_ == coords)
 
@@ -79,6 +79,11 @@ def test_creation():
     )
     assert np.all(np.array(reg6._coords) == coords_)
 
+    with pytest.raises(
+        ValueError, match="must only be 'layout' and 'trap_ids'"
+    ):
+        Register(qubits, spacing=10, layout="square", trap_ids=(0, 1, 3))
+
 
 def test_rectangle():
     # Check rows
@@ -199,6 +204,7 @@ def test_max_connectivity():
             ]
         )
         reg = Register.max_connectivity(i, device)
+        device.validate_register(reg)
         reg2 = Register.from_coordinates(
             spacing * hex_coords[:i], center=False
         )
@@ -210,6 +216,7 @@ def test_max_connectivity():
 
     # Check full layers on a small hexagon (1 layer)
     reg = Register.max_connectivity(7, device)
+    device.validate_register(reg)
     assert len(reg.qubits) == 7
     atoms = list(reg.qubits.values())
     assert np.all(np.isclose(atoms[0], [0.0, 0.0]))
@@ -222,6 +229,7 @@ def test_max_connectivity():
 
     # Check full layers for a bigger hexagon (2 layers)
     reg = Register.max_connectivity(19, device)
+    device.validate_register(reg)
     assert len(reg.qubits) == 19
     atoms = list(reg.qubits.values())
     assert np.all(np.isclose(atoms[7], [-1.5 * spacing, crest_y * spacing]))
@@ -236,6 +244,7 @@ def test_max_connectivity():
     # Check extra atoms (2 full layers + 7 extra atoms)
     # for C3 symmetry, C6 symmetry and offset for next atoms
     reg = Register.max_connectivity(26, device)
+    device.validate_register(reg)
     assert len(reg.qubits) == 26
     atoms = list(reg.qubits.values())
     assert np.all(np.isclose(atoms[19], [-2.5 * spacing, crest_y * spacing]))
@@ -369,3 +378,33 @@ def test_to_2D():
 
     reg = Register3D.cuboid(2, 2, 1)
     reg.to_2D()
+
+
+def assert_eq(left, right):
+    assert left == right
+    assert right == left
+
+
+def assert_ineq(left, right):
+    assert left != right
+    assert right != left
+
+
+def test_equality_function():
+    reg1 = Register({"c": (1, 2), "d": (8, 4)})
+    assert_eq(reg1, reg1)
+    assert_eq(reg1, Register({"d": (8, 4), "c": (1, 2)}))
+    assert_ineq(reg1, Register({"c": (8, 4), "d": (1, 2)}))
+    assert_ineq(reg1, Register({"c": (1, 2), "d": (8, 4), "e": (8, 4)}))
+    assert_ineq(reg1, 10)
+
+    reg2 = Register3D({"a": (1, 2, 3), "b": (8, 5, 6)})
+    assert_eq(reg2, reg2)
+    assert_eq(reg2, Register3D({"a": (1, 2, 3), "b": (8, 5, 6)}))
+    assert_ineq(reg2, Register3D({"b": (1, 2, 3), "a": (8, 5, 6)}))
+    assert_ineq(
+        reg2, Register3D({"a": (1, 2, 3), "b": (8, 5, 6), "e": (8, 5, 6)})
+    )
+    assert_ineq(reg2, 10)
+
+    assert_ineq(reg1, reg2)
diff --git a/pulser/tests/test_register_layout.py b/pulser/tests/test_register_layout.py
new file mode 100644
index 000000000..bcc605e85
--- /dev/null
+++ b/pulser/tests/test_register_layout.py
@@ -0,0 +1,204 @@
+# 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.
+
+from hashlib import sha256
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+
+from pulser.register import Register, Register3D
+from pulser.register.register_layout import RegisterLayout
+from pulser.register.special_layouts import (
+    SquareLatticeLayout,
+    TriangularLatticeLayout,
+)
+
+layout = RegisterLayout([[0, 0], [1, 1], [1, 0], [0, 1]])
+layout3d = RegisterLayout([[0, 0, 0], [1, 1, 1], [0, 1, 0], [1, 0, 1]])
+
+
+def test_creation():
+    with pytest.raises(
+        ValueError, match="must be an array or list of coordinates"
+    ):
+        RegisterLayout([[0, 0, 0], [1, 1], [1, 0], [0, 1]])
+
+    with pytest.raises(ValueError, match="size 2 or 3"):
+        RegisterLayout([[0], [1], [2]])
+
+    assert np.all(layout.coords == [[0, 0], [0, 1], [1, 0], [1, 1]])
+    assert np.all(
+        layout3d.coords == [[0, 0, 0], [0, 1, 0], [1, 0, 1], [1, 1, 1]]
+    )
+    assert layout.number_of_traps == 4
+    assert layout.max_atom_num == 2
+    assert layout.dimensionality == 2
+    for i, coord in enumerate(layout.coords):
+        assert np.all(layout.traps_dict[i] == coord)
+
+
+def test_register_definition():
+    with pytest.raises(ValueError, match="must be a unique integer"):
+        layout.define_register(0, 1, 1)
+
+    with pytest.raises(ValueError, match="correspond to the ID of a trap"):
+        layout.define_register(0, 4, 3)
+
+    with pytest.raises(ValueError, match="must be a sequence of unique IDs"):
+        layout.define_register(0, 1, qubit_ids=["a", "b", "b"])
+
+    with pytest.raises(ValueError, match="must have the same size"):
+        layout.define_register(0, 1, qubit_ids=["a", "b", "c"])
+
+    with pytest.raises(
+        ValueError, match="greater than the maximum number of qubits"
+    ):
+        layout.define_register(0, 1, 3)
+
+    assert layout.define_register(0, 1) == Register.from_coordinates(
+        [[0, 0], [0, 1]], prefix="q", center=False
+    )
+
+    assert layout3d.define_register(0, 1) == Register3D(
+        {"q0": [0, 0, 0], "q1": [0, 1, 0]}
+    )
+
+    reg2d = layout.define_register(0, 2)
+    assert reg2d._layout_info == (layout, (0, 2))
+    with pytest.raises(ValueError, match="dimensionality is not the same"):
+        reg2d._validate_layout(layout3d, (0, 2))
+    with pytest.raises(
+        ValueError, match="Every 'trap_id' must be a unique integer"
+    ):
+        reg2d._validate_layout(layout, (0, 2, 2))
+    with pytest.raises(
+        ValueError, match="must be equal to the number of atoms"
+    ):
+        reg2d._validate_layout(layout, (0,))
+    with pytest.raises(
+        ValueError, match="don't match this register's coordinates"
+    ):
+        reg2d._validate_layout(layout, (0, 1))
+
+    with pytest.raises(TypeError, match="cannot be rotated"):
+        reg2d.rotate(30)
+
+
+def test_draw():
+    with patch("matplotlib.pyplot.show"):
+        layout.draw()
+
+    with patch("matplotlib.pyplot.show"):
+        layout3d.draw()
+
+    with patch("matplotlib.pyplot.show"):
+        layout3d.draw(projection=False)
+
+
+def test_repr():
+    hash_ = sha256(bytes(2))
+    hash_.update(layout.coords.tobytes())
+    assert repr(layout) == f"RegisterLayout_{hash_.hexdigest()}"
+
+
+def test_eq():
+    assert RegisterLayout([[0, 0], [1, 0]]) != Register.from_coordinates(
+        [[0, 0], [1, 0]]
+    )
+    assert layout != layout3d
+    layout1 = RegisterLayout([[0, 0], [1, 0]])
+    layout2 = RegisterLayout([[1, 0], [0, 0]])
+    assert layout1 == layout2
+    assert hash(layout1) == hash(layout2)
+
+
+def test_traps_from_coordinates():
+    assert layout._coords_to_traps == {
+        (0, 0): 0,
+        (0, 1): 1,
+        (1, 0): 2,
+        (1, 1): 3,
+    }
+    assert layout.get_traps_from_coordinates(
+        (0.9999995, 0.0000004), (0, 1), (1, 1)
+    ) == [2, 1, 3]
+    with pytest.raises(ValueError, match="not a part of the RegisterLayout"):
+        layout.get_traps_from_coordinates((0.9999994, 1))
+
+
+def test_square_lattice_layout():
+    square = SquareLatticeLayout(9, 7, 5)
+    assert str(square) == "SquareLatticeLayout(9x7, 5µm)"
+    assert square.square_register(3) == Register.square(
+        3, spacing=5, prefix="q"
+    )
+    # An even number of atoms on the side won't align the center with an atom
+    assert square.square_register(4) != Register.square(
+        4, spacing=5, prefix="q"
+    )
+    with pytest.raises(
+        ValueError, match="'6 x 6' array has more atoms than those available"
+    ):
+        square.square_register(6)
+
+    assert square.rectangular_register(3, 7, prefix="r") == Register.rectangle(
+        3, 7, spacing=5, prefix="r"
+    )
+    with pytest.raises(ValueError, match="'10 x 3' array doesn't fit"):
+        square.rectangular_register(10, 3)
+
+
+def test_triangular_lattice_layout():
+    tri = TriangularLatticeLayout(50, 5)
+    assert str(tri) == "TriangularLatticeLayout(50, 5µm)"
+
+    assert tri.hexagonal_register(19) == Register.hexagon(
+        2, spacing=5, prefix="q"
+    )
+    with pytest.raises(ValueError, match="hold at most 25 atoms, not '26'"):
+        tri.hexagonal_register(26)
+
+    with pytest.raises(
+        ValueError, match="has more atoms than those available"
+    ):
+        tri.rectangular_register(7, 4)
+
+    # Case where the register doesn't fit
+    with pytest.raises(ValueError, match="not a part of the RegisterLayout"):
+        tri.rectangular_register(8, 3)
+
+    # But this fits fine, though off-centered with the Register default
+    tri.rectangular_register(5, 5) != Register.triangular_lattice(
+        5, 5, spacing=5, prefix="q"
+    )
+
+
+def test_mappable_register_creation():
+    tri = TriangularLatticeLayout(50, 5)
+    with pytest.raises(ValueError, match="greater than the maximum"):
+        tri.make_mappable_register(26)
+
+    mapp_reg = tri.make_mappable_register(5)
+    assert mapp_reg.qubit_ids == ("q0", "q1", "q2", "q3", "q4")
+
+    with pytest.raises(
+        ValueError, match="labeled with pre-declared qubit IDs"
+    ):
+        mapp_reg.build_register({"q0": 0, "q5": 2})
+
+    reg = mapp_reg.build_register({"q0": 10, "q1": 49})
+    assert reg == Register(
+        {"q0": tri.traps_dict[10], "q1": tri.traps_dict[49]}
+    )
diff --git a/pulser/tests/test_sequence.py b/pulser/tests/test_sequence.py
index 86a69aed8..009856a9f 100644
--- a/pulser/tests/test_sequence.py
+++ b/pulser/tests/test_sequence.py
@@ -18,15 +18,17 @@
 import pytest
 
 import pulser
-from pulser import Sequence, Pulse, Register, Register3D
+from pulser import Pulse, Register, Register3D, Sequence
+from pulser.channels import Raman, Rydberg
 from pulser.devices import Chadoq2, MockDevice
 from pulser.devices._device_datacls import Device
+from pulser.register.special_layouts import TriangularLatticeLayout
 from pulser.sequence import _TimeSlot
 from pulser.waveforms import (
     BlackmanWaveform,
     CompositeWaveform,
-    RampWaveform,
     InterpolatedWaveform,
+    RampWaveform,
 )
 
 reg = Register.triangular_lattice(4, 7, spacing=5, prefix="q")
@@ -152,7 +154,7 @@ def test_target():
 
     assert seq._schedule["ch0"][-1] == _TimeSlot("target", -1, 0, {"q1"})
     seq.target("q4", "ch0")
-    retarget_t = seq.declared_channels["ch0"].retarget_time
+    retarget_t = seq.declared_channels["ch0"].min_retarget_interval
     assert seq._schedule["ch0"][-1] == _TimeSlot(
         "target", 0, retarget_t, {"q4"}
     )
@@ -554,3 +556,152 @@ def test_draw_register():
     seq3d.config_slm_mask([6, 15])
     with patch("matplotlib.pyplot.show"):
         seq3d.draw(draw_register=True)
+
+
+def test_hardware_constraints():
+    rydberg_global = Rydberg.Global(
+        2 * np.pi * 20,
+        2 * np.pi * 2.5,
+        phase_jump_time=120,  # ns
+        mod_bandwidth=4,  # MHz
+    )
+
+    raman_local = Raman.Local(
+        2 * np.pi * 20,
+        2 * np.pi * 10,
+        phase_jump_time=120,  # ns
+        fixed_retarget_t=200,  # ns
+        mod_bandwidth=7,  # MHz
+    )
+
+    ConstrainedChadoq2 = Device(
+        name="ConstrainedChadoq2",
+        dimensions=2,
+        rydberg_level=70,
+        max_atom_num=100,
+        max_radial_distance=50,
+        min_atom_distance=4,
+        _channels=(
+            ("rydberg_global", rydberg_global),
+            ("raman_local", raman_local),
+        ),
+    )
+    with pytest.warns(
+        UserWarning, match="should be imported from 'pulser.devices'"
+    ):
+        seq = Sequence(reg, ConstrainedChadoq2)
+    seq.declare_channel("ch0", "rydberg_global")
+    seq.declare_channel("ch1", "raman_local", initial_target="q1")
+
+    const_pls = Pulse.ConstantPulse(100, 1, 0, np.pi)
+    seq.add(const_pls, "ch0")
+    black_wf = BlackmanWaveform(500, np.pi)
+    black_pls = Pulse.ConstantDetuning(black_wf, 0, 0)
+    seq.add(black_pls, "ch1")
+    blackman_slot = seq._last("ch1")
+    # The pulse accounts for the modulation buffer
+    assert (
+        blackman_slot.ti == const_pls.duration + rydberg_global.rise_time * 2
+    )
+    seq.target("q0", "ch1")
+    target_slot = seq._last("ch1")
+    fall_time = black_pls.fall_time(raman_local)
+    assert (
+        fall_time
+        == raman_local.rise_time + black_wf.modulation_buffers(raman_local)[1]
+    )
+    fall_time += (
+        raman_local.clock_period - fall_time % raman_local.clock_period
+    )
+    assert target_slot.ti == blackman_slot.tf + fall_time
+    assert target_slot.tf == target_slot.ti + raman_local.fixed_retarget_t
+
+    assert raman_local.min_retarget_interval > raman_local.fixed_retarget_t
+    seq.target("q2", "ch1")
+    assert (
+        seq.get_duration("ch1")
+        == target_slot.tf + raman_local.min_retarget_interval
+    )
+
+    # Check for phase jump buffer
+    seq.add(black_pls, "ch0")  # Phase = 0
+    tf_ = seq.get_duration("ch0")
+    mid_delay = 40
+    seq.delay(mid_delay, "ch0")
+    seq.add(const_pls, "ch0")  # Phase = π
+    assert seq._last("ch0").ti - tf_ == rydberg_global.phase_jump_time
+    added_delay_slot = seq._schedule["ch0"][-2]
+    assert added_delay_slot.type == "delay"
+    assert (
+        added_delay_slot.tf - added_delay_slot.ti
+        == rydberg_global.phase_jump_time - mid_delay
+    )
+
+    tf_ = seq.get_duration("ch0")
+    seq.align("ch0", "ch1")
+    fall_time = const_pls.fall_time(rydberg_global)
+    assert seq.get_duration() == tf_ + fall_time
+
+    with pytest.raises(ValueError, match="'mode' must be one of"):
+        seq.draw(mode="all")
+
+    with patch("matplotlib.pyplot.show"):
+        with pytest.warns(
+            UserWarning,
+            match="'draw_phase_area' doesn't work in 'output' mode",
+        ):
+            seq.draw(
+                mode="output", draw_interp_pts=False, draw_phase_area=True
+            )
+        with pytest.warns(
+            UserWarning,
+            match="'draw_interp_pts' doesn't work in 'output' mode",
+        ):
+            seq.draw(mode="output")
+        seq.draw(mode="input+output")
+
+
+def test_mappable_register():
+    layout = TriangularLatticeLayout(100, 5)
+    mapp_reg = layout.make_mappable_register(10)
+    seq = Sequence(mapp_reg, Chadoq2)
+    assert seq.is_register_mappable()
+    reserved_qids = tuple([f"q{i}" for i in range(10)])
+    assert seq._qids == set(reserved_qids)
+    with pytest.raises(RuntimeError, match="Can't access the qubit info"):
+        seq.qubit_info
+    with pytest.raises(
+        RuntimeError, match="Can't access the sequence's register"
+    ):
+        seq.register
+
+    seq.declare_channel("ryd", "rydberg_global")
+    seq.declare_channel("ram", "raman_local", initial_target="q2")
+    seq.add(Pulse.ConstantPulse(100, 1, 0, 0), "ryd")
+    seq.add(Pulse.ConstantPulse(200, 1, 0, 0), "ram")
+    assert seq._last("ryd").targets == set(reserved_qids)
+    assert seq._last("ram").targets == {"q2"}
+
+    with pytest.raises(ValueError, match="Can't draw the register"):
+        seq.draw(draw_register=True)
+
+    # Can draw if 'draw_register=False'
+    with patch("matplotlib.pyplot.show"):
+        seq.draw()
+
+    with pytest.raises(ValueError, match="'qubits' must be specified"):
+        seq.build()
+
+    with pytest.raises(
+        ValueError, match="targeted but have not been assigned"
+    ):
+        seq.build(qubits={"q0": 1, "q1": 10})
+
+    seq_ = seq.build(qubits={"q2": 20, "q0": 10})
+    seq_._last("ryd").targets == {"q2", "q0"}
+    assert not seq_.is_register_mappable()
+    assert seq_.register == Register(
+        {"q0": layout.traps_dict[10], "q2": layout.traps_dict[20]}
+    )
+    with pytest.raises(ValueError, match="already has a concrete register"):
+        seq_.build(qubits={"q2": 20, "q0": 10})
diff --git a/pulser/tests/test_simresults.py b/pulser/tests/test_simresults.py
index f8219c884..ec0aecadf 100644
--- a/pulser/tests/test_simresults.py
+++ b/pulser/tests/test_simresults.py
@@ -11,20 +11,19 @@
 # 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.
-from copy import deepcopy
-
 from collections import Counter
+from copy import deepcopy
 
 import numpy as np
 import pytest
 import qutip
 from qutip.piqs import isdiagonal
 
-from pulser import Sequence, Pulse, Register
+from pulser import Pulse, Register, Sequence
 from pulser.devices import Chadoq2, MockDevice
-from pulser.waveforms import BlackmanWaveform
-from pulser.simulation import Simulation, SimConfig
+from pulser.simulation import SimConfig, Simulation
 from pulser.simulation.simresults import CoherentResults, NoisyResults
+from pulser.waveforms import BlackmanWaveform
 
 np.random.seed(123)
 q_dict = {
diff --git a/pulser/tests/test_simulation.py b/pulser/tests/test_simulation.py
index a3707909d..9841c8d9f 100644
--- a/pulser/tests/test_simulation.py
+++ b/pulser/tests/test_simulation.py
@@ -12,18 +12,18 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from unittest.mock import patch
-
 from collections import Counter
+from unittest.mock import patch
 
 import numpy as np
 import pytest
 import qutip
 
-from pulser import Sequence, Pulse, Register
+from pulser import Pulse, Register, Sequence
 from pulser.devices import Chadoq2, MockDevice
-from pulser.waveforms import BlackmanWaveform, RampWaveform, ConstantWaveform
+from pulser.register.register_layout import RegisterLayout
 from pulser.simulation import SimConfig, Simulation
+from pulser.waveforms import BlackmanWaveform, ConstantWaveform, RampWaveform
 
 q_dict = {
     "control1": np.array([-4.0, 0.0]),
@@ -104,6 +104,21 @@ def test_initialization_and_construction_of_hamiltonian():
         for sh in qobjevo.qobj.shape:
             assert sh == sim.dim**sim._size
 
+    assert not seq.is_parametrized()
+    seq_copy = seq.build()  # Take a copy of the sequence
+    x = seq_copy.declare_variable("x")
+    seq_copy.add(Pulse.ConstantPulse(x, 1, 0, 0), "ryd")
+    assert seq_copy.is_parametrized()
+    with pytest.raises(ValueError, match="needs to be built"):
+        Simulation(seq_copy)
+
+    layout = RegisterLayout([[0, 0], [10, 10]])
+    mapp_reg = layout.make_mappable_register(1)
+    seq_ = Sequence(mapp_reg, Chadoq2)
+    assert seq_.is_register_mappable() and not seq_.is_parametrized()
+    with pytest.raises(ValueError, match="needs to be built"):
+        Simulation(seq_)
+
 
 def test_extraction_of_sequences():
     sim = Simulation(seq)
diff --git a/pulser/tests/test_waveforms.py b/pulser/tests/test_waveforms.py
index c0487362b..3c140ddb3 100644
--- a/pulser/tests/test_waveforms.py
+++ b/pulser/tests/test_waveforms.py
@@ -18,18 +18,19 @@
 
 import numpy as np
 import pytest
-from scipy.interpolate import interp1d, PchipInterpolator
+from scipy.interpolate import PchipInterpolator, interp1d
 
-from pulser.json.coders import PulserEncoder, PulserDecoder
-from pulser.parametrized import Variable, ParamObj
+from pulser.channels import Rydberg
+from pulser.json.coders import PulserDecoder, PulserEncoder
+from pulser.parametrized import ParamObj, Variable
 from pulser.waveforms import (
-    ConstantWaveform,
-    KaiserWaveform,
-    RampWaveform,
     BlackmanWaveform,
-    CustomWaveform,
     CompositeWaveform,
+    ConstantWaveform,
+    CustomWaveform,
     InterpolatedWaveform,
+    KaiserWaveform,
+    RampWaveform,
 )
 
 np.random.seed(20201105)
@@ -98,10 +99,16 @@ def test_integral():
 
 
 def test_draw():
+    rydberg_global = Rydberg.Global(
+        2 * np.pi * 20,
+        2 * np.pi * 2.5,
+        phase_jump_time=120,  # ns
+        mod_bandwidth=4,  # MHz
+    )
     with patch("matplotlib.pyplot.show"):
         composite.draw()
-        blackman.draw()
-        interp.draw()
+        blackman.draw(output_channel=rydberg_global)
+        interp.draw(output_channel=rydberg_global)
 
 
 def test_eq():
@@ -405,3 +412,21 @@ def test_get_item():
         assert wf[duration * 2 :].size == 0
         assert wf[duration * 2 : duration * 3].size == 0
         assert wf[-duration * 3 : -duration * 2].size == 0
+
+
+def test_modulation():
+    rydberg_global = Rydberg.Global(
+        2 * np.pi * 20,
+        2 * np.pi * 2.5,
+        phase_jump_time=120,  # ns
+        mod_bandwidth=4,  # MHz
+    )
+    mod_samples = constant.modulated_samples(rydberg_global)
+    assert np.all(mod_samples == rydberg_global.modulate(constant.samples))
+    assert constant.modulation_buffers(rydberg_global) == (
+        rydberg_global.rise_time,
+        rydberg_global.rise_time,
+    )
+    assert len(mod_samples) == constant.duration + 2 * rydberg_global.rise_time
+    assert np.isclose(np.sum(mod_samples) * 1e-3, constant.integral)
+    assert max(np.abs(mod_samples)) < np.abs(constant[0])