Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 53 additions & 50 deletions pyaml/bpm/bpm.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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:
"""
Expand All @@ -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]:
"""
Expand All @@ -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"])
4 changes: 3 additions & 1 deletion pyaml/configuration/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 34 additions & 4 deletions pyaml/validation/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
18 changes: 9 additions & 9 deletions tests/common/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading