Repository: QCHackers/tqec
Pull request #350 (2024-10-10 15:28:12)

Introduce `DetectorDatabase` and needed helpers
This PR introduces a `DetectorDatabase` class to manage detectors and re-introduce methods to compute detectors on sub-templates instead of doing the computation on the whole template.

github-actions[bot]: A preview of  is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: A preview of 727a1e44e43b216f3e9ea8760d911dbf865723ad is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-80%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 92% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 0% | 0 | ❌
src.tqec.computation | 74% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 86% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **80%** (3213 / 4007) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
github-actions[bot]: A preview of 9e733864d260d372ee0183d12125977a64108bb5 is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-80%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 92% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 0% | 0 | ❌
src.tqec.computation | 74% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 86% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **80%** (3213 / 4007) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
github-actions[bot]: A preview of 2baca9bf267ecb1a58da4ffc5552d5d4068547bb is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-81%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 96% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 0% | 0 | ❌
src.tqec.computation | 74% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 86% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **81%** (3240 / 4007) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
github-actions[bot]: A preview of 5cad842098ed5abb7c4e2ad1c28b20ce00bb0f86 is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-81%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 96% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 0% | 0 | ❌
src.tqec.computation | 74% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 86% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **81%** (3242 / 4010) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
nelimee: To demonstrate part of what is working right now:
```py
from tqec.compile.specs.base import CubeSpec
from tqec.compile.specs.library.css import CSS_SPEC_RULES
from tqec.computation.block_graph.enums import CubeType

# Getting a cube to facilitate Template and Plaquettes creation.
spec = CubeSpec(CubeType.ZXZ)
# TODO: why do we have to repeat `spec` twice here?
cube = CSS_SPEC_RULES[spec](spec)

# Getting the plaquettes in the right type.
plaquettes = cube.layers
# We just want to try with the first (initialisation) layer.
plaquettes_tup = (plaquettes[0],)

database = DetectorDatabase()

detectors = compute_detectors_for_constant_template_and_fixed_radius(
    template=cube.template,
    k=2,
    plaquettes_at_timestep=plaquettes_tup,
    fixed_subtemplate_radius=1,
    database=database,
)
# detectors should be valid here.
# They should also be registered in the database:
urls = database.to_crumble_urls(cube.template.get_increments())
for i, url in enumerate(urls):
    print(f"- [Crumble link for subtemplate {i}]({url})")
```
prints the following (Markdown formatted) URLs:

- [Crumble link for subtemplate 0](https://algassert.com/crumble#circuit=Q(2,4)0;Q(3,3)1;Q(3,5)2;Q(4,4)3;Q(5,3)4;Q(5,5)5;R_0_1_2_4_5;RX_3;TICK;CX_3_1;TICK;CX_3_4;TICK;CX_1_0_3_2;TICK;CX_2_0_3_5;TICK;M_0;MX_3;DT(2,4)rec[-2])
- [Crumble link for subtemplate 1](https://algassert.com/crumble#circuit=Q(0,4)0;Q(1,3)1;Q(1,5)2;Q(2,4)3;Q(3,3)4;Q(3,5)5;Q(4,2)6;Q(4,4)7;Q(5,3)8;Q(5,5)9;RX_3_6;R_0_1_2_4_5_7_8_9;TICK;CX_3_1_4_7;TICK;CX_3_4_5_7;TICK;CX_6_4_1_0_3_2_8_7;TICK;CX_6_8_2_0_3_5_9_7;TICK;MX_3_6;M_0_7;DT(0,4)rec[-2];DT(4,4)rec[-1])
- [Crumble link for subtemplate 2](https://algassert.com/crumble#circuit=Q(-1,3)0;Q(-1,5)1;Q(0,4)2;Q(1,3)3;Q(1,5)4;Q(2,2)5;Q(2,4)6;Q(3,3)7;Q(3,5)8;RX_2_5;R_0_1_3_4_6_7_8;TICK;CX_2_0_3_6;TICK;CX_2_3_4_6;TICK;CX_5_3_2_1_7_6;TICK;CX_5_7_2_4_8_6;TICK;MX_2_5;M_6;DT(2,4)rec[-1])
- [Crumble link for subtemplate 3](https://algassert.com/crumble#circuit=Q(-1,3)0;Q(-1,5)1;Q(0,4)2;Q(1,3)3;Q(1,5)4;Q(2,2)5;Q(2,4)6;Q(3,3)7;Q(3,5)8;Q(4,4)9;Q(5,3)10;Q(5,5)11;RX_2_5_9;R_0_1_3_4_6_7_8_10_11;TICK;CX_2_0_3_6_9_7;TICK;CX_2_3_4_6_9_10;TICK;CX_5_3_2_1_7_6_9_8;TICK;CX_5_7_2_4_8_6_9_11;TICK;MX_2_5_9;M_6;DT(2,4)rec[-1])
- [Crumble link for subtemplate 4](https://algassert.com/crumble#circuit=Q(-1,3)0;Q(-1,5)1;Q(0,2)2;Q(0,4)3;Q(1,3)4;Q(1,5)5;RX_2;R_0_1_3_4_5;TICK;CX_0_3;TICK;CX_1_3;TICK;CX_2_0_4_3;TICK;CX_2_4_5_3;TICK;MX_2;M_3;DT(0,4)rec[-1])
- [Crumble link for subtemplate 5](https://algassert.com/crumble#circuit=Q(-1,3)0;Q(-1,5)1;Q(0,2)2;Q(0,4)3;Q(1,3)4;Q(1,5)5;Q(2,4)6;Q(3,3)7;Q(3,5)8;Q(4,2)9;Q(4,4)10;Q(5,3)11;Q(5,5)12;RX_2_6_9;R_0_1_3_4_5_7_8_10_11_12;TICK;CX_0_3_6_4_7_10;TICK;CX_1_3_6_7_8_10;TICK;CX_2_0_9_7_4_3_6_5_11_10;TICK;CX_2_4_9_11_5_3_6_8_12_10;TICK;MX_2_6_9;M_3_10;DT(0,4)rec[-2];DT(4,4)rec[-1])
- [Crumble link for subtemplate 6](https://algassert.com/crumble#circuit=Q(2,2)0;Q(3,1)1;Q(3,3)2;Q(3,5)3;Q(4,2)4;Q(4,4)5;Q(5,1)6;Q(5,3)7;Q(5,5)8;R_0_1_2_3_5_6_7_8;RX_4;TICK;CX_4_1_2_5;TICK;CX_4_6_3_5;TICK;CX_1_0_4_2_7_5;TICK;CX_2_0_4_7_8_5;TICK;M_0_5;MX_4;DT(4,4)rec[-2];DT(2,2)rec[-3])
- [Crumble link for subtemplate 7](https://algassert.com/crumble#circuit=Q(2,0)0;Q(3,-1)1;Q(3,1)2;Q(3,3)3;Q(4,0)4;Q(4,2)5;Q(4,4)6;Q(5,-1)7;Q(5,1)8;Q(5,3)9;R_0_1_2_3_5_7_8_9;RX_4_6;TICK;CX_4_1_2_5_6_3;TICK;CX_4_7_3_5_6_9;TICK;CX_1_0_4_2_8_5;TICK;CX_2_0_4_8_9_5;TICK;M_0_5;MX_4_6;DT(2,0)rec[-4];DT(4,2)rec[-3])
- [Crumble link for subtemplate 8](https://algassert.com/crumble#circuit=Q(2,0)0;Q(2,4)1;Q(3,-1)2;Q(3,1)3;Q(3,3)4;Q(3,5)5;Q(4,0)6;Q(4,2)7;Q(4,4)8;Q(5,-1)9;Q(5,1)10;Q(5,3)11;Q(5,5)12;R_0_1_2_3_4_5_7_9_10_11_12;RX_6_8;TICK;CX_6_2_3_7_8_4;TICK;CX_6_9_4_7_8_11;TICK;CX_2_0_6_3_10_7_4_1_8_5;TICK;CX_3_0_6_10_11_7_5_1_8_12;TICK;M_0_1_7;MX_6_8;DT(2,0)rec[-5];DT(4,2)rec[-3];DT(2,4)rec[-4])
- [Crumble link for subtemplate 9](https://algassert.com/crumble#circuit=Q(3,-1)0;Q(3,1)1;Q(4,0)2;Q(4,2)3;Q(5,-1)4;Q(5,1)5;R_0_1_2_4_5;RX_3;TICK;CX_0_2_3_1;TICK;CX_1_2_3_5;TICK;CX_4_2;TICK;CX_5_2;TICK;M_2;MX_3;DT(4,0)rec[-2])
- [Crumble link for subtemplate 10](https://algassert.com/crumble#circuit=Q(2,2)0;Q(3,-1)1;Q(3,1)2;Q(3,3)3;Q(3,5)4;Q(4,0)5;Q(4,2)6;Q(4,4)7;Q(5,-1)8;Q(5,1)9;Q(5,3)10;Q(5,5)11;R_0_1_2_3_4_5_7_8_9_10_11;RX_6;TICK;CX_1_5_6_2_3_7;TICK;CX_2_5_6_9_4_7;TICK;CX_8_5_2_0_6_3_10_7;TICK;CX_9_5_3_0_6_10_11_7;TICK;M_0_5_7;MX_6;DT(4,4)rec[-2];DT(2,2)rec[-4];DT(4,0)rec[-3])
- [Crumble link for subtemplate 11](https://algassert.com/crumble#circuit=Q(0,2)0;Q(1,1)1;Q(1,3)2;Q(1,5)3;Q(2,2)4;Q(2,4)5;Q(3,1)6;Q(3,3)7;Q(3,5)8;Q(4,0)9;Q(4,2)10;Q(4,4)11;Q(5,1)12;Q(5,3)13;Q(5,5)14;RX_4_9_11;R_0_1_2_3_5_6_7_8_10_12_13_14;TICK;CX_4_1_6_10_2_5_11_7;TICK;CX_4_6_7_10_3_5_11_13;TICK;CX_9_6_1_0_4_2_12_10_7_5_11_8;TICK;CX_9_12_2_0_4_7_13_10_8_5_11_14;TICK;MX_4_9_11;M_0_5_10;DT(0,2)rec[-3];DT(4,2)rec[-1];DT(2,4)rec[-2])
- [Crumble link for subtemplate 12](https://algassert.com/crumble#circuit=Q(-1,1)0;Q(-1,3)1;Q(-1,5)2;Q(0,2)3;Q(0,4)4;Q(1,1)5;Q(1,3)6;Q(1,5)7;Q(2,0)8;Q(2,2)9;Q(2,4)10;Q(3,1)11;Q(3,3)12;Q(3,5)13;Q(4,4)14;RX_3_8_10;R_0_1_2_4_5_6_7_9_11_12_13_14;TICK;CX_3_0_5_9_1_4_10_6_12_14;TICK;CX_3_5_6_9_2_4_10_12_13_14;TICK;CX_8_5_3_1_11_9_6_4_10_7;TICK;CX_8_11_3_6_12_9_7_4_10_13;TICK;MX_3_8_10;M_4_9_14;DT(0,4)rec[-3];DT(4,4)rec[-1];DT(2,2)rec[-2])
- [Crumble link for subtemplate 13](https://algassert.com/crumble#circuit=Q(-1,1)0;Q(-1,3)1;Q(-1,5)2;Q(0,2)3;Q(0,4)4;Q(1,1)5;Q(1,3)6;Q(1,5)7;Q(2,0)8;Q(2,2)9;Q(2,4)10;Q(3,1)11;Q(3,3)12;Q(3,5)13;Q(4,2)14;Q(4,4)15;Q(5,1)16;Q(5,3)17;Q(5,5)18;RX_3_8_10_14;R_0_1_2_4_5_6_7_9_11_12_13_15_16_17_18;TICK;CX_3_0_5_9_14_11_1_4_10_6_12_15;TICK;CX_3_5_6_9_14_16_2_4_10_12_13_15;TICK;CX_8_5_3_1_11_9_14_12_6_4_10_7_17_15;TICK;CX_8_11_3_6_12_9_14_17_7_4_10_13_18_15;TICK;MX_3_8_10_14;M_4_9_15;DT(0,4)rec[-3];DT(4,4)rec[-1];DT(2,2)rec[-2])
- [Crumble link for subtemplate 14](https://algassert.com/crumble#circuit=Q(-1,1)0;Q(-1,3)1;Q(-1,5)2;Q(0,0)3;Q(0,2)4;Q(0,4)5;Q(1,1)6;Q(1,3)7;Q(1,5)8;Q(2,4)9;RX_3_5;R_0_1_2_4_6_7_8_9;TICK;CX_0_4_5_1_7_9;TICK;CX_1_4_5_7_8_9;TICK;CX_3_0_6_4_5_2;TICK;CX_3_6_7_4_5_8;TICK;MX_3_5;M_4_9;DT(0,2)rec[-2];DT(2,4)rec[-1])
- [Crumble link for subtemplate 15](https://algassert.com/crumble#circuit=Q(-1,1)0;Q(-1,3)1;Q(-1,5)2;Q(0,0)3;Q(0,2)4;Q(0,4)5;Q(1,1)6;Q(1,3)7;Q(1,5)8;Q(2,2)9;Q(2,4)10;Q(3,1)11;Q(3,3)12;Q(3,5)13;Q(4,0)14;Q(4,2)15;Q(4,4)16;Q(5,1)17;Q(5,3)18;Q(5,5)19;RX_3_5_9_14_16;R_0_1_2_4_6_7_8_10_11_12_13_15_17_18_19;TICK;CX_0_4_9_6_11_15_5_1_7_10_16_12;TICK;CX_1_4_9_11_12_15_5_7_8_10_16_18;TICK;CX_3_0_14_11_6_4_9_7_17_15_5_2_12_10_16_13;TICK;CX_3_6_14_17_7_4_9_12_18_15_5_8_13_10_16_19;TICK;MX_3_5_9_14_16;M_4_10_15;DT(0,2)rec[-3];DT(4,2)rec[-1];DT(2,4)rec[-2])
- [Crumble link for subtemplate 16](https://algassert.com/crumble#circuit=Q(0,0)0;Q(1,-1)1;Q(1,1)2;Q(1,3)3;Q(2,0)4;Q(2,2)5;Q(2,4)6;Q(3,-1)7;Q(3,1)8;Q(3,3)9;Q(4,0)10;Q(4,2)11;Q(5,-1)12;Q(5,1)13;Q(5,3)14;R_0_1_2_3_5_7_8_9_10_12_13_14;RX_4_6_11;TICK;CX_4_1_7_10_2_5_11_8_6_3;TICK;CX_4_7_8_10_3_5_11_13_6_9;TICK;CX_1_0_4_2_12_10_8_5_11_9;TICK;CX_2_0_4_8_13_10_9_5_11_14;TICK;M_0_5_10;MX_4_6_11;DT(0,0)rec[-6];DT(2,2)rec[-5];DT(4,0)rec[-4])
- [Crumble link for subtemplate 17](https://algassert.com/crumble#circuit=Q(0,0)0;Q(0,4)1;Q(1,-1)2;Q(1,1)3;Q(1,3)4;Q(1,5)5;Q(2,0)6;Q(2,2)7;Q(2,4)8;Q(3,-1)9;Q(3,1)10;Q(3,3)11;Q(3,5)12;Q(4,0)13;Q(4,2)14;Q(4,4)15;Q(5,-1)16;Q(5,1)17;Q(5,3)18;Q(5,5)19;R_0_1_2_3_4_5_7_9_10_11_12_13_15_16_17_18_19;RX_6_8_14;TICK;CX_6_2_9_13_3_7_14_10_8_4_11_15;TICK;CX_6_9_10_13_4_7_14_17_8_11_12_15;TICK;CX_2_0_6_3_16_13_10_7_14_11_4_1_8_5_18_15;TICK;CX_3_0_6_10_17_13_11_7_14_18_5_1_8_12_19_15;TICK;M_0_1_7_13_15;MX_6_8_14;DT(0,0)rec[-8];DT(0,4)rec[-7];DT(4,0)rec[-5];DT(4,4)rec[-4];DT(2,2)rec[-6])
- [Crumble link for subtemplate 18](https://algassert.com/crumble#circuit=Q(1,-1)0;Q(1,1)1;Q(2,0)2;Q(2,2)3;Q(3,-1)4;Q(3,1)5;Q(4,0)6;Q(5,-1)7;Q(5,1)8;R_0_1_2_4_5_7_8;RX_3_6;TICK;CX_0_2_6_4_3_1;TICK;CX_1_2_6_7_3_5;TICK;CX_4_2_6_5;TICK;CX_5_2_6_8;TICK;M_2;MX_3_6;DT(2,0)rec[-3])
- [Crumble link for subtemplate 19](https://algassert.com/crumble#circuit=Q(0,2)0;Q(1,-1)1;Q(1,1)2;Q(1,3)3;Q(1,5)4;Q(2,0)5;Q(2,2)6;Q(2,4)7;Q(3,-1)8;Q(3,1)9;Q(3,3)10;Q(3,5)11;Q(4,0)12;Q(4,2)13;Q(4,4)14;Q(5,-1)15;Q(5,1)16;Q(5,3)17;Q(5,5)18;R_0_1_2_3_4_5_7_8_9_10_11_13_15_16_17_18;RX_6_12_14;TICK;CX_1_5_12_8_6_2_9_13_3_7_14_10;TICK;CX_2_5_12_15_6_9_10_13_4_7_14_17;TICK;CX_8_5_12_9_2_0_6_3_16_13_10_7_14_11;TICK;CX_9_5_12_16_3_0_6_10_17_13_11_7_14_18;TICK;M_0_5_7_13;MX_6_12_14;DT(2,0)rec[-6];DT(0,2)rec[-7];DT(4,2)rec[-4];DT(2,4)rec[-5])
- [Crumble link for subtemplate 20](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(-1,5)3;Q(0,0)4;Q(0,2)5;Q(0,4)6;Q(1,-1)7;Q(1,1)8;Q(1,3)9;Q(1,5)10;Q(2,0)11;Q(2,2)12;Q(2,4)13;Q(3,-1)14;Q(3,1)15;Q(3,3)16;Q(3,5)17;Q(4,0)18;Q(4,2)19;Q(4,4)20;Q(5,-1)21;Q(5,1)22;Q(5,3)23;Q(5,5)24;RX_4_6_12_18_20;R_0_1_2_3_5_7_8_9_10_11_13_14_15_16_17_19_21_22_23_24;TICK;CX_4_0_7_11_18_14_1_5_12_8_15_19_6_2_9_13_20_16;TICK;CX_4_7_8_11_18_21_2_5_12_15_16_19_6_9_10_13_20_23;TICK;CX_4_1_14_11_18_15_8_5_12_9_22_19_6_3_16_13_20_17;TICK;CX_4_8_15_11_18_22_9_5_12_16_23_19_6_10_17_13_20_24;TICK;MX_4_6_12_18_20;M_5_11_13_19;DT(2,0)rec[-3];DT(0,2)rec[-4];DT(4,2)rec[-1];DT(2,4)rec[-2])
- [Crumble link for subtemplate 21](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(0,0)3;Q(0,2)4;Q(0,4)5;Q(1,-1)6;Q(1,1)7;Q(1,3)8;Q(2,0)9;Q(2,2)10;Q(3,-1)11;Q(3,1)12;Q(3,3)13;Q(4,0)14;Q(4,2)15;Q(4,4)16;Q(5,-1)17;Q(5,1)18;Q(5,3)19;RX_3_5_10_14_16;R_0_1_2_4_6_7_8_9_11_12_13_15_17_18_19;TICK;CX_3_0_6_9_14_11_1_4_10_7_12_15_5_2_16_13;TICK;CX_3_6_7_9_14_17_2_4_10_12_13_15_5_8_16_19;TICK;CX_3_1_11_9_14_12_7_4_10_8_18_15;TICK;CX_3_7_12_9_14_18_8_4_10_13_19_15;TICK;MX_3_5_10_14_16;M_4_9_15;DT(2,0)rec[-2];DT(0,2)rec[-3];DT(4,2)rec[-1])
- [Crumble link for subtemplate 22](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(0,0)2;Q(1,-1)3;Q(1,1)4;Q(2,0)5;Q(2,2)6;Q(3,-1)7;Q(3,1)8;Q(4,0)9;Q(5,-1)10;Q(5,1)11;RX_2_6_9;R_0_1_3_4_5_7_8_10_11;TICK;CX_2_0_3_5_9_7_6_4;TICK;CX_2_3_4_5_9_10_6_8;TICK;CX_2_1_7_5_9_8;TICK;CX_2_4_8_5_9_11;TICK;MX_2_6_9;M_5;DT(2,0)rec[-1])
- [Crumble link for subtemplate 23](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(-1,5)3;Q(0,0)4;Q(0,2)5;Q(0,4)6;Q(1,-1)7;Q(1,1)8;Q(1,3)9;Q(1,5)10;Q(2,0)11;Q(2,2)12;Q(2,4)13;Q(3,-1)14;Q(3,1)15;Q(3,3)16;Q(3,5)17;Q(4,2)18;RX_4_6_12;R_0_1_2_3_5_7_8_9_10_11_13_14_15_16_17_18;TICK;CX_4_0_7_11_1_5_12_8_15_18_6_2_9_13;TICK;CX_4_7_8_11_2_5_12_15_16_18_6_9_10_13;TICK;CX_4_1_14_11_8_5_12_9_6_3_16_13;TICK;CX_4_8_15_11_9_5_12_16_6_10_17_13;TICK;MX_4_6_12;M_5_11_13_18;DT(2,0)rec[-3];DT(0,2)rec[-4];DT(4,2)rec[-1];DT(2,4)rec[-2])
- [Crumble link for subtemplate 24](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(0,0)3;Q(0,2)4;Q(0,4)5;Q(1,-1)6;Q(1,1)7;Q(1,3)8;Q(2,0)9;Q(2,2)10;Q(3,-1)11;Q(3,1)12;Q(3,3)13;Q(4,2)14;RX_3_5_10;R_0_1_2_4_6_7_8_9_11_12_13_14;TICK;CX_3_0_6_9_1_4_10_7_12_14_5_2;TICK;CX_3_6_7_9_2_4_10_12_13_14_5_8;TICK;CX_3_1_11_9_7_4_10_8;TICK;CX_3_7_12_9_8_4_10_13;TICK;MX_3_5_10;M_4_9_14;DT(2,0)rec[-2];DT(0,2)rec[-3];DT(4,2)rec[-1])
- [Crumble link for subtemplate 25](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(-1,5)3;Q(0,0)4;Q(0,2)5;Q(0,4)6;Q(1,-1)7;Q(1,1)8;Q(1,3)9;Q(1,5)10;Q(2,0)11;Q(2,4)12;RX_4_6;R_0_1_2_3_5_7_8_9_10_11_12;TICK;CX_4_0_7_11_1_5_6_2_9_12;TICK;CX_4_7_8_11_2_5_6_9_10_12;TICK;CX_4_1_8_5_6_3;TICK;CX_4_8_9_5_6_10;TICK;MX_4_6;M_5_11_12;DT(2,0)rec[-2];DT(0,2)rec[-3];DT(2,4)rec[-1])
- [Crumble link for subtemplate 26](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(0,0)2;Q(1,-1)3;Q(1,1)4;Q(2,0)5;RX_2;R_0_1_3_4_5;TICK;CX_2_0_3_5;TICK;CX_2_3_4_5;TICK;CX_2_1;TICK;CX_2_4;TICK;MX_2;M_5;DT(2,0)rec[-1])
- [Crumble link for subtemplate 27](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(-1,5)3;Q(0,0)4;Q(0,2)5;Q(0,4)6;Q(1,-1)7;Q(1,1)8;Q(1,3)9;Q(1,5)10;Q(2,0)11;Q(2,2)12;Q(2,4)13;Q(3,-1)14;Q(3,1)15;Q(3,3)16;Q(3,5)17;Q(4,0)18;Q(4,2)19;Q(4,4)20;Q(5,-1)21;Q(5,1)22;Q(5,3)23;Q(5,5)24;R_0_1_2_3_4_6_7_8_9_10_12_14_15_16_17_18_20_21_22_23_24;RX_5_11_13_19;TICK;CX_0_4_11_7_14_18_5_1_8_12_19_15_2_6_13_9_16_20;TICK;CX_1_4_11_14_15_18_5_8_9_12_19_22_3_6_13_16_17_20;TICK;CX_7_4_11_8_21_18_5_2_15_12_19_16_9_6_13_10_23_20;TICK;CX_8_4_11_15_22_18_5_9_16_12_19_23_10_6_13_17_24_20;TICK;M_4_6_12_18_20;MX_5_11_13_19;DT(0,0)rec[-9];DT(0,4)rec[-8];DT(4,0)rec[-6];DT(4,4)rec[-5];DT(2,2)rec[-7])
- [Crumble link for subtemplate 28](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(0,0)3;Q(0,2)4;Q(1,-1)5;Q(1,1)6;Q(1,3)7;Q(2,0)8;Q(2,2)9;Q(2,4)10;Q(3,-1)11;Q(3,1)12;Q(3,3)13;Q(4,0)14;Q(4,2)15;Q(5,-1)16;Q(5,1)17;Q(5,3)18;R_0_1_2_3_5_6_7_9_11_12_13_14_16_17_18;RX_4_8_10_15;TICK;CX_0_3_8_5_11_14_4_1_6_9_15_12_10_7;TICK;CX_1_3_8_11_12_14_4_6_7_9_15_17_10_13;TICK;CX_5_3_8_6_16_14_4_2_12_9_15_13;TICK;CX_6_3_8_12_17_14_4_7_13_9_15_18;TICK;M_3_9_14;MX_4_8_10_15;DT(0,0)rec[-7];DT(2,2)rec[-6];DT(4,0)rec[-5])
- [Crumble link for subtemplate 29](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(0,0)2;Q(0,2)3;Q(1,-1)4;Q(1,1)5;Q(2,0)6;Q(3,-1)7;Q(3,1)8;Q(4,0)9;Q(4,2)10;Q(5,-1)11;Q(5,1)12;R_0_1_2_4_5_7_8_9_11_12;RX_3_6_10;TICK;CX_0_2_6_4_7_9_3_1_10_8;TICK;CX_1_2_6_7_8_9_3_5_10_12;TICK;CX_4_2_6_5_11_9;TICK;CX_5_2_6_8_12_9;TICK;M_2_9;MX_3_6_10;DT(0,0)rec[-5];DT(4,0)rec[-4])
- [Crumble link for subtemplate 30](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(-1,5)3;Q(0,0)4;Q(0,2)5;Q(0,4)6;Q(1,-1)7;Q(1,1)8;Q(1,3)9;Q(1,5)10;Q(2,0)11;Q(2,2)12;Q(2,4)13;Q(3,-1)14;Q(3,1)15;Q(3,3)16;Q(3,5)17;Q(4,0)18;Q(4,4)19;R_0_1_2_3_4_6_7_8_9_10_12_14_15_16_17_18_19;RX_5_11_13;TICK;CX_0_4_11_7_14_18_5_1_8_12_2_6_13_9_16_19;TICK;CX_1_4_11_14_15_18_5_8_9_12_3_6_13_16_17_19;TICK;CX_7_4_11_8_5_2_15_12_9_6_13_10;TICK;CX_8_4_11_15_5_9_16_12_10_6_13_17;TICK;M_4_6_12_18_19;MX_5_11_13;DT(0,0)rec[-8];DT(0,4)rec[-7];DT(4,0)rec[-5];DT(4,4)rec[-4];DT(2,2)rec[-6])
- [Crumble link for subtemplate 31](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(0,0)2;Q(0,2)3;Q(1,-1)4;Q(1,1)5;Q(2,0)6;Q(3,-1)7;Q(3,1)8;Q(4,0)9;R_0_1_2_4_5_7_8_9;RX_3_6;TICK;CX_0_2_6_4_7_9_3_1;TICK;CX_1_2_6_7_8_9_3_5;TICK;CX_4_2_6_5;TICK;CX_5_2_6_8;TICK;M_2_9;MX_3_6;DT(0,0)rec[-4];DT(4,0)rec[-3])
- [Crumble link for subtemplate 32](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(-1,5)3;Q(0,0)4;Q(0,2)5;Q(0,4)6;Q(1,-1)7;Q(1,1)8;Q(1,3)9;Q(1,5)10;Q(2,2)11;R_0_1_2_3_4_6_7_8_9_10_11;RX_5;TICK;CX_0_4_5_1_8_11_2_6;TICK;CX_1_4_5_8_9_11_3_6;TICK;CX_7_4_5_2_9_6;TICK;CX_8_4_5_9_10_6;TICK;M_4_6_11;MX_5;DT(0,0)rec[-4];DT(0,4)rec[-3];DT(2,2)rec[-2])
- [Crumble link for subtemplate 33](https://algassert.com/crumble#circuit=Q(-1,-1)0;Q(-1,1)1;Q(-1,3)2;Q(0,0)3;Q(0,2)4;Q(1,-1)5;Q(1,1)6;Q(1,3)7;Q(2,2)8;R_0_1_2_3_5_6_7_8;RX_4;TICK;CX_0_3_4_1_6_8;TICK;CX_1_3_4_6_7_8;TICK;CX_5_3_4_2;TICK;CX_6_3_4_7;TICK;M_3_8;MX_4;DT(0,0)rec[-3];DT(2,2)rec[-2])

Detectors are annotated on each link.
inmzhang: It looks cool! Just to note here in case anyone does not aware it, from Crumble's documentation:
> `j+#`: Picks a DETECTOR or OBSERVABLE_INCLUDE declaration touching the current selection and converts it into a tracked Pauli product.

You can easily convert the detector into stabilizer flow annotation by pressing `j+1`.
github-actions[bot]: A preview of 277f3204a22931b2aea4c28db855031505cf589f is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-81%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 96% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 0% | 0 | ❌
src.tqec.computation | 74% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 84% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **81%** (3263 / 4039) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
nelimee: I had to perform a few additional changes to make sure that the database was behaving as expected. The main one being that the `Collection` in `Plaquettes` is now a custom type `FrozenDefaultDict`. This was needed because the database needs to be able to reliably compute hashes for `Plaquettes` instances, and `defaultdict` defaults to inserting the missing key/value on an access that triggers the `default_factory`.

Also, I changed back `GridQubit` to using `int` instead of `Fraction` due to the large overhead that `Fraction` was adding. Even with `int`, most of the current overhead in `compute_detectors_for_constant_template_and_fixed_radius` is due to the repetitive calls to `Measurement.offset_spatially_by` that in turns calls `GridQubit.__add__`.

Benchmarking the approach shows exactly what's written above (here, the database is empty and is only populated, never used to retrieve detectors):
![image](https://github.com/user-attachments/assets/37134834-d17e-41b2-9e18-e3ecc23de8c7)

In terms of raw performances, on my computer, using the following code
```py
from time import perf_counter

from tqec.compile.specs.base import CubeSpec
from tqec.compile.specs.library.css import CSS_SPEC_RULES
from tqec.computation.block_graph.enums import CubeType

# Getting a cube to facilitate Template and Plaquettes creation.
spec = CubeSpec(CubeType.ZXZ)
# TODO: why do we have to repeat `spec` twice here?
cube = CSS_SPEC_RULES[spec](spec)

# Getting the plaquettes in the right type.
plaquettes = cube.layers
# We just want to try with the first (initialisation) layer.
plaquettes_tup = (plaquettes[0],)

k = 200
for k in [10, 50, 100, 150, 200, 250, 300]:
    start = perf_counter()
    detectors = compute_detectors_for_constant_template_and_fixed_radius(
        template=cube.template,
        k=k,
        plaquettes_at_timestep=plaquettes_tup,
        fixed_subtemplate_radius=1,
    )
    end = perf_counter()
    print(
        f"[{k:>3}] Computed {len(detectors)} detectors in {end-start:.2f} seconds."
    )
```
I get the following timings:
```
[ 10] Computed 220 detectors in 0.20 seconds.
[ 50] Computed 5100 detectors in 0.39 seconds.
[100] Computed 20200 detectors in 1.04 seconds.
[150] Computed 45300 detectors in 2.26 seconds.
[200] Computed 80400 detectors in 4.18 seconds.
[250] Computed 125500 detectors in 6.90 seconds.
[300] Computed 180600 detectors in 10.72 seconds.
```
Several things to note:

- this is only detectors from the initial layer of a Z-initialized memory experiment, so there is only half of the measurements that should form detectors.
- the needed time scales with the number of detectors (which is expected as the main bottleneck is with offsetting measurements from detectors and in this case each detector is only composed of one measurement).
- timings seems reasonable, but we might have to optimize calls to `Measurement.offset_spatially_by` in order to be really fast.

One interesting thing to note is that the main overhead (`Measurement.offset_spatially_by`) is outside of the database caching, meaning that even with a filled database of detectors I do not expect detector computation to be significantly more efficient. Nevertheless, having the possibility to look at **all** the computed/used detectors is a nice feature, and adding the possibility to only use detectors from the database should be quite easy.
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-81%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 96% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 0% | 0 | ❌
src.tqec.computation | 74% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 84% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **81%** (3263 / 4039) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
github-actions[bot]: A preview of 33c31a068feb1de78dc28e2b21f7aa94a64d57ee is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: A preview of 4c17ff583b87b7fb85c3462d0eebbbfa75568da8 is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-81%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 96% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 0% | 0 | ❌
src.tqec.computation | 74% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 84% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **81%** (3268 / 4039) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
github-actions[bot]: A preview of 557033d98540f4263a8f7a1c9399ead57d0458d3 is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-81%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 97% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.detectors | 85% | 0 | ✔
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 0% | 0 | ❌
src.tqec.computation | 74% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 84% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **81%** (3297 / 4072) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
inmzhang: I'll do the review at tomorrow.
github-actions[bot]: A preview of 62d06caa99fd528666aac60cb73857500ac31391 is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-82%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 97% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.detectors | 85% | 0 | ✔
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 48% | 0 | ❌
src.tqec.computation | 75% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 84% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **82%** (3328 / 4083) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
github-actions[bot]: A preview of 57289d60d7dde9f05a2e87a666dd11c7d73bcb9d is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-82%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 97% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.detectors | 85% | 0 | ✔
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 48% | 0 | ❌
src.tqec.computation | 75% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 84% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **82%** (3327 / 4082) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
github-actions[bot]: A preview of b50028d52b88ac7d495fb86b2f3d3a0b479583c0 is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-82%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 96% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.detectors | 85% | 0 | ✔
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 48% | 0 | ❌
src.tqec.computation | 75% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 84% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **82%** (3338 / 4095) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->
github-actions[bot]: A preview of 162cb065ce3fbcbd0ce0f85b0ec8242332c299ca is uploaded and can be seen here:

✨ https://qchackers.github.io/tqec/pull/350/ ✨

Changes may take a few minutes to propagate.
<!-- Sticky Pull Request Commentdocumentation-preview -->
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-82%25-success?style=flat)

Package | Line Rate | Complexity | Health
-------- | --------- | ---------- | ------
src.tqec | 97% | 0 | ✔
src.tqec.circuit | 96% | 0 | ✔
src.tqec.circuit.detectors | 94% | 0 | ✔
src.tqec.circuit.detectors.match_utils | 90% | 0 | ✔
src.tqec.compile | 31% | 0 | ❌
src.tqec.compile.detectors | 85% | 0 | ✔
src.tqec.compile.specs | 70% | 0 | ➖
src.tqec.compile.specs.library | 48% | 0 | ❌
src.tqec.computation | 75% | 0 | ➖
src.tqec.computation.block_graph | 81% | 0 | ✔
src.tqec.noise_models | 91% | 0 | ✔
src.tqec.plaquette | 84% | 0 | ✔
src.tqec.plaquette.library | 100% | 0 | ✔
src.tqec.simulation | 0% | 0 | ❌
src.tqec.simulation.plotting | 0% | 0 | ❌
src.tqec.templates | 78% | 0 | ✔
**Summary** | **82%** (3338 / 4095) | **0** | ✔

<!-- Sticky Pull Request Commentcode-coverage -->

Patch:
diff --git a/src/tqec/circuit/detectors/boundary.py b/src/tqec/circuit/detectors/boundary.py
index 671f2ea0..845bedf9 100644
--- a/src/tqec/circuit/detectors/boundary.py
+++ b/src/tqec/circuit/detectors/boundary.py
@@ -1,8 +1,9 @@
 from __future__ import annotations
 
-import typing as ty
+from typing import Iterable, Mapping
 
 import numpy
+
 from tqec.circuit.detectors.measurement import RelativeMeasurementLocation
 from tqec.circuit.detectors.pauli import PauliString
 from tqec.exceptions import TQECException
@@ -12,7 +13,7 @@ class BoundaryStabilizer:
     def __init__(
         self,
         stabilizer: PauliString,
-        collapsing_operations: ty.Iterable[PauliString],
+        collapsing_operations: Iterable[PauliString],
         measurements: list[RelativeMeasurementLocation],
         reset_qubits: frozenset[int],
         forward: bool,
@@ -91,7 +92,7 @@ class BoundaryStabilizer:
         return self._stabilizer
 
     @property
-    def collapsing_operations(self) -> ty.Iterable[PauliString]:
+    def collapsing_operations(self) -> Iterable[PauliString]:
         """Iterator on all the collapsing operations defining the boundary this
         stabilizer is applied to."""
         return self._collapsing_operations
@@ -190,7 +191,7 @@ class BoundaryStabilizer:
         return ret
 
     def coordinates(
-        self, qubit_coordinates: dict[int, tuple[float, ...]]
+        self, qubit_coordinates: Mapping[int, tuple[float, ...]]
     ) -> tuple[float, ...]:
         """Compute and return the coordinates of the boundary stabilizer.
 
@@ -206,7 +207,9 @@ class BoundaryStabilizer:
         measurement_coordinates = [
             qubit_coordinates[source] for source in self.source_qubits
         ]
-        return tuple(numpy.mean(measurement_coordinates, axis=0))
+        # Avoid returning numpy.float64 type returned by numpy.mean by
+        # explicitly calling float() on it.
+        return tuple(float(c) for c in numpy.mean(measurement_coordinates, axis=0))
 
     def with_measurement_offset(self, offset: int) -> BoundaryStabilizer:
         return BoundaryStabilizer(
diff --git a/src/tqec/circuit/detectors/match.py b/src/tqec/circuit/detectors/match.py
index dbad5ee9..1736094a 100644
--- a/src/tqec/circuit/detectors/match.py
+++ b/src/tqec/circuit/detectors/match.py
@@ -1,8 +1,8 @@
 from __future__ import annotations
 
-import typing as ty
 from copy import deepcopy
 from dataclasses import dataclass
+from typing import Iterator, Mapping
 
 import numpy
 import stim
@@ -118,7 +118,7 @@ def match_detectors_from_flows_shallow(
 
 def match_detectors_within_fragment(
     flows: FragmentFlows | FragmentLoopFlows,
-    qubit_coordinates: dict[int, tuple[float, ...]],
+    qubit_coordinates: Mapping[int, tuple[float, ...]],
 ) -> list[MatchedDetector]:
     """Match all the detectors that can be resolved without considering
     neighbouring fragments.
@@ -142,7 +142,6 @@ def match_detectors_within_fragment(
         the list of all the detectors found.
     """
     matched_detectors: list[MatchedDetector] = []
-
     matched_detectors.extend(
         _match_non_propagating_non_trivial_flows_inline(
             flows.creation, qubit_coordinates
@@ -158,7 +157,7 @@ def match_detectors_within_fragment(
 
 def _match_non_propagating_non_trivial_flows_inline(
     boundary_stabilizers: list[BoundaryStabilizer],
-    qubit_coordinates: dict[int, tuple[float, ...]],
+    qubit_coordinates: Mapping[int, tuple[float, ...]],
 ) -> list[MatchedDetector]:
     """Match all the detectors that can be trivially resolved and remove the
     matched boundary stabilizers from the provided list.
@@ -225,7 +224,7 @@ def _match_non_propagating_non_trivial_flows_inline(
 
 def _find_non_propagating_non_trivial_flows(
     boundary_stabilizers: list[BoundaryStabilizer],
-) -> ty.Iterator[int]:
+) -> Iterator[int]:
     """Find all the boundary stabilizers that do not have any anti-commuting
     collapsing operations and that are fully collapsed by the commuting
     operations.
@@ -250,7 +249,7 @@ def _find_non_propagating_non_trivial_flows(
 def match_boundary_stabilizers(
     left_flows: FragmentFlows | FragmentLoopFlows,
     right_flows: FragmentFlows | FragmentLoopFlows,
-    qubit_coordinates: dict[int, tuple[float, ...]],
+    qubit_coordinates: Mapping[int, tuple[float, ...]],
     perform_sanity_check: bool = True,
 ) -> list[MatchedDetector]:
     """Match detectors using the boundary stabilizers between the two given
@@ -347,7 +346,7 @@ def match_boundary_stabilizers(
 def _match_commute_stabilizers(
     left_flows: FragmentFlows | FragmentLoopFlows,
     right_flows: FragmentFlows | FragmentLoopFlows,
-    qubit_coordinates: dict[int, tuple[float, ...]],
+    qubit_coordinates: Mapping[int, tuple[float, ...]],
 ) -> list[MatchedDetector]:
     """Take stabilizers from two contiguous fragments and try to match them.
 
@@ -420,7 +419,7 @@ def _match_commute_stabilizers(
 def _match_boundary_stabilizers_by_disjoint_cover(
     target_stabilizers: list[BoundaryStabilizer],
     covering_stabilizers: list[BoundaryStabilizer],
-    qubit_coordinates: dict[int, tuple[float, ...]],
+    qubit_coordinates: Mapping[int, tuple[float, ...]],
     target_is_creation: bool,
 ) -> tuple[list[MatchedDetector], list[int]]:
     """Try to match stabilizers by finding a cover.
@@ -491,7 +490,7 @@ def _match_boundary_stabilizers_by_disjoint_cover(
 def _match_by_disjoint_cover(
     left_flows: FragmentFlows | FragmentLoopFlows,
     right_flows: FragmentFlows | FragmentLoopFlows,
-    qubit_coordinates: dict[int, tuple[float, ...]],
+    qubit_coordinates: Mapping[int, tuple[float, ...]],
 ) -> list[MatchedDetector]:
     """Try to match stabilizers by finding a cover.
 
diff --git a/src/tqec/circuit/generation.py b/src/tqec/circuit/generation.py
index 37dd760d..6663afe8 100644
--- a/src/tqec/circuit/generation.py
+++ b/src/tqec/circuit/generation.py
@@ -66,16 +66,6 @@ def generate_circuit(
     Raises:
         TQECException: if `len(plaquettes) != template.expected_plaquettes_number`.
     """
-    # Check that the user gave enough plaquettes.
-    if (
-        not plaquettes.has_default
-        and len(plaquettes) != template.expected_plaquettes_number
-    ):
-        raise TQECException(
-            f"{len(plaquettes)} plaquettes have been provided, but "
-            f"{template.expected_plaquettes_number} were expected."
-        )
-
     # instantiate the template with the appropriate plaquette indices.
     # Index 0 is "no plaquette" by convention and should not be included here.
     _indices = list(range(1, template.expected_plaquettes_number + 1))
@@ -130,12 +120,6 @@ def generate_circuit_from_instantiation(
     # Remove the first 0 entry in indices if present
     if indices[0] == 0:
         indices = indices[1:]
-    expected_plaquettes_number = len(indices)
-    if not plaquettes.has_default and len(plaquettes) != expected_plaquettes_number:
-        raise TQECException(
-            f"{len(plaquettes)} plaquettes have been provided, but "
-            f"{expected_plaquettes_number} were expected."
-        )
 
     # Plaquettes indices are starting at 1 in template_plaquettes. To avoid
     # offsets in the following code, we add an empty circuit at position 0.
diff --git a/src/tqec/circuit/instructions.py b/src/tqec/circuit/instructions.py
new file mode 100644
index 00000000..72a28987
--- /dev/null
+++ b/src/tqec/circuit/instructions.py
@@ -0,0 +1,39 @@
+"""Defines a few functions to analyse and create `stim.CircuitInstruction` instances."""
+
+import stim
+
+SINGLE_QUBIT_MEASUREMENT_INSTRUCTION_NAMES: frozenset[str] = frozenset(
+    ["M", "MR", "MRX", "MRY", "MRZ", "MX", "MY", "MZ"]
+)
+MULTIPLE_QUBIT_MEASUREMENT_INSTRUCTION_NAMES: frozenset[str] = frozenset(
+    ["MXX", "MYY", "MZZ", "MPP"]
+)
+
+MEASUREMENT_INSTRUCTION_NAMES = (
+    SINGLE_QUBIT_MEASUREMENT_INSTRUCTION_NAMES
+    | MULTIPLE_QUBIT_MEASUREMENT_INSTRUCTION_NAMES
+)
+
+ANNOTATION_INSTRUCTION_NAMES: frozenset[str] = frozenset(
+    ["DETECTOR", "MPAD", "OBSERVABLE_INCLUDE", "QUBIT_COORDS", "SHIFT_COORDS", "TICK"]
+)
+
+
+def is_measurement_instruction(instruction: stim.CircuitInstruction) -> bool:
+    return instruction.name in MEASUREMENT_INSTRUCTION_NAMES
+
+
+def is_single_qubit_measurement_instruction(
+    instruction: stim.CircuitInstruction,
+) -> bool:
+    return instruction.name in SINGLE_QUBIT_MEASUREMENT_INSTRUCTION_NAMES
+
+
+def is_multi_qubit_measurement_instruction(
+    instruction: stim.CircuitInstruction,
+) -> bool:
+    return instruction.name in MULTIPLE_QUBIT_MEASUREMENT_INSTRUCTION_NAMES
+
+
+def is_annotation_instruction(instruction: stim.CircuitInstruction) -> bool:
+    return instruction.name in ANNOTATION_INSTRUCTION_NAMES
diff --git a/src/tqec/circuit/measurement.py b/src/tqec/circuit/measurement.py
index 85794de2..714654d4 100644
--- a/src/tqec/circuit/measurement.py
+++ b/src/tqec/circuit/measurement.py
@@ -11,9 +11,15 @@ import typing
 from abc import ABC, abstractmethod
 from dataclasses import dataclass
 
+import stim
 from typing_extensions import override
 
+from tqec.circuit.instructions import (
+    is_multi_qubit_measurement_instruction,
+    is_single_qubit_measurement_instruction,
+)
 from tqec.circuit.qubit import GridQubit
+from tqec.circuit.qubit_map import QubitMap
 from tqec.exceptions import TQECException
 from tqec.interval import Interval, Rplus_interval
 from tqec.position import Displacement
@@ -103,6 +109,9 @@ class Measurement(AbstractMeasurement):
     def __repr__(self) -> str:
         return f"{self.__class__.__name__}({self.qubit}, {self.offset})"
 
+    def __str__(self) -> str:
+        return f"M[{self.qubit},{self.offset}]"
+
     @override
     def map_qubit(self, qubit_map: typing.Mapping[GridQubit, GridQubit]) -> Measurement:
         return Measurement(qubit_map[self.qubit], self.offset)
@@ -165,3 +174,34 @@ class RepeatedMeasurement(AbstractMeasurement):
         return [
             Measurement(self.qubit, offset) for offset in self.offsets.iter_integers()
         ]
+
+
+def get_measurements_from_circuit(circuit: stim.Circuit) -> list[Measurement]:
+    qubit_map = QubitMap.from_circuit(circuit)
+    num_measurements: dict[GridQubit, int] = {}
+    for instruction in circuit:
+        if isinstance(instruction, stim.CircuitRepeatBlock):
+            raise TQECException(
+                "Found a REPEAT block in get_measurements_from_circuit. This "
+                "is not supported."
+            )
+        if is_multi_qubit_measurement_instruction(instruction):
+            raise TQECException(
+                f"Got a multi-qubit measurement instruction ({instruction.name}) "
+                "but multi-qubit measurements are not supported yet."
+            )
+        if is_single_qubit_measurement_instruction(instruction):
+            for (target,) in instruction.target_groups():
+                if not target.is_qubit_target:
+                    raise TQECException(
+                        "Found a measurement instruction with a target that is "
+                        f"not a qubit target: {instruction}."
+                    )
+                qi: int = typing.cast(int, target.qubit_value)
+                qubit = qubit_map.i2q[qi]
+                num_measurements[qubit] = num_measurements.get(qubit, 0) + 1
+    return [
+        Measurement(q, -i)
+        for q, num_measurement in num_measurements.items()
+        for i in range(1, num_measurement + 1)
+    ]
diff --git a/src/tqec/circuit/measurement_map.py b/src/tqec/circuit/measurement_map.py
new file mode 100644
index 00000000..c03a1c2c
--- /dev/null
+++ b/src/tqec/circuit/measurement_map.py
@@ -0,0 +1,157 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Sequence
+
+import numpy
+import stim
+
+from tqec.circuit.instructions import (
+    is_multi_qubit_measurement_instruction,
+    is_single_qubit_measurement_instruction,
+)
+from tqec.circuit.qubit import GridQubit
+from tqec.circuit.qubit_map import QubitMap
+from tqec.circuit.schedule import ScheduledCircuit
+from tqec.exceptions import TQECException
+
+
+@dataclass(frozen=True)
+class MeasurementRecordsMap:
+    """A mapping from measurements appearing in a circuit and their record offsets.
+
+    This class stores record offsets which are, by essence, relative to a certain
+    position in a circuit. This means that this class and the measurement offsets
+    it stores are meaningless without knowledge about the circuit containing the
+    represented measurements and the position(s) in the circuit at which the
+    instance at hand is valid.
+
+    Raises:
+        TQECException: if at least one of the provided measurement record offsets
+            is non-negative (`>=0`).
+        TQECException: if, for any of the provided qubits, the provided offsets
+            are not sorted.
+        TQECException: if any measurement offset is duplicated.
+    """
+
+    mapping: dict[GridQubit, list[int]] = field(default_factory=dict)
+
+    def __post_init__(self) -> None:
+        all_measurement_records_indices: list[int] = []
+        for qubit, measurement_record_offsets in self.mapping.items():
+            # Check that the provided measurement record offsets are negative.
+            nonnegative_offsets = [
+                offset for offset in measurement_record_offsets if offset >= 0
+            ]
+            if nonnegative_offsets:
+                raise TQECException(
+                    "Invalid mapping from qubit offsets to measurement record "
+                    f"offsets. Found positive offsets ({nonnegative_offsets}) for "
+                    f"qubit {qubit}."
+                )
+            # Check that measurement record offsets are sorted
+            if measurement_record_offsets != sorted(measurement_record_offsets):
+                raise TQECException(
+                    "Got measurement record offsets that are not in sorted "
+                    f"order: {measurement_record_offsets}. This is not supported."
+                )
+            all_measurement_records_indices.extend(measurement_record_offsets)
+        # Check that a given measurement record offset only appears once.
+        deduplicated_indices = numpy.unique(all_measurement_records_indices)
+        if len(deduplicated_indices) != len(all_measurement_records_indices):
+            raise TQECException(
+                "At least one measurement record offset has been found twice "
+                "in the provided offsets."
+            )
+
+    @staticmethod
+    def from_scheduled_circuit(circuit: ScheduledCircuit) -> MeasurementRecordsMap:
+        """Build a :class:`MeasurementMap` from a scheduled circuit.
+
+        Args:
+            circuit: circuit containing the measurements to map.
+
+        Raises:
+            TQECException: if the provided `circuit` contains a `REPEAT` block.
+            TQECException: if the provided `circuit` contains an unsupported
+                measurement instruction. Currently, this method supports all
+                single-qubit measurement instructions (see the value of
+                `SINGLE_QUBIT_MEASUREMENT_INSTRUCTION_NAMES`).
+            TQECException: if one of the measurement counted in
+                `circuit.num_measurements` was not processed (this should never
+                raise, but the post-condition is easy to check so this method
+                performs the check just in case).
+
+        Returns:
+            a :class:`MeasurementMap` linking measurements in the provided
+            `circuit` to their offset.
+        """
+        return MeasurementRecordsMap.from_circuit(
+            circuit.get_circuit(include_qubit_coords=False), circuit.qubit_map
+        )
+
+    @staticmethod
+    def from_circuit(
+        circuit: stim.Circuit, qubit_map: QubitMap | None = None
+    ) -> MeasurementRecordsMap:
+        """Build a :class:`MeasurementMap` from a circuit.
+
+        Args:
+            circuit: circuit containing the measurements to map.
+            qubit_map: qubit map of the provided circuit. If `None`, the
+                qubit map is computed from the provided circuit. Default to
+                `None`.
+        Raises:
+            TQECException: if the provided `circuit` contains a `REPEAT` block.
+            TQECException: if the provided `circuit` contains an unsupported
+                measurement instruction. Currently, this method supports all
+                single-qubit measurement instructions (see the value of
+                `SINGLE_QUBIT_MEASUREMENT_INSTRUCTION_NAMES`).
+            TQECException: if one of the measurement counted in
+                `circuit.num_measurements` was not processed (this should never
+                raise, but the post-condition is easy to check so this method
+                performs the check just in case).
+
+        Returns:
+            a :class:`MeasurementMap` linking measurements in the provided
+            `circuit` to their offset.
+        """
+        # We iterate the circuit in forward order, which means that the first
+        # measurement we will encounter will have a record offset of
+        # `-circuit.num_measurements`. Iterating in this order also allows to
+        # ensure by construction that the measurement record offsets will be in
+        # sorted order.
+        current_measurement_record = -circuit.num_measurements
+        if qubit_map is None:
+            qubit_map = QubitMap.from_circuit(circuit)
+        measurement_records: dict[GridQubit, list[int]] = {}
+        for instruction in circuit:
+            if isinstance(instruction, stim.CircuitRepeatBlock):
+                raise TQECException(
+                    "Found a REPEAT instruction. This is not supported for the moment."
+                )
+            if is_multi_qubit_measurement_instruction(instruction):
+                raise TQECException(
+                    f"Found a non-supported measurement instruction: {instruction}"
+                )
+            if is_single_qubit_measurement_instruction(instruction):
+                for (qi,) in instruction.target_groups():
+                    qubit = qubit_map.i2q[qi.value]
+                    measurement_records.setdefault(qubit, []).append(
+                        current_measurement_record
+                    )
+                    current_measurement_record += 1
+        if current_measurement_record != 0:
+            raise TQECException(
+                "Failed post-condition check. Expected a final measurement record of "
+                f"-1 but got {current_measurement_record - 1}. Did we miss a "
+                f"measurement? Circuit:\n{circuit}"
+            )
+        return MeasurementRecordsMap(measurement_records)
+
+    # Explicitly returns a Sequence to show that the returned value is read-only.
+    def __getitem__(self, qubit: GridQubit) -> Sequence[int]:
+        return self.mapping[qubit]
+
+    def __contains__(self, qubit: GridQubit) -> bool:
+        return qubit in self.mapping
diff --git a/src/tqec/circuit/moment.py b/src/tqec/circuit/moment.py
index 8cfcda44..1a1c398e 100644
--- a/src/tqec/circuit/moment.py
+++ b/src/tqec/circuit/moment.py
@@ -122,6 +122,12 @@ class Moment:
         circuit_to_add.append(name, targets, args)
         self += circuit_to_add
 
+    def append_instruction(self, instruction: stim.CircuitInstruction) -> None:
+        """Append an instruction to the :class:`Moment`."""
+        circuit_to_add = stim.Circuit()
+        circuit_to_add.append(instruction)
+        self += circuit_to_add
+
     @property
     def instructions(self) -> ty.Iterator[stim.CircuitInstruction]:
         """Iterator over all the instructions contained in the moment."""
diff --git a/src/tqec/circuit/qubit.py b/src/tqec/circuit/qubit.py
index 2aac2423..ad37c540 100644
--- a/src/tqec/circuit/qubit.py
+++ b/src/tqec/circuit/qubit.py
@@ -9,51 +9,25 @@ from __future__ import annotations
 
 import typing as ty
 from collections import defaultdict
-from fractions import Fraction
 
 import stim
 
 from tqec.position import Displacement, Position2D
 
-NumericType = int | float | Fraction
-
-
-def _to_fraction(value: NumericType) -> Fraction:
-    if isinstance(value, int):
-        return Fraction(value, 1)
-    elif isinstance(value, float):
-        return Fraction.from_float(value)
-    return value
-
 
 class GridQubit:
-    """Represent a qubit placed on a 2-dimensional grid.
-
-    Internally, this class represents the qubit coordinates as fractions. This
-    design choice has been made for several reasons:
+    """Represent a qubit placed on a 2-dimensional grid."""
 
-    - the :class:`fractions.Fraction` type is hashable (and its hash is usable,
-      which is not the case for `float` for example).
-    - most of the qubit coordinates we manipulated since the beginning are
-      integers and so can be exactly represented by an instance of
-      :class:`fractions.Fraction`.
-    - it might make sense to have some qubits on "half-integer" (e.g., `0.5` or
-      `4.5`) as some Crumble or Stim pre-built circuits use such coordinates.
-
-    This means that the `x` and `y` coordinates will be returned as `Fraction`
-    instances to the user.
-    """
-
-    def __init__(self, x: NumericType, y: NumericType) -> None:
-        self._x = _to_fraction(x)
-        self._y = _to_fraction(y)
+    def __init__(self, x: int, y: int) -> None:
+        self._x = x
+        self._y = y
 
     @property
-    def x(self) -> Fraction:
+    def x(self) -> int:
         return self._x
 
     @property
-    def y(self) -> Fraction:
+    def y(self) -> int:
         return self._y
 
     def to_qubit_coords_instruction(self, index: int) -> stim.CircuitInstruction:
@@ -70,10 +44,10 @@ class GridQubit:
     def __sub__(self, other: GridQubit | Position2D | Displacement) -> GridQubit:
         return GridQubit(self.x - other.x, self.y - other.y)
 
-    def __mul__(self, other: float) -> GridQubit:
+    def __mul__(self, other: int) -> GridQubit:
         return GridQubit(other * self.x, other * self.y)
 
-    def __rmul__(self, other: float) -> GridQubit:
+    def __rmul__(self, other: int) -> GridQubit:
         return GridQubit(other * self.x, other * self.y)
 
     def __hash__(self) -> int:
@@ -90,6 +64,9 @@ class GridQubit:
     def __repr__(self) -> str:
         return f"GridQubit({self.x}, {self.y})"
 
+    def __str__(self) -> str:
+        return f"Q[{self.x}, {self.y}]"
+
 
 """Names of the `stim` instructions that are considered as annotations."""
 ANNOTATION_INSTRUCTIONS: frozenset[str] = frozenset(
diff --git a/src/tqec/circuit/qubit_map.py b/src/tqec/circuit/qubit_map.py
index 87da7964..57f549eb 100644
--- a/src/tqec/circuit/qubit_map.py
+++ b/src/tqec/circuit/qubit_map.py
@@ -9,6 +9,7 @@ import stim
 
 from tqec.circuit.qubit import GridQubit
 from tqec.exceptions import TQECException
+from tqec.scale import round_or_fail
 
 
 @dataclass(frozen=True)
@@ -39,6 +40,10 @@ class QubitMap:
     def from_qubits(qubits: Iterable[GridQubit]) -> QubitMap:
         return QubitMap(dict(enumerate(qubits)))
 
+    @staticmethod
+    def from_circuit(circuit: stim.Circuit) -> QubitMap:
+        return get_qubit_map(circuit)
+
     @functools.cached_property
     def q2i(self) -> dict[GridQubit, int]:
         return {q: i for i, q in self.i2q.items()}
@@ -84,6 +89,13 @@ class QubitMap:
         kept_qubits = frozenset(qubits_to_keep)
         return QubitMap({i: q for i, q in self.i2q.items() if q in kept_qubits})
 
+    def to_circuit(self) -> stim.Circuit:
+        """Get a circuit with only `QUBIT_COORDS` instructions representing `self`."""
+        ret = stim.Circuit()
+        for qi, qubit in sorted(self.i2q.items(), key=lambda t: t[0]):
+            ret.append("QUBIT_COORDS", qi, (float(qubit.x), float(qubit.y)))
+        return ret
+
 
 def get_qubit_map(circuit: stim.Circuit) -> QubitMap:
     """Returns the existing qubits and their coordinates at the end of the
@@ -112,5 +124,7 @@ def get_qubit_map(circuit: stim.Circuit) -> QubitMap:
                 "Qubits should be defined on exactly 2 spatial dimensions. "
                 f"Found {qi} -> {coords} defined on {len(coords)} spatial dimensions."
             )
-        qubits[qi] = GridQubit(*coords)
+        x = round_or_fail(coords[0])
+        y = round_or_fail(coords[1])
+        qubits[qi] = GridQubit(x, y)
     return QubitMap(qubits)
diff --git a/src/tqec/circuit/schedule.py b/src/tqec/circuit/schedule.py
index ab91ac9a..5757e2d5 100644
--- a/src/tqec/circuit/schedule.py
+++ b/src/tqec/circuit/schedule.py
@@ -34,6 +34,7 @@ from dataclasses import dataclass, field
 
 import stim
 
+from tqec.circuit.instructions import is_annotation_instruction
 from tqec.circuit.moment import Moment, iter_stim_circuit_without_repeat_by_moments
 from tqec.circuit.qubit import GridQubit
 from tqec.circuit.qubit_map import QubitMap, get_qubit_map
@@ -134,6 +135,15 @@ class Schedule:
             self.schedule.pop(-1)
             raise e
 
+    def append_schedule(self, schedule: Schedule) -> None:
+        starting_index = (
+            self.schedule[-1] + 1 if self.schedule else Schedule._INITIAL_SCHEDULE
+        )
+        # Note: not using a generator here but explicitly constructing a list
+        #       because if `schedule == self` a generator would induce an
+        #       infinite loop.
+        self.schedule.extend([starting_index + s for s in schedule.schedule])
+
 
 class ScheduledCircuit:
     def __init__(
@@ -195,7 +205,7 @@ class ScheduledCircuit:
     def from_circuit(
         circuit: stim.Circuit,
         schedule: Schedule | list[int] | int = 0,
-        i2q: QubitMap | None = None,
+        qubit_map: QubitMap | None = None,
     ) -> ScheduledCircuit:
         """Build a :class:`ScheduledCircuit` instance from a circuit and a
         schedule.
@@ -208,7 +218,7 @@ class ScheduledCircuit:
                 in the provided `stim.Circuit` instance. If an integer is provided,
                 each moment of the provided `stim.Circuit` is scheduled sequentially,
                 starting by the provided schedule.
-            i2q: a map from indices to qubits. If None, this function will try to
+            qubit_map: a map from indices to qubits. If None, this function will try to
                 extract the qubit coordinates from the `QUBIT_COORDS` instructions
                 found in the provided `circuit`. Else, the provided map will be used.
 
@@ -240,15 +250,15 @@ class ScheduledCircuit:
                 "ScheduledCircuit instance expects the input `stim.Circuit` to "
                 "only contain QUBIT_COORDS instructions before the first TICK."
             )
-        if i2q is None:
+        if qubit_map is None:
             # Here, we know for sure that no qubit is (re)defined in another place
             # than the first moment, so we can directly get qubit coordinates from
             # that moment.
-            i2q = get_qubit_map(moments[0].circuit)
+            qubit_map = get_qubit_map(moments[0].circuit)
         # And because we want the cleanest possible moments, we can remove the
         # `QUBIT_COORDS` instructions from the first moment.
         moments[0].remove_all_instructions_inplace(frozenset(["QUBIT_COORDS"]))
-        return ScheduledCircuit(moments, schedule, i2q)
+        return ScheduledCircuit(moments, schedule, qubit_map)
 
     @staticmethod
     def _check_input_circuit(circuit: stim.Circuit) -> None:
@@ -266,10 +276,7 @@ class ScheduledCircuit:
 
     def get_qubit_coords_definition_preamble(self) -> stim.Circuit:
         """Get a circuit with only `QUBIT_COORDS` instructions."""
-        ret = stim.Circuit()
-        for qi, qubit in sorted(self._qubit_map.items(), key=lambda t: t[0]):
-            ret.append("QUBIT_COORDS", qi, (float(qubit.x), float(qubit.y)))
-        return ret
+        return self._qubit_map.to_circuit()
 
     def get_circuit(self, include_qubit_coords: bool = True) -> stim.Circuit:
         """Build and return the `stim.Circuit` instance represented by self.
@@ -554,11 +561,6 @@ class ScheduledCircuit:
             # operations overlap.
             self._moments[moment_index] += moment
 
-    @property
-    def q2i(self) -> dict[GridQubit, int]:
-        """Return the map from qubits used in `self` their index."""
-        return {q: i for i, q in self._qubit_map.items()}
-
     def append_observable(
         self, index: int, targets: ty.Sequence[stim.GateTarget]
     ) -> None:
@@ -568,12 +570,26 @@ class ScheduledCircuit:
             index: index of the observable to append measurement records to.
             targets: measurement records forming (part of) the observable.
         """
-        if any(not t.is_measurement_record_target for t in targets):
+        self.append_annotation(
+            stim.CircuitInstruction("OBSERVABLE_INCLUDE", targets, [index])
+        )
+
+    def append_annotation(self, instruction: stim.CircuitInstruction) -> None:
+        """Append an annotation to the last moment.
+
+        Args:
+            instruction: an annotation that will be added to the last moment of
+                `self`.
+
+        Raises:
+            TQECException: if the provided instruction is not an annotation.
+        """
+        if not is_annotation_instruction(instruction):
             raise TQECException(
-                "Cannot create an observable with targets that are not measurement "
-                f"records. Got: {list(targets)}."
+                "The provided instruction is not an annotation, which is "
+                "disallowed by the append_annotation method."
             )
-        self._moments[-1].append("OBSERVABLE_INCLUDE", targets, [index])
+        self._moments[-1].append_instruction(instruction)
 
     @property
     def num_measurements(self) -> int:
@@ -597,7 +613,7 @@ class ScheduledCircuit:
             schedules.
         """
         qubits_indices_to_keep = frozenset(
-            self.q2i[q] for q in qubits_to_keep if q in self.qubits
+            self._qubit_map.q2i[q] for q in qubits_to_keep if q in self.qubits
         )
         filtered_moments: list[Moment] = []
         filtered_schedule: list[int] = []
@@ -615,6 +631,10 @@ class ScheduledCircuit:
         indices_map = {oi: ni for ni, oi in enumerate(qubit_map.indices)}
         return filtered_circuit.map_qubit_indices(indices_map)
 
+    @property
+    def qubit_map(self) -> QubitMap:
+        return self._qubit_map
+
 
 class _ScheduledCircuits:
     def __init__(self, circuits: list[ScheduledCircuit]) -> None:
@@ -887,8 +907,8 @@ def relabel_circuits_qubit_indices(
     """
     # First, get a global qubit index map.
     needed_qubits = frozenset.union(*[c.qubits for c in circuits])
-    qubit_map = QubitMap.from_qubits(sorted(needed_qubits))
-    q2i = qubit_map.q2i
+    global_qubit_map = QubitMap.from_qubits(sorted(needed_qubits))
+    global_q2i = global_qubit_map.q2i
     # Then, get the remapped circuits. Note that map_qubit_indices should
     # have approximately the same runtime cost whatever the value of inplace
     # so we ask for a new instance to avoid keeping a reference to the given
@@ -896,9 +916,9 @@ def relabel_circuits_qubit_indices(
     relabeled_circuits: list[ScheduledCircuit] = []
     for circuit in circuits:
         local_indices_to_global_indices = {
-            local_index: q2i[q] for q, local_index in circuit.q2i.items()
+            local_index: global_q2i[q] for local_index, q in circuit.qubit_map.items()
         }
         relabeled_circuits.append(
             circuit.map_qubit_indices(local_indices_to_global_indices, inplace=False)
         )
-    return relabeled_circuits, qubit_map
+    return relabeled_circuits, global_qubit_map
diff --git a/src/tqec/compile/block.py b/src/tqec/compile/block.py
index a25379e8..c9dd798a 100644
--- a/src/tqec/compile/block.py
+++ b/src/tqec/compile/block.py
@@ -1,11 +1,12 @@
 from __future__ import annotations
 
-from collections import defaultdict
 from dataclasses import dataclass
+from typing import Mapping
 
 from tqec.circuit.generation import generate_circuit
 from tqec.circuit.schedule import ScheduledCircuit
 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 Position2D
@@ -35,7 +36,7 @@ class CompiledBlock:
 
     def with_updated_layer(
         self,
-        plaquettes_to_update: dict[int, Plaquette],
+        plaquettes_to_update: Mapping[int, Plaquette],
         layer_to_update: int,
     ) -> CompiledBlock:
         """Returns a new `CompiledBlock` with the specified layer updated.
@@ -93,9 +94,7 @@ class BlockLayout:
         indices_map = self._template.get_indices_map_for_instantiation()
         merged_layers: list[Plaquettes] = []
         for i in range(num_layers.pop()):
-            merged_plaquettes: defaultdict[int, Plaquette] = defaultdict(
-                empty_square_plaquette
-            )
+            merged_plaquettes: dict[int, Plaquette] = {}
             repetitions: LinearFunction | None = None
             for pos, layers in layers_layout.items():
                 layer = layers[i]
@@ -112,7 +111,11 @@ class BlockLayout:
                 merged_plaquettes.update(
                     {imap[i]: plaquette for i, plaquette in layer.collection.items()}
                 )
-            plaquettes = Plaquettes(merged_plaquettes)
+            plaquettes = Plaquettes(
+                FrozenDefaultDict(
+                    merged_plaquettes, default_factory=empty_square_plaquette
+                )
+            )
             if repetitions is not None:
                 plaquettes = plaquettes.repeat(repetitions)
             merged_layers.append(plaquettes)
diff --git a/src/tqec/compile/compile.py b/src/tqec/compile/compile.py
index efb23772..53fb1fe6 100644
--- a/src/tqec/compile/compile.py
+++ b/src/tqec/compile/compile.py
@@ -113,7 +113,8 @@ class CompiledGraph:
                 # no index clash.
                 circuit = circuits[t][i]
                 local_indices_to_global_indices = {
-                    local_index: global_q2i[q] for q, local_index in circuit.q2i.items()
+                    local_index: global_q2i[q]
+                    for local_index, q in circuit.qubit_map.items()
                 }
                 circuit.map_qubit_indices(local_indices_to_global_indices, inplace=True)
 
diff --git a/src/tqec/compile/detectors/compute.py b/src/tqec/compile/detectors/compute.py
new file mode 100644
index 00000000..9fe17649
--- /dev/null
+++ b/src/tqec/compile/detectors/compute.py
@@ -0,0 +1,259 @@
+import stim
+
+from tqec.circuit.detectors.flow import build_flows_from_fragments
+from tqec.circuit.detectors.fragment import Fragment
+from tqec.circuit.detectors.match import (
+    MatchedDetector,
+    match_boundary_stabilizers,
+    match_detectors_within_fragment,
+)
+from tqec.circuit.generation import generate_circuit_from_instantiation
+from tqec.circuit.measurement import Measurement, get_measurements_from_circuit
+from tqec.circuit.qubit import GridQubit
+from tqec.circuit.schedule import ScheduledCircuit, relabel_circuits_qubit_indices
+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.templates.base import Template
+from tqec.templates.subtemplates import SubTemplateType
+
+
+def _get_measurement_offset_mapping(circuit: stim.Circuit) -> dict[int, Measurement]:
+    """Get a mapping from measurement offsets as used and returned by the
+    package `tqec.circuit.detectors` to a `Measurement` instance.
+
+    This function returns the mapping from negative offsets that are
+    supposed to each represent a unique measurement in the circuit to
+    `Measurement` instances, that serve the same purpose but use a different
+    encoding.
+
+    Note:
+        As a sanity check, the user of this function is encouraged to check
+        if the qubits in each `Measurement` instances returned as values of
+        the mapping corresponds to the expected qubit.
+
+    Args:
+        circuit: the circuit to create the mapping for.
+
+    Raises:
+        TQECException: if any qubit of the generated circuit is not a
+            `cirq.GridQubit` instance.
+
+    Returns:
+        a mapping from record offset to :class:`Measurement` for all the
+        measurements appearing in the provided `circuit`.
+    """
+    return {
+        -i - 1: m
+        for i, m in enumerate(reversed(get_measurements_from_circuit(circuit)))
+    }
+
+
+def _matched_detectors_to_detectors(
+    detectors: list[MatchedDetector],
+    measurements_by_offset: dict[int, Measurement],
+    qubits_by_indices: dict[int, GridQubit],
+) -> frozenset[Detector]:
+    ret: list[Detector] = []
+    for d in detectors:
+        measurements: list[Measurement] = []
+        for m in d.measurements:
+            measurement = measurements_by_offset[m.offset]
+            expected_qubit = qubits_by_indices[m.qubit_index]
+            if measurement.qubit != expected_qubit:
+                raise TQECException(
+                    f"Expected qubit {expected_qubit} from index {m.qubit_index} "
+                    f"but got {measurement.qubit}."
+                )
+            measurements.append(measurement)
+        ret.append(Detector(frozenset(measurements), d.coords))
+    return frozenset(ret)
+
+
+def _compute_detectors_at_end_of_situation(
+    subtemplates: tuple[SubTemplateType] | tuple[SubTemplateType, SubTemplateType],
+    plaquettes_by_timestep: tuple[Plaquettes] | tuple[Plaquettes, Plaquettes],
+    increments: Displacement,
+) -> frozenset[Detector]:
+    # Build subcircuit for each Plaquettes layer
+    subcircuits: list[ScheduledCircuit] = []
+    for subtemplate, plaquettes in zip(subtemplates, plaquettes_by_timestep):
+        subcircuit = generate_circuit_from_instantiation(
+            subtemplate, plaquettes, increments
+        )
+        subcircuits.append(subcircuit)
+
+    # Extract the global qubit map from the generated sub-circuits, relabeling
+    # qubits if needed.
+    subcircuits, global_qubit_map = relabel_circuits_qubit_indices(subcircuits)
+    # Get the stim.Circuit instances. We do not need the coordinates here
+    # because we have already computed the global qubit map.
+    coordless_subcircuits = [
+        sc.get_circuit(include_qubit_coords=False) for sc in subcircuits
+    ]
+    complete_circuit = global_qubit_map.to_circuit() + sum(
+        coordless_subcircuits, start=stim.Circuit()
+    )
+    # Construct the different mappings we will need during the computation
+    measurements_by_offset = _get_measurement_offset_mapping(complete_circuit)
+    coordinates_by_index = {
+        i: (float(q.x), float(q.y)) for i, q in global_qubit_map.items()
+    }
+    # Start the detector computation.
+    fragments = [Fragment(circ) for circ in coordless_subcircuits]
+    flows = build_flows_from_fragments(fragments)
+    # Match the detectors
+    matched_detectors = match_detectors_within_fragment(flows[-1], coordinates_by_index)
+    if len(flows) == 2:
+        matched_detectors.extend(
+            match_boundary_stabilizers(flows[-2], flows[-1], coordinates_by_index)
+        )
+
+    return _matched_detectors_to_detectors(
+        matched_detectors, measurements_by_offset, global_qubit_map.i2q
+    )
+
+
+def compute_detectors_at_end_of_situation(
+    subtemplates: tuple[SubTemplateType] | tuple[SubTemplateType, SubTemplateType],
+    plaquettes_by_timestep: tuple[Plaquettes] | tuple[Plaquettes, Plaquettes],
+    increments: Displacement,
+    database: DetectorDatabase | None = None,
+) -> frozenset[Detector]:
+    """Returns detectors that should be added at the end of the provided situation.
+
+    Args:
+        subtemplates: a tuple containing either one or two sub-template(s), each
+            consisting of square 2-dimensional array of integers with odd-length
+            sides representing the arrangement of plaquettes in a subtemplate.
+        plaquettes_at_timestep: a tuple containing either one or two collection(s)
+            of plaquettes each representing one QEC round.
+        increments: spatial increments between each `Plaquette` origin.
+        database: existing database of detectors that is used to avoid computing
+            detectors if the database already contains them. If provided, this
+            function guarantees that the database will contain the provided
+            situation when returning (i.e., either it already contained the
+            situation or it has been updated **in-place** with the computed
+            detectors). Default to `None` which result in not using any kind of
+            database and unconditionally performing the detector computation.
+
+    Returns:
+        all the detectors that can be appended at the end of the circuit
+        represented by the provided `subtemplates` and `plaquettes_at_timestep`.
+
+    Raises:
+        TQECException: if `len(subtemplates) != len(plaquettes_at_timestep)`.
+    """
+    if len(subtemplates) != len(plaquettes_by_timestep):
+        raise TQECException("You should provide as many subtemplates as timesteps.")
+
+    # Try to recover the result from the database.
+    if database is not None:
+        detectors = database.get_detectors(subtemplates, plaquettes_by_timestep)
+        # If not found, compute and store in database.
+        if detectors is None:
+            detectors = _compute_detectors_at_end_of_situation(
+                subtemplates, plaquettes_by_timestep, increments
+            )
+            database.add_situation(subtemplates, plaquettes_by_timestep, detectors)
+    # If database is None, just compute the detectors.
+    else:
+        detectors = _compute_detectors_at_end_of_situation(
+            subtemplates, plaquettes_by_timestep, increments
+        )
+
+    # `subtemplate.shape` should be `(2 * radius + 1, 2 * radius + 1)` so we can
+    # recover the radius with the below expression.
+    radius = subtemplates[0].shape[0] // 2
+
+    # We have a coordinate system change to apply to `detectors`.
+    # `detectors` is using a coordinate system with the origin at the
+    # top-left corner of the current sub-template, but we need to return
+    # detectors that use the central plaquette origin as their coordinate system
+    # origin.
+    shift_x, shift_y = -radius * increments.x, -radius * increments.y
+
+    return frozenset(d.offset_spatially_by(shift_x, shift_y) for d in detectors)
+
+
+def compute_detectors_for_constant_template_and_fixed_radius(
+    template: Template,
+    k: int,
+    plaquettes_at_timestep: tuple[Plaquettes] | tuple[Plaquettes, Plaquettes],
+    fixed_subtemplate_radius: int = 2,
+    database: DetectorDatabase | None = None,
+) -> frozenset[Detector]:
+    """Returns detectors that should be added at the end of the circuit that would
+    be obtained from the provided `template` and `plaquettes_at_timestep`.
+
+    Args:
+        template: constant Template that describes accurately the plaquette
+            arrangement on all the considered timesteps.
+        k: scaling factor to consider in order to instantiate the provided
+            template.
+        plaquettes_at_timestep: a tuple containing either one or two collection(s)
+            of plaquettes each representing one QEC round.
+        fixed_subtemplate_radius: Manhattan radius to consider when splitting the
+            provided `template` into sub-templates. Should be large enough so
+            that flows cancelling each other to form a detector are strictly
+            contained in the sub-template and cannot escape from it (which is
+            mostly equivalent to say that flows should not interact with qubits
+            on the border of the sub-templates).
+        database: existing database of detectors that is used to avoid computing
+            detectors if the database already contains them. If provided, this
+            function guarantees that the database will contain the provided
+            situation when returning (i.e., either it already contained the
+            situation or it has been updated **in-place** with the computed
+            detectors). Default to `None` which result in not using any kind of
+            database and unconditionally performing the detector computation.
+
+    Returns:
+        a collection of detectors that should be added at the end of the circuit
+        that would be obtained from the provided `template` and
+        `plaquettes_at_timestep`.
+    """
+    unique_subtemplates = template.get_spatially_distinct_subtemplates(
+        k, fixed_subtemplate_radius, avoid_zero_plaquettes=True
+    )
+    increments = template.get_increments()
+    # Each detector in detectors_by_subtemplate is using a coordinate system
+    # centered on the central plaquette origin.
+    detectors_by_subtemplate: dict[int, frozenset[Detector]] = {
+        i: compute_detectors_at_end_of_situation(
+            (subtemplate,)
+            if len(plaquettes_at_timestep) == 1
+            else (subtemplate, subtemplate),
+            plaquettes_at_timestep,
+            increments,
+            database,
+        )
+        for i, subtemplate in unique_subtemplates.subtemplates.items()
+    }
+
+    detectors_by_measurements: dict[frozenset[Measurement], Detector] = dict()
+    for i, row in enumerate(unique_subtemplates.subtemplate_indices):
+        for j, subtemplate_index in enumerate(row):
+            if subtemplate_index == 0:
+                continue
+            plaquette_origin = Position2D(j * increments.x, i * increments.y)
+            for d in detectors_by_subtemplate[subtemplate_index]:
+                offset_measurements = frozenset(
+                    m.offset_spatially_by(plaquette_origin.x, plaquette_origin.y)
+                    for m in d.measurements
+                )
+                # We use the convention of `cirq.GridQubit` where the first coordinate
+                # is the column and the second coordinate is the row. Note that the
+                # coordinates of detectors are derived from the qubits, which are
+                # already in the `GridQubit` convention. So we only need to swap the
+                # coordinates of the plaquette origin.
+                coordinates = (
+                    d.coordinates[0] + plaquette_origin.y,
+                    d.coordinates[1] + plaquette_origin.x,
+                    *d.coordinates[2:],
+                )
+                detectors_by_measurements[offset_measurements] = Detector(
+                    offset_measurements, coordinates
+                )
+    return frozenset(detectors_by_measurements.values())
diff --git a/src/tqec/compile/detectors/database.py b/src/tqec/compile/detectors/database.py
new file mode 100644
index 00000000..8781f5a1
--- /dev/null
+++ b/src/tqec/compile/detectors/database.py
@@ -0,0 +1,233 @@
+from dataclasses import dataclass, field
+from typing import Sequence
+
+import numpy
+import numpy.typing as npt
+
+from tqec.circuit.generation import generate_circuit_from_instantiation
+from tqec.circuit.measurement_map import MeasurementRecordsMap
+from tqec.circuit.moment import Moment
+from tqec.circuit.schedule import (
+    Schedule,
+    ScheduledCircuit,
+    relabel_circuits_qubit_indices,
+)
+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.templates.subtemplates import SubTemplateType
+
+
+def _NUMPY_ARRAY_HASHER(arr: npt.NDArray[numpy.int_]) -> int:
+    return hash(arr.data.tobytes())
+
+
+@dataclass(frozen=True)
+class DetectorDatabaseKey:
+    """Immutable type used as a key in the database of detectors.
+
+    This class represents a "situation" for which we might be able to compute
+    several detectors. Its purpose of existence is to provide sensible
+    `__hash__` and `__eq__` operations in order to be able to use a "situation"
+    as a `dict` key.
+
+    Attributes:
+        subtemplates: a sequence of 2-dimensional arrays of integers representing
+            the sub-template(s). Each entry corresponds to one QEC round.
+        plaquettes_by_timestep: a list of :class:`Plaquettes`, each
+            :class:`Plaquettes` entry storing enough :class:`Plaquette`
+            instances to generate a circuit from corresponding entry in
+            `self.subtemplates` and corresponding to one QEC round.
+
+    ## Implementation details
+
+    This class stores data types that are not efficiently hashable (i.e., not in
+    constant time) when considering their values:
+
+    - `self.subtemplates` is a raw array of integers without any guarantee on the
+      stored values except that they are positive.
+    - `self.plaquettes_by_timestep` contains :class:`Plaquette` instances, each
+      containing a class:`ScheduledCircuit` instance, ultimately containing a
+      `stim.Circuit`. Hashing a quantum circuit cannot be performed in constant
+      time.
+
+    For `self.subtemplates`, we hash the `shape` of the array as well as the
+    hash of the array's data. This is a constant time operation, because
+    we only consider spatially local detectors at the moment and that
+    restriction makes sub-templates that are of constant size (w.r.t the number
+    of qubits).
+
+    For `self.plaquettes_by_timestep`, we rely on the hash implementation of
+    :class:`Plaquettes`. It is up to :class:`Plaquettes` to implement hash
+    efficiently.
+    """
+
+    subtemplates: Sequence[SubTemplateType]
+    plaquettes_by_timestep: Sequence[Plaquettes]
+
+    def __post_init__(self) -> None:
+        if len(self.subtemplates) != len(self.plaquettes_by_timestep):
+            raise TQECException(
+                "DetectorDatabaseKey can only store an equal number of "
+                f"subtemplates and plaquettes. Got {len(self.subtemplates)} "
+                f"subtemplates and {len(self.plaquettes_by_timestep)} plaquettes."
+            )
+
+    @property
+    def num_timeslices(self) -> int:
+        return len(self.subtemplates)
+
+    def __hash__(self) -> int:
+        return hash(
+            (
+                tuple(st.shape for st in self.subtemplates),
+                tuple(_NUMPY_ARRAY_HASHER(st) for st in self.subtemplates),
+                tuple(self.plaquettes_by_timestep),
+            )
+        )
+
+    def __eq__(self, rhs: object) -> bool:
+        return (
+            isinstance(rhs, DetectorDatabaseKey)
+            and len(self.subtemplates) == len(rhs.subtemplates)
+            and all(
+                bool(numpy.all(self_st == rhs_st))
+                for self_st, rhs_st in zip(self.subtemplates, rhs.subtemplates)
+            )
+            and self.plaquettes_by_timestep == rhs.plaquettes_by_timestep
+        )
+
+    def circuit(self, plaquette_increments: Displacement) -> ScheduledCircuit:
+        """Get the `stim.Circuit` instance represented by `self`.
+
+        Args:
+            plaquette_increments: displacement between each plaquette origin.
+
+        Returns:
+            `stim.Circuit` instance represented by `self`.
+        """
+        circuits, qubit_map = relabel_circuits_qubit_indices(
+            [
+                generate_circuit_from_instantiation(
+                    subtemplate, plaquettes, plaquette_increments
+                )
+                for subtemplate, plaquettes in zip(
+                    self.subtemplates, self.plaquettes_by_timestep
+                )
+            ]
+        )
+        moments: list[Moment] = list(circuits[0].moments)
+        schedule: Schedule = circuits[0].schedule
+        for circuit in circuits[1:]:
+            moments.extend(circuit.moments)
+            schedule.append_schedule(circuit.schedule)
+        return ScheduledCircuit(moments, schedule, qubit_map)
+
+
+@dataclass
+class DetectorDatabase:
+    """Store a mapping from "situations" to the corresponding detectors.
+
+    This class aims at storing efficiently a set of "situations" in which the
+    corresponding detectors are known and do not have to be re-computed.
+
+    In this class, a "situation" is described by :class:`DetectorDatabaseKey`
+    and correspond to a spatially and temporally local piece of a larger
+    computation.
+    """
+
+    mapping: dict[DetectorDatabaseKey, frozenset[Detector]] = field(
+        default_factory=dict
+    )
+    frozen: bool = False
+
+    def add_situation(
+        self,
+        subtemplates: Sequence[SubTemplateType],
+        plaquettes_by_timestep: Sequence[Plaquettes],
+        detectors: frozenset[Detector] | Detector,
+    ) -> None:
+        """Add a new situation to the database.
+
+        Args:
+            subtemplate: a sequence of 2-dimensional arrays of integers
+                representing the sub-template(s). Each entry corresponds to one
+                QEC round.
+            plaquettes_by_timestep: a list of :class:`Plaquettes`, each
+                :class:`Plaquettes` entry storing enough :class:`Plaquette`
+                instances to generate a circuit from corresponding entry in
+                `self.subtemplates` and corresponding to one QEC round.
+            detectors: computed detectors that should be stored in the database.
+                The coordinates used by the :class:`Measurement` instances stored
+                in each entry should be relative to the top-left qubit of the
+                top-left plaquette in the provided `subtemplates`.
+
+        Raises:
+            TQECException: if this method is called and `self.frozen`.
+        """
+        if self.frozen:
+            raise TQECException("Cannot add a situation to a frozen database.")
+        key = DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
+        self.mapping[key] = (
+            frozenset([detectors]) if isinstance(detectors, Detector) else detectors
+        )
+
+    def remove_situation(
+        self,
+        subtemplates: Sequence[SubTemplateType],
+        plaquettes_by_timestep: Sequence[Plaquettes],
+    ) -> None:
+        """Remove an existing situation from the database.
+
+        Args:
+            subtemplate: a sequence of 2-dimensional arrays of integers
+                representing the sub-template(s). Each entry corresponds to one
+                QEC round.
+            plaquettes_by_timestep: a list of :class:`Plaquettes`, each
+                :class:`Plaquettes` entry storing enough :class:`Plaquette`
+                instances to generate a circuit from corresponding entry in
+                `self.subtemplates` and corresponding to one QEC round.
+
+        Raises:
+            TQECException: if this method is called and `self.frozen`.
+        """
+        if self.frozen:
+            raise TQECException("Cannot remove a situation to a frozen database.")
+        key = DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
+        del self.mapping[key]
+
+    def get_detectors(
+        self,
+        subtemplates: Sequence[SubTemplateType],
+        plaquettes_by_timestep: Sequence[Plaquettes],
+    ) -> frozenset[Detector] | None:
+        """Return the detectors associated with the provided situation or `None`
+        if the situation is not in the database.
+
+        Args:
+            subtemplate: a sequence of 2-dimensional arrays of integers
+                representing the sub-template(s). Each entry corresponds to one
+                QEC round.
+            plaquettes_by_timestep: a list of :class:`Plaquettes`, each
+                :class:`Plaquettes` entry storing enough :class:`Plaquette`
+                instances to generate a circuit from corresponding entry in
+                `self.subtemplates` and corresponding to one QEC round.
+            detectors: computed detectors that should be stored in the database.
+
+        Returns:
+            detectors associated with the provided situation or `None` if the
+            situation is not in the database.
+        """
+        key = DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
+        return self.mapping.get(key)
+
+    def to_crumble_urls(self, plaquette_increments: Displacement) -> list[str]:
+        urls: list[str] = []
+        for key, detectors in self.mapping.items():
+            circuit = key.circuit(plaquette_increments)
+            rec_map = MeasurementRecordsMap.from_scheduled_circuit(circuit)
+            for detector in detectors:
+                circuit.append_annotation(detector.to_instruction(rec_map))
+            urls.append(circuit.get_circuit().to_crumble_url())
+        return urls
diff --git a/src/tqec/compile/detectors/detector.py b/src/tqec/compile/detectors/detector.py
new file mode 100644
index 00000000..8714018d
--- /dev/null
+++ b/src/tqec/compile/detectors/detector.py
@@ -0,0 +1,100 @@
+"""Defines :class:`Detector` to represent detectors."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy
+import stim
+
+from tqec.circuit.measurement import Measurement
+from tqec.circuit.measurement_map import MeasurementRecordsMap
+from tqec.exceptions import TQECException
+
+
+@dataclass(frozen=True)
+class Detector:
+    """Represent a detector as a set of measurements and optional coordinates."""
+
+    measurements: frozenset[Measurement]
+    coordinates: tuple[float, ...]
+
+    def __post_init__(self) -> None:
+        if not self.measurements:
+            raise TQECException("Trying to create a detector without any measurement.")
+        if len(self.coordinates) < 2:
+            raise TQECException(
+                "Coordinates should at least provide 2 spatial dimensions. "
+                f"The provided  coordinates {self.coordinates} only contain "
+                f"{len(self.coordinates)} dimensions."
+            )
+
+    def __hash__(self) -> int:
+        return hash(self.measurements)
+
+    def __eq__(self, rhs: object) -> bool:
+        return (
+            isinstance(rhs, Detector)
+            and self.measurements == rhs.measurements
+            and numpy.allclose(self.coordinates, rhs.coordinates)
+        )
+
+    def __str__(self) -> str:
+        coordinates_str = "(" + ",".join(f"{c:.2f}" for c in self.coordinates) + ")"
+        measurements_str = "{" + ",".join(map(str, self.measurements)) + "}"
+        return f"D{coordinates_str}{measurements_str}"
+
+    def to_instruction(
+        self, measurement_records_map: MeasurementRecordsMap
+    ) -> stim.CircuitInstruction:
+        """Return the `stim.CircuitInstruction` instance representing the
+        detector stored in `self`.
+
+        Args:
+            measurement_records_map: a map from qubits and qubit-local
+                measurement offsets to global measurement offsets.
+
+        Raises:
+            TQECException: if any of the measurements stored in `self` is
+                performed on a qubit that is not in the provided
+                `measurement_records_map`.
+            KeyError: if any of the qubit-local measurement offsets stored in
+                `self` is not present in the provided `measurement_records_map`.
+
+        Returns:
+            the `DETECTOR` instruction representing `self`. Note that the
+            instruction has the same validity region as the provided
+            `measurement_records_map`.
+        """
+        measurement_records: list[stim.GateTarget] = []
+        for measurement in self.measurements:
+            if measurement.qubit not in measurement_records_map:
+                raise TQECException(
+                    f"Trying to get measurement record for {measurement.qubit} "
+                    "but qubit is not in the measurement record map."
+                )
+            measurement_records.append(
+                stim.target_rec(
+                    measurement_records_map[measurement.qubit][measurement.offset]
+                )
+            )
+        return stim.CircuitInstruction(
+            "DETECTOR", measurement_records, self.coordinates
+        )
+
+    def offset_spatially_by(self, x: int, y: int) -> Detector:
+        """Offset the coordinates and all the qubits involved in `self`.
+
+        Args:
+            x: offset in the first spatial dimension.
+            y: offset in the second spatial dimension.
+
+        Returns:
+            a new detector that has been spatially offset by the provided `x`
+            and `y` offsets.
+        """
+
+        return Detector(
+            frozenset(m.offset_spatially_by(x, y) for m in self.measurements),
+            (self.coordinates[0] + x, self.coordinates[1] + y, *self.coordinates[2:]),
+        )
diff --git a/src/tqec/compile/observables.py b/src/tqec/compile/observables.py
index 10e04fcf..361f2f1f 100644
--- a/src/tqec/compile/observables.py
+++ b/src/tqec/compile/observables.py
@@ -1,5 +1,6 @@
 import stim
 
+from tqec.circuit.measurement_map import MeasurementRecordsMap
 from tqec.circuit.qubit import GridQubit
 from tqec.circuit.schedule import ScheduledCircuit
 from tqec.computation.block_graph import AbstractObservable, Cube, Pipe
@@ -146,7 +147,7 @@ def inplace_add_observables(
             stabilizer_qubits = get_stabilizer_region_qubits_for_pipe(
                 pipe, template.element_shape(k), template.get_increments()
             )
-            measurement_records = _get_measurement_records_from_scheduled_circuit(
+            measurement_records = MeasurementRecordsMap.from_scheduled_circuit(
                 circuits[z][0]
             )
             circuits[z][0].append_observable(
@@ -175,61 +176,10 @@ def inplace_add_observables(
                         template.get_increments(),
                     )
                 ]
-            measurement_records = _get_measurement_records_from_scheduled_circuit(
+            measurement_records = MeasurementRecordsMap.from_scheduled_circuit(
                 circuits[z][-1]
             )
             circuits[z][-1].append_observable(
                 i,
                 [stim.target_rec(measurement_records[q][-1]) for q in qubits],
             )
-
-
-_SUPPORTED_MEASUREMENT_GATE_NAMES: frozenset[str] = frozenset(
-    [
-        # Collapsing Gates
-        "M",
-        "MR",
-        "MRX",
-        "MRY",
-        "MRZ",
-        "MX",
-        "MY",
-        "MZ",
-    ]
-)
-_NOT_SUPPORTED_MEASUREMENT_GATE_NAMES: frozenset[str] = frozenset(
-    [
-        # Pair Measurement Gates
-        "MXX",
-        "MYY",
-        "MZZ",
-        # Generalized Pauli Product Gates
-        "MPP",
-    ]
-)
-
-
-def _get_measurement_records_from_scheduled_circuit(
-    circuit: ScheduledCircuit,
-) -> dict[GridQubit, list[int]]:
-    # Get the global qubit map
-    i2q: dict[int, GridQubit] = {i: q for q, i in circuit.q2i.items()}
-    # Build the local measurement map.
-    current_measurement_record = -circuit.num_measurements
-    measurement_records: dict[GridQubit, list[int]] = {}
-    for moment in circuit.moments:
-        for instruction in moment.instructions:
-            assert not isinstance(instruction, stim.CircuitRepeatBlock)
-            if instruction.name in _NOT_SUPPORTED_MEASUREMENT_GATE_NAMES:
-                raise TQECException(
-                    f"Found a non-supported measurement instruction: {instruction}"
-                )
-            if instruction.name in _SUPPORTED_MEASUREMENT_GATE_NAMES:
-                for (qi,) in instruction.target_groups():
-                    qubit = i2q[qi.value]
-                    measurement_records.setdefault(qubit, []).append(
-                        current_measurement_record
-                    )
-                    current_measurement_record += 1
-    assert current_measurement_record == 0, "Sanity check"
-    return measurement_records
diff --git a/src/tqec/compile/specs/library/_utils.py b/src/tqec/compile/specs/library/_utils.py
index 5a824972..535d89a7 100644
--- a/src/tqec/compile/specs/library/_utils.py
+++ b/src/tqec/compile/specs/library/_utils.py
@@ -1,10 +1,10 @@
-from collections import defaultdict
 from functools import partial
 from typing import Literal, cast
 
 from tqec.compile.block import CompiledBlock
 from tqec.compile.specs.base import CubeSpec
 from tqec.plaquette.enums import MeasurementBasis, PlaquetteOrientation, ResetBasis
+from tqec.plaquette.frozendefaultdict import FrozenDefaultDict
 from tqec.plaquette.library.empty import empty_square_plaquette
 from tqec.plaquette.plaquette import PlaquetteBuilder, Plaquettes, RepeatedPlaquettes
 from tqec.scale import LinearFunction
@@ -30,48 +30,60 @@ def regular_block(
     b2 = cast(Literal["X", "Z"], b2)
 
     initial_plaquettes = Plaquettes(
-        defaultdict(empty_square_plaquette)
-        | {
-            6: factory(b1, reset_basis).project_on_boundary(PlaquetteOrientation.UP),
-            7: factory(b2, reset_basis).project_on_boundary(PlaquetteOrientation.LEFT),
-            9: factory(b1, reset_basis),
-            10: factory(b2, reset_basis),
-            12: factory(b2, reset_basis).project_on_boundary(
-                PlaquetteOrientation.RIGHT
-            ),
-            13: factory(b1, reset_basis).project_on_boundary(PlaquetteOrientation.DOWN),
-        }
+        FrozenDefaultDict(
+            {
+                6: factory(b1, reset_basis).project_on_boundary(
+                    PlaquetteOrientation.UP
+                ),
+                7: factory(b2, reset_basis).project_on_boundary(
+                    PlaquetteOrientation.LEFT
+                ),
+                9: factory(b1, reset_basis),
+                10: factory(b2, reset_basis),
+                12: factory(b2, reset_basis).project_on_boundary(
+                    PlaquetteOrientation.RIGHT
+                ),
+                13: factory(b1, reset_basis).project_on_boundary(
+                    PlaquetteOrientation.DOWN
+                ),
+            },
+            default_factory=empty_square_plaquette,
+        )
     )
     repeating_plaquettes = RepeatedPlaquettes(
-        defaultdict(empty_square_plaquette)
-        | {
-            6: factory(b1).project_on_boundary(PlaquetteOrientation.UP),
-            7: factory(b2).project_on_boundary(PlaquetteOrientation.LEFT),
-            9: factory(b1),
-            10: factory(b2),
-            12: factory(b2).project_on_boundary(PlaquetteOrientation.RIGHT),
-            13: factory(b1).project_on_boundary(PlaquetteOrientation.DOWN),
-        },
+        FrozenDefaultDict(
+            {
+                6: factory(b1).project_on_boundary(PlaquetteOrientation.UP),
+                7: factory(b2).project_on_boundary(PlaquetteOrientation.LEFT),
+                9: factory(b1),
+                10: factory(b2),
+                12: factory(b2).project_on_boundary(PlaquetteOrientation.RIGHT),
+                13: factory(b1).project_on_boundary(PlaquetteOrientation.DOWN),
+            },
+            default_factory=empty_square_plaquette,
+        ),
         number_of_repetitions,
     )
     final_plaquettes = Plaquettes(
-        defaultdict(empty_square_plaquette)
-        | {
-            6: factory(b1, None, measurement_basis).project_on_boundary(
-                PlaquetteOrientation.UP
-            ),
-            7: factory(b2, None, measurement_basis).project_on_boundary(
-                PlaquetteOrientation.LEFT
-            ),
-            9: factory(b1, None, measurement_basis),
-            10: factory(b2, None, measurement_basis),
-            12: factory(b2, None, measurement_basis).project_on_boundary(
-                PlaquetteOrientation.RIGHT
-            ),
-            13: factory(b1, None, measurement_basis).project_on_boundary(
-                PlaquetteOrientation.DOWN
-            ),
-        }
+        FrozenDefaultDict(
+            {
+                6: factory(b1, None, measurement_basis).project_on_boundary(
+                    PlaquetteOrientation.UP
+                ),
+                7: factory(b2, None, measurement_basis).project_on_boundary(
+                    PlaquetteOrientation.LEFT
+                ),
+                9: factory(b1, None, measurement_basis),
+                10: factory(b2, None, measurement_basis),
+                12: factory(b2, None, measurement_basis).project_on_boundary(
+                    PlaquetteOrientation.RIGHT
+                ),
+                13: factory(b1, None, measurement_basis).project_on_boundary(
+                    PlaquetteOrientation.DOWN
+                ),
+            },
+            default_factory=empty_square_plaquette,
+        )
     )
     return CompiledBlock(
         template=QubitTemplate(),
diff --git a/src/tqec/compile/substitute.py b/src/tqec/compile/substitute.py
index 70dc63fe..8f8ef7e3 100644
--- a/src/tqec/compile/substitute.py
+++ b/src/tqec/compile/substitute.py
@@ -175,7 +175,7 @@ def _inplace_add_operation_on_data_qubits(
     qubits = plaquette.qubits.get_qubits_on_side(side)
     # Do not add resets on qubits that have already be reset
     moment = plaquette.circuit.moment_at_schedule(moment_schedule)
-    q2i = plaquette.circuit.q2i
+    q2i = plaquette.circuit.qubit_map.q2i
 
     operations_to_add = stim.Circuit()
     for q in qubits:
diff --git a/src/tqec/plaquette/frozendefaultdict.py b/src/tqec/plaquette/frozendefaultdict.py
new file mode 100644
index 00000000..1c4b9afd
--- /dev/null
+++ b/src/tqec/plaquette/frozendefaultdict.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+import operator
+from collections.abc import Mapping
+from copy import deepcopy
+from typing import Callable, Generic, Iterable, Iterator, TypeVar
+
+from typing_extensions import override
+
+from tqec.exceptions import TQECException
+
+K = TypeVar("K")
+V = TypeVar("V")
+
+
+class FrozenException(TQECException):
+    pass
+
+
+class FrozenDefaultDict(Generic[K, V], Mapping[K, V]):
+    """A defaultdict implementation that cannot be mutated.
+
+    This class re-defines all the mutating methods of `defaultdict` (i.e., all
+    the mutating methods of `dict` and `__missing__`) in order to make any
+    instance immutable.
+
+    Note on re-defining `__missing__`:
+
+    The standard `defaultdict` implementation is entirely based on the
+    `__missing__` method (described here
+    https://docs.python.org/3/library/collections.html#collections.defaultdict.__missing__)
+    that is called when a user-provided key was not found in the defined keys.
+    This `__missing__` method try to use `self.default_factory` to create a new
+    value and inserts that new value in the dictionary. That last part is
+    problematic for :class:`Plaquettes` and in particular to compare collections
+    of :class:`Plaquettes` through `__hash__` and `__eq__`.
+    """
+
+    def __init__(
+        self,
+        arg: Mapping[K, V] | Iterable[tuple[K, V]] | None = None,
+        *,
+        default_factory: Callable[[], V] | None = None,
+    ) -> None:
+        super().__init__()
+        self._dict: dict[K, V] = dict(arg) if arg is not None else dict()
+        self._default_factory = default_factory
+
+    def __missing__(self, key: K) -> V:
+        if self._default_factory is None:
+            raise KeyError(key)
+        return self._default_factory()
+
+    @override
+    def __getitem__(self, key: K) -> V:
+        try:
+            return self._dict[key]
+        except KeyError:
+            return self.__missing__(key)
+
+    @override
+    def __iter__(self) -> Iterator[K]:
+        return iter(self._dict)
+
+    @override
+    def __len__(self) -> int:
+        return len(self._dict)
+
+    @override
+    def __contains__(self, key: object) -> bool:
+        return self._dict.__contains__(key)
+
+    def __or__(self, other: Mapping[K, V]) -> FrozenDefaultDict[K, V]:
+        mapping = deepcopy(self._dict)
+        mapping.update(other)
+        return FrozenDefaultDict(mapping, default_factory=self._default_factory)
+
+    def __hash__(self) -> int:
+        return hash(tuple(sorted(self.items())))
+
+    def __eq__(self, other: object) -> bool:
+        return (
+            isinstance(other, FrozenDefaultDict)
+            and (
+                not operator.xor(
+                    self._default_factory is None, other._default_factory is None
+                )
+            )
+            and (
+                self._default_factory is not None
+                and other._default_factory is not None
+                and (self._default_factory() == other._default_factory())
+            )
+            and self._dict == other._dict
+        )
+
+    def has_default_factory(self) -> bool:
+        return self._default_factory is not None
diff --git a/src/tqec/plaquette/library/css.py b/src/tqec/plaquette/library/css.py
index 7e9e3e57..a7cdaedf 100644
--- a/src/tqec/plaquette/library/css.py
+++ b/src/tqec/plaquette/library/css.py
@@ -66,6 +66,6 @@ def make_css_surface_code_plaquette(
 
     return Plaquette(
         qubits,
-        ScheduledCircuit.from_circuit(circuit, i2q=i2q),
-        mergeable_instructions={"M", "MZ", "MX", "R", "RZ", "RX"},
+        ScheduledCircuit.from_circuit(circuit, qubit_map=i2q),
+        mergeable_instructions=frozenset(("M", "MZ", "MX", "R", "RZ", "RX")),
     )
diff --git a/src/tqec/plaquette/library/zxxz.py b/src/tqec/plaquette/library/zxxz.py
index 5339814e..c751f696 100644
--- a/src/tqec/plaquette/library/zxxz.py
+++ b/src/tqec/plaquette/library/zxxz.py
@@ -98,6 +98,6 @@ H {sq}
 
     return Plaquette(
         qubits,
-        ScheduledCircuit.from_circuit(circuit, i2q=i2q),
-        mergeable_instructions={"H", "R", "RZ", "M", "MZ"},
+        ScheduledCircuit.from_circuit(circuit, qubit_map=i2q),
+        mergeable_instructions=frozenset(("H", "R", "RZ", "M", "MZ")),
     )
diff --git a/src/tqec/plaquette/plaquette.py b/src/tqec/plaquette/plaquette.py
index 22cddb5c..4be7c80f 100644
--- a/src/tqec/plaquette/plaquette.py
+++ b/src/tqec/plaquette/plaquette.py
@@ -1,77 +1,63 @@
 from __future__ import annotations
 
-import itertools
 from collections import defaultdict
-from dataclasses import dataclass
-from typing import Iterable, Iterator, Literal, Protocol
+from dataclasses import dataclass, field
+from typing import Literal, Mapping, Protocol
 
 from typing_extensions import override
 
 from tqec.circuit.schedule import ScheduledCircuit
 from tqec.exceptions import TQECException
 from tqec.plaquette.enums import MeasurementBasis, PlaquetteOrientation, ResetBasis
+from tqec.plaquette.frozendefaultdict import FrozenDefaultDict
 from tqec.plaquette.qubit import PlaquetteQubits
 from tqec.position import Position2D
 from tqec.scale import LinearFunction, round_or_fail
 
 
+@dataclass(frozen=True)
 class Plaquette:
-    def __init__(
-        self,
-        qubits: PlaquetteQubits,
-        circuit: ScheduledCircuit,
-        mergeable_instructions: Iterable[str] = (),
-    ) -> None:
-        """Represents a QEC plaquette.
-
-        This class stores qubits in the plaquette local coordinate system and a
-        scheduled circuit that should be applied on those qubits to perform the
-        QEC experiment.
+    """Represents a QEC plaquette.
+
+    This class stores qubits in the plaquette local coordinate system and a
+    scheduled circuit that should be applied on those qubits to perform the
+    QEC experiment.
+
+    By convention, the local plaquette coordinate system is composed of a
+    X-axis pointing to the right and a Y-axis pointing down.
+
+    Attributes:
+        qubits: qubits used by the plaquette circuit, given in the local
+            plaquette coordinate system.
+        circuit: scheduled quantum circuit implementing the computation that
+            the plaquette should represent.
+        mergeable_instructions: a set of instructions that can
+            be merged. This is useful when merging several plaquettes'
+            circuits together to remove duplicate instructions.
+
+    Raises:
+        TQECException: if the provided `circuit` uses qubits not listed in
+            `qubits`.
+    """
 
-        By convention, the local plaquette coordinate system is composed of a
-        X-axis pointing to the right and a Y-axis pointing down.
+    qubits: PlaquetteQubits
+    circuit: ScheduledCircuit
+    mergeable_instructions: frozenset[str] = field(default_factory=frozenset)
 
-        Args:
-            qubits: qubits used by the plaquette circuit, given in the local
-                plaquette coordinate system.
-            circuit: scheduled quantum circuit implementing the computation that
-                the plaquette should represent.
-            mergeable_instructions: a set of instructions that can
-                be merged. This is useful when merging several plaquettes'
-                circuits together to remove duplicate instructions.
-
-        Raises:
-            TQECException: if the provided circuit uses qubits not listed in
-                `qubits`.
-        """
-        plaquette_qubits = set(qubits)
-        circuit_qubits = set(circuit.qubits)
+    def __post_init__(self) -> None:
+        plaquette_qubits = set(self.qubits)
+        circuit_qubits = set(self.circuit.qubits)
         if not circuit_qubits.issubset(plaquette_qubits):
             wrong_qubits = circuit_qubits.difference(plaquette_qubits)
             raise TQECException(
                 f"The following qubits ({wrong_qubits}) are in the provided circuit "
                 "but not in the provided list of qubits."
             )
-        self._qubits = qubits
-        self._circuit = circuit
-        self._mergeable_instructions = frozenset(mergeable_instructions)
 
     @property
     def origin(self) -> Position2D:
         return Position2D(0, 0)
 
-    @property
-    def qubits(self) -> PlaquetteQubits:
-        return self._qubits
-
-    @property
-    def circuit(self) -> ScheduledCircuit:
-        return self._circuit
-
-    @property
-    def mergeable_instructions(self) -> frozenset[str]:
-        return self._mergeable_instructions
-
     def project_on_boundary(
         self, plaquette_orientation: PlaquetteOrientation
     ) -> Plaquette:
@@ -105,8 +91,15 @@ class Plaquette:
             new_plaquette_qubits, new_scheduled_circuit, self.mergeable_instructions
         )
 
+    def __eq__(self, rhs: object) -> bool:
+        return (
+            isinstance(rhs, Plaquette)
+            and self.qubits == rhs.qubits
+            and self.circuit == rhs.circuit
+        )
 
-CollectionType = dict[int, Plaquette] | defaultdict[int, Plaquette]
+    def __hash__(self) -> int:
+        return hash((self.qubits, str(self.circuit.get_circuit())))
 
 
 @dataclass(frozen=True)
@@ -123,14 +116,9 @@ class Plaquettes:
     and conventionally reserved for the empty plaquette).
     """
 
-    collection: CollectionType
+    collection: FrozenDefaultDict[int, Plaquette]
 
     def __post_init__(self) -> None:
-        if not isinstance(self.collection, (dict, defaultdict)):
-            raise TQECException(
-                f"Plaquettes initialized with {type(self.collection)} but only"
-                "dict and defaultdict instances are allowed."
-            )
         if 0 in self.collection:
             raise TQECException(
                 "Found a Plaquette with index 0. This index is reserved to express "
@@ -140,36 +128,24 @@ class Plaquettes:
     def __getitem__(self, index: int) -> Plaquette:
         return self.collection[index]
 
-    def __iter__(self) -> Iterator[Plaquette]:
-        if (
-            isinstance(self.collection, defaultdict)
-            and self.collection.default_factory is not None
-        ):
-            default = self.collection.default_factory()
-            return itertools.chain(
-                self.collection.values(), [default] if default is not None else []
-            )
-        return iter(self.collection.values())
-
     @property
     def has_default(self) -> bool:
         return isinstance(self.collection, defaultdict)
 
-    def __len__(self) -> int:
-        if isinstance(self.collection, defaultdict):
-            raise TQECException(
-                "Cannot accurately get the length of a defaultdict instance."
-            )
-        return len(self.collection)
-
     def repeat(self, repetitions: LinearFunction) -> RepeatedPlaquettes:
         return RepeatedPlaquettes(self.collection, repetitions)
 
     def with_updated_plaquettes(
-        self, plaquettes_to_update: dict[int, Plaquette]
+        self, plaquettes_to_update: Mapping[int, Plaquette]
     ) -> Plaquettes:
         return Plaquettes(self.collection | plaquettes_to_update)
 
+    def __eq__(self, rhs: object) -> bool:
+        return isinstance(rhs, Plaquettes) and self.collection == rhs.collection
+
+    def __hash__(self) -> int:
+        return hash(self.collection)
+
 
 @dataclass(frozen=True)
 class RepeatedPlaquettes(Plaquettes):
@@ -182,13 +158,23 @@ class RepeatedPlaquettes(Plaquettes):
 
     @override
     def with_updated_plaquettes(
-        self, plaquettes_to_update: dict[int, Plaquette]
+        self, plaquettes_to_update: Mapping[int, Plaquette]
     ) -> RepeatedPlaquettes:
         return RepeatedPlaquettes(
             self.collection | plaquettes_to_update,
             repetitions=self.repetitions,
         )
 
+    def __eq__(self, rhs: object) -> bool:
+        return (
+            isinstance(rhs, RepeatedPlaquettes)
+            and self.repetitions == rhs.repetitions
+            and self.collection == rhs.collection
+        )
+
+    def __hash__(self) -> int:
+        return hash((self.repetitions, super().__hash__()))
+
 
 class PlaquetteBuilder(Protocol):
     """Protocol for functions building a `Plaquette`."""
diff --git a/src/tqec/plaquette/qubit.py b/src/tqec/plaquette/qubit.py
index 6841d77b..754841d2 100644
--- a/src/tqec/plaquette/qubit.py
+++ b/src/tqec/plaquette/qubit.py
@@ -36,7 +36,7 @@ class PlaquetteQubits:
             The qubits on the edge of the plaquette.
         """
 
-        def _get_relevant_value(qubit: GridQubit) -> Fraction:
+        def _get_relevant_value(qubit: GridQubit) -> int:
             return qubit.y if orientation == TemplateOrientation.HORIZONTAL else qubit.x
 
         max_index = max(_get_relevant_value(q) for q in self.data_qubits)
@@ -72,6 +72,16 @@ class PlaquetteQubits:
             max_y = max(q.y for q in self)
             return [q for q in self if q.y == max_y]
 
+    def __hash__(self) -> int:
+        return hash((tuple(self.syndrome_qubits), tuple(self.data_qubits)))
+
+    def __eq__(self, rhs: object) -> bool:
+        return (
+            isinstance(rhs, PlaquetteQubits)
+            and self.data_qubits == rhs.data_qubits
+            and self.syndrome_qubits == rhs.syndrome_qubits
+        )
+
 
 class SquarePlaquetteQubits(PlaquetteQubits):
     def __init__(self) -> None:
diff --git a/src/tqec/templates/subtemplates.py b/src/tqec/templates/subtemplates.py
index 8aaa470b..2b5bae84 100644
--- a/src/tqec/templates/subtemplates.py
+++ b/src/tqec/templates/subtemplates.py
@@ -7,6 +7,8 @@ import numpy.typing as npt
 
 from tqec.exceptions import TQECException
 
+SubTemplateType = npt.NDArray[numpy.int_]
+
 
 @dataclass(frozen=True)
 class UniqueSubTemplates:
@@ -80,7 +82,7 @@ class UniqueSubTemplates:
     """
 
     subtemplate_indices: npt.NDArray[numpy.int_]
-    subtemplates: dict[int, npt.NDArray[numpy.int_]]
+    subtemplates: dict[int, SubTemplateType]
 
     def __post_init__(self) -> None:
         # We do not need a valid subtemplate for the 0 index.
@@ -192,7 +194,7 @@ def get_spatially_distinct_subtemplates(
         instantiation, manhattan_radius, "constant", constant_values=0
     )
 
-    all_possible_subarrays: list[npt.NDArray[numpy.int_]] = []
+    all_possible_subarrays: list[SubTemplateType] = []
     ignored_flattened_indices: list[int] = []
     considered_flattened_indices: list[int] = []
     for i in range(manhattan_radius, manhattan_radius + y):


Test patch:
diff --git a/src/tqec/circuit/generation_test.py b/src/tqec/circuit/generation_test.py
index 0133ada2..357889b8 100644
--- a/src/tqec/circuit/generation_test.py
+++ b/src/tqec/circuit/generation_test.py
@@ -4,8 +4,8 @@ import pytest
 import stim
 
 from tqec.circuit.generation import generate_circuit
-from tqec.exceptions import TQECException
 from tqec.plaquette.enums import PlaquetteOrientation
+from tqec.plaquette.frozendefaultdict import FrozenDefaultDict
 from tqec.plaquette.library import make_css_surface_code_plaquette
 from tqec.plaquette.plaquette import Plaquette, Plaquettes
 from tqec.templates._testing import FixedTemplate
@@ -41,35 +41,12 @@ def _expected_circuit() -> stim.Circuit:
 """)
 
 
-def test_generate_circuit_dict(
-    plaquette: Plaquette, one_by_one_template: Template
-) -> None:
-    circuit = generate_circuit(one_by_one_template, 2, Plaquettes({1: plaquette}))
-    assert circuit.get_circuit() == _expected_circuit()
-
-
 def test_generate_circuit_defaultdict(
     plaquette: Plaquette, one_by_one_template: Template
 ) -> None:
     circuit = generate_circuit(
-        one_by_one_template, 2, Plaquettes(defaultdict(lambda: plaquette))
+        one_by_one_template,
+        2,
+        Plaquettes(FrozenDefaultDict(default_factory=lambda: plaquette)),
     )
     assert circuit.get_circuit() == _expected_circuit()
-
-
-def test_generate_circuit_dict_0_indexed(
-    plaquette: Plaquette, one_by_one_template: Template
-) -> None:
-    with pytest.raises(TQECException):
-        generate_circuit(one_by_one_template, 2, Plaquettes({0: plaquette}))
-
-
-def test_generate_circuit_wrong_number_of_plaquettes(
-    plaquette: Plaquette, one_by_one_template: Template
-) -> None:
-    with pytest.raises(TQECException):
-        generate_circuit(
-            one_by_one_template, 2, Plaquettes({1: plaquette, 2: plaquette})
-        )
-    with pytest.raises(TQECException):
-        generate_circuit(one_by_one_template, 2, Plaquettes({}))
diff --git a/src/tqec/circuit/measurement_map_test.py b/src/tqec/circuit/measurement_map_test.py
new file mode 100644
index 00000000..41fc84f4
--- /dev/null
+++ b/src/tqec/circuit/measurement_map_test.py
@@ -0,0 +1,72 @@
+import re
+
+import pytest
+import stim
+
+from tqec.circuit.measurement_map import MeasurementRecordsMap
+from tqec.circuit.qubit import GridQubit
+from tqec.circuit.qubit_map import QubitMap
+from tqec.circuit.schedule import ScheduledCircuit
+from tqec.exceptions import TQECException
+
+
+def test_measurement_records_map_creation() -> None:
+    MeasurementRecordsMap()
+    MeasurementRecordsMap({})
+
+    q = GridQubit(0, 0)
+    MeasurementRecordsMap({q: []})
+    MeasurementRecordsMap({q: [-2, -1]})
+
+    with pytest.raises(
+        TQECException,
+        match="^Invalid mapping from qubit offsets to measurement record "
+        f"offsets. Found positive offsets \\({re.escape(str([0]))}\\) for qubit "
+        f"{re.escape(str(q))}.$",
+    ):
+        MeasurementRecordsMap({q: [-2, 0]})
+
+    with pytest.raises(
+        TQECException,
+        match="^Got measurement record offsets that are not in sorted order.*",
+    ):
+        MeasurementRecordsMap({q: [-1, -2]})
+
+    q2 = GridQubit(1, 1)
+    with pytest.raises(
+        TQECException,
+        match="^At least one measurement record offset has been found twice "
+        "in the provided offsets.$",
+    ):
+        MeasurementRecordsMap({q: [-1], q2: [-1]})
+
+
+def test_measurement_records_map_from_circuit() -> None:
+    qubit_map = QubitMap({i: GridQubit(i, i) for i in range(3)})
+    rec_map = MeasurementRecordsMap.from_circuit(stim.Circuit("M 0 2 1"), qubit_map)
+    assert rec_map[GridQubit(0, 0)][-1] == -3
+    assert rec_map[GridQubit(2, 2)][-1] == -2
+    assert rec_map[GridQubit(1, 1)][-1] == -1
+
+    rec_map = MeasurementRecordsMap.from_circuit(
+        qubit_map.to_circuit() + stim.Circuit("M 0 2 1")
+    )
+    assert rec_map[GridQubit(0, 0)][-1] == -3
+    assert rec_map[GridQubit(2, 2)][-1] == -2
+    assert rec_map[GridQubit(1, 1)][-1] == -1
+
+
+def test_measurement_records_map_from_scheduled_circuit() -> None:
+    qubit_map = QubitMap({i: GridQubit(i, i) for i in range(3)})
+    circuit = stim.Circuit("M 0 2 1\nTICK\nMX 0 1 2")
+    scheduled_circuit = ScheduledCircuit.from_circuit(
+        circuit, schedule=0, qubit_map=qubit_map
+    )
+
+    rec_map = MeasurementRecordsMap.from_scheduled_circuit(scheduled_circuit)
+    assert rec_map[GridQubit(0, 0)][-1] == -3
+    assert rec_map[GridQubit(0, 0)][-2] == -6
+    assert rec_map[GridQubit(2, 2)][-1] == -1
+    assert rec_map[GridQubit(2, 2)][-2] == -5
+    assert rec_map[GridQubit(1, 1)][-1] == -2
+    assert rec_map[GridQubit(1, 1)][-2] == -4
diff --git a/src/tqec/circuit/measurement_test.py b/src/tqec/circuit/measurement_test.py
index f69186f5..3145f56b 100644
--- a/src/tqec/circuit/measurement_test.py
+++ b/src/tqec/circuit/measurement_test.py
@@ -1,14 +1,19 @@
 import pytest
+import stim
 
-from tqec.circuit.measurement import Measurement, RepeatedMeasurement
+from tqec.circuit.measurement import (
+    Measurement,
+    RepeatedMeasurement,
+    get_measurements_from_circuit,
+)
 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.interval import Interval
 from tqec.position import Displacement
 
-_grid_qubits: list[GridQubit] = [
-    GridQubit(*init) for init in ((0, 0), (-1, -1), (0.5, 9), (-5.5, 10), (1.1, 18))
-]
+_grid_qubits: list[GridQubit] = [GridQubit(0, 0), GridQubit(-1, -1)]
 
 
 @pytest.mark.parametrize("qubit", _grid_qubits)
@@ -121,3 +126,21 @@ def test_repeated_measurement_measurements() -> None:
     assert RepeatedMeasurement(qubit, interval).measurements() == [
         Measurement(qubit, i) for i in range(-10, 0)
     ]
+
+
+def test_get_measurements_from_circuit() -> None:
+    circuit = ScheduledCircuit.from_circuit(
+        stim.Circuit("H 0\nM 1 2\nTICK\nMX 2 3"),
+        qubit_map=QubitMap({i: GridQubit(i, i) for i in range(4)}),
+    )
+    measurements = get_measurements_from_circuit(
+        circuit.get_circuit(include_qubit_coords=True)
+    )
+    assert frozenset(measurements) == frozenset(
+        [
+            Measurement(GridQubit(1, 1), -1),
+            Measurement(GridQubit(2, 2), -2),
+            Measurement(GridQubit(2, 2), -1),
+            Measurement(GridQubit(3, 3), -1),
+        ]
+    )
diff --git a/src/tqec/circuit/moment_test.py b/src/tqec/circuit/moment_test.py
index c9f13cb6..ee683d2d 100644
--- a/src/tqec/circuit/moment_test.py
+++ b/src/tqec/circuit/moment_test.py
@@ -124,6 +124,25 @@ def test_moment_append() -> None:
         moment.append("H", [stim.GateTarget(0)], [])
 
 
+def test_moment_append_instruction() -> None:
+    moment = Moment(stim.Circuit("H 0"))
+    moment.append_instruction(stim.CircuitInstruction("H", [stim.GateTarget(1)], []))
+    moment.append_instruction(stim.CircuitInstruction("H", [stim.GateTarget(2)], []))
+    assert moment.circuit == stim.Circuit("H 0 1 2")
+    moment.append_instruction(
+        stim.CircuitInstruction("QUBIT_COORDS", [stim.GateTarget(0)], [0, 0])
+    )
+    assert moment.circuit == stim.Circuit("H 0 1 2\nQUBIT_COORDS(0, 0) 0")
+
+    with pytest.raises(
+        TQECException,
+        match="^Trying to add an overlapping quantum circuit to a Moment instance.$",
+    ):
+        moment.append_instruction(
+            stim.CircuitInstruction("H", [stim.GateTarget(0)], [])
+        )
+
+
 @pytest.mark.parametrize("circuit", _VALID_MOMENT_CIRCUITS)
 def test_moment_instructions_property(circuit: stim.Circuit) -> None:
     assert list(Moment(circuit).instructions) == list(iter(circuit))
@@ -154,6 +173,28 @@ def test_moment_num_measurements() -> None:
     assert Moment(stim.Circuit("QUBIT_COORDS(0, 0) 0\nH 0")).num_measurements == 0
 
 
+def test_moment_filter_by_qubits() -> None:
+    fqs = [0, 2, 4, 5, 6, 789345]
+    assert Moment(stim.Circuit("H 0 1 2 3")).filter_by_qubits(
+        fqs
+    ).circuit == stim.Circuit("H 0 2")
+    assert Moment(stim.Circuit("M 1 4 6")).filter_by_qubits(
+        fqs
+    ).circuit == stim.Circuit("M 4 6")
+    assert Moment(stim.Circuit("MX 1 4 6")).filter_by_qubits(
+        fqs
+    ).circuit == stim.Circuit("MX 4 6")
+    assert Moment(stim.Circuit("CX 0 1 2 3 4 5")).filter_by_qubits(
+        fqs
+    ).circuit == stim.Circuit("CX 4 5")
+    assert Moment(stim.Circuit("H 0 3\nM 1 2 4")).filter_by_qubits(
+        fqs
+    ).circuit == stim.Circuit("H 0\nM 2 4")
+    assert Moment(stim.Circuit("QUBIT_COORDS(0, 0) 0\nH 0")).filter_by_qubits(
+        fqs
+    ).circuit == stim.Circuit("QUBIT_COORDS(0, 0) 0\nH 0")
+
+
 @pytest.mark.parametrize("circuit", _VALID_MOMENT_CIRCUITS)
 def test_iterate_circuit_by_moment_simple(circuit: stim.Circuit) -> None:
     moments = list(iter_stim_circuit_without_repeat_by_moments(circuit))
diff --git a/src/tqec/circuit/qubit_map_test.py b/src/tqec/circuit/qubit_map_test.py
index d8288dfe..82c58568 100644
--- a/src/tqec/circuit/qubit_map_test.py
+++ b/src/tqec/circuit/qubit_map_test.py
@@ -70,9 +70,6 @@ def test_get_final_qubits() -> None:
     assert get_qubit_map(stim.Circuit("QUBIT_COORDS(0, 0) 0")) == QubitMap(
         {0: GridQubit(0, 0)}
     )
-    assert get_qubit_map(stim.Circuit("QUBIT_COORDS(0, 0.5) 0")) == QubitMap(
-        {0: GridQubit(0, 0.5)}
-    )
     assert get_qubit_map(stim.Circuit("QUBIT_COORDS(0, 0) 1")) == QubitMap(
         {1: GridQubit(0, 0)}
     )
diff --git a/src/tqec/circuit/qubit_test.py b/src/tqec/circuit/qubit_test.py
index 28645828..76452112 100644
--- a/src/tqec/circuit/qubit_test.py
+++ b/src/tqec/circuit/qubit_test.py
@@ -10,17 +10,6 @@ from tqec.position import Displacement, Position2D
 def test_grid_qubit_creation() -> None:
     GridQubit(0, 0)
     GridQubit(-1, 10)
-    GridQubit(0.5, 10)
-    GridQubit(1.5, -4.5)
-    GridQubit(Fraction(1, 9), Fraction(0, 2))
-    GridQubit(-Fraction(1, 9), -Fraction(0, 2))
-
-    with pytest.raises(Exception):
-        GridQubit(float("NaN"), 0.0)
-    with pytest.raises(Exception):
-        GridQubit(-float("inf"), 0.0)
-    with pytest.raises(Exception):
-        GridQubit(0.0, float("NaN"))
 
 
 def test_grid_qubit_coordinates() -> None:
@@ -28,10 +17,6 @@ def test_grid_qubit_coordinates() -> None:
     assert GridQubit(0, 0).y == 0
     assert GridQubit(-1, 10).x == -1
     assert GridQubit(-1, 10).y == 10
-    assert GridQubit(0.5, 10).x == 0.5
-    assert GridQubit(0.5, 10).y == 10
-    assert GridQubit(Fraction(1, 9), Fraction(0, 2)).x == Fraction(1, 9)
-    assert GridQubit(Fraction(1, 9), Fraction(0, 2)).y == 0
 
 
 def test_grid_qubit_operators() -> None:
@@ -49,7 +34,7 @@ def test_grid_qubit_operators() -> None:
     assert q + Position2D(1, 3) == GridQubit(1, 3)
     assert q + GridQubit(1, 3) == GridQubit(1, 3)
 
-    q = GridQubit(-1, 0.5)
+    q = GridQubit(-1, 0)
     assert q + Displacement(0, 0) == q
     assert q + q == 2 * q
     assert q - q == GridQubit(0, 0)
@@ -60,7 +45,7 @@ def test_grid_qubit_in_set() -> None:
     assert len(set(grid_qubits)) == len(grid_qubits)
     assert len(set(grid_qubits + grid_qubits)) == len(grid_qubits)
 
-    grid_qubits = [GridQubit(i / 3, 3 * j) for i in range(10) for j in range(10)]
+    grid_qubits = [GridQubit(i * (3 + i), 3 * j) for i in range(10) for j in range(10)]
     assert len(set(grid_qubits)) == len(grid_qubits)
     assert len(set(grid_qubits + grid_qubits)) == len(grid_qubits)
 
diff --git a/src/tqec/circuit/schedule_test.py b/src/tqec/circuit/schedule_test.py
index 9aeb7b67..7a36ea13 100644
--- a/src/tqec/circuit/schedule_test.py
+++ b/src/tqec/circuit/schedule_test.py
@@ -84,6 +84,12 @@ def test_schedule_iter() -> None:
     assert list(Schedule(list(range(100000)))) == list(range(100000))
 
 
+def test_schedule_append_schedule() -> None:
+    sched = Schedule(list(range(10)))
+    sched.append_schedule(sched)
+    assert sched.schedule == list(range(20))
+
+
 def test_schedule_insert() -> None:
     schedule = Schedule([0, 3])
     with pytest.raises(ScheduleException):
diff --git a/src/tqec/compile/detectors/detector_test.py b/src/tqec/compile/detectors/detector_test.py
new file mode 100644
index 00000000..a519ef80
--- /dev/null
+++ b/src/tqec/compile/detectors/detector_test.py
@@ -0,0 +1,60 @@
+import pytest
+import stim
+
+from tqec.circuit.measurement import Measurement
+from tqec.circuit.measurement_map import MeasurementRecordsMap
+from tqec.circuit.qubit import GridQubit
+from tqec.compile.detectors.detector import Detector
+from tqec.exceptions import TQECException
+
+
+@pytest.fixture(name="measurement")
+def measurement_fixture() -> Measurement:
+    return Measurement(GridQubit(0, 0), -1)
+
+
+@pytest.fixture(name="mrecords_map")
+def mrecords_map_fixture() -> MeasurementRecordsMap:
+    return MeasurementRecordsMap({GridQubit(0, 0): [-1]})
+
+
+def test_detector_creation(measurement: Measurement) -> None:
+    with pytest.raises(
+        TQECException, match="^Trying to create a detector without any measurement.$"
+    ):
+        Detector(frozenset(), tuple())
+    with pytest.raises(
+        TQECException,
+        match="^Coordinates should at least provide 2 spatial dimensions.",
+    ):
+        Detector(frozenset([measurement]), tuple())
+    with pytest.raises(
+        TQECException,
+        match="^Coordinates should at least provide 2 spatial dimensions.",
+    ):
+        Detector(frozenset([measurement]), (1,))
+
+    Detector(frozenset([measurement]), (0, 3))
+
+
+def test_detector_to_instruction(
+    measurement: Measurement, mrecords_map: MeasurementRecordsMap
+) -> None:
+    detector = Detector(frozenset([measurement]), (1, 1))
+    instruction = detector.to_instruction(mrecords_map)
+    assert instruction.name == "DETECTOR"
+    assert instruction.targets_copy() == [stim.target_rec(-1)]
+    assert instruction.gate_args_copy() == [1, 1]
+
+    empty_mrecords_map = MeasurementRecordsMap()
+    with pytest.raises(TQECException):
+        detector.to_instruction(empty_mrecords_map)
+
+
+def test_detector_offset_spatially_by(measurement: Measurement) -> None:
+    detector = Detector(frozenset([measurement]), (1, 1))
+    offset_detector = detector.offset_spatially_by(45, -2)
+    assert offset_detector.measurements == frozenset(
+        [measurement.offset_spatially_by(45, -2)]
+    )
+    assert offset_detector.coordinates == (46, -1)