dls-dodal 1.29.4__py3-none-any.whl → 1.31.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 (103) hide show
  1. {dls_dodal-1.29.4.dist-info → dls_dodal-1.31.0.dist-info}/METADATA +29 -44
  2. dls_dodal-1.31.0.dist-info/RECORD +134 -0
  3. {dls_dodal-1.29.4.dist-info → dls_dodal-1.31.0.dist-info}/WHEEL +1 -1
  4. dls_dodal-1.31.0.dist-info/entry_points.txt +3 -0
  5. dodal/__init__.py +1 -4
  6. dodal/_version.py +2 -2
  7. dodal/beamline_specific_utils/i03.py +1 -4
  8. dodal/beamlines/__init__.py +7 -1
  9. dodal/beamlines/i03.py +34 -29
  10. dodal/beamlines/i04.py +39 -16
  11. dodal/beamlines/i13_1.py +66 -0
  12. dodal/beamlines/i22.py +22 -22
  13. dodal/beamlines/i24.py +1 -1
  14. dodal/beamlines/p38.py +21 -21
  15. dodal/beamlines/p45.py +18 -16
  16. dodal/beamlines/p99.py +61 -0
  17. dodal/beamlines/training_rig.py +64 -0
  18. dodal/cli.py +6 -3
  19. dodal/common/beamlines/beamline_parameters.py +7 -6
  20. dodal/common/beamlines/beamline_utils.py +15 -14
  21. dodal/common/maths.py +1 -3
  22. dodal/common/types.py +6 -5
  23. dodal/common/udc_directory_provider.py +39 -21
  24. dodal/common/visit.py +60 -62
  25. dodal/devices/CTAB.py +22 -17
  26. dodal/devices/aperture.py +1 -1
  27. dodal/devices/aperturescatterguard.py +139 -209
  28. dodal/devices/areadetector/adaravis.py +8 -6
  29. dodal/devices/areadetector/adsim.py +2 -3
  30. dodal/devices/areadetector/adutils.py +20 -12
  31. dodal/devices/areadetector/plugins/MJPG.py +2 -1
  32. dodal/devices/backlight.py +12 -1
  33. dodal/devices/cryostream.py +19 -7
  34. dodal/devices/dcm.py +1 -1
  35. dodal/devices/detector/__init__.py +13 -2
  36. dodal/devices/detector/det_dim_constants.py +2 -2
  37. dodal/devices/detector/det_dist_to_beam_converter.py +1 -1
  38. dodal/devices/detector/detector.py +33 -32
  39. dodal/devices/detector/detector_motion.py +38 -31
  40. dodal/devices/eiger.py +11 -15
  41. dodal/devices/eiger_odin.py +9 -10
  42. dodal/devices/fast_grid_scan.py +18 -27
  43. dodal/devices/fluorescence_detector_motion.py +13 -4
  44. dodal/devices/focusing_mirror.py +6 -6
  45. dodal/devices/hutch_shutter.py +4 -4
  46. dodal/devices/i22/dcm.py +5 -4
  47. dodal/devices/i22/fswitch.py +10 -6
  48. dodal/devices/i22/nxsas.py +55 -43
  49. dodal/devices/i24/aperture.py +1 -1
  50. dodal/devices/i24/beamstop.py +1 -1
  51. dodal/devices/i24/dcm.py +1 -1
  52. dodal/devices/i24/{I24_detector_motion.py → i24_detector_motion.py} +1 -1
  53. dodal/devices/i24/pmac.py +67 -12
  54. dodal/devices/ipin.py +7 -4
  55. dodal/devices/linkam3.py +12 -6
  56. dodal/devices/logging_ophyd_device.py +1 -1
  57. dodal/devices/motors.py +32 -6
  58. dodal/devices/oav/grid_overlay.py +1 -0
  59. dodal/devices/oav/microns_for_zoom_levels.json +1 -1
  60. dodal/devices/oav/oav_detector.py +2 -1
  61. dodal/devices/oav/oav_parameters.py +18 -10
  62. dodal/devices/oav/oav_to_redis_forwarder.py +129 -0
  63. dodal/devices/oav/pin_image_recognition/__init__.py +6 -6
  64. dodal/devices/oav/pin_image_recognition/utils.py +5 -6
  65. dodal/devices/oav/utils.py +2 -2
  66. dodal/devices/p99/__init__.py +0 -0
  67. dodal/devices/p99/sample_stage.py +43 -0
  68. dodal/devices/robot.py +31 -20
  69. dodal/devices/scatterguard.py +1 -1
  70. dodal/devices/scintillator.py +8 -5
  71. dodal/devices/slits.py +1 -1
  72. dodal/devices/smargon.py +4 -4
  73. dodal/devices/status.py +2 -31
  74. dodal/devices/tetramm.py +23 -19
  75. dodal/devices/thawer.py +5 -3
  76. dodal/devices/training_rig/__init__.py +0 -0
  77. dodal/devices/training_rig/sample_stage.py +10 -0
  78. dodal/devices/turbo_slit.py +1 -1
  79. dodal/devices/undulator.py +1 -1
  80. dodal/devices/undulator_dcm.py +6 -8
  81. dodal/devices/util/adjuster_plans.py +3 -3
  82. dodal/devices/util/epics_util.py +5 -7
  83. dodal/devices/util/lookup_tables.py +2 -3
  84. dodal/devices/util/save_panda.py +87 -0
  85. dodal/devices/util/test_utils.py +17 -0
  86. dodal/devices/webcam.py +3 -3
  87. dodal/devices/xbpm_feedback.py +1 -25
  88. dodal/devices/xspress3/xspress3.py +1 -1
  89. dodal/devices/zebra.py +15 -10
  90. dodal/devices/zebra_controlled_shutter.py +26 -11
  91. dodal/devices/zocalo/zocalo_interaction.py +10 -2
  92. dodal/devices/zocalo/zocalo_results.py +36 -19
  93. dodal/log.py +46 -15
  94. dodal/plans/check_topup.py +65 -10
  95. dodal/plans/data_session_metadata.py +8 -9
  96. dodal/plans/motor_util_plans.py +117 -0
  97. dodal/utils.py +65 -22
  98. dls_dodal-1.29.4.dist-info/RECORD +0 -125
  99. dls_dodal-1.29.4.dist-info/entry_points.txt +0 -2
  100. dodal/devices/beamstop.py +0 -8
  101. dodal/devices/qbpm1.py +0 -8
  102. {dls_dodal-1.29.4.dist-info → dls_dodal-1.31.0.dist-info}/LICENSE +0 -0
  103. {dls_dodal-1.29.4.dist-info → dls_dodal-1.31.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,87 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from argparse import ArgumentParser
5
+ from pathlib import Path
6
+ from typing import cast
7
+
8
+ from bluesky.run_engine import RunEngine
9
+ from ophyd_async.core import Device, save_device
10
+ from ophyd_async.fastcs.panda import phase_sorter
11
+
12
+ from dodal.beamlines import module_name_for_beamline
13
+ from dodal.utils import make_device
14
+
15
+
16
+ def main(argv: list[str]):
17
+ """CLI Utility to save the panda configuration."""
18
+ parser = ArgumentParser(description="Save an ophyd_async panda to yaml")
19
+ parser.add_argument(
20
+ "--beamline", help="beamline to save from e.g. i03. Defaults to BEAMLINE"
21
+ )
22
+ parser.add_argument(
23
+ "--device-name",
24
+ help='name of the device. The default is "panda"',
25
+ default="panda",
26
+ )
27
+ parser.add_argument(
28
+ "-f",
29
+ "--force",
30
+ action=argparse.BooleanOptionalAction,
31
+ help="Force overwriting an existing file",
32
+ )
33
+ parser.add_argument("output_file", help="output filename")
34
+
35
+ # this exit()s with message/help unless args parsed successfully
36
+ args = parser.parse_args(argv[1:])
37
+
38
+ beamline = args.beamline
39
+ device_name = args.device_name
40
+ output_file = args.output_file
41
+ force = args.force
42
+
43
+ if beamline:
44
+ os.environ["BEAMLINE"] = beamline
45
+ else:
46
+ beamline = os.environ.get("BEAMLINE", None)
47
+
48
+ if not beamline:
49
+ sys.stderr.write("BEAMLINE not set and --beamline not specified\n")
50
+ return 1
51
+
52
+ if Path(output_file).exists() and not force:
53
+ sys.stderr.write(
54
+ f"Output file {output_file} already exists and --force not specified."
55
+ )
56
+ return 1
57
+
58
+ _save_panda(beamline, device_name, output_file)
59
+
60
+ print("Done.")
61
+ return 0
62
+
63
+
64
+ def _save_panda(beamline, device_name, output_file):
65
+ RE = RunEngine()
66
+ print("Creating devices...")
67
+ module_name = module_name_for_beamline(beamline)
68
+ try:
69
+ devices = make_device(f"dodal.beamlines.{module_name}", device_name)
70
+ except Exception as error:
71
+ sys.stderr.write(f"Couldn't create device {device_name}: {error}\n")
72
+ sys.exit(1)
73
+
74
+ panda = devices[device_name]
75
+ print(f"Saving to {output_file} from {device_name} on {beamline}...")
76
+ _save_panda_to_file(RE, cast(Device, panda), output_file)
77
+
78
+
79
+ def _save_panda_to_file(RE: RunEngine, panda: Device, path: str):
80
+ def save_to_file():
81
+ yield from save_device(panda, path, sorter=phase_sorter)
82
+
83
+ RE(save_to_file())
84
+
85
+
86
+ if __name__ == "__main__": # pragma: no cover
87
+ sys.exit(main(sys.argv))
@@ -0,0 +1,17 @@
1
+ from ophyd_async.core import (
2
+ callback_on_mock_put,
3
+ set_mock_value,
4
+ )
5
+ from ophyd_async.epics.motor import Motor
6
+
7
+
8
+ def patch_motor(motor: Motor, initial_position=0):
9
+ set_mock_value(motor.user_setpoint, initial_position)
10
+ set_mock_value(motor.user_readback, initial_position)
11
+ set_mock_value(motor.deadband, 0.001)
12
+ set_mock_value(motor.motor_done_move, 1)
13
+ set_mock_value(motor.velocity, 3)
14
+ return callback_on_mock_put(
15
+ motor.user_setpoint,
16
+ lambda pos, *args, **kwargs: set_mock_value(motor.user_readback, pos),
17
+ )
dodal/devices/webcam.py CHANGED
@@ -3,7 +3,7 @@ from pathlib import Path
3
3
  import aiofiles
4
4
  from aiohttp import ClientSession
5
5
  from bluesky.protocols import Triggerable
6
- from ophyd_async.core import AsyncStatus, StandardReadable, soft_signal_rw
6
+ from ophyd_async.core import AsyncStatus, HintedSignal, StandardReadable, soft_signal_rw
7
7
 
8
8
  from dodal.log import LOGGER
9
9
 
@@ -15,7 +15,7 @@ class Webcam(StandardReadable, Triggerable):
15
15
  self.directory = soft_signal_rw(str, name="directory")
16
16
  self.last_saved_path = soft_signal_rw(str, name="last_saved_path")
17
17
 
18
- self.set_readable_signals([self.last_saved_path])
18
+ self.add_readables([self.last_saved_path], wrapper=HintedSignal)
19
19
  super().__init__(name=name)
20
20
 
21
21
  async def _write_image(self, file_path: str):
@@ -24,7 +24,7 @@ class Webcam(StandardReadable, Triggerable):
24
24
  response.raise_for_status()
25
25
  LOGGER.info(f"Saving webcam image from {self.url} to {file_path}")
26
26
  async with aiofiles.open(file_path, "wb") as file:
27
- await file.write((await response.read()))
27
+ await file.write(await response.read())
28
28
 
29
29
  @AsyncStatus.wrap
30
30
  async def trigger(self) -> None:
@@ -1,11 +1,7 @@
1
1
  from enum import Enum
2
2
 
3
- import ophyd
4
3
  from bluesky.protocols import Triggerable
5
- from ophyd import Component, EpicsSignal, EpicsSignalRO
6
- from ophyd.status import StatusBase, SubscriptionStatus
7
- from ophyd_async.core import Device, observe_value
8
- from ophyd_async.core.async_status import AsyncStatus
4
+ from ophyd_async.core import AsyncStatus, Device, observe_value
9
5
  from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw
10
6
 
11
7
 
@@ -31,23 +27,3 @@ class XBPMFeedback(Device, Triggerable):
31
27
  async for value in observe_value(self.pos_stable):
32
28
  if value:
33
29
  return
34
-
35
-
36
- class XBPMFeedbackI04(ophyd.Device):
37
- """The I04 version of this device has a slightly different trigger method"""
38
-
39
- # Values to set to pause_feedback
40
- PAUSE = 0
41
- RUN = 1
42
-
43
- pos_ok = Component(EpicsSignalRO, "-EA-FDBK-01:XBPM2POSITION_OK")
44
- pause_feedback = Component(EpicsSignal, "-EA-FDBK-01:FB_PAUSE")
45
- x = Component(EpicsSignalRO, "-EA-XBPM-02:PosX:MeanValue_RBV")
46
- y = Component(EpicsSignalRO, "-EA-XBPM-02:PosY:MeanValue_RBV")
47
-
48
- def trigger(self) -> StatusBase:
49
- return SubscriptionStatus(
50
- self.pos_ok,
51
- lambda *, old_value, value, **kwargs: value == 1,
52
- timeout=60,
53
- )
@@ -9,7 +9,7 @@ from ophyd_async.core import (
9
9
  DeviceVector,
10
10
  wait_for_value,
11
11
  )
12
- from ophyd_async.epics.signal.signal import (
12
+ from ophyd_async.epics.signal import (
13
13
  epics_signal_r,
14
14
  epics_signal_rw,
15
15
  epics_signal_rw_rbv,
dodal/devices/zebra.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  from enum import Enum
5
5
  from functools import partialmethod
6
- from typing import List
7
6
 
8
7
  from ophyd_async.core import (
9
8
  AsyncStatus,
@@ -38,6 +37,11 @@ TTL_SHUTTER = 2
38
37
  TTL_XSPRESS3 = 3
39
38
  TTL_PANDA = 4
40
39
 
40
+ # The AND gate that controls the automatic shutter
41
+ AUTO_SHUTTER_GATE = 2
42
+ # The input that triggers the automatic shutter
43
+ AUTO_SHUTTER_INPUT = 1
44
+
41
45
 
42
46
  class ArmSource(str, Enum):
43
47
  SOFT = "Soft"
@@ -95,7 +99,7 @@ class ArmingDevice(StandardReadable):
95
99
  """A useful device that can abstract some of the logic of arming.
96
100
  Allows a user to just call arm.set(ArmDemand.ARM)"""
97
101
 
98
- TIMEOUT = 3
102
+ TIMEOUT: float = 3
99
103
 
100
104
  def __init__(self, prefix: str, name: str = "") -> None:
101
105
  self.arm_set = epics_signal_rw(float, prefix + "PC_ARM")
@@ -110,10 +114,9 @@ class ArmingDevice(StandardReadable):
110
114
  if reading == demand.value:
111
115
  return
112
116
 
113
- def set(self, demand: ArmDemand) -> AsyncStatus:
114
- return AsyncStatus(
115
- asyncio.wait_for(self._set_armed(demand), timeout=self.TIMEOUT)
116
- )
117
+ @AsyncStatus.wrap
118
+ async def set(self, demand: ArmDemand):
119
+ await asyncio.wait_for(self._set_armed(demand), timeout=self.TIMEOUT)
117
120
 
118
121
 
119
122
  class PositionCompare(StandardReadable):
@@ -166,7 +169,7 @@ class ZebraOutputPanel(StandardReadable):
166
169
  super().__init__(name)
167
170
 
168
171
 
169
- def boolean_array_to_integer(values: List[bool]) -> int:
172
+ def boolean_array_to_integer(values: list[bool]) -> int:
170
173
  """Converts a boolean array to integer by interpretting it in binary with LSB 0 bit
171
174
  numbering.
172
175
 
@@ -245,8 +248,8 @@ class LogicGateConfiguration:
245
248
  NUMBER_OF_INPUTS = 4
246
249
 
247
250
  def __init__(self, input_source: int, invert: bool = False) -> None:
248
- self.sources: List[int] = []
249
- self.invert: List[bool] = []
251
+ self.sources: list[int] = []
252
+ self.invert: list[bool] = []
250
253
  self.add_input(input_source, invert)
251
254
 
252
255
  def add_input(
@@ -271,7 +274,9 @@ class LogicGateConfiguration:
271
274
 
272
275
  def __str__(self) -> str:
273
276
  input_strings = []
274
- for input, (source, invert) in enumerate(zip(self.sources, self.invert)):
277
+ for input, (source, invert) in enumerate(
278
+ zip(self.sources, self.invert, strict=False)
279
+ ):
275
280
  input_strings.append(f"INP{input+1}={'!' if invert else ''}{source}")
276
281
 
277
282
  return ", ".join(input_strings)
@@ -7,7 +7,7 @@ from ophyd_async.core import (
7
7
  StandardReadable,
8
8
  wait_for_value,
9
9
  )
10
- from ophyd_async.epics.signal import epics_signal_r, epics_signal_w
10
+ from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw, epics_signal_w
11
11
 
12
12
 
13
13
  class ZebraShutterState(str, Enum):
@@ -15,24 +15,39 @@ class ZebraShutterState(str, Enum):
15
15
  OPEN = "Open"
16
16
 
17
17
 
18
+ class ZebraShutterControl(str, Enum):
19
+ MANUAL = "Manual"
20
+ AUTO = "Auto"
21
+
22
+
18
23
  class ZebraShutter(StandardReadable, Movable):
24
+ """The shutter on most MX beamlines is controlled by the zebra.
25
+
26
+ Internally in the zebra there are two AND gates, one for manual control and one for
27
+ automatic control. A soft input (aliased to control_mode) will switch between
28
+ which of these AND gates to use. For the manual gate the shutter is then controlled
29
+ by a different soft input (aliased to _manual_position_setpoint). Both these AND
30
+ gates then feed into an OR gate, which then feeds to the shutter."""
31
+
19
32
  def __init__(self, prefix: str, name: str):
20
- self.position_setpoint = epics_signal_w(
21
- write_pv=prefix + "CTRL2",
22
- datatype=ZebraShutterState,
33
+ self._manual_position_setpoint = epics_signal_w(
34
+ ZebraShutterState, prefix + "CTRL2"
23
35
  )
36
+ self.control_mode = epics_signal_rw(ZebraShutterControl, prefix + "CTRL1")
37
+
24
38
  with self.add_children_as_readables():
25
- self.position_readback = epics_signal_r(
26
- read_pv=prefix + "STA",
27
- datatype=ZebraShutterState,
28
- )
39
+ self.position_readback = epics_signal_r(ZebraShutterState, prefix + "STA")
29
40
  super().__init__(name=name)
30
41
 
31
42
  @AsyncStatus.wrap
32
- async def set(self, desired_position: ZebraShutterState):
33
- await self.position_setpoint.set(desired_position)
43
+ async def set(self, value: ZebraShutterState):
44
+ if await self.control_mode.get_value() == ZebraShutterControl.AUTO:
45
+ raise UserWarning(
46
+ f"Tried to set shutter to {value.value} but the shutter is in auto mode."
47
+ )
48
+ await self._manual_position_setpoint.set(value)
34
49
  return await wait_for_value(
35
50
  signal=self.position_readback,
36
- match=desired_position,
51
+ match=value,
37
52
  timeout=DEFAULT_TIMEOUT,
38
53
  )
@@ -1,5 +1,6 @@
1
1
  import dataclasses
2
2
  import getpass
3
+ import os
3
4
  import socket
4
5
  from dataclasses import dataclass
5
6
 
@@ -37,6 +38,12 @@ class ZocaloStartInfo:
37
38
  message_index: int
38
39
 
39
40
 
41
+ def _get_zocalo_headers() -> tuple[str, str]:
42
+ user = os.environ.get("ZOCALO_GO_USER", getpass.getuser())
43
+ hostname = os.environ.get("ZOCALO_GO_HOSTNAME", socket.gethostname())
44
+ return user, hostname
45
+
46
+
40
47
  class ZocaloTrigger:
41
48
  """This class just sends 'run_start' and 'run_end' messages to zocalo, it is
42
49
  intended to be used in bluesky callback classes. To get results from zocalo back
@@ -55,9 +62,10 @@ class ZocaloTrigger:
55
62
  "recipes": ["mimas"],
56
63
  "parameters": parameters,
57
64
  }
65
+ user, hostname = _get_zocalo_headers()
58
66
  header = {
59
- "zocalo.go.user": getpass.getuser(),
60
- "zocalo.go.host": socket.gethostname(),
67
+ "zocalo.go.user": user,
68
+ "zocalo.go.host": hostname,
61
69
  }
62
70
  transport.send("processing_recipe", message, headers=header)
63
71
  finally:
@@ -1,8 +1,9 @@
1
1
  import asyncio
2
2
  from collections import OrderedDict
3
+ from collections.abc import Generator, Sequence
3
4
  from enum import Enum
4
5
  from queue import Empty, Queue
5
- from typing import Any, Generator, Sequence, Tuple, TypedDict
6
+ from typing import Any, TypedDict
6
7
 
7
8
  import bluesky.plan_stubs as bps
8
9
  import numpy as np
@@ -10,8 +11,12 @@ import workflows.recipe
10
11
  import workflows.transport
11
12
  from bluesky.protocols import Descriptor, Triggerable
12
13
  from numpy.typing import NDArray
13
- from ophyd_async.core import StandardReadable, soft_signal_r_and_setter
14
- from ophyd_async.core.async_status import AsyncStatus
14
+ from ophyd_async.core import (
15
+ AsyncStatus,
16
+ HintedSignal,
17
+ StandardReadable,
18
+ soft_signal_r_and_setter,
19
+ )
15
20
  from workflows.transport.common_transport import CommonTransport
16
21
 
17
22
  from dodal.devices.zocalo.zocalo_interaction import _get_zocalo_connection
@@ -79,34 +84,41 @@ class ZocaloResults(StandardReadable, Triggerable):
79
84
  self._raw_results_received: Queue = Queue()
80
85
  self.transport: CommonTransport | None = None
81
86
 
82
- self.results, _ = soft_signal_r_and_setter(list[XrcResult], name="results")
83
- self.centres_of_mass, _ = soft_signal_r_and_setter(
87
+ self.results, self._results_setter = soft_signal_r_and_setter(
88
+ list[XrcResult], name="results"
89
+ )
90
+ self.centres_of_mass, self._com_setter = soft_signal_r_and_setter(
84
91
  NDArray[np.uint64], name="centres_of_mass"
85
92
  )
86
- self.bbox_sizes, _ = soft_signal_r_and_setter(
93
+ self.bbox_sizes, self._bbox_setter = soft_signal_r_and_setter(
87
94
  NDArray[np.uint64], "bbox_sizes", self.name
88
95
  )
89
- self.ispyb_dcid, _ = soft_signal_r_and_setter(int, name="ispyb_dcid")
90
- self.ispyb_dcgid, _ = soft_signal_r_and_setter(int, name="ispyb_dcgid")
91
- self.set_readable_signals(
92
- read=[
96
+ self.ispyb_dcid, self._ispyb_dcid_setter = soft_signal_r_and_setter(
97
+ int, name="ispyb_dcid"
98
+ )
99
+ self.ispyb_dcgid, self._ispyb_dcgid_setter = soft_signal_r_and_setter(
100
+ int, name="ispyb_dcgid"
101
+ )
102
+ self.add_readables(
103
+ [
93
104
  self.results,
94
105
  self.centres_of_mass,
95
106
  self.bbox_sizes,
96
107
  self.ispyb_dcid,
97
108
  self.ispyb_dcgid,
98
- ]
109
+ ],
110
+ wrapper=HintedSignal,
99
111
  )
100
112
  super().__init__(name)
101
113
 
102
114
  async def _put_results(self, results: Sequence[XrcResult], ispyb_ids):
103
- await self.results._backend.put(list(results))
115
+ self._results_setter(list(results))
104
116
  centres_of_mass = np.array([r["centre_of_mass"] for r in results])
105
117
  bbox_sizes = np.array([bbox_size(r) for r in results])
106
- await self.centres_of_mass._backend.put(centres_of_mass)
107
- await self.bbox_sizes._backend.put(bbox_sizes)
108
- await self.ispyb_dcid._backend.put(ispyb_ids["dcid"])
109
- await self.ispyb_dcgid._backend.put(ispyb_ids["dcgid"])
118
+ self._com_setter(centres_of_mass)
119
+ self._bbox_setter(bbox_sizes)
120
+ self._ispyb_dcid_setter(ispyb_ids["dcid"])
121
+ self._ispyb_dcgid_setter(ispyb_ids["dcgid"])
110
122
 
111
123
  def _clear_old_results(self):
112
124
  LOGGER.info("Clearing queue")
@@ -120,7 +132,12 @@ class ZocaloResults(StandardReadable, Triggerable):
120
132
  before triggering processing for the experiment"""
121
133
 
122
134
  LOGGER.info("Subscribing to results queue")
123
- self._subscribe_to_results()
135
+ try:
136
+ self._subscribe_to_results()
137
+ except Exception as e:
138
+ print(f"GOT {e}")
139
+ raise
140
+
124
141
  await asyncio.sleep(CLEAR_QUEUE_WAIT_S)
125
142
  self._clear_old_results()
126
143
 
@@ -152,7 +169,7 @@ class ZocaloResults(StandardReadable, Triggerable):
152
169
  )
153
170
 
154
171
  raw_results = self._raw_results_received.get(timeout=self.timeout_s)
155
- LOGGER.info(f"Zocalo: found {len(raw_results)} crystals.")
172
+ LOGGER.info(f"Zocalo: found {len(raw_results['results'])} crystals.")
156
173
  # Sort from strongest to weakest in case of multiple crystals
157
174
  await self._put_results(
158
175
  sorted(
@@ -242,7 +259,7 @@ class ZocaloResults(StandardReadable, Triggerable):
242
259
 
243
260
  def get_processing_result(
244
261
  zocalo: ZocaloResults,
245
- ) -> Generator[Any, Any, Tuple[np.ndarray, np.ndarray] | Tuple[None, None]]:
262
+ ) -> Generator[Any, Any, tuple[np.ndarray, np.ndarray] | tuple[None, None]]:
246
263
  """A minimal plan which will extract the top ranked xray centre and crystal bounding
247
264
  box size from the zocalo results. Returns (None, None) if no crystals were found."""
248
265
 
dodal/log.py CHANGED
@@ -6,30 +6,52 @@ from logging import Logger, StreamHandler
6
6
  from logging.handlers import TimedRotatingFileHandler
7
7
  from os import environ
8
8
  from pathlib import Path
9
- from typing import Deque, Tuple, TypedDict
9
+ from typing import TypedDict
10
10
 
11
+ import colorlog
11
12
  from bluesky.log import logger as bluesky_logger
12
13
  from graypy import GELFTCPHandler
13
14
  from ophyd.log import logger as ophyd_logger
14
- from ophyd_async.log import (
15
- DEFAULT_DATE_FORMAT,
16
- DEFAULT_FORMAT,
17
- DEFAULT_LOG_COLORS,
18
- ColoredFormatterWithDeviceName,
19
- )
20
- from ophyd_async.log import logger as ophyd_async_logger
21
15
 
22
16
  LOGGER = logging.getLogger("Dodal")
17
+ # Temporarily duplicated https://github.com/bluesky/ophyd-async/issues/550
18
+ ophyd_async_logger = logging.getLogger("ophyd_async")
23
19
  LOGGER.setLevel(logging.DEBUG)
24
20
 
25
- DEFAULT_FORMATTER = ColoredFormatterWithDeviceName(
26
- fmt=DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT, log_colors=DEFAULT_LOG_COLORS
27
- )
28
21
  ERROR_LOG_BUFFER_LINES = 20000
29
22
  INFO_LOG_DAYS = 30
30
23
  DEBUG_LOG_FILES_TO_KEEP = 7
31
24
  DEFAULT_GRAYLOG_PORT = 12231
32
25
 
26
+ # Temporarily duplicated https://github.com/bluesky/ophyd-async/issues/550
27
+ DEFAULT_FORMAT = (
28
+ "%(log_color)s[%(levelname)1.1s %(asctime)s.%(msecs)03d "
29
+ "%(module)s:%(lineno)d] %(message)s"
30
+ )
31
+
32
+ DEFAULT_DATE_FORMAT = "%y%m%d %H:%M:%S"
33
+
34
+ DEFAULT_LOG_COLORS = {
35
+ "DEBUG": "cyan",
36
+ "INFO": "green",
37
+ "WARNING": "yellow",
38
+ "ERROR": "red",
39
+ "CRITICAL": "red,bg_white",
40
+ }
41
+
42
+
43
+ class ColoredFormatterWithDeviceName(colorlog.ColoredFormatter):
44
+ def format(self, record):
45
+ message = super().format(record)
46
+ if device_name := getattr(record, "ophyd_async_device_name", None):
47
+ message = f"[{device_name}]{message}"
48
+ return message
49
+
50
+
51
+ DEFAULT_FORMATTER = ColoredFormatterWithDeviceName(
52
+ fmt=DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT, log_colors=DEFAULT_LOG_COLORS
53
+ )
54
+
33
55
 
34
56
  class CircularMemoryHandler(logging.Handler):
35
57
  """Loosely based on the MemoryHandler, which keeps a buffer and writes it when full
@@ -37,11 +59,14 @@ class CircularMemoryHandler(logging.Handler):
37
59
  that always contains the last {capacity} number of messages, this is only flushed
38
60
  when a log of specific {flushLevel} comes in. On flush this buffer is then passed to
39
61
  the {target} handler.
62
+
63
+ The CircularMemoryHandler becomes the owner of the target handler which will be closed
64
+ on close of this handler.
40
65
  """
41
66
 
42
67
  def __init__(self, capacity, flushLevel=logging.ERROR, target=None):
43
68
  logging.Handler.__init__(self)
44
- self.buffer: Deque[logging.LogRecord] = deque(maxlen=capacity)
69
+ self.buffer: deque[logging.LogRecord] = deque(maxlen=capacity)
45
70
  self.flushLevel = flushLevel
46
71
  self.target = target
47
72
 
@@ -66,6 +91,12 @@ class CircularMemoryHandler(logging.Handler):
66
91
  self.acquire()
67
92
  try:
68
93
  self.buffer.clear()
94
+ if self.target:
95
+ self.target.acquire()
96
+ try:
97
+ self.target.close()
98
+ finally:
99
+ self.target.release()
69
100
  self.target = None
70
101
  logging.Handler.close(self)
71
102
  finally:
@@ -121,7 +152,7 @@ def set_up_graylog_handler(logger: Logger, host: str, port: int):
121
152
  def set_up_INFO_file_handler(logger, path: Path, filename: str):
122
153
  """Set up a file handler for the logger, at INFO level, which will keep 30 days
123
154
  of logs, rotating once per day. Creates the directory if necessary."""
124
- print(f"Logging to {path/filename}")
155
+ print(f"Logging to INFO file handler {path/filename}")
125
156
  path.mkdir(parents=True, exist_ok=True)
126
157
  file_handler = TimedRotatingFileHandler(
127
158
  filename=path / filename, when="MIDNIGHT", backupCount=INFO_LOG_DAYS
@@ -137,8 +168,8 @@ def set_up_DEBUG_memory_handler(
137
168
  """Set up a Memory handler which holds 200k lines, and writes them to an hourly
138
169
  log file when it sees a message of severity ERROR. Creates the directory if
139
170
  necessary"""
140
- print(f"Logging to {path/filename}")
141
171
  debug_path = path / "debug"
172
+ print(f"Logging to DEBUG handler {debug_path/filename}")
142
173
  debug_path.mkdir(parents=True, exist_ok=True)
143
174
  file_handler = TimedRotatingFileHandler(
144
175
  filename=debug_path / filename, when="H", backupCount=DEBUG_LOG_FILES_TO_KEEP
@@ -240,7 +271,7 @@ def get_logging_file_path() -> Path:
240
271
 
241
272
  def get_graylog_configuration(
242
273
  dev_mode: bool, graylog_port: int | None = None
243
- ) -> Tuple[str, int]:
274
+ ) -> tuple[str, int]:
244
275
  """Get the host and port for the graylog handler.
245
276
 
246
277
  If running in dev mode, this switches to localhost. Otherwise it publishes to the
@@ -1,5 +1,10 @@
1
+ from typing import Any
2
+
1
3
  import bluesky.plan_stubs as bps
2
4
 
5
+ from dodal.common.beamlines.beamline_parameters import (
6
+ get_beamline_parameters,
7
+ )
3
8
  from dodal.devices.synchrotron import Synchrotron, SynchrotronMode
4
9
  from dodal.log import LOGGER
5
10
 
@@ -7,6 +12,20 @@ ALLOWED_MODES = [SynchrotronMode.USER, SynchrotronMode.SPECIAL]
7
12
  DECAY_MODE_COUNTDOWN = -1 # Value of the start_countdown PV when in decay mode
8
13
  COUNTDOWN_DURING_TOPUP = 0
9
14
 
15
+ DEFAULT_THRESHOLD_EXPOSURE_S = 120
16
+ DEFAULT_TOPUP_GATE_DELAY_S = 1
17
+
18
+
19
+ class TopupConfig:
20
+ # For planned exposures less than this value, wait for topup to finish instead of
21
+ # collecting throughout topup.
22
+ THRESHOLD_EXPOSURE_S = "dodal_topup_threshold_exposure_s"
23
+ # Additional configurable safety margin to wait after the end of topup, as the start
24
+ # and end countdowns do not have the same precision, and in addition we want to be sure
25
+ # that collection does not overlap with any transients that may occur after the
26
+ # nominal endpoint.
27
+ TOPUP_GATE_DELAY_S = "dodal_topup_end_delay_s"
28
+
10
29
 
11
30
  def _in_decay_mode(time_to_topup):
12
31
  if time_to_topup == DECAY_MODE_COUNTDOWN:
@@ -23,15 +42,38 @@ def _gating_permitted(machine_mode: SynchrotronMode):
23
42
  return False
24
43
 
25
44
 
26
- def _delay_to_avoid_topup(total_run_time, time_to_topup):
27
- if total_run_time > time_to_topup:
28
- LOGGER.info(
29
- """
30
- Total run time for this collection exceeds time to next top up.
31
- Collection delayed until top up done.
32
- """
45
+ def _delay_to_avoid_topup(
46
+ total_run_time_s: float,
47
+ time_to_topup_s: float,
48
+ topup_configuration: dict,
49
+ total_exposure_time_s: float,
50
+ ) -> bool:
51
+ """Determine whether we should delay collection until after a topup. Generally
52
+ if a topup is due to occur during the collection we will delay collection until after the topup.
53
+ However for long-running collections, impact of the topup is potentially less and collection-duration may be
54
+ a significant fraction of the topup-interval, therefore we may wish to collect during a topup.
55
+
56
+ Args:
57
+ total_run_time_s: Anticipated time until end of the collection in seconds
58
+ time_to_topup_s: Time to the start of the topup as measured from the PV
59
+ topup_configuration: configuration dictionary
60
+ total_exposure_time_s: Total exposure time of the sample in s"""
61
+ if total_run_time_s > time_to_topup_s:
62
+ limit_s = topup_configuration.get(
63
+ TopupConfig.THRESHOLD_EXPOSURE_S, DEFAULT_THRESHOLD_EXPOSURE_S
33
64
  )
34
- return True
65
+ gate = total_exposure_time_s < limit_s
66
+ if gate:
67
+ LOGGER.info(f"""
68
+ Exposure time of {total_exposure_time_s}s below the threshold of {limit_s}s.
69
+ Collection delayed until topup done.
70
+ """)
71
+ else:
72
+ LOGGER.info(f"""
73
+ Exposure time of {total_exposure_time_s}s meets the threshold of {limit_s}s.
74
+ Collection proceeding through topup.
75
+ """)
76
+ return gate
35
77
  LOGGER.info(
36
78
  """
37
79
  Total run time less than time to next topup. Proceeding with collection.
@@ -71,12 +113,25 @@ def check_topup_and_wait_if_necessary(
71
113
  return
72
114
  tot_run_time = total_exposure_time + ops_time
73
115
  end_topup = yield from bps.rd(synchrotron.top_up_end_countdown)
74
- time_to_wait = (
75
- end_topup if _delay_to_avoid_topup(tot_run_time, time_to_topup) else 0.0
116
+ topup_configuration = _load_topup_configuration_from_properties_file()
117
+ should_wait = _delay_to_avoid_topup(
118
+ tot_run_time,
119
+ time_to_topup,
120
+ topup_configuration,
121
+ total_exposure_time,
122
+ )
123
+ topup_gate_delay = topup_configuration.get(
124
+ TopupConfig.TOPUP_GATE_DELAY_S, DEFAULT_TOPUP_GATE_DELAY_S
76
125
  )
126
+ time_to_wait = end_topup + topup_gate_delay if should_wait else 0.0
77
127
 
78
128
  yield from bps.sleep(time_to_wait)
79
129
 
80
130
  check_start = yield from bps.rd(synchrotron.top_up_start_countdown)
81
131
  if check_start == COUNTDOWN_DURING_TOPUP:
82
132
  yield from wait_for_topup_complete(synchrotron)
133
+
134
+
135
+ def _load_topup_configuration_from_properties_file() -> dict[str, Any]:
136
+ params = get_beamline_parameters()
137
+ return params.params