From 6a69f015a73a94c5616fde3a77402bb252607f26 Mon Sep 17 00:00:00 2001 From: Jacob Williamson Date: Mon, 30 Mar 2026 16:06:19 +0000 Subject: [PATCH] Add zoom levels config model --- .../app/_file_converter_map.py | 3 +- src/daq_config_server/models/__init__.py | 8 +--- src/daq_config_server/models/oav/__init__.py | 4 ++ .../models/{ => oav}/display_config_models.py | 26 ++---------- .../models/oav/per_zoom_level.py | 25 +++++++++++ .../models/oav/zoom_levels.py | 37 ++++++++++++++++ src/daq_config_server/models/utils.py | 4 +- tests/conftest.py | 2 +- tests/constants.py | 3 ++ tests/system_tests/test_client.py | 3 +- tests/test_data/test_jCameraManZoomLevels.xml | 42 +++++++++++++++++++ tests/unit_tests/app/test_client.py | 3 +- .../models/test_display_config_models.py | 33 ++++++++++++++- tests/unit_tests/models/test_utils.py | 3 +- 14 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 src/daq_config_server/models/oav/__init__.py rename src/daq_config_server/models/{ => oav}/display_config_models.py (65%) create mode 100644 src/daq_config_server/models/oav/per_zoom_level.py create mode 100644 src/daq_config_server/models/oav/zoom_levels.py create mode 100644 tests/test_data/test_jCameraManZoomLevels.xml diff --git a/src/daq_config_server/app/_file_converter_map.py b/src/daq_config_server/app/_file_converter_map.py index 984ef3e9..76810095 100644 --- a/src/daq_config_server/app/_file_converter_map.py +++ b/src/daq_config_server/app/_file_converter_map.py @@ -3,7 +3,7 @@ import xmltodict -from daq_config_server.models import DisplayConfig, beamline_parameters_to_dict +from daq_config_server.models import beamline_parameters_to_dict from daq_config_server.models.base_model import ConfigModel from daq_config_server.models.feature_settings.hyperion_feature_settings import ( HyperionFeatureSettings, @@ -21,6 +21,7 @@ UndulatorEnergyGapLookupTable, parse_i09_hu_undulator_energy_gap_lut, ) +from daq_config_server.models.oav import DisplayConfig FILE_TO_CONVERTER_MAP: dict[str, Callable[[str], ConfigModel | dict[str, Any]]] = { # type: ignore "/tests/test_data/test_good_lut.txt": UndulatorEnergyGapLookupTable.from_contents, # For system tests # noqa: E501 diff --git a/src/daq_config_server/models/__init__.py b/src/daq_config_server/models/__init__.py index 7ae9a6ee..5201e993 100644 --- a/src/daq_config_server/models/__init__.py +++ b/src/daq_config_server/models/__init__.py @@ -1,10 +1,4 @@ from .base_model import ConfigModel from .beamline_parameters import beamline_parameters_to_dict -from .display_config_models import DisplayConfig, DisplayConfigData -__all__ = [ - "ConfigModel", - "beamline_parameters_to_dict", - "DisplayConfig", - "DisplayConfigData", -] +__all__ = ["ConfigModel", "beamline_parameters_to_dict"] diff --git a/src/daq_config_server/models/oav/__init__.py b/src/daq_config_server/models/oav/__init__.py new file mode 100644 index 00000000..31c7903c --- /dev/null +++ b/src/daq_config_server/models/oav/__init__.py @@ -0,0 +1,4 @@ +from .display_config_models import DisplayConfig, DisplayConfigData +from .zoom_levels import ZoomLevelData, ZoomLevels + +__all__ = ["DisplayConfig", "DisplayConfigData", "ZoomLevels", "ZoomLevelData"] diff --git a/src/daq_config_server/models/display_config_models.py b/src/daq_config_server/models/oav/display_config_models.py similarity index 65% rename from src/daq_config_server/models/display_config_models.py rename to src/daq_config_server/models/oav/display_config_models.py index 1cddfe71..60a87f2c 100644 --- a/src/daq_config_server/models/display_config_models.py +++ b/src/daq_config_server/models/oav/display_config_models.py @@ -1,12 +1,8 @@ from typing import Self -from pydantic import model_validator - from daq_config_server.models.base_model import ConfigModel -from daq_config_server.models.utils import ( - camel_to_snake_case, - remove_comments, -) +from daq_config_server.models.oav.per_zoom_level import PerZoomLevel +from daq_config_server.models.utils import camel_to_snake_case, remove_comments class DisplayConfigData(ConfigModel): @@ -18,23 +14,7 @@ class DisplayConfigData(ConfigModel): bottom_right_y: int -class DisplayConfig(ConfigModel): - zoom_levels: dict[float, DisplayConfigData] - required_zoom_levels: set[float] | None = None - - @model_validator(mode="after") - def check_zoom_levels_match_required(self): - existing_keys = set(self.zoom_levels.keys()) - if ( - self.required_zoom_levels is not None - and self.required_zoom_levels != existing_keys - ): - raise ValueError( - f"Zoom levels {existing_keys} " - f"do not match required zoom levels: {self.required_zoom_levels}" - ) - return self - +class DisplayConfig(PerZoomLevel[DisplayConfigData]): @classmethod def from_contents(cls, contents: str) -> Self: lines = contents.splitlines() diff --git a/src/daq_config_server/models/oav/per_zoom_level.py b/src/daq_config_server/models/oav/per_zoom_level.py new file mode 100644 index 00000000..0bf2f270 --- /dev/null +++ b/src/daq_config_server/models/oav/per_zoom_level.py @@ -0,0 +1,25 @@ +from typing import Generic, TypeVar + +from pydantic import model_validator + +from daq_config_server.models.base_model import ConfigModel + +T = TypeVar("T", bound=ConfigModel) + + +class PerZoomLevel(ConfigModel, Generic[T]): + zoom_levels: dict[float, T] + required_zoom_levels: set[float] | None = None + + @model_validator(mode="after") + def check_zoom_levels_match_required(self): + existing_keys = set(self.zoom_levels.keys()) + if ( + self.required_zoom_levels is not None + and self.required_zoom_levels != existing_keys + ): + raise ValueError( + f"Zoom levels {existing_keys} " + f"do not match required zoom levels: {self.required_zoom_levels}" + ) + return self diff --git a/src/daq_config_server/models/oav/zoom_levels.py b/src/daq_config_server/models/oav/zoom_levels.py new file mode 100644 index 00000000..3551f299 --- /dev/null +++ b/src/daq_config_server/models/oav/zoom_levels.py @@ -0,0 +1,37 @@ +from typing import Self + +import xmltodict + +from daq_config_server.models.base_model import ConfigModel +from daq_config_server.models.oav.per_zoom_level import PerZoomLevel +from daq_config_server.models.utils import camel_to_snake_case + + +class ZoomLevelData(ConfigModel): + position: int + microns_per_x_pixel: float + microns_per_y_pixel: float + + +class ZoomLevels(PerZoomLevel[ZoomLevelData]): + tolerance: float + + @classmethod + def from_jcamera_man_zoom_levels(cls, contents: str) -> Self: + config_dict = xmltodict.parse(contents) + zoom_levels_list: list[dict[str, str]] = config_dict["JCameraManSettings"][ + "levels" + ]["zoomLevel"] + zoom_levels: dict[float, ZoomLevelData] = {} + for zoom_level in zoom_levels_list: + level = float(zoom_level["level"]) + new_zoom_level = { + camel_to_snake_case(key): value + for key, value in zoom_level.items() + if key != "level" + } + zoom_levels[level] = ZoomLevelData.model_validate(new_zoom_level) + return cls( + zoom_levels=zoom_levels, + tolerance=config_dict["JCameraManSettings"]["tolerance"], + ) diff --git a/src/daq_config_server/models/utils.py b/src/daq_config_server/models/utils.py index 5ca75837..7a00a28b 100644 --- a/src/daq_config_server/models/utils.py +++ b/src/daq_config_server/models/utils.py @@ -12,7 +12,9 @@ def remove_comments(lines: Iterable[str]) -> list[str]: def camel_to_snake_case(value: str) -> str: - return re.sub(r"([a-z])([A-Z])", r"\1_\2", value).lower() + value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", value) + value = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value) + return value.lower() DEFAULT_IGNORE_LINES_STARTING_WITH = ("Units", "ScannableUnits", "ScannableNames") diff --git a/tests/conftest.py b/tests/conftest.py index 09808908..c7541795 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,12 +7,12 @@ from daq_config_server.models import ( ConfigModel, - DisplayConfig, beamline_parameters_to_dict, ) from daq_config_server.models.lookup_tables.insertion_device import ( UndulatorEnergyGapLookupTable, ) +from daq_config_server.models.oav import DisplayConfig from tests.constants import ServerFilePaths, TestDataPaths diff --git a/tests/constants.py b/tests/constants.py index aa698e88..6b5e9259 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -52,6 +52,9 @@ class TestDataPaths: TEST_I15_1_XPDF_LOCAL_PARAMETERS = TEST_DATA_DIR_PATH.joinpath( "test_xpdfLocalParameters.xml" ) + TEST_JCAMERA_MAN_ZOOM_LEVELS = TEST_DATA_DIR_PATH.joinpath( + "test_jCameraManZoomLevels.xml" + ) # These are the file locations accessible from the server running in a container diff --git a/tests/system_tests/test_client.py b/tests/system_tests/test_client.py index a572b109..869668aa 100644 --- a/tests/system_tests/test_client.py +++ b/tests/system_tests/test_client.py @@ -12,7 +12,7 @@ FILE_TO_CONVERTER_MAP, ) from daq_config_server.app.client import ConfigClient -from daq_config_server.models import ConfigModel, DisplayConfig +from daq_config_server.models import ConfigModel from daq_config_server.models.lookup_tables import ( BeamlinePitchLookupTable, BeamlineRollLookupTable, @@ -20,6 +20,7 @@ from daq_config_server.models.lookup_tables.insertion_device import ( UndulatorEnergyGapLookupTable, ) +from daq_config_server.models.oav import DisplayConfig from tests.constants import ( ServerFilePaths, TestDataPaths, diff --git a/tests/test_data/test_jCameraManZoomLevels.xml b/tests/test_data/test_jCameraManZoomLevels.xml new file mode 100644 index 00000000..d751fd69 --- /dev/null +++ b/tests/test_data/test_jCameraManZoomLevels.xml @@ -0,0 +1,42 @@ + + + + + 1.0 + 0 + 2.87 + 2.87 + + + 2.5 + 10 + 2.31 + 2.31 + + + 5.0 + 25 + 1.58 + 1.58 + + + 7.5 + 50 + 0.806 + 0.806 + + + 10.0 + 75 + 0.438 + 0.438 + + + 15.0 + 90 + 0.302 + 0.302 + + +1.0 + diff --git a/tests/unit_tests/app/test_client.py b/tests/unit_tests/app/test_client.py index 7bf9dd3e..c1556512 100644 --- a/tests/unit_tests/app/test_client.py +++ b/tests/unit_tests/app/test_client.py @@ -16,7 +16,7 @@ TypeConversionError, _get_mime_type, ) -from daq_config_server.models import ConfigModel, DisplayConfig +from daq_config_server.models import ConfigModel from daq_config_server.models.lookup_tables import ( BeamlinePitchLookupTable, GenericLookupTable, @@ -24,6 +24,7 @@ from daq_config_server.models.lookup_tables.insertion_device import ( UndulatorEnergyGapLookupTable, ) +from daq_config_server.models.oav import DisplayConfig from daq_config_server.testing import make_test_response test_path = Path("test") diff --git a/tests/unit_tests/models/test_display_config_models.py b/tests/unit_tests/models/test_display_config_models.py index 46b5ea28..39619221 100644 --- a/tests/unit_tests/models/test_display_config_models.py +++ b/tests/unit_tests/models/test_display_config_models.py @@ -2,7 +2,8 @@ import pytest -from daq_config_server.models import DisplayConfig, DisplayConfigData +from daq_config_server.models.oav import DisplayConfig, DisplayConfigData +from daq_config_server.models.oav.zoom_levels import ZoomLevelData, ZoomLevels from tests.constants import TestDataPaths @@ -58,3 +59,33 @@ def test_display_config_with_wrong_zoom_levels_causes_error(): match="Zoom levels {1.0, 2.5} do not match required zoom levels: {1.0, 3.0}", ): DisplayConfig(zoom_levels=zoom_levels, required_zoom_levels=({1.0, 3.0})) + + +def test_zoom_levels_config_model_can_parse_j_camera_man_file(): + with open(TestDataPaths.TEST_JCAMERA_MAN_ZOOM_LEVELS) as f: + contents = f.read() + expected = ZoomLevels( + zoom_levels={ + 1.0: ZoomLevelData( + position=0, microns_per_x_pixel=2.87, microns_per_y_pixel=2.87 + ), + 2.5: ZoomLevelData( + position=10, microns_per_x_pixel=2.31, microns_per_y_pixel=2.31 + ), + 5.0: ZoomLevelData( + position=25, microns_per_x_pixel=1.58, microns_per_y_pixel=1.58 + ), + 7.5: ZoomLevelData( + position=50, microns_per_x_pixel=0.806, microns_per_y_pixel=0.806 + ), + 10.0: ZoomLevelData( + position=75, microns_per_x_pixel=0.438, microns_per_y_pixel=0.438 + ), + 15.0: ZoomLevelData( + position=90, microns_per_x_pixel=0.302, microns_per_y_pixel=0.302 + ), + }, + tolerance=1.0, + ) + result = ZoomLevels.from_jcamera_man_zoom_levels(contents) + assert result == expected diff --git a/tests/unit_tests/models/test_utils.py b/tests/unit_tests/models/test_utils.py index a449f0db..c60fc99c 100644 --- a/tests/unit_tests/models/test_utils.py +++ b/tests/unit_tests/models/test_utils.py @@ -32,7 +32,8 @@ def test_remove_comments_works_as_expected(): ("camelCase", "camel_case"), ("_Camel_Case", "_camel_case"), ("CAMELCASE", "camelcase"), - ("CAMELCAsE", "camelcas_e"), + ("CAMELCAsE", "camelc_as_e"), + ("micronsPerXPixel", "microns_per_x_pixel"), ], ) def test_camel_to_snake_case_works_as_expected(camel_case: str, snake_case: str):