From c133817bf92cc52779427da31725b7f805408eb4 Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Wed, 9 Oct 2024 16:15:49 +0200 Subject: [PATCH 1/8] Bump version to 1.1dev0 --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 3eefcb9d..84885d82 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.0.0 +1.1dev0 From cdedaebaee20a925bdf85b86ebf575a76eb0fa07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= <29920212+HGSilveri@users.noreply.github.com> Date: Wed, 9 Oct 2024 18:32:46 +0200 Subject: [PATCH 2/8] Fix rounding error in RampWaveform (#747) --- pulser-core/pulser/math/__init__.py | 7 +++++++ pulser-core/pulser/waveforms.py | 5 +++-- tests/test_waveforms.py | 5 +++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pulser-core/pulser/math/__init__.py b/pulser-core/pulser/math/__init__.py index d33d4aa3..11a94039 100644 --- a/pulser-core/pulser/math/__init__.py +++ b/pulser-core/pulser/math/__init__.py @@ -200,6 +200,13 @@ def diff(a: AbstractArrayLike) -> AbstractArray: return AbstractArray(np.diff(a.as_array())) +def clip(a: AbstractArrayLike, a_min: float, a_max: float) -> AbstractArray: + a = AbstractArray(a) + if a.is_tensor: + return AbstractArray(torch.clamp(a.as_tensor(), a_min, a_max)) + return AbstractArray(np.clip(a.as_array(), a_min, a_max)) + + def dot(a: AbstractArrayLike, b: AbstractArrayLike) -> AbstractArray: a, b = map(AbstractArray, (a, b)) if a.is_tensor or b.is_tensor: diff --git a/pulser-core/pulser/waveforms.py b/pulser-core/pulser/waveforms.py index 7c7e6264..6b029011 100644 --- a/pulser-core/pulser/waveforms.py +++ b/pulser-core/pulser/waveforms.py @@ -599,8 +599,9 @@ def _samples(self) -> pm.AbstractArray: Returns: A numpy array with a value for each time step. """ - return ( - self._slope * np.arange(self._duration, dtype=float) + self._start + return pm.clip( + self._slope * np.arange(self._duration, dtype=float) + self._start, + *sorted(map(float, [self._start, self._stop])), ) @property diff --git a/tests/test_waveforms.py b/tests/test_waveforms.py index 8357d8d4..59648cfb 100644 --- a/tests/test_waveforms.py +++ b/tests/test_waveforms.py @@ -164,6 +164,11 @@ def test_custom(): def test_ramp(): assert np.isclose(ramp.slope, 7e-3, atol=1e-5) + ramp_samples = RampWaveform( + 3000, top := 25.757450291031688, 0 + ).samples.as_array() + assert np.all(np.logical_and(ramp_samples <= top, ramp_samples >= 0)) + def test_blackman(): with pytest.raises(TypeError): From 956af970a5b0f0b6b225c6de708ff7514bad5e82 Mon Sep 17 00:00:00 2001 From: Harold Date: Mon, 14 Oct 2024 07:05:57 -0400 Subject: [PATCH 3/8] Fix normalisation in ring of atoms (#750) The original formulas was normalized using np.tan(np.pi / L). However, this is not correct, as can be checked by computing the normalization between the distances between the atoms (one does not get R_interatomic). --- tutorials/simulating_sequences.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/simulating_sequences.ipynb b/tutorials/simulating_sequences.ipynb index 3c942b5e..fd956511 100644 --- a/tutorials/simulating_sequences.ipynb +++ b/tutorials/simulating_sequences.ipynb @@ -51,7 +51,7 @@ "R_interatomic = pulser.MockDevice.rydberg_blockade_radius(U)\n", "coords = (\n", " R_interatomic\n", - " / (2 * np.tan(np.pi / L))\n", + " / (2 * np.sin(np.pi / L))\n", " * np.array(\n", " [\n", " (np.cos(theta * 2 * np.pi / L), np.sin(theta * 2 * np.pi / L))\n", From a8cd6f1f04d76bcc73eb5b6da3660b9905ee7463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= <29920212+HGSilveri@users.noreply.github.com> Date: Wed, 16 Oct 2024 10:06:56 +0200 Subject: [PATCH 4/8] Add layout restricting parameters to BaseDevice (#751) --- pulser-core/pulser/devices/_device_datacls.py | 79 ++++++++++++++++++- .../abstract_repr/schemas/device-schema.json | 24 ++++++ tests/test_abstract_repr.py | 3 + tests/test_devices.py | 48 +++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/pulser-core/pulser/devices/_device_datacls.py b/pulser-core/pulser/devices/_device_datacls.py index 9a1d44bd..3a6872a3 100644 --- a/pulser-core/pulser/devices/_device_datacls.py +++ b/pulser-core/pulser/devices/_device_datacls.py @@ -40,7 +40,12 @@ DIMENSIONS = Literal[2, 3] -ALWAYS_OPTIONAL_PARAMS = ("max_sequence_duration", "max_runs") +ALWAYS_OPTIONAL_PARAMS = ( + "max_sequence_duration", + "max_runs", + "optimal_layout_filling", + "max_layout_traps", +) OPTIONAL_IN_ABSTR_REPR = tuple( list(ALWAYS_OPTIONAL_PARAMS) + [ @@ -48,6 +53,7 @@ "default_noise_model", "requires_layout", "accepts_new_layouts", + "min_layout_traps", ] ) PARAMS_WITH_ABSTR_REPR = ("channel_objects", "channel_ids", "dmm_objects") @@ -83,6 +89,11 @@ class BaseDevice(ABC): supports_slm_mask: Whether the device supports the SLM mask feature. max_layout_filling: The largest fraction of a layout that can be filled with atoms. + optimal_layout_filling: An optional value for the fraction of a layout + that should be filled with atoms. + min_layout_traps: The minimum number of traps a layout can have. + max_layout_traps: An optional value for the maximum number of traps a + layout can have. max_sequence_duration: The maximum allowed duration for a sequence (in ns). max_runs: The maximum number of runs allowed on the device. Only used @@ -103,6 +114,9 @@ class BaseDevice(ABC): interaction_coeff_xy: float | None = None supports_slm_mask: bool = False max_layout_filling: float = 0.5 + optimal_layout_filling: float | None = None + min_layout_traps: int = 1 + max_layout_traps: int | None = None max_sequence_duration: int | None = None max_runs: int | None = None requires_layout: bool = False @@ -141,6 +155,8 @@ def type_check( "max_radial_distance", "max_sequence_duration", "max_runs", + "min_layout_traps", + "max_layout_traps", ): value = getattr(self, param) if ( @@ -180,6 +196,40 @@ def type_check( f"not {self.max_layout_filling}." ) + if self.optimal_layout_filling is not None and not ( + 0.0 < self.optimal_layout_filling <= self.max_layout_filling + ): + raise ValueError( + "When defined, the optimal layout filling fraction " + "must be greater than 0. and less than or equal to " + f"`max_layout_filling` ({self.max_layout_filling}), " + f"not {self.optimal_layout_filling}." + ) + + if self.max_layout_traps is not None: + if self.max_layout_traps < self.min_layout_traps: + raise ValueError( + "The maximum number of layout traps " + f"({self.max_layout_traps}) must be greater than " + "or equal to the minimum number of layout traps " + f"({self.min_layout_traps})." + ) + if ( + self.max_atom_num is not None + and ( + max_atoms_ := int( + self.max_layout_filling * self.max_layout_traps + ) + ) + < self.max_atom_num + ): + raise ValueError( + "With the given maximum layout filling and maximum number " + f"of traps, a layout supports at most {max_atoms_} atoms, " + "which is less than the maximum number of atoms allowed" + f"({self.max_atom_num})." + ) + for ch_obj in self.channel_objects: type_check("All channels", Channel, value_override=ch_obj) @@ -360,6 +410,23 @@ def validate_layout(self, layout: RegisterLayout) -> None: f"{self.dimensions} dimensions." ) + if layout.number_of_traps < self.min_layout_traps: + raise ValueError( + "The device requires register layouts to have " + f"at least {self.min_layout_traps} traps; " + f"{layout!s} has only {layout.number_of_traps}." + ) + + if ( + self.max_layout_traps is not None + and layout.number_of_traps > self.max_layout_traps + ): + raise ValueError( + "The device requires register layouts to have " + f"at most {self.max_layout_traps} traps; " + f"{layout!s} has {layout.number_of_traps}." + ) + self._validate_coords(layout.traps_dict, kind="traps") def validate_layout_filling( @@ -547,6 +614,11 @@ class Device(BaseDevice): supports_slm_mask: Whether the device supports the SLM mask feature. max_layout_filling: The largest fraction of a layout that can be filled with atoms. + optimal_layout_filling: An optional value for the fraction of a layout + that should be filled with atoms. + min_layout_traps: The minimum number of traps a layout can have. + max_layout_traps: An optional value for the maximum number of traps a + layout can have. max_sequence_duration: The maximum allowed duration for a sequence (in ns). max_runs: The maximum number of runs allowed on the device. Only used @@ -792,6 +864,11 @@ class VirtualDevice(BaseDevice): supports_slm_mask: Whether the device supports the SLM mask feature. max_layout_filling: The largest fraction of a layout that can be filled with atoms. + optimal_layout_filling: An optional value for the fraction of a layout + that should be filled with atoms. + min_layout_traps: The minimum number of traps a layout can have. + max_layout_traps: An optional value for the maximum number of traps a + layout can have. max_sequence_duration: The maximum allowed duration for a sequence (in ns). max_runs: The maximum number of runs allowed on the device. Only used diff --git a/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json index b70192f2..258894de 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json @@ -166,6 +166,10 @@ "description": "The largest fraction of a layout that can be filled with atoms.", "type": "number" }, + "max_layout_traps": { + "description": "The maximum number of traps a layout can have.", + "type": "number" + }, "max_radial_distance": { "description": "Maximum distance an atom can be from the center of the array (in µm).", "type": "number" @@ -182,10 +186,18 @@ "description": "The closest together two atoms can be (in μm).", "type": "number" }, + "min_layout_traps": { + "description": "The minimum number of traps a layout can have.", + "type": "number" + }, "name": { "description": "A unique name for the device.", "type": "string" }, + "optimal_layout_filling": { + "description": "The optimal fraction of a layout that should be filled with atoms.", + "type": "number" + }, "pre_calibrated_layouts": { "description": "Register layouts already calibrated on the device.", "items": { @@ -288,6 +300,10 @@ "description": "The largest fraction of a layout that can be filled with atoms.", "type": "number" }, + "max_layout_traps": { + "description": "The maximum number of traps a layout can have.", + "type": "number" + }, "max_radial_distance": { "description": "Maximum distance an atom can be from the center of the array (in µm).", "type": [ @@ -307,10 +323,18 @@ "description": "The closest together two atoms can be (in μm).", "type": "number" }, + "min_layout_traps": { + "description": "The minimum number of traps a layout can have.", + "type": "number" + }, "name": { "description": "A unique name for the device.", "type": "string" }, + "optimal_layout_filling": { + "description": "The optimal fraction of a layout that should be filled with atoms.", + "type": "number" + }, "requires_layout": { "description": "Whether the register used in the sequence must be created from a register layout. Only enforced in QPU execution.", "type": "boolean" diff --git a/tests/test_abstract_repr.py b/tests/test_abstract_repr.py index bcf533e7..1d4bfcdc 100644 --- a/tests/test_abstract_repr.py +++ b/tests/test_abstract_repr.py @@ -479,6 +479,9 @@ def check_error_raised( [ (MockDevice, "max_sequence_duration", 1000), (MockDevice, "max_runs", 100), + (MockDevice, "optimal_layout_filling", 0.4), + (MockDevice, "min_layout_traps", 10), + (MockDevice, "max_layout_traps", 200), (MockDevice, "requires_layout", True), (AnalogDevice, "requires_layout", False), (AnalogDevice, "accepts_new_layouts", False), diff --git a/tests/test_devices.py b/tests/test_devices.py index 5252fe64..5c4017bc 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -47,6 +47,8 @@ def test_params(): min_atom_distance=1, max_atom_num=None, max_radial_distance=None, + min_layout_traps=10, + max_layout_traps=100, ) @@ -122,6 +124,36 @@ def test_post_init_type_checks(test_params, param, value, msg): "maximum layout filling fraction must be greater than 0. and" " less than or equal to 1.", ), + ( + "optimal_layout_filling", + 0.0, + "When defined, the optimal layout filling fraction must be greater" + " than 0. and less than or equal to `max_layout_filling`", + ), + ( + "optimal_layout_filling", + 0.9, + "When defined, the optimal layout filling fraction must be greater" + " than 0. and less than or equal to `max_layout_filling`", + ), + ( + "min_layout_traps", + 0, + "'min_layout_traps' must be greater than zero", + ), + ("max_layout_traps", 0, None), + ( + "max_atom_num", + 100, + "With the given maximum layout filling and maximum number " + "of traps, a layout supports at most 50 atoms", + ), + ( + "max_layout_traps", + 9, + "must be greater than or equal to the minimum number of " + "layout traps", + ), ( "channel_ids", ("rydberg_global", "rydberg_global"), @@ -336,6 +368,22 @@ def test_validate_layout(): ) ) + restricted_device = replace( + DigitalAnalogDevice, min_layout_traps=10, max_layout_traps=200 + ) + with pytest.raises( + ValueError, + match="The device requires register layouts to have " + "at least 10 traps", + ): + restricted_device.validate_layout(TriangularLatticeLayout(9, 10)) + with pytest.raises( + ValueError, + match="The device requires register layouts to have " + "at most 200 traps", + ): + restricted_device.validate_layout(TriangularLatticeLayout(201, 10)) + valid_layout = RegisterLayout( Register.square( int(np.sqrt(DigitalAnalogDevice.max_atom_num * 2)) From c6e9fbf4af4ceb815e5da80f1c234b8b3341004d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= <29920212+HGSilveri@users.noreply.github.com> Date: Wed, 16 Oct 2024 16:33:24 +0200 Subject: [PATCH 5/8] Improve the NoiseModel unused parameters warning message (#752) --- pulser-core/pulser/noise_model.py | 6 +++++- tests/test_noise_model.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pulser-core/pulser/noise_model.py b/pulser-core/pulser/noise_model.py index 36fad053..e46a9533 100644 --- a/pulser-core/pulser/noise_model.py +++ b/pulser-core/pulser/noise_model.py @@ -258,12 +258,16 @@ def to_tuple(obj: tuple) -> tuple: object.__setattr__( self, "noise_types", tuple(sorted(true_noise_types)) ) + non_zero_relevant_params = [ + p for p in relevant_params if param_vals[p] + ] for param_, val_ in param_vals.items(): object.__setattr__(self, param_, val_) if val_ and param_ not in relevant_params: warnings.warn( f"{param_!r} is not used by any active noise type " - f"{self.noise_types}.", + f"in {self.noise_types} when the only defined parameters " + f"are {non_zero_relevant_params}.", stacklevel=2, ) diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py index 9977dcf3..c61463e9 100644 --- a/tests/test_noise_model.py +++ b/tests/test_noise_model.py @@ -13,6 +13,8 @@ # limitations under the License. from __future__ import annotations +import re + import numpy as np import pytest @@ -86,7 +88,14 @@ def test_init(self, params, noise_types): ) @pytest.mark.parametrize("unused_param", ["runs", "samples_per_run"]) def test_unused_params(self, unused_param, noise_param): - with pytest.warns(UserWarning, match=f"'{unused_param}' is not used"): + with pytest.warns( + UserWarning, + match=re.escape( + f"'{unused_param}' is not used by any active noise type in" + f" {(_PARAM_TO_NOISE_TYPE[noise_param],)} when the only " + f"defined parameters are {[noise_param]}" + ), + ): NoiseModel(**{unused_param: 100, noise_param: 1.0}) @pytest.mark.parametrize( From c32b4bdaf60edb884cd9f7beaff911a6be05dbd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= <29920212+HGSilveri@users.noreply.github.com> Date: Thu, 17 Oct 2024 17:48:43 +0200 Subject: [PATCH 6/8] Attempt to compare pulser versions when abstract repr validation fails (#754) * Attempt to compare pulser versions when abstract repr validation fails * Protect against an invalid serialized version * Allow pulser version in the JSON schema --- .../abstract_repr/schemas/device-schema.json | 8 ++++++ .../abstract_repr/schemas/layout-schema.json | 8 ++++++ .../abstract_repr/schemas/noise-schema.json | 4 +++ .../schemas/register-schema.json | 16 +++++++++++ .../schemas/sequence-schema.json | 20 ++++++++++++++ .../pulser/json/abstract_repr/validation.py | 27 +++++++++++++++++-- pulser-core/requirements.txt | 1 + tests/test_abstract_repr.py | 17 ++++++++++++ 8 files changed, 99 insertions(+), 2 deletions(-) diff --git a/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json index 258894de..6d410851 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/device-schema.json @@ -205,6 +205,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "requires_layout": { "description": "Whether the register used in the sequence must be created from a register layout. Only enforced in QPU execution.", "type": "boolean" @@ -335,6 +339,10 @@ "description": "The optimal fraction of a layout that should be filled with atoms.", "type": "number" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "requires_layout": { "description": "Whether the register used in the sequence must be created from a register layout. Only enforced in QPU execution.", "type": "boolean" diff --git a/pulser-core/pulser/json/abstract_repr/schemas/layout-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/layout-schema.json index 01899461..899aedd2 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/layout-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/layout-schema.json @@ -29,6 +29,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "slug": { "description": "An optional name for the layout.", "type": "string" @@ -54,6 +58,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "slug": { "description": "An optional name for the layout.", "type": "string" diff --git a/pulser-core/pulser/json/abstract_repr/schemas/noise-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/noise-schema.json index 7da4afad..570be122 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/noise-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/noise-schema.json @@ -83,6 +83,10 @@ "p_false_pos": { "type": "number" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "relaxation_rate": { "type": "number" }, diff --git a/pulser-core/pulser/json/abstract_repr/schemas/register-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/register-schema.json index f67e41fe..02354da5 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/register-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/register-schema.json @@ -69,6 +69,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "slug": { "description": "An optional name for the layout.", "type": "string" @@ -94,6 +98,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "slug": { "description": "An optional name for the layout.", "type": "string" @@ -125,6 +133,10 @@ "$ref": "#/definitions/Layout2D", "description": "The trap layout underlying the register." }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "register": { "description": "A 2D register containing a set of atoms.", "items": { @@ -145,6 +157,10 @@ "$ref": "#/definitions/Layout3D", "description": "The trap layout underlying the register." }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "register": { "description": "A 3D register containing a set of atoms.", "items": { diff --git a/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json b/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json index 2c0b8afd..21cf2dba 100644 --- a/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json +++ b/pulser-core/pulser/json/abstract_repr/schemas/sequence-schema.json @@ -395,6 +395,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "slug": { "description": "An optional name for the layout.", "type": "string" @@ -420,6 +424,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "slug": { "description": "An optional name for the layout.", "type": "string" @@ -1013,6 +1021,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "register": { "description": "A 2D register containing a set of atoms.", "items": { @@ -1110,6 +1122,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "register": { "description": "A 3D register containing a set of atoms.", "items": { @@ -1207,6 +1223,10 @@ }, "type": "array" }, + "pulser_version": { + "description": "The pulser version used to serialize the object.", + "type": "string" + }, "register": { "description": "A list of qubit IDs.", "items": { diff --git a/pulser-core/pulser/json/abstract_repr/validation.py b/pulser-core/pulser/json/abstract_repr/validation.py index 42725aa0..e69104f3 100644 --- a/pulser-core/pulser/json/abstract_repr/validation.py +++ b/pulser-core/pulser/json/abstract_repr/validation.py @@ -17,11 +17,16 @@ from typing import Literal import jsonschema +from packaging.version import InvalidVersion, Version from referencing import Registry, Resource +import pulser from pulser.json.abstract_repr import SCHEMAS, SCHEMAS_PATH +from pulser.json.exceptions import AbstractReprError -LEGACY_JSONSCHEMA = "4.18" > version("jsonschema") >= "4.17.3" +LEGACY_JSONSCHEMA = ( + Version("4.18") > Version(version("jsonschema")) >= Version("4.17.3") +) REGISTRY: Registry = Registry( [ @@ -52,4 +57,22 @@ def validate_abstract_repr( ) else: # pragma: no cover validate_args["registry"] = REGISTRY - jsonschema.validate(**validate_args) + try: + jsonschema.validate(**validate_args) + except Exception as exc: + try: + ser_pulser_version = Version(obj.get("pulser_version", "0.0.0")) + except InvalidVersion: + # In case the serialized version is invalid + raise exc + if Version(pulser.__version__) < ser_pulser_version: + raise AbstractReprError( + "The provided object is invalid under the current abstract " + "representation schema. It appears it was serialized with a " + f"more recent version of pulser ({ser_pulser_version!s}) than " + f"the one currently being used ({pulser.__version__}). " + "It is possible validation failed because new features have " + "since been added; consider upgrading your pulser " + "installation and retrying." + ) from exc + raise exc diff --git a/pulser-core/requirements.txt b/pulser-core/requirements.txt index 3b984cc1..40e64f76 100644 --- a/pulser-core/requirements.txt +++ b/pulser-core/requirements.txt @@ -1,6 +1,7 @@ jsonschema >= 4.17.3, < 5 referencing matplotlib < 4 +packaging # This is already required by matplotlib but we use it too # Numpy 1.20 introduces type hints, 1.24.0 breaks matplotlib < 3.6.1 numpy >= 1.20, != 1.24.0, < 2 scipy < 2 \ No newline at end of file diff --git a/tests/test_abstract_repr.py b/tests/test_abstract_repr.py index 1d4bfcdc..95ca4305 100644 --- a/tests/test_abstract_repr.py +++ b/tests/test_abstract_repr.py @@ -26,6 +26,7 @@ import numpy as np import pytest +import pulser from pulser import Pulse, Register, Register3D, Sequence, devices from pulser.channels import Rydberg from pulser.channels.eom import RydbergBeam, RydbergEOM @@ -575,6 +576,22 @@ def validate_schema(instance): validate_abstract_repr(json.dumps(instance), "sequence") +def test_pulser_version_mismatch(): + curr_ver = pulser.__version__ + higher_ver = f"{int(curr_ver[0])+1}{curr_ver[1:]}" + obj_str = json.dumps({"pulser_version": higher_ver}) + with pytest.raises( + AbstractReprError, + match="It is possible validation failed because new features have " + "since been added; consider upgrading your pulser " + "installation and retrying.", + ): + validate_abstract_repr(obj_str, "device") + obj_str = json.dumps({"pulser_version": "bad_version"}) + with pytest.raises(jsonschema.ValidationError): + validate_abstract_repr(obj_str, "device") + + class TestSerialization: @pytest.fixture def triangular_lattice(self): From 2fa0f7f6563cab5270b8a45c6fd0581c4facae41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Silv=C3=A9rio?= <29920212+HGSilveri@users.noreply.github.com> Date: Thu, 17 Oct 2024 18:10:41 +0200 Subject: [PATCH 7/8] Incorporate automatic layout generation in `Register` (#753) * Adding the register layout generator * Define Register.with_automatic_layout() * Function scope modifications * Unit tests * Update tutorial * Address review comments * Account for when the Register requires more traps than max_traps --- pulser-core/pulser/register/_layout_gen.py | 104 ++++++++++++++++++ pulser-core/pulser/register/register.py | 44 +++++++- tests/test_register.py | 72 +++++++++++- .../Backends for Sequence Execution.ipynb | 6 +- 4 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 pulser-core/pulser/register/_layout_gen.py diff --git a/pulser-core/pulser/register/_layout_gen.py b/pulser-core/pulser/register/_layout_gen.py new file mode 100644 index 00000000..ac95f0c1 --- /dev/null +++ b/pulser-core/pulser/register/_layout_gen.py @@ -0,0 +1,104 @@ +# Copyright 2024 Pulser Development Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import numpy as np +from scipy.spatial.distance import cdist + + +def generate_trap_coordinates( + atom_coords: np.ndarray, + min_trap_dist: float, + max_radial_dist: int, + max_layout_filling: float, + optimal_layout_filling: float | None = None, + mesh_resolution: float = 1.0, + min_traps: int = 1, + max_traps: int | None = None, +) -> list[np.ndarray]: + """Generates trap coordinates for a collection of atom coordinates. + + Generates a mesh of resolution `mesh_resolution` covering a disk of radius + `max_radial_dist`. Deletes all the points of the mesh that are below a + radius `min_trap_dist` of any atoms or traps and iteratively selects from + the remaining points the necessary number of traps such that the ratio + number of atoms to number of traps is at most max_layout_filling and as + close as possible to optimal_layout_filling, while being above min_traps + and below max_traps. + + Args: + atom_coords: The coordinates where atoms will be placed. + min_trap_dist: The minimum distance between traps, in µm. + max_radial_dist: The maximum distance from the origin, in µm. + max_layout_filling: The maximum ratio of atoms to traps. + optimal_layout_filling: An optional value for the optimal ratio of + atoms to traps. If not given, takes max_layout_filling. + mesh_resolution: The spacing between points in the mesh of candidate + coordinates, in µm. + min_traps: The minimum number of traps in the resulting layout. + max_traps: The maximum number of traps in the resulting layout. + """ + optimal_layout_filling = optimal_layout_filling or max_layout_filling + assert optimal_layout_filling <= max_layout_filling + assert max_traps is None or min_traps <= max_traps + + # Generate all coordinates where a trap can be placed + lx = 2 * max_radial_dist + side = np.linspace(0, lx, num=int(lx / mesh_resolution)) - max_radial_dist + x, y = np.meshgrid(side, side) + in_circle = x**2 + y**2 <= max_radial_dist**2 + coords = np.c_[x[in_circle].ravel(), y[in_circle].ravel()] + + # Get the atoms in the register (the "seeds") + seeds: list[np.ndarray] = list(atom_coords) + n_seeds = len(seeds) + + # Record indices and distances between coords and seeds + c_indx = np.arange(len(coords)) + all_dists = cdist(coords, seeds) + + # Accounts for the case when the needed number is less than min_traps + min_traps = max( + np.ceil(n_seeds / max_layout_filling).astype(int), min_traps + ) + + # Use max() in case min_traps is larger than the optimal number + target_traps = max( + np.round(n_seeds / optimal_layout_filling).astype(int), + min_traps, + ) + if max_traps: + target_traps = min(target_traps, max_traps) + + # This is the region where we can still add traps + region_left = np.all(all_dists > min_trap_dist, axis=1) + # The traps start out as being just the seeds + traps = seeds.copy() + for _ in range(target_traps - n_seeds): + if not np.any(region_left): + break + # Select the point in the valid region that is closest to a seed + selected = c_indx[region_left][ + np.argmin(np.min(all_dists[region_left][:, :n_seeds], axis=1)) + ] + # Add the selected point to the traps + traps.append(coords[selected]) + # Add the distances to the new trap + all_dists = np.append(all_dists, cdist(coords, [traps[-1]]), axis=1) + region_left *= all_dists[:, -1] > min_trap_dist + if len(traps) < min_traps: + raise RuntimeError( + f"Failed to find a site for {min_traps - len(traps)} traps." + ) + return traps diff --git a/pulser-core/pulser/register/register.py b/pulser-core/pulser/register/register.py index 2ed7379a..b110ca26 100644 --- a/pulser-core/pulser/register/register.py +++ b/pulser-core/pulser/register/register.py @@ -17,7 +17,7 @@ import warnings from collections.abc import Mapping -from typing import Any, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Optional, Union, cast import matplotlib.pyplot as plt import numpy as np @@ -31,9 +31,13 @@ deserialize_abstract_register, ) from pulser.json.utils import stringify_qubit_ids +from pulser.register._layout_gen import generate_trap_coordinates from pulser.register._reg_drawer import RegDrawer from pulser.register.base_register import BaseRegister, QubitId +if TYPE_CHECKING: + from pulser.devices import Device + class Register(BaseRegister, RegDrawer): """A 2D quantum register containing a set of qubits. @@ -324,6 +328,44 @@ def max_connectivity( return cls.from_coordinates(coords, center=False, prefix=prefix) + def with_automatic_layout( + self, + device: Device, + layout_slug: str | None = None, + ) -> Register: + """Replicates the register with an automatically generated layout. + + The generated `RegisterLayout` can be accessed via `Register.layout`. + + Args: + device: The device constraints for the layout generation. + layout_slug: An optional slug for the generated layout. + + Raises: + RuntimeError: If the automatic layout generation fails to meet + the device constraints. + + Returns: + Register: A new register instance with identical qubit IDs and + coordinates but also the newly generated RegisterLayout. + """ + if not isinstance(device, pulser.devices.Device): + raise TypeError( + f"'device' must be of type Device, not {type(device)}." + ) + trap_coords = generate_trap_coordinates( + self.sorted_coords, + min_trap_dist=device.min_atom_distance, + max_radial_dist=device.max_radial_distance, + max_layout_filling=device.max_layout_filling, + optimal_layout_filling=device.optimal_layout_filling, + min_traps=device.min_layout_traps, + max_traps=device.max_layout_traps, + ) + layout = pulser.register.RegisterLayout(trap_coords, slug=layout_slug) + trap_ids = layout.get_traps_from_coordinates(*self.sorted_coords) + return cast(Register, layout.define_register(*trap_ids)) + def rotated(self, degrees: float) -> Register: """Makes a new rotated register. diff --git a/tests/test_register.py b/tests/test_register.py index 294bff8f..a782bff1 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -13,13 +13,15 @@ # limitations under the License. from __future__ import annotations +import dataclasses from unittest.mock import patch import numpy as np import pytest from pulser import Register, Register3D -from pulser.devices import DigitalAnalogDevice, MockDevice +from pulser.devices import AnalogDevice, DigitalAnalogDevice, MockDevice +from pulser.register import RegisterLayout def test_creation(): @@ -587,3 +589,71 @@ def test_register_recipes_torch( } reg = reg_classmethod(**kwargs) _assert_reg_requires_grad(reg, invert=not requires_grad) + + +@pytest.mark.parametrize("optimal_filling", [None, 0.4, 0.1]) +def test_automatic_layout(optimal_filling): + reg = Register.square(4, spacing=5) + max_layout_filling = 0.5 + min_traps = int(np.ceil(len(reg.qubits) / max_layout_filling)) + optimal_traps = int( + np.ceil(len(reg.qubits) / (optimal_filling or max_layout_filling)) + ) + device = dataclasses.replace( + AnalogDevice, + max_atom_num=20, + max_layout_filling=max_layout_filling, + optimal_layout_filling=optimal_filling, + pre_calibrated_layouts=(), + ) + device.validate_register(reg) + + # On its own, it works + new_reg = reg.with_automatic_layout(device, layout_slug="foo") + assert isinstance(new_reg.layout, RegisterLayout) + assert str(new_reg.layout) == "foo" + trap_num = new_reg.layout.number_of_traps + assert min_traps <= trap_num <= optimal_traps + # To test the device limits on trap number are enforced + if not optimal_filling: + assert trap_num == min_traps + bound_below_dev = dataclasses.replace( + device, min_layout_traps=trap_num + 1 + ) + assert ( + reg.with_automatic_layout(bound_below_dev).layout.number_of_traps + == bound_below_dev.min_layout_traps + ) + elif trap_num < optimal_traps: + assert trap_num > min_traps + bound_above_dev = dataclasses.replace( + device, max_layout_traps=trap_num - 1 + ) + assert ( + reg.with_automatic_layout(bound_above_dev).layout.number_of_traps + == bound_above_dev.max_layout_traps + ) + + with pytest.raises(TypeError, match="must be of type Device"): + reg.with_automatic_layout(MockDevice) + + # Minimum number of traps is too high + with pytest.raises(RuntimeError, match="Failed to find a site"): + reg.with_automatic_layout( + dataclasses.replace(device, min_layout_traps=200) + ) + + # The Register is larger than max_traps + big_reg = Register.square(8, spacing=5) + min_traps = np.ceil(len(big_reg.qubit_ids) / max_layout_filling) + with pytest.raises( + RuntimeError, match="Failed to find a site for 2 traps" + ): + big_reg.with_automatic_layout( + dataclasses.replace(device, max_layout_traps=int(min_traps - 2)) + ) + # Without max_traps, it would still work + assert ( + big_reg.with_automatic_layout(device).layout.number_of_traps + >= min_traps + ) diff --git a/tutorials/advanced_features/Backends for Sequence Execution.ipynb b/tutorials/advanced_features/Backends for Sequence Execution.ipynb index 51854054..956e1ec4 100644 --- a/tutorials/advanced_features/Backends for Sequence Execution.ipynb +++ b/tutorials/advanced_features/Backends for Sequence Execution.ipynb @@ -62,7 +62,11 @@ "Sequence execution on a QPU is done through the `QPUBackend`, which is a remote backend. Therefore, it requires a remote backend connection, which should be open from the start due to two additional QPU constraints:\n", "\n", "1. The `Device` must be chosen among the options available at the moment, which can be found through `connection.fetch_available_devices()`.\n", - "2. The `Register` must be defined from one of the register layouts calibrated for the chosen `Device`, which are found under `Device.calibrated_register_layouts`. Check out [this tutorial](reg_layouts.nblink) for more information on how to define a `Register` from a `RegisterLayout`.\n", + "2. If in the chosen device `Device.requires_layout` is `True`, the `Register` must be defined from a register layout: \n", + " - If `Device.accepts_new_layouts` is `False`, use one of the register layouts calibrated for the chosen `Device` (found under `Device.calibrated_register_layouts`). Check out [this tutorial](reg_layouts.nblink) for more information on how to define a `Register` from a `RegisterLayout`.\n", + " - Otherwise, we may choose to define our own custom layout or rely on `Register.with_automatic_layout()` to\n", + " give us a register from an automatically generated register layout that fits our desired register while obeying the device constraints. \n", + "\n", "\n", "On the contrary, execution on emulator backends imposes no further restriction on the device and the register. We will stick to emulator backends in this tutorial, so we will forego the requirements of QPU backends in the following steps." ] From c0173a703684ed8382222b11ec12ae6caeb9b166 Mon Sep 17 00:00:00 2001 From: HGSilveri Date: Thu, 17 Oct 2024 18:12:17 +0200 Subject: [PATCH 8/8] Bump version to 1.1.0 --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 84885d82..9084fa2f 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.1dev0 +1.1.0