dls-dodal 1.31.1__py3-none-any.whl → 1.32.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dls-dodal
3
- Version: 1.31.1
3
+ Version: 1.32.0
4
4
  Summary: Ophyd devices and other utils that could be used across DLS beamlines
5
5
  Author-email: Dominic Oram <dominic.oram@diamond.ac.uk>
6
6
  License: Apache License
@@ -216,7 +216,7 @@ Description-Content-Type: text/markdown
216
216
  License-File: LICENSE
217
217
  Requires-Dist: click
218
218
  Requires-Dist: ophyd
219
- Requires-Dist: ophyd-async >=0.5.2
219
+ Requires-Dist: ophyd-async <0.7,>=0.6
220
220
  Requires-Dist: bluesky
221
221
  Requires-Dist: pyepics
222
222
  Requires-Dist: dataclasses-json
@@ -232,6 +232,7 @@ Requires-Dist: numpy <2.0
232
232
  Requires-Dist: aiofiles
233
233
  Requires-Dist: aiohttp
234
234
  Requires-Dist: redis
235
+ Requires-Dist: deepdiff
235
236
  Provides-Extra: dev
236
237
  Requires-Dist: black ; extra == 'dev'
237
238
  Requires-Dist: diff-cover ; extra == 'dev'
@@ -1,20 +1,20 @@
1
1
  dodal/__init__.py,sha256=v-rRiDOgZ3sQSMQKq0vgUQZvpeOkoHFXissAx6Ktg84,61
2
2
  dodal/__main__.py,sha256=kP2S2RPitnOWpNGokjZ1Yq-1umOtp5sNOZk2B3tBPLM,111
3
- dodal/_version.py,sha256=PiWs-LuU4Z2lwlP2cmR1JpAL95YAAdwL2Jy98tsKfJ0,413
3
+ dodal/_version.py,sha256=DJRUo3ZQOOrgoMbrpGNhypFaRgQ4TvlEoMxoldzMF6Y,413
4
4
  dodal/adsim.py,sha256=OW2dcS7ciD4Yq9WFw4PN_c5Bwccrmu7R-zr-u6ZCbQM,497
5
5
  dodal/cli.py,sha256=_crmaHchxphSW8eEJB58_XZIeK82aiUv9bV7tpz-LpA,2122
6
6
  dodal/log.py,sha256=0to7CRsbzbgVfAAfKRAMhsaUuKqF2-7CGdQc-z8Uhno,9499
7
- dodal/utils.py,sha256=VV-IQHehAdE95wxq3C4kIl5Dt5MTtmgzVaqKm-k3Q4I,11769
7
+ dodal/utils.py,sha256=zlHPQjJOYeEvdC-UHPRvuTZaLt4hG6o9x2Vm4eHFBDU,11851
8
8
  dodal/beamline_specific_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  dodal/beamline_specific_utils/i03.py,sha256=eM6ZWZzpL0JYNBff8LhOnwFoZTJ5PDCY2XWI7ZKdtFY,276
10
10
  dodal/beamlines/README.md,sha256=K9MkL_GomxlsoTB7Mz-_dJA5NNSbmCfMiutchGg3C8o,404
11
11
  dodal/beamlines/__init__.py,sha256=CD0Dz2H1adLsqY4H3-_QxTdODPZD6mquMfsep5W5s0Q,3076
12
- dodal/beamlines/i03.py,sha256=VHfGk4-Smz5KviL4OpPVlnYGO4rbDAtnGwKtN20hxK0,17004
13
- dodal/beamlines/i04.py,sha256=JbpOssCBdpDd-TbElc0TfVOZ1tUnDjEFZkj5h1gR6Og,13767
12
+ dodal/beamlines/i03.py,sha256=8Lra4u1kLjWUoboxuSx6Po92OymRaKaJjM9ERr9zeUI,17004
13
+ dodal/beamlines/i04.py,sha256=pRVNIBaUn3oIgbxInCgBUwLqHtPirxf0fKfTfx4RcJ8,13877
14
14
  dodal/beamlines/i04_1.py,sha256=KDxSUQNhIs_NFiRaLY-Jiory0DeN7Y0ErvGuoTrwCDU,4731
15
15
  dodal/beamlines/i13_1.py,sha256=csXHrdwUh4sXTmb4X6ZiiSS_XxRkNShsVoBMxYI6rG0,1833
16
16
  dodal/beamlines/i20_1.py,sha256=MaPgONHqpoZuBtkiKEzYtViJnKBM2_ekeP4OdbmuXHE,1158
17
- dodal/beamlines/i22.py,sha256=ko3bo0WoZCFTSQkrNs9m_qDhvEpGWkSzEAZgpo0sRU0,9804
17
+ dodal/beamlines/i22.py,sha256=YWTz2PjOMTEO7n3QRfrCerIEUMHd6JTHsd2dYe_4F7c,9915
18
18
  dodal/beamlines/i23.py,sha256=2j5qLoqE_hg9ETHqNkOVu7LLkVB8qalgXeORnVYKN_I,1075
19
19
  dodal/beamlines/i24.py,sha256=1XVCNWbzowQB6CWpJGSLp9Ih0zypktIzHxb64h-Xj6Y,6583
20
20
  dodal/beamlines/p38.py,sha256=mCDjRVpY6AIFIAAYCWe-jGABHadFwfJB7poa-dGMU6s,7940
@@ -35,24 +35,24 @@ dodal/devices/CTAB.py,sha256=MoExneblYUHg9Va8vAVx_p_Vw_HnqbhkzxxrX7Ic_wo,2000
35
35
  dodal/devices/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
36
  dodal/devices/adsim.py,sha256=dMU0TKIuiODHYFHQOH4_5UvB8iJtaJEtjqaEDGjcU-w,311
37
37
  dodal/devices/aperture.py,sha256=BLaroQ3n8yd7uZyacJ3KvDWZH8yhA_sJc8b49QMKg9o,585
38
- dodal/devices/aperturescatterguard.py,sha256=id2-h6VB9hMU2dmgkpFkTLOPKiqAktMHsLb7YY0LX-4,8428
38
+ dodal/devices/aperturescatterguard.py,sha256=fOdwQl_qZLaUo1cI1-wz1t2OqncMoUm_uqeZNWyHn3M,8459
39
39
  dodal/devices/attenuator.py,sha256=viK1iccNekX6ZvR_ZmSwj5JdM1j2B8pcTg8qWDdmzhQ,2584
40
40
  dodal/devices/backlight.py,sha256=mOnptopsVOsT8JUIX_siDRgJ73CQPz_bm0Eb7oA81wc,1607
41
41
  dodal/devices/cryostream.py,sha256=CpNA2HGhN_PXkL9eqH_yAPsDxyOLIiehlUxEoNmXJVg,668
42
42
  dodal/devices/dcm.py,sha256=eZNMGjLM56Ll0siU14XomB77W_grLIdxIrMOQNmYFG8,1609
43
43
  dodal/devices/eiger.py,sha256=sR-Fr97Y0lzzq57fFOUTwUZw5E7asoj36A1JR1QUkLI,13985
44
44
  dodal/devices/eiger_odin.py,sha256=tDpEhOUY02YManYAMRI3TwSDDa3uITBxI0JHevaK7Rk,7010
45
- dodal/devices/fast_grid_scan.py,sha256=7u4YvaoR0CsKs9hgawi5p73v0dY_yJwC0_RlFz25R7Y,11922
45
+ dodal/devices/fast_grid_scan.py,sha256=WQGeKR-82fbnY4zUD_MQBsQyJgyIiuRpJK5nn_mfR1E,11969
46
46
  dodal/devices/fluorescence_detector_motion.py,sha256=5IcyaVHXa9TXLFlLB0tfpQ1_ThgIRJNaFNw_uj6ahCA,501
47
47
  dodal/devices/flux.py,sha256=RtPStHw7Mad0igVKntKWVZfuZn2clokVJqH14HLix6M,198
48
- dodal/devices/focusing_mirror.py,sha256=Gx3diH-0VH457MR40HMCovXRjcAALSCO0q2np4l-dL4,5911
48
+ dodal/devices/focusing_mirror.py,sha256=-jq2uqBfDjSgRVrV3-sqswPAND72cagBUQVvzd04diw,5901
49
49
  dodal/devices/hutch_shutter.py,sha256=_-hR3SJHM05YHV_fEtc0VYOLamYnpVGDE56AwJGJS48,3320
50
50
  dodal/devices/ipin.py,sha256=qsf8E3xrJYNDwzsacNLCCp3gaqsadqmN1b-Fvou8y8k,420
51
51
  dodal/devices/linkam3.py,sha256=3oYwPtaKSPvLKEat8m7tuhE4Wizz8mg8HMrEWPCYxn0,3820
52
52
  dodal/devices/logging_ophyd_device.py,sha256=dUVE-XhWA56WUXez0mrc4sf322CXY3MVLreTycO5j_A,668
53
53
  dodal/devices/motors.py,sha256=dYa9T6FDMTbr8GvTb-lXtk3v4QEqAWRuGmHIO20fazQ,1039
54
54
  dodal/devices/p45.py,sha256=jzBW2fGRhIbGzSRs5Fgupxro6aqE611n1RTcrTTG-yY,1047
55
- dodal/devices/robot.py,sha256=VaD2_ogt497myakfkCC1cGfBoz1I6QVdO8AXzFuGis4,4813
55
+ dodal/devices/robot.py,sha256=yzRq-77fVrlhxaIqeORJLGkDHzGgLrHsFYTxmkG-b0w,5343
56
56
  dodal/devices/s4_slit_gaps.py,sha256=j3kgF9WfGFaU9xdUuiAh-QqI5u_vhiAftaDVINt91SM,243
57
57
  dodal/devices/scatterguard.py,sha256=jx03in9QgaThWxD4t1S8_Llent2kWrn_hThJ9KkUWTk,330
58
58
  dodal/devices/scintillator.py,sha256=PlD6cnJ39PTB_e7QrRspPliLYE4kL_K7ziJURzuxgdA,365
@@ -60,12 +60,12 @@ dodal/devices/slits.py,sha256=uOyVmbgeygiP6e5Z9t5zMPXLuVEWFfYg9GB3ZU76Tug,600
60
60
  dodal/devices/smargon.py,sha256=hX-tCftKumxk67eS5-_gQRmYOrjSyQ4s3mMJsTRuvCk,4706
61
61
  dodal/devices/status.py,sha256=hVrJS1yooQo6PRumRACoIEh-SKBUKxvBlQl-MtLFUMQ,327
62
62
  dodal/devices/synchrotron.py,sha256=QtTufJA_fCaBawHougSc7nxwu240oX46_y0P-4qIW8o,1960
63
- dodal/devices/tetramm.py,sha256=ofWhLYFZHCRopDK_WRs1eo-B1ra4bUhwq1MLaVKl4ys,8572
63
+ dodal/devices/tetramm.py,sha256=EgtaExJBZWZC6lWgUEg0RcWQYmSKwRm20KjTZyhCjBk,8439
64
64
  dodal/devices/thawer.py,sha256=Gq-3f__KJUM6_Ds9OVxpZ5jC447HywJxQGXen6L33Lk,1616
65
65
  dodal/devices/turbo_slit.py,sha256=B6SPXqviMnG-U4PnUF1BdTO0LBKmTuwAUKRbxMiNJXo,1125
66
- dodal/devices/undulator.py,sha256=Uf7DpRjhtqkRcNWTqgtrRjoj6tLLgHEPgkxccWU6LE8,2073
67
- dodal/devices/undulator_dcm.py,sha256=qUMGKnRluO2jsfz6TuXx6ddN59MlLg1lBqloeBgYZIg,5134
68
- dodal/devices/webcam.py,sha256=znZHHmQjM0T0XSAYXJraCGlwPPGbt3PF6rWjTSbAikI,1394
66
+ dodal/devices/undulator.py,sha256=udwAodxYM9XgtsQGH2PDBA6ehtel5dAFkjsK13nKp6Q,5160
67
+ dodal/devices/undulator_dcm.py,sha256=5hn3UZeu4CYXmfUVSdIxjrcIpStgeA1S744p0iIFp4I,2725
68
+ dodal/devices/webcam.py,sha256=EqdzUBov5wMCULzzkfnCfD-5TQMZFQLp-2nlDHezmPs,2332
69
69
  dodal/devices/xbpm_feedback.py,sha256=-1wbnahJ_oSljQR0Sjiwn3mytVP-VwsAy0a_YPjPM0Y,1168
70
70
  dodal/devices/zebra.py,sha256=iTHkKv8EP-gkr0Cl2gR9yxt2qTHT2Q4etS67Rshf83k,9327
71
71
  dodal/devices/zebra_controlled_shutter.py,sha256=w2ISASJ_sb3dbQGi63Yuj3ymTkjX73aSl_ZTYs8TyaI,1860
@@ -73,7 +73,7 @@ dodal/devices/areadetector/__init__.py,sha256=8IwLxuZMW0MOJpJp_ZDdlaE20hrtsH_PXW
73
73
  dodal/devices/areadetector/adaravis.py,sha256=Cqw_Mzrp_zODFxQ2LZBJzHp_DsZ6_dAITkZz8gYz_0w,3797
74
74
  dodal/devices/areadetector/adsim.py,sha256=cIc9PRbKnftBk7Ut8d8CU_TVrin8EwcKHObP2n9VxWM,1876
75
75
  dodal/devices/areadetector/adutils.py,sha256=4axFR3wtn-K-sjMVJyfTcu-8g35odf2cY8mTKv1gS-o,3093
76
- dodal/devices/areadetector/plugins/MJPG.py,sha256=MqxDbrgpTzM0ezjcEWuLlK80i4M_KLNfdknHCx7k_LM,4100
76
+ dodal/devices/areadetector/plugins/MJPG.py,sha256=XztHFB1e7qHeZORYxvPgHfDMfkpXGKvfQYmej_hGVVc,4934
77
77
  dodal/devices/detector/__init__.py,sha256=-RdACL3tzc3lLArWOoGNje48UUlv2fElOmGOz9yOuO0,317
78
78
  dodal/devices/detector/det_dim_constants.py,sha256=LNrVMd0DbFEcnyNFmXosCP-VYaZ71Ajuv6inwo4Mg3U,2299
79
79
  dodal/devices/detector/det_dist_to_beam_converter.py,sha256=7keoqZYfvgayePVx97lHYpcFRTJnQOfAk_PYP4EZTZQ,1951
@@ -93,7 +93,7 @@ dodal/devices/i24/dcm.py,sha256=nP2qymTy5TrOu078XOY7h1TEwVfcgli5lTyxmwsG4O8,1990
93
93
  dodal/devices/i24/dual_backlight.py,sha256=Th-RKr28aFxE8LCT_mdN9KkRIVw0BHLGKkI0ienfRZU,2049
94
94
  dodal/devices/i24/i24_detector_motion.py,sha256=_HgdsZqFYY0tKqUgMzViHaPEUFXL3WlXXioGvDehRUw,364
95
95
  dodal/devices/i24/i24_vgonio.py,sha256=Igqs7687z6lyhGVeJEDtDmPachYxU48MUH2BF0RpK9Q,461
96
- dodal/devices/i24/pmac.py,sha256=suWoOIQKhCeQvgBJ8_htRAG6k0REN32viElwvs2nkEg,5224
96
+ dodal/devices/i24/pmac.py,sha256=qug40tz00vjvmQox3W6GrEcorriEeGkNyP5m4u5CVHo,7201
97
97
  dodal/devices/oav/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
98
98
  dodal/devices/oav/grid_overlay.py,sha256=kYs4sCvmo7yG75HQtptuI8jPzM7dR4fSqnOGL0D5j6g,5338
99
99
  dodal/devices/oav/microns_for_zoom_levels.json,sha256=kJTkbu2v6_Ccc_cDy7FRTX-gRhXxfYskjVqwBCZIqCQ,1209
@@ -120,15 +120,15 @@ dodal/devices/util/test_utils.py,sha256=VrSFFGLNKOcCAsWFMZOxwhng3wGR5kV8NqqnKfj8
120
120
  dodal/devices/xspress3/xspress3.py,sha256=JTx3ppAc8GwV9K-Gfqo81iGYH_L-ONyFWiPRs9XUs-w,4661
121
121
  dodal/devices/xspress3/xspress3_channel.py,sha256=yJRwseLmtkW2Vv6GB8sLdOFuBn3e4c9Q8fgPacMgl5w,1638
122
122
  dodal/devices/zocalo/__init__.py,sha256=oPhjFB39yf2NWkGD-MMcPFnnOVZ_RtdyBt2OLYn-Xa4,505
123
- dodal/devices/zocalo/zocalo_interaction.py,sha256=cc-OwjNsh9oMpMghceVWKW9gym_TFmP-mOYBd3H1H7Q,3379
124
- dodal/devices/zocalo/zocalo_results.py,sha256=L4bUc8xkYCmn5KCx_vcMKFBiBO5F9nhYEffv8fGUS48,9891
123
+ dodal/devices/zocalo/zocalo_interaction.py,sha256=y8YKMaVwfsRPBofHGGLYmYsd4QwMvm7JIPEo6wrN_Xo,3493
124
+ dodal/devices/zocalo/zocalo_results.py,sha256=MStx8iK--ITff-rT3AQu_RHnqKqNGLJDVyV3ewBwaKE,14316
125
125
  dodal/parameters/experiment_parameter_base.py,sha256=O7JamfuJ5cYHkPf9tsHJPqn-OMHTAGouigvM1cDFehE,313
126
126
  dodal/plans/check_topup.py,sha256=3gyLHfHNQBCgEWuAg4QE-ONx7y2Do1vVv5HP8ss0Z1I,5371
127
127
  dodal/plans/data_session_metadata.py,sha256=urexZ3mA0K6VWxVW3MlrcsB1Tyi09tFvpKBlaVil7TQ,1567
128
128
  dodal/plans/motor_util_plans.py,sha256=JT1K4DBB66MrzNqimxFgiL6mRsj11fF7xZXOz0udEeo,4522
129
- dls_dodal-1.31.1.dist-info/LICENSE,sha256=tAkwu8-AdEyGxGoSvJ2gVmQdcicWw3j1ZZueVV74M-E,11357
130
- dls_dodal-1.31.1.dist-info/METADATA,sha256=DcnbFBg6UX0o2covzHK0zCgw05wLGCXlzWN08tB6Cvg,16547
131
- dls_dodal-1.31.1.dist-info/WHEEL,sha256=cVxcB9AmuTcXqmwrtPhNK88dr7IR_b6qagTj0UvIEbY,91
132
- dls_dodal-1.31.1.dist-info/entry_points.txt,sha256=bycw_EKUzup_rxfCetOwcauXV4kLln_OPpPT8jEnr-I,94
133
- dls_dodal-1.31.1.dist-info/top_level.txt,sha256=xIozdmZk_wmMV4wugpq9-6eZs0vgADNUKz3j2UAwlhc,6
134
- dls_dodal-1.31.1.dist-info/RECORD,,
129
+ dls_dodal-1.32.0.dist-info/LICENSE,sha256=tAkwu8-AdEyGxGoSvJ2gVmQdcicWw3j1ZZueVV74M-E,11357
130
+ dls_dodal-1.32.0.dist-info/METADATA,sha256=Y7XXnR4vFkIsqoZ6vXh9STdoUyc9cbudVRqzP30rHUM,16574
131
+ dls_dodal-1.32.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
132
+ dls_dodal-1.32.0.dist-info/entry_points.txt,sha256=bycw_EKUzup_rxfCetOwcauXV4kLln_OPpPT8jEnr-I,94
133
+ dls_dodal-1.32.0.dist-info/top_level.txt,sha256=xIozdmZk_wmMV4wugpq9-6eZs0vgADNUKz3j2UAwlhc,6
134
+ dls_dodal-1.32.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (74.1.2)
2
+ Generator: setuptools (75.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
dodal/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '1.31.1'
16
- __version_tuple__ = version_tuple = (1, 31, 1)
15
+ __version__ = version = '1.32.0'
16
+ __version_tuple__ = version_tuple = (1, 32, 0)
dodal/beamlines/i03.py CHANGED
@@ -316,6 +316,7 @@ def undulator(
316
316
  wait_for_connection,
317
317
  fake_with_ophyd_sim,
318
318
  bl_prefix=False,
319
+ id_gap_lookup_table_path="/dls_sw/i03/software/daq_configuration/lookup/BeamLine_Undulator_toGap.txt",
319
320
  )
320
321
 
321
322
 
@@ -334,7 +335,6 @@ def undulator_dcm(
334
335
  undulator=undulator(wait_for_connection, fake_with_ophyd_sim),
335
336
  dcm=dcm(wait_for_connection, fake_with_ophyd_sim),
336
337
  daq_configuration_path=DAQ_CONFIGURATION_PATH,
337
- id_gap_lookup_table_path="/dls_sw/i03/software/daq_configuration/lookup/BeamLine_Undulator_toGap.txt",
338
338
  )
339
339
 
340
340
 
dodal/beamlines/i04.py CHANGED
@@ -308,6 +308,7 @@ def undulator(
308
308
  wait_for_connection,
309
309
  fake_with_ophyd_sim,
310
310
  bl_prefix=False,
311
+ id_gap_lookup_table_path="/dls_sw/i04/software/gda/config/lookupTables/BeamLine_Undulator_toGap.txt",
311
312
  )
312
313
 
313
314
 
dodal/beamlines/i22.py CHANGED
@@ -199,6 +199,7 @@ def undulator(
199
199
  bl_prefix=False,
200
200
  poles=80,
201
201
  length=2.0,
202
+ id_gap_lookup_table_path="/dls_sw/i22/software/daq_configuration/lookup/BeamLine_Undulator_toGap.txt",
202
203
  )
203
204
 
204
205
 
@@ -220,15 +220,15 @@ class ApertureScatterguard(StandardReadable, Movable):
220
220
  self.aperture.y.set(aperture_y),
221
221
  self.aperture.z.set(aperture_z),
222
222
  )
223
- return
224
- await asyncio.gather(
225
- self.aperture.x.set(aperture_x),
226
- self.aperture.y.set(aperture_y),
227
- self.aperture.z.set(aperture_z),
228
- )
223
+ else:
224
+ await asyncio.gather(
225
+ self.aperture.x.set(aperture_x),
226
+ self.aperture.y.set(aperture_y),
227
+ self.aperture.z.set(aperture_z),
228
+ )
229
229
 
230
- await asyncio.gather(
231
- self.scatterguard.x.set(scatterguard_x),
232
- self.scatterguard.y.set(scatterguard_y),
233
- )
230
+ await asyncio.gather(
231
+ self.scatterguard.x.set(scatterguard_x),
232
+ self.scatterguard.y.set(scatterguard_y),
233
+ )
234
234
  await self.selected_aperture.set(value)
@@ -87,11 +87,12 @@ class MJPG(Device, ABC):
87
87
 
88
88
 
89
89
  class SnapshotWithBeamCentre(MJPG):
90
- """A child of MJPG which, when triggered, draws a crosshair at the beam centre in the
91
- image and saves the image to disk."""
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
92
 
93
93
  CROSSHAIR_LENGTH_PX = 20
94
- CROSSHAIR_COLOUR = "Blue"
94
+ CROSSHAIR_OUTLINE_COLOUR = "Black"
95
+ CROSSHAIR_FILL_COLOUR = "White"
95
96
 
96
97
  def post_processing(self, image: Image.Image):
97
98
  assert (
@@ -100,15 +101,38 @@ class SnapshotWithBeamCentre(MJPG):
100
101
  beam_x = self.oav_params.beam_centre_i
101
102
  beam_y = self.oav_params.beam_centre_j
102
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):
103
110
  draw = ImageDraw.Draw(image)
104
- HALF_LEN = self.CROSSHAIR_LENGTH_PX / 2
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
+ )
105
131
  draw.line(
106
132
  ((beam_x, beam_y - HALF_LEN), (beam_x, beam_y + HALF_LEN)),
107
- fill=self.CROSSHAIR_COLOUR,
133
+ fill=cls.CROSSHAIR_FILL_COLOUR,
108
134
  )
109
135
  draw.line(
110
136
  ((beam_x - HALF_LEN, beam_y), (beam_x + HALF_LEN, beam_y)),
111
- fill=self.CROSSHAIR_COLOUR,
137
+ fill=cls.CROSSHAIR_FILL_COLOUR,
112
138
  )
113
-
114
- self._save_image(image)
@@ -3,6 +3,7 @@ from typing import Generic, TypeVar
3
3
 
4
4
  import numpy as np
5
5
  from bluesky.plan_stubs import mv
6
+ from bluesky.protocols import Flyable
6
7
  from numpy import ndarray
7
8
  from ophyd_async.core import (
8
9
  AsyncStatus,
@@ -193,7 +194,7 @@ class ExpectedImages(SignalR[int]):
193
194
  return first_grid + second_grid
194
195
 
195
196
 
196
- class FastGridScanCommon(StandardReadable, ABC, Generic[ParamType]):
197
+ class FastGridScanCommon(StandardReadable, Flyable, ABC, Generic[ParamType]):
197
198
  """Device for a general fast grid scan
198
199
 
199
200
  When the motion program is started, the goniometer will move in a snake-like grid trajectory,
@@ -76,7 +76,7 @@ class MirrorVoltageDevice(Device):
76
76
  LOGGER.debug(f"{setpoint_v.name} already at {value} - skipping set")
77
77
  return
78
78
 
79
- LOGGER.debug(f"setting {setpoint_v.name} to {value}")
79
+ LOGGER.debug(f"Setting {setpoint_v.name} to {value}")
80
80
 
81
81
  # Register an observer up front to ensure we don't miss events after we
82
82
  # perform the set
@@ -85,16 +85,14 @@ class MirrorVoltageDevice(Device):
85
85
  )
86
86
  # discard the current value (OK) so we can await a subsequent change
87
87
  await anext(demand_accepted_iterator)
88
- await setpoint_v.set(value)
88
+ set_status = setpoint_v.set(value, wait=False)
89
89
 
90
90
  # The set should always change to SLEW regardless of whether we are
91
91
  # already at the set point, then change back to OK/FAIL depending on
92
92
  # success
93
93
  accepted_value = await anext(demand_accepted_iterator)
94
94
  assert accepted_value == MirrorVoltageDemand.SLEW
95
- LOGGER.debug(
96
- f"Demand not accepted for {setpoint_v.name}, waiting for acceptance..."
97
- )
95
+ LOGGER.debug(f"Waiting for {setpoint_v.name} to set")
98
96
  while MirrorVoltageDemand.SLEW == (
99
97
  accepted_value := await anext(demand_accepted_iterator)
100
98
  ):
@@ -104,6 +102,7 @@ class MirrorVoltageDevice(Device):
104
102
  raise AssertionError(
105
103
  f"Voltage slew failed for {setpoint_v.name}, new state={accepted_value}"
106
104
  )
105
+ await set_status
107
106
 
108
107
 
109
108
  class VFMMirrorVoltages(StandardReadable):
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)
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
 
@@ -10,6 +10,7 @@ import numpy as np
10
10
  import workflows.recipe
11
11
  import workflows.transport
12
12
  from bluesky.protocols import Descriptor, Triggerable
13
+ from deepdiff import DeepDiff
13
14
  from numpy.typing import NDArray
14
15
  from ophyd_async.core import (
15
16
  AsyncStatus,
@@ -37,6 +38,11 @@ class SortKeys(str, Enum):
37
38
  n_voxels = "n_voxels"
38
39
 
39
40
 
41
+ class ZocaloSource(str, Enum):
42
+ CPU = "CPU"
43
+ GPU = "GPU"
44
+
45
+
40
46
  DEFAULT_TIMEOUT = 180
41
47
  DEFAULT_SORT_KEY = SortKeys.max_count
42
48
  ZOCALO_READING_PLAN_NAME = "zocalo reading"
@@ -60,12 +66,50 @@ def bbox_size(result: XrcResult):
60
66
  ]
61
67
 
62
68
 
69
+ def get_dict_differences(
70
+ dict1: dict, dict1_source: str, dict2: dict, dict2_source: str
71
+ ) -> str | None:
72
+ """Returns a string containing dict1 and dict2 if there are differences between them, greater than a
73
+ 1e-5 tolerance. If dictionaries are identical, return None"""
74
+
75
+ diff = DeepDiff(dict1, dict2, math_epsilon=1e-5, ignore_numeric_type_changes=True)
76
+
77
+ if diff:
78
+ return f"Zocalo results from {dict1_source} and {dict2_source} are not identical.\n Results from {dict1_source}: {dict1}\n Results from {dict2_source}: {dict2}"
79
+
80
+
81
+ def source_from_results(results):
82
+ return (
83
+ ZocaloSource.GPU.value
84
+ if results["recipe_parameters"].get("gpu")
85
+ else ZocaloSource.CPU.value
86
+ )
87
+
88
+
63
89
  class ZocaloResults(StandardReadable, Triggerable):
64
90
  """An ophyd device which can wait for results from a Zocalo job. These jobs should
65
91
  be triggered from a plan-subscribed callback using the run_start() and run_end()
66
92
  methods on dodal.devices.zocalo.ZocaloTrigger.
67
93
 
68
- See https://github.com/DiamondLightSource/dodal/wiki/How-to-Interact-with-Zocalo"""
94
+ See https://diamondlightsource.github.io/dodal/main/how-to/zocalo.html
95
+
96
+ Args:
97
+ name (str): Name of the device
98
+
99
+ zocalo_environment (str): How zocalo is configured. Defaults to i03's development configuration
100
+
101
+ channel (str): Name for the results Queue
102
+
103
+ sort_key (str): How results are ranked. Defaults to sorting by highest counts
104
+
105
+ timeout_s (float): Maximum time to wait for the Queue to be filled by an object, starting
106
+ from when the ZocaloResults device is triggered
107
+
108
+ prefix (str): EPICS PV prefix for the device
109
+
110
+ use_cpu_and_gpu (bool): When True, ZocaloResults will wait for results from the CPU and the GPU, compare them, and provide a warning if the results differ. When False, ZocaloResults will only use results from the CPU
111
+
112
+ """
69
113
 
70
114
  def __init__(
71
115
  self,
@@ -75,6 +119,7 @@ class ZocaloResults(StandardReadable, Triggerable):
75
119
  sort_key: str = DEFAULT_SORT_KEY.value,
76
120
  timeout_s: float = DEFAULT_TIMEOUT,
77
121
  prefix: str = "",
122
+ use_cpu_and_gpu: bool = False,
78
123
  ) -> None:
79
124
  self.zocalo_environment = zocalo_environment
80
125
  self.sort_key = SortKeys[sort_key]
@@ -83,6 +128,7 @@ class ZocaloResults(StandardReadable, Triggerable):
83
128
  self._prefix = prefix
84
129
  self._raw_results_received: Queue = Queue()
85
130
  self.transport: CommonTransport | None = None
131
+ self.use_cpu_and_gpu = use_cpu_and_gpu
86
132
 
87
133
  self.results, self._results_setter = soft_signal_r_and_setter(
88
134
  list[XrcResult], name="results"
@@ -111,14 +157,14 @@ class ZocaloResults(StandardReadable, Triggerable):
111
157
  )
112
158
  super().__init__(name)
113
159
 
114
- async def _put_results(self, results: Sequence[XrcResult], ispyb_ids):
160
+ async def _put_results(self, results: Sequence[XrcResult], recipe_parameters):
115
161
  self._results_setter(list(results))
116
162
  centres_of_mass = np.array([r["centre_of_mass"] for r in results])
117
163
  bbox_sizes = np.array([bbox_size(r) for r in results])
118
164
  self._com_setter(centres_of_mass)
119
165
  self._bbox_setter(bbox_sizes)
120
- self._ispyb_dcid_setter(ispyb_ids["dcid"])
121
- self._ispyb_dcgid_setter(ispyb_ids["dcgid"])
166
+ self._ispyb_dcid_setter(recipe_parameters["dcid"])
167
+ self._ispyb_dcgid_setter(recipe_parameters["dcgid"])
122
168
 
123
169
  def _clear_old_results(self):
124
170
  LOGGER.info("Clearing queue")
@@ -127,7 +173,7 @@ class ZocaloResults(StandardReadable, Triggerable):
127
173
  @AsyncStatus.wrap
128
174
  async def stage(self):
129
175
  """Stages the Zocalo device by: subscribing to the queue, doing a background
130
- sleep for a few seconds to wait for any stale messages to be recieved, then
176
+ sleep for a few seconds to wait for any stale messages to be received, then
131
177
  clearing the queue. Plans using this device should wait on ZOCALO_STAGE_GROUP
132
178
  before triggering processing for the experiment"""
133
179
 
@@ -169,7 +215,57 @@ class ZocaloResults(StandardReadable, Triggerable):
169
215
  )
170
216
 
171
217
  raw_results = self._raw_results_received.get(timeout=self.timeout_s)
172
- LOGGER.info(f"Zocalo: found {len(raw_results['results'])} crystals.")
218
+ source_of_first_results = source_from_results(raw_results)
219
+
220
+ # Wait for results from CPU and GPU, warn and continue if only GPU times out. Error if CPU times out
221
+ if self.use_cpu_and_gpu:
222
+ if source_of_first_results == ZocaloSource.CPU:
223
+ LOGGER.warning("Received zocalo results from CPU before GPU")
224
+ raw_results_two_sources = [raw_results]
225
+ try:
226
+ raw_results_two_sources.append(
227
+ self._raw_results_received.get(timeout=self.timeout_s / 2)
228
+ )
229
+ source_of_second_results = source_from_results(
230
+ raw_results_two_sources[1]
231
+ )
232
+
233
+ # Compare results from both sources and warn if they aren't the same
234
+ differences_str = get_dict_differences(
235
+ raw_results_two_sources[0]["results"][0],
236
+ source_of_first_results,
237
+ raw_results_two_sources[1]["results"][0],
238
+ source_of_second_results,
239
+ )
240
+ if differences_str:
241
+ LOGGER.warning(differences_str)
242
+
243
+ # Always use CPU results
244
+ raw_results = (
245
+ raw_results_two_sources[0]
246
+ if source_of_first_results == ZocaloSource.CPU
247
+ else raw_results_two_sources[1]
248
+ )
249
+
250
+ except Empty as err:
251
+ source_of_missing_results = (
252
+ ZocaloSource.CPU.value
253
+ if source_of_first_results == ZocaloSource.GPU.value
254
+ else ZocaloSource.GPU.value
255
+ )
256
+ if source_of_missing_results == ZocaloSource.GPU.value:
257
+ LOGGER.warning(
258
+ f"Zocalo results from {source_of_missing_results} timed out. Using results from {source_of_first_results}"
259
+ )
260
+ else:
261
+ LOGGER.error(
262
+ f"Zocalo results from {source_of_missing_results} timed out and GPU results not yet reliable"
263
+ )
264
+ raise err
265
+
266
+ LOGGER.info(
267
+ f"Zocalo results from {ZocaloSource.CPU.value} processing: found {len(raw_results['results'])} crystals."
268
+ )
173
269
  # Sort from strongest to weakest in case of multiple crystals
174
270
  await self._put_results(
175
271
  sorted(
@@ -177,7 +273,7 @@ class ZocaloResults(StandardReadable, Triggerable):
177
273
  key=lambda d: d[self.sort_key.value],
178
274
  reverse=True,
179
275
  ),
180
- raw_results["ispyb_ids"],
276
+ raw_results["recipe_parameters"],
181
277
  )
182
278
  except Empty as timeout_exception:
183
279
  LOGGER.warning("Timed out waiting for zocalo results!")
@@ -241,9 +337,17 @@ class ZocaloResults(StandardReadable, Triggerable):
241
337
  self.transport.ack(header) # type: ignore # we create transport here
242
338
 
243
339
  results = message.get("results", [])
244
- self._raw_results_received.put(
245
- {"results": results, "ispyb_ids": recipe_parameters}
246
- )
340
+
341
+ if self.use_cpu_and_gpu:
342
+ self._raw_results_received.put(
343
+ {"results": results, "recipe_parameters": recipe_parameters}
344
+ )
345
+ else:
346
+ # Only add to queue if results are from CPU
347
+ if not recipe_parameters.get("gpu"):
348
+ self._raw_results_received.put(
349
+ {"results": results, "recipe_parameters": recipe_parameters}
350
+ )
247
351
 
248
352
  subscription = workflows.recipe.wrap_subscribe(
249
353
  self.transport,
dodal/utils.py CHANGED
@@ -13,6 +13,7 @@ from os import environ
13
13
  from types import ModuleType
14
14
  from typing import (
15
15
  Any,
16
+ TypeGuard,
16
17
  TypeVar,
17
18
  )
18
19
 
@@ -259,7 +260,7 @@ def _is_device_skipped(func: AnyDeviceFactory) -> bool:
259
260
  return getattr(func, "__skip__", False)
260
261
 
261
262
 
262
- def is_v1_device_factory(func: Callable) -> bool:
263
+ def is_v1_device_factory(func: Callable) -> TypeGuard[V1DeviceFactory]:
263
264
  try:
264
265
  return_type = signature(func).return_annotation
265
266
  return is_v1_device_type(return_type)
@@ -267,7 +268,7 @@ def is_v1_device_factory(func: Callable) -> bool:
267
268
  return False
268
269
 
269
270
 
270
- def is_v2_device_factory(func: Callable) -> bool:
271
+ def is_v2_device_factory(func: Callable) -> TypeGuard[V2DeviceFactory]:
271
272
  try:
272
273
  return_type = signature(func).return_annotation
273
274
  return is_v2_device_type(return_type)
@@ -275,7 +276,7 @@ def is_v2_device_factory(func: Callable) -> bool:
275
276
  return False
276
277
 
277
278
 
278
- def is_any_device_factory(func: Callable) -> bool:
279
+ def is_any_device_factory(func: Callable) -> TypeGuard[AnyDeviceFactory]:
279
280
  return is_v1_device_factory(func) or is_v2_device_factory(func)
280
281
 
281
282