Repository: tqec/tqec

Issue:
Have coordinate system aware `Position2D`
**Is your feature request related to a problem? Please describe.**
We are currently using `Position2D` to represent 2-dimensional positions in different coordinate systems. I can list at least 3:

- physical qubit coordinate system,
- plaquette coordinate system,
- block coordinate system.

This is error-prone and requires to be extra careful each time a `Position2D` is used.

**Describe the solution you'd like**
Make `Position2D` coordinate-system-aware, and use Python type system to avoid by design mixing positions on different coordinate systems.

Hints from issue comments:

Solution patch:
diff --git a/src/tqec/__init__.py b/src/tqec/__init__.py
index 612b064b..080d0627 100644
--- a/src/tqec/__init__.py
+++ b/src/tqec/__init__.py
@@ -36,11 +36,13 @@ from .plaquette import RAPNGDescription as RAPNGDescription
 from .plaquette import RPNGDescription as RPNGDescription
 from .plaquette import SquarePlaquetteQubits as SquarePlaquetteQubits
 from .plaquette.enums import PlaquetteOrientation as PlaquetteOrientation
+from .position import BlockPosition2D as BlockPosition2D
 from .position import Direction3D as Direction3D
-from .position import Displacement as Displacement
-from .position import Position2D as Position2D
+from .position import PhysicalQubitPosition2D as PhysicalQubitPosition2D
+from .position import PlaquettePosition2D as PlaquettePosition2D
 from .position import Position3D as Position3D
 from .position import Shape2D as Shape2D
+from .position import Shift2D as Shift2D
 from .position import SignedDirection3D as SignedDirection3D
 from .scale import LinearFunction as LinearFunction
 from .scale import Scalable2D as Scalable2D
diff --git a/src/tqec/circuit/generation.py b/src/tqec/circuit/generation.py
index ec0e9ddc..b4351606 100644
--- a/src/tqec/circuit/generation.py
+++ b/src/tqec/circuit/generation.py
@@ -27,7 +27,7 @@ from tqec.circuit.schedule import (
     relabel_circuits_qubit_indices,
 )
 from tqec.plaquette.plaquette import Plaquettes
-from tqec.position import Displacement
+from tqec.position import Shift2D
 from tqec.templates.indices.base import Template
 
 
@@ -78,7 +78,7 @@ def generate_circuit(
 def generate_circuit_from_instantiation(
     plaquette_array: npt.NDArray[numpy.int_],
     plaquettes: Plaquettes,
-    increments: Displacement,
+    increments: Shift2D,
 ) -> ScheduledCircuit:
     """Generate a quantum circuit from an array of plaquette indices and the
     associated plaquettes.
@@ -135,7 +135,7 @@ def generate_circuit_from_instantiation(
             if plaquette_index != 0:
                 # Computing the offset that should be applied to each qubits.
                 plaquette = plaquettes[plaquette_index]
-                qubit_offset = Displacement(
+                qubit_offset = Shift2D(
                     plaquette.origin.x + column_index * increments.x,
                     plaquette.origin.y + row_index * increments.y,
                 )
diff --git a/src/tqec/circuit/measurement.py b/src/tqec/circuit/measurement.py
index ff54b4b0..97bdefca 100644
--- a/src/tqec/circuit/measurement.py
+++ b/src/tqec/circuit/measurement.py
@@ -21,7 +21,7 @@ from tqec.circuit.instructions import (
 from tqec.circuit.qubit import GridQubit
 from tqec.circuit.qubit_map import QubitMap
 from tqec.exceptions import TQECException
-from tqec.position import Displacement
+from tqec.position import Shift2D
 
 
 class AbstractMeasurement(ABC):
@@ -100,7 +100,7 @@ class Measurement(AbstractMeasurement):
 
     @override
     def offset_spatially_by(self, x: int, y: int) -> Measurement:
-        return Measurement(self.qubit + Displacement(x, y), self.offset)
+        return Measurement(self.qubit + Shift2D(x, y), self.offset)
 
     @override
     def offset_temporally_by(self, t: int) -> Measurement:
diff --git a/src/tqec/circuit/qubit.py b/src/tqec/circuit/qubit.py
index 693191f8..e15a5b84 100644
--- a/src/tqec/circuit/qubit.py
+++ b/src/tqec/circuit/qubit.py
@@ -14,7 +14,7 @@ from collections import defaultdict
 import stim
 
 from tqec.circuit.coordinates import StimCoordinates
-from tqec.position import Displacement, Position2D
+from tqec.position import PhysicalQubitPosition2D, Shift2D
 
 
 class GridQubit:
@@ -43,10 +43,14 @@ class GridQubit:
             StimCoordinates(self.x, self.y).to_stim_coordinates(),
         )
 
-    def __add__(self, other: GridQubit | Position2D | Displacement) -> GridQubit:
+    def __add__(
+        self, other: GridQubit | PhysicalQubitPosition2D | Shift2D
+    ) -> GridQubit:
         return GridQubit(self.x + other.x, self.y + other.y)
 
-    def __sub__(self, other: GridQubit | Position2D | Displacement) -> GridQubit:
+    def __sub__(
+        self, other: GridQubit | PhysicalQubitPosition2D | Shift2D
+    ) -> GridQubit:
         return GridQubit(self.x - other.x, self.y - other.y)
 
     def __mul__(self, other: int) -> GridQubit:
diff --git a/src/tqec/compile/block.py b/src/tqec/compile/block.py
index 872f287a..2a5f859b 100644
--- a/src/tqec/compile/block.py
+++ b/src/tqec/compile/block.py
@@ -9,7 +9,7 @@ from tqec.exceptions import TQECException
 from tqec.plaquette.frozendefaultdict import FrozenDefaultDict
 from tqec.plaquette.library.empty import empty_square_plaquette
 from tqec.plaquette.plaquette import Plaquette, Plaquettes, RepeatedPlaquettes
-from tqec.position import Displacement, Position2D
+from tqec.position import BlockPosition2D, Shift2D
 from tqec.scale import LinearFunction
 from tqec.templates.indices.base import RectangularTemplate
 from tqec.templates.indices.layout import LayoutTemplate
@@ -72,13 +72,13 @@ class CompiledBlock:
 
 
 class BlockLayout:
-    def __init__(self, blocks_layout: dict[Position2D, CompiledBlock]):
+    def __init__(self, blocks_layout: dict[BlockPosition2D, CompiledBlock]):
         """Create a layout of :class:`CompiledBlock` instances in the 2D grid.
 
         We require that all the blocks in the layout have the same
         scalable shape.
         """
-        template_layout: dict[Position2D, RectangularTemplate] = {}
+        template_layout: dict[BlockPosition2D, RectangularTemplate] = {}
         for pos, block in blocks_layout.items():
             template_layout[pos] = block.template
         self._template = LayoutTemplate(element_layout=template_layout)
@@ -97,7 +97,7 @@ class BlockLayout:
         return self._layers
 
     def _merge_layers(
-        self, layers_layout: dict[Position2D, list[Plaquettes]]
+        self, layers_layout: dict[BlockPosition2D, list[Plaquettes]]
     ) -> list[Plaquettes]:
         """Merge the layers of the different blocks in the layout."""
         # Check if all the blocks have the same number of layers.
@@ -166,7 +166,7 @@ class BlockLayout:
         # We need to shift the circuit based on the shift of the layout template.
         top_left_plaquette = self._template.instantiation_origin(k)
         increments = self._template.get_increments()
-        offset = Displacement(
+        offset = Shift2D(
             top_left_plaquette.x * increments.x, top_left_plaquette.y * increments.y
         )
         return [
diff --git a/src/tqec/compile/compile.py b/src/tqec/compile/compile.py
index 8d185328..067a31dd 100644
--- a/src/tqec/compile/compile.py
+++ b/src/tqec/compile/compile.py
@@ -365,7 +365,13 @@ def compile_block_graph(
     min_z = min(pos.z for pos in blocks.keys())
     max_z = max(pos.z for pos in blocks.keys())
     layout_slices: list[BlockLayout] = [
-        BlockLayout({pos.as_2d(): block for pos, block in blocks.items() if pos.z == z})
+        BlockLayout(
+            {
+                pos.as_2d().with_block_coordinate_system(): block
+                for pos, block in blocks.items()
+                if pos.z == z
+            }
+        )
         for z in range(min_z, max_z + 1)
     ]
 
diff --git a/src/tqec/compile/detectors/compute.py b/src/tqec/compile/detectors/compute.py
index 58424f67..78fc8c9b 100644
--- a/src/tqec/compile/detectors/compute.py
+++ b/src/tqec/compile/detectors/compute.py
@@ -21,7 +21,7 @@ from tqec.compile.detectors.database import DetectorDatabase
 from tqec.compile.detectors.detector import Detector
 from tqec.exceptions import TQECException
 from tqec.plaquette.plaquette import Plaquettes
-from tqec.position import Displacement, Position2D
+from tqec.position import PhysicalQubitPosition2D, Shift2D
 from tqec.templates.indices.base import Template
 from tqec.templates.indices.display import (
     get_template_representation_from_instantiation,
@@ -86,7 +86,7 @@ def _matched_detectors_to_detectors(
 
 
 def _center_plaquette_syndrome_qubits(
-    subtemplate: SubTemplateType, plaquettes: Plaquettes, increments: Displacement
+    subtemplate: SubTemplateType, plaquettes: Plaquettes, increments: Shift2D
 ) -> list[GridQubit]:
     """Return a collection of qubits that are used as syndrome qubits by the
     central plaquette of the provided `subtemplate`.
@@ -116,7 +116,7 @@ def _center_plaquette_syndrome_qubits(
 
     central_plaquette = plaquettes[central_plaquette_index]
     origin = central_plaquette.origin
-    offset = Displacement(r * increments.x + origin.x, r * increments.y + origin.y)
+    offset = Shift2D(r * increments.x + origin.x, r * increments.y + origin.y)
     return [q + offset for q in central_plaquette.qubits.syndrome_qubits]
 
 
@@ -124,7 +124,7 @@ def _filter_detectors(
     detectors: list[Detector],
     subtemplates: Sequence[SubTemplateType],
     plaquettes: Sequence[Plaquettes],
-    increments: Displacement,
+    increments: Shift2D,
 ) -> frozenset[Detector]:
     """Filter detectors that do not involve at least one measurement on a
     syndrome qubit of the central plaquette, in the last round.
@@ -173,7 +173,7 @@ def _filter_detectors(
 def _compute_detectors_at_end_of_situation(
     subtemplates: Sequence[SubTemplateType],
     plaquettes: Sequence[Plaquettes],
-    increments: Displacement,
+    increments: Shift2D,
 ) -> frozenset[Detector]:
     if len(plaquettes) != len(subtemplates):
         raise TQECException(
@@ -276,7 +276,7 @@ def _get_database_access_exception(
 def compute_detectors_at_end_of_situation(
     subtemplates: Sequence[SubTemplateType],
     plaquettes_by_timestep: Sequence[Plaquettes],
-    increments: Displacement,
+    increments: Shift2D,
     database: DetectorDatabase | None = None,
     only_use_database: bool = False,
 ) -> frozenset[Detector]:
@@ -452,7 +452,7 @@ def _compute_superimposed_template_instantiations(
 
     top_left = origins[-1]
     n, m = instantiations[-1].shape
-    bottom_right = Position2D(top_left.x + m, top_left.y + n)
+    bottom_right = PhysicalQubitPosition2D(top_left.x + m, top_left.y + n)
 
     # Get the correct instantiations
     ret: list[npt.NDArray[numpy.int_]] = []
diff --git a/src/tqec/compile/detectors/database.py b/src/tqec/compile/detectors/database.py
index c64a6e86..ee5762b4 100644
--- a/src/tqec/compile/detectors/database.py
+++ b/src/tqec/compile/detectors/database.py
@@ -18,7 +18,7 @@ from tqec.circuit.schedule import (
 from tqec.compile.detectors.detector import Detector
 from tqec.exceptions import TQECException
 from tqec.plaquette.plaquette import Plaquettes
-from tqec.position import Displacement
+from tqec.position import Shift2D
 from tqec.templates.indices.subtemplates import SubTemplateType
 
 
@@ -113,7 +113,7 @@ class _DetectorDatabaseKey:
             and self.plaquette_names == rhs.plaquette_names
         )
 
-    def circuit(self, plaquette_increments: Displacement) -> ScheduledCircuit:
+    def circuit(self, plaquette_increments: Shift2D) -> ScheduledCircuit:
         """Get the `stim.Circuit` instance represented by `self`.
 
         Args:
@@ -244,7 +244,7 @@ class DetectorDatabase:
         self.frozen = False
 
     def to_crumble_urls(
-        self, plaquette_increments: Displacement = Displacement(2, 2)
+        self, plaquette_increments: Shift2D = Shift2D(2, 2)
     ) -> list[str]:
         """Returns one URL pointing to https://algassert.com/crumble for each of
         the registered situations.
diff --git a/src/tqec/plaquette/plaquette.py b/src/tqec/plaquette/plaquette.py
index 140a4f42..9f572ee5 100644
--- a/src/tqec/plaquette/plaquette.py
+++ b/src/tqec/plaquette/plaquette.py
@@ -12,7 +12,7 @@ from tqec.exceptions import TQECException
 from tqec.plaquette.enums import PlaquetteOrientation
 from tqec.plaquette.frozendefaultdict import FrozenDefaultDict
 from tqec.plaquette.qubit import PlaquetteQubits
-from tqec.position import Position2D
+from tqec.position import PhysicalQubitPosition2D
 from tqec.scale import LinearFunction, round_or_fail
 
 
@@ -62,8 +62,8 @@ class Plaquette:
             )
 
     @property
-    def origin(self) -> Position2D:
-        return Position2D(0, 0)
+    def origin(self) -> PhysicalQubitPosition2D:
+        return PhysicalQubitPosition2D(0, 0)
 
     def __eq__(self, rhs: object) -> bool:
         return isinstance(rhs, Plaquette) and self.name == rhs.name
diff --git a/src/tqec/position.py b/src/tqec/position.py
index d1ccbe57..55142bff 100644
--- a/src/tqec/position.py
+++ b/src/tqec/position.py
@@ -32,45 +32,57 @@ from enum import Enum
 import numpy as np
 import numpy.typing as npt
 
-from tqec.exceptions import TQECException
 
+@dataclass(frozen=True, order=True)
+class Vec2D:
+    x: int
+    y: int
 
-@dataclass(frozen=True)
-class Position2D:
-    """Simple wrapper around tuple[int, int].
-
-    This class is here to explicitly name the type of variables as positions
-    instead of having a tuple[int, int] that could be:
-    - a position,
-    - a shape,
-    - coefficients for positions,
-    - displacements.
-    """
 
+@dataclass(frozen=True, order=True)
+class Vec3D:
     x: int
     y: int
+    z: int
 
-    def to_grid_qubit(self) -> tuple[int, int]:
-        """Returns the position as a tuple following the cirq.GridQubit
-        coordinate system."""
-        return (self.y, self.x)
 
+class Position2D(Vec2D):
+    """Represents a position on a 2-dimensional plane.
 
-@dataclass(frozen=True)
-class Shape2D:
-    """Simple wrapper around tuple[int, int].
-
-    This class is here to explicitly name the type of variables as shapes
-    instead of having a tuple[int, int] that could be:
-    - a position,
-    - a shape,
-    - coefficients for positions,
-    - displacements.
+    Warning:
+        This class represents a position without any knowledge of the coordinate
+        system being used. As such, it should only be used when the coordinate
+        system is meaningless or in localised places where the coordinate system
+        is obvious. In particular, this class should be avoided in interfaces.
     """
 
-    x: int
-    y: int
+    def with_block_coordinate_system(self) -> BlockPosition2D:
+        return BlockPosition2D(self.x, self.y)
+
+
+class PhysicalQubitPosition2D(Position2D):
+    """Represents the position of a physical qubit on a 2-dimensional plane."""
+
 
+class PlaquettePosition2D(Position2D):
+    """Represents the position of a plaquette on a 2-dimensional plane."""
+
+    def get_origin_position(self, displacement: Shift2D) -> PhysicalQubitPosition2D:
+        """Returns the position of the plaquette origin."""
+        return PhysicalQubitPosition2D(displacement.x * self.x, displacement.y * self.y)
+
+
+class BlockPosition2D(Position2D):
+    """Represents the position of a block on a 2-dimensional plane."""
+
+    def get_top_left_plaquette_position(
+        self, block_shape: Shape2D
+    ) -> PlaquettePosition2D:
+        """Returns the position of the top-left plaquette of the block."""
+        return PlaquettePosition2D(block_shape.x * self.x, block_shape.y * self.y)
+
+
+class Shape2D(Vec2D):
     def to_numpy_shape(self) -> tuple[int, int]:
         """Returns the shape according to numpy indexing.
 
@@ -82,40 +94,21 @@ class Shape2D:
         return (self.y, self.x)
 
 
-@dataclass(frozen=True)
-class Displacement:
-    """Simple wrapper around tuple[int, int].
-
-    This class is here to explicitly name the type of variables as displacements
-    instead of having a tuple[int, int] that could be:
-    - a position,
-    - a shape,
-    - coefficients for positions,
-    - displacements.
-    """
-
-    x: int
-    y: int
+class Shift2D(Vec2D):
+    def __mul__(self, factor: int) -> Shift2D:
+        return Shift2D(factor * self.x, factor * self.y)
 
-    def __mul__(self, factor: int) -> Displacement:
-        return Displacement(factor * self.x, factor * self.y)
-
-    def __rmul__(self, factor: int) -> Displacement:
+    def __rmul__(self, factor: int) -> Shift2D:
         return self.__mul__(factor)
 
 
-@dataclass(frozen=True, order=True)
-class Position3D:
+class Position3D(Vec3D):
     """A 3D integer position."""
 
     x: int
     y: int
     z: int
 
-    def __post_init__(self) -> None:
-        if any(not isinstance(i, int) for i in astuple(self)):
-            raise TQECException("Position must be an integer.")
-
     def shift_by(self, dx: int = 0, dy: int = 0, dz: int = 0) -> Position3D:
         """Shift the position by the given offset."""
         return Position3D(self.x + dx, self.y + dy, self.z + dz)
diff --git a/src/tqec/templates/indices/_testing.py b/src/tqec/templates/indices/_testing.py
index 42bfa6c2..23366388 100644
--- a/src/tqec/templates/indices/_testing.py
+++ b/src/tqec/templates/indices/_testing.py
@@ -5,7 +5,7 @@ import numpy.typing as npt
 from typing_extensions import override
 
 from tqec.exceptions import TQECException
-from tqec.position import Displacement
+from tqec.position import Shift2D
 from tqec.scale import LinearFunction, Scalable2D
 from tqec.templates.indices.base import BorderIndices, RectangularTemplate
 from tqec.templates.indices.enums import TemplateBorder
@@ -17,7 +17,7 @@ class FixedTemplate(RectangularTemplate):
     def __init__(
         self,
         indices: Sequence[Sequence[int]],
-        default_increments: Displacement | None = None,
+        default_increments: Shift2D | None = None,
     ) -> None:
         super().__init__(default_increments)
         self._indices: npt.NDArray[numpy.int_] = numpy.array(
diff --git a/src/tqec/templates/indices/base.py b/src/tqec/templates/indices/base.py
index 8a8e7291..0444eb02 100644
--- a/src/tqec/templates/indices/base.py
+++ b/src/tqec/templates/indices/base.py
@@ -10,7 +10,7 @@ from typing import Iterator, Sequence
 import numpy
 import numpy.typing as npt
 
-from tqec.position import Displacement, Position2D, Shape2D
+from tqec.position import BlockPosition2D, PlaquettePosition2D, Shape2D, Shift2D
 from tqec.scale import Scalable2D, round_or_fail
 from tqec.templates.indices.enums import TemplateBorder
 from tqec.templates.indices.subtemplates import (
@@ -27,7 +27,7 @@ class Template(ABC):
     library.
     """
 
-    def __init__(self, default_increments: Displacement | None = None) -> None:
+    def __init__(self, default_increments: Shift2D | None = None) -> None:
         """Construct an instance of the template.
 
         Args:
@@ -35,7 +35,7 @@ class Template(ABC):
                 to ``Displacement(2, 2)`` when ``None``
         """
         super().__init__()
-        self._default_increments = default_increments or Displacement(2, 2)
+        self._default_increments = default_increments or Shift2D(2, 2)
 
     @abstractmethod
     def instantiate(
@@ -74,7 +74,7 @@ class Template(ABC):
             the number of plaquettes expected from the :py:meth:`instantiate` method.
         """
 
-    def get_increments(self) -> Displacement:
+    def get_increments(self) -> Shift2D:
         """Get the default increments of the template.
 
         Returns:
@@ -112,7 +112,7 @@ class Template(ABC):
             self.instantiate(k), manhattan_radius, avoid_zero_plaquettes
         )
 
-    def instantiation_origin(self, k: int) -> Position2D:
+    def instantiation_origin(self, k: int) -> PlaquettePosition2D:
         """Coordinates of the top-left entry origin.
 
         This property returns the coordinates of the origin of the plaquette
@@ -135,7 +135,7 @@ class Template(ABC):
             to the top-left entry of the array returned by
             :meth:`~tqec.templates.indices.base.Template.instantiate`.
         """
-        return Position2D(0, 0)
+        return BlockPosition2D(0, 0).get_top_left_plaquette_position(self.shape(k))
 
 
 @dataclass(frozen=True)
diff --git a/src/tqec/templates/indices/layout.py b/src/tqec/templates/indices/layout.py
index 57092bd0..ec4771f2 100644
--- a/src/tqec/templates/indices/layout.py
+++ b/src/tqec/templates/indices/layout.py
@@ -14,17 +14,17 @@ A grid of :math:`2 \\times 2` logical qubits can be represented with
 
 .. code-block:: python
 
-    from tqec.position import Position2D
+    from tqec.position import BlockPosition2D
     from tqec.templates.indices.layout import LayoutTemplate
     from tqec.templates.indices.qubit import QubitTemplate
 
     qubit = QubitTemplate()
     grid = LayoutTemplate(
         {
-            Position2D(0, 0): qubit,
-            Position2D(0, 1): qubit,
-            Position2D(1, 0): qubit,
-            Position2D(1, 1): qubit,
+            BlockPosition2D(0, 0): qubit,
+            BlockPosition2D(0, 1): qubit,
+            BlockPosition2D(1, 0): qubit,
+            BlockPosition2D(1, 1): qubit,
         }
     )
 
@@ -72,7 +72,7 @@ import numpy.typing as npt
 from typing_extensions import override
 
 from tqec.exceptions import TQECException
-from tqec.position import Displacement, Position2D, Shape2D
+from tqec.position import BlockPosition2D, PlaquettePosition2D, Shape2D, Shift2D
 from tqec.scale import Scalable2D
 from tqec.templates.indices.base import RectangularTemplate, Template
 
@@ -80,8 +80,8 @@ from tqec.templates.indices.base import RectangularTemplate, Template
 class LayoutTemplate(Template):
     def __init__(
         self,
-        element_layout: dict[Position2D, RectangularTemplate],
-        default_increments: Displacement | None = None,
+        element_layout: dict[BlockPosition2D, RectangularTemplate],
+        default_increments: Shift2D | None = None,
     ) -> None:
         """A template representing a layout of other templates.
 
@@ -120,7 +120,7 @@ class LayoutTemplate(Template):
         min_y = min(position.y for position in all_positions)
         max_y = max(position.y for position in all_positions)
         # Shift the bounding box to the origin
-        self._origin_shift = Displacement(min_x, min_y)
+        self._block_origin = BlockPosition2D(min_x, min_y)
         self._nx = max_x - min_x + 1
         self._ny = max_y - min_y + 1
 
@@ -129,7 +129,7 @@ class LayoutTemplate(Template):
     def get_indices_map_for_instantiation(
         self,
         instantiate_indices: Sequence[int] | None = None,
-    ) -> dict[Position2D, dict[int, int]]:
+    ) -> dict[BlockPosition2D, dict[int, int]]:
         """Get one index map for each of the templates in the layout.
 
         This method is used internally by :meth:`LayoutTemplate.instantiate` and
@@ -155,7 +155,7 @@ class LayoutTemplate(Template):
         if instantiate_indices is None:
             instantiate_indices = list(range(1, self.expected_plaquettes_number + 1))
         index_count = 0
-        indices_map: dict[Position2D, dict[int, int]] = {}
+        indices_map: dict[BlockPosition2D, dict[int, int]] = {}
         for position, template in self._layout.items():
             indices_map[position] = {
                 i + 1: instantiate_indices[i + index_count]
@@ -164,29 +164,6 @@ class LayoutTemplate(Template):
             index_count += template.expected_plaquettes_number
         return indices_map
 
-    @property
-    def origin_shift(self) -> Displacement:
-        """Returns the shift that should be applied to the global origin to
-        become the template origin.
-
-        Due to how :class:`LayoutTemplate` organizes the
-        :class:`~tqec.templates.indices.base.Template` instances it manages, it is entirely
-        possible (and valid) for the template to not be aligned with the global
-        origin.
-
-        For example, the following instance exhibit this behaviour:
-
-        .. code-block:: python
-
-            not_on_origin = LayoutTemplate({Position2D(1, 1): QubitTemplate()})
-            assert not_on_origin.origin_shift == Position2D(1, 1)
-
-        Returns:
-            the shift that should be applied to the global origin to become the
-            template origin.
-        """
-        return self._origin_shift
-
     @property
     @override
     def scalable_shape(self) -> Scalable2D:
@@ -239,8 +216,8 @@ class LayoutTemplate(Template):
                 imap[i] for i in range(1, element.expected_plaquettes_number + 1)
             ]
             element_instantiation = element.instantiate(k, indices)
-            shifted_pos = Position2D(
-                pos.x - self.origin_shift.x, pos.y - self.origin_shift.y
+            shifted_pos = BlockPosition2D(
+                pos.x - self._block_origin.x, pos.y - self._block_origin.y
             )
             ret[
                 shifted_pos.y * element_shape[0] : (shifted_pos.y + 1)
@@ -251,9 +228,5 @@ class LayoutTemplate(Template):
         return ret
 
     @override
-    def instantiation_origin(self, k: int) -> Position2D:
-        origin_shift = self.origin_shift
-        element_shape = self.element_shape(k)
-        return Position2D(
-            origin_shift.x * element_shape.x, origin_shift.y * element_shape.y
-        )
+    def instantiation_origin(self, k: int) -> PlaquettePosition2D:
+        return self._block_origin.get_top_left_plaquette_position(self.element_shape(k))
diff --git a/src/tqec/templates/rpng.py b/src/tqec/templates/rpng.py
index dd927132..48a58fc1 100644
--- a/src/tqec/templates/rpng.py
+++ b/src/tqec/templates/rpng.py
@@ -5,7 +5,7 @@ from typing import Generic, TypeVar
 
 from tqec.plaquette.frozendefaultdict import FrozenDefaultDict
 from tqec.plaquette.rpng import RPNGDescription
-from tqec.position import Displacement, Position2D, Shape2D
+from tqec.position import PlaquettePosition2D, Shape2D, Shift2D
 from tqec.scale import Scalable2D
 from tqec.templates.indices.base import Template
 
@@ -30,7 +30,7 @@ class RPNGTemplate(Generic[T]):
         """Returns a scalable version of the template shape."""
         return self.template.scalable_shape
 
-    def get_increments(self) -> Displacement:
+    def get_increments(self) -> Shift2D:
         """Get the default increments of the template.
 
         Returns:
@@ -38,7 +38,7 @@ class RPNGTemplate(Generic[T]):
         """
         return self.template.get_increments()
 
-    def instantiation_origin(self, k: int) -> Position2D:
+    def instantiation_origin(self, k: int) -> PlaquettePosition2D:
         """Coordinates of the top-left entry origin.
 
         This property returns the coordinates of the origin of the plaquette


Test patch:
diff --git a/src/tqec/circuit/measurement_test.py b/src/tqec/circuit/measurement_test.py
index 993b49ae..d6b06ecc 100644
--- a/src/tqec/circuit/measurement_test.py
+++ b/src/tqec/circuit/measurement_test.py
@@ -6,7 +6,7 @@ from tqec.circuit.qubit import GridQubit
 from tqec.circuit.qubit_map import QubitMap
 from tqec.circuit.schedule import ScheduledCircuit
 from tqec.exceptions import TQECException
-from tqec.position import Displacement
+from tqec.position import Shift2D
 
 _grid_qubits: list[GridQubit] = [GridQubit(0, 0), GridQubit(-1, -1)]
 
@@ -25,10 +25,10 @@ def test_measurement_construction(qubit: GridQubit) -> None:
 def test_measurement_offset(qubit: GridQubit) -> None:
     assert Measurement(qubit, -1).offset_spatially_by(0, 0) == Measurement(qubit, -1)
     assert Measurement(qubit, -1).offset_spatially_by(1, 0) == Measurement(
-        qubit + Displacement(1, 0), -1
+        qubit + Shift2D(1, 0), -1
     )
     assert Measurement(qubit, -1).offset_spatially_by(-3, 12) == Measurement(
-        qubit + Displacement(-3, 12), -1
+        qubit + Shift2D(-3, 12), -1
     )
     assert Measurement(qubit, -2).offset_spatially_by(0, 0) == Measurement(qubit, -2)
 
@@ -39,7 +39,7 @@ def test_measurement_offset(qubit: GridQubit) -> None:
 
 
 def test_measurement_map_qubit() -> None:
-    qubit_map = {q: q + Displacement(3, 8) for q in _grid_qubits}
+    qubit_map = {q: q + Shift2D(3, 8) for q in _grid_qubits}
 
     for qubit in _grid_qubits:
         assert Measurement(qubit, -1).map_qubit(qubit_map) == Measurement(
diff --git a/src/tqec/circuit/qubit_test.py b/src/tqec/circuit/qubit_test.py
index f722a90a..b9e57dbb 100644
--- a/src/tqec/circuit/qubit_test.py
+++ b/src/tqec/circuit/qubit_test.py
@@ -1,7 +1,7 @@
 import stim
 
 from tqec.circuit.qubit import GridQubit, count_qubit_accesses, get_used_qubit_indices
-from tqec.position import Displacement, Position2D
+from tqec.position import PhysicalQubitPosition2D, Shift2D
 
 
 def test_grid_qubit_creation() -> None:
@@ -18,21 +18,21 @@ def test_grid_qubit_coordinates() -> None:
 
 def test_grid_qubit_operators() -> None:
     q = GridQubit(0, 0)
-    assert q + Displacement(0, 0) == q
-    assert q + Position2D(0, 0) == q
+    assert q + Shift2D(0, 0) == q
+    assert q + PhysicalQubitPosition2D(0, 0) == q
     assert q + q == q
-    assert q - Displacement(0, 0) == q
-    assert q - Position2D(0, 0) == q
+    assert q - Shift2D(0, 0) == q
+    assert q - PhysicalQubitPosition2D(0, 0) == q
     assert q - q == q
     assert 3 * q == q
     assert q * 3 == q
 
-    assert q + Displacement(1, 3) == GridQubit(1, 3)
-    assert q + Position2D(1, 3) == GridQubit(1, 3)
+    assert q + Shift2D(1, 3) == GridQubit(1, 3)
+    assert q + PhysicalQubitPosition2D(1, 3) == GridQubit(1, 3)
     assert q + GridQubit(1, 3) == GridQubit(1, 3)
 
     q = GridQubit(-1, 0)
-    assert q + Displacement(0, 0) == q
+    assert q + Shift2D(0, 0) == q
     assert q + q == 2 * q
     assert q - q == GridQubit(0, 0)
 
diff --git a/src/tqec/compile/detectors/compute_test.py b/src/tqec/compile/detectors/compute_test.py
index 1f59c63f..3e5a38fe 100644
--- a/src/tqec/compile/detectors/compute_test.py
+++ b/src/tqec/compile/detectors/compute_test.py
@@ -26,7 +26,7 @@ from tqec.plaquette.frozendefaultdict import FrozenDefaultDict
 from tqec.plaquette.library.css import make_css_surface_code_plaquette
 from tqec.plaquette.library.empty import empty_square_plaquette
 from tqec.plaquette.plaquette import Plaquettes
-from tqec.position import Displacement, Position2D
+from tqec.position import BlockPosition2D, Shift2D
 from tqec.templates.indices._testing import FixedTemplate
 from tqec.templates.indices.layout import LayoutTemplate
 from tqec.templates.indices.qubit import QubitTemplate
@@ -133,7 +133,7 @@ def test_center_plaquette_syndrome_qubits_empty(
         _center_plaquette_syndrome_qubits(
             empty_center_plaquette_subtemplate,
             Plaquettes(FrozenDefaultDict({})),
-            Displacement(2, 2),
+            Shift2D(2, 2),
         )
         == []
     )
@@ -145,7 +145,7 @@ def test_center_plaquette_syndrome_qubits_empty(
                     {}, default_factory=lambda: make_css_surface_code_plaquette("X")
                 )
             ),
-            Displacement(2, 2),
+            Shift2D(2, 2),
         )
         == []
     )
@@ -157,7 +157,7 @@ def test_center_plaquette_syndrome_qubits_empty(
                     {}, default_factory=lambda: make_css_surface_code_plaquette("X")
                 )
             ),
-            Displacement(4, 2),
+            Shift2D(4, 2),
         )
         == []
     )
@@ -179,7 +179,7 @@ def test_center_plaquette_syndrome_qubits(
                 default_factory=lambda: empty_square_plaquette(),
             )
         ),
-        Displacement(2, 2),
+        Shift2D(2, 2),
     ) == [GridQubit(2 * r, 2 * r)]
     assert _center_plaquette_syndrome_qubits(
         center_plaquette_subtemplate,
@@ -189,14 +189,14 @@ def test_center_plaquette_syndrome_qubits(
                 default_factory=lambda: empty_square_plaquette(),
             )
         ),
-        Displacement(4, 2),
+        Shift2D(4, 2),
     ) == [GridQubit(4 * r, 2 * r)]
 
 
 def test_filter_detectors(
     alternating_subtemplate: SubTemplateType, init_plaquettes: Plaquettes
 ) -> None:
-    increments = Displacement(2, 2)
+    increments = Shift2D(2, 2)
     syndrome_qubits = _center_plaquette_syndrome_qubits(
         alternating_subtemplate, init_plaquettes, increments
     )
@@ -253,7 +253,7 @@ def test_compute_detectors_at_end_of_situation(
     memory_plaquettes: Plaquettes,
 ) -> None:
     # No detector due to empty plaquette
-    increments = Displacement(2, 2)
+    increments = Shift2D(2, 2)
     assert (
         _compute_detectors_at_end_of_situation(
             [numpy.array([[0]])], [Plaquettes(FrozenDefaultDict({}))], increments
@@ -301,7 +301,7 @@ def test_compute_detectors_at_end_of_situation(
 def test_public_compute_detectors_at_end_of_situation(
     alternating_subtemplate: SubTemplateType, init_plaquettes: Plaquettes
 ) -> None:
-    increments = Displacement(2, 2)
+    increments = Shift2D(2, 2)
     database = DetectorDatabase()
     # No database
     detectors = compute_detectors_at_end_of_situation(
@@ -373,20 +373,22 @@ def test_compute_superimposed_template_instantiations_shifted(k: int) -> None:
     templates = [
         LayoutTemplate(
             {
-                Position2D(0, 0): template,
-                Position2D(0, 1): template,
-                Position2D(1, 0): template,
-                Position2D(1, 1): template,
+                BlockPosition2D(0, 0): template,
+                BlockPosition2D(0, 1): template,
+                BlockPosition2D(1, 0): template,
+                BlockPosition2D(1, 1): template,
             }
         ),
-        LayoutTemplate({Position2D(0, 0): template, Position2D(1, 1): template}),
-        LayoutTemplate({Position2D(1, 1): template}),
+        LayoutTemplate(
+            {BlockPosition2D(0, 0): template, BlockPosition2D(1, 1): template}
+        ),
+        LayoutTemplate({BlockPosition2D(1, 1): template}),
     ]
     instantiations = _compute_superimposed_template_instantiations(templates, k)
     # The only template that should be left in the returned instantiations is the
     # one at the following position, because this is the only position at which
     # `templates[-1]` is non-zero.
-    pos = Position2D(1, 1)
+    pos = BlockPosition2D(1, 1)
     for i, inst in enumerate(instantiations):
         # There might be indices shifts.
         indices_map = templates[i].get_indices_map_for_instantiation()[pos]
diff --git a/src/tqec/compile/observables/builder_test.py b/src/tqec/compile/observables/builder_test.py
index bd9eaef0..818b782a 100644
--- a/src/tqec/compile/observables/builder_test.py
+++ b/src/tqec/compile/observables/builder_test.py
@@ -2,18 +2,18 @@ import pytest
 
 from tqec.circuit.qubit import GridQubit
 from tqec.compile.observables.builder import (
-    _get_top_readout_cube_qubits,
-    _get_top_readout_pipe_qubits,
     _get_bottom_stabilizer_cube_qubits,
     _get_bottom_stabilizer_spatial_cube_qubits,
+    _get_top_readout_cube_qubits,
+    _get_top_readout_pipe_qubits,
     _get_top_readout_spatial_cube_qubits,
     _transform_coords_into_grid,
 )
 from tqec.compile.specs.enums import SpatialArms
 from tqec.computation.cube import ZXCube
 from tqec.position import (
+    BlockPosition2D,
     Direction3D,
-    Position2D,
     Position3D,
     Shape2D,
     SignedDirection3D,
@@ -167,9 +167,9 @@ def test_get_top_readout_spatial_cube_qubits(
 def test_transform_coords_into_grid() -> None:
     template = LayoutTemplate(
         {
-            Position2D(0, 0): QubitTemplate(),
-            Position2D(1, 0): QubitTemplate(),
-            Position2D(1, 1): QubitTemplate(),
+            BlockPosition2D(0, 0): QubitTemplate(),
+            BlockPosition2D(1, 0): QubitTemplate(),
+            BlockPosition2D(1, 1): QubitTemplate(),
         }
     )
     qubit = _transform_coords_into_grid(
diff --git a/src/tqec/position_test.py b/src/tqec/position_test.py
index fd3d13a6..1323b35b 100644
--- a/src/tqec/position_test.py
+++ b/src/tqec/position_test.py
@@ -26,5 +26,3 @@ def test_position_3d() -> None:
     assert p1.is_neighbour(Position3D(-1, 0, 0))
     assert not p1.is_neighbour(Position3D(1, 0, 1))
     assert not p1.is_neighbour(Position3D(0, -1, 1))
-    with pytest.raises(TQECException, match="Position must be an integer"):
-        Position3D(0.5, 0, 0)  # type: ignore[arg-type]
diff --git a/src/tqec/templates/indices/subtemplates_test.py b/src/tqec/templates/indices/subtemplates_test.py
index 255e3bea..36f41103 100644
--- a/src/tqec/templates/indices/subtemplates_test.py
+++ b/src/tqec/templates/indices/subtemplates_test.py
@@ -4,7 +4,7 @@ import numpy
 import numpy.typing as npt
 import pytest
 
-from tqec.position import Position2D
+from tqec.position import BlockPosition2D
 from tqec.templates.indices.base import Template
 from tqec.templates.indices.layout import LayoutTemplate
 from tqec.templates.indices.qubit import QubitSpatialCubeTemplate, QubitTemplate
@@ -17,7 +17,7 @@ _TEMPLATES_TO_TEST = [
     QubitTemplate(),
     QubitSpatialCubeTemplate(),
     LayoutTemplate(
-        {Position2D(0, 0): QubitTemplate(), Position2D(1, 1): QubitTemplate()}
+        {BlockPosition2D(0, 0): QubitTemplate(), BlockPosition2D(1, 1): QubitTemplate()}
     ),
 ]
 _VALUES_OF_K_TO_TEST = [1, 10]
@@ -91,14 +91,14 @@ _TEMPLATE_PAIRS_TO_TEST = [
     (
         LayoutTemplate(
             {
-                Position2D(0, 0): QubitTemplate(),
-                Position2D(1, 1): QubitSpatialCubeTemplate(),
+                BlockPosition2D(0, 0): QubitTemplate(),
+                BlockPosition2D(1, 1): QubitSpatialCubeTemplate(),
             }
         ),
         LayoutTemplate(
             {
-                Position2D(0, 0): QubitSpatialCubeTemplate(),
-                Position2D(1, 1): QubitTemplate(),
+                BlockPosition2D(0, 0): QubitSpatialCubeTemplate(),
+                BlockPosition2D(1, 1): QubitTemplate(),
             }
         ),
     ),