dls-dodal 1.66.0__py3-none-any.whl → 1.67.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/METADATA +1 -1
  2. {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/RECORD +47 -37
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/i03.py +92 -208
  5. dodal/beamlines/i04.py +22 -1
  6. dodal/beamlines/i05.py +1 -1
  7. dodal/beamlines/i06.py +1 -1
  8. dodal/beamlines/i09_1.py +26 -2
  9. dodal/beamlines/i09_2.py +57 -2
  10. dodal/beamlines/i10_optics.py +44 -25
  11. dodal/beamlines/i17.py +7 -3
  12. dodal/beamlines/i19_1.py +26 -14
  13. dodal/beamlines/i19_2.py +49 -38
  14. dodal/beamlines/i21.py +2 -2
  15. dodal/beamlines/i22.py +16 -1
  16. dodal/beamlines/training_rig.py +0 -16
  17. dodal/cli.py +26 -12
  18. dodal/common/coordination.py +3 -2
  19. dodal/device_manager.py +604 -0
  20. dodal/devices/cryostream.py +28 -57
  21. dodal/devices/eiger.py +26 -18
  22. dodal/devices/i04/max_pixel.py +38 -0
  23. dodal/devices/i09_1_shared/__init__.py +8 -1
  24. dodal/devices/i09_1_shared/hard_energy.py +112 -0
  25. dodal/devices/i09_2_shared/__init__.py +0 -0
  26. dodal/devices/i09_2_shared/i09_apple2.py +86 -0
  27. dodal/devices/i10/i10_apple2.py +23 -21
  28. dodal/devices/i17/i17_apple2.py +32 -20
  29. dodal/devices/i19/access_controlled/attenuator_motor_squad.py +61 -0
  30. dodal/devices/i19/access_controlled/blueapi_device.py +9 -1
  31. dodal/devices/i19/access_controlled/shutter.py +2 -4
  32. dodal/devices/insertion_device/__init__.py +0 -0
  33. dodal/devices/{apple2_undulator.py → insertion_device/apple2_undulator.py} +36 -28
  34. dodal/devices/insertion_device/energy_motor_lookup.py +88 -0
  35. dodal/devices/insertion_device/lookup_table_models.py +287 -0
  36. dodal/devices/motors.py +14 -0
  37. dodal/devices/robot.py +16 -11
  38. dodal/plans/__init__.py +1 -1
  39. dodal/plans/configure_arm_trigger_and_disarm_detector.py +2 -4
  40. dodal/testing/fixtures/devices/__init__.py +0 -0
  41. dodal/testing/fixtures/devices/apple2.py +78 -0
  42. dodal/utils.py +6 -3
  43. dodal/devices/util/lookup_tables_apple2.py +0 -390
  44. {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/WHEEL +0 -0
  45. {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/entry_points.txt +0 -0
  46. {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/licenses/LICENSE +0 -0
  47. {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/top_level.txt +0 -0
  48. /dodal/plans/{scanspec.py → spec_path.py} +0 -0
@@ -0,0 +1,61 @@
1
+ from typing import Annotated, Any, Self
2
+
3
+ from ophyd_async.core import AsyncStatus
4
+ from pydantic import BaseModel, model_validator
5
+ from pydantic.types import PositiveInt, StringConstraints
6
+
7
+ from dodal.devices.i19.access_controlled.blueapi_device import (
8
+ OpticsBlueAPIDevice,
9
+ )
10
+ from dodal.devices.i19.access_controlled.hutch_access import ACCESS_DEVICE_NAME
11
+
12
+ PermittedKeyStr = Annotated[str, StringConstraints(pattern="^[A-Za-z0-9-_]*$")]
13
+
14
+
15
+ class AttenuatorMotorPositionDemands(BaseModel):
16
+ continuous_demands: dict[PermittedKeyStr, float] = {}
17
+ indexed_demands: dict[PermittedKeyStr, PositiveInt] = {}
18
+
19
+ @model_validator(mode="after")
20
+ def no_keys_clash(self) -> Self:
21
+ common_keys = set(self.continuous_demands).intersection(self.indexed_demands)
22
+ common_key_count = sum(1 for _ in common_keys)
23
+ if common_key_count < 1:
24
+ return self
25
+ else:
26
+ ks: str = "key" if common_key_count == 1 else "keys"
27
+ error_msg = f"Common {ks} found in distinct motor demands: {common_keys}"
28
+ raise ValueError(error_msg)
29
+
30
+ def validated_complete_demand(self) -> dict[PermittedKeyStr, Any]:
31
+ return self.continuous_demands | self.indexed_demands
32
+
33
+
34
+ class AttenuatorMotorSquad(OpticsBlueAPIDevice):
35
+ """ I19-specific proxy device which requests absorber position changes in the x-ray attenuator.
36
+
37
+ Sends REST call to blueapi controlling optics on the I19 cluster.
38
+ The hutch in use is compared against the hutch which sent the REST call.
39
+ Only the hutch in use will be permitted to execute a plan (requesting motor moves).
40
+ As the two hutches are located in series, checking the hutch in use is necessary to \
41
+ avoid accidentally operating optics devices from one hutch while the other has beam time.
42
+
43
+ The name of the hutch that wants to operate the optics device is passed to the \
44
+ access controlled device upon instantiation of the latter.
45
+
46
+ For details see the architecture described in \
47
+ https://github.com/DiamondLightSource/i19-bluesky/issues/30.
48
+ """
49
+
50
+ @AsyncStatus.wrap
51
+ async def set(self, value: AttenuatorMotorPositionDemands):
52
+ request_params = {
53
+ "name": "operate_motor_squad_plan",
54
+ "params": {
55
+ "experiment_hutch": self._invoking_hutch,
56
+ "access_device": ACCESS_DEVICE_NAME,
57
+ "attenuator_demands": value.validated_complete_demand(),
58
+ },
59
+ "instrument_session": self.instrument_session,
60
+ }
61
+ await super().set(request_params)
@@ -29,11 +29,19 @@ class OpticsBlueAPIDevice(StandardReadable, Movable[D]):
29
29
  https://github.com/DiamondLightSource/i19-bluesky/issues/30.
30
30
  """
31
31
 
32
- def __init__(self, name: str = "") -> None:
32
+ def __init__(
33
+ self, hutch: HutchState, instrument_session: str = "", name: str = ""
34
+ ) -> None:
35
+ self.hutch_request = hutch
36
+ self.instrument_session = instrument_session
33
37
  self.url = OPTICS_BLUEAPI_URL
34
38
  self.headers = HEADERS
35
39
  super().__init__(name)
36
40
 
41
+ @property
42
+ def _invoking_hutch(self) -> str:
43
+ return self.hutch_request.value
44
+
37
45
  @AsyncStatus.wrap
38
46
  async def set(self, value: D):
39
47
  """ On set send a POST request to the optics blueapi with the name and \
@@ -36,16 +36,14 @@ class AccessControlledShutter(OpticsBlueAPIDevice):
36
36
  # see https://github.com/DiamondLightSource/blueapi/issues/1187
37
37
  with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
38
38
  self.shutter_status = epics_signal_r(ShutterState, f"{prefix}STA")
39
- self.hutch_request = hutch
40
- self.instrument_session = instrument_session
41
- super().__init__(name)
39
+ super().__init__(hutch=hutch, instrument_session=instrument_session, name=name)
42
40
 
43
41
  @AsyncStatus.wrap
44
42
  async def set(self, value: ShutterDemand):
45
43
  request_params = {
46
44
  "name": "operate_shutter_plan",
47
45
  "params": {
48
- "experiment_hutch": self.hutch_request.value,
46
+ "experiment_hutch": self._invoking_hutch,
49
47
  "access_device": ACCESS_DEVICE_NAME,
50
48
  "shutter_demand": value.value,
51
49
  },
File without changes
@@ -398,7 +398,7 @@ class Apple2(StandardReadable, Movable[Apple2Val], Generic[PhaseAxesType]):
398
398
 
399
399
 
400
400
  class EnergyMotorConvertor(Protocol):
401
- def __call__(self, energy: float, pol: Pol) -> tuple[float, float]:
401
+ def __call__(self, energy: float, pol: Pol) -> float:
402
402
  """Protocol to provide energy to motor position conversion"""
403
403
  ...
404
404
 
@@ -426,19 +426,18 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
426
426
  Soft signal for the polarisation setpoint.
427
427
  polarisation : derived_signal_rw
428
428
  Hardware-backed signal for polarisation readback and control.
429
- energy_to_motor : EnergyMotorConvertor
430
- Callable that converts energy and polarisation to motor positions.
429
+ gap_energy_to_motor_converter : EnergyMotorConvertor
430
+ Callable that converts energy and polarisation to gap motor positions.
431
+ phase_energy_to_motor_converter : EnergyMotorConvertor
432
+ Callable that converts energy and polarisation to phase motor positions.
431
433
 
432
434
  Abstract Methods
433
435
  ----------------
434
- _set_motors_from_energy(value: float) -> None
435
- Abstract method to set motor positions for a given energy and polarisation.
436
- energy_to_motor : EnergyMotorConvertor
437
- A callable that converts energy and polarisation to motor positions.
438
-
436
+ _get_apple2_value(gap: float, phase: float) -> Apple2Val
437
+ Abstract method to return the Apple2Val used to set the apple2 with.
439
438
  Notes
440
439
  -----
441
- - Subclasses must implement `_set_motors_from_energy` for beamline-specific logic.
440
+ - Subclasses must implement `_get_apple2_value` for beamline-specific logic.
442
441
  - LH3 polarisation is indistinguishable from LH in hardware; special handling is provided.
443
442
  - Supports multiple polarisation modes, including linear horizontal (LH), linear vertical (LV),
444
443
  positive circular (PC), negative circular (NC), and linear arbitrary (LA).
@@ -448,7 +447,9 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
448
447
  def __init__(
449
448
  self,
450
449
  apple2: Apple2Type,
451
- energy_to_motor_converter: EnergyMotorConvertor,
450
+ gap_energy_motor_converter: EnergyMotorConvertor,
451
+ phase_energy_motor_converter: EnergyMotorConvertor,
452
+ units: str = "eV",
452
453
  name: str = "",
453
454
  ) -> None:
454
455
  """
@@ -460,19 +461,20 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
460
461
  name: str
461
462
  Name of the device.
462
463
  """
463
- self.energy_to_motor = energy_to_motor_converter
464
464
  self.apple2 = Reference(apple2)
465
+ self.gap_energy_motor_converter = gap_energy_motor_converter
466
+ self.phase_energy_motor_converter = phase_energy_motor_converter
465
467
 
466
468
  # Store the set energy for readback.
467
469
  self._energy, self._energy_set = soft_signal_r_and_setter(
468
- float, initial_value=None, units="eV"
470
+ float, initial_value=None, units=units
469
471
  )
470
472
  with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
471
473
  self.energy = derived_signal_rw(
472
474
  raw_to_derived=self._read_energy,
473
475
  set_derived=self._set_energy,
474
476
  energy=self._energy,
475
- derived_units="eV",
477
+ derived_units=units,
476
478
  )
477
479
 
478
480
  # Store the polarisation for setpoint. And provide readback for LH3.
@@ -480,10 +482,11 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
480
482
  self.polarisation_setpoint, self._polarisation_setpoint_set = (
481
483
  soft_signal_r_and_setter(Pol)
482
484
  )
485
+ phase = self.apple2().phase()
483
486
  # check if undulator phase is unlocked.
484
- if isinstance(self.apple2().phase(), UndulatorPhaseAxes):
485
- top_inner = self.apple2().phase().top_inner.user_readback
486
- btm_outer = self.apple2().phase().btm_outer.user_readback
487
+ if isinstance(phase, UndulatorPhaseAxes):
488
+ top_inner = phase.top_inner.user_readback
489
+ btm_outer = phase.btm_outer.user_readback
487
490
  else:
488
491
  # If locked phase axes make the locked phase 0.
489
492
  top_inner = btm_outer = soft_signal_rw(float, initial_value=0.0)
@@ -495,24 +498,35 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
495
498
  raw_to_derived=self._read_pol,
496
499
  set_derived=self._set_pol,
497
500
  pol=self.polarisation_setpoint,
498
- top_outer=self.apple2().phase().top_outer.user_readback,
501
+ top_outer=phase.top_outer.user_readback,
499
502
  top_inner=top_inner,
500
- btm_inner=self.apple2().phase().btm_inner.user_readback,
503
+ btm_inner=phase.btm_inner.user_readback,
501
504
  btm_outer=btm_outer,
502
505
  gap=self.apple2().gap().user_readback,
503
506
  )
504
507
  super().__init__(name)
505
508
 
506
509
  @abc.abstractmethod
507
- async def _set_motors_from_energy(self, value: float) -> None:
510
+ def _get_apple2_value(self, gap: float, phase: float, pol: Pol) -> Apple2Val:
508
511
  """
509
512
  This method should be implemented by the beamline specific ID class as the
510
513
  motor positions will be different for each beamline depending on the
511
- undulator design and the lookup table used.
514
+ undulator design.
512
515
  """
513
516
 
517
+ async def _set_motors_from_energy_and_polarisation(
518
+ self, energy: float, pol: Pol
519
+ ) -> None:
520
+ """Set the undulator motors for a given energy and polarisation."""
521
+ gap = self.gap_energy_motor_converter(energy=energy, pol=pol)
522
+ phase = self.phase_energy_motor_converter(energy=energy, pol=pol)
523
+ apple2_val = self._get_apple2_value(gap, phase, pol)
524
+ LOGGER.info(f"Setting polarisation to {pol}, with values: {apple2_val}")
525
+ await self.apple2().set(id_motor_values=apple2_val)
526
+
514
527
  async def _set_energy(self, energy: float) -> None:
515
- await self._set_motors_from_energy(energy)
528
+ pol = await self._check_and_get_pol_setpoint()
529
+ await self._set_motors_from_energy_and_polarisation(energy, pol)
516
530
  self._energy_set(energy)
517
531
 
518
532
  def _read_energy(self, energy: float) -> float:
@@ -524,7 +538,6 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
524
538
  Check the polarisation setpoint and if it is NONE try to read it from
525
539
  hardware.
526
540
  """
527
-
528
541
  pol = await self.polarisation_setpoint.get_value()
529
542
 
530
543
  if pol == Pol.NONE:
@@ -717,12 +730,7 @@ class InsertionDeviceEnergy(InsertionDeviceEnergyBase):
717
730
  self.energy = Reference(id_controller.energy)
718
731
  super().__init__(name=name)
719
732
 
720
- self.add_readables(
721
- [
722
- self.energy(),
723
- ],
724
- StandardReadableFormat.HINTED_SIGNAL,
725
- )
733
+ self.add_readables([self.energy()], StandardReadableFormat.HINTED_SIGNAL)
726
734
 
727
735
  @AsyncStatus.wrap
728
736
  async def set(self, energy: float) -> None:
@@ -0,0 +1,88 @@
1
+ from pathlib import Path
2
+
3
+ from daq_config_server.client import ConfigServer
4
+
5
+ from dodal.devices.insertion_device.apple2_undulator import Pol
6
+ from dodal.devices.insertion_device.lookup_table_models import (
7
+ LookupTable,
8
+ LookupTableColumnConfig,
9
+ convert_csv_to_lookup,
10
+ )
11
+
12
+
13
+ class EnergyMotorLookup:
14
+ """
15
+ Handles a lookup table for Apple2 ID, converting energy/polarisation to a motor
16
+ position.
17
+
18
+ After update_lookup_table() has populated the lookup table, `find_value_in_lookup_table()`
19
+ can be used to compute gap / phase for a requested energy / polarisation pair.
20
+ """
21
+
22
+ def __init__(self, lut: LookupTable | None = None):
23
+ if lut is None:
24
+ lut = LookupTable()
25
+ self.lut = lut
26
+
27
+ def update_lookup_table(self) -> None:
28
+ """Do nothing by default. Sub classes may override this method to provide logic
29
+ on what updating lookup table does."""
30
+ pass
31
+
32
+ def find_value_in_lookup_table(self, energy: float, pol: Pol) -> float:
33
+ """
34
+ Convert energy and polarisation to a value from the lookup table.
35
+
36
+ Parameters:
37
+ -----------
38
+ energy : float
39
+ Desired energy.
40
+ pol : Pol
41
+ Polarisation mode.
42
+
43
+ Returns:
44
+ ----------
45
+ float
46
+ gap / phase motor position from the lookup table.
47
+ """
48
+ # if lut is empty, force an update to pull updated lut incase subclasses have
49
+ # implemented it.
50
+ if not self.lut.root:
51
+ self.update_lookup_table()
52
+ poly = self.lut.get_poly(energy=energy, pol=pol)
53
+ return poly(energy)
54
+
55
+
56
+ class ConfigServerEnergyMotorLookup(EnergyMotorLookup):
57
+ """Fetches and parses lookup table (csv) from a config server, supports dynamic
58
+ updates, and validates input."""
59
+
60
+ def __init__(
61
+ self,
62
+ config_client: ConfigServer,
63
+ lut_config: LookupTableColumnConfig,
64
+ path: Path,
65
+ ):
66
+ """
67
+ Parameters:
68
+ -----------
69
+ config_client:
70
+ The config server client to fetch the look up table data.
71
+ lut_config:
72
+ Configuration that defines how to process file contents into a LookupTable
73
+ path:
74
+ File path to the lookup table.
75
+ """
76
+ self.path = path
77
+ self.config_client = config_client
78
+ self.lut_config = lut_config
79
+ super().__init__()
80
+
81
+ def read_lut(self) -> LookupTable:
82
+ file_contents = self.config_client.get_file_contents(
83
+ self.path, reset_cached_result=True
84
+ )
85
+ return convert_csv_to_lookup(file_contents, lut_config=self.lut_config)
86
+
87
+ def update_lookup_table(self) -> None:
88
+ self.lut = self.read_lut()
@@ -0,0 +1,287 @@
1
+ """Apple2 lookup table utilities and CSV converter.
2
+
3
+ This module provides helpers to read, validate and convert Apple2 insertion-device
4
+ lookup tables (energy -> gap/phase polynomials) from CSV sources into an
5
+ in-memory dictionary format used by the Apple2 controllers.
6
+
7
+ Data format produced
8
+ The lookup-table dictionary created by convert_csv_to_lookup() follows this
9
+ structure:
10
+
11
+ {
12
+ "POL_MODE": {
13
+ "energy_entries": [
14
+ {
15
+ "low": <float>,
16
+ "high": <float>,
17
+ "poly": <numpy.poly1d>
18
+ },
19
+ ...
20
+ ]
21
+ },
22
+ ...
23
+ }
24
+ """
25
+
26
+ import csv
27
+ import io
28
+ from collections.abc import Generator
29
+ from typing import Annotated as A
30
+ from typing import Any, NamedTuple, Self
31
+
32
+ import numpy as np
33
+ from pydantic import (
34
+ BaseModel,
35
+ ConfigDict,
36
+ Field,
37
+ RootModel,
38
+ field_serializer,
39
+ field_validator,
40
+ )
41
+
42
+ from dodal.devices.insertion_device.apple2_undulator import Pol
43
+
44
+ DEFAULT_POLY_DEG = [
45
+ "7th-order",
46
+ "6th-order",
47
+ "5th-order",
48
+ "4th-order",
49
+ "3rd-order",
50
+ "2nd-order",
51
+ "1st-order",
52
+ "b",
53
+ ]
54
+
55
+ MODE_NAME_CONVERT = {"cr": "pc", "cl": "nc"}
56
+ DEFAULT_GAP_FILE = "IDEnergy2GapCalibrations.csv"
57
+ DEFAULT_PHASE_FILE = "IDEnergy2PhaseCalibrations.csv"
58
+
59
+ ROW_PHASE_MOTOR_TOLERANCE = 0.004
60
+ ROW_PHASE_CIRCULAR = 15
61
+ MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
62
+ MAXIMUM_GAP_MOTOR_POSITION = 100
63
+
64
+ DEFAULT_POLY1D_PARAMETERS = {
65
+ Pol.LH: [0],
66
+ Pol.LV: [MAXIMUM_ROW_PHASE_MOTOR_POSITION],
67
+ Pol.PC: [ROW_PHASE_CIRCULAR],
68
+ Pol.NC: [-ROW_PHASE_CIRCULAR],
69
+ Pol.LH3: [0],
70
+ }
71
+
72
+
73
+ class Source(NamedTuple):
74
+ column: str
75
+ value: str
76
+
77
+
78
+ class LookupTableColumnConfig(BaseModel):
79
+ """Configuration on how to process a csv file columns into a LookupTable data model."""
80
+
81
+ source: A[
82
+ Source | None,
83
+ Field(
84
+ description="If not None, only process the row if the source column name match the value."
85
+ ),
86
+ ] = None
87
+ mode: A[str, Field(description="Polarisation mode column name.")] = "Mode"
88
+ min_energy: A[str, Field(description="Minimum energy column name.")] = "MinEnergy"
89
+ max_energy: A[str, Field(description="Maximum energy column name.")] = "MaxEnergy"
90
+ poly_deg: list[str] = Field(
91
+ description="Polynomial column names.", default_factory=lambda: DEFAULT_POLY_DEG
92
+ )
93
+ mode_name_convert: dict[str, str] = Field(
94
+ description="When processing polarisation mode values, map their alias values to a real value.",
95
+ default_factory=lambda: MODE_NAME_CONVERT,
96
+ )
97
+
98
+
99
+ class EnergyCoverageEntry(BaseModel):
100
+ model_config = ConfigDict(arbitrary_types_allowed=True) # So np.poly1d can be used.
101
+ min_energy: float
102
+ max_energy: float
103
+ poly: np.poly1d
104
+
105
+ @field_validator("poly", mode="before")
106
+ @classmethod
107
+ def validate_and_convert_poly(
108
+ cls: type[Self], value: np.poly1d | list
109
+ ) -> np.poly1d:
110
+ """If reading from serialized data, it will be using a list. Convert to np.poly1d"""
111
+ if isinstance(value, list):
112
+ return np.poly1d(value)
113
+ return value
114
+
115
+ @field_serializer("poly", mode="plain")
116
+ def serialize_poly(self, value: np.poly1d) -> list:
117
+ """Allow np.poly1d to work when serializing."""
118
+ return value.coefficients.tolist()
119
+
120
+
121
+ class EnergyCoverage(BaseModel):
122
+ energy_entries: list[EnergyCoverageEntry]
123
+
124
+ @classmethod
125
+ def generate(
126
+ cls: type[Self],
127
+ min_energies: list[float],
128
+ max_energies: list[float],
129
+ poly1d_params: list[list[float]],
130
+ ) -> Self:
131
+ energy_entries = [
132
+ EnergyCoverageEntry(
133
+ min_energy=min_energy,
134
+ max_energy=max_energy,
135
+ poly=np.poly1d(poly_params),
136
+ )
137
+ for min_energy, max_energy, poly_params in zip(
138
+ min_energies, max_energies, poly1d_params, strict=True
139
+ )
140
+ ]
141
+ return cls(energy_entries=energy_entries)
142
+
143
+ @property
144
+ def min_energy(self) -> float:
145
+ return min(e.min_energy for e in self.energy_entries)
146
+
147
+ @property
148
+ def max_energy(self) -> float:
149
+ return max(e.max_energy for e in self.energy_entries)
150
+
151
+ def get_poly(self, energy: float) -> np.poly1d:
152
+ """
153
+ Return the numpy.poly1d polynomial applicable for the given energy.
154
+
155
+ Parameters:
156
+ -----------
157
+ energy:
158
+ Energy value in the same units used to create the lookup table.
159
+ """
160
+ # Cache initial values so don't do unnecessary work again
161
+ min_energy = self.min_energy
162
+ max_energy = self.max_energy
163
+ if energy < min_energy or energy > max_energy:
164
+ raise ValueError(
165
+ f"Demanding energy must lie between {min_energy} and {max_energy}!"
166
+ )
167
+ else:
168
+ for energy_coverage in self.energy_entries:
169
+ if (
170
+ energy >= energy_coverage.min_energy
171
+ and energy < energy_coverage.max_energy
172
+ ):
173
+ return energy_coverage.poly
174
+ raise ValueError(
175
+ "Cannot find polynomial coefficients for your requested energy."
176
+ + " There might be gap in the calibration lookup table."
177
+ )
178
+
179
+
180
+ class LookupTable(RootModel[dict[Pol, EnergyCoverage]]):
181
+ """
182
+ Specialised lookup table for insertion devices to relate the energy and polarisation
183
+ values to Apple2 motor positions.
184
+ """
185
+
186
+ # Allow to auto specify a dict if one not provided
187
+ def __init__(self, root: dict[Pol, EnergyCoverage] | None = None):
188
+ super().__init__(root=root or {})
189
+
190
+ @classmethod
191
+ def generate(
192
+ cls: type[Self],
193
+ pols: list[Pol],
194
+ energy_coverage: list[EnergyCoverage],
195
+ ) -> Self:
196
+ """Generate a LookupTable containing multiple EnergyCoverage
197
+ for provided polarisations."""
198
+ lut = cls()
199
+ for i in range(len(pols)):
200
+ lut.root[pols[i]] = energy_coverage[i]
201
+ return lut
202
+
203
+ def get_poly(
204
+ self,
205
+ energy: float,
206
+ pol: Pol,
207
+ ) -> np.poly1d:
208
+ """
209
+ Return the numpy.poly1d polynomial applicable for the given energy and polarisation.
210
+
211
+ Parameters:
212
+ -----------
213
+ energy:
214
+ Energy value in the same units used to create the lookup table.
215
+ pol:
216
+ Polarisation mode (Pol enum).
217
+ """
218
+ return self.root[pol].get_poly(energy)
219
+
220
+
221
+ def convert_csv_to_lookup(
222
+ file_contents: str,
223
+ lut_config: LookupTableColumnConfig,
224
+ skip_line_start_with: str = "#",
225
+ ) -> LookupTable:
226
+ """
227
+ Convert CSV content into the Apple2 lookup-table dictionary.
228
+
229
+ Parameters:
230
+ -----------
231
+ file_contents:
232
+ The CSV file contents as string.
233
+ lut_config:
234
+ The configuration that how to process the file_contents into a LookupTable.
235
+ skip_line_start_with
236
+ Lines beginning with this prefix are skipped (default "#").
237
+
238
+ Returns:
239
+ -----------
240
+ LookupTable
241
+ """
242
+
243
+ def process_row(row: dict[str, Any], lut: LookupTable) -> None:
244
+ """Process a single row from the CSV file and update the lookup table."""
245
+ raw_mode_value = str(row[lut_config.mode]).lower()
246
+ mode_value = Pol(
247
+ lut_config.mode_name_convert.get(raw_mode_value, raw_mode_value)
248
+ )
249
+
250
+ # Create polynomial object for energy-to-gap/phase conversion
251
+ coefficients = np.poly1d([float(row[coef]) for coef in lut_config.poly_deg])
252
+
253
+ energy_entry = EnergyCoverageEntry(
254
+ min_energy=float(row[lut_config.min_energy]),
255
+ max_energy=float(row[lut_config.max_energy]),
256
+ poly=coefficients,
257
+ )
258
+ if mode_value not in lut.root:
259
+ lut.root[mode_value] = EnergyCoverage(energy_entries=[energy_entry])
260
+ else:
261
+ lut.root[mode_value].energy_entries.append(energy_entry)
262
+
263
+ reader = csv.DictReader(read_file_and_skip(file_contents, skip_line_start_with))
264
+ lut = LookupTable()
265
+
266
+ for row in reader:
267
+ source = lut_config.source
268
+ # If there are multiple source only convert requested.
269
+ if source is None or row[source.column] == source.value:
270
+ process_row(row=row, lut=lut)
271
+
272
+ # Check if our LookupTable is empty after processing, raise error if it is.
273
+ if not lut.root:
274
+ raise RuntimeError(
275
+ "LookupTable content is empty, failed to convert the file contents to "
276
+ "a LookupTable!"
277
+ )
278
+ return lut
279
+
280
+
281
+ def read_file_and_skip(file: str, skip_line_start_with: str = "#") -> Generator[str]:
282
+ """Yield non-comment lines from the CSV content string."""
283
+ for line in io.StringIO(file):
284
+ if line.startswith(skip_line_start_with):
285
+ continue
286
+ else:
287
+ yield line
dodal/devices/motors.py CHANGED
@@ -122,6 +122,20 @@ class XYPitchStage(XYStage):
122
122
  super().__init__(prefix, name, x_infix, y_infix)
123
123
 
124
124
 
125
+ class XYRollStage(XYStage):
126
+ def __init__(
127
+ self,
128
+ prefix: str,
129
+ x_infix: str = _X,
130
+ y_infix: str = _Y,
131
+ roll_infix: str = "ROLL",
132
+ name: str = "",
133
+ ) -> None:
134
+ with self.add_children_as_readables():
135
+ self.roll = Motor(prefix + roll_infix)
136
+ super().__init__(prefix, name, x_infix, y_infix)
137
+
138
+
125
139
  class XYZPitchYawStage(XYZStage):
126
140
  def __init__(
127
141
  self,