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.
- {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/METADATA +1 -1
- {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/RECORD +47 -37
- dodal/_version.py +2 -2
- dodal/beamlines/i03.py +92 -208
- dodal/beamlines/i04.py +22 -1
- dodal/beamlines/i05.py +1 -1
- dodal/beamlines/i06.py +1 -1
- dodal/beamlines/i09_1.py +26 -2
- dodal/beamlines/i09_2.py +57 -2
- dodal/beamlines/i10_optics.py +44 -25
- dodal/beamlines/i17.py +7 -3
- dodal/beamlines/i19_1.py +26 -14
- dodal/beamlines/i19_2.py +49 -38
- dodal/beamlines/i21.py +2 -2
- dodal/beamlines/i22.py +16 -1
- dodal/beamlines/training_rig.py +0 -16
- dodal/cli.py +26 -12
- dodal/common/coordination.py +3 -2
- dodal/device_manager.py +604 -0
- dodal/devices/cryostream.py +28 -57
- dodal/devices/eiger.py +26 -18
- dodal/devices/i04/max_pixel.py +38 -0
- dodal/devices/i09_1_shared/__init__.py +8 -1
- dodal/devices/i09_1_shared/hard_energy.py +112 -0
- dodal/devices/i09_2_shared/__init__.py +0 -0
- dodal/devices/i09_2_shared/i09_apple2.py +86 -0
- dodal/devices/i10/i10_apple2.py +23 -21
- dodal/devices/i17/i17_apple2.py +32 -20
- dodal/devices/i19/access_controlled/attenuator_motor_squad.py +61 -0
- dodal/devices/i19/access_controlled/blueapi_device.py +9 -1
- dodal/devices/i19/access_controlled/shutter.py +2 -4
- dodal/devices/insertion_device/__init__.py +0 -0
- dodal/devices/{apple2_undulator.py → insertion_device/apple2_undulator.py} +36 -28
- dodal/devices/insertion_device/energy_motor_lookup.py +88 -0
- dodal/devices/insertion_device/lookup_table_models.py +287 -0
- dodal/devices/motors.py +14 -0
- dodal/devices/robot.py +16 -11
- dodal/plans/__init__.py +1 -1
- dodal/plans/configure_arm_trigger_and_disarm_detector.py +2 -4
- dodal/testing/fixtures/devices/__init__.py +0 -0
- dodal/testing/fixtures/devices/apple2.py +78 -0
- dodal/utils.py +6 -3
- dodal/devices/util/lookup_tables_apple2.py +0 -390
- {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/WHEEL +0 -0
- {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/entry_points.txt +0 -0
- {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/licenses/LICENSE +0 -0
- {dls_dodal-1.66.0.dist-info → dls_dodal-1.67.0.dist-info}/top_level.txt +0 -0
- /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__(
|
|
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
|
-
|
|
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.
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
Abstract method to
|
|
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 `
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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(
|
|
485
|
-
top_inner =
|
|
486
|
-
btm_outer =
|
|
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=
|
|
501
|
+
top_outer=phase.top_outer.user_readback,
|
|
499
502
|
top_inner=top_inner,
|
|
500
|
-
btm_inner=
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|