From c62bcc9863fd4b7c08bd4a992bdebd9eb5b4da36 Mon Sep 17 00:00:00 2001 From: sligara7 Date: Fri, 4 Sep 2026 13:28:39 -0400 Subject: [PATCH 1/5] Add take_radiograph: detector-generic burst radiograph plan Bluesky port of hex-acq-pyepics techniques/tomography/kinetix/take_radiograph.py. Bursts (frames_per_burst x num_bursts, wait between) per current operating practice; internal trigger via prepare(TriggerInfo) with deadtime enforcing the period-larger-than-exposure discipline; generic over StandardDetector; photon shutter under a finalizer; file placement owned by the path provider. --- src/hextools/tomography/take_radiograph.py | 163 +++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/hextools/tomography/take_radiograph.py diff --git a/src/hextools/tomography/take_radiograph.py b/src/hextools/tomography/take_radiograph.py new file mode 100644 index 0000000..8eedb65 --- /dev/null +++ b/src/hextools/tomography/take_radiograph.py @@ -0,0 +1,163 @@ +""" +Radiograph acquisition plan for HEX beamline. + +Equivalent of the old pyepics script: + hex-acq-pyepics/techniques/tomography/kinetix/take_radiograph.py + +What this plan does +------------------- +1. Check the front-end shutter and open the photon shutter. + The front-end shutter is only checked at entry; must already be open — this + plan never actuates it. +2. For each burst: fire ``frames_per_burst`` frames, then wait + ``wait_between_bursts``. +3. Close the photon shutter. + +Everything from shutter-open onward runs under a finalizer, so an error or +interrupt still closes the shutter. + +Trigger model +------------- +Each frame is acquired with ``bps.trigger_and_read`` using the camera's +internal trigger; the plan owns the exposure via ``prepare(TriggerInfo)`` +(control screens just reflect it). Non-overlapping frames are guaranteed by +``deadtime = frame_period - exposure_time`` — the same "period larger than +exposure" discipline the old PandA-paced script enforced with its PULSE step. +A PandA-paced external-trigger variant remains possible if precision frame +timing is ever needed. + +Usage +----- + RE(take_radiograph( + [kinetix1], fe_shutter, ph_shutter, + exposure_time=0.5, + frames_per_burst=10, + num_bursts=5, + wait_between_bursts=10.0, + )) + +``detectors`` is a list (``[kinetix1]``) since multiple detectors are supported. + +Where files land is decided by each detector's path provider (set in the +profile), not by this plan — the old script's proposal-folder logic is gone. +""" + +import bluesky.plan_stubs as bps +import bluesky.preprocessors as bpp +from ophyd_async.core import DetectorTrigger, StandardDetector, TriggerInfo + +from hextools.photon_delivery_system import Shutter + +# Readout headroom (s) added to exposure_time when frame_period is unset; +# same margin the beamline's deployed PandA plan kept between step and exposure. +FRAME_PERIOD_MARGIN = 0.1 + + +def take_radiograph( + detectors: list[StandardDetector], + front_end_shutter: Shutter, + photon_shutter: Shutter, + exposure_time: float, + frames_per_burst: int = 10, + num_bursts: int = 5, + wait_between_bursts: float = 10.0, + frame_period: float | None = None, + use_shutter: bool = True, + sample_name: str | None = None, + md: dict | None = None, +): + """Acquire a burst-mode radiograph series on the HEX beamline. + + Parameters + ---------- + detectors : list[StandardDetector] + detectors to trigger; any ophyd-async detector is accepted + front_end_shutter : Shutter + the front-end shutter to check before opening the photon shutter + photon_shutter : Shutter + the photon shutter to open/close around the acquisition + exposure_time : float + camera exposure time, in seconds (no default — depends on the sample) + frames_per_burst : int + number of frames fired in each burst + num_bursts : int + number of bursts to acquire + wait_between_bursts : float + idle time between bursts, in seconds + frame_period : float, optional + minimum time per frame, in seconds; must exceed ``exposure_time``, and + the difference is enforced as the camera's deadtime. If None, computed + from ``exposure_time`` plus a readout margin + use_shutter : bool + whether to open/check the photon shutter during the scan + sample_name : str, optional + name of the sample being imaged + md : dict, optional + extra metadata to merge into the run's metadata + """ + # Validate arguments before touching hardware. + if frame_period is None: + frame_period = exposure_time + FRAME_PERIOD_MARGIN + if frame_period <= exposure_time: + raise ValueError( + f"frame_period ({frame_period}) must be larger than exposure_time " + f"({exposure_time}) to leave readout margin." + ) + + if use_shutter: + # FE shutter must already be open; this plan never actuates it. + fe_shutter_open = yield from bps.rd(front_end_shutter.status) + if not fe_shutter_open: + raise ValueError( + "Front-end shutter is closed. Please open it before starting the scan." + ) + + def _body(): + if use_shutter: + photon_shutter_open = yield from bps.rd(photon_shutter.status) + if not photon_shutter_open: + yield from bps.mv(photon_shutter, True) + total_frames = frames_per_burst * num_bursts + + _md = { + "detectors": [det.name for det in detectors], + "num_points": total_frames, + "plan_name": "take_radiograph", + "hints": {}, + # burst structure — lets analysis reconstruct the timing + "frames_per_burst": frames_per_burst, + "num_bursts": num_bursts, + "wait_between_bursts": wait_between_bursts, + "frame_period": frame_period, + "exposure_time": exposure_time, + } + + if sample_name is not None: + _md["sample_name"] = sample_name + _md.update(md or {}) + yield from bps.open_run(md=_md) + + trigger_info = TriggerInfo( + trigger=DetectorTrigger.INTERNAL, + livetime=exposure_time, + deadtime=frame_period - exposure_time, + ) + + yield from bps.stage_all(*detectors) + for det in detectors: + yield from bps.prepare(det, trigger_info, wait=True) + + for burst in range(num_bursts): + for _ in range(frames_per_burst): + yield from bps.trigger_and_read(detectors) + if burst < num_bursts - 1: + yield from bps.sleep(wait_between_bursts) + + yield from bps.unstage_all(*detectors) + yield from bps.close_run() + + def _cleanup(): + if use_shutter: + yield from bps.mv(photon_shutter, False) + + return (yield from bpp.finalize_wrapper(_body(), _cleanup())) From 5a6e5f00ab0e751e4baf3902cf802b5e18079db6 Mon Sep 17 00:00:00 2001 From: sligara7 Date: Fri, 4 Sep 2026 13:34:38 -0400 Subject: [PATCH 2/5] changed in flyscan.py the _md plan_nmae to 'tomo_flyscan' --- src/hextools/tomography/flyscans.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hextools/tomography/flyscans.py b/src/hextools/tomography/flyscans.py index cf2040b..9082ee5 100644 --- a/src/hextools/tomography/flyscans.py +++ b/src/hextools/tomography/flyscans.py @@ -93,7 +93,7 @@ def tomo_flyscan( _md = { "detectors": [det.name for det in detectors], "num_points": num_images, - "plan_name": "single_axis_flyscan", + "plan_name": "tomo_flyscan", "hints": {}, } if sample_name is not None: From dc7ecff77ad010b3346b9a98a9ffb2d91499a94b Mon Sep 17 00:00:00 2001 From: sligara7 Date: Tue, 8 Sep 2026 17:00:57 -0400 Subject: [PATCH 3/5] initial version of take_radiograph.py --- src/hextools/tomography/take_radiograph.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/hextools/tomography/take_radiograph.py b/src/hextools/tomography/take_radiograph.py index 8eedb65..a7d6c44 100644 --- a/src/hextools/tomography/take_radiograph.py +++ b/src/hextools/tomography/take_radiograph.py @@ -45,6 +45,7 @@ import bluesky.plan_stubs as bps import bluesky.preprocessors as bpp from ophyd_async.core import DetectorTrigger, StandardDetector, TriggerInfo +from ophyd_async.epics.adcore import AreaDetector from hextools.photon_delivery_system import Shutter @@ -54,7 +55,7 @@ def take_radiograph( - detectors: list[StandardDetector], + detectors: list[AreaDetector], front_end_shutter: Shutter, photon_shutter: Shutter, exposure_time: float, @@ -70,7 +71,7 @@ def take_radiograph( Parameters ---------- - detectors : list[StandardDetector] + detectors : list[AreaDetector] detectors to trigger; any ophyd-async detector is accepted front_end_shutter : Shutter the front-end shutter to check before opening the photon shutter @@ -131,25 +132,18 @@ def _body(): "frame_period": frame_period, "exposure_time": exposure_time, } + for det in detectors: + yield from bps.mv(det.driver.num_images, frames_per_burst) if sample_name is not None: _md["sample_name"] = sample_name _md.update(md or {}) yield from bps.open_run(md=_md) - trigger_info = TriggerInfo( - trigger=DetectorTrigger.INTERNAL, - livetime=exposure_time, - deadtime=frame_period - exposure_time, - ) - yield from bps.stage_all(*detectors) - for det in detectors: - yield from bps.prepare(det, trigger_info, wait=True) for burst in range(num_bursts): - for _ in range(frames_per_burst): - yield from bps.trigger_and_read(detectors) + yield from bps.trigger_and_read(detectors) if burst < num_bursts - 1: yield from bps.sleep(wait_between_bursts) From 75401f21249b335741531b8057872c19583151a0 Mon Sep 17 00:00:00 2001 From: sligara7 Date: Wed, 9 Sep 2026 14:28:38 -0400 Subject: [PATCH 4/5] added test for take_radiograph.py --- pixi.lock | 22 +-- src/hextools/tomography/take_radiograph.py | 21 +-- tests/tomography/test_take_radiograph.py | 155 +++++++++++++++++++++ 3 files changed, 177 insertions(+), 21 deletions(-) create mode 100644 tests/tomography/test_take_radiograph.py diff --git a/pixi.lock b/pixi.lock index 8dc266c..e936b86 100644 --- a/pixi.lock +++ b/pixi.lock @@ -299,7 +299,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#93ef2e4a24892699debc56d6f9891e57dc2205d5 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca + - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/13/b5/3515126cb6dce557cb928cc3147a77d5f84b9cade3c8548b802792e6bbe4/redis_json_dict-0.2.2-py3-none-any.whl @@ -800,7 +800,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#93ef2e4a24892699debc56d6f9891e57dc2205d5 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca + - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/13/b5/3515126cb6dce557cb928cc3147a77d5f84b9cade3c8548b802792e6bbe4/redis_json_dict-0.2.2-py3-none-any.whl @@ -1213,7 +1213,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#93ef2e4a24892699debc56d6f9891e57dc2205d5 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca + - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 - pypi: https://files.pythonhosted.org/packages/23/ef/3b82c9a5c6fe4bcc7ebafbc4a06efe4f631929441bddd5d53541434fb941/pyOlog-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/c2/2ec71837ac862d823de280e72c245686b37de6dde0a18a02c9c18f589c39/setuptools_dso-2.12.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2f/35/b577d82c6d1be7aee7ac7e249bc86f7847998345042e5f8360de238e177b/pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -1698,7 +1698,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#93ef2e4a24892699debc56d6f9891e57dc2205d5 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca + - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/29/ce9c8e81a6b9be7722ac00cb592ce6b652405eecb178bf410810732957ec/epicscorelibs-7.0.10.99.0.1-cp311-cp311-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/13/b5/3515126cb6dce557cb928cc3147a77d5f84b9cade3c8548b802792e6bbe4/redis_json_dict-0.2.2-py3-none-any.whl @@ -2199,7 +2199,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#93ef2e4a24892699debc56d6f9891e57dc2205d5 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca + - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/b5/3515126cb6dce557cb928cc3147a77d5f84b9cade3c8548b802792e6bbe4/redis_json_dict-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/dd/a9fe6a0a09512da23951c68bf36466aeecd89def3183dc095edbc807ddc5/pint-0.25.3-py3-none-any.whl @@ -2700,7 +2700,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#93ef2e4a24892699debc56d6f9891e57dc2205d5 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca + - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/13/b5/3515126cb6dce557cb928cc3147a77d5f84b9cade3c8548b802792e6bbe4/redis_json_dict-0.2.2-py3-none-any.whl @@ -3132,7 +3132,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#93ef2e4a24892699debc56d6f9891e57dc2205d5 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca + - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 - pypi: https://files.pythonhosted.org/packages/23/ef/3b82c9a5c6fe4bcc7ebafbc4a06efe4f631929441bddd5d53541434fb941/pyOlog-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/c2/2ec71837ac862d823de280e72c245686b37de6dde0a18a02c9c18f589c39/setuptools_dso-2.12.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2f/35/b577d82c6d1be7aee7ac7e249bc86f7847998345042e5f8360de238e177b/pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -3536,7 +3536,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#93ef2e4a24892699debc56d6f9891e57dc2205d5 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca + - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 - pypi: https://files.pythonhosted.org/packages/23/ef/3b82c9a5c6fe4bcc7ebafbc4a06efe4f631929441bddd5d53541434fb941/pyOlog-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/c2/2ec71837ac862d823de280e72c245686b37de6dde0a18a02c9c18f589c39/setuptools_dso-2.12.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2f/35/b577d82c6d1be7aee7ac7e249bc86f7847998345042e5f8360de238e177b/pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -4042,7 +4042,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#93ef2e4a24892699debc56d6f9891e57dc2205d5 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca + - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 - pypi: https://files.pythonhosted.org/packages/1b/dd/a9fe6a0a09512da23951c68bf36466aeecd89def3183dc095edbc807ddc5/pint-0.25.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/21/bd/4d1f59c9287ec5f93f9d879db3ac06785ba7c4d04a7120678d894e0c53d0/caproto-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/23/ef/3b82c9a5c6fe4bcc7ebafbc4a06efe4f631929441bddd5d53541434fb941/pyOlog-4.5.1-py3-none-any.whl @@ -13693,9 +13693,9 @@ packages: - requests - shortuuid requires_python: '>=3.11' -- pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5495b4d80acd7a98b69bdb7dab73f137a3dcc3ca +- pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#9c4c762d6c333f7907084f109ffc4c1ce0af7da8 name: ophyd-async - version: 0.1.dev1025+g5495b4d80 + version: 0.1.dev1040+g9c4c762d6 requires_dist: - numpy - bluesky>=1.13.1rc2 diff --git a/src/hextools/tomography/take_radiograph.py b/src/hextools/tomography/take_radiograph.py index a7d6c44..7462708 100644 --- a/src/hextools/tomography/take_radiograph.py +++ b/src/hextools/tomography/take_radiograph.py @@ -18,13 +18,14 @@ Trigger model ------------- -Each frame is acquired with ``bps.trigger_and_read`` using the camera's -internal trigger; the plan owns the exposure via ``prepare(TriggerInfo)`` -(control screens just reflect it). Non-overlapping frames are guaranteed by -``deadtime = frame_period - exposure_time`` — the same "period larger than -exposure" discipline the old PandA-paced script enforced with its PULSE step. -A PandA-paced external-trigger variant remains possible if precision frame -timing is ever needed. +Each burst is a single ``bps.trigger_and_read`` on the camera's internal +trigger, with ``num_images`` set to ``frames_per_burst`` so one trigger fires +the whole burst. The plan owns the timing directly: ``acquire_time`` is set to +``exposure_time`` and ``acquire_period`` to ``frame_period``, so +``frame_period - exposure_time`` is the readout margin that keeps frames +non-overlapping — the same "period larger than exposure" discipline the old +PandA-paced script enforced with its PULSE step. A PandA-paced external-trigger +variant remains possible if precision frame timing is ever needed. Usage ----- @@ -44,7 +45,6 @@ import bluesky.plan_stubs as bps import bluesky.preprocessors as bpp -from ophyd_async.core import DetectorTrigger, StandardDetector, TriggerInfo from ophyd_async.epics.adcore import AreaDetector from hextools.photon_delivery_system import Shutter @@ -118,11 +118,10 @@ def _body(): photon_shutter_open = yield from bps.rd(photon_shutter.status) if not photon_shutter_open: yield from bps.mv(photon_shutter, True) - total_frames = frames_per_burst * num_bursts _md = { "detectors": [det.name for det in detectors], - "num_points": total_frames, + "num_points": num_bursts, "plan_name": "take_radiograph", "hints": {}, # burst structure — lets analysis reconstruct the timing @@ -134,6 +133,8 @@ def _body(): } for det in detectors: yield from bps.mv(det.driver.num_images, frames_per_burst) + yield from bps.mv(det.driver.acquire_time, exposure_time) + yield from bps.mv(det.driver.acquire_period, frame_period) if sample_name is not None: _md["sample_name"] = sample_name diff --git a/tests/tomography/test_take_radiograph.py b/tests/tomography/test_take_radiograph.py new file mode 100644 index 0000000..1433e34 --- /dev/null +++ b/tests/tomography/test_take_radiograph.py @@ -0,0 +1,155 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest +from bluesky import Msg, RunEngine +from bluesky import plan_stubs as bps +from ophyd_async.core import ( + StaticPathProvider, + UUIDFilenameProvider, + callback_on_mock_execute, + callback_on_mock_put, + init_devices, + set_mock_value, +) +from ophyd_async.epics.adcore import ADBaseDataType, ADWriterFactory, NDPluginFileIO +from ophyd_async.epics.adkinetix import KinetixDetector + +from hextools.photon_delivery_system import Shutter +from hextools.tomography.take_radiograph import FRAME_PERIOD_MARGIN, take_radiograph + + +# --- shutters: same shape as tests/tomography/test_alignment.py --------------- + +@pytest.fixture +def shutter_factory() -> Callable[[str], Shutter]: + def _factory(name: str) -> Shutter: + with init_devices(mock=True): + shutter = Shutter(name, name=name) + # the only two arcs Shutter.set awaits: a command put flips the status readback + callback_on_mock_execute( + shutter.open_cmd, lambda *_: set_mock_value(shutter.status, True) + ) + callback_on_mock_execute( + shutter.close_cmd, lambda *_: set_mock_value(shutter.status, False) + ) + return shutter + + return _factory + + +@pytest.fixture +def two_shutters(shutter_factory: Callable[[str], Shutter]) -> tuple[Shutter, Shutter]: + return shutter_factory("front_end_shutter"), shutter_factory("photon_shutter") + + +# --- detector: Kinetix with an HDF writer, minimum causal chain --------------- + + +@pytest.fixture +def static_path_provider(tmp_path: Path) -> StaticPathProvider: + return StaticPathProvider(UUIDFilenameProvider(), tmp_path) + + +@pytest.fixture +def kinetix_hdf_factory( + static_path_provider: StaticPathProvider, +) -> Callable[[int], KinetixDetector]: + def _factory(num: int) -> KinetixDetector: + with init_devices(mock=True): + ktx = KinetixDetector( + f"KTX{num}", + ADWriterFactory.hdf(static_path_provider), + name=f"kinetix{num}", + ) + hdf = ktx.get_plugin_by_name("hdf", NDPluginFileIO) + + # what the descriptor needs to describe the image + set_mock_value(ktx.driver.array_size_x, 3200) + set_mock_value(ktx.driver.array_size_y, 3200) + set_mock_value(ktx.driver.data_type, ADBaseDataType.UINT16) + + async def _one_burst_arrives(_): + # one acquire produces a whole burst: num_images frames land in the file + n = await ktx.driver.num_images.get_value() + got = await hdf.num_captured.get_value() + set_mock_value(hdf.num_captured, got + n) + + # prepare: setting the directory must make the IOC report it exists + callback_on_mock_put( + hdf.file_path, lambda _: set_mock_value(hdf.file_path_exists, True) + ) + # prepare: starting capture resets the frame counter + callback_on_mock_put(hdf.capture, lambda _: set_mock_value(hdf.num_captured, 0)) + # trigger: the writer waits on num_captured reaching the expected count + callback_on_mock_put(ktx.driver.acquire, _one_burst_arrives) + return ktx + + return _factory + + +# --- one row of the happy path, to prove the arcs before the table exists ----- + + +async def test_take_radiograph_single_row( + RE: RunEngine, + kinetix_hdf_factory: Callable[[int], KinetixDetector], + two_shutters: tuple[Shutter, Shutter], + monkeypatch: pytest.MonkeyPatch, +): + # the profile sets this; tests do not load the profile + monkeypatch.setenv("OPHYD_ASYNC_PRESERVE_DETECTOR_STATE", "YES") + exposure_time, frames_per_burst, num_bursts, wait = 0.1, 10, 5, 0.01 + + fe_shutter, photon_shutter = two_shutters + ktx = kinetix_hdf_factory(1) + RE(bps.mv(fe_shutter, True)) # precondition: front end already open + + docs: dict[str, list[dict[str, Any]]] = {} + + def cache_docs(name: str, doc: dict[str, Any]): + docs.setdefault(name, []).append(doc) + + messages_by_type: dict[str, list[Msg]] = {} + + def msg_hook(msg: Msg): + messages_by_type.setdefault(msg.command, []).append(msg) + + RE.msg_hook = msg_hook + + RE( + take_radiograph( + [ktx], + fe_shutter, + photon_shutter, + exposure_time, + frames_per_burst=frames_per_burst, + num_bursts=num_bursts, + wait_between_bursts=wait, + ), + cache_docs, + ) + + for kind in ("start", "descriptor", "stream_resource", "stop"): + assert len(docs[kind]) == 1 + assert len(docs["event"]) == num_bursts + assert len(docs["stream_datum"]) == num_bursts + + start = docs["start"][0] + assert start["plan_name"] == "take_radiograph" + assert start["frames_per_burst"] == frames_per_burst + assert start["num_bursts"] == num_bursts + assert start["num_points"] == num_bursts + + sleeps = messages_by_type.get("sleep", []) + assert len(sleeps) == num_bursts - 1 + assert all(m.args == (wait,) for m in sleeps) + + assert await ktx.driver.acquire_time.get_value() == exposure_time + assert await ktx.driver.num_images.get_value() == frames_per_burst + assert await photon_shutter.status.get_value() is False # finalizer closed it + + assert await ktx.driver.acquire_period.get_value() == pytest.approx( + exposure_time + FRAME_PERIOD_MARGIN + ) From 144ad93f34119d6541f5517ef46de5834a885c6b Mon Sep 17 00:00:00 2001 From: sligara7 Date: Wed, 9 Sep 2026 14:33:20 -0400 Subject: [PATCH 5/5] test_take_radiograph: sort imports, ruff format, type-ignore the RE subscriber like test_alignment --- tests/tomography/test_take_radiograph.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tomography/test_take_radiograph.py b/tests/tomography/test_take_radiograph.py index 1433e34..a1a93c4 100644 --- a/tests/tomography/test_take_radiograph.py +++ b/tests/tomography/test_take_radiograph.py @@ -19,9 +19,9 @@ from hextools.photon_delivery_system import Shutter from hextools.tomography.take_radiograph import FRAME_PERIOD_MARGIN, take_radiograph - # --- shutters: same shape as tests/tomography/test_alignment.py --------------- + @pytest.fixture def shutter_factory() -> Callable[[str], Shutter]: def _factory(name: str) -> Shutter: @@ -128,7 +128,7 @@ def msg_hook(msg: Msg): num_bursts=num_bursts, wait_between_bursts=wait, ), - cache_docs, + cache_docs, # type: ignore ) for kind in ("start", "descriptor", "stream_resource", "stop"): @@ -151,5 +151,5 @@ def msg_hook(msg: Msg): assert await photon_shutter.status.get_value() is False # finalizer closed it assert await ktx.driver.acquire_period.get_value() == pytest.approx( - exposure_time + FRAME_PERIOD_MARGIN + exposure_time + FRAME_PERIOD_MARGIN )