diff --git a/pyaml/bpm/bpm.py b/pyaml/bpm/bpm.py index 22f3514a..ccdbc0c8 100644 --- a/pyaml/bpm/bpm.py +++ b/pyaml/bpm/bpm.py @@ -1,60 +1,60 @@ +import copy +from typing import Self + from ..common.abstract import ReadFloatArray, ReadWriteFloatArray, ReadWriteFloatScalar -from ..common.element import Element, ElementConfigModel +from ..common.element import Element, __pyaml_repr__ from ..common.exception import PyAMLException - -try: - from typing import Self # Python 3.11+ -except ImportError: - from typing_extensions import Self # Python 3.10 and earlier +from ..validation import DynamicValidation, register_schema PYAMLCLASS = "BPM" -class ConfigModel(ElementConfigModel): +@register_schema +class BPM(Element, DynamicValidation): """ - Configuration model for BPM element. + Beam position monitor (BPM) element. + + Represents a BPM in the accelerator lattice and provides access to its + associated readback signals, including horizontal and vertical beam + positions, calibration offsets, and mechanical tilt. Parameters ---------- - x_pos : str - Horizontal position device catalog key - y_pos : str - Vertical position device catalog key - x_offset : str - Horizontal BPM offset device catalog key - y_offset : str - Vertical BPM offset device catalog key - tilt : str - BPM tilt device catalog key + name : str + Name of the BPM. + lattice_names : str | None, optional + Lattice-specific name or names identifying the BPM. + description : str | None, optional + Description of the BPM. + x_pos : str | None, optional + Device catalog key for the horizontal beam position. + y_pos : str | None, optional + Device catalog key for the vertical beam position. + x_offset : str | None, optional + Device catalog key for the horizontal BPM offset. + y_offset : str | None, optional + Device catalog key for the vertical BPM offset. + tilt : str | None, optional + Device catalog key for the BPM tilt. """ - x_pos: str | None = None - y_pos: str | None = None - x_offset: str | None = None - y_offset: str | None = None - tilt: str | None = None - - -class BPM(Element): - """ - Class providing access to one BPM of a physical or simulated lattice - """ - - def __init__(self, cfg: ConfigModel): - """ - Construct a BPM - - Parameters - ---------- - name : str - Element name - model : BPMModel - BPM model in charge of computing beam position - """ - - super().__init__(cfg.name) - - self._cfg = cfg + def __init__( + self, + name: str, + lattice_names: str | None = None, + description: str | None = None, + x_pos: str | None = None, + y_pos: str | None = None, + x_offset: str | None = None, + y_offset: str | None = None, + tilt: str | None = None, + ): + super().__init__(name, lattice_names, description) + self._x_pos = x_pos + self._y_pos = y_pos + self._x_offset = x_offset + self._y_offset = y_offset + self._tilt_name = tilt self._positions = None self._offset = None self._tilt = None @@ -144,14 +144,14 @@ def attach( """ # Attach positions, offset and tilt attributes and returns a new # reference - obj = self.__class__(self._cfg) + obj = copy.copy(self) obj._positions = positions obj._offset = offset obj._tilt = tilt obj._peer = peer return obj - def get_pos_devices(self) -> list[str]: + def get_pos_devices(self) -> list[str | None]: """ Get device handles used for position reading @@ -160,7 +160,7 @@ def get_pos_devices(self) -> list[str]: list[DeviceAccess] Array of DeviceAcess """ - return [self._cfg.x_pos, self._cfg.y_pos] + return [self._x_pos, self._y_pos] def get_tilt_device(self) -> str | None: """ @@ -171,7 +171,7 @@ def get_tilt_device(self) -> str | None: DeviceAccess DeviceAcess """ - return self._cfg.tilt + return self._tilt_name def get_offset_devices(self) -> list[str | None]: """ @@ -182,4 +182,7 @@ def get_offset_devices(self) -> list[str | None]: list[DeviceAccess] Array of DeviceAcess """ - return [self._cfg.x_offset, self._cfg.y_offset] + return [self._x_offset, self._y_offset] + + def __repr__(self): + return __pyaml_repr__(self, exclude=["positions", "offset", "tilt"]) diff --git a/pyaml/configuration/factory.py b/pyaml/configuration/factory.py index f607e361..0035cb12 100644 --- a/pyaml/configuration/factory.py +++ b/pyaml/configuration/factory.py @@ -284,7 +284,9 @@ def _build_object(self, data: dict, ignore_external: bool = False): try: cfg = build_info.config_cls.model_validate(config) except ValidationError as exc: - raise PyAMLConfigException(str(exc)) from exc + raise PyAMLConfigException( + f"Validation failed for {build_info.config_cls.__module__}.{build_info.config_cls.__name__}:\n{exc}" + ) from exc else: cfg = config diff --git a/pyaml/validation/validator.py b/pyaml/validation/validator.py index 03cd4cec..cee96a45 100644 --- a/pyaml/validation/validator.py +++ b/pyaml/validation/validator.py @@ -2,9 +2,10 @@ import logging import warnings +from collections.abc import Mapping from typing import Any -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from .configuration_models import ConfigurationSchema, ModuleConfigurationSchema from .errors import extract_location_metadata, raise_validation_error @@ -13,6 +14,37 @@ logger = logging.getLogger(__name__) +def dump_nested(value: Any) -> Any: + """ + Recursively convert nested Pydantic models to plain Python objects. + + Traverses mappings, lists, and tuples, replacing any + :class:`pydantic.BaseModel` instances with the result of + :meth:`BaseModel.model_dump`. All other values are returned unchanged. + + Parameters + ---------- + value : Any + The object to convert. + + Returns + ------- + Any + The converted object with all nested Pydantic models represented as + dictionaries. + """ + + if isinstance(value, BaseModel): + return value.model_dump() + if isinstance(value, Mapping): + return {k: dump_nested(v) for k, v in value.items()} + if isinstance(value, list): + return [dump_nested(v) for v in value] + if isinstance(value, tuple): + return tuple(dump_nested(v) for v in value) + return value + + class SchemaValidator: """Recursive validator for configuration dictionaries. @@ -73,9 +105,7 @@ def validate_to_dict( """ validated = cls.validate(data) - if isinstance(validated, dict): - return validated - return validated.model_dump() + return dump_nested(validated) @classmethod def _recursive_validate(cls, obj: Any) -> Any: diff --git a/tests/common/test_errors.py b/tests/common/test_errors.py index cd8546b7..1912847e 100644 --- a/tests/common/test_errors.py +++ b/tests/common/test_errors.py @@ -14,36 +14,36 @@ def test_tune(install_test_package): with pytest.raises(PyAMLConfigException) as exc: ml: Accelerator = Accelerator.load("tests/config/bad_conf_duplicate_1.yaml", include_locations=True, validate=True) - assert "MagnetArray HCORR : duplicate name SH1A-C02-H @index 2" in str(exc) + assert "MagnetArray HCORR : duplicate name SH1A-C02-H @index 2" in str(exc.value) with pytest.raises(PyAMLConfigException) as exc: ml: Accelerator = Accelerator.load("tests/config/bad_conf_duplicate_2.yaml", include_locations=True, validate=True) - assert "BPMArray BPM : duplicate name BPM_C04-06 @index 3" in str(exc) + assert "BPMArray BPM : duplicate name BPM_C04-06 @index 3" in str(exc.value) with pytest.raises(PyAMLConfigException) as exc: ml: Accelerator = Accelerator.load("tests/config/bad_conf_duplicate_3.yaml", include_locations=True, validate=True) - assert "Configuration entry 'BPM_C04-06' is duplicated inside category 'devices'" in str(exc) - assert "bad_conf_duplicate_3.yaml" in str(exc) - assert "line 43, column 3" in str(exc) + assert "Configuration entry 'BPM_C04-06' is duplicated inside category 'devices'" in str(exc.value) + assert "bad_conf_duplicate_3.yaml" in str(exc.value) + assert "line 43, column 3" in str(exc.value) with pytest.raises(PyAMLConfigException) as exc: ml: Accelerator = Accelerator.load("tests/config/bad_conf_duplicate_4.yaml", include_locations=True, validate=True) - assert "MagnetArray HCORR : duplicate name SH1A-C02-H @index 2" in str(exc) + assert "MagnetArray HCORR : duplicate name SH1A-C02-H @index 2" in str(exc.value) sr: Accelerator = Accelerator.load("tests/config/EBSTune.yaml", include_locations=True, validate=True) m1 = sr.live.get_magnet("QF1E-C04") m2 = sr.design.get_magnet("QF1A-C05") with pytest.raises(PyAMLException) as exc: ma = MagnetArray("Test", [m1, m2]) - assert "MagnetArray Test: All elements must be attached to the same instance" in str(exc) + assert "MagnetArray Test: All elements must be attached to the same instance" in str(exc.value) with pytest.raises(PyAMLException) as exc: m2 = sr.design.get_magnet("QF1A-C05XX") - assert "Magnet QF1A-C05XX not defined" in str(exc) + assert "Magnet QF1A-C05XX not defined" in str(exc.value) with pytest.raises(PyAMLException) as exc: m2 = sr.design.get_bpm("QF1A-C05XX") - assert "BPM QF1A-C05XX not defined" in str(exc) + assert "BPM QF1A-C05XX not defined" in str(exc.value) def test_duplicate_error_reports_source_line_and_column_across_files(tmp_path):