diff --git a/docs/how-to/config-server-guide.md b/docs/how-to/config-server-guide.md index 211b08b..c80c3c8 100644 --- a/docs/how-to/config-server-guide.md +++ b/docs/how-to/config-server-guide.md @@ -15,7 +15,14 @@ You can then make a request through this client through its `get_file_contents` ```python config_server.get_file_contents(FILE_PATH,reset_cached_result=True) ``` -By default, this will return the file's raw string output - which includes things like linebreaks. It is up to you to format this output - using `splitlines()` will get you a list where each item is a line in the file. `get_file_contents` has a `desired_return_type` parameter where you can instead ask for a `dict` or `bytes`. The server will try and do the conversion, and respond with an HTTP error if the requested file is incompatible with that type. +By default, this will return the file's raw string output - which includes things like linebreaks. It is up to you to format this output - using `splitlines()` will get you a list where each item is a line in the file. `get_file_contents` has a `desired_return_type` parameter where you can instead ask for a `dict`, `bytes` or a one of the `pydantic models` defined in the server. The server will try and do the conversion, and respond with an HTTP error if the requested file is incompatible with that type, or a pydantic validation error if the data cannot be converted to the pydantic model provided. Custom [file converters](#file-converters) can be used to specify how a file should be converted to a dict or pydantic model. + +(file-converters)= +# File converters + +Converters can be used to turn a file into a standard format server-side, reducing the complexity of reading config files client-side. Converters can convert config files to a `dict` or `pydantic model`, and the same `pydantic model` can be reconstructed client-side by the `get_file_contents` method. Available converters exist [here](https://github.com/DiamondLightSource/daq-config-server/blob/main/converters._converters.py) - see if there's a suitable converter you can use before adding your own. [This dictionary](https://github.com/DiamondLightSource/daq-config-server/blob/main/converters._file_converter_map.py) maps files to converters. Add the path of your config file and a suitable converter to this dictionary and it will automatically be used by the config server when a request for that file is made. + +A request for `str` or `bytes` will fetch the raw file with no conversion. # Adding files to the whitelist diff --git a/docs/reference/current_and_planned_features.md b/docs/reference/current_and_planned_features.md index 3537018..b2aab4a 100644 --- a/docs/reference/current_and_planned_features.md +++ b/docs/reference/current_and_planned_features.md @@ -6,11 +6,11 @@ - Periodically check the main branch's to update the whitelist. - Provide a client module for users to easily communicate with the server, with caching. - Have this service hosted on diamond's central argus cluster - with url `https://daq-config-server.diamond.ac.uk` +- Provide server-side json and pydantic model formatting for commonly used configuration files - eg `beamline_parameters.txt` should be returned as a dictionary or pydantic model. ## Future features Note that this is not actively ongoing work, but features that we are aware will be needed in the future. -- Provide server-side formatting for commonly used configuration files - eg `beamline_parameters.txt` should be returned as a dictionary. - Remove absolute filepath as a user dependancy. For example, once we have a good picture of which files are being read, the client should be able to request the beamline parameters with something like `client.get_file_contents(SUPPORTED_CONFIG_FILES.BeamlineParameters, beamline=i03)` - Add authorisation + authentication. This is a pre-requisite for file-writing. - Add endpoints for configuration file-writing. At this point, we can begin to remove any configuration from the filesystem. The config-server will use a redis database to store these values, and we can have a simple web-interface to change the values. diff --git a/pyproject.toml b/pyproject.toml index b4bebb3..ed16e4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "pydantic", "urllib3", "requests", + "xmltodict", ] [project.optional-dependencies] diff --git a/src/daq_config_server/app.py b/src/daq_config_server/app.py index a151a0f..ad29a4e 100644 --- a/src/daq_config_server/app.py +++ b/src/daq_config_server/app.py @@ -1,4 +1,3 @@ -import json import logging import os from collections.abc import Awaitable, Callable @@ -16,6 +15,7 @@ from daq_config_server.constants import ( ENDPOINTS, ) +from daq_config_server.converters import get_converted_file_contents from daq_config_server.log import set_up_logging from daq_config_server.whitelist import get_whitelist @@ -134,8 +134,7 @@ def get_configuration( try: match accept: case ValidAcceptHeaders.JSON: - with file_path.open("r", encoding="utf-8") as f: - content = json.loads(f.read()) + content = get_converted_file_contents(file_path) return JSONResponse( content=content, ) diff --git a/src/daq_config_server/client.py b/src/daq_config_server/client.py index 5ffd4ce..649ad61 100644 --- a/src/daq_config_server/client.py +++ b/src/daq_config_server/client.py @@ -1,7 +1,7 @@ import operator from logging import Logger, getLogger from pathlib import Path -from typing import Any, TypeVar, get_origin +from typing import Any, TypeVar, get_origin, overload import requests from cachetools import TTLCache, cachedmethod @@ -10,18 +10,26 @@ from requests.exceptions import HTTPError from daq_config_server.app import ValidAcceptHeaders +from daq_config_server.converters.models import ConfigModel from .constants import ENDPOINTS -T = TypeVar("T", str, bytes, dict[Any, Any]) +TModel = TypeVar("TModel", bound=ConfigModel) +TNonModel = TypeVar("TNonModel", str, bytes, dict[str, Any]) class TypeConversionException(Exception): ... -def _get_mime_type(requested_return_type: type[T]) -> ValidAcceptHeaders: +def _get_mime_type( + requested_return_type: type[TModel | TNonModel], +) -> ValidAcceptHeaders: # Get correct mapping for typed dict or plain dict - if get_origin(requested_return_type) is dict or requested_return_type is dict: + if ( + get_origin(requested_return_type) is dict + or requested_return_type is dict + or issubclass(requested_return_type, ConfigModel) + ): return ValidAcceptHeaders.JSON elif requested_return_type is bytes: return ValidAcceptHeaders.RAW_BYTES @@ -131,12 +139,28 @@ def _get( return content + @overload + def get_file_contents( + self, + file_path: str | Path, + desired_return_type: type[TNonModel] = str, + reset_cached_result: bool = False, + ) -> TNonModel: ... + + @overload + def get_file_contents( + self, + file_path: str | Path, + desired_return_type: type[TModel], + reset_cached_result: bool = False, + ) -> TModel: ... + def get_file_contents( self, - file_path: Path | str, - desired_return_type: type[T] = str, + file_path: str | Path, + desired_return_type: type[Any] = str, reset_cached_result: bool = False, - ) -> T: + ) -> Any: """ Get contents of a file from the config server in the format specified. Optionally look for cached result before making request. diff --git a/src/daq_config_server/converters/__init__.py b/src/daq_config_server/converters/__init__.py new file mode 100644 index 0000000..7c22741 --- /dev/null +++ b/src/daq_config_server/converters/__init__.py @@ -0,0 +1,3 @@ +from daq_config_server.converters.convert import get_converted_file_contents + +__all__ = ["get_converted_file_contents"] diff --git a/src/daq_config_server/converters/_converter_utils.py b/src/daq_config_server/converters/_converter_utils.py new file mode 100644 index 0000000..94a3728 --- /dev/null +++ b/src/daq_config_server/converters/_converter_utils.py @@ -0,0 +1,48 @@ +import ast +from typing import Any + +from daq_config_server.converters.models import GenericLookupTable + +ALLOWED_BEAMLINE_PARAMETER_STRINGS = ["FB", "FULL", "deadtime"] + + +class ConverterParseError(Exception): ... + + +def remove_comments(lines: list[str]) -> list[str]: + return [ + line.strip().split("#", 1)[0].strip() + for line in lines + if line.strip().split("#", 1)[0] + ] + + +def parse_value(value: str, convert_to: type | None = None) -> Any: + """Convert a string value into an appropriate Python type. Optionally provide a type + to convert to. If not given, the type will be inferred. + """ + value = ast.literal_eval(value.replace("Yes", "True").replace("No", "False")) + if convert_to: + value = convert_to(value) + return value + + +def parse_lut(contents: str, *params: tuple[str, type | None]) -> GenericLookupTable: + """Converts a lookup table to a pydantic model, containing the names of each column + and the rows as a 2D list. + + Any args after the contents provide the column names and optionally, python types + for values in a column to be converted to. e.g: (energy_EV, float), (pixels, int). + If a type is provided, the values in that column will be converted to that type. + Otherwise, the type will be inferred. Units should be included in the column name. + """ + rows: list[list[Any]] = [] + column_names = [param[0] for param in params] + types = [param[1] for param in params] + for line in remove_comments(contents.splitlines()): + if line.startswith("Units"): + continue + rows.append( + [parse_value(value, types[i]) for i, value in enumerate(line.split())] + ) + return GenericLookupTable(column_names=column_names, rows=rows) diff --git a/src/daq_config_server/converters/_converters.py b/src/daq_config_server/converters/_converters.py new file mode 100644 index 0000000..1f5e64d --- /dev/null +++ b/src/daq_config_server/converters/_converters.py @@ -0,0 +1,82 @@ +from typing import Any + +from daq_config_server.converters._converter_utils import ( + ALLOWED_BEAMLINE_PARAMETER_STRINGS, + GenericLookupTable, + parse_lut, + parse_value, + remove_comments, +) +from daq_config_server.converters.models import DisplayConfig, DisplayConfigData + + +def beamline_parameters_to_dict(contents: str) -> dict[str, Any]: + """Extracts the key value pairs from a beamline parameters file. If the value + is in ALLOWED_BEAMLINE_PARAMETER_STRINGS, it leaves it as a string. Otherwise, it is + converted to a number or bool.""" + lines = contents.splitlines() + config_pairs: dict[str, Any] = {} + + # Get dict of parameter keys and values + for line in remove_comments(lines): + splitline = line.split("=") + if len(splitline) >= 2: + param, value = line.split("=") + if param.strip() in config_pairs: + raise ValueError(f"Repeated key in parameters: {param}") + config_pairs[param.strip()] = value.strip() + + # Parse each value + for param, value in config_pairs.items(): + if value not in ALLOWED_BEAMLINE_PARAMETER_STRINGS: + config_pairs[param] = parse_value(value) + return dict(config_pairs) + + +def display_config_to_model(contents: str) -> DisplayConfig: + lines = contents.splitlines() + config_dict: dict[float, dict[str, int | float]] = {} + zoom_level = None + + for line in remove_comments(lines): + key, value = (item.strip() for item in line.split("=", 1)) + if key == "zoomLevel": + zoom_level = float(value) + assert zoom_level not in config_dict.keys(), ( + f"Multiple instances of zoomLevel {zoom_level}" + ) + config_dict[zoom_level] = {} + continue + + assert zoom_level is not None, "File must start with a zoom level" + assert key not in config_dict[zoom_level].keys(), ( + "File can't have repeated keys for a given zoom level" + ) + config_dict[zoom_level][key] = parse_value(value) + zoom_levels = { + key: DisplayConfigData.model_validate(value) + for key, value in config_dict.items() + } + + return DisplayConfig(zoom_levels=zoom_levels) + + +def detector_xy_lut(contents: str) -> GenericLookupTable: + return parse_lut( + contents, + ("detector_distances_mm", float), + ("beam_centre_x_mm", float), + ("beam_centre_y_mm", float), + ) + + +def beamline_pitch_lut(contents: str) -> GenericLookupTable: + return parse_lut(contents, ("bragg_angle_deg", float), ("pitch_mrad", float)) + + +def beamline_roll_lut(contents: str) -> GenericLookupTable: + return parse_lut(contents, ("bragg_angle_deg", float), ("roll_mrad", float)) + + +def undulator_energy_gap_lut(contents: str) -> GenericLookupTable: + return parse_lut(contents, ("energy_eV", int), ("gap_mm", float)) diff --git a/src/daq_config_server/converters/_file_converter_map.py b/src/daq_config_server/converters/_file_converter_map.py new file mode 100644 index 0000000..6531a8a --- /dev/null +++ b/src/daq_config_server/converters/_file_converter_map.py @@ -0,0 +1,38 @@ +from collections.abc import Callable +from typing import Any + +import xmltodict + +from daq_config_server.converters._converters import ( + beamline_parameters_to_dict, + beamline_pitch_lut, + beamline_roll_lut, + detector_xy_lut, + display_config_to_model, + undulator_energy_gap_lut, +) +from daq_config_server.converters.models import ConfigModel + +FILE_TO_CONVERTER_MAP: dict[str, Callable[[str], ConfigModel | dict[str, Any]]] = { # type: ignore + "/tests/test_data/test_good_lut.txt": undulator_energy_gap_lut, # For system tests # noqa + "/dls_sw/i23/software/aithre/aithre_display.configuration": display_config_to_model, + "/dls_sw/i03/software/gda_versions/var/display.configuration": display_config_to_model, # noqa + "/dls_sw/i04/software/bluesky/scratch/display.configuration": display_config_to_model, # noqa + "/dls_sw/i19-1/software/daq_configuration/domain/display.configuration": display_config_to_model, # noqa + "/dls_sw/i24/software/gda_versions/var/display.configuration": display_config_to_model, # noqa + "/dls_sw/i03/software/gda/configurations/i03-config/xml/jCameraManZoomLevels.xml": xmltodict.parse, # noqa + "/dls_sw/i04/software/bluesky/scratch/jCameraManZoomLevels.xml": xmltodict.parse, + "/dls_sw/i19-1/software/gda_versions/gda/config/xml/jCameraManZoomLevels.xml": xmltodict.parse, # noqa + "/dls_sw/i24/software/gda_versions/gda/config/xml/jCameraManZoomLevels.xml": xmltodict.parse, # noqa + "/dls_sw/i03/software/daq_configuration/domain/beamlineParameters": beamline_parameters_to_dict, # noqa + "/dls_sw/i04/software/daq_configuration/domain/beamlineParameters": beamline_parameters_to_dict, # noqa + "/dls_sw/i03/software/daq_configuration/lookup/DetDistToBeamXYConverter.txt": detector_xy_lut, # noqa + "/dls_sw/i04/software/daq_configuration/lookup/DetDistToBeamXYConverter.txt": detector_xy_lut, # noqa + "/dls_sw/i04-1/software/daq_configuration/lookup/DetDistToBeamXYConverter.txt": detector_xy_lut, # noqa + "/dls_sw/i19-1/software/daq_configuration/lookup/DetDistToBeamXYConverter.txt": detector_xy_lut, # noqa + "/dls_sw/i23/software/daq_configuration/lookup/DetDistToBeamXYConverter.txt": detector_xy_lut, # noqa + "/dls_sw/i24/software/daq_configuration/lookup/DetDistToBeamXYConverter.txt": detector_xy_lut, # noqa + "/dls_sw/i03/software/daq_configuration/lookup/BeamLineEnergy_DCM_Pitch_converter.txt": beamline_pitch_lut, # noqa + "/dls_sw/i03/software/daq_configuration/lookup/BeamLineEnergy_DCM_Roll_converter.txt": beamline_roll_lut, # noqa + "/dls_sw/i03/software/daq_configuration/lookup/BeamLine_Undulator_toGap.txt": undulator_energy_gap_lut, # noqa +} diff --git a/src/daq_config_server/converters/convert.py b/src/daq_config_server/converters/convert.py new file mode 100644 index 0000000..35073ad --- /dev/null +++ b/src/daq_config_server/converters/convert.py @@ -0,0 +1,24 @@ +import json +from pathlib import Path +from typing import Any + +import daq_config_server.converters._file_converter_map as file_converter_map +from daq_config_server.converters._converter_utils import ConverterParseError +from daq_config_server.converters.models import ConfigModel + + +def get_converted_file_contents(file_path: Path) -> dict[str, Any]: + with file_path.open("r", encoding="utf-8") as f: + raw_contents = f.read() + if converter := file_converter_map.FILE_TO_CONVERTER_MAP.get(str(file_path)): + try: + contents = converter(raw_contents) + if isinstance(contents, ConfigModel): + return contents.model_dump() + return contents + except Exception as e: + raise ConverterParseError( + f"Unable to parse {str(file_path)} due to the following exception: \ + {type(e).__name__}: {e}" + ) from e + return json.loads(raw_contents) diff --git a/src/daq_config_server/converters/models.py b/src/daq_config_server/converters/models.py new file mode 100644 index 0000000..923ac8c --- /dev/null +++ b/src/daq_config_server/converters/models.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel, model_validator + + +class ConfigModel(BaseModel): ... + + +class DisplayConfigData(ConfigModel): + crosshairX: int + crosshairY: int + topLeftX: int + topLeftY: int + bottomRightX: int + bottomRightY: 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 GenericLookupTable(ConfigModel): + column_names: list[str] + rows: list[list[int | float]] + + @model_validator(mode="after") + def check_row_length_matches_n_columns(self): + n_columns = len(self.column_names) + for row in self.rows: + if len(row) != n_columns: + raise ValueError( + f"Length of row {row} does not match number " + f"of columns: {self.column_names}" + ) + return self diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4fc69a7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,33 @@ +from collections.abc import Callable, Generator, Mapping +from typing import Any +from unittest.mock import patch + +import pytest +import xmltodict + +from daq_config_server.converters._converters import ( + beamline_parameters_to_dict, + display_config_to_model, + undulator_energy_gap_lut, +) +from daq_config_server.converters.models import ConfigModel +from tests.constants import ServerFilePaths, TestDataPaths + + +@pytest.fixture +def mock_file_converter_map() -> Generator[ + Mapping[str, Callable[[str], ConfigModel | dict[str, Any]]], None, None +]: + with patch( + "daq_config_server.converters._file_converter_map.FILE_TO_CONVERTER_MAP", + { + str(TestDataPaths.TEST_GOOD_XML_PATH): xmltodict.parse, + str( + TestDataPaths.TEST_BEAMLINE_PARAMETERS_PATH + ): beamline_parameters_to_dict, + str(TestDataPaths.TEST_GOOD_LUT_PATH): undulator_energy_gap_lut, + str(TestDataPaths.TEST_GOOD_DISPLAY_CONFIG_PATH): display_config_to_model, + str(ServerFilePaths.GOOD_LUT): undulator_energy_gap_lut, + }, + ) as mock_map: + yield mock_map diff --git a/tests/constants.py b/tests/constants.py index 6fd8b3b..67caa16 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -12,6 +12,10 @@ class TestDataPaths: "beamline_parameters.txt" ) + TEST_BAD_BEAMLINE_PARAMETERS_PATH = TEST_DATA_DIR_PATH.joinpath( + "bad_beamline_parameters.txt" + ) + TEST_BAD_JSON_PATH = TEST_DATA_DIR_PATH.joinpath("test_bad_json") TEST_GOOD_JSON_PATH = TEST_DATA_DIR_PATH.joinpath("test_good_json.json") @@ -26,6 +30,20 @@ class TestDataPaths: TEST_INVALID_FILE_PATH = TEST_DATA_DIR_PATH.joinpath("invalid_file") + TEST_GOOD_LUT_PATH = TEST_DATA_DIR_PATH.joinpath("test_good_lut.txt") + + TEST_BAD_LUT_PATH = TEST_DATA_DIR_PATH.joinpath("test_bad_lut.txt") + + TEST_GOOD_DISPLAY_CONFIG_PATH = TEST_DATA_DIR_PATH.joinpath( + "test_display.configuration" + ) + + TEST_GOOD_XML_PATH = TEST_DATA_DIR_PATH.joinpath("test_xml.xml") + + EXPECTED_BEAMLINE_PARAMETERS_JSON_PATH = TEST_DATA_DIR_PATH.joinpath( + "expected_beamline_parameters.json" + ) + # These are the file locations accessible from the server running in a container @dataclass @@ -34,6 +52,7 @@ class ServerFilePaths: GOOD_JSON_FILE = Path("/tests/test_data/test_good_json.json") BAD_JSON_FILE = Path("/tests/test_data/test_bad_json") FILE_IN_GOOD_DIR = Path("/tests/test_data/test_bad_json") + GOOD_LUT = Path("/tests/test_data/test_good_lut.txt") TEST_CONFIG_PATH = TEST_DATA_DIR_PATH.joinpath("test_config.yaml") diff --git a/tests/system_tests/test_client.py b/tests/system_tests/test_client.py index 0c720f4..b06acd0 100644 --- a/tests/system_tests/test_client.py +++ b/tests/system_tests/test_client.py @@ -1,12 +1,19 @@ import json import os -from typing import Any -from unittest.mock import MagicMock +from typing import Any, get_type_hints +from unittest.mock import MagicMock, patch import pytest import requests +from pydantic import ValidationError +import daq_config_server.converters._file_converter_map as file_converter_map from daq_config_server.client import ConfigServer +from daq_config_server.converters.models import ( + ConfigModel, + DisplayConfig, + GenericLookupTable, +) from tests.constants import ( ServerFilePaths, TestDataPaths, @@ -104,3 +111,56 @@ def test_request_with_file_not_on_whitelist(server: ConfigServer): server.get_file_contents( file_path, ) + + +@pytest.mark.requires_local_server +def test_request_for_file_with_converter_works(server: ConfigServer): + expected = { + "column_names": ["energy_eV", "gap_mm"], + "rows": [[5700, 5.4606], [5760, 5.5], [6000, 5.681], [6500, 6.045]], + } + result = server.get_file_contents(ServerFilePaths.GOOD_LUT, dict) + assert result == expected + + +@pytest.mark.requires_local_server +def test_request_for_file_with_converter_works_with_pydantic_model( + server: ConfigServer, +): + expected = GenericLookupTable( + column_names=["energy_eV", "gap_mm"], + rows=[[5700, 5.4606], [5760, 5.5], [6000, 5.681], [6500, 6.045]], + ) + result = server.get_file_contents(ServerFilePaths.GOOD_LUT, GenericLookupTable) + assert isinstance(result, GenericLookupTable) + assert result == expected + + +@pytest.mark.requires_local_server +def test_request_for_file_with_converter_with_wrong_pydantic_model_errors( + server: ConfigServer, +): + with pytest.raises(ValidationError): + server.get_file_contents(ServerFilePaths.GOOD_LUT, DisplayConfig) + + +@pytest.mark.requires_local_server +def test_all_files_in_file_converter_map_can_be_converted_to_dict(server: ConfigServer): + for filename in file_converter_map.FILE_TO_CONVERTER_MAP.keys(): + result = server.get_file_contents(filename, dict) + assert isinstance(result, dict) + + +@pytest.mark.requires_local_server +def test_all_files_in_file_converter_map_can_be_converted_to_target_type( + server: ConfigServer, +): + with patch( + "daq_config_server.converters._file_converter_map.xmltodict.parse.__annotations__", + {"return": dict}, # Force a return type for xmltodict.parse() + ): + for filename, converter in file_converter_map.FILE_TO_CONVERTER_MAP.items(): + return_type = get_type_hints(converter)["return"] + assert return_type is dict or issubclass(return_type, ConfigModel) + result = server.get_file_contents(filename, return_type) + assert isinstance(result, return_type) diff --git a/tests/test_data/bad_beamline_parameters.txt b/tests/test_data/bad_beamline_parameters.txt new file mode 100644 index 0000000..0b8dbed --- /dev/null +++ b/tests/test_data/bad_beamline_parameters.txt @@ -0,0 +1,41 @@ +# +# +BeamLine BL03I + +## BLSE=FB switches between scan alignment and feedback alignment +## by creating bl energy scannable with beamLineSpecificEnergy_FB +## after changing you must restart servers or >>> reset_namespace +BLSE=FB + +## BPFB (Beam Position FeedBack) +## HALF (default) only off during data collection +## FULL only off for XBPM2 during attenuation optimisation, fluo when trans < 2% and wedged MAD +## UNAVAILABLE (not default) prevents xbpm_feedback.py trying to access EPICS IOC that may not be running +BPFB=FULL +## Note: only beamline scientists control whether feedback is enabled +## via the XBPM feedback EDM screen in Synoptic + +# DCM parameters +DCM_Perp_Offset_FIXED = 25.6 +# +# beamstop +# +parked_x = 4.49 +parked_y = -50.0 +parked_y_plate = -50.5 +parked_z = -49.5 +parked_z_robot = 30.0 + +in_beam_z_MIN_START_POS = 60.0 + +in_beam_x_HIGHRES = 1.52 +in_beam_y_HIGHRES = 44.78 +in_beam_z_HIGHRES = 30.0 + +in_beam_x_STANDARD = 1.52 +in_beam_y_STANDARD = 44.78 +in_beam_z_STANDARD = 30.0 + +in_beam_x_LOWRES = Error +in_beam_y_LOWRES = 44.78 +in_beam_z_LOWRES = 48 diff --git a/tests/test_data/expected_beamline_parameters.json b/tests/test_data/expected_beamline_parameters.json new file mode 100644 index 0000000..b757968 --- /dev/null +++ b/tests/test_data/expected_beamline_parameters.json @@ -0,0 +1,185 @@ +{ + "BLSE": "FB", + "BPFB": "FULL", + "DCM_Perp_Offset_FIXED": 25.6, + "parked_x": 4.49, + "parked_y": -50.0, + "parked_y_plate": -50.5, + "parked_z": -49.5, + "parked_z_robot": 30.0, + "in_beam_z_MIN_START_POS": 60.0, + "in_beam_x_HIGHRES": 1.52, + "in_beam_y_HIGHRES": 44.78, + "in_beam_z_HIGHRES": 30.0, + "in_beam_x_STANDARD": 1.52, + "in_beam_y_STANDARD": 44.78, + "in_beam_z_STANDARD": 30.0, + "in_beam_x_LOWRES": 1.52, + "in_beam_y_LOWRES": 44.78, + "in_beam_z_LOWRES": 48, + "checkCryojet": false, + "manualCryojet": true, + "miniap_x_LARGE_APERTURE": 2.389, + "miniap_y_LARGE_APERTURE": 40.986, + "miniap_z_LARGE_APERTURE": 15.8, + "sg_x_LARGE_APERTURE": 5.25, + "sg_y_LARGE_APERTURE": 4.43, + "miniap_x_MEDIUM_APERTURE": 2.384, + "miniap_y_MEDIUM_APERTURE": 44.967, + "miniap_z_MEDIUM_APERTURE": 15.8, + "sg_x_MEDIUM_APERTURE": 5.285, + "sg_y_MEDIUM_APERTURE": 0.46, + "miniap_x_SMALL_APERTURE": 2.43, + "miniap_y_SMALL_APERTURE": 48.974, + "miniap_z_SMALL_APERTURE": 15.8, + "sg_x_SMALL_APERTURE": 5.3375, + "sg_y_SMALL_APERTURE": -3.55, + "miniap_x_ROBOT_LOAD": 2.386, + "miniap_y_ROBOT_LOAD": 31.4, + "miniap_z_ROBOT_LOAD": 15.8, + "sg_x_ROBOT_LOAD": 5.25, + "sg_y_ROBOT_LOAD": 4.43, + "miniap_x_MANUAL_LOAD": -4.91, + "miniap_y_MANUAL_LOAD": -49.0, + "miniap_z_MANUAL_LOAD": -10.0, + "sg_x_MANUAL_LOAD": -4.7, + "sg_y_MANUAL_LOAD": 1.8, + "miniap_x_SCIN_MOVE": -4.91, + "sg_x_SCIN_MOVE": -4.75, + "scin_y_SCIN_IN": 100.855, + "scin_y_SCIN_OUT": -0.02, + "scin_z_SCIN_IN": 101.5115, + "scin_z_SCIN_OUT": 0.1, + "gon_x_SCIN_OUT_DISTANCE": 1.0, + "gon_x_SCIN_OUT_DISTANCE_smargon": 1, + "gon_y_SCIN_OUT_DISTANCE": 2.0, + "gon_z_SCIN_OUT_DISTANCE": -0.5, + "miniap_x_tolerance": 0.004, + "miniap_y_tolerance": 0.1, + "miniap_z_tolerance": 0.1, + "sg_x_tolerance": 0.1, + "sg_y_tolerance": 0.1, + "scin_y_tolerance": 0.1, + "scin_z_tolerance": 0.12, + "gon_x_tolerance": 0.01, + "gon_y_tolerance": 0.1, + "gon_z_tolerance": 0.001, + "bs_x_tolerance": 0.02, + "bs_y_tolerance": 0.005, + "bs_z_tolerance": 0.3, + "crl_x_tolerance": 0.01, + "crl_y_tolerance": 0.01, + "crl_pitch_tolerance": 0.01, + "crl_yaw_tolerance": 0.01, + "sg_y_up_movement_tolerance": 1.0, + "sg_x_timeout": 10, + "sg_y_timeout": 10, + "miniap_x_timeout": 60, + "miniap_y_timeout": 10, + "gon_x_timeout": 60, + "gon_y_timeout": 30, + "gon_z_timeout": 30, + "crl_x_timeout": 10, + "crl_y_timeout": 10, + "crl_pitch_timeout": 10, + "crl_yaw_timeout": 10, + "col_inbeam_tolerance": 1.0, + "col_parked_tolerance": 1.0, + "col_parked_upstream_x": 0.0, + "col_parked_downstream_x": 0.0, + "col_parked_upstream_y": 0.0, + "col_parked_inboard_y": 0.0, + "col_parked_outboard_y": 0.0, + "crl_x_LOWE": -11.78, + "crl_y_LOWE": -4.3, + "crl_pitch_LOWE": -4.75, + "crl_yaw_LOWE": -1.0, + "crl_x_HIGHE": 2.22, + "crl_y_HIGHE": -4.3, + "crl_pitch_HIGHE": -2.75, + "crl_yaw_HIGHE": 0, + "MinBackStopZ": 30.0, + "BackStopYsafe": 20.0, + "BackStopXyag": -4.8, + "BackStopYyag": 17.2, + "BackStopZyag": 19.1, + "SampleYnormal": 2.65, + "SampleYshift": 2.0, + "parked_fluo_x": -18.0, + "in_beam_fluo_x": 12.0, + "move_fluo": true, + "safe_det_z_default": 900, + "safe_det_z_sampleChanger": 337, + "store_data_collections_in_ispyb": true, + "TakePNGsOfSample": true, + "gonio_parked_x": 0.0, + "gonio_parked_y": 0.0, + "gonio_parked_z": 0.0, + "gonio_parked_omega": 0, + "gonio_parked_chi": 0, + "gonio_parked_phi": 0, + "setupBeamLine_energyStart": 7000.0, + "setupBeamLine_energyEnd": 17000.0, + "setupBeamLine_energyStep": 500, + "setupBeamLine_rollStart": -4, + "setupBeamLine_rollEnd": 4, + "setupBeamLine_rollSteps": 21, + "setupBeamLine_pitchStart": -3.7, + "setupBeamLine_pitchEnd": -3.5, + "setupBeamLine_pitchSteps": 200, + "beamXCentre": 0, + "beamYCentre": 0, + "beamXYSettleTime": 6.0, + "beamXYTolerance": 5.0, + "DataCollection_TurboMode": true, + "beamLineEnergy__rollWidth": 0.2, + "beamLineEnergy__rollStep": 0.02, + "beamLineEnergy__pitchWidth": 0.02, + "beamLineEnergy__pitchStep": 0.002, + "beamLineEnergy__fpitchWidth": 0.02, + "beamLineEnergy__fpitchStep": 0.001, + "beamLineEnergy__adjustSlits": false, + "dataCollectionMinSampleCurrent": 0.0, + "MinIPin": 1.0, + "YAGPin": 1, + "RotationAxisPin": 2, + "PtPin": 3, + "PowderPin": 4, + "iPinInDetZ": 340.0, + "DataCollectionDetX": -7.8504, + "DataCollectionDetYaw": 6.499, + "DataCollectionDetY": 48.0, + "StandardEnergy": 12700, + "keyence_max_attempts": 1, + "keyence_slopeYToX": 2.5, + "keyence_slopeYToY": -2.5, + "keyence_slopeXToZ": 3.23, + "YAGSamX": 1022, + "YAGSamY": -98.0, + "YAGSamZ": -147, + "YAGOmega": 0.0, + "ipin_threshold": 0.1, + "mirror_threshold_bare_rh": 6900, + "mirror_threshold_rh_pt": 30000, + "flux_factor_no_aperture": 1, + "flux_factor_LARGE_APERTURE": 0.738, + "flux_factor_MEDIUM_APERTURE": 0.36, + "flux_factor_SMALL_APERTURE": 0.084, + "flux_factor_no_aperture_plate": 1, + "flux_factor_LARGE_APERTURE_plate": 0.738, + "flux_factor_MEDIUM_APERTURE_plate": 0.36, + "flux_factor_SMALL_APERTURE_plate": 0.084, + "pin_diode_factor": 2.66e+19, + "attenuation_optimisation_type": "deadtime", + "fluorescence_analyser_deadtimeThreshold": 0.002, + "fluorescence_spectrum_deadtimeThreshold": 0.0005, + "fluorescence_attenuation_low_roi": 100, + "fluorescence_attenuation_high_roi": 2048, + "attenuation_optimisation_optimisation_cycles": 10, + "attenuation_optimisation_start_transmission": 0.1, + "fluorescence_mca_sca_offset": 400, + "attenuation_optimisation_multiplier": 2, + "attenuation_optimisation_target_count": 2000, + "attenuation_optimisation_upper_limit": 50000, + "attenuation_optimisation_lower_limit": 20000 +} diff --git a/tests/test_data/test_bad_display_1.configuration b/tests/test_data/test_bad_display_1.configuration new file mode 100644 index 0000000..33bdaca --- /dev/null +++ b/tests/test_data/test_bad_display_1.configuration @@ -0,0 +1,3 @@ +zoomLevel = 1.0 +crosshairX = 541 +crosshairX = 409 diff --git a/tests/test_data/test_bad_display_2.configuration b/tests/test_data/test_bad_display_2.configuration new file mode 100644 index 0000000..85bc1cd --- /dev/null +++ b/tests/test_data/test_bad_display_2.configuration @@ -0,0 +1,6 @@ +zoomLevel = 1.0 +crosshairX = 541 +crosshairY = 409 +zoomLevel = 1.0 +crosshairX = 600 +crosshairY = 700 diff --git a/tests/test_data/test_bad_display_3.configuration b/tests/test_data/test_bad_display_3.configuration new file mode 100644 index 0000000..9c99c4f --- /dev/null +++ b/tests/test_data/test_bad_display_3.configuration @@ -0,0 +1,6 @@ +crosshairX = 541 +zoomLevel = 1.0 +crosshairY = 409 +zoomLevel = 1.0 +crosshairX = 600 +crosshairY = 700 diff --git a/tests/test_data/test_bad_lut.txt b/tests/test_data/test_bad_lut.txt new file mode 100644 index 0000000..f4bb7e9 --- /dev/null +++ b/tests/test_data/test_bad_lut.txt @@ -0,0 +1,51 @@ +# Used to convert from energy to gap. Constructed from tables for 3rd, 5th and 7th harmonic. +# It is important that at the point of change from one harmonic to another that there is +# point for the same energy from both harmomics to prevent invalid interpolation. +# run reloadLookupTables() when done +Units eV mm +5700 5.4606 +5760 5.5 +6000 5.681 +6500 6.045 +7000 6.404 +7500 6.765 +8000 7.124 +8500 7.491 +9000 7.872 +9500 8.258 +9700 8.395 +10000 8.651 +10500 9.085 +11000 9.552 +11500 10.07 +11500 6.328 +12000 6.545 +12500 6.758 +12700 6.83 +13000 6.98 +13500 7.185 +14000 7.404 +14500 7.628 +15000 7.858 +15500 8.087 +16000 8.32 +16000 6.290 +16500 6.447 +17000 6.597 +17500 6.748 +18000 6.904 +18500 7.061 +19000 7.215 +19500 7.373 +20000 7.53 +20500 7.690 +20500 6.271 +21000 6.392 10 +21500 6.510 +22000 6.629 +22500 6.749 +23000 6.868 +23500 6.988 +24000 7.110 +#24500 7.2 +#25000 7.3 diff --git a/tests/test_data/test_display.configuration b/tests/test_data/test_display.configuration new file mode 100644 index 0000000..c546699 --- /dev/null +++ b/tests/test_data/test_display.configuration @@ -0,0 +1,14 @@ +zoomLevel = 1.0 +crosshairX = 541 +crosshairY = 409 +topLeftX = 383 +topLeftY = 253 +bottomRightX = 410 +bottomRightY = 278 +zoomLevel = 2.5 +crosshairX = 551 +crosshairY = 410 +topLeftX = 340 +topLeftY = 283 +bottomRightX = 388 +bottomRightY = 322 diff --git a/tests/test_data/test_good_lut.txt b/tests/test_data/test_good_lut.txt new file mode 100644 index 0000000..94469da --- /dev/null +++ b/tests/test_data/test_good_lut.txt @@ -0,0 +1,13 @@ +# Used to convert from energy to gap. Constructed from tables for 3rd, 5th and 7th harmonic. +# It is important that at the point of change from one harmonic to another that there is +# point for the same energy from both harmomics to prevent invalid interpolation. +# run reloadLookupTables() when done +Units eV mm +5700 5.4606 +5760 5.5 +6000 5.681 +6500 6.045 +#24500 7.2 +#25000 7.3 +Units anything on this line should be ignored by converter +# anything on this line should be ignored by converter diff --git a/tests/test_data/test_xml.xml b/tests/test_data/test_xml.xml new file mode 100644 index 0000000..a0fe6d1 --- /dev/null +++ b/tests/test_data/test_xml.xml @@ -0,0 +1,17 @@ + + + + 1.0 + 0 + 2.432 + 2.432 + + + 1.5 + 16.3 + 1.888 + 1.888 + + +1.0 + diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 8fddfaa..cc948bd 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -10,11 +10,13 @@ from daq_config_server.app import ValidAcceptHeaders from daq_config_server.client import ( ConfigServer, - T, + TModel, + TNonModel, TypeConversionException, _get_mime_type, ) from daq_config_server.constants import ENDPOINTS +from daq_config_server.converters.models import DisplayConfig, GenericLookupTable from daq_config_server.testing import make_test_response test_path = Path("test") @@ -138,7 +140,9 @@ def test_get_file_contents_with_untyped_dict(mock_request: MagicMock): (dict[Any, Any], ValidAcceptHeaders.JSON), (str, ValidAcceptHeaders.PLAIN_TEXT), (bytes, ValidAcceptHeaders.RAW_BYTES), + (GenericLookupTable, ValidAcceptHeaders.JSON), + (DisplayConfig, ValidAcceptHeaders.JSON), ], ) -def test_get_mime_type(input: type[T], expected: ValidAcceptHeaders): +def test_get_mime_type(input: type[TModel | TNonModel], expected: ValidAcceptHeaders): assert _get_mime_type(input) == expected diff --git a/tests/unit_tests/test_converters.py b/tests/unit_tests/test_converters.py new file mode 100644 index 0000000..f66eae4 --- /dev/null +++ b/tests/unit_tests/test_converters.py @@ -0,0 +1,310 @@ +import json +from collections.abc import Callable +from typing import Any +from unittest.mock import MagicMock + +import pytest +import xmltodict + +from daq_config_server.converters._converter_utils import ( + ConverterParseError, + parse_lut, + parse_value, + remove_comments, +) +from daq_config_server.converters._converters import ( + beamline_parameters_to_dict, + beamline_pitch_lut, + beamline_roll_lut, + detector_xy_lut, + display_config_to_model, + undulator_energy_gap_lut, +) +from daq_config_server.converters.convert import get_converted_file_contents +from daq_config_server.converters.models import ( + DisplayConfig, + DisplayConfigData, + GenericLookupTable, +) +from tests.constants import ( + TestDataPaths, +) + + +def test_get_converted_file_contents_uses_converter_if_file_in_map( + mock_file_converter_map: dict[str, Callable[[str], Any]], +): + file_to_convert = TestDataPaths.TEST_GOOD_XML_PATH + mock_convert_function = MagicMock() + mock_file_converter_map[str(file_to_convert)] = mock_convert_function + get_converted_file_contents(file_to_convert) + + mock_convert_function.assert_called_once() + + +def test_get_converted_file_contents_converts_pydantic_model_to_dict( + mock_file_converter_map: dict[str, Callable[[str], Any]], +): + file_to_convert = TestDataPaths.TEST_GOOD_LUT_PATH + model = GenericLookupTable( + column_names=["column1", "column2"], rows=[[1, 2], [2, 3]] + ) + mock_convert_function = MagicMock(return_value=model) + mock_file_converter_map[str(file_to_convert)] = mock_convert_function + result = get_converted_file_contents(file_to_convert) + assert isinstance(result, dict) + mock_convert_function.assert_called_once() + + +def test_error_is_raised_if_file_cant_be_parsed( + mock_file_converter_map: dict[str, Callable[[str], Any]], +): + file_to_convert = TestDataPaths.TEST_BAD_BEAMLINE_PARAMETERS_PATH + mock_file_converter_map[str(file_to_convert)] = beamline_parameters_to_dict + with pytest.raises(ConverterParseError): + get_converted_file_contents(file_to_convert) + + +def test_parse_lut_to_dict_gives_expected_result_and_can_be_jsonified(): + with open(TestDataPaths.TEST_GOOD_LUT_PATH) as f: + contents = f.read() + expected = GenericLookupTable( + column_names=["energy_eV", "gap_mm"], + rows=[[5700, 5.4606], [5760, 5.5], [6000, 5.681], [6500, 6.045]], + ) + result = parse_lut(contents, ("energy_eV", int), ("gap_mm", float)) + assert result == expected + result.model_dump_json() + + +def test_parsing_bad_lut_causes_error(): + with open(TestDataPaths.TEST_BAD_LUT_PATH) as f: + contents = f.read() + with pytest.raises(IndexError): + parse_lut(contents, ("energy_eV", int), ("gap_mm", float)) + + +def test_lut_with_different_number_of_row_items_to_column_names_causes_error(): + with pytest.raises(ValueError, match=" does not match number of columns:"): + GenericLookupTable( + column_names=["column1", "column2"], rows=[[1, 2], [1, 2, 3]] + ) + + +def test_display_config_to_model_gives_expected_result_and_can_be_jsonified(): + with open(TestDataPaths.TEST_GOOD_DISPLAY_CONFIG_PATH) as f: + contents = f.read() + expected = DisplayConfig( + zoom_levels={ + 1.0: DisplayConfigData( + bottomRightX=410, + bottomRightY=278, + crosshairX=541, + crosshairY=409, + topLeftX=383, + topLeftY=253, + ), + 2.5: DisplayConfigData( + bottomRightX=388, + bottomRightY=322, + crosshairX=551, + crosshairY=410, + topLeftX=340, + topLeftY=283, + ), + } + ) + result = display_config_to_model(contents) + assert result == expected + json.dumps(result.model_dump()) + + +def test_display_config_with_wrong_zoom_levels_causes_error(): + zoom_levels = { + 1.0: DisplayConfigData( + bottomRightX=410, + bottomRightY=278, + crosshairX=541, + crosshairY=409, + topLeftX=383, + topLeftY=253, + ), + 2.5: DisplayConfigData( + bottomRightX=388, + bottomRightY=322, + crosshairX=551, + crosshairY=410, + topLeftX=340, + topLeftY=283, + ), + } + with pytest.raises( + ValueError, + 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_xml_to_dict_gives_expected_result_and_can_be_jsonified(): + with open(TestDataPaths.TEST_GOOD_XML_PATH) as f: + contents = f.read() + expected = { + "JCameraManSettings": { + "levels": { + "zoomLevel": [ + { + "level": "1.0", + "micronsPerXPixel": "2.432", + "micronsPerYPixel": "2.432", + "position": "0", + }, + { + "level": "1.5", + "micronsPerXPixel": "1.888", + "micronsPerYPixel": "1.888", + "position": "16.3", + }, + ], + }, + "tolerance": "1.0", + }, + } + result = xmltodict.parse(contents) + assert result == expected + json.dumps(result) + + +def test_beamline_parameters_to_dict_gives_expected_result(): + with open(TestDataPaths.TEST_BEAMLINE_PARAMETERS_PATH) as f: + contents = f.read() + with open(TestDataPaths.EXPECTED_BEAMLINE_PARAMETERS_JSON_PATH) as f: + expected = json.load(f) + result = beamline_parameters_to_dict(contents) + assert result == expected + + +def test_bad_beamline_parameters_with_non_keyword_string_value_causes_error(): + with open(TestDataPaths.TEST_BAD_BEAMLINE_PARAMETERS_PATH) as f: + contents = f.read() + with pytest.raises(ValueError, match="malformed node or string"): + beamline_parameters_to_dict(contents) + + +def test_beam_line_parameters_with_repeated_key_causes_error(): + input = "thing = 1\nthing = 2" + with pytest.raises(ValueError, match="Repeated key in parameters: thing"): + beamline_parameters_to_dict(input) + + +def test_remove_comments_works_as_expected(): + input = [ + "This line should not be changed", + "This should stay # this should go", + "#This entire line should go", + " # as should this one", + "# and this one", + "", + " ", + " whitespace should be stripped ", + ] + expected_output = [ + "This line should not be changed", + "This should stay", + "whitespace should be stripped", + ] + assert remove_comments(input) == expected_output + + +@pytest.mark.parametrize( + "value, convert_to, expected_parsed_value", + [ + (" 2.0 ", None, 2.0), + (" 3 ", None, 3), + ("5.0", float, 5.0), + ("5", int, 5), + ], +) +def test_parse_value_works_as_expected( + value: str, convert_to: type, expected_parsed_value: Any +): + parsed_value = parse_value(value, convert_to) + assert parsed_value == expected_parsed_value + assert type(parsed_value) is type(expected_parsed_value) + + +def test_detector_xy_lut_gives_expected_results(): + input = ( + "# distance beamY beamX (values from mosflm)\n" + "Units mm mm mm\n" + "150 152.2 166.26\n" + "800 152.08 160.96\n" + ) + expected = GenericLookupTable( + column_names=["detector_distances_mm", "beam_centre_x_mm", "beam_centre_y_mm"], + rows=[[150, 152.2, 166.26], [800, 152.08, 160.96]], + ) + result = detector_xy_lut(input) + assert result == expected + + +def test_beamline_pitch_lut_gives_expected_result(): + input = ( + "# Bragg pitch\n" + "# Degree values for pitch are interpreted as mrad\n" + "# The values cannot change direction.\n" + "# last update 2025/01/15 NP\n" + "Units Deg mrad\n" + "Units Deg Deg\n" + "16.40956 -0.62681\n" + "14.31123 -0.61833\n" + "12.69285 -0.61243\n" + "11.40557 -0.60849\n" + ) + expected = GenericLookupTable( + column_names=["bragg_angle_deg", "pitch_mrad"], + rows=[ + [16.40956, -0.62681], + [14.31123, -0.61833], + [12.69285, -0.61243], + [11.40557, -0.60849], + ], + ) + result = beamline_pitch_lut(input) + assert result == expected + + +def test_beamline_roll_lut_gives_expected_result(): + input = ( + "#Bragg angle against roll( absolute number)\n" + "#reloadLookupTables()\n" + "# last update 2024/06/20 NP\n" + "Units Deg mrad\n" + "26.4095 2.6154\n" + "6.3075 2.6154\n" + ) + expected = GenericLookupTable( + column_names=["bragg_angle_deg", "roll_mrad"], + rows=[[26.4095, 2.6154], [6.3075, 2.6154]], + ) + result = beamline_roll_lut(input) + assert result == expected + + +def test_undulator_gap_lut_gives_expected_result(): + input = ( + "#######################\n" + "# #\n" + "# 5.5mm CPMU 20/11/22 #\n" + "# #\n" + "Units eV mm\n" + "5700 5.4606\n" + "5760 5.5\n" + "6000 5.681\n" + "6500 6.045\n" + ) + expected = GenericLookupTable( + column_names=["energy_eV", "gap_mm"], + rows=[[5700, 5.4606], [5760, 5.5], [6000, 5.681], [6500, 6.045]], + ) + result = undulator_energy_gap_lut(input) + assert result == expected diff --git a/whitelist.yaml b/whitelist.yaml index 64229a4..35eaba4 100644 --- a/whitelist.yaml +++ b/whitelist.yaml @@ -12,8 +12,9 @@ whitelist_files: - /dls_sw/i04/software/bluesky/scratch/jCameraManZoomLevels.xml - /dls_sw/i19-1/software/gda_versions/gda/config/xml/jCameraManZoomLevels.xml - /dls_sw/i24/software/gda_versions/gda/config/xml/jCameraManZoomLevels.xml -- /dls_sw/i03/software/gda_versions/var/display.configurations +- /dls_sw/i03/software/gda_versions/var/display.configuration - /dls_sw/i04/software/bluesky/scratch/display.configuration +- /dls_sw/i23/software/aithre/aithre_display.configuration - /dls_sw/i24/software/gda_versions/var/display.configuration #For long-term directories with frequently changing files