dls-dodal 1.32.0__py3-none-any.whl → 1.34.1__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 (55) hide show
  1. {dls_dodal-1.32.0.dist-info → dls_dodal-1.34.1.dist-info}/METADATA +3 -3
  2. {dls_dodal-1.32.0.dist-info → dls_dodal-1.34.1.dist-info}/RECORD +53 -43
  3. {dls_dodal-1.32.0.dist-info → dls_dodal-1.34.1.dist-info}/WHEEL +1 -1
  4. dodal/__init__.py +8 -0
  5. dodal/_version.py +2 -2
  6. dodal/beamline_specific_utils/i03.py +6 -2
  7. dodal/beamlines/__init__.py +2 -3
  8. dodal/beamlines/b01_1.py +77 -0
  9. dodal/beamlines/i03.py +41 -9
  10. dodal/beamlines/i04.py +26 -4
  11. dodal/beamlines/i10.py +257 -0
  12. dodal/beamlines/i22.py +1 -2
  13. dodal/beamlines/i24.py +7 -7
  14. dodal/beamlines/p38.py +1 -2
  15. dodal/common/signal_utils.py +53 -0
  16. dodal/common/types.py +2 -7
  17. dodal/devices/aperturescatterguard.py +12 -15
  18. dodal/devices/apple2_undulator.py +602 -0
  19. dodal/devices/areadetector/plugins/CAM.py +31 -0
  20. dodal/devices/areadetector/plugins/MJPG.py +51 -106
  21. dodal/devices/backlight.py +7 -6
  22. dodal/devices/diamond_filter.py +47 -0
  23. dodal/devices/eiger.py +6 -2
  24. dodal/devices/eiger_odin.py +48 -39
  25. dodal/devices/focusing_mirror.py +14 -8
  26. dodal/devices/i10/i10_apple2.py +398 -0
  27. dodal/devices/i10/i10_setting_data.py +7 -0
  28. dodal/devices/i22/dcm.py +7 -8
  29. dodal/devices/i24/dual_backlight.py +5 -5
  30. dodal/devices/oav/oav_calculations.py +22 -0
  31. dodal/devices/oav/oav_detector.py +118 -97
  32. dodal/devices/oav/oav_parameters.py +50 -104
  33. dodal/devices/oav/oav_to_redis_forwarder.py +75 -34
  34. dodal/devices/oav/{grid_overlay.py → snapshots/grid_overlay.py} +0 -43
  35. dodal/devices/oav/snapshots/snapshot_with_beam_centre.py +64 -0
  36. dodal/devices/oav/snapshots/snapshot_with_grid.py +57 -0
  37. dodal/devices/oav/utils.py +26 -25
  38. dodal/devices/pgm.py +41 -0
  39. dodal/devices/qbpm.py +18 -0
  40. dodal/devices/robot.py +2 -2
  41. dodal/devices/smargon.py +2 -2
  42. dodal/devices/tetramm.py +2 -2
  43. dodal/devices/undulator.py +2 -1
  44. dodal/devices/util/adjuster_plans.py +1 -1
  45. dodal/devices/util/lookup_tables.py +4 -5
  46. dodal/devices/zebra.py +5 -2
  47. dodal/devices/zocalo/zocalo_results.py +13 -10
  48. dodal/plans/data_session_metadata.py +2 -2
  49. dodal/plans/motor_util_plans.py +11 -9
  50. dodal/utils.py +7 -0
  51. dodal/beamlines/i04_1.py +0 -140
  52. dodal/devices/oav/oav_errors.py +0 -35
  53. {dls_dodal-1.32.0.dist-info → dls_dodal-1.34.1.dist-info}/LICENSE +0 -0
  54. {dls_dodal-1.32.0.dist-info → dls_dodal-1.34.1.dist-info}/entry_points.txt +0 -0
  55. {dls_dodal-1.32.0.dist-info → dls_dodal-1.34.1.dist-info}/top_level.txt +0 -0
@@ -1,138 +1,83 @@
1
- import os
2
- import threading
3
1
  from abc import ABC, abstractmethod
4
2
  from io import BytesIO
5
3
  from pathlib import Path
6
4
 
7
- import requests
8
- from ophyd import Component, Device, DeviceStatus, EpicsSignal, EpicsSignalRO, Signal
9
- from PIL import Image, ImageDraw
5
+ import aiofiles
6
+ from aiohttp import ClientSession
7
+ from bluesky.protocols import Triggerable
8
+ from ophyd_async.core import AsyncStatus, StandardReadable, soft_signal_rw
9
+ from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw
10
+ from PIL import Image
10
11
 
11
- from dodal.devices.oav.oav_parameters import OAVConfigParams
12
12
  from dodal.log import LOGGER
13
13
 
14
+ IMG_FORMAT = "png"
14
15
 
15
- class MJPG(Device, ABC):
16
+
17
+ async def asyncio_save_image(image: Image.Image, path: str):
18
+ buffer = BytesIO()
19
+ image.save(buffer, format=IMG_FORMAT)
20
+ async with aiofiles.open(path, "wb") as fh:
21
+ await fh.write(buffer.getbuffer())
22
+
23
+
24
+ class MJPG(StandardReadable, Triggerable, ABC):
16
25
  """The MJPG areadetector plugin creates an MJPG video stream of the camera's output.
17
26
 
18
27
  This devices uses that stream to grab images. When it is triggered it will send the
19
28
  latest image from the stream to the `post_processing` method for child classes to handle.
20
29
  """
21
30
 
22
- filename = Component(Signal)
23
- directory = Component(Signal)
24
- last_saved_path = Component(Signal)
25
- url = Component(EpicsSignal, "JPG_URL_RBV", string=True)
26
- x_size = Component(EpicsSignalRO, "ArraySize1_RBV")
27
- y_size = Component(EpicsSignalRO, "ArraySize2_RBV")
28
- input_rbpv = Component(EpicsSignalRO, "NDArrayPort_RBV")
29
- input_plugin = Component(EpicsSignal, "NDArrayPort")
31
+ def __init__(self, prefix: str, name: str = "") -> None:
32
+ self.url = epics_signal_rw(str, prefix + "JPG_URL_RBV")
30
33
 
31
- # scaling factors for the snapshot at the time it was triggered
32
- microns_per_pixel_x = Component(Signal)
33
- microns_per_pixel_y = Component(Signal)
34
+ self.x_size = epics_signal_r(int, prefix + "ArraySize1_RBV")
35
+ self.y_size = epics_signal_r(int, prefix + "ArraySize2_RBV")
34
36
 
35
- oav_params: OAVConfigParams | None = None
37
+ with self.add_children_as_readables():
38
+ self.filename = soft_signal_rw(str)
39
+ self.directory = soft_signal_rw(str)
40
+ self.last_saved_path = soft_signal_rw(str)
36
41
 
37
- KICKOFF_TIMEOUT: float = 30.0
42
+ self.KICKOFF_TIMEOUT = 30.0
38
43
 
39
- def _save_image(self, image: Image.Image):
40
- """A helper function to save a given image to the path supplied by the directory
41
- and filename signals. The full resultant path is put on the last_saved_path signal
44
+ super().__init__(name)
45
+
46
+ async def _save_image(self, image: Image.Image):
47
+ """A helper function to save a given image to the path supplied by the \
48
+ directory and filename signals. The full resultant path is put on the \
49
+ last_saved_path signal
42
50
  """
43
- filename_str = self.filename.get()
44
- directory_str: str = self.directory.get() # type: ignore
51
+ filename_str = await self.filename.get_value()
52
+ directory_str = await self.directory.get_value()
45
53
 
46
- path = Path(f"{directory_str}/{filename_str}.png").as_posix()
47
- if not os.path.isdir(Path(directory_str)):
54
+ path = Path(f"{directory_str}/{filename_str}.{IMG_FORMAT}").as_posix()
55
+ if not Path(directory_str).is_dir():
48
56
  LOGGER.info(f"Snapshot folder {directory_str} does not exist, creating...")
49
- os.mkdir(directory_str)
57
+ Path(directory_str).mkdir(parents=True)
50
58
 
51
59
  LOGGER.info(f"Saving image to {path}")
52
- image.save(path)
53
- self.last_saved_path.put(path)
54
60
 
55
- def trigger(self):
61
+ await asyncio_save_image(image, path)
62
+
63
+ await self.last_saved_path.set(path, wait=True)
64
+
65
+ @AsyncStatus.wrap
66
+ async def trigger(self):
56
67
  """This takes a snapshot image from the MJPG stream and send it to the
57
68
  post_processing method, expected to be implemented by a child of this class.
58
69
 
59
- It is the responsibility of the child class to save any resulting images.
70
+ It is the responsibility of the child class to save any resulting images by \
71
+ calling _save_image.
60
72
  """
61
- st = DeviceStatus(device=self, timeout=self.KICKOFF_TIMEOUT)
62
- url_str = self.url.get()
73
+ url_str = await self.url.get_value()
63
74
 
64
- assert isinstance(
65
- self.oav_params, OAVConfigParams
66
- ), "MJPG does not have valid OAV parameters"
67
- self.microns_per_pixel_x.set(self.oav_params.micronsPerXPixel)
68
- self.microns_per_pixel_y.set(self.oav_params.micronsPerYPixel)
69
-
70
- def get_snapshot():
71
- try:
72
- response = requests.get(url_str, stream=True)
73
- response.raise_for_status()
74
- with Image.open(BytesIO(response.content)) as image:
75
- self.post_processing(image)
76
- st.set_finished()
77
- except requests.HTTPError as e:
78
- st.set_exception(e)
79
-
80
- threading.Thread(target=get_snapshot, daemon=True).start()
81
-
82
- return st
75
+ async with ClientSession(raise_for_status=True) as session:
76
+ async with session.get(url_str) as response:
77
+ data = await response.read()
78
+ with Image.open(BytesIO(data)) as image:
79
+ await self.post_processing(image)
83
80
 
84
81
  @abstractmethod
85
- def post_processing(self, image: Image.Image):
82
+ async def post_processing(self, image: Image.Image):
86
83
  pass
87
-
88
-
89
- class SnapshotWithBeamCentre(MJPG):
90
- """A child of MJPG which, when triggered, draws an outlined crosshair at the beam
91
- centre in the image and saves the image to disk."""
92
-
93
- CROSSHAIR_LENGTH_PX = 20
94
- CROSSHAIR_OUTLINE_COLOUR = "Black"
95
- CROSSHAIR_FILL_COLOUR = "White"
96
-
97
- def post_processing(self, image: Image.Image):
98
- assert (
99
- self.oav_params is not None
100
- ), "Snapshot device does not have valid OAV parameters"
101
- beam_x = self.oav_params.beam_centre_i
102
- beam_y = self.oav_params.beam_centre_j
103
-
104
- SnapshotWithBeamCentre.draw_crosshair(image, beam_x, beam_y)
105
-
106
- self._save_image(image)
107
-
108
- @classmethod
109
- def draw_crosshair(cls, image: Image.Image, beam_x: int, beam_y: int):
110
- draw = ImageDraw.Draw(image)
111
- OUTLINE_WIDTH = 1
112
- HALF_LEN = cls.CROSSHAIR_LENGTH_PX / 2
113
- draw.rectangle(
114
- [
115
- beam_x - OUTLINE_WIDTH,
116
- beam_y - HALF_LEN - OUTLINE_WIDTH,
117
- beam_x + OUTLINE_WIDTH,
118
- beam_y + HALF_LEN + OUTLINE_WIDTH,
119
- ],
120
- fill=cls.CROSSHAIR_OUTLINE_COLOUR,
121
- )
122
- draw.rectangle(
123
- [
124
- beam_x - HALF_LEN - OUTLINE_WIDTH,
125
- beam_y - OUTLINE_WIDTH,
126
- beam_x + HALF_LEN + OUTLINE_WIDTH,
127
- beam_y + OUTLINE_WIDTH,
128
- ],
129
- fill=cls.CROSSHAIR_OUTLINE_COLOUR,
130
- )
131
- draw.line(
132
- ((beam_x, beam_y - HALF_LEN), (beam_x, beam_y + HALF_LEN)),
133
- fill=cls.CROSSHAIR_FILL_COLOUR,
134
- )
135
- draw.line(
136
- ((beam_x - HALF_LEN, beam_y), (beam_x + HALF_LEN, beam_y)),
137
- fill=cls.CROSSHAIR_FILL_COLOUR,
138
- )
@@ -1,6 +1,7 @@
1
1
  from asyncio import sleep
2
2
  from enum import Enum
3
3
 
4
+ from bluesky.protocols import Movable
4
5
  from ophyd_async.core import AsyncStatus, StandardReadable
5
6
  from ophyd_async.epics.signal import epics_signal_rw
6
7
 
@@ -15,10 +16,10 @@ class BacklightPosition(str, Enum):
15
16
  OUT = "Out"
16
17
 
17
18
 
18
- class Backlight(StandardReadable):
19
+ class Backlight(StandardReadable, Movable):
19
20
  """Simple device to trigger the pneumatic in/out."""
20
21
 
21
- TIME_TO_MOVE_S = 1 # Tested using a stopwatch on the beamline 09/2024
22
+ TIME_TO_MOVE_S = 1.0 # Tested using a stopwatch on the beamline 09/2024
22
23
 
23
24
  def __init__(self, prefix: str, name: str = "") -> None:
24
25
  with self.add_children_as_readables():
@@ -29,7 +30,7 @@ class Backlight(StandardReadable):
29
30
  super().__init__(name)
30
31
 
31
32
  @AsyncStatus.wrap
32
- async def set(self, position: BacklightPosition):
33
+ async def set(self, value: BacklightPosition):
33
34
  """This setter will turn the backlight on when we move it in to the beam and off
34
35
  when we move it out.
35
36
 
@@ -38,10 +39,10 @@ class Backlight(StandardReadable):
38
39
  to move completely in/out so we sleep here to simulate this.
39
40
  """
40
41
  old_position = await self.position.get_value()
41
- await self.position.set(position)
42
- if position == BacklightPosition.OUT:
42
+ await self.position.set(value)
43
+ if value == BacklightPosition.OUT:
43
44
  await self.power.set(BacklightPower.OFF)
44
45
  else:
45
46
  await self.power.set(BacklightPower.ON)
46
- if old_position != position:
47
+ if old_position != value:
47
48
  await sleep(self.TIME_TO_MOVE_S)
@@ -0,0 +1,47 @@
1
+ from enum import Enum
2
+ from typing import Generic, TypeVar
3
+
4
+ from ophyd_async.core import StandardReadable
5
+ from ophyd_async.epics.motor import Motor
6
+ from ophyd_async.epics.signal import epics_signal_rw
7
+
8
+
9
+ class _Filters(str, Enum):
10
+ pass
11
+
12
+
13
+ class I03Filters(_Filters):
14
+ EMPTY = "Empty"
15
+ TWO_HUNDRED = "200um"
16
+ ONE_HUNDRED = "100um"
17
+
18
+
19
+ class I04Filters(_Filters):
20
+ EMPTY = "Empty"
21
+ TWO_HUNDRED = "200um"
22
+ FIFTY = "50um"
23
+ OUT = "Out"
24
+
25
+
26
+ T = TypeVar("T", bound=_Filters)
27
+
28
+
29
+ class DiamondFilter(StandardReadable, Generic[T]):
30
+ """
31
+ A filter set that is used to reduce the heat load on the monochromator.
32
+
33
+ It has 4 slots that can contain filters of different thickness. Changing the thickness
34
+ signal will move the filter set to select this filter.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ prefix: str,
40
+ data_type: type[T],
41
+ name: str = "",
42
+ ) -> None:
43
+ with self.add_children_as_readables():
44
+ self.y_motor = Motor(prefix + "Y")
45
+ self.thickness = epics_signal_rw(data_type, f"{prefix}Y:MP:SELECT")
46
+
47
+ super().__init__(name)
dodal/devices/eiger.py CHANGED
@@ -81,7 +81,9 @@ class EigerDetector(Device):
81
81
 
82
82
  def async_stage(self):
83
83
  self.odin.nodes.clear_odin_errors()
84
- status_ok, error_message = self.odin.check_odin_initialised()
84
+ status_ok, error_message = self.odin.wait_for_odin_initialised(
85
+ self.GENERAL_STATUS_TIMEOUT
86
+ )
85
87
  if not status_ok:
86
88
  raise Exception(f"Odin not initialised: {error_message}")
87
89
 
@@ -129,7 +131,9 @@ class EigerDetector(Device):
129
131
  LOGGER.info("Disarming detector")
130
132
  finally:
131
133
  self.disarm_detector()
132
- status_ok = self.odin.check_odin_state()
134
+ status_ok = self.odin.check_and_wait_for_odin_state(
135
+ self.GENERAL_STATUS_TIMEOUT
136
+ )
133
137
  self.disable_roi_mode()
134
138
  return status_ok
135
139
 
@@ -1,8 +1,10 @@
1
1
  # type: ignore # Eiger will soon be ophyd-async https://github.com/DiamondLightSource/dodal/issues/700
2
+ from functools import partial, reduce
3
+
2
4
  from ophyd import Component, Device, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV
3
5
  from ophyd.areadetector.plugins import HDF5Plugin_V22
4
6
  from ophyd.sim import NullStatus
5
- from ophyd.status import StatusBase
7
+ from ophyd.status import StatusBase, SubscriptionStatus
6
8
 
7
9
  from dodal.devices.status import await_value
8
10
 
@@ -61,47 +63,47 @@ class OdinNodesStatus(Device):
61
63
  def nodes(self) -> list[OdinNode]:
62
64
  return [self.node_0, self.node_1, self.node_2, self.node_3]
63
65
 
64
- def check_node_frames_from_attr(
66
+ def _check_node_frames_from_attr(
65
67
  self, node_get_func, error_message_verb: str
66
68
  ) -> tuple[bool, str]:
67
69
  nodes_frames_values = [0] * len(self.nodes)
68
70
  frames_details = []
69
71
  for node_number, node_pv in enumerate(self.nodes):
70
- nodes_frames_values[node_number] = node_get_func(node_pv)
71
- error_message = f"Filewriter {node_number} {error_message_verb} \
72
- {nodes_frames_values[node_number]} frames"
73
- frames_details.append(error_message)
72
+ node_state = node_get_func(node_pv)
73
+ nodes_frames_values[node_number] = node_state
74
+ if node_state != 0:
75
+ error_message = f"Filewriter {node_number} {error_message_verb} \
76
+ {nodes_frames_values[node_number]} frames"
77
+ frames_details.append(error_message)
74
78
  bad_frames = any(v != 0 for v in nodes_frames_values)
75
79
  return bad_frames, "\n".join(frames_details)
76
80
 
77
81
  def check_frames_timed_out(self) -> tuple[bool, str]:
78
- return self.check_node_frames_from_attr(
82
+ return self._check_node_frames_from_attr(
79
83
  lambda node: node.frames_timed_out.get(), "timed out"
80
84
  )
81
85
 
82
86
  def check_frames_dropped(self) -> tuple[bool, str]:
83
- return self.check_node_frames_from_attr(
87
+ return self._check_node_frames_from_attr(
84
88
  lambda node: node.frames_dropped.get(), "dropped"
85
89
  )
86
90
 
87
- def get_error_state(self) -> tuple[bool, str]:
88
- is_error = []
89
- error_messages = []
91
+ def wait_for_no_errors(self, timeout) -> dict[SubscriptionStatus, str]:
92
+ errors = {}
90
93
  for node_number, node_pv in enumerate(self.nodes):
91
- is_error.append(node_pv.error_status.get())
92
- if is_error[node_number]:
93
- error_messages.append(
94
- f"Filewriter {node_number} is in an error state with error message\
94
+ errors[
95
+ await_value(node_pv.error_status, False, timeout)
96
+ ] = f"Filewriter {node_number} is in an error state with error message\
95
97
  - {node_pv.error_message.get()}"
96
- )
97
- return any(is_error), "\n".join(error_messages)
98
98
 
99
- def get_init_state(self) -> bool:
99
+ return errors
100
+
101
+ def get_init_state(self, timeout) -> SubscriptionStatus:
100
102
  is_initialised = []
101
103
  for node_pv in self.nodes:
102
- is_initialised.append(node_pv.fr_initialised.get())
103
- is_initialised.append(node_pv.fp_initialised.get())
104
- return all(is_initialised)
104
+ is_initialised.append(await_value(node_pv.fr_initialised, True, timeout))
105
+ is_initialised.append(await_value(node_pv.fp_initialised, True, timeout))
106
+ return reduce(lambda x, y: x & y, is_initialised)
105
107
 
106
108
  def clear_odin_errors(self):
107
109
  clearing_status = NullStatus()
@@ -125,8 +127,8 @@ class EigerOdin(Device):
125
127
  writing_finished &= await_value(node_pv.writing, 0)
126
128
  return writing_finished
127
129
 
128
- def check_odin_state(self) -> bool:
129
- is_initialised, error_message = self.check_odin_initialised()
130
+ def check_and_wait_for_odin_state(self, timeout) -> bool:
131
+ is_initialised, error_message = self.wait_for_odin_initialised(timeout)
130
132
  frames_dropped, frames_dropped_details = self.nodes.check_frames_dropped()
131
133
  frames_timed_out, frames_timed_out_details = self.nodes.check_frames_timed_out()
132
134
 
@@ -139,22 +141,29 @@ class EigerOdin(Device):
139
141
 
140
142
  return is_initialised and not frames_dropped and not frames_timed_out
141
143
 
142
- def check_odin_initialised(self) -> tuple[bool, str]:
143
- is_error_state, error_messages = self.nodes.get_error_state()
144
- to_check = [
145
- (not self.fan.consumers_connected.get(), "EigerFan is not connected"),
146
- (not self.fan.on.get(), "EigerFan is not initialised"),
147
- (not self.meta.initialised.get(), "MetaListener is not initialised"),
148
- (is_error_state, error_messages),
149
- (
150
- not self.nodes.get_init_state(),
151
- "One or more filewriters is not initialised",
152
- ),
153
- ]
154
-
155
- errors = [message for check_result, message in to_check if check_result]
156
-
157
- return not errors, "\n".join(errors)
144
+ def wait_for_odin_initialised(self, timeout) -> tuple[bool, str]:
145
+ errors = self.nodes.wait_for_no_errors(timeout)
146
+ await_true = partial(await_value, expected_value=True, timeout=timeout)
147
+ errors[
148
+ await_value(
149
+ self.fan.consumers_connected, expected_value=True, timeout=timeout
150
+ )
151
+ ] = "EigerFan is not connected"
152
+ errors[await_true(self.fan.on)] = "EigerFan is not initialised"
153
+ errors[await_true(self.meta.initialised)] = "MetaListener is not initialised"
154
+ errors[self.nodes.get_init_state(timeout)] = (
155
+ "One or more filewriters is not initialised"
156
+ )
157
+
158
+ error_strings = []
159
+
160
+ for error_status, string in errors.items():
161
+ try:
162
+ error_status.wait(timeout=timeout)
163
+ except Exception:
164
+ error_strings.append(string)
165
+
166
+ return not error_strings, "\n".join(error_strings)
158
167
 
159
168
  def stop(self) -> StatusBase:
160
169
  """Stop odin manually"""
@@ -45,7 +45,7 @@ class MirrorVoltageDemand(str, Enum):
45
45
  SLEW = "SLEW"
46
46
 
47
47
 
48
- class MirrorVoltageDevice(Device):
48
+ class SingleMirrorVoltage(Device):
49
49
  """Abstract the bimorph mirror voltage PVs into a single device that can be set asynchronously and returns when
50
50
  the demanded voltage setpoint is accepted, without blocking the caller as this process can take significant time.
51
51
  """
@@ -105,22 +105,28 @@ class MirrorVoltageDevice(Device):
105
105
  await set_status
106
106
 
107
107
 
108
- class VFMMirrorVoltages(StandardReadable):
108
+ class MirrorVoltages(StandardReadable):
109
109
  def __init__(
110
110
  self, name: str, prefix: str, *args, daq_configuration_path: str, **kwargs
111
111
  ):
112
112
  self.voltage_lookup_table_path = (
113
113
  daq_configuration_path + "/json/mirrorFocus.json"
114
114
  )
115
+
115
116
  with self.add_children_as_readables():
116
- self.voltage_channels = DeviceVector(
117
- {
118
- i - 14: MirrorVoltageDevice(prefix=f"{prefix}BM:V{i}")
119
- for i in range(14, 22)
120
- }
121
- )
117
+ self.horizontal_voltages = self._channels_in_range(prefix, 0, 14)
118
+ self.vertical_voltages = self._channels_in_range(prefix, 14, 22)
119
+
122
120
  super().__init__(*args, name=name, **kwargs)
123
121
 
122
+ def _channels_in_range(self, prefix, start_idx, end_idx):
123
+ return DeviceVector(
124
+ {
125
+ i - start_idx: SingleMirrorVoltage(prefix=f"{prefix}BM:V{i}")
126
+ for i in range(start_idx, end_idx)
127
+ }
128
+ )
129
+
124
130
 
125
131
  class FocusingMirror(StandardReadable):
126
132
  """Focusing Mirror"""