-
Notifications
You must be signed in to change notification settings - Fork 1
Add file converters #129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add file converters #129
Changes from all commits
7b76268
1bb3ec8
a46b54d
39dfbe0
6288f53
2e28d8b
8fce306
d6b55c8
26b6c6e
4f98473
51ef146
1aa8192
4275240
a009780
2c2ca95
c4657be
857ca3e
a7dba6d
75f5a91
2f433bd
fbc6b3f
2da1ffd
de8d0d4
40af375
04f612f
278aaf1
b7d1097
b34f773
052eca2
8ed706f
20826b1
aae2937
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ dependencies = [ | |
| "pydantic", | ||
| "urllib3", | ||
| "requests", | ||
| "xmltodict", | ||
| ] | ||
|
|
||
| [project.optional-dependencies] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from daq_config_server.converters.convert import get_converted_file_contents | ||
|
|
||
| __all__ = ["get_converted_file_contents"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: | ||
olliesilvester marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| from typing import Any | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This file will grow very big so I think this isn't going to be very scalable. I would separate out the converters and models into a module for the thing it is used for e.g something like below: By organizing each "example" as its own module, you can keep the functionality isolated and focused which will help prevent one large file that becomes hard to manage in the future if it is dumped with all the converters and models from every beamline. Maybe Then in daq_config_server and dodal we can then do from daq_config_server.serializer_plugins.display_config.models import DisplayConfigDataOr with shortcuts via from daq_config_server.serializer_plugins.display_config import DisplayConfigData
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This sounds good, I'll create an issue for it and do seperately just in the interest of getting this one merged soon
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 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)) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
olliesilvester marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return json.loads(raw_contents) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we still have converters to get things into a dict? I thought we'd just leave the dict requesting logic as it was before this PR
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could do, I'd need to make some more pydantic models which I'm happy to do. But for example, currently there's a general xml to dict converter that will work on all xml files, which could be useful to keep.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Discussed in person: We think this is fine for now.
However, I am wondering if someone requests a file as a dict, and a converter to a basemodel for that file exists. They'd probably expect their file to be dumbly converted with
json.loads(raw_contents), whereas in reality they'd get a dict which is formatted for the base model.Could make a separate issue for this and think about it later? After typing this I'm leaning towards not having specific converts for when you request a dict.