dls-dodal 1.31.1__py3-none-any.whl → 1.33.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.
dodal/devices/i24/pmac.py CHANGED
@@ -1,16 +1,17 @@
1
+ from asyncio import sleep
1
2
  from enum import Enum, IntEnum
2
- from typing import SupportsFloat
3
3
 
4
- from bluesky.protocols import Triggerable
4
+ from bluesky.protocols import Flyable, Triggerable
5
5
  from ophyd_async.core import (
6
+ CALCULATE_TIMEOUT,
6
7
  DEFAULT_TIMEOUT,
7
8
  AsyncStatus,
8
- CalculateTimeout,
9
9
  SignalBackend,
10
10
  SignalR,
11
11
  SignalRW,
12
12
  SoftSignalBackend,
13
13
  StandardReadable,
14
+ soft_signal_rw,
14
15
  wait_for_value,
15
16
  )
16
17
  from ophyd_async.epics.motor import Motor
@@ -89,7 +90,7 @@ class PMACStringLaser(SignalRW):
89
90
  self,
90
91
  value: LaserSettings,
91
92
  wait=True,
92
- timeout=CalculateTimeout,
93
+ timeout=CALCULATE_TIMEOUT,
93
94
  ):
94
95
  await self.signal.set(value.value, wait, timeout)
95
96
 
@@ -112,13 +113,13 @@ class PMACStringEncReset(SignalRW):
112
113
  self,
113
114
  value: EncReset,
114
115
  wait=True,
115
- timeout=CalculateTimeout,
116
+ timeout=CALCULATE_TIMEOUT,
116
117
  ):
117
118
  await self.signal.set(value.value, wait, timeout)
118
119
 
119
120
 
120
- class ProgramRunner(SignalRW):
121
- """Trigger the collection by setting the program number on the PMAC string.
121
+ class ProgramRunner(SignalRW, Flyable):
122
+ """Run the collection by setting the program number on the PMAC string.
122
123
 
123
124
  Once the program number has been set, wait for the collection to be complete.
124
125
  This will only be true when the status becomes 0.
@@ -128,22 +129,73 @@ class ProgramRunner(SignalRW):
128
129
  self,
129
130
  pmac_str_sig: SignalRW,
130
131
  status_sig: SignalR,
132
+ prog_num_sig: SignalRW,
133
+ collection_time_sig: SignalRW,
131
134
  backend: SignalBackend,
132
135
  timeout: float | None = DEFAULT_TIMEOUT,
133
136
  name: str = "",
134
137
  ) -> None:
135
138
  self.signal = pmac_str_sig
136
139
  self.status = status_sig
140
+ self.prog_num = prog_num_sig
141
+
142
+ self.collection_time = collection_time_sig
143
+ self.KICKOFF_TIMEOUT = timeout
144
+
137
145
  super().__init__(backend, timeout, name)
138
146
 
147
+ async def _get_prog_number_string(self) -> str:
148
+ prog_num = await self.prog_num.get_value()
149
+ return f"&2b{prog_num}r"
150
+
151
+ @AsyncStatus.wrap
152
+ async def kickoff(self):
153
+ """Kick off the collection by sending a program number to the pmac_string and \
154
+ wait for the scan status PV to go to 1.
155
+ """
156
+ prog_num_str = await self._get_prog_number_string()
157
+ await self.signal.set(prog_num_str, wait=True)
158
+ await wait_for_value(
159
+ self.status,
160
+ ScanState.RUNNING,
161
+ timeout=self.KICKOFF_TIMEOUT,
162
+ )
163
+
164
+ @AsyncStatus.wrap
165
+ async def complete(self):
166
+ """Stop collecting when the scan status PV goes to 0.
167
+
168
+ Args:
169
+ complete_time (float): total time required by the collection to \
170
+ finish correctly.
171
+ """
172
+ scan_complete_time = await self.collection_time.get_value()
173
+ await wait_for_value(self.status, ScanState.DONE, timeout=scan_complete_time)
174
+
175
+
176
+ class ProgramAbort(Triggerable):
177
+ """Abort a data collection by setting the PMAC string and then wait for the \
178
+ status value to go back to 0.
179
+ """
180
+
181
+ def __init__(
182
+ self,
183
+ pmac_str_sig: SignalRW,
184
+ status_sig: SignalR,
185
+ ) -> None:
186
+ self.signal = pmac_str_sig
187
+ self.status = status_sig
188
+
139
189
  @AsyncStatus.wrap
140
- async def set(self, value: int, wait=True, timeout=None):
141
- prog_str = f"&2b{value}r"
142
- assert isinstance(timeout, SupportsFloat) or (
143
- timeout is None
144
- ), f"ProgramRunner does not support calculating timeout itself, {timeout=}"
145
- await self.signal.set(prog_str, wait=wait)
146
- await wait_for_value(self.status, ScanState.DONE, timeout)
190
+ async def trigger(self):
191
+ await self.signal.set("A", wait=True)
192
+ await sleep(1.0) # TODO Check with scientist what this sleep is really for.
193
+ await self.signal.set("P2401=0", wait=True)
194
+ await wait_for_value(
195
+ self.status,
196
+ ScanState.DONE,
197
+ timeout=DEFAULT_TIMEOUT,
198
+ )
147
199
 
148
200
 
149
201
  class PMAC(StandardReadable):
@@ -172,8 +224,18 @@ class PMAC(StandardReadable):
172
224
  self.scanstatus = epics_signal_r(float, "BL24I-MO-STEP-14:signal:P2401")
173
225
  self.counter = epics_signal_r(float, "BL24I-MO-STEP-14:signal:P2402")
174
226
 
227
+ # A couple of soft signals for running a collection: program number to send to
228
+ # the PMAC_STRING and expected collection time.
229
+ self.program_number = soft_signal_rw(int)
230
+ self.collection_time = soft_signal_rw(float, initial_value=600.0, units="s")
231
+
175
232
  self.run_program = ProgramRunner(
176
- self.pmac_string, self.scanstatus, backend=SoftSignalBackend(str)
233
+ self.pmac_string,
234
+ self.scanstatus,
235
+ self.program_number,
236
+ self.collection_time,
237
+ backend=SoftSignalBackend(str),
177
238
  )
239
+ self.abort_program = ProgramAbort(self.pmac_string, self.scanstatus)
178
240
 
179
241
  super().__init__(name)
@@ -12,7 +12,6 @@ from ophyd import (
12
12
  OverlayPlugin,
13
13
  ProcessPlugin,
14
14
  ROIPlugin,
15
- Signal,
16
15
  StatusBase,
17
16
  )
18
17
 
@@ -35,8 +34,6 @@ class ZoomController(Device):
35
34
 
36
35
  # Level is the string description of the zoom level e.g. "1.0x"
37
36
  level = Component(EpicsSignal, "MP:SELECT", string=True)
38
- # Used by OAV to work out if we're changing the setpoint
39
- _level_sp = Component(Signal)
40
37
 
41
38
  zrst = Component(EpicsSignal, "MP:SELECT.ZRST")
42
39
  onst = Component(EpicsSignal, "MP:SELECT.ONST")
@@ -46,14 +43,6 @@ class ZoomController(Device):
46
43
  fvst = Component(EpicsSignal, "MP:SELECT.FVST")
47
44
  sxst = Component(EpicsSignal, "MP:SELECT.SXST")
48
45
 
49
- def set_flatfield_on_zoom_level_one(self, value):
50
- self.parent: OAV
51
- flat_applied = self.parent.proc.port_name.get()
52
- no_flat_applied = self.parent.cam.port_name.get()
53
- return self.parent.grid_snapshot.input_plugin.set(
54
- flat_applied if value == "1.0x" else no_flat_applied
55
- )
56
-
57
46
  @property
58
47
  def allowed_zoom_levels(self):
59
48
  return [
@@ -67,10 +56,7 @@ class ZoomController(Device):
67
56
  ]
68
57
 
69
58
  def set(self, level_to_set: str) -> StatusBase:
70
- return_status = self._level_sp.set(level_to_set)
71
- return_status &= self.level.set(level_to_set)
72
- return_status &= self.set_flatfield_on_zoom_level_one(level_to_set)
73
- return return_status
59
+ return self.level.set(level_to_set)
74
60
 
75
61
 
76
62
  class OAV(AreaDetector):
dodal/devices/robot.py CHANGED
@@ -1,5 +1,5 @@
1
1
  import asyncio
2
- from asyncio import FIRST_COMPLETED, CancelledError, Task
2
+ from asyncio import FIRST_COMPLETED, CancelledError, Task, wait_for
3
3
  from dataclasses import dataclass
4
4
  from enum import Enum
5
5
 
@@ -41,6 +41,9 @@ class PinMounted(str, Enum):
41
41
  class BartRobot(StandardReadable, Movable):
42
42
  """The sample changing robot."""
43
43
 
44
+ # How long to wait for the robot if it is busy soaking/drying
45
+ NOT_BUSY_TIMEOUT = 60
46
+ # How long to wait for the actual load to happen
44
47
  LOAD_TIMEOUT = 60
45
48
  NO_PIN_ERROR_CODE = 25
46
49
 
@@ -54,10 +57,15 @@ class BartRobot(StandardReadable, Movable):
54
57
  ) -> None:
55
58
  self.barcode = epics_signal_r(str, prefix + "BARCODE")
56
59
  self.gonio_pin_sensor = epics_signal_r(PinMounted, prefix + "PIN_MOUNTED")
60
+
57
61
  self.next_pin = epics_signal_rw_rbv(float, prefix + "NEXT_PIN")
58
62
  self.next_puck = epics_signal_rw_rbv(float, prefix + "NEXT_PUCK")
63
+ self.current_puck = epics_signal_r(float, prefix + "CURRENT_PUCK_RBV")
64
+ self.current_pin = epics_signal_r(float, prefix + "CURRENT_PIN_RBV")
65
+
59
66
  self.next_sample_id = epics_signal_rw_rbv(float, prefix + "NEXT_ID")
60
67
  self.sample_id = epics_signal_r(float, prefix + "CURRENT_ID_RBV")
68
+
61
69
  self.load = epics_signal_x(prefix + "LOAD.PROC")
62
70
  self.program_running = epics_signal_r(bool, prefix + "PROGRAM_RUNNING")
63
71
  self.program_name = epics_signal_r(str, prefix + "PROGRAM_NAME")
@@ -93,7 +101,7 @@ class BartRobot(StandardReadable, Movable):
93
101
  for task in finished:
94
102
  await task
95
103
  except CancelledError:
96
- # If the outer enclosing task cancels after LOAD_TIMEOUT, this causes CancelledError to be raised
104
+ # If the outer enclosing task cancels after a timeout, this causes CancelledError to be raised
97
105
  # in the current task, when it propagates to here we should cancel all pending tasks before bubbling up
98
106
  for task in tasks:
99
107
  task.cancel()
@@ -105,7 +113,9 @@ class BartRobot(StandardReadable, Movable):
105
113
  LOGGER.info(
106
114
  f"Waiting on robot to finish {await self.program_name.get_value()}"
107
115
  )
108
- await wait_for_value(self.program_running, False, None)
116
+ await wait_for_value(
117
+ self.program_running, False, timeout=self.NOT_BUSY_TIMEOUT
118
+ )
109
119
  await asyncio.gather(
110
120
  set_and_wait_for_value(self.next_puck, sample_location.puck),
111
121
  set_and_wait_for_value(self.next_pin, sample_location.pin),
@@ -121,10 +131,12 @@ class BartRobot(StandardReadable, Movable):
121
131
  @AsyncStatus.wrap
122
132
  async def set(self, value: SampleLocation):
123
133
  try:
124
- await asyncio.wait_for(
125
- self._load_pin_and_puck(value), timeout=self.LOAD_TIMEOUT
134
+ await wait_for(
135
+ self._load_pin_and_puck(value),
136
+ timeout=self.LOAD_TIMEOUT + self.NOT_BUSY_TIMEOUT,
126
137
  )
127
- except asyncio.TimeoutError as e:
138
+ except (asyncio.TimeoutError, TimeoutError) as e:
139
+ # Will only need to catch asyncio.TimeoutError after https://github.com/bluesky/ophyd-async/issues/572
128
140
  error_code = await self.error_code.get_value()
129
141
  error_string = await self.error_str.get_value()
130
142
  raise RobotLoadFailed(int(error_code), error_string) from e
dodal/devices/tetramm.py CHANGED
@@ -3,13 +3,13 @@ from enum import Enum
3
3
 
4
4
  from bluesky.protocols import Hints
5
5
  from ophyd_async.core import (
6
- AsyncStatus,
7
6
  DatasetDescriber,
8
7
  DetectorControl,
9
8
  DetectorTrigger,
10
9
  Device,
11
10
  PathProvider,
12
11
  StandardDetector,
12
+ TriggerInfo,
13
13
  set_and_wait_for_value,
14
14
  soft_signal_r_and_setter,
15
15
  )
@@ -113,29 +113,24 @@ class TetrammController(DetectorControl):
113
113
  # 2 internal clock cycles. Best effort approximation
114
114
  return 2 / self.base_sample_rate
115
115
 
116
- async def arm(
117
- self,
118
- num: int,
119
- trigger: DetectorTrigger = DetectorTrigger.edge_trigger,
120
- exposure: float | None = None,
121
- ) -> AsyncStatus:
122
- if exposure is None:
123
- raise ValueError(
124
- "Tetramm does not support arm without exposure time. "
125
- "Is this a software scan? Tetramm only supports hardware scans."
126
- )
127
- self._validate_trigger(trigger)
116
+ async def prepare(self, trigger_info: TriggerInfo):
117
+ self._validate_trigger(trigger_info.trigger)
118
+ assert trigger_info.livetime is not None
128
119
 
129
120
  # trigger mode must be set first and on its own!
130
121
  await self._drv.trigger_mode.set(TetrammTrigger.ExtTrigger)
131
122
 
132
123
  await asyncio.gather(
133
- self._drv.averaging_time.set(exposure), self.set_exposure(exposure)
124
+ self._drv.averaging_time.set(trigger_info.livetime),
125
+ self.set_exposure(trigger_info.livetime),
134
126
  )
135
127
 
136
- status = await set_and_wait_for_value(self._drv.acquire, True)
128
+ async def arm(self):
129
+ self._arm_status = await set_and_wait_for_value(self._drv.acquire, True)
137
130
 
138
- return status
131
+ async def wait_for_idle(self):
132
+ if self._arm_status:
133
+ await self._arm_status
139
134
 
140
135
  def _validate_trigger(self, trigger: DetectorTrigger) -> None:
141
136
  supported_trigger_types = {
@@ -1,12 +1,35 @@
1
1
  from enum import Enum
2
2
 
3
- from ophyd_async.core import ConfigSignal, StandardReadable, soft_signal_r_and_setter
3
+ import numpy as np
4
+ from bluesky.protocols import Movable
5
+ from numpy import argmin, ndarray
6
+ from ophyd_async.core import (
7
+ AsyncStatus,
8
+ ConfigSignal,
9
+ StandardReadable,
10
+ soft_signal_r_and_setter,
11
+ )
4
12
  from ophyd_async.epics.motor import Motor
5
13
  from ophyd_async.epics.signal import epics_signal_r
6
14
 
15
+ from dodal.log import LOGGER
16
+
17
+ from .util.lookup_tables import energy_distance_table
18
+
19
+
20
+ class AccessError(Exception):
21
+ pass
22
+
23
+
24
+ # Enable to allow testing when the beamline is down, do not change in production!
25
+ TEST_MODE = False
26
+ # will be made more generic in https://github.com/DiamondLightSource/dodal/issues/754
27
+
28
+
7
29
  # The acceptable difference, in mm, between the undulator gap and the DCM
8
30
  # energy, when the latter is converted to mm using lookup tables
9
31
  UNDULATOR_DISCREPANCY_THRESHOLD_MM = 2e-3
32
+ STATUS_TIMEOUT_S: float = 10.0
10
33
 
11
34
 
12
35
  class UndulatorGapAccess(str, Enum):
@@ -14,7 +37,15 @@ class UndulatorGapAccess(str, Enum):
14
37
  DISABLED = "DISABLED"
15
38
 
16
39
 
17
- class Undulator(StandardReadable):
40
+ def _get_closest_gap_for_energy(
41
+ dcm_energy_ev: float, energy_to_distance_table: ndarray
42
+ ) -> float:
43
+ table = energy_to_distance_table.transpose()
44
+ idx = argmin(np.abs(table[0] - dcm_energy_ev))
45
+ return table[1][idx]
46
+
47
+
48
+ class Undulator(StandardReadable, Movable):
18
49
  """
19
50
  An Undulator-type insertion device, used to control photon emission at a given
20
51
  beam energy.
@@ -23,6 +54,7 @@ class Undulator(StandardReadable):
23
54
  def __init__(
24
55
  self,
25
56
  prefix: str,
57
+ id_gap_lookup_table_path: str,
26
58
  name: str = "",
27
59
  poles: int | None = None,
28
60
  length: float | None = None,
@@ -36,6 +68,7 @@ class Undulator(StandardReadable):
36
68
  name (str, optional): Name for device. Defaults to "".
37
69
  """
38
70
 
71
+ self.id_gap_lookup_table_path = id_gap_lookup_table_path
39
72
  with self.add_children_as_readables():
40
73
  self.gap_motor = Motor(prefix + "BLGAPMTR")
41
74
  self.current_gap = epics_signal_r(float, prefix + "CURRGAPD")
@@ -63,3 +96,59 @@ class Undulator(StandardReadable):
63
96
  self.length = None
64
97
 
65
98
  super().__init__(name)
99
+
100
+ @AsyncStatus.wrap
101
+ async def set(self, value: float):
102
+ """
103
+ Set the undulator gap to a given energy in keV
104
+
105
+ Args:
106
+ value: energy in keV
107
+ """
108
+ await self._set_undulator_gap(value)
109
+
110
+ async def _set_undulator_gap(self, energy_kev: float) -> None:
111
+ access_level = await self.gap_access.get_value()
112
+ if access_level is UndulatorGapAccess.DISABLED and not TEST_MODE:
113
+ raise AccessError("Undulator gap access is disabled. Contact Control Room")
114
+ LOGGER.info(f"Setting undulator gap to {energy_kev:.2f} kev")
115
+ target_gap = await self._get_gap_to_match_energy(energy_kev)
116
+
117
+ # Check if undulator gap is close enough to the value from the DCM
118
+ current_gap = await self.current_gap.get_value()
119
+ tolerance = await self.gap_discrepancy_tolerance_mm.get_value()
120
+ difference = abs(target_gap - current_gap)
121
+ if difference > tolerance:
122
+ LOGGER.info(
123
+ f"Undulator gap mismatch. {difference:.3f}mm is outside tolerance.\
124
+ Moving gap to nominal value, {target_gap:.3f}mm"
125
+ )
126
+ if not TEST_MODE:
127
+ # Only move if the gap is sufficiently different to the value from the
128
+ # DCM lookup table AND we're not in TEST_MODE
129
+ await self.gap_motor.set(
130
+ target_gap,
131
+ timeout=STATUS_TIMEOUT_S,
132
+ )
133
+ else:
134
+ LOGGER.debug("In test mode, not moving ID gap")
135
+ else:
136
+ LOGGER.debug(
137
+ "Gap is already in the correct place for the new energy value "
138
+ f"{energy_kev}, no need to ask it to move"
139
+ )
140
+
141
+ async def _get_gap_to_match_energy(self, energy_kev: float) -> float:
142
+ """
143
+ get a 2d np.array from lookup table that
144
+ converts energies to undulator gap distance
145
+ """
146
+ energy_to_distance_table: np.ndarray = await energy_distance_table(
147
+ self.id_gap_lookup_table_path
148
+ )
149
+
150
+ # Use the lookup table to get the undulator gap associated with this dcm energy
151
+ return _get_closest_gap_for_energy(
152
+ energy_kev * 1000,
153
+ energy_to_distance_table,
154
+ )
@@ -1,19 +1,14 @@
1
1
  import asyncio
2
2
 
3
- import numpy as np
4
3
  from bluesky.protocols import Movable
5
- from numpy import argmin, ndarray
6
4
  from ophyd_async.core import AsyncStatus, StandardReadable
7
5
 
8
6
  from dodal.common.beamlines.beamline_parameters import get_beamline_parameters
9
- from dodal.log import LOGGER
10
7
 
11
8
  from .dcm import DCM
12
9
  from .undulator import Undulator, UndulatorGapAccess
13
- from .util.lookup_tables import energy_distance_table
14
10
 
15
11
  ENERGY_TIMEOUT_S: float = 30.0
16
- STATUS_TIMEOUT_S: float = 10.0
17
12
 
18
13
  # Enable to allow testing when the beamline is down, do not change in production!
19
14
  TEST_MODE = False
@@ -23,14 +18,6 @@ class AccessError(Exception):
23
18
  pass
24
19
 
25
20
 
26
- def _get_closest_gap_for_energy(
27
- dcm_energy_ev: float, energy_to_distance_table: ndarray
28
- ) -> float:
29
- table = energy_to_distance_table.transpose()
30
- idx = argmin(np.abs(table[0] - dcm_energy_ev))
31
- return table[1][idx]
32
-
33
-
34
21
  class UndulatorDCM(StandardReadable, Movable):
35
22
  """
36
23
  Composite device to handle changing beamline energies, wraps the Undulator and the
@@ -48,7 +35,6 @@ class UndulatorDCM(StandardReadable, Movable):
48
35
  self,
49
36
  undulator: Undulator,
50
37
  dcm: DCM,
51
- id_gap_lookup_table_path: str,
52
38
  daq_configuration_path: str,
53
39
  prefix: str = "",
54
40
  name: str = "",
@@ -61,11 +47,10 @@ class UndulatorDCM(StandardReadable, Movable):
61
47
  self.dcm = dcm
62
48
 
63
49
  # These attributes are just used by hyperion for lookup purposes
64
- self.id_gap_lookup_table_path = id_gap_lookup_table_path
65
- self.dcm_pitch_converter_lookup_table_path = (
50
+ self.pitch_energy_table_path = (
66
51
  daq_configuration_path + "/lookup/BeamLineEnergy_DCM_Pitch_converter.txt"
67
52
  )
68
- self.dcm_roll_converter_lookup_table_path = (
53
+ self.roll_energy_table_path = (
69
54
  daq_configuration_path + "/lookup/BeamLineEnergy_DCM_Roll_converter.txt"
70
55
  )
71
56
  # I03 configures the DCM Perp as a side effect of applying this fixed value to the DCM Offset after an energy change
@@ -78,7 +63,7 @@ class UndulatorDCM(StandardReadable, Movable):
78
63
  async def set(self, value: float):
79
64
  await asyncio.gather(
80
65
  self._set_dcm_energy(value),
81
- self._set_undulator_gap_if_required(value),
66
+ self.undulator.set(value),
82
67
  )
83
68
 
84
69
  async def _set_dcm_energy(self, energy_kev: float) -> None:
@@ -90,42 +75,3 @@ class UndulatorDCM(StandardReadable, Movable):
90
75
  energy_kev,
91
76
  timeout=ENERGY_TIMEOUT_S,
92
77
  )
93
-
94
- async def _set_undulator_gap_if_required(self, energy_kev: float) -> None:
95
- LOGGER.info(f"Setting DCM energy to {energy_kev:.2f} kev")
96
- gap_to_match_dcm_energy = await self._gap_to_match_dcm_energy(energy_kev)
97
-
98
- # Check if undulator gap is close enough to the value from the DCM
99
- current_gap = await self.undulator.current_gap.get_value()
100
- tolerance = await self.undulator.gap_discrepancy_tolerance_mm.get_value()
101
- if abs(gap_to_match_dcm_energy - current_gap) > tolerance:
102
- LOGGER.info(
103
- f"Undulator gap mismatch. {abs(gap_to_match_dcm_energy-current_gap):.3f}mm is outside tolerance.\
104
- Moving gap to nominal value, {gap_to_match_dcm_energy:.3f}mm"
105
- )
106
- if not TEST_MODE:
107
- # Only move if the gap is sufficiently different to the value from the
108
- # DCM lookup table AND we're not in TEST_MODE
109
- await self.undulator.gap_motor.set(
110
- gap_to_match_dcm_energy,
111
- timeout=STATUS_TIMEOUT_S,
112
- )
113
- else:
114
- LOGGER.debug("In test mode, not moving ID gap")
115
- else:
116
- LOGGER.debug(
117
- "Gap is already in the correct place for the new energy value "
118
- f"{energy_kev}, no need to ask it to move"
119
- )
120
-
121
- async def _gap_to_match_dcm_energy(self, energy_kev: float) -> float:
122
- # Get 2d np.array converting energies to undulator gap distance, from lookup table
123
- energy_to_distance_table = await energy_distance_table(
124
- self.id_gap_lookup_table_path
125
- )
126
-
127
- # Use the lookup table to get the undulator gap associated with this dcm energy
128
- return _get_closest_gap_for_energy(
129
- energy_kev * 1000,
130
- energy_to_distance_table,
131
- )
dodal/devices/webcam.py CHANGED
@@ -1,12 +1,24 @@
1
+ from collections.abc import ByteString
2
+ from io import BytesIO
1
3
  from pathlib import Path
2
4
 
3
5
  import aiofiles
4
6
  from aiohttp import ClientSession
5
7
  from bluesky.protocols import Triggerable
6
8
  from ophyd_async.core import AsyncStatus, HintedSignal, StandardReadable, soft_signal_rw
9
+ from PIL import Image
7
10
 
8
11
  from dodal.log import LOGGER
9
12
 
13
+ PLACEHOLDER_IMAGE_SIZE = (1024, 768)
14
+ IMAGE_FORMAT = "png"
15
+
16
+
17
+ def create_placeholder_image() -> ByteString:
18
+ image = Image.new("RGB", PLACEHOLDER_IMAGE_SIZE)
19
+ image.save(buffer := BytesIO(), format=IMAGE_FORMAT)
20
+ return buffer.getbuffer()
21
+
10
22
 
11
23
  class Webcam(StandardReadable, Triggerable):
12
24
  def __init__(self, name, prefix, url):
@@ -18,19 +30,33 @@ class Webcam(StandardReadable, Triggerable):
18
30
  self.add_readables([self.last_saved_path], wrapper=HintedSignal)
19
31
  super().__init__(name=name)
20
32
 
21
- async def _write_image(self, file_path: str):
33
+ async def _write_image(self, file_path: str, image: ByteString):
34
+ async with aiofiles.open(file_path, "wb") as file:
35
+ await file.write(image)
36
+
37
+ async def _get_and_write_image(self, file_path: str):
22
38
  async with ClientSession() as session:
23
39
  async with session.get(self.url) as response:
24
- response.raise_for_status()
25
- LOGGER.info(f"Saving webcam image from {self.url} to {file_path}")
26
- async with aiofiles.open(file_path, "wb") as file:
27
- await file.write(await response.read())
40
+ if not response.ok:
41
+ LOGGER.warning(
42
+ f"Webcam responded with {response.status}: {response.reason}. Attempting to read anyway."
43
+ )
44
+ try:
45
+ data = await response.read()
46
+ LOGGER.info(f"Saving webcam image from {self.url} to {file_path}")
47
+ except Exception as e:
48
+ LOGGER.warning(
49
+ f"Failed to read data from {self.url} ({e}). Using placeholder image."
50
+ )
51
+ data = create_placeholder_image()
52
+
53
+ await self._write_image(file_path, data)
28
54
 
29
55
  @AsyncStatus.wrap
30
56
  async def trigger(self) -> None:
31
57
  filename = await self.filename.get_value()
32
58
  directory = await self.directory.get_value()
33
59
 
34
- file_path = Path(f"{directory}/{filename}.png").as_posix()
35
- await self._write_image(file_path)
60
+ file_path = Path(f"{directory}/{filename}.{IMAGE_FORMAT}").as_posix()
61
+ await self._get_and_write_image(file_path)
36
62
  await self.last_saved_path.set(file_path)
@@ -39,7 +39,12 @@ class ZocaloStartInfo:
39
39
 
40
40
 
41
41
  def _get_zocalo_headers() -> tuple[str, str]:
42
- user = os.environ.get("ZOCALO_GO_USER", getpass.getuser())
42
+ user = os.environ.get("ZOCALO_GO_USER")
43
+
44
+ # cannot default as getuser() will throw when called from inside a container
45
+ if not user:
46
+ user = getpass.getuser()
47
+
43
48
  hostname = os.environ.get("ZOCALO_GO_HOSTNAME", socket.gethostname())
44
49
  return user, hostname
45
50