diff --git a/pixi.lock b/pixi.lock index 357f649..139e1f8 100644 --- a/pixi.lock +++ b/pixi.lock @@ -319,8 +319,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - pypi: https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/b5/3515126cb6dce557cb928cc3147a77d5f84b9cade3c8548b802792e6bbe4/redis_json_dict-0.2.2-py3-none-any.whl @@ -844,9 +844,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - pypi: git+https://github.com/jwlodek/ntnda-qt-viewer?rev=main#6763cde3c4bb3b2baa47c5ab5208af6d3c148a21 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 - pypi: https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1c/8a/b34eb6d94c0b7ac75d278e6c67990c4be5311ea9d9f6e6c101bf48df3068/databroker-2.0.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl @@ -1229,8 +1229,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - pypi: https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/b5/3515126cb6dce557cb928cc3147a77d5f84b9cade3c8548b802792e6bbe4/redis_json_dict-0.2.2-py3-none-any.whl @@ -1582,8 +1582,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - pypi: https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/b5/3515126cb6dce557cb928cc3147a77d5f84b9cade3c8548b802792e6bbe4/redis_json_dict-0.2.2-py3-none-any.whl @@ -2037,9 +2037,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - pypi: git+https://github.com/jwlodek/ntnda-qt-viewer?rev=main#6763cde3c4bb3b2baa47c5ab5208af6d3c148a21 - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 - pypi: https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1c/8a/b34eb6d94c0b7ac75d278e6c67990c4be5311ea9d9f6e6c101bf48df3068/databroker-2.0.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl @@ -2577,8 +2577,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - pypi: https://files.pythonhosted.org/packages/0e/99/e3d4e3b8feae70ea2546533dc348ae325884db2ddf88b423f4452cc21d46/pvxslibs-1.5.3-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 - pypi: https://files.pythonhosted.org/packages/1b/dd/a9fe6a0a09512da23951c68bf36466aeecd89def3183dc095edbc807ddc5/pint-0.25.3-py3-none-any.whl @@ -3097,8 +3097,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - 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/1c/8a/b34eb6d94c0b7ac75d278e6c67990c4be5311ea9d9f6e6c101bf48df3068/databroker-2.0.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl @@ -3616,8 +3616,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - pypi: https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.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 - pypi: https://files.pythonhosted.org/packages/1c/8a/b34eb6d94c0b7ac75d278e6c67990c4be5311ea9d9f6e6c101bf48df3068/databroker-2.0.2-py2.py3-none-any.whl @@ -4024,8 +4024,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - pypi: https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1c/8a/b34eb6d94c0b7ac75d278e6c67990c4be5311ea9d9f6e6c101bf48df3068/databroker-2.0.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl @@ -4451,8 +4451,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.2-pyh5ded981_0.conda - pypi: ./ - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - - pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd - - pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 + - pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 + - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - pypi: https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1c/8a/b34eb6d94c0b7ac75d278e6c67990c4be5311ea9d9f6e6c101bf48df3068/databroker-2.0.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl @@ -5063,7 +5063,7 @@ packages: - zstd >=1.5.7,<1.6.0a0 license: BSD-3-Clause AND MIT AND EPL-2.0 purls: - - pkg:pypi/backports-zstd?source=hash-mapping + - pkg:pypi/backports-zstd?source=compressed-mapping run_exports: {} size: 249002 timestamp: 1791654829528 @@ -5323,7 +5323,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/cffi?source=hash-mapping + - pkg:pypi/cffi?source=compressed-mapping run_exports: {} size: 310350 timestamp: 1788485287159 @@ -5582,7 +5582,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/cytoolz?source=hash-mapping + - pkg:pypi/cytoolz?source=compressed-mapping run_exports: {} size: 762767 timestamp: 1791511255926 @@ -5598,7 +5598,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/cytoolz?source=hash-mapping + - pkg:pypi/cytoolz?source=compressed-mapping run_exports: {} size: 765976 timestamp: 1791511269143 @@ -5614,7 +5614,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/cytoolz?source=hash-mapping + - pkg:pypi/cytoolz?source=compressed-mapping run_exports: {} size: 760733 timestamp: 1791511265780 @@ -6056,7 +6056,7 @@ packages: license: Apache-2.0 license_family: Apache purls: - - pkg:pypi/google-crc32c?source=hash-mapping + - pkg:pypi/google-crc32c?source=compressed-mapping run_exports: {} size: 29520 timestamp: 1788537554663 @@ -9167,7 +9167,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/openpyxl?source=hash-mapping + - pkg:pypi/openpyxl?source=compressed-mapping run_exports: {} size: 478098 timestamp: 1788523142686 @@ -9929,8 +9929,7 @@ packages: license: LGPL-3.0-only license_family: LGPL purls: - - pkg:pypi/pyside6?source=hash-mapping - - pkg:pypi/shiboken6?source=hash-mapping + - pkg:pypi/pyside6?source=compressed-mapping run_exports: {} size: 13947145 timestamp: 1788745031498 @@ -10525,7 +10524,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/rpds-py?source=hash-mapping + - pkg:pypi/rpds-py?source=compressed-mapping run_exports: {} size: 302657 timestamp: 1791202432096 @@ -11578,7 +11577,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/zstandard?source=hash-mapping + - pkg:pypi/zstandard?source=compressed-mapping run_exports: {} size: 466599 timestamp: 1788604311166 @@ -11596,7 +11595,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/zstandard?source=hash-mapping + - pkg:pypi/zstandard?source=compressed-mapping run_exports: {} size: 466862 timestamp: 1788604308908 @@ -11614,7 +11613,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/zstandard?source=hash-mapping + - pkg:pypi/zstandard?source=compressed-mapping run_exports: {} size: 472702 timestamp: 1788604307981 @@ -12029,7 +12028,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/bokeh?source=hash-mapping + - pkg:pypi/bokeh?source=compressed-mapping run_exports: {} size: 4454345 timestamp: 1787144786949 @@ -12087,7 +12086,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/cached-property?source=hash-mapping + - pkg:pypi/cached-property?source=compressed-mapping run_exports: {} size: 16597 timestamp: 1787678456483 @@ -12932,7 +12931,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/imagesize?source=hash-mapping + - pkg:pypi/imagesize?source=compressed-mapping run_exports: {} size: 21467 timestamp: 1787649248672 @@ -13190,7 +13189,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/jsonschema?source=hash-mapping + - pkg:pypi/jsonschema?source=compressed-mapping run_exports: {} size: 82084 timestamp: 1787579299493 @@ -13223,7 +13222,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/jupyter-client?source=hash-mapping + - pkg:pypi/jupyter-client?source=compressed-mapping run_exports: {} size: 118962 timestamp: 1787929615607 @@ -13552,7 +13551,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/nexusformat?source=hash-mapping + - pkg:pypi/nexusformat?source=compressed-mapping run_exports: {} size: 1021789 timestamp: 1791251134726 @@ -13902,7 +13901,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/pyproject-hooks?source=hash-mapping + - pkg:pypi/pyproject-hooks?source=compressed-mapping run_exports: {} size: 17061 timestamp: 1791576869230 @@ -14840,7 +14839,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/tenacity?source=hash-mapping + - pkg:pypi/tenacity?source=compressed-mapping run_exports: {} size: 34691 timestamp: 1791389550882 @@ -15399,11 +15398,11 @@ packages: name: hextools requires_dist: - algotom - - bluesky + - bluesky @ git+https://github.com/bluesky/bluesky@main - ipython - - nslsii + - nslsii @ git+https://github.com/NSLS2/nslsii@main - numpy - - ophyd-async[ca,pva] + - ophyd-async[ca,pva] @ git+https://github.com/bluesky/ophyd-async@main - rich>=15.0.0 - scikit-image>=0.26.0 - scipy>=1.17.1 @@ -15452,9 +15451,9 @@ packages: - redis-json-dict - requests requires_python: '>=3.11,<3.15' -- pypi: git+https://github.com/bluesky/bluesky?rev=main#a424322948feb80dec9a051847f80bee939b55cd +- pypi: git+https://github.com/bluesky/bluesky?rev=main#7672549a145c59d5208ada6cc30c0f8262728b50 name: bluesky - version: 1.15.2.dev274+ga42432294 + version: 1.15.2.dev285+g7672549a1 requires_dist: - cycler - event-model>=1.24.0 @@ -15471,7 +15470,7 @@ packages: - cloudpickle ; extra == 'dev' - copier ; extra == 'dev' - coverage ; extra == 'dev' - - databroker==2.0.1 ; extra == 'dev' + - databroker ; extra == 'dev' - doct ; extra == 'dev' - doctr ; extra == 'dev' - flake8 ; extra == 'dev' @@ -15541,24 +15540,9 @@ packages: - zict<3 ; extra == 'old-persistentdict' - bluesky[cmd,common,dev,ipython,olog,plotting,streamz,tools,zmq] ; extra == 'all' requires_python: '>=3.10' -- pypi: git+https://github.com/jwlodek/ntnda-qt-viewer?rev=main#6763cde3c4bb3b2baa47c5ab5208af6d3c148a21 - name: ntnda-qt-viewer - version: 0.0.3.dev16+g6763cde3c - requires_dist: - - aioca>=2.0 - - p4p>=4.2.2 - - pyqtgraph>=0.14.0 - - qtpy>=2.4.3 - - pyside6>=6.9,<6.10 ; extra == 'pyside6' - - pyqt6>=6.9,<6.10 ; extra == 'pyqt6' - - blosc2 ; extra == 'codecs' - - lz4 ; extra == 'codecs' - - pillow ; extra == 'codecs' - - imagecodecs ; extra == 'codecs' - requires_python: '>=3.11' -- pypi: git+https://github.com/jwlodek/ophyd-async?rev=add-codec-and-process-plugin-allow-for-filtering#5895770395c6f05db456b405c673b0f811f8d174 +- pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 name: ophyd-async - version: 0.21.3.dev63+g589577039 + version: 0.21.4.dev18+gde097ace3 requires_dist: - numpy - bluesky>=1.13.1rc2 @@ -15579,6 +15563,21 @@ packages: - pyqt6<6.10 ; extra == 'demo' - pytest ; extra == 'demo' requires_python: '>=3.11' +- pypi: git+https://github.com/jwlodek/ntnda-qt-viewer?rev=main#6763cde3c4bb3b2baa47c5ab5208af6d3c148a21 + name: ntnda-qt-viewer + version: 0.0.3.dev16+g6763cde3c + requires_dist: + - aioca>=2.0 + - p4p>=4.2.2 + - pyqtgraph>=0.14.0 + - qtpy>=2.4.3 + - pyside6>=6.9,<6.10 ; extra == 'pyside6' + - pyqt6>=6.9,<6.10 ; extra == 'pyqt6' + - blosc2 ; extra == 'codecs' + - lz4 ; extra == 'codecs' + - pillow ; extra == 'codecs' + - imagecodecs ; extra == 'codecs' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: pymongo version: 4.19.0 diff --git a/pixi.toml b/pixi.toml index 3b0a0aa..1dd9cdd 100644 --- a/pixi.toml +++ b/pixi.toml @@ -22,7 +22,7 @@ bluesky-tiled-plugins = ">=2.0.9,<3" hextools = { path = ".", editable = true } nslsii = { git = "https://github.com/NSLS2/nslsii", rev = "main" } bluesky = { git = "https://github.com/bluesky/bluesky", rev = "main" } -ophyd-async = { git = "https://github.com/jwlodek/ophyd-async", rev = "add-codec-and-process-plugin-allow-for-filtering" } +ophyd-async = { git = "https://github.com/bluesky/ophyd-async", rev = "main" } [feature.profile.dependencies] redis-json-dict = ">=0.2.2,<0.3" @@ -115,6 +115,7 @@ auto-exporter = "python -m hextools.exporters.auto_exporter" [feature.exporter.dependencies] h5py = ">=3.16.0,<4" + [feature.docs.dependencies] sphinx = "*" sphinx-autobuild = "*" @@ -156,5 +157,12 @@ solve-group = "profile" [feature.py313.tasks] tests = "pytest --cov=src/hextools --cov-report=term" +# Run the tests, then serve the html coverage report whether or not they passed. +# VS Code's terminal opens file:// links in the editor, but http:// in the browser. +coverage = """ +pytest --cov=src/hextools --cov-report=term --cov-report=html; \ +echo "Coverage report: http://localhost:8765/index.html (Ctrl-C to stop)" \ +&& python -m http.server 8765 --bind 127.0.0.1 --directory $PIXI_PROJECT_ROOT/htmlcov +""" lint = "pre-commit run --all-files" type-check = "ty check src tests" diff --git a/pyproject.toml b/pyproject.toml index 15b6b53..ff6ab50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,9 @@ dependencies = [ "algotom", "ipython", "numpy", - "bluesky", - "nslsii", - "ophyd-async[ca, pva]", + "bluesky @ git+https://github.com/bluesky/bluesky@main", + "nslsii @ git+https://github.com/NSLS2/nslsii@main", + "ophyd-async[ca, pva] @ git+https://github.com/bluesky/ophyd-async@main", "rich>=15.0.0", "scikit-image>=0.26.0", "scipy>=1.17.1", @@ -111,7 +111,7 @@ allow-direct-references = true artifacts = ["src/hextools/_version.py"] [tool.hatch.build.targets.wheel] -sources = ["src"] +projects = ["src/hextools"] [tool.hatch.build.hooks.vcs] version-file = "src/hextools/_version.py" diff --git a/src/hextools/specs.py b/src/hextools/specs.py index ec7440e..3c820c0 100644 --- a/src/hextools/specs.py +++ b/src/hextools/specs.py @@ -141,6 +141,12 @@ def validate_tomo_dark_flat(run: RunLike, version: str | None = "1") -> None: # sample_name: str optional # last_dark_flat_uid: str optional, see the tomography dark/flat convention # last_dark_flat_scan_id: int optional, see the tomography dark/flat convention +# grid_index: int optional, from the step scans: 0-based point index +# num_grid_points: int optional, from the step scans: points in the scan +# grid_positions: dict[str, float] optional, from the step scans: step motor name to +# setpoint; empty when the scan has no step axes +# previous_grid_point_uid: str optional, from the step scans: start uid of the +# previous point's successful run; absent on first # Streams: # primary: collected while flying # (num_points, height, width) image stack, external diff --git a/src/hextools/tomography/alignment.py b/src/hextools/tomography/alignment.py index 1409ef3..3e311a3 100644 --- a/src/hextools/tomography/alignment.py +++ b/src/hextools/tomography/alignment.py @@ -725,14 +725,15 @@ def tomo_alignment_scan( fe_shutter = ensure_available(Shutter, fe_shutter=fe_shutter) photon_shutter = ensure_available(Shutter, photon_shutter=photon_shutter) rot_motor = ensure_available(RotationMotor, rot_motor=rot_motor) + # Restored in the cleanup, so the stage returns at full speed before slowing. + initial_position = yield from bps.rd(rot_motor.user_readback) + initial_velocity = yield from bps.rd(rot_motor.velocity) @bpp.reset_positions_decorator( [ det.driver.num_images, det.driver.acquire_period, det.driver.acquire_time, - rot_motor, - rot_motor.velocity, ] ) def _body(): @@ -769,5 +770,7 @@ def _cleanup(): yield from ensure_shutter_closed(photon_shutter) if isinstance(det, KinetixDetector): yield from enable_preview_mode(det) + yield from bps.mv(rot_motor, initial_position) + yield from bps.mv(rot_motor.velocity, initial_velocity) - return bpp.finalize_wrapper(_body(), _cleanup()) + return (yield from bpp.finalize_wrapper(_body(), _cleanup())) diff --git a/src/hextools/tomography/flyscans.py b/src/hextools/tomography/flyscans.py index d82b79a..ce14326 100644 --- a/src/hextools/tomography/flyscans.py +++ b/src/hextools/tomography/flyscans.py @@ -1,17 +1,18 @@ """Tomography plans for HEX beamline.""" +import asyncio import logging from collections import defaultdict from collections.abc import Sequence -from typing import Any +from typing import Any, cast from bluesky import plan_patterns from bluesky import plan_stubs as bps from bluesky import preprocessors as bpp -from bluesky.protocols import Collectable, Flyable +from bluesky.protocols import Collectable from bluesky.utils import ( FailedStatus, - MsgGenerator, + PlanHalt, RunEngineControlException, ) from ophyd_async.core import ( @@ -45,67 +46,6 @@ MAX_MISSING_FRAMES = 10 -# TODO: Remove once bluesky has a fly stub builtin -def _fly_stub( - flyers: list[Flyable], - *, - collect_flush_period: float | None = None, - stream_name: str | None = None, - watch: Sequence[str] = (), -) -> MsgGenerator[None]: - """ - Perform a fly scan with one or more 'flyers'. - - Parameters - ---------- - flyers : collection - objects that support the flyer interface - md : dict, optional - metadata - collect_flush_period : float, optional - If set, will use `collect_while_completing` with the given flush period - stream_name : str, optional - If set, will declare a stream with the given name for all flyers - watch: set of watch groups, optional - Additional groups to monitor while collecting from flyers. - Will only be used if `collect_flush_period` is set. - - Yields - ------ - msg : Msg - 'kickoff', 'wait', 'complete, 'wait', 'collect' messages - - See Also - -------- - :func:`bluesky.preprocessors.fly_during_wrapper` - :func:`bluesky.preprocessors.fly_during_decorator` - """ - # Extract list of collectable detectors from flyers - dets = [flyer for flyer in flyers if isinstance(flyer, Collectable)] - - # If provided, attempt to declare single stream for all collectable detectors - # note that if set, all detectors must produce the same number of events. - if stream_name is not None: - yield from bps.declare_stream(*dets, name=stream_name) # type: ignore - - # Kickoff all flyers - yield from bps.kickoff_all(*flyers, wait=True) - - # If flush period given, collect while completing. - if collect_flush_period is not None: - yield from bps.collect_while_completing( - flyers, - dets, - flush_period=collect_flush_period, - stream_name=stream_name, - watch=watch, - ) - else: - # Otherwise, wait for all flyers to complete before collecting. - yield from bps.complete_all(*flyers, wait=True) - yield from bps.collect_all(*dets, name=stream_name) - - def tomo_dark_flat( detectors: list[KinetixDetector | PhantomDetector], exposure_time: float, @@ -147,6 +87,11 @@ def tomo_dark_flat( if use_shutter: yield from ensure_shutter_open(fe_shutter) + # Each detector's prepare sets its exposure, so put it back in the cleanup. + initial_acquire_times = [] + for det in detectors: + initial_acquire_times.append((yield from bps.rd(det.driver.acquire_time))) + _md = { "plan_name": "tomo_dark_flat", "detectors": [det.name for det in detectors], @@ -159,44 +104,38 @@ def tomo_dark_flat( ], } - @bpp.reset_positions_decorator( - [sample_tower.x1, *[det.driver.acquire_time for det in detectors]] - ) + @bpp.reset_positions_decorator([sample_tower.x1]) @bpp.run_decorator(md=_md) @bpp.stage_decorator([*detectors]) def _body(): yield from ensure_shutter_closed(photon_shutter) - for det in detectors: - yield from bps.prepare( - det, - TriggerInfo( - livetime=exposure_time, collections_per_event=num_dark_images - ), - group="prepare-dark", - ) - - yield from bps.wait(group="prepare-dark") - yield from bps.trigger_and_read(list(detectors), name="dark") - - yield from bps.mvr(sample_tower.x1, x_offset) - - if use_shutter: - yield from ensure_shutter_open(photon_shutter, allow_actuation=True) - for det in detectors: - yield from bps.prepare( - det, - TriggerInfo( - livetime=exposure_time, collections_per_event=num_flat_images - ), - group="prepare-flat", - ) - yield from bps.wait(group="prepare-flat") - yield from bps.trigger_and_read(list(detectors), name="flat") + for dset in ("dark", "flat"): + if dset == "flat": + yield from bps.mvr(sample_tower.x1, x_offset) + if use_shutter: + yield from ensure_shutter_open(photon_shutter, allow_actuation=True) + + for det in detectors: + yield from bps.prepare( + det, + TriggerInfo( + livetime=exposure_time, + collections_per_event=num_dark_images + if dset == "dark" + else num_flat_images, + ), + group=f"prepare-{dset}", + ) + yield from bps.wait(group=f"prepare-{dset}") + yield from bps.trigger_and_read(list(detectors), name=dset) def _cleanup(): if use_shutter: yield from ensure_shutter_closed(photon_shutter) + # Before the preview restarts, so it runs at the original exposure. + for det, acquire_time in zip(detectors, initial_acquire_times, strict=True): + yield from bps.mv(det.driver.acquire_time, acquire_time) for det in detectors: if isinstance(det, KinetixDetector): yield from enable_preview_mode(det) @@ -219,6 +158,7 @@ def tomo_flyscan( rot_motor: RotationMotor | None = None, fe_shutter: Shutter | None = None, photon_shutter: Shutter | None = None, + md: dict[str, Any] | None = None, ): """Take a single tomography fly scan. @@ -254,6 +194,8 @@ def tomo_flyscan( The front-end shutter to use. photon_shutter : Shutter | None The photon shutter to use. + md : dict[str, Any] | None + Extra metadata for the run's start document. """ panda = ensure_available(HDFPanda, panda=panda) rot_motor = ensure_available(RotationMotor, rot_motor=rot_motor) @@ -278,6 +220,7 @@ def tomo_flyscan( } if sample_name is not None: _md["sample_name"] = sample_name + _md.update(md or {}) if use_shutter: yield from ensure_shutter_open(fe_shutter) @@ -361,13 +304,17 @@ def _body(): yield from bps.wait(group="arm") try: - yield from _fly_stub( + yield from bps.fly_stub( all_devices, # type: ignore collect_flush_period=max(1, exposure_time + overhead), stream_name="primary", ) except FailedStatus as err: - indices = yield from bps.wait_for([det.get_index for det in detectors]) + # bluesky annotates this as Status, but the RunEngine returns futures. + indices = cast( + list[asyncio.Future[int]], + (yield from bps.wait_for([det.get_index for det in detectors])), + ) missing = { det.name: num_projections - index.result() for det, index in zip(detectors, indices, strict=True) @@ -408,143 +355,6 @@ def _cleanup(): return (yield from bpp.finalize_wrapper(_body(), _cleanup())) -def tomo_flyscan_loop( - detectors: list[KinetixDetector | PhantomDetector], - num_projections: int, - exposure_time: float, - acquire_period: float | None = None, - images_to_average: int = 1, - start: float = 0, - stop: float = 180, - num_iterations: int = 1, - delay_between_tomograms: float = 0.0, - dark_flat_offset: float = 0.0, - num_dark_images: int = 20, - num_flat_images: int = 50, - dark_flat_frequency: int = 0, - dark_flat_exposure_time: float | None = None, - use_shutter: bool = True, - sample_name: str | None = None, - time_based: bool = True, - panda: HDFPanda | None = None, - sample_tower: SampleTower | None = None, - rot_motor: RotationMotor | None = None, - fe_shutter: Shutter | None = None, - photon_shutter: Shutter | None = None, -): - """Take a series of tomography fly scans, with interspersed dark/flat acquisitions. - - Parameters - ---------- - detectors : list[KinetixDetector | PhantomDetector] - The list of detectors to use for the tomography fly scans. - num_projections : int - The number of projections to acquire for each tomography fly scan. - exposure_time : float - The exposure time for each projection. - acquire_period : float | None, optional - The period between successive acquisitions. If None, it will be - determined automatically. - images_to_average : int, optional - The number of images to average for each projection. Default is 1. - start : float, optional - The starting angle for the tomography scan. Default is 0. - stop : float, optional - The stopping angle for the tomography scan. Default is 180. - num_iterations : int, optional - The number of times to repeat the tomography fly scan. Default is 1. - delay_between_tomograms : float, optional - The delay between successive tomography fly scans. Default is 0.0. - dark_flat_offset : float, optional - The offset to apply for dark/flat acquisitions. A value of 0.0 means no - darks/flats. Default is 0.0. - num_dark_images : int, optional - The number of dark images to acquire for each dark/flat acquisition. - Default is 20. - num_flat_images : int, optional - The number of flat images to acquire for each dark/flat acquisition. - Default is 50. - dark_flat_frequency : int, optional - The frequency at which to take dark/flat acquisitions. Default is 0 - (no dark/flats). - dark_flat_exposure_time : float | None, optional - The exposure time for dark/flat acquisitions. If None, it will use the - same exposure time as the projections. - use_shutter : bool, optional - Whether to use the shutter during acquisitions. Default is True. - sample_name : str | None, optional - The name of the sample being scanned. Default is None. - time_based : bool, optional - Whether the fly scan is time-based. Default is True. - panda : HDFPanda | None, optional - The HDFPanda instance to use for data storage. Default is None. - sample_tower : SampleTower | None, optional - The SampleTower instance to use for positioning the sample. Default is None. - rot_motor : RotationMotor | None, optional - The rotation motor to use for the tomography scan. Default is None. - fe_shutter : Shutter | None, optional - The front-end shutter to use during acquisitions. Default is None. - photon_shutter : Shutter | None, optional - The photon shutter to use during acquisitions. Default is None. - - """ - # Flats need the sample moved out of the beam, so a zero offset means no - # darks/flats. - take_dark_flats = dark_flat_offset != 0.0 - - def _dark_flat(): - yield from tomo_dark_flat( - detectors, - exposure_time=dark_flat_exposure_time or exposure_time, - num_dark_images=num_dark_images, - num_flat_images=num_flat_images, - x_offset=dark_flat_offset, - sample_tower=sample_tower, - use_shutter=use_shutter, - fe_shutter=fe_shutter, - photon_shutter=photon_shutter, - ) - - def _body(): - if take_dark_flats: - yield from _dark_flat() - - for i in range(num_iterations): - if i > 0 and delay_between_tomograms > 0: - yield from bps.sleep(delay_between_tomograms) - - yield from tomo_flyscan( - detectors=detectors, - num_projections=num_projections, - exposure_time=exposure_time, - acquire_period=acquire_period, - images_to_average=images_to_average, - start=start, - stop=stop, - use_shutter=use_shutter, - sample_name=sample_name, - time_based=time_based, - panda=panda, - rot_motor=rot_motor, - fe_shutter=fe_shutter, - photon_shutter=photon_shutter, - ) - - # The final dark/flat below covers the last tomogram. - if ( - take_dark_flats - and dark_flat_frequency > 0 - and not i == num_iterations - 1 - and (i + 1) % dark_flat_frequency == 0 - ): - yield from _dark_flat() - - if take_dark_flats: - yield from _dark_flat() - - return (yield from _body()) - - def tomo_nd_step_scan( detectors: list[KinetixDetector | PhantomDetector], *args, @@ -554,6 +364,8 @@ def tomo_nd_step_scan( images_to_average: int = 1, start: float = 0, stop: float = 180, + num_repeats: int = 1, + delay_between_points: float = 0.0, use_shutter: bool = True, alternate_flyscan_dir: bool = False, dark_flat_offset: float = 0.0, @@ -582,7 +394,8 @@ def tomo_nd_step_scan( ``*args`` Step axes patterned like ``(motor1, start1, stop1, num1, motor2, start2, stop2, num2, ...)``, outermost (slowest) axis first, as in - :func:`bluesky.plans.grid_scan`. + :func:`bluesky.plans.grid_scan`. With no axes, the flyscan is repeated + in place. num_projections : int The number of images to acquire at each grid point. exposure_time : float @@ -596,6 +409,10 @@ def tomo_nd_step_scan( The starting angle for the rotation motor. stop : float, optional The stopping angle for the rotation motor. + num_repeats : int, optional + The number of times to run the whole trajectory. + delay_between_points : float, optional + Seconds to wait between consecutive flyscans. use_shutter : bool, optional Whether to use the shutters during the scan. alternate_flyscan_dir : bool, optional @@ -617,11 +434,8 @@ def tomo_nd_step_scan( Whether the scan is time-based. snake_axes : bool | Sequence[Steppable], optional If True, snake every axis but the outermost; otherwise, the motors to snake. - retry : bool, optional - Whether to retry a grid point's flyscan if it fails. num_retries : int, optional - Maximum retries per grid point when ``retry`` is True; 0 retries until - the flyscan succeeds. + How many times to retry a grid point's flyscan if it fails; 0 never retries. panda : HDFPanda | None, optional The HDFPanda instance for data storage. rot_motor : RotationMotor | None, optional @@ -633,22 +447,37 @@ def tomo_nd_step_scan( sample_tower : SampleTower | None, optional The SampleTower instance to use for positioning the sample. Default is None. """ - if not args or len(args) % 4: + # Ensure the number of retries is non-negative. + if num_retries < 0: + num_retries = 0 + + # Extract the step axes from the positional arguments. + if len(args) % 4: raise ValueError("Step axes must be given as (motor, start, stop, num) groups.") axes = [args[i : i + 4] for i in range(0, len(args), 4)] motors = [axis[0] for axis in axes] + # Determine which axes are snaked based on the snake_axes argument. if isinstance(snake_axes, bool): snaked = motors[1:] if snake_axes else [] else: snaked = list(snake_axes) - if motors[0] in snaked: + + # Make sure we didn't ask to snake the outermost axis. + if motors and motors[0] in snaked: raise ValueError("The outermost axis cannot be snaked.") - pattern_args: list[Any] = [*axes[0]] - for axis in axes[1:]: - pattern_args += [*axis, axis[0] in snaked] - trajectory = plan_patterns.outer_product(pattern_args) + # Construct the grid of positions for the scan. + if axes: + pattern_args: list[Any] = [*axes[0]] + for axis in axes[1:]: + pattern_args += [*axis, axis[0] in snaked] + grid = plan_patterns.outer_product(pattern_args) + else: + grid = [{}] # When no axes are specified, create a single-point grid. + + # Repeat the trajectory the specified number of times. + trajectory = grid * num_repeats @bpp.reset_positions_decorator(motors) def _body(): @@ -656,7 +485,9 @@ def _body(): # darks/flats. take_dark_flats = dark_flat_offset != 0 + # Define a helper function to acquire dark and flat images. def _dark_flat(): + yield from bps.checkpoint() yield from tomo_dark_flat( detectors=detectors, exposure_time=dark_flat_exposure_time or exposure_time, @@ -674,15 +505,28 @@ def _dark_flat(): num_points = len(trajectory) pos_cache: defaultdict[Any, Any] = defaultdict(lambda: None) + previous_uid: str | None = None for i, point in enumerate(trajectory): + # A deferred pause stops here; resuming starts this point afresh. + yield from bps.checkpoint() + if i > 0 and delay_between_points > 0: + yield from bps.sleep(delay_between_points) + # Only moves the axes whose position changed since the last point. yield from bps.move_per_step(point, pos_cache) reverse = alternate_flyscan_dir and i % 2 == 1 - attempt = 0 - while True: + md: dict[str, Any] = { + "grid_index": i, + "num_grid_points": num_points, + "grid_positions": {motor.name: pos for motor, pos in point.items()}, + } + if previous_uid is not None: + md["previous_grid_point_uid"] = previous_uid + + for attempt in range(1, num_retries + 2): try: - yield from tomo_flyscan( + previous_uid = yield from tomo_flyscan( detectors=detectors, num_projections=num_projections, exposure_time=exposure_time, @@ -697,25 +541,24 @@ def _dark_flat(): rot_motor=rot_motor, fe_shutter=fe_shutter, photon_shutter=photon_shutter, + md=md, ) break - except RunEngineControlException: - # Never retry a user's abort or stop request. + except (RunEngineControlException, PlanHalt): + # Never retry a user's abort, stop or halt request. raise except Exception as err: - attempt += 1 - if num_retries > 0 and attempt <= num_retries: - logger.error( - f"Flyscan at grid point {i} failed ({err}), retrying " - f"(attempt {attempt}/{num_retries})." - ) - else: + if attempt > num_retries: raise RuntimeError( - f"Flyscan at grid point {i} failed after {attempt} " + f"Flyscan at point {i} failed after {attempt} " f"attempts: {err}" ) from err + logger.error( + f"Flyscan at point {i} failed ({err}), retrying " + f"(attempt {attempt}/{num_retries})." + ) - # The final dark/flat below covers the last grid point. + # The final dark/flat below covers the last point. is_last = i == num_points - 1 if ( take_dark_flats @@ -731,6 +574,114 @@ def _dark_flat(): return (yield from _body()) +def tomo_flyscan_loop( + detectors: list[KinetixDetector | PhantomDetector], + num_projections: int, + exposure_time: float, + acquire_period: float | None = None, + images_to_average: int = 1, + start: float = 0, + stop: float = 180, + num_iterations: int = 1, + delay_between_tomograms: float = 0.0, + dark_flat_offset: float = 0.0, + num_dark_images: int = 20, + num_flat_images: int = 50, + dark_flat_frequency: int = 0, + dark_flat_exposure_time: float | None = None, + use_shutter: bool = True, + sample_name: str | None = None, + time_based: bool = True, + panda: HDFPanda | None = None, + sample_tower: SampleTower | None = None, + rot_motor: RotationMotor | None = None, + fe_shutter: Shutter | None = None, + photon_shutter: Shutter | None = None, +): + """Take a series of tomography fly scans, with interspersed dark/flat acquisitions. + + Parameters + ---------- + detectors : list[KinetixDetector | PhantomDetector] + The list of detectors to use for the tomography fly scans. + num_projections : int + The number of projections to acquire for each tomography fly scan. + exposure_time : float + The exposure time for each projection. + acquire_period : float | None, optional + The period between successive acquisitions. If None, it will be + determined automatically. + images_to_average : int, optional + The number of images to average for each projection. Default is 1. + start : float, optional + The starting angle for the tomography scan. Default is 0. + stop : float, optional + The stopping angle for the tomography scan. Default is 180. + num_iterations : int, optional + The number of times to repeat the tomography fly scan. Default is 1. + delay_between_tomograms : float, optional + The delay between successive tomography fly scans. Default is 0.0. + dark_flat_offset : float, optional + The offset to apply for dark/flat acquisitions. A value of 0.0 means no + darks/flats. Default is 0.0. + num_dark_images : int, optional + The number of dark images to acquire for each dark/flat acquisition. + Default is 20. + num_flat_images : int, optional + The number of flat images to acquire for each dark/flat acquisition. + Default is 50. + dark_flat_frequency : int, optional + The frequency at which to take dark/flat acquisitions. Default is 0 + (no dark/flats). + dark_flat_exposure_time : float | None, optional + The exposure time for dark/flat acquisitions. If None, it will use the + same exposure time as the projections. + use_shutter : bool, optional + Whether to use the shutter during acquisitions. Default is True. + sample_name : str | None, optional + The name of the sample being scanned. Default is None. + time_based : bool, optional + Whether the fly scan is time-based. Default is True. + panda : HDFPanda | None, optional + The HDFPanda instance to use for data storage. Default is None. + sample_tower : SampleTower | None, optional + The SampleTower instance to use for positioning the sample. Default is None. + rot_motor : RotationMotor | None, optional + The rotation motor to use for the tomography scan. Default is None. + fe_shutter : Shutter | None, optional + The front-end shutter to use during acquisitions. Default is None. + photon_shutter : Shutter | None, optional + The photon shutter to use during acquisitions. Default is None. + + """ + return ( + yield from tomo_nd_step_scan( + detectors, + num_projections=num_projections, + exposure_time=exposure_time, + acquire_period=acquire_period, + images_to_average=images_to_average, + start=start, + stop=stop, + num_repeats=num_iterations, + delay_between_points=delay_between_tomograms, + dark_flat_offset=dark_flat_offset, + dark_flat_exposure_time=dark_flat_exposure_time, + num_dark_images=num_dark_images, + num_flat_images=num_flat_images, + dark_flat_frequency=dark_flat_frequency, + use_shutter=use_shutter, + sample_name=sample_name, + time_based=time_based, + panda=panda, + sample_tower=sample_tower, + rot_motor=rot_motor, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ) + ) + + def tomo_1d_step_scan( detectors: list[KinetixDetector | PhantomDetector], step_motor: Steppable, diff --git a/tests/tomography/test_alignment.py b/tests/tomography/test_alignment.py index 89d259a..31bbeb5 100644 --- a/tests/tomography/test_alignment.py +++ b/tests/tomography/test_alignment.py @@ -7,6 +7,7 @@ import pytest from bluesky import Msg, RunEngine from bluesky import plan_stubs as bps +from bluesky import preprocessors as bpp from bluesky.run_engine import RunEngineResult from ophyd_async.core import ( StaticPathProvider, @@ -428,15 +429,19 @@ def cache_docs(name: str, doc: dict[str, Any]): RE.msg_hook = messages.append # type: ignore runs: RunEngineResult = RE( - tomo_alignment_scan( - ktx1, - exposure_time, - num_projections=num_projections, - init_angle=init_angle, - stop_angle=stop_angle, - fe_shutter=fe_shutter, - photon_shutter=photon_shutter, - rot_motor=rotation_motor, + # the shutter settle sleeps are only for hardware + bpp.msg_mutator( + tomo_alignment_scan( + ktx1, + exposure_time, + num_projections=num_projections, + init_angle=init_angle, + stop_angle=stop_angle, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + rot_motor=rotation_motor, + ), + lambda msg: msg._replace(args=(0,)) if msg.command == "sleep" else msg, ), cache_docs, # type: ignore ) # type: ignore @@ -469,3 +474,11 @@ def cache_docs(name: str, doc: dict[str, Any]): assert await rotation_motor.velocity.get_value() == initial_velocity assert await rotation_motor.user_readback.get_value() == initial_position assert await ktx1.driver.acquire_time.get_value() == initial_acquire_time + # The stage returns at full speed, and only then slows back down. + close_run = next(i for i, msg in enumerate(messages) if msg.command == "close_run") + restores = [ + msg.obj + for msg in messages[close_run:] + if msg.command == "set" and msg.obj in (rotation_motor, rotation_motor.velocity) + ] + assert restores == [rotation_motor, rotation_motor.velocity] diff --git a/tests/tomography/test_flyscans.py b/tests/tomography/test_flyscans.py index 9f7369f..aacaab8 100644 --- a/tests/tomography/test_flyscans.py +++ b/tests/tomography/test_flyscans.py @@ -1,25 +1,297 @@ -from collections.abc import Callable +from collections.abc import Callable, Generator +from pathlib import Path +from typing import Any +import h5py +import numpy as np import pytest -from bluesky import RunEngine +from bluesky import Msg, RunEngine from bluesky import plan_stubs as bps +from bluesky import preprocessors as bpp +from bluesky.protocols import Collectable +from bluesky.utils import ( + FailedStatus, + PlanHalt, + RequestAbort, + RunEngineInterrupted, +) +from bluesky_tiled_plugins import TiledWriter from ophyd_async.core import ( Device, DeviceVector, StaticPathProvider, + UUIDFilenameProvider, callback_on_mock_put, get_mock_put, init_devices, set_mock_value, ) -from ophyd_async.epics.adkinetix import KinetixDetector +from ophyd_async.epics.adcore import NDPluginFileIO +from ophyd_async.epics.adkinetix import KinetixDetector, KinetixTriggerMode from ophyd_async.epics.core import epics_signal_r, epics_signal_rw -from ophyd_async.fastcs.panda import DatasetTable, HDFPanda, PandaHdf5DatasetType +from ophyd_async.epics.motor import Motor as AsyncEpicsMotor +from ophyd_async.fastcs.panda import ( + DatasetTable, + HDFPanda, + PandaHdf5DatasetType, + PandaPcompDirection, +) +from tiled.client.container import Container -from hextools.motors import RotationMotor -from hextools.photon_delivery_system import Shutter +from hextools.detectors import FRAME_PERIOD_MARGIN +from hextools.motors import RotationMotor, SampleTower +from hextools.photon_delivery_system import Shutter, ShutterStatus +from hextools.specs import TOMO_DARK_FLAT_V1, TOMO_FLYSCAN_V1 from hextools.tomography import flyscans -from hextools.tomography.flyscans import tomo_flyscan +from hextools.tomography.flyscans import ( + MAX_MISSING_FRAMES, + tomo_1d_step_scan, + tomo_2d_step_scan, + tomo_dark_flat, + tomo_flyscan, + tomo_flyscan_loop, + tomo_nd_step_scan, +) + +Docs = dict[str, list[dict[str, Any]]] + + +def _doc_cache() -> tuple[Docs, Callable[[str, dict[str, Any]], None]]: + docs: Docs = {} + + def cache(name: str, doc: dict[str, Any]): + docs.setdefault(name, []).append(doc) + + return docs, cache + + +def _no_sleeps(plan: Generator[Msg, Any, Any]): + """Drop the shutter settling and preview sleeps, which are only for hardware.""" + return ( + yield from bpp.msg_mutator( + plan, lambda msg: msg._replace(args=(0,)) if msg.command == "sleep" else msg + ) + ) + + +def _mock_motor(motor: AsyncEpicsMotor) -> AsyncEpicsMotor: + set_mock_value(motor.velocity, 10000) + set_mock_value(motor.max_velocity, 10000) + set_mock_value(motor.acceleration_time, 0.0001) + callback_on_mock_put( + motor.user_setpoint, lambda v: set_mock_value(motor.user_readback, v) + ) + return motor + + +@pytest.fixture +def static_path_provider(tmp_path: Path) -> StaticPathProvider: + # tiled_client only serves files under tmp_path / "data" + return StaticPathProvider(UUIDFilenameProvider(), tmp_path / "data") + + +@pytest.fixture +def sample_tower() -> SampleTower: + with init_devices(mock=True): + tower = SampleTower("TWR:") + _mock_motor(tower.x1) + return tower + + +@pytest.fixture +def step_motors() -> tuple[AsyncEpicsMotor, AsyncEpicsMotor]: + with init_devices(mock=True): + outer = AsyncEpicsMotor("OUTER:") + inner = AsyncEpicsMotor("INNER:") + return _mock_motor(outer), _mock_motor(inner) + + +@pytest.fixture +def open_fe_shutter(two_shutters: tuple[Shutter, Shutter]) -> tuple[Shutter, Shutter]: + fe_shutter, photon_shutter = two_shutters + set_mock_value(fe_shutter.status, ShutterStatus.OPEN) + return fe_shutter, photon_shutter + + +# ================================================================================== +# tomo_dark_flat +# ================================================================================== + + +def _shutter_and_x_at_each_trigger( + det: KinetixDetector, photon_shutter: Shutter, sample_tower: SampleTower +) -> list[tuple[ShutterStatus, float]]: + """Record the photon shutter state and sample x each time the camera acquires.""" + seen: list[tuple[ShutterStatus, float]] = [] + file_plugin = det.get_plugin("hdf", NDPluginFileIO) + + async def _acquire(value): + if not value: + return + seen.append( + ( + await photon_shutter.status.get_value(), + await sample_tower.x1.user_setpoint.get_value(), + ) + ) + if await det.driver.trigger_mode.get_value() != KinetixTriggerMode.INTERNAL: + return + frames = max(1, await det.driver.num_images.get_value()) + captured = await file_plugin.num_captured.get_value() + set_mock_value(file_plugin.num_captured, captured + frames) + + callback_on_mock_put(det.driver.acquire, _acquire) + return seen + + +async def test_tomo_dark_flat( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + open_fe_shutter: tuple[Shutter, Shutter], + sample_tower: SampleTower, +): + fe_shutter, photon_shutter = open_fe_shutter + ktx = kinetix_det_factory(1) + exposure_time, num_dark, num_flat, x_offset = 0.05, 4, 7, 2.5 + set_mock_value(sample_tower.x1.user_readback, 1.0) + await sample_tower.x1.user_setpoint.set(1.0) + await ktx.driver.acquire_time.set(0.3) + seen = _shutter_and_x_at_each_trigger(ktx, photon_shutter, sample_tower) + docs, cache = _doc_cache() + + RE( + _no_sleeps( + tomo_dark_flat( + [ktx], + exposure_time, + num_dark_images=num_dark, + num_flat_images=num_flat, + x_offset=x_offset, + sample_tower=sample_tower, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ) + ), + cache, # type: ignore + ) + + assert len(docs["start"]) == 1 + start = docs["start"][0] + assert start["plan_name"] == "tomo_dark_flat" + assert start["detectors"] == ["kinetix1"] + assert start["num_dark_images"] == num_dark + assert start["num_flat_images"] == num_flat + assert start["x_offset"] == x_offset + assert start["tiled_specs"] == [ + {"name": TOMO_DARK_FLAT_V1.name, "version": TOMO_DARK_FLAT_V1.version} + ] + assert [d["name"] for d in docs["descriptor"]] == ["dark", "flat"] + assert len(docs["event"]) == 2 + assert docs["stop"][0]["exit_status"] == "success" + + # Darks with the photon shutter closed at the sample position; flats with it + # open and the sample moved out. The trailing entry is the preview restart. + assert seen[:2] == [ + (ShutterStatus.CLOSED, pytest.approx(1.0)), + (ShutterStatus.OPEN, pytest.approx(1.0 + x_offset)), + ] + assert get_mock_put(ktx.driver.num_images).call_args_list[0].args[0] == num_dark + + # Positions restored, shutter closed, camera back in preview. + assert await sample_tower.x1.user_setpoint.get_value() == pytest.approx(1.0) + assert await photon_shutter.status.get_value() == ShutterStatus.CLOSED + assert await ktx.driver.trigger_mode.get_value() == KinetixTriggerMode.INTERNAL + + +async def test_tomo_dark_flat_restores_acquire_time( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + open_fe_shutter: tuple[Shutter, Shutter], + sample_tower: SampleTower, +): + fe_shutter, photon_shutter = open_fe_shutter + ktx = kinetix_det_factory(1) + await ktx.driver.acquire_time.set(0.3) + + RE( + _no_sleeps( + tomo_dark_flat( + [ktx], + 0.05, + num_dark_images=2, + num_flat_images=2, + sample_tower=sample_tower, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ) + ) + ) + + assert await ktx.driver.acquire_time.get_value() == pytest.approx(0.3) + + +async def test_tomo_dark_flat_without_shutter_never_opens_it( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + two_shutters: tuple[Shutter, Shutter], + sample_tower: SampleTower, +): + # The front-end shutter is only checked when the shutter is in use. + fe_shutter, photon_shutter = two_shutters + ktx = kinetix_det_factory(1) + seen = _shutter_and_x_at_each_trigger(ktx, photon_shutter, sample_tower) + docs, cache = _doc_cache() + + RE( + _no_sleeps( + tomo_dark_flat( + [ktx], + 0.05, + num_dark_images=2, + num_flat_images=3, + x_offset=1.0, + use_shutter=False, + sample_tower=sample_tower, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ) + ), + cache, # type: ignore + ) + + assert docs["stop"][0]["exit_status"] == "success" + assert seen + assert all(status == ShutterStatus.CLOSED for status, _ in seen) + + +def test_tomo_dark_flat_fails_if_fe_shutter_closed( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + two_shutters: tuple[Shutter, Shutter], + sample_tower: SampleTower, +): + fe_shutter, photon_shutter = two_shutters + ktx = kinetix_det_factory(1) + docs, cache = _doc_cache() + + with pytest.raises(RuntimeError, match=f"Shutter {fe_shutter.name}"): + RE( + tomo_dark_flat( + [ktx], + 0.05, + sample_tower=sample_tower, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ), + cache, # type: ignore + ) + assert "start" not in docs + assert not get_mock_put(ktx.driver.acquire).called + + +# ================================================================================== +# tomo_flyscan +# ================================================================================== class _Bits(Device): @@ -31,7 +303,7 @@ def __init__(self, prefix: str, name: str = ""): class _Calc(Device): def __init__(self, prefix: str, name: str = ""): - self.out = epics_signal_r(float, f"{prefix}OUT") + self.out = epics_signal_r(int, f"{prefix}OUT") self.out_dataset = epics_signal_rw(str, f"{prefix}OUT:DATASET") self.out_units = epics_signal_rw(str, f"{prefix}OUT:UNITS") self.out_scale = epics_signal_rw(float, f"{prefix}OUT:SCALE") @@ -39,8 +311,8 @@ def __init__(self, prefix: str, name: str = ""): super().__init__(name=name) -class _HexPanda(HDFPanda): - """The blocks tomo_flyscan uses that the real PandA discovers from its IOC.""" +class _FlyPanda(HDFPanda): + """HDFPanda with the blocks the real one discovers from the IOC.""" def __init__(self, prefix: str, path_provider, name: str = ""): self.bits = _Bits(f"{prefix}BITS:") @@ -48,64 +320,1307 @@ def __init__(self, prefix: str, path_provider, name: str = ""): super().__init__(prefix, path_provider, name=name) +class FlyRig: + """Mock PandA, rotation stage and cameras wired together for a flyscan. + + Enabling BITS.A starts PCOMP; the rotation stage's fly move then writes + ``frames_per_move`` frames on every camera and the PandA, and ends PCOMP. + With ``write_files``, each move also writes the HDF5 files the real devices + would, recording what it wrote in ``written``. + """ + + # neither the max velocity nor any fly speed, so a missed restore shows + INITIAL_VELOCITY = 7.0 + + def __init__( + self, + static_path_provider: StaticPathProvider, + cameras: list[KinetixDetector], + encoder_counts: int = 1000, + ): + # init_devices only connects devices bound to local names + with init_devices(mock=True): + panda = _FlyPanda("PANDA:", static_path_provider, name="panda1") + rot_motor = RotationMotor("ROT:", name="rot") + self.panda, self.rot_motor = panda, rot_motor + self.cameras = cameras + self.frames_per_move = 0 + self.photon_shutter: Shutter | None = None + self.shutter_during_move: ShutterStatus | None = None + self.write_files = False + self.written: list[dict[str, np.ndarray]] = [] + + set_mock_value(panda.data.directory_exists, True) + set_mock_value( + panda.data.datasets, + DatasetTable( + name=["rotation_angle"], dtype=[PandaHdf5DatasetType.FLOAT_64] + ), + ) + set_mock_value(panda.calc[1].out, encoder_counts) + callback_on_mock_put( + panda.pcap.arm, lambda v: set_mock_value(panda.pcap.active, v) + ) + callback_on_mock_put( + panda.data.capture, lambda _: set_mock_value(panda.data.num_captured, 0) + ) + callback_on_mock_put( + panda.bits.a, lambda v: set_mock_value(panda.pcomp[1].active, bool(v)) + ) + + rot = _mock_motor(self.rot_motor) + set_mock_value(rot.velocity, self.INITIAL_VELOCITY) + set_mock_value(rot.encoder_resolution, 0.01) + callback_on_mock_put(rot.user_setpoint, self._move) + + async def _move(self, value: float): + start = await self.rot_motor.user_readback.get_value() + set_mock_value(self.rot_motor.user_readback, value) + if not await self.panda.pcomp[1].active.get_value(): + return # the run-up move, before PCOMP is enabled + if self.photon_shutter is not None: + self.shutter_during_move = await self.photon_shutter.status.get_value() + if self.write_files: + await self._write_files(start, value) + for cam in self.cameras: + hdf = cam.get_plugin("hdf", NDPluginFileIO) + captured = await hdf.num_captured.get_value() + set_mock_value(hdf.num_captured, captured + self.frames_per_move) + captured = await self.panda.data.num_captured.get_value() + set_mock_value(self.panda.data.num_captured, captured + self.frames_per_move) + set_mock_value(self.panda.pcomp[1].active, False) + + async def _write_files(self, start: float, stop: float): + n = self.frames_per_move + written: dict[str, np.ndarray] = {} + for cam in self.cameras: + hdf = cam.get_plugin("hdf", NDPluginFileIO) + y = await cam.driver.array_size_y.get_value() + x = await cam.driver.array_size_x.get_value() + # distinct frames per move, so a mixed-up run shows in the data + offset = len(self.written) * n * y * x + images = (np.arange(n * y * x) + offset).reshape(n, y, x).astype(np.uint16) + path = Path(await hdf.file_path.get_value()) + with h5py.File(path / f"{await hdf.file_name.get_value()}.h5", "w") as f: + f.create_dataset("/entry/data/data", data=images) + written[cam.name] = images + + angles = np.linspace(start, stop, n) + data = self.panda.data + path = Path(await data.hdf_directory.get_value()) + with h5py.File(path / await data.hdf_file_name.get_value(), "w") as f: + f.create_dataset("/rotation_angle", data=angles) + written["rotation_angle"] = angles + self.written.append(written) + + def flyscan(self, detectors: list[Any], shutters, **kwargs): + fe_shutter, photon_shutter = shutters + self.photon_shutter = photon_shutter + self.frames_per_move = kwargs["num_projections"] + return _no_sleeps( + tomo_flyscan( + detectors, + panda=self.panda, # type: ignore + rot_motor=self.rot_motor, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + **kwargs, + ) + ) + + @pytest.fixture -def flyscan_devices( - static_path_provider: StaticPathProvider, -) -> tuple[_HexPanda, RotationMotor]: - with init_devices(mock=True): - panda = _HexPanda("PANDA:", static_path_provider, name="panda1") - rot_motor = RotationMotor("ROT:", name="rot") - set_mock_value(panda.data.directory_exists, True) - set_mock_value( - panda.data.datasets, - DatasetTable(name=["Angle"], dtype=[PandaHdf5DatasetType.FLOAT_64]), +def fly_rig(static_path_provider: StaticPathProvider) -> Callable[..., FlyRig]: + def _factory(cameras: list[KinetixDetector]) -> FlyRig: + return FlyRig(static_path_provider, cameras) + + return _factory + + +@pytest.mark.parametrize("num_cameras", [1, 2]) +async def test_tomo_flyscan( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], + open_fe_shutter: tuple[Shutter, Shutter], + num_cameras: int, +): + cameras = [kinetix_det_factory(i + 1) for i in range(num_cameras)] + rig = fly_rig(cameras) + num_projections, exposure_time = 11, 0.01 + docs, cache = _doc_cache() + + RE( + rig.flyscan( + cameras, + open_fe_shutter, + num_projections=num_projections, + exposure_time=exposure_time, + start=0, + stop=180, + sample_name="rock", + ), + cache, # type: ignore + ) + + assert len(docs["start"]) == 1 + start = docs["start"][0] + assert start["plan_name"] == "tomo_flyscan" + assert start["sample_name"] == "rock" + assert start["detectors"] == [cam.name for cam in cameras] + assert start["num_points"] == num_projections + assert (start["start_position"], start["stop_position"]) == (0, 180) + assert start["tiled_specs"] == [ + {"name": TOMO_FLYSCAN_V1.name, "version": TOMO_FLYSCAN_V1.version} + ] + assert docs["stop"][0]["exit_status"] == "success" + assert [d["name"] for d in docs["descriptor"]] == ["primary"] + + # Every camera and the PandA's angle land in primary, covering every projection. + data_keys = {d["data_key"] for d in docs["stream_resource"]} + assert {cam.name for cam in cameras} <= data_keys + assert "rotation_angle" in data_keys + for resource in docs["stream_resource"]: + datums = [ + d for d in docs["stream_datum"] if d["stream_resource"] == resource["uid"] + ] + assert sum(d["indices"]["stop"] - d["indices"]["start"] for d in datums) == ( + num_projections + ) + + # Cameras armed for external edges, with plugin queues big enough for the run. + for cam in cameras: + modes = [ + c.args[0] for c in get_mock_put(cam.driver.trigger_mode).call_args_list + ] + assert any(mode != KinetixTriggerMode.INTERNAL for mode in modes) + hdf = cam.get_plugin("hdf", NDPluginFileIO) + assert await hdf.queue_size.get_value() == num_projections + # cleanup puts the camera back into preview + assert await cam.driver.trigger_mode.get_value() == KinetixTriggerMode.INTERNAL + + # Time based: PCOMP fires once and PULSE paces the projections. + pcomp, pulse = rig.panda.pcomp[1], rig.panda.pulse[1] + assert await pcomp.dir.get_value() == PandaPcompDirection.POSITIVE + assert await pcomp.pulses.get_value() == 1 + assert await pulse.pulses.get_value() == num_projections + assert await pulse.width.get_value() == pytest.approx(exposure_time) + move_time = num_projections * (exposure_time + FRAME_PERIOD_MARGIN) + assert await pulse.step.get_value() == pytest.approx( + move_time / (num_projections - 1) + ) + + _, photon_shutter = open_fe_shutter + assert rig.shutter_during_move == ShutterStatus.OPEN + assert await photon_shutter.status.get_value() == ShutterStatus.CLOSED + assert get_mock_put(rig.rot_motor.motor_stop).call_args.args[0] == 1 + assert await rig.rot_motor.velocity.get_value() == FlyRig.INITIAL_VELOCITY + + +async def test_tomo_flyscan_arms_only_after_run_up( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], + open_fe_shutter: tuple[Shutter, Shutter], +): + ktx = kinetix_det_factory(1) + rig = fly_rig([ktx]) + messages: list[Msg] = [] + RE.msg_hook = messages.append # type: ignore + + RE(rig.flyscan([ktx], open_fe_shutter, num_projections=5, exposure_time=0.01)) + + prepared = [msg.obj for msg in messages if msg.command == "prepare"] + assert prepared[0] is rig.rot_motor + assert set(prepared[1:]) >= {ktx, rig.panda} + wait_for_run_up = next( + i + for i, msg in enumerate(messages) + if msg.command == "wait" and msg.kwargs.get("group") == "prepare" + ) + first_arm = next( + i + for i, msg in enumerate(messages) + if msg.command == "prepare" and msg.kwargs.get("group") == "arm" + ) + assert wait_for_run_up < first_arm + + +async def test_tomo_flyscan_reverse_direction( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], + open_fe_shutter: tuple[Shutter, Shutter], +): + ktx = kinetix_det_factory(1) + rig = fly_rig([ktx]) + + RE( + rig.flyscan( + [ktx], + open_fe_shutter, + num_projections=5, + exposure_time=0.01, + start=180, + stop=0, + ) + ) + + assert await rig.panda.pcomp[1].dir.get_value() == PandaPcompDirection.NEGATIVE + + +async def test_tomo_flyscan_position_based( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], + open_fe_shutter: tuple[Shutter, Shutter], +): + ktx = kinetix_det_factory(1) + rig = fly_rig([ktx]) + num_projections = 11 + + RE( + rig.flyscan( + [ktx], + open_fe_shutter, + num_projections=num_projections, + exposure_time=0.01, + start=0, + stop=10, + time_based=False, + ) + ) + + # 10 deg at 0.01 deg/count over 10 steps: PCOMP paces 100 counts apart. + pcomp = rig.panda.pcomp[1] + assert await pcomp.pulses.get_value() == num_projections + assert await pcomp.step.get_value() == 100 + assert await rig.panda.pulse[1].pulses.get_value() == 1 + + +async def test_tomo_flyscan_acquire_period_sets_deadtime( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], + open_fe_shutter: tuple[Shutter, Shutter], +): + ktx = kinetix_det_factory(1) + rig = fly_rig([ktx]) + num_projections, exposure_time, acquire_period = 5, 0.01, 0.05 + docs, cache = _doc_cache() + + RE( + rig.flyscan( + [ktx], + open_fe_shutter, + num_projections=num_projections, + exposure_time=exposure_time, + acquire_period=acquire_period, + ), + cache, # type: ignore + ) + + assert docs["start"][0]["acquire_period"] == acquire_period + move_time = num_projections * acquire_period + assert await rig.panda.pulse[1].step.get_value() == pytest.approx( + move_time / (num_projections - 1) ) - callback_on_mock_put(panda.pcap.arm, lambda v: set_mock_value(panda.pcap.active, v)) - set_mock_value(rot_motor.encoder_resolution, 0.001) - return panda, rot_motor -@pytest.mark.parametrize("fly_fails", [False, True]) -async def test_tomo_flyscan_restores_rotation_velocity( +async def test_tomo_flyscan_without_shutter( RE: RunEngine, - flyscan_devices: tuple[_HexPanda, RotationMotor], kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], two_shutters: tuple[Shutter, Shutter], +): + # A closed front end is fine when the shutter is not used. + ktx = kinetix_det_factory(1) + rig = fly_rig([ktx]) + docs, cache = _doc_cache() + + RE( + rig.flyscan( + [ktx], + two_shutters, + num_projections=5, + exposure_time=0.01, + use_shutter=False, + ), + cache, # type: ignore + ) + + assert docs["stop"][0]["exit_status"] == "success" + assert rig.shutter_during_move == ShutterStatus.CLOSED + + +def test_tomo_flyscan_fails_if_fe_shutter_closed( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], + two_shutters: tuple[Shutter, Shutter], +): + ktx = kinetix_det_factory(1) + rig = fly_rig([ktx]) + docs, cache = _doc_cache() + + with pytest.raises(RuntimeError, match="not in the desired state"): + RE( + rig.flyscan([ktx], two_shutters, num_projections=5, exposure_time=0.01), + cache, # type: ignore + ) + assert "start" not in docs + + +# --- Dropped frames --------------------------------------------------------------- + + +def _failing_fly_stub(rig: FlyRig, frames_written: dict[str, int]): + """A fly_stub whose devices wrote some frames before a status failed.""" + + def _fly_stub(flyers, *, collect_flush_period=None, stream_name="", watch=()): + dets = [f for f in flyers if isinstance(f, Collectable)] + yield from bps.declare_stream(*dets, name=stream_name) # type: ignore + for cam in rig.cameras: + hdf = cam.get_plugin("hdf", NDPluginFileIO) + set_mock_value(hdf.num_captured, frames_written[cam.name]) + set_mock_value(rig.panda.data.num_captured, max(frames_written.values())) + raise FailedStatus("a device timed out") + + return _fly_stub + + +@pytest.mark.parametrize("missed", [1, MAX_MISSING_FRAMES]) +async def test_tomo_flyscan_tolerates_a_few_missed_frames( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], + open_fe_shutter: tuple[Shutter, Shutter], monkeypatch: pytest.MonkeyPatch, - fly_fails: bool, + caplog: pytest.LogCaptureFixture, + missed: int, ): - # The motor's fly prepare leaves VELO at the fly speed; the plan must put back - # the operator's speed however it ends (Nghia Vo, 2026-10-23). - panda, rot_motor = flyscan_devices - fe_shutter, photon_shutter = two_shutters - await rot_motor.velocity.set(5.0) + num_projections = 50 + cameras = [kinetix_det_factory(1), kinetix_det_factory(2)] + rig = fly_rig(cameras) + written = {"kinetix1": num_projections - missed, "kinetix2": num_projections} + monkeypatch.setattr(flyscans.bps, "fly_stub", _failing_fly_stub(rig, written)) + docs, cache = _doc_cache() + + RE( + rig.flyscan( + cameras, + open_fe_shutter, + num_projections=num_projections, + exposure_time=0.01, + ), + cache, # type: ignore + ) + + assert docs["stop"][0]["exit_status"] == "success" + # Events stay aligned, so only the frames every camera wrote are recorded. + for cam in cameras: + datums = [ + d + for d in docs["stream_datum"] + for r in docs["stream_resource"] + if r["data_key"] == cam.name and d["stream_resource"] == r["uid"] + ] + assert sum(d["indices"]["stop"] - d["indices"]["start"] for d in datums) == ( + num_projections - missed + ) + assert f"Detector kinetix1 missed {missed} frame(s)" in caplog.text + assert "Detector kinetix2 missed 0 frame(s)" in caplog.text + + +@pytest.mark.parametrize( + ("missed", "error", "match"), + [ + # Nothing dropped: the failure is elsewhere, so it propagates as is. + (0, FailedStatus, "timed out"), + (MAX_MISSING_FRAMES + 1, RuntimeError, "missed up to 11 frame"), + # Losing half the scan points to a bigger problem than dropped frames. + (25, FailedStatus, "timed out"), + (50, FailedStatus, "timed out"), + ], +) +async def test_tomo_flyscan_fails_on_too_many_missed_frames( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], + open_fe_shutter: tuple[Shutter, Shutter], + monkeypatch: pytest.MonkeyPatch, + missed: int, + error: type[Exception], + match: str, +): + num_projections = 50 + ktx = kinetix_det_factory(1) + rig = fly_rig([ktx]) + written = {"kinetix1": num_projections - missed} + monkeypatch.setattr(flyscans.bps, "fly_stub", _failing_fly_stub(rig, written)) + docs, cache = _doc_cache() + + with pytest.raises(error, match=match): + RE( + rig.flyscan( + [ktx], + open_fe_shutter, + num_projections=num_projections, + exposure_time=0.01, + ), + cache, # type: ignore + ) - def _fly(*_args, **_kwargs): - if fly_fails: - raise RuntimeError("fly failed") + assert docs["stop"][0]["exit_status"] == "fail" + # Cleanup still runs. + _, photon_shutter = open_fe_shutter + assert await photon_shutter.status.get_value() == ShutterStatus.CLOSED + assert get_mock_put(rig.rot_motor.motor_stop).called + assert await rig.rot_motor.velocity.get_value() == FlyRig.INITIAL_VELOCITY + assert await ktx.driver.trigger_mode.get_value() == KinetixTriggerMode.INTERNAL + + +# ================================================================================== +# Orchestration: tomo_flyscan_loop and the step scans +# ================================================================================== + + +class Recorder: + """Stands in for tomo_flyscan and tomo_dark_flat, recording each call.""" + + def __init__(self, motors: tuple[AsyncEpicsMotor, ...] = ()): + self.motors = motors + self.calls: list[tuple[str, dict[str, Any]]] = [] + self.failures: list[BaseException] = [] + self.pause_after_call: int | None = None + + def flyscan(self, **kwargs): + positions = [] + for motor in self.motors: + positions.append((yield from bps.rd(motor))) + kwargs["positions"] = tuple(positions) + self.calls.append(("flyscan", kwargs)) + if self.failures: + raise self.failures.pop(0) + if len(self.calls) - 1 == self.pause_after_call: + # what a deferred pause from the queue server or Ctrl-C sends + yield Msg("pause", None, defer=True) yield from bps.null() + # tomo_flyscan returns its run's start uid + return f"uid-{len(self.calls) - 1}" + + def dark_flat(self, *args, **kwargs): + if args: + kwargs["detectors"] = args[0] + self.calls.append(("dark_flat", kwargs)) + yield from bps.null() + + @property + def sequence(self) -> str: + return "".join("F" if kind == "flyscan" else "D" for kind, _ in self.calls) + + @property + def flyscans(self) -> list[dict[str, Any]]: + return [kwargs for kind, kwargs in self.calls if kind == "flyscan"] + + @property + def dark_flats(self) -> list[dict[str, Any]]: + return [kwargs for kind, kwargs in self.calls if kind == "dark_flat"] + + +@pytest.fixture +def recorder( + monkeypatch: pytest.MonkeyPatch, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +) -> Recorder: + rec = Recorder(step_motors) + monkeypatch.setattr(flyscans, "tomo_flyscan", rec.flyscan) + monkeypatch.setattr(flyscans, "tomo_dark_flat", rec.dark_flat) + return rec + + +DEVICES = { + "panda": "PANDA", + "rot_motor": "ROT", + "fe_shutter": "FE", + "photon_shutter": "PH", +} + + +# --- tomo_flyscan_loop -------------------------------------------------------------- + + +def test_tomo_flyscan_loop_forwards_flyscan_arguments( + RE: RunEngine, recorder: Recorder +): + RE( + tomo_flyscan_loop( + ["det"], # type: ignore + num_projections=100, + exposure_time=0.2, + acquire_period=0.3, + images_to_average=2, + start=10, + stop=190, + num_iterations=3, + use_shutter=False, + sample_name="rock", + time_based=False, + **DEVICES, # type: ignore + ) + ) + + assert recorder.sequence == "FFF" + expected = { + "detectors": ["det"], + "num_projections": 100, + "exposure_time": 0.2, + "acquire_period": 0.3, + "images_to_average": 2, + "start": 10, + "stop": 190, + "use_shutter": False, + "sample_name": "rock", + "time_based": False, + **DEVICES, + } + for call in recorder.flyscans: + assert {k: call[k] for k in expected} == expected - # The PandA and motor fly logic are not what is under test here. - monkeypatch.setattr(flyscans, "_fly_stub", _fly) - plan = tomo_flyscan( - [kinetix_det_factory(1)], +@pytest.mark.parametrize( + ("num_iterations", "dark_flat_offset", "dark_flat_frequency", "sequence"), + [ + (3, 0.0, 0, "FFF"), + # A zero offset means no darks/flats, whatever the frequency. + (3, 0.0, 1, "FFF"), + (3, 1.5, 0, "DFFFD"), + (1, 1.5, 1, "DFD"), + # The final dark/flat covers the last tomogram, so none is doubled up. + (3, 1.5, 1, "DFDFDFD"), + (5, -1.5, 2, "DFFDFFDFD"), + (4, 1.5, 2, "DFFDFFD"), + (3, 1.5, 5, "DFFFD"), + ], +) +def test_tomo_flyscan_loop_dark_flat_schedule( + RE: RunEngine, + recorder: Recorder, + num_iterations: int, + dark_flat_offset: float, + dark_flat_frequency: int, + sequence: str, +): + RE( + tomo_flyscan_loop( + ["det"], # type: ignore + num_projections=10, + exposure_time=0.1, + num_iterations=num_iterations, + dark_flat_offset=dark_flat_offset, + dark_flat_frequency=dark_flat_frequency, + **DEVICES, # type: ignore + ) + ) + assert recorder.sequence == sequence + + +@pytest.mark.parametrize( + ("dark_flat_exposure_time", "expected"), [(None, 0.1), (0.5, 0.5)] +) +def test_tomo_flyscan_loop_dark_flat_arguments( + RE: RunEngine, + recorder: Recorder, + sample_tower: SampleTower, + dark_flat_exposure_time: float | None, + expected: float, +): + RE( + tomo_flyscan_loop( + ["det"], # type: ignore + num_projections=10, + exposure_time=0.1, + dark_flat_offset=2.0, + num_dark_images=7, + num_flat_images=9, + dark_flat_exposure_time=dark_flat_exposure_time, + use_shutter=False, + sample_tower=sample_tower, + **DEVICES, # type: ignore + ) + ) + + assert len(recorder.dark_flats) == 2 + for call in recorder.dark_flats: + assert call == { + "detectors": ["det"], + "exposure_time": expected, + "num_dark_images": 7, + "num_flat_images": 9, + "x_offset": 2.0, + "sample_tower": sample_tower, + "use_shutter": False, + "fe_shutter": "FE", + "photon_shutter": "PH", + } + + +def test_tomo_flyscan_loop_wraps_flyscan_failures(RE: RunEngine, recorder: Recorder): + recorder.failures = [FailedStatus("a device timed out")] + + with pytest.raises(RuntimeError, match="point 0 failed after 1 attempts") as info: + RE( + tomo_flyscan_loop( + ["det"], # type: ignore + num_projections=10, + exposure_time=0.1, + num_iterations=3, + **DEVICES, # type: ignore + ) + ) + + assert isinstance(info.value.__cause__, FailedStatus) + assert len(recorder.flyscans) == 1 + + +@pytest.mark.parametrize(("num_iterations", "delay"), [(1, 2.0), (4, 2.0), (4, 0.0)]) +def test_tomo_flyscan_loop_delay_between_tomograms( + RE: RunEngine, recorder: Recorder, num_iterations: int, delay: float +): + messages: list[Msg] = [] + RE.msg_hook = messages.append # type: ignore + + RE( + bpp.msg_mutator( + tomo_flyscan_loop( + ["det"], # type: ignore + num_projections=10, + exposure_time=0.1, + num_iterations=num_iterations, + delay_between_tomograms=delay, + **DEVICES, # type: ignore + ), + # count the sleeps without waiting them out + lambda msg: ( + msg._replace(args=(0,), kwargs={"requested": msg.args[0]}) + if msg.command == "sleep" + else msg + ), + ) + ) + + sleeps = [msg.kwargs["requested"] for msg in messages if msg.command == "sleep"] + # Only between tomograms, never before the first. + assert sleeps == ([delay] * (num_iterations - 1) if delay else []) + assert len(recorder.flyscans) == num_iterations + + +# --- tomo_nd_step_scan -------------------------------------------------------------- + + +@pytest.mark.parametrize( + "args", + [ + ("motor", 0, 1), + ("motor", 0, 1, 3, "other"), + ], +) +def test_tomo_nd_step_scan_rejects_malformed_axes(args: tuple): + plan = tomo_nd_step_scan(["det"], *args, num_projections=10, exposure_time=0.1) # type: ignore + with pytest.raises(ValueError, match="motor, start, stop, num"): + next(plan) + + +def test_tomo_nd_step_scan_without_axes_repeats_in_place( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +): + outer, inner = step_motors + + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + num_projections=10, + exposure_time=0.1, + num_repeats=3, + **DEVICES, # type: ignore + ) + ) + + assert recorder.sequence == "FFF" + assert [c["md"]["grid_index"] for c in recorder.flyscans] == [0, 1, 2] + assert all(c["md"]["grid_positions"] == {} for c in recorder.flyscans) + assert not get_mock_put(outer.user_setpoint).called + assert not get_mock_put(inner.user_setpoint).called + + +def test_tomo_nd_step_scan_repeats_the_whole_grid( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +): + outer, _ = step_motors + + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 1, 2, + num_projections=10, + exposure_time=0.1, + num_repeats=2, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + positions = [c["positions"][0] for c in recorder.flyscans] + assert positions == pytest.approx([0, 1, 0, 1]) + + +def test_tomo_nd_step_scan_rejects_snaking_outer_axis( + RE: RunEngine, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +): + outer, inner = step_motors + plan = tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 1, 2, + inner, 0, 1, 2, num_projections=10, - exposure_time=0.01, - start=0, - stop=1, - use_shutter=False, - panda=panda, - rot_motor=rot_motor, - fe_shutter=fe_shutter, - photon_shutter=photon_shutter, - ) - if fly_fails: - with pytest.raises(RuntimeError, match="fly failed"): - RE(plan) - else: - RE(plan) - - speeds = [call.args[0] for call in get_mock_put(rot_motor.velocity).call_args_list] - assert any(speed != 5.0 for speed in speeds) # the fly prepare did change it - assert await rot_motor.velocity.get_value() == 5.0 + exposure_time=0.1, + snake_axes=[outer], + ) # fmt: skip + with pytest.raises(ValueError, match="outermost axis cannot be snaked"): + next(plan) + + +@pytest.mark.parametrize( + ("snake_axes", "expected"), + [ + ( + False, + [(0, 10), (0, 15), (0, 20), (1, 10), (1, 15), (1, 20)], + ), + ( + True, + [(0, 10), (0, 15), (0, 20), (1, 20), (1, 15), (1, 10)], + ), + ( + "inner", + [(0, 10), (0, 15), (0, 20), (1, 20), (1, 15), (1, 10)], + ), + ], +) +async def test_tomo_nd_step_scan_grid( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + snake_axes: bool | str, + expected: list[tuple[float, float]], +): + outer, inner = step_motors + await outer.user_setpoint.set(-3.0) + await inner.user_setpoint.set(7.0) + snake: Any = [inner] if snake_axes == "inner" else snake_axes + + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 1, 2, + inner, 10, 20, 3, + num_projections=10, + exposure_time=0.1, + snake_axes=snake, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + positions = [ + (call["positions"][0], call["positions"][1]) for call in recorder.flyscans + ] + assert positions == [pytest.approx(p) for p in expected] + # Each flyscan's run records where on the grid it was taken, and the run before. + assert [c["md"] for c in recorder.flyscans] == [ + { + "grid_index": i, + "num_grid_points": len(expected), + "grid_positions": { + outer.name: pytest.approx(o), + inner.name: pytest.approx(n), + }, + **({"previous_grid_point_uid": f"uid-{i - 1}"} if i else {}), + } + for i, (o, n) in enumerate(expected) + ] + # Step motors go back to where they started. + assert await outer.user_setpoint.get_value() == pytest.approx(-3.0) + assert await inner.user_setpoint.get_value() == pytest.approx(7.0) + + +async def test_tomo_nd_step_scan_only_moves_changed_axes( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +): + outer, inner = step_motors + + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 1, 2, + inner, 10, 20, 3, + num_projections=10, + exposure_time=0.1, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + outer_moves = [c.args[0] for c in get_mock_put(outer.user_setpoint).call_args_list] + inner_moves = [c.args[0] for c in get_mock_put(inner.user_setpoint).call_args_list] + # two grid rows plus the return to the start position + assert outer_moves == pytest.approx([0, 1, 0]) + assert inner_moves == pytest.approx([10, 15, 20, 10, 15, 20, 0]) + + +@pytest.mark.parametrize( + ("alternate", "expected"), + [ + (False, [(0, 180)] * 4), + (True, [(0, 180), (180, 0), (0, 180), (180, 0)]), + ], +) +def test_tomo_nd_step_scan_alternates_flyscan_direction( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + alternate: bool, + expected: list[tuple[float, float]], +): + outer, _ = step_motors + + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 3, 4, + num_projections=10, + exposure_time=0.1, + start=0, + stop=180, + alternate_flyscan_dir=alternate, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + assert [(c["start"], c["stop"]) for c in recorder.flyscans] == expected + + +def test_tomo_nd_step_scan_forwards_flyscan_arguments( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +): + outer, _ = step_motors + + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 1, 2, + num_projections=100, + exposure_time=0.2, + acquire_period=0.3, + images_to_average=2, + use_shutter=False, + sample_name="rock", + time_based=False, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + expected = { + "detectors": ["det"], + "num_projections": 100, + "exposure_time": 0.2, + "acquire_period": 0.3, + "images_to_average": 2, + "use_shutter": False, + "sample_name": "rock", + "time_based": False, + **DEVICES, + } + assert len(recorder.flyscans) == 2 + for call in recorder.flyscans: + assert {k: call[k] for k in expected} == expected + + +@pytest.mark.parametrize( + ("num_points", "dark_flat_offset", "dark_flat_frequency", "sequence"), + [ + (3, 0.0, 1, "FFF"), + (3, 1.0, 0, "DFFFD"), + (3, 1.0, 1, "DFDFDFD"), + (5, 1.0, 2, "DFFDFFDFD"), + ], +) +def test_tomo_nd_step_scan_dark_flat_schedule( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + num_points: int, + dark_flat_offset: float, + dark_flat_frequency: int, + sequence: str, +): + outer, _ = step_motors + + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 1, num_points, + num_projections=10, + exposure_time=0.1, + dark_flat_offset=dark_flat_offset, + dark_flat_frequency=dark_flat_frequency, + dark_flat_exposure_time=0.4, + num_dark_images=3, + num_flat_images=4, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + assert recorder.sequence == sequence + for call in recorder.dark_flats: + assert call["exposure_time"] == 0.4 + assert (call["num_dark_images"], call["num_flat_images"]) == (3, 4) + assert call["x_offset"] == dark_flat_offset + + +def test_tomo_nd_step_scan_retries_failed_flyscans( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +): + outer, _ = step_motors + recorder.failures = [RuntimeError("one"), RuntimeError("two")] + + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 1, 2, + num_projections=10, + exposure_time=0.1, + num_retries=2, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + # the first point succeeds on its third attempt, the second on its first + positions = [c["positions"][0] for c in recorder.flyscans] + assert positions == pytest.approx([0, 0, 0, 1]) + # Failed attempts are skipped: the second point links to the first's success. + previous = [c["md"].get("previous_grid_point_uid") for c in recorder.flyscans] + assert previous == [None, None, None, "uid-2"] + + +@pytest.mark.parametrize("num_retries", [0, 2]) +def test_tomo_nd_step_scan_gives_up_after_retries( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + num_retries: int, +): + outer, _ = step_motors + recorder.failures = [RuntimeError("boom")] * (num_retries + 1) + + with pytest.raises( + RuntimeError, + match=f"point 0 failed after {num_retries + 1} attempts: boom", + ): + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 1, 2, + num_projections=10, + exposure_time=0.1, + num_retries=num_retries, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + assert len(recorder.flyscans) == num_retries + 1 + + +@pytest.mark.parametrize("request_cls", [RequestAbort, PlanHalt]) +def test_tomo_nd_step_scan_never_retries_an_abort_or_halt( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + request_cls: type[BaseException], +): + outer, _ = step_motors + recorder.failures = [request_cls()] + + result = RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 1, 2, + num_projections=10, + exposure_time=0.1, + num_retries=5, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + assert result.exit_status == "abort" # type: ignore + assert len(recorder.flyscans) == 1 + + +def test_tomo_nd_step_scan_checkpoints_before_each_step( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +): + outer, _ = step_motors + messages: list[Msg] = [] + RE.msg_hook = messages.append # type: ignore + + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 2, 3, + num_projections=10, + exposure_time=0.1, + dark_flat_offset=1.0, + dark_flat_frequency=2, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + # 3 points and 3 dark/flats (before, after point 2, at the end), plus the one + # bps.move_per_step adds before each move + assert recorder.sequence == "DFFDFD" + assert sum(msg.command == "checkpoint" for msg in messages) == 6 + 3 + # a checkpoint falls between every pair of grid moves + commands = [msg.command for msg in messages] + moves = [i for i, msg in enumerate(messages) if msg.command == "set"] + for before, after in zip([-1, *moves], moves, strict=False): + assert "checkpoint" in commands[before + 1 : after] + + +def test_tomo_nd_step_scan_deferred_pause_stops_between_points( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +): + outer, _ = step_motors + recorder.pause_after_call = 1 + + with pytest.raises(RunEngineInterrupted): + RE( + tomo_nd_step_scan( + ["det"], # type: ignore + outer, 0, 3, 4, + num_projections=10, + exposure_time=0.1, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + # The point in progress finishes, and the scan holds before the next one. + assert RE.state == "paused" + assert [c["positions"][0] for c in recorder.flyscans] == pytest.approx([0, 1]) + + RE.resume() + + # Resuming picks up at the next point; nothing is repeated or skipped. + positions = [c["positions"][0] for c in recorder.flyscans] + assert positions == pytest.approx([0, 1, 2, 3]) + previous = [c["md"].get("previous_grid_point_uid") for c in recorder.flyscans] + assert previous == [None, "uid-0", "uid-1", "uid-2"] + + +# --- tomo_1d_step_scan / tomo_2d_step_scan ---------------------------------------- + + +async def test_tomo_1d_step_scan( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + sample_tower: SampleTower, +): + outer, _ = step_motors + + RE( + tomo_1d_step_scan( + ["det"], # type: ignore + outer, + step_start=-1, + step_stop=1, + num_tomograms=5, + num_projections=100, + exposure_time=0.2, + start=0, + stop=360, + alternate_flyscan_dir=True, + dark_flat_offset=3.0, + dark_flat_frequency=2, + num_dark_images=6, + num_flat_images=8, + sample_name="rock", + sample_tower=sample_tower, + **DEVICES, # type: ignore + ) + ) + + positions = [c["positions"][0] for c in recorder.flyscans] + assert positions == pytest.approx(list(np.linspace(-1, 1, 5))) + assert [(c["start"], c["stop"]) for c in recorder.flyscans] == [ + (0, 360), + (360, 0), + (0, 360), + (360, 0), + (0, 360), + ] + assert recorder.sequence == "DFFDFFDFD" + assert all(c["sample_name"] == "rock" for c in recorder.flyscans) + for call in recorder.dark_flats: + assert call["sample_tower"] is sample_tower + assert (call["num_dark_images"], call["num_flat_images"]) == (6, 8) + + +def test_tomo_1d_step_scan_retries_by_default( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], +): + outer, _ = step_motors + recorder.failures = [RuntimeError("boom")] * 3 + + RE( + tomo_1d_step_scan( + ["det"], # type: ignore + outer, + step_start=0, + step_stop=1, + num_tomograms=1, + num_projections=10, + exposure_time=0.1, + **DEVICES, # type: ignore + ) + ) + + assert len(recorder.flyscans) == 4 + + +@pytest.mark.parametrize( + ("snake_axes", "expected_inner"), + [(False, [5, 6, 5, 6, 5, 6]), (True, [5, 6, 6, 5, 5, 6])], +) +def test_tomo_2d_step_scan( + RE: RunEngine, + recorder: Recorder, + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + snake_axes: bool, + expected_inner: list[float], +): + outer, inner = step_motors + + RE( + tomo_2d_step_scan( + ["det"], # type: ignore + outer, 0, 2, 3, + inner, 5, 6, 2, + num_projections=100, + exposure_time=0.2, + sample_name="rock", + snake_axes=snake_axes, + **DEVICES, # type: ignore + ) + ) # fmt: skip + + positions = [(c["positions"][0], c["positions"][1]) for c in recorder.flyscans] + assert positions == [ + pytest.approx((o, i)) + for o, i in zip([0, 0, 1, 1, 2, 2], expected_inner, strict=True) + ] + assert all(c["sample_name"] == "rock" for c in recorder.flyscans) + assert recorder.sequence == "F" * 6 + + +# ================================================================================== +# Integration +# ================================================================================== + + +async def test_tomo_2d_step_scan_saves_to_tiled( + RE: RunEngine, + kinetix_det_factory: Callable[[int], KinetixDetector], + fly_rig: Callable[..., FlyRig], + open_fe_shutter: tuple[Shutter, Shutter], + step_motors: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + tiled_client: tuple[Path, Container], +): + _, client = tiled_client + outer, inner = step_motors + fe_shutter, photon_shutter = open_fe_shutter + ktx = kinetix_det_factory(1) + set_mock_value(ktx.driver.array_size_x, 4) + set_mock_value(ktx.driver.array_size_y, 3) + rig = fly_rig([ktx]) + num_projections = 5 + rig.frames_per_move = num_projections + rig.photon_shutter = photon_shutter + rig.write_files = True + RE.subscribe(TiledWriter(client)) + + result = RE( + _no_sleeps( + tomo_2d_step_scan( + [ktx], + outer, 0, 1, 2, + inner, 0, 1, 2, + num_projections=num_projections, + exposure_time=0.01, + sample_name="rock", + panda=rig.panda, # type: ignore + rot_motor=rig.rot_motor, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ) + ) + ) # fmt: skip + + uids = result.run_start_uids # type: ignore + assert len(uids) == 4 + grid_positions = [] + for i, uid in enumerate(uids): + run = client[uid] + start = run.metadata["start"] + assert start["plan_name"] == "tomo_flyscan" + assert start["sample_name"] == "rock" + assert start["num_points"] == num_projections + assert (start["grid_index"], start["num_grid_points"]) == (i, 4) + grid_positions.append(start["grid_positions"]) + assert run.metadata["stop"]["exit_status"] == "success" + primary = run["primary"] + assert {"kinetix1", "rotation_angle"} <= set(primary) + assert primary["kinetix1"].shape == (num_projections, 1, 3, 4) + assert primary["rotation_angle"].shape == (num_projections,) + # The data read back through tiled is what this point's devices wrote. + written = rig.written[i] + np.testing.assert_array_equal( + primary["kinetix1"].read(), written["kinetix1"][:, np.newaxis] + ) + np.testing.assert_allclose( + primary["rotation_angle"].read(), written["rotation_angle"] + ) + assert grid_positions == [ + {outer.name: o, inner.name: n} for o, n in [(0, 0), (0, 1), (1, 0), (1, 1)] + ] + + # Walking back from the last point visits every point. + chain = [uids[-1]] + while ( + previous := client[chain[-1]].metadata["start"].get("previous_grid_point_uid") + ): + chain.append(previous) + assert chain[::-1] == list(uids)