diff --git a/pixi.lock b/pixi.lock index 7253ea4..fabfaa9 100644 --- a/pixi.lock +++ b/pixi.lock @@ -5,6 +5,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -29,12 +31,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda + - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/41/497f55b454aa64e2cb3f27990e5cb76c64457d54365b07d6c125bdac7b38/bluesky-1.14.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/4f/f94ac1b84d2169cf2ebf64353ce98fd743f85d30678059c514d9b3d6644c/compress_pickle-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/af/6bb0e1de4fb7218fa4ba95fe91ba1fd4a5661dd28a461eff6a0f609c996c/epicscorelibs-7.0.7.99.1.2-cp313-cp313-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/32/e31e3363bf48ad2ba80b644b01ad9676ce154f1b755950de81eb4ed5b6bd/event_model-1.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl @@ -51,7 +56,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/cc/c528311d798e22ec884b816e8aa2989e0f1f28cdc8e5969e2be5f10bce85/pint-0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl @@ -63,13 +68,17 @@ environments: - pypi: https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b9/36/c101788fad13e8ea65c5b3d3dee8ff996500800cd554ae6ff72143690247/setuptools_dso-2.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/ba/d03f7ee711391af1d5f4dd7c44f8abdd06bce247028af2441ba8f6ff329b/stamina-25.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/98/eb27cc78ad3af8e302c9d8ff4977f5026676e130d28dd7578132a457170c/toolz-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ dev: @@ -77,6 +86,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -104,7 +115,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a2/22/44738b41bb5ca30f94b5f4c00c71c20be86d7eb4ddc389d4cf3c7b8b69ef/adbc_driver_manager-1.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/58/15/86561628738161017273d9a689e9405e4ea9a9d41a70fd2460dbc5d646ae/adbc_driver_postgresql-1.7.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/93/1f/618d88542ca66baf6bc25a3e5ecbd698eff31b12b2ab2a590bae8d9d8c83/adbc_driver_sqlite-1.7.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/5b/08/185c3b29b0698328b202e6c965c23187e2e29ead78cb468aab0a09ee97fc/aioca-1.8.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl @@ -205,7 +216,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -253,6 +264,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl @@ -279,6 +291,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl @@ -293,6 +306,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -318,6 +333,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl @@ -326,10 +342,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/41/497f55b454aa64e2cb3f27990e5cb76c64457d54365b07d6c125bdac7b38/bluesky-1.14.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/4f/f94ac1b84d2169cf2ebf64353ce98fd743f85d30678059c514d9b3d6644c/compress_pickle-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/af/6bb0e1de4fb7218fa4ba95fe91ba1fd4a5661dd28a461eff6a0f609c996c/epicscorelibs-7.0.7.99.1.2-cp313-cp313-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/32/e31e3363bf48ad2ba80b644b01ad9676ce154f1b755950de81eb4ed5b6bd/event_model-1.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl @@ -355,7 +373,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/cc/c528311d798e22ec884b816e8aa2989e0f1f28cdc8e5969e2be5f10bce85/pint-0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl @@ -370,7 +388,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b9/36/c101788fad13e8ea65c5b3d3dee8ff996500800cd554ae6ff72143690247/setuptools_dso-2.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl @@ -390,6 +411,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl - pypi: ./ test: @@ -397,6 +419,8 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 @@ -421,6 +445,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda + - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl @@ -447,6 +472,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/11/79/479e2194c9096b92aecdf33634ae948d2be306c6011673e98ee1917f32c2/dpkt-1.9.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/35/a8/365059bbcd4572cbc41de17fd5b682be5868b218c3c5479071865cab9078/entrypoints-0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/af/6bb0e1de4fb7218fa4ba95fe91ba1fd4a5661dd28a461eff6a0f609c996c/epicscorelibs-7.0.7.99.1.2-cp313-cp313-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/32/e31e3363bf48ad2ba80b644b01ad9676ce154f1b755950de81eb4ed5b6bd/event_model-1.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl @@ -484,7 +510,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b3/1e63820f1a4df7854e33f0c3c5fa6be758c7fb7ea007ae9c8b9b35f48690/ophyd-1.10.7-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl @@ -514,7 +540,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b9/36/c101788fad13e8ea65c5b3d3dee8ff996500800cd554ae6ff72143690247/setuptools_dso-2.12.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl @@ -530,6 +559,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e0/a3/d3d4fd394a10b1256f9dccb2fe0ddd125fc575d7c437b1c70df050f14176/zarr-3.1.2-py3-none-any.whl @@ -613,29 +643,13 @@ packages: - pyarrow>=14.0.1 ; extra == 'test' - pytest ; extra == 'test' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/5b/08/185c3b29b0698328b202e6c965c23187e2e29ead78cb468aab0a09ee97fc/aioca-1.8.1-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl name: aioca - version: 1.8.1 - sha256: b856b68c4722387bc88917c10f71f87b7e74d95a28581187b6577682a6c68909 + version: '2.1' + sha256: df0f062b81a4846d3e5705f0bfaf7bee9f876009302cf3b06ada93d6f5153b6f requires_dist: - numpy - epicscorelibs>=7.0.3.99.4.0 - - black ; extra == 'dev' - - click ; extra == 'dev' - - mypy ; extra == 'dev' - - myst-parser ; extra == 'dev' - - pipdeptree ; extra == 'dev' - - pre-commit ; extra == 'dev' - - pydata-sphinx-theme>=0.12 ; extra == 'dev' - - pytest ; extra == 'dev' - - pytest-asyncio ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - ruff ; extra == 'dev' - - sphinx-autobuild ; extra == 'dev' - - sphinx-copybutton ; extra == 'dev' - - sphinx-design ; extra == 'dev' - - tox-direct ; extra == 'dev' - - types-mock ; extra == 'dev' requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl name: aiofiles @@ -1031,11 +1045,11 @@ packages: requires_python: '>=3.8' - pypi: ./ name: cditools - version: 0.1.dev82+g9cfe709dd.d20250902 - sha256: 8d150403b1dbd5cc6f2f621421facbd9a8aef505ba22ab908fd0346bc6f2b324 + version: 0.1.1.dev77+gbd96ebd3c.d20260617 + sha256: 5581ab46c321a090c4331aa21510018a9ba1f1ec373bb6bc0d01b7df4f6c52a1 requires_dist: - ophyd - - ophyd-async>=0.10.0a4 + - ophyd-async[ca]==0.17a2 - h5py - entrypoints ; extra == 'test' - pytest>=6 ; extra == 'test' @@ -1065,7 +1079,6 @@ packages: - sphinx-autodoc-typehints ; extra == 'docs' - furo>=2023.8.17 ; extra == 'docs' requires_python: '>=3.9' - editable: true - pypi: https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl name: certifi version: 2025.8.3 @@ -2388,10 +2401,10 @@ packages: - sphinx-design ; extra == 'dev' - tox-direct ; extra == 'dev' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/34/6a/68cf0ccf958ddae11d6da56905cd71c5c8101a581abb67f8c7574b95823f/ophyd_async-0.17a2-py3-none-any.whl name: ophyd-async - version: 0.12.3 - sha256: 3d0e98f9ab0169776dad24c072ef19ece6d53faa2933e55d3c139c525761b802 + version: 0.17a2 + sha256: 9e7e05d8fbd34ddc9fb907cbcab1b9fbd54fa9aa2d4a26615302d3841200ffeb requires_dist: - numpy - bluesky>=1.13.1rc2 @@ -2401,46 +2414,16 @@ packages: - pydantic>=2.0 - pydantic-numpy - stamina>=23.1.0 + - scanspec>=0.8 + - velocity-profile - h5py ; extra == 'sim' - aioca>=2.0a4 ; extra == 'ca' - p4p>=4.2.0 ; extra == 'pva' - - pytango==10.0.0 ; extra == 'tango' + - pytango>=10.1.3 ; extra == 'tango' - ipython ; extra == 'demo' - matplotlib ; extra == 'demo' - pyqt6 ; extra == 'demo' - - ophyd-async[sim] ; extra == 'dev' - - ophyd-async[ca] ; extra == 'dev' - - ophyd-async[pva] ; extra == 'dev' - - ophyd-async[tango] ; extra == 'dev' - - ophyd-async[demo] ; extra == 'dev' - - inflection ; extra == 'dev' - - import-linter ; extra == 'dev' - - myst-parser ; extra == 'dev' - - numpydoc ; extra == 'dev' - - ophyd>=1.10.7 ; extra == 'dev' - - pickleshare ; extra == 'dev' - - pipdeptree ; extra == 'dev' - - pre-commit ; extra == 'dev' - - pydata-sphinx-theme>=0.12 ; extra == 'dev' - - pyepics>=3.4.2 ; extra == 'dev' - - pyright ; extra == 'dev' - - pytest ; extra == 'dev' - - pytest-asyncio ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - pytest-faulthandler ; extra == 'dev' - - pytest-forked ; extra == 'dev' - - pytest-rerunfailures ; extra == 'dev' - - pytest-timeout ; extra == 'dev' - - ruff ; extra == 'dev' - - sphinx-autobuild ; extra == 'dev' - - sphinx-autodoc2 ; extra == 'dev' - - sphinxcontrib-mermaid ; extra == 'dev' - - sphinx-copybutton ; extra == 'dev' - - sphinx-design ; extra == 'dev' - - tox-direct ; extra == 'dev' - - types-mock ; extra == 'dev' - - types-pyyaml ; extra == 'dev' - requires_python: '>=3.10' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: orjson version: 3.11.3 @@ -3065,6 +3048,19 @@ packages: version: 0.12.11 sha256: 4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8 requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl + name: scanspec + version: 1.0.0 + sha256: 4ad6dccd6ac19dbd8af888c80c5db53c70d6b8aee9d26c0ca174cebd3c396730 + requires_dist: + - numpy + - click>=8.1 + - pydantic>=2.0 + - scipy ; extra == 'plotting' + - matplotlib ; extra == 'plotting' + - fastapi>=0.100.0 ; extra == 'service' + - uvicorn ; extra == 'service' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl name: secretstorage version: 3.3.3 @@ -3840,6 +3836,24 @@ packages: - pyopenssl~=23.0.0 ; extra == 'test' - mypy>=0.800 ; extra == 'test' requires_python: '>=3.8.0' +- pypi: https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl + name: velocity-profile + version: 1.0.0 + sha256: b9082aedb2863748e1e6e56e7a794cd5742addd571f6ba2e13f4f5b8a09422d9 + requires_dist: + - numpy + - copier ; extra == 'dev' + - mypy ; extra == 'dev' + - pipdeptree ; extra == 'dev' + - pre-commit ; extra == 'dev' + - pytest ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - ruff ; extra == 'dev' + - tox-direct ; extra == 'dev' + - types-mock ; extra == 'dev' + - scanspec ; extra == 'dev' + - pydantic<2.0 ; extra == 'dev' + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl name: virtualenv version: 20.34.0 diff --git a/pyproject.toml b/pyproject.toml index 4a03250..1d015ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "ophyd", - "ophyd-async >=0.10.0a4", + "ophyd-async[ca] ==0.17a2", "h5py", ] diff --git a/src/cditools/eiger_async.py b/src/cditools/eiger_async.py index f8b38b0..2de72d8 100644 --- a/src/cditools/eiger_async.py +++ b/src/cditools/eiger_async.py @@ -3,103 +3,53 @@ """ from __future__ import annotations - -# import asyncio -# from collections.abc import AsyncGenerator, AsyncIterator, Iterator, Sequence +import asyncio +import functools +import os +from collections.abc import AsyncGenerator, AsyncIterator, Sequence +from urllib.parse import urlunparse +from pathlib import Path from logging import getLogger -# from pathlib import Path from typing import Annotated as A -# from typing import Any, cast -# from urllib.parse import urlunparse - -import numpy as np # type: ignore[import-not-found] -# from bluesky.protocols import StreamAsset -# from event_model import ( # type: ignore[import-untyped] -# ComposeStreamResource, -# ComposeStreamResourceBundle, -# DataKey, # type: ignore[import-untyped] -# StreamDatum, -# StreamRange, -# StreamResource, -# ) +import numpy as np + +from ophyd_async.epics.adcore import ( + ADBaseIO, + NDFileIO, + ADImageMode, + AreaDetector, + NDPluginBaseIO, + trigger_info_from_num_images, +) +from ophyd_async.epics.core import PvSuffix, stop_busy_record from ophyd_async.core import ( - # DetectorTrigger, - # PathInfo, - # PathProvider, - # SignalDatatypeT, SignalR, SignalRW, StrictEnum, SubsetEnum, - # TriggerInfo, - # observe_value, + AsyncStatus, + DetectorArmLogic, + DetectorDataLogic, + DetectorTriggerLogic, + PathInfo, + PathProvider, + SignalDatatypeT, + StreamResourceDataProvider, + StreamResourceInfo, + TriggerInfo, + observe_value, + set_and_wait_for_other_value, ) -from ophyd_async.epics.adcore import ( - #ADBaseController, - #ADBaseDatasetDescriber, - ADBaseIO, - #ADImageMode, - #ADWriter, - # AreaDetector, - NDFileIO, - # NDPluginBaseIO, +from ophyd_async.core._status import WatchableAsyncStatus +from ophyd_async.core._utils import ( + DEFAULT_TIMEOUT, + WatcherUpdate, + error_if_none, ) -from ophyd_async.epics.signal import PvSuffix logger = getLogger(__name__) -# class EigerDocumentComposer: -# def __init__( -# self, -# full_file_name: Path, -# datasets: list[Any], -# last_emitted_index: int = 0, -# hostname: str = "localhost", -# ) -> None: -# self._last_emitted = last_emitted_index -# self._hostname = hostname -# uri = urlunparse( -# ( -# "file", -# self._hostname, -# str(full_file_name.absolute()), -# "", -# "", -# None, -# ) -# ) -# bundler_composer = ComposeStreamResource() -# self._bundles: list[ComposeStreamResourceBundle] = [ -# bundler_composer( -# mimetype="application/x-hdf5", -# uri=uri, -# data_key=ds.data_key, -# parameters={ -# "dataset": ds.dataset, -# "chunk_shape": ds.chunk_shape, -# }, -# uid=None, -# validate=True, -# ) -# for ds in datasets -# ] - -# def stream_resources(self) -> Iterator[StreamResource]: -# for bundle in self._bundles: -# yield bundle.stream_resource_doc - -# def stream_data(self, indices_written: int) -> Iterator[StreamDatum]: -# if indices_written > self._last_emitted: -# indices: StreamRange = { -# "start": self._last_emitted, -# "stop": indices_written, -# } -# self._last_emitted = indices_written -# for bundle in self._bundles: -# yield bundle.compose_stream_datum(indices) - - # TODO - add extra options in eiger2 and revert to StrictEnum class EigerTriggerMode(SubsetEnum): """Trigger modes for the Eiger detector. @@ -172,7 +122,7 @@ class EigerStreamVersion(StrictEnum): See https://areadetector.github.io/areaDetector/ADEiger/eiger.html#stream-interface """ - STREAM1 = "Stream1" + STREAM1 = "Stream" STREAM2 = "Stream2" @@ -309,9 +259,9 @@ class Eiger2DriverIO(EigerDriverIO): hv_state: A[SignalR[str], PvSuffix("HVState_RBV")] # Acquisition Setup - threshold: A[SignalRW[float], PvSuffix.rbv("Threshold")] + threshold: A[SignalRW[float], PvSuffix.rbv("ThresholdEnergy")] threshold1_enable: A[SignalRW[bool], PvSuffix.rbv("Threshold1Enable")] - threshold2: A[SignalRW[float], PvSuffix.rbv("Threshold2")] + threshold2: A[SignalRW[float], PvSuffix.rbv("Threshold2Energy")] threshold2_enable: A[SignalRW[bool], PvSuffix.rbv("Threshold2Enable")] threshold_diff_enable: A[SignalRW[bool], PvSuffix.rbv("ThresholdDiffEnable")] counting_mode: A[SignalRW[str], PvSuffix.rbv("CountingMode")] @@ -325,344 +275,355 @@ class Eiger2DriverIO(EigerDriverIO): # Stream Interface stream_version: A[SignalRW[EigerStreamVersion], PvSuffix.rbv("StreamVersion")] - stream_hdr_appendix: A[SignalRW[str], PvSuffix.rbv("StreamHdrAppendix")] - stream_img_appendix: A[SignalRW[str], PvSuffix.rbv("StreamImgAppendix")] + stream_hdr_appendix: None + stream_img_appendix: None # FileWriter Interface fw_hdf5_format: A[SignalRW[EigerHDF5Format], PvSuffix.rbv("FWHDF5Format")] -# class EigerWriter(ADWriter[EigerDriverIO]): # type: ignore[reportInvalidTypeArguments] -# """Eiger-specific file writer using the built-in FileWriter interface.""" - -# default_suffix: str = "cam1:" -# # Forced minimum number of images per file to force a single HDF5 file -# _min_num_images_per_file: int = 1_000_000_000 - -# def __init__( -# self, -# fileio: EigerDriverIO, -# path_provider: PathProvider, -# dataset_describer: ADBaseDatasetDescriber, -# plugins: dict[str, NDPluginBaseIO] | None = None, -# ): -# super().__init__( -# fileio, -# path_provider, -# dataset_describer, -# file_extension=".h5", -# mimetype="application/x-hdf5", -# plugins=plugins, -# ) - -# self._file_info: PathInfo | None = None -# self._datasets: list[Any] = [] -# self._master_file_path_cache: list[Path] = [] - -# async def open(self, name: str, exposures_per_event: int = 1) -> dict[str, DataKey]: -# """Setup file writing for acquisition.""" -# # Get file path info from path provider -# self._file_info = self._path_provider() -# self._master_file_path_cache.clear() - -# # Cache for use later -# self._exposures_per_event = exposures_per_event - -# # Set the name pattern with $id replacement similar to original -# name_pattern = f"{self._file_info.filename}_$id" - -# # Configure the Eiger FileWriter -# await asyncio.gather( -# self.fileio.file_path.set(self._file_info.directory_path.as_posix()), -# self.fileio.create_directory.set(self._file_info.create_dir_depth), -# self.fileio.fw_name_pattern.set(name_pattern), -# self.fileio.fw_enable.set(True), -# self.fileio.save_files.set(True), -# self.fileio.data_source.set(EigerDataSource.FILE_WRITER), -# self.fileio.num_capture.set(0), -# # Use array_counter to track the total number of images written -# self.fileio.array_counter.set(0), -# ) - -# if not await self.fileio.file_path_exists.get_value(): -# msg = f"File path {self._file_info.directory_path} does not exist" -# raise FileNotFoundError(msg) - -# if isinstance(self.fileio, Eiger2DriverIO): -# await self.fileio.fw_hdf5_format.set(EigerHDF5Format.LEGACY) - -# # Force the number of images per file to a large number to simplify the logic -# num_images_per_file = await self.fileio.fw_nimgs_per_file.get_value() -# if num_images_per_file < self._min_num_images_per_file: -# await self.fileio.fw_nimgs_per_file.set(self._min_num_images_per_file) -# logger.warning( -# "Setting fw_nimgs_per_file to %d to force writing to a single HDF5 file", -# self._min_num_images_per_file, -# ) - -# detector_shape = await self._dataset_describer.shape() - -# # TODO: Add these when empty shape datasets are supported by tiled -# # Add the master file datasets -# master_datasets = [] -# # master_datasets = [ -# # HDFDatasetDescription2( -# # data_key=f"{name}_y_pixel_size", -# # dataset="entry/instrument/detector/y_pixel_size", -# # shape=(), -# # dtype_numpy=np.dtype(np.float32).str, -# # chunk_shape=(), -# # join_method="stack", -# # ), -# # HDFDatasetDescription2( -# # data_key=f"{name}_x_pixel_size", -# # dataset="entry/instrument/detector/x_pixel_size", -# # shape=(), -# # dtype_numpy=np.dtype(np.float32).str, -# # chunk_shape=(), -# # join_method="stack", -# # ), -# # HDFDatasetDescription2( -# # data_key=f"{name}_detector_distance", -# # dataset="entry/instrument/detector/detector_distance", -# # shape=(), -# # dtype_numpy=np.dtype(np.float32).str, -# # chunk_shape=(), -# # join_method="stack", -# # ), -# # HDFDatasetDescription2( -# # data_key=f"{name}_incident_wavelength", -# # dataset="entry/instrument/detector/incident_wavelength", -# # shape=(), -# # dtype_numpy=np.dtype(np.float32).str, -# # chunk_shape=(), -# # join_method="stack", -# # ), -# # HDFDatasetDescription2( -# # data_key=f"{name}_frame_time", -# # dataset="entry/instrument/detector/frame_time", -# # shape=(), -# # dtype_numpy=np.dtype(np.float32).str, -# # chunk_shape=(), -# # join_method="stack", -# # ), -# # HDFDatasetDescription2( -# # data_key=f"{name}_beam_center_x", -# # dataset="entry/instrument/detector/beam_center_x", -# # shape=(), -# # dtype_numpy=np.dtype(np.float32).str, -# # chunk_shape=(), -# # join_method="stack", -# # ), -# # HDFDatasetDescription2( -# # data_key=f"{name}_beam_center_y", -# # dataset="entry/instrument/detector/beam_center_y", -# # shape=(), -# # dtype_numpy=np.dtype(np.float32).str, -# # chunk_shape=(), -# # join_method="stack", -# # ), -# # HDFDatasetDescription2( -# # data_key=f"{name}_count_time", -# # dataset="entry/instrument/detector/count_time", -# # shape=(), -# # dtype_numpy=np.dtype(np.float32).str, -# # chunk_shape=(), -# # join_method="stack", -# # ), -# # HDFDatasetDescription2( -# # data_key=f"{name}_pixel_mask", -# # dataset="entry/instrument/detector/detectorSpecific/pixel_mask", -# # shape=detector_shape, -# # dtype_numpy=np.dtype(np.uint32).str, -# # chunk_shape=detector_shape, -# # join_method="stack", -# # ), -# # ] - -# if any(s is None for s in detector_shape): -# chunk_shape = (1,) -# else: -# chunk_shape = cast(tuple[int, ...], (1, *detector_shape)) -# # frame_datasets = [ -# # HDFDatasetDescription( -# # data_key=f"{name}_image", -# # dataset=f"entry/data/data_{1:06d}", -# # shape=(exposures_per_event, *detector_shape), -# # # Always write as uint32 -# # dtype_numpy=np.dtype(np.uint32).str, -# # chunk_shape=chunk_shape, -# # ) -# # ] - -# # Cache descriptions for later use -# self._datasets = master_datasets + frame_datasets - -# return { -# ds.data_key: DataKey( -# source="ADEiger FileWriter", -# shape=list(ds.shape), -# dtype="array" -# if exposures_per_event > 1 or len(ds.shape) > 1 -# else "number", -# dtype_numpy=ds.dtype_numpy, -# external="STREAM:", -# ) -# for ds in self._datasets -# } - -# @property -# async def _master_file_path(self) -> Path | None: -# if self._file_info is None: -# logger.warning( -# "No master file path found for file info %s", -# self._file_info, -# ) -# return None -# sequence_id = await self.fileio.sequence_id.get_value() -# return Path( -# self._file_info.directory_path -# / f"{self._file_info.filename}_{sequence_id}_master.h5" -# ) - -# async def collect_stream_docs( -# self, name: str, indices_written: int -# ) -> AsyncIterator[StreamAsset]: -# """Generate stream documents for the written HDF5 files.""" -# if indices_written: -# master_file_path = await self._master_file_path -# if master_file_path is None: -# msg = f"Master file path is not set for {name}: {self._file_info}" -# raise ValueError(msg) - -# # Eiger generates a new master file for each trigger -# # so we need to create a new composer with a new -# # master file path -# composer = EigerDocumentComposer( -# master_file_path, -# self._datasets, -# last_emitted_index=indices_written - 1, -# ) - -# # For later validation -# self._master_file_path_cache.append(master_file_path) - -# for doc in composer.stream_resources(): -# yield "stream_resource", doc - -# for doc in composer.stream_data(indices_written): -# yield "stream_datum", doc - -# async def observe_indices_written( -# self, timeout: float -# ) -> AsyncGenerator[int, None]: -# async for num_captured in observe_value(self.fileio.array_counter, timeout): -# yield num_captured // self._exposures_per_event - -# async def get_indices_written(self) -> int: -# return await self.fileio.array_counter.get_value() // self._exposures_per_event - -# async def close(self) -> None: -# """Clean up file writing after acquisition and validate files exist.""" - -# # Check that the master files were written -# for master_file_path in self._master_file_path_cache: -# if not master_file_path.exists(): -# logger.warning("Master file was not written: %s", master_file_path) - -# self._file_info = None - - -# class EigerController(ADBaseController[EigerDriverIO]): -# """Controller for Eiger detector, handling trigger modes and acquisition setup.""" - -# def __init__( -# self, driver: EigerDriverIO, *args: Any, **kwargs: dict[str, Any] -# ) -> None: -# super().__init__(driver, *args, **kwargs) - -# def get_deadtime(self, exposure: float | None) -> float: -# """Get detector deadtime for the given exposure.""" -# default_deadtime = 0.000001 -# if exposure is not None: -# logger.warning( -# "Ignoring exposure to calculate deadtime: %s, defaulting to %s", -# exposure, -# default_deadtime, -# ) -# return default_deadtime - -# async def prepare(self, trigger_info: TriggerInfo) -> None: -# """Prepare the detector for acquisition.""" -# if (exposure := trigger_info.livetime) is not None: -# await self.driver.acquire_time.set(exposure) - -# # Configure trigger mode based on TriggerInfo -# if trigger_info.trigger == DetectorTrigger.INTERNAL: -# await self.driver.trigger_mode.set(EigerTriggerMode.INTERNAL_SERIES) -# elif trigger_info.trigger == DetectorTrigger.EDGE_TRIGGER: -# await self.driver.trigger_mode.set(EigerTriggerMode.EXTERNAL_SERIES) -# else: -# msg = f"Trigger mode {trigger_info.trigger} not supported" -# raise NotImplementedError(msg) - -# if trigger_info.total_number_of_exposures == 0: -# image_mode = ADImageMode.CONTINUOUS -# else: -# image_mode = ADImageMode.MULTIPLE - -# if isinstance(trigger_info.number_of_events, list): -# logger.warning( -# "Got a list for number of events, expected to be set up externally: %s", -# trigger_info.number_of_events, -# ) -# else: -# await self.driver.num_triggers.set(trigger_info.number_of_events) - -# await asyncio.gather( -# self.driver.num_images.set(trigger_info.exposures_per_event), -# self.driver.image_mode.set(image_mode), -# ) - - -# class EigerDetector(AreaDetector[EigerController]): -# """Eiger detector implementation using the AreaDetector pattern.""" - -# def __init__( -# self, -# prefix: str, -# path_provider: PathProvider, -# driver_suffix: str = "cam1:", -# writer_cls: type[ADWriter] = EigerWriter, # type: ignore[reportUnknownParameterType] -# fileio_suffix: str | None = None, -# name: str = "", -# config_sigs: Sequence[SignalR[SignalDatatypeT]] = (), -# plugins: dict[str, NDPluginBaseIO] | None = None, -# ): -# driver = EigerDriverIO(prefix + driver_suffix) -# controller = EigerController(driver) -# if issubclass(writer_cls, EigerWriter): -# dataset_describer = ADBaseDatasetDescriber(driver) -# # EigerWriter takes the driver as the fileio, since it relies on driver PVs -# writer = writer_cls( -# driver, -# path_provider, -# dataset_describer=dataset_describer, -# plugins=plugins, -# ) -# else: -# writer = writer_cls.with_io( -# prefix, -# path_provider, -# dataset_source=driver, -# fileio_suffix=fileio_suffix, -# plugins=plugins, -# ) - -# super().__init__( -# controller=controller, -# writer=writer, -# plugins=plugins, -# name=name, -# config_sigs=config_sigs, -# ) +class EigerController(DetectorTriggerLogic): + """Controller for Eiger detector, handling trigger modes and acquisition setup.""" + + def __init__(self, driver: EigerDriverIO) -> None: + self.driver = driver + + def get_deadtime(self, exposure: float | None) -> float: + """Get detector deadtime for the given exposure.""" + default_deadtime = 0.000001 + if exposure is not None: + logger.warning( + "Ignoring exposure to calculate deadtime: %s, defaulting to %s", + exposure, + default_deadtime, + ) + return default_deadtime + + async def prepare_internal(self, num: int, livetime: float, deadtime: float): + """Prepare the detector for acquisition. + https://areadetector.github.io/areaDetector/ADEiger/eiger.html#implementation-of-standard-driver-parameters + """ + # TODO - should we do something with deadtime? + # TODO - put other awaits into the gather + if livetime > 0: + await self.driver.acquire_time.set(livetime) + + await self.driver.trigger_mode.set(EigerTriggerMode.INTERNAL_SERIES) + + if num == 0: + image_mode = ADImageMode.CONTINUOUS + else: + image_mode = ADImageMode.MULTIPLE + + # TODO - should we set num_images here? + # num_triggers gets overwritten in .prepare_unbounded(), which gets called further + # alone in .prepare() + # await self.driver.num_triggers.set(num), + await asyncio.gather( + self.driver.image_mode.set(image_mode), + ) + + # TODO should num_triggers or num_images be set? + # TODO - put other awaits into the gather + async def prepare_edge(self, num: int, livetime: float): + """Prepare the detector to take external edge triggered exposures. + + :param num: the number of exposures to take + :param livetime: how long the exposure should be, 0 means what is currently set + """ + + await self.driver.acquire_time.set(livetime) + await self.driver.num_triggers.set(num) + if num == 0: + image_mode = ADImageMode.CONTINUOUS + else: + image_mode = ADImageMode.MULTIPLE + + await self.driver.trigger_mode.set(EigerTriggerMode.EXTERNAL_SERIES) + await asyncio.gather( + self.driver.image_mode.set(image_mode), + ) + + async def default_trigger_info(self): + return await trigger_info_from_num_images(self.driver) + + +class EigerDataLogic(DetectorDataLogic): + """Eiger-specific file writer using the built-in FileWriter interface.""" + + default_suffix: str = "cam1:" + # Forced minimum number of images per file to force a single HDF5 file + _min_num_images_per_file: int = 1_000_000_000 + + def __init__( + self, + fileio: EigerDriverIO, + path_provider: PathProvider, + ): + self.fileio = fileio + self._path_provider = path_provider + + self._file_info: PathInfo | None = None + self._datasets: list[StreamResourceDataProvider] = [] + self._master_file_path_cache: list[Path] = [] + + async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvider: + """Provider can work for an unbounded number of collections.""" + # Get file path info from path provider + # TODO: should probably just pass datakey_name + self._file_info = self._path_provider("eiger2-1") + self._master_file_path_cache.clear() + + # Set the name pattern with $id replacement similar to original + name_pattern = f"{self._file_info.filename}_$id" + + # Configure the Eiger FileWriter + await asyncio.gather( + self.fileio.file_path.set(self._file_info.directory_path.as_posix()), + self.fileio.create_directory.set(self._file_info.create_dir_depth), + self.fileio.fw_name_pattern.set(name_pattern), + self.fileio.fw_enable.set(True), + self.fileio.save_files.set(True), + self.fileio.num_capture.set(0), + # Use array_counter to track the total number of images written + self.fileio.array_counter.set(0), + self.fileio.manual_trigger.set(True), + # TODO sort out how to get this from the plan + self.fileio.num_triggers.set(5000), + self.fileio.data_source.set(EigerDataSource.STREAM) + ) + + await set_and_wait_for_other_value( + set_signal=self.fileio.acquire, + set_value=True, + match_signal=self.fileio.armed, + match_value=True, + wait_for_set_completion=False, + timeout=DEFAULT_TIMEOUT, + ) + + if not await self.fileio.file_path_exists.get_value(): + msg = f"File path {self._file_info.directory_path} does not exist" + raise FileNotFoundError(msg) + + if isinstance(self.fileio, Eiger2DriverIO): + await self.fileio.fw_hdf5_format.set(EigerHDF5Format.LEGACY) + + # Force the number of images per file to a large number to simplify the logic + # TODO: allow multiple files + num_images_per_file = await self.fileio.fw_nimgs_per_file.get_value() + if num_images_per_file < self._min_num_images_per_file: + await self.fileio.fw_nimgs_per_file.set(self._min_num_images_per_file) + logger.warning( + "Setting fw_nimgs_per_file to %d to force writing to a single HDF5 file", + self._min_num_images_per_file, + ) + driver = self.fileio + + shape = await asyncio.gather( + *[sig.get_value() for sig in [driver.array_size_y, driver.array_size_x]] + ) + datatype = "uint32" + # Remove entries in shape that are zero + shape = [x for x in shape if x > 0] + + mfp = await self._master_file_path + # TODO sort out how to get from parent + # TODO - should this be the datakey_name that gets passed in? + name = "eiger" + exposures_per_event = await self.fileio.num_images.get_value() + + # TODO sort out how to tell tiled about the additional data files. + return StreamResourceDataProvider( + uri=urlunparse(("file", "localhost", str(mfp), "", "", None)), + resources=[ + StreamResourceInfo( + data_key=f"{name}_image", + shape=(exposures_per_event, *shape), + # TODO sort out how to set this and mirror here + chunk_shape=(1, *shape), + dtype_numpy=np.dtype(datatype.lower()).str, + parameters={ + "dataset": f"entry/data/data_{1:06d}", + }, + # TODO put in better value; should it match EigerDataSource.FILE_WRITER? + source=EigerDataSource.STREAM, + ) + ], + mimetype="application/x-hdf5", + collections_written_signal=self.fileio.array_counter, + ) + + @property + async def _master_file_path(self) -> Path | None: + if self._file_info is None: + logger.warning( + "No master file path found for file info %s", + self._file_info, + ) + return None + sequence_id = await self.fileio.sequence_id.get_value() + return Path( + self._file_info.directory_path + / f"{self._file_info.filename}_{sequence_id}_master.h5" + ) + + async def observe_indices_written( + self, timeout: float + ) -> AsyncGenerator[int, None]: + async for num_captured in observe_value(self.fileio.array_counter, timeout): + yield num_captured + + async def get_indices_written(self) -> int: + return await self.fileio.array_counter.get_value() + + async def stop(self) -> None: + """Clean up file writing after acquisition and validate files exist.""" + + # Check that the master files were written + # for master_file_path in self._master_file_path_cache: + # if not master_file_path.exists(): + # ... + + self._file_info = None + await self.fileio.fw_enable.set(False) + + +# TODO sort out if ths is the right name of things +class EigerArmLogic(DetectorArmLogic): + def __init__( + self, driver: Eiger2DriverIO, driver_armed_signal: SignalR[bool] | None = None + ): + self.driver = driver + # TODO - remove? driver_armed_signal doesn't seem to be a thing anywhere else + if driver_armed_signal is not None: + self.driver_armed_signal = driver_armed_signal + else: + self.driver_armed_signal = driver.acquire + self.acquire_status: AsyncStatus | None = None + self._rolling_image_counter = 0 + + async def arm(self): + self._rolling_image_counter = await self.driver.num_images_counter.get_value() + ret = await self.driver.trigger.set(1) + return ret + + async def wait_for_idle(self): + target_num_images, frame_acquire_period = await asyncio.gather(self.driver.num_images.get_value(), + self.driver.acquire_period.get_value()) + frame_timeout = frame_acquire_period + DEFAULT_TIMEOUT + done_timeout = frame_timeout * target_num_images + target_num_images += self._rolling_image_counter + async for images_complete in observe_value(self.driver.num_images_counter, timeout=frame_timeout, done_timeout=done_timeout): + if images_complete == target_num_images: + break + + async def disarm(self): + self._rolling_image_counter = 0 + await stop_busy_record(self.driver.acquire) + + await asyncio.gather( + self.driver.manual_trigger.set(False), + self.driver.num_triggers.set(1), + ) + + +class EigerDetector(AreaDetector[Eiger2DriverIO]): + """Eiger detector implementation using the AreaDetector pattern.""" + + def __init__( + self, + prefix: str, + path_provider: PathProvider, + driver_suffix: str = "cam1:", + name: str = "", + config_sigs: Sequence[SignalR[SignalDatatypeT]] = (), + plugins: dict[str, NDPluginBaseIO] | None = None, + ): + driver = Eiger2DriverIO(prefix + driver_suffix) + controller = EigerController(driver) + arm_logic = EigerArmLogic(driver) + super().__init__( + prefix=prefix, + driver=driver, + trigger_logic=controller, + writer_type=None, + name=name, + config_sigs=config_sigs, + plugins=plugins, + arm_logic=arm_logic, + ) + self.data_logic = EigerDataLogic(fileio=driver, path_provider=path_provider) + self.add_detector_logics(self.data_logic) + + # TODO remove this as it should be identical to upstream. + @WatchableAsyncStatus.wrap + async def trigger(self) -> AsyncIterator[WatcherUpdate[int]]: + """Trigger a single exposure. + + If [`prepare()`](#StandardDetector.prepare) has not been called since + the last [`stage()`](#StandardDetector.stage), an implicit prepare is + performed. When [](#OPHYD_ASYNC_PRESERVE_DETECTOR_STATE) is `YES` + [](#DetectorTriggerLogic.default_trigger_info) is called to read current + hardware state; otherwise a bare [`TriggerInfo()`](#TriggerInfo) is + used. + """ + if self._prepare_ctx is None: + # Opt-in: set OPHYD_ASYNC_PRESERVE_DETECTOR_STATE=YES to have + # trigger() read back current hardware state (e.g. num_images) via + # default_trigger_info() instead of always falling back to TriggerInfo(). + # See ADR 0013 for rationale. + # TODO: flip default to YES and remove this guard in a future PR once + # downstream code has had time to implement default_trigger_info(). + preserve_state = ( + os.environ.get("OPHYD_ASYNC_PRESERVE_DETECTOR_STATE", "NO").upper() + == "YES" + ) + if preserve_state and self._trigger_logic is not None: + + def _logic_supported(base_class, method) -> bool: + # If the function that is bound in a subclass is the same as the function + # attached to the superclass, then the subclass has not overridden it, so + # this method is not supported by the subclass. + return method.__func__ is not getattr(base_class, method.__name__) + + _trigger_logic_supported = functools.partial( + _logic_supported, DetectorTriggerLogic + ) + if not _trigger_logic_supported( + self._trigger_logic.default_trigger_info + ): + raise RuntimeError( + f"OPHYD_ASYNC_PRESERVE_DETECTOR_STATE=YES is set but " + f"'{self.name}' has no default_trigger_info() - implement " + "default_trigger_info() on your DetectorTriggerLogic subclass " + "or unset the environment variable." + ) + trigger_info = await self._trigger_logic.default_trigger_info() + else: + trigger_info = TriggerInfo() + await self.prepare(trigger_info) + else: + # Check the one that was provided is suitable for triggering + trigger_info = self._prepare_ctx.trigger_info + if trigger_info.number_of_events != 1: + msg = ( + "trigger() is not supported for multiple events, the detector was " + f"prepared with number_of_events={trigger_info.number_of_events}." + ) + raise ValueError(msg) + # Ensure the data provider is still usable + await self._update_prepare_context(trigger_info) + ctx = error_if_none(self._prepare_ctx, "Prepare should have been run") + # Arm the detector and wait for it to finish. + if self._arm_logic: + await self._arm_logic.arm() + + async for update in self._wait_for_index( + data_providers=ctx.streamable_data_providers, + trigger_info=ctx.trigger_info, + initial_collections_written=ctx.collections_written, + collections_requested=1, + wait_for_idle=True, + ): + yield update diff --git a/src/cditools/screens.py b/src/cditools/screens.py index 7fcd69b..d2e22c8 100644 --- a/src/cditools/screens.py +++ b/src/cditools/screens.py @@ -7,7 +7,6 @@ Device, EpicsMotor, EpicsSignal, - ImagePlugin, ProsilicaDetector, ProsilicaDetectorCam, ROIPlugin, diff --git a/tests/test_eiger_async.py b/tests/test_eiger_async.py index da60bd8..9c5bab1 100644 --- a/tests/test_eiger_async.py +++ b/tests/test_eiger_async.py @@ -6,16 +6,16 @@ import asyncio import shutil -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from pathlib import Path import bluesky.plans as bp import h5py import numpy as np import pytest +import pytest_asyncio from bluesky.callbacks.tiled_writer import TiledWriter from bluesky.run_engine import RunEngine -from event_model import StreamDatum, StreamResource from ophyd_async.core import ( DetectorTrigger, PathProvider, @@ -24,7 +24,7 @@ TriggerInfo, init_devices, ) -from ophyd_async.epics.adcore import ADBaseDatasetDescriber, ADBaseDataType, ADImageMode +from ophyd_async.epics.adcore import ADBaseDataType, ADImageMode from ophyd_async.testing import ( callback_on_mock_put, set_mock_value, @@ -33,11 +33,11 @@ from cditools.eiger_async import ( EigerController, + EigerDataLogic, EigerDataSource, EigerDetector, EigerDriverIO, EigerTriggerMode, - EigerWriter, ) EIGER_DATA_PATH = Path("/tmp/pytest/eiger_data/") @@ -101,10 +101,19 @@ def mock_eiger_detector(RE: RunEngine) -> Generator[EigerDetector, None, None]: ) with init_devices(mock=True): detector = EigerDetector("MOCK:EIGER:", path_provider, name="test_eiger") - set_mock_value(detector.fileio.file_path_exists, True) + + set_mock_value(detector.driver.file_path_exists, True) set_mock_value(detector.driver.array_size_x, 2048) set_mock_value(detector.driver.array_size_y, 2048) set_mock_value(detector.driver.data_type, "UInt16") + set_mock_value(detector.driver.acquire, False) + set_mock_value(detector.data_logic.fileio.armed, False) + + # Sync acquire with armed when acquire is set + async def sync_fileio_armed(value: bool): + set_mock_value(detector.data_logic.fileio.armed, value) + + callback_on_mock_put(detector.driver.acquire, sync_fileio_armed) yield detector @@ -135,17 +144,25 @@ def mock_path_provider() -> PathProvider: ) -@pytest.fixture -def eiger_writer( +@pytest_asyncio.fixture +async def eiger_writer( mock_eiger_driver: EigerDriverIO, mock_path_provider: PathProvider, -) -> Generator[EigerWriter, None, None]: +) -> AsyncGenerator[EigerDataLogic, None]: """Create an EigerWriter instance for testing.""" if not EIGER_DATA_PATH.exists(): EIGER_DATA_PATH.mkdir(parents=True) assert EIGER_DATA_PATH.exists() - dataset_describer = ADBaseDatasetDescriber(mock_eiger_driver) - yield EigerWriter(mock_eiger_driver, mock_path_provider, dataset_describer) + + with init_devices(mock=True): + datalogic = EigerDataLogic(mock_eiger_driver, mock_path_provider) + + async def sync_fileio_armed(value: bool): + set_mock_value(datalogic.fileio.armed, value) + + callback_on_mock_put(datalogic.fileio.acquire, sync_fileio_armed) + # yield EigerDataLogic(mock_eiger_driver, mock_path_provider) + yield datalogic if EIGER_DATA_PATH.exists(): shutil.rmtree(EIGER_DATA_PATH) @@ -157,20 +174,19 @@ def eiger_controller(mock_eiger_driver: EigerDriverIO) -> EigerController: @pytest.mark.asyncio async def test_eiger_writer_initialization( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, mock_path_provider: PathProvider, ): - """Test that EigerWriter initializes correctly.""" + """Test that EigerDataLogic initializes correctly.""" assert eiger_writer.fileio is mock_eiger_driver assert eiger_writer._path_provider is mock_path_provider # type: ignore[reportPrivateUsage] - assert eiger_writer._dataset_describer is not None # type: ignore[reportPrivateUsage] assert eiger_writer._file_info is None # type: ignore[reportPrivateUsage] @pytest.mark.asyncio -async def test_eiger_writer_open( - eiger_writer: EigerWriter, +async def test_eiger_data_logic_prepare_unbounded( + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: """Test the open method configures the detector correctly.""" @@ -183,58 +199,28 @@ async def test_eiger_writer_open( set_mock_value(mock_eiger_driver.sequence_id, 0) set_mock_value(mock_eiger_driver.num_images, 1) - description = await eiger_writer.open(name="test_eiger", exposures_per_event=1) + streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") assert await mock_eiger_driver.fw_enable.get_value() is True assert await mock_eiger_driver.save_files.get_value() is True - assert description.keys() == { - # TODO: Add these when empty shape datasets are supported by tiled - # "test_eiger_y_pixel_size", - # "test_eiger_x_pixel_size", - # "test_eiger_detector_distance", - # "test_eiger_incident_wavelength", - # "test_eiger_frame_time", - # "test_eiger_beam_center_x", - # "test_eiger_beam_center_y", - # "test_eiger_count_time", - # "test_eiger_pixel_mask", - "test_eiger_image", - } - assert description["test_eiger_image"]["source"] == "ADEiger FileWriter" + # TODO data_key should probably match datakey_name actually + assert streamDataProv.resources[0].data_key == "eiger_image" + assert streamDataProv.resources[0].source == "STREAM" # Case 2: 4 images per file, 11 images, 2 triggers # Expect 6 files, the first 5 will have 4 images, the last will have 2 set_mock_value(mock_eiger_driver.sequence_id, 1) set_mock_value(mock_eiger_driver.num_images, 11) - description = await eiger_writer.open( - name="test_eiger", - exposures_per_event=await mock_eiger_driver.num_images.get_value(), - ) - assert description.keys() == { - # TODO: Add these when empty shape datasets are supported by tiled - # "test_eiger_y_pixel_size", - # "test_eiger_x_pixel_size", - # "test_eiger_detector_distance", - # "test_eiger_incident_wavelength", - # "test_eiger_frame_time", - # "test_eiger_beam_center_x", - # "test_eiger_beam_center_y", - # "test_eiger_count_time", - # "test_eiger_pixel_mask", - "test_eiger_image", - } - data_key = description["test_eiger_image"] - assert tuple(data_key["shape"]) == (11, array_size_x, array_size_y) - assert data_key["dtype"] == "array" - assert "dtype_numpy" in data_key - assert data_key["dtype_numpy"] == np.dtype(np.uint32).str - assert "external" in data_key - assert data_key["external"] == "STREAM:" - assert data_key["source"] == "ADEiger FileWriter" + streamDataProv = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") + streamResourceProv = streamDataProv.resources[0] + assert streamResourceProv.data_key == "eiger_image" + assert streamResourceProv.shape == (11, array_size_x, array_size_y) + assert streamResourceProv.dtype_numpy == np.dtype(np.uint32).str + assert streamResourceProv.source == "STREAM" @pytest.mark.asyncio async def test_eiger_writer_get_indices_written( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ): """Test getting the number of indices written.""" @@ -243,9 +229,8 @@ async def test_eiger_writer_get_indices_written( # Case 1: 1 image, 1 trigger set_mock_value(mock_eiger_driver.num_images, 1) set_mock_value(mock_eiger_driver.array_counter, 0) - await eiger_writer.open( - name="test_eiger", - exposures_per_event=await mock_eiger_driver.num_images.get_value(), + await eiger_writer.prepare_unbounded( + datakey_name="test_eiger" ) assert await eiger_writer.get_indices_written() == 0 set_mock_value(mock_eiger_driver.array_counter, 1) @@ -254,9 +239,8 @@ async def test_eiger_writer_get_indices_written( # Case 2: 1 image, 5 triggers set_mock_value(mock_eiger_driver.num_images, 1) set_mock_value(mock_eiger_driver.array_counter, 0) - await eiger_writer.open( - name="test_eiger", - exposures_per_event=await mock_eiger_driver.num_images.get_value(), + await eiger_writer.prepare_unbounded( + datakey_name="test_eiger" ) assert await eiger_writer.get_indices_written() == 0 set_mock_value(mock_eiger_driver.array_counter, 1) @@ -266,12 +250,19 @@ async def test_eiger_writer_get_indices_written( set_mock_value(mock_eiger_driver.array_counter, 5) assert await eiger_writer.get_indices_written() == 5 + +# TODO - should we add this? +@pytest.mark.skip("Driver does not currently allow setting num_images per trigger") +@pytest.mark.asyncio +async def test_eiger_writer_get_indices_written_multi_images( + eiger_writer: EigerDataLogic, + mock_eiger_driver: EigerDriverIO, +): # Case 3: 5 images, 2 triggers set_mock_value(mock_eiger_driver.num_images, 5) set_mock_value(mock_eiger_driver.array_counter, 0) - await eiger_writer.open( - name="test_eiger", - exposures_per_event=await mock_eiger_driver.num_images.get_value(), + await eiger_writer.prepare_unbounded( + datakey_name="test_eiger" ) assert await eiger_writer.get_indices_written() == 0 set_mock_value(mock_eiger_driver.array_counter, 4) @@ -286,7 +277,7 @@ async def test_eiger_writer_get_indices_written( @pytest.mark.asyncio async def test_eiger_writer_observe_indices_written( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: """Test observing indices as they are written.""" @@ -329,7 +320,7 @@ async def _complete(): set_mock_value(mock_eiger_driver.num_triggers, 1) num_images = await mock_eiger_driver.num_images.get_value() num_triggers = await mock_eiger_driver.num_triggers.get_value() - await eiger_writer.open(name="test_eiger", exposures_per_event=num_images) + await eiger_writer.prepare_unbounded(datakey_name="test_eiger") observed = await _simulate_writing_indices( num_images=num_images, num_triggers=num_triggers ) @@ -340,18 +331,26 @@ async def _complete(): set_mock_value(mock_eiger_driver.num_triggers, 5) num_images = await mock_eiger_driver.num_images.get_value() num_triggers = await mock_eiger_driver.num_triggers.get_value() - await eiger_writer.open(name="test_eiger", exposures_per_event=num_images) + await eiger_writer.prepare_unbounded(datakey_name="test_eiger") observed = await _simulate_writing_indices( num_images=num_images, num_triggers=num_triggers ) assert observed == [0, 1, 2, 3, 4, 5] + +# TODO - should we add this? +@pytest.mark.skip("Driver does not currently allow setting num_images per trigger") +@pytest.mark.asyncio +async def test_eiger_writer_observe_indices_written_multi_image( + eiger_writer: EigerDataLogic, + mock_eiger_driver: EigerDriverIO, +) -> None: # Case 3: 5 images, 2 triggers set_mock_value(mock_eiger_driver.num_images, 5) set_mock_value(mock_eiger_driver.num_triggers, 2) num_images = await mock_eiger_driver.num_images.get_value() num_triggers = await mock_eiger_driver.num_triggers.get_value() - await eiger_writer.open(name="test_eiger", exposures_per_event=num_images) + await eiger_writer.prepare_unbounded(datakey_name="test_eiger") observed = await _simulate_writing_indices( num_images=num_images, num_triggers=num_triggers ) @@ -360,64 +359,55 @@ async def _complete(): @pytest.mark.asyncio async def test_eiger_writer_collect_stream_docs( - eiger_writer: EigerWriter, + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: """Test collecting stream documents.""" - async def collect_docs( - num_triggers: int, - ) -> tuple[list[StreamResource], list[StreamDatum]]: - resource_docs = [] - data_docs = [] - for i in range(1, num_triggers + 1): - sequence_id = await mock_eiger_driver.sequence_id.get_value() - set_mock_value(mock_eiger_driver.sequence_id, sequence_id + 1) - async for doc_type, doc in eiger_writer.collect_stream_docs( - name="", indices_written=i - ): - if doc_type == "stream_resource": - resource_docs.append(doc) - elif doc_type == "stream_datum": - data_docs.append(doc) - return resource_docs, data_docs - set_mock_value(mock_eiger_driver.sequence_id, 0) set_mock_value(mock_eiger_driver.num_images, 1) - await eiger_writer.open(name="test_eiger", exposures_per_event=1) - resource_docs, data_docs = await collect_docs(num_triggers=1) + + provider = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") + # simulate first trigger + set_mock_value(mock_eiger_driver.sequence_id, 1) + set_mock_value(mock_eiger_driver.array_counter, 1) + + resource_docs = [] + data_docs = [] + async for doc_type, doc in provider.make_stream_docs(1, 1): + if doc_type == "stream_resource": + resource_docs.append(doc) + elif doc_type == "stream_datum": + data_docs.append(doc) + assert len(resource_docs) == 1 assert len(data_docs) == 1 assert ( resource_docs[0]["uri"] - == f"file://localhost{EIGER_DATA_PATH}/test_eiger_1_master.h5" + == f"file://localhost{EIGER_DATA_PATH}/test_eiger_0_master.h5" ) - await eiger_writer.close() - - await eiger_writer.open(name="test_eiger", exposures_per_event=1) - resource_docs, data_docs = await collect_docs(num_triggers=3) - assert len(resource_docs) == 3 - assert len(data_docs) == 3 - # There are 10 different datasets inside a single master file - # 3 triggers, so 30 total resources/datasets - assert ( - resource_docs[0]["uri"] - == f"file://localhost{EIGER_DATA_PATH}/test_eiger_2_master.h5" - ) - assert ( - resource_docs[1]["uri"] - == f"file://localhost{EIGER_DATA_PATH}/test_eiger_3_master.h5" - ) - assert ( - resource_docs[2]["uri"] - == f"file://localhost{EIGER_DATA_PATH}/test_eiger_4_master.h5" - ) + await eiger_writer.stop() + + provider2 = await eiger_writer.prepare_unbounded(datakey_name="test_eiger") + resource_docs = [] + data_docs = [] + for i in range(1, 4): + set_mock_value(mock_eiger_driver.sequence_id, i + 1) + set_mock_value(mock_eiger_driver.array_counter, i) + async for doc_type, doc in provider2.make_stream_docs(i, i): + if doc_type == "stream_resource": + resource_docs.append(doc) + elif doc_type == "stream_datum": + data_docs.append(doc) + assert len(resource_docs) == 1 + assert len(data_docs) == 1 + assert await provider2.collections_written_signal.get_value() == 3 @pytest.mark.asyncio -async def test_eiger_writer_close( - eiger_writer: EigerWriter, +async def test_eiger_writer_stop( + eiger_writer: EigerDataLogic, mock_eiger_driver: EigerDriverIO, ) -> None: """Test closing the writer.""" @@ -425,51 +415,74 @@ async def test_eiger_writer_close( # Verify the writing was enabled set_mock_value(mock_eiger_driver.sequence_id, 1) set_mock_value(mock_eiger_driver.num_images, 1) - await eiger_writer.open(name="test_eiger", exposures_per_event=1) + await eiger_writer.prepare_unbounded(datakey_name="test_eiger") assert await mock_eiger_driver.fw_enable.get_value() is True assert await mock_eiger_driver.save_files.get_value() is True # Verify the writing was disabled - await eiger_writer.close() + await eiger_writer.stop() assert eiger_writer._file_info is None # type: ignore[reportPrivateUsage] - @pytest.mark.asyncio -async def test_eiger_controller_prepare(eiger_controller: EigerController) -> None: +async def test_eiger_prepare(mock_eiger_detector: EigerDetector) -> None: trigger_info = TriggerInfo( - number_of_events=1, + trigger=DetectorTrigger.INTERNAL, livetime=0.01, deadtime=0.001, - trigger=DetectorTrigger.INTERNAL, + exposures_per_collection=1, + collections_per_event=1, + number_of_events=1, exposure_timeout=1.0, - exposures_per_event=1, ) - await eiger_controller.prepare(trigger_info) - assert await eiger_controller.driver.acquire_time.get_value() == 0.01 + await mock_eiger_detector.prepare(trigger_info) + assert await mock_eiger_detector.driver.acquire_time.get_value() == 0.01 assert ( - await eiger_controller.driver.trigger_mode.get_value() + await mock_eiger_detector.driver.trigger_mode.get_value() == EigerTriggerMode.INTERNAL_SERIES ) - assert await eiger_controller.driver.num_images.get_value() == 1 - assert await eiger_controller.driver.image_mode.get_value() == ADImageMode.MULTIPLE + # num_triggers in this context is the number of triggers + assert await mock_eiger_detector.driver.num_triggers.get_value() == 5000 + assert await mock_eiger_detector.driver.image_mode.get_value() == ADImageMode.MULTIPLE + assert await mock_eiger_detector.events_to_kickoff.get_value() == 1 + # Implement tests for these other trigger_infos trigger_info = TriggerInfo( - number_of_events=10, + trigger=DetectorTrigger.EXTERNAL_EDGE, livetime=0.0, deadtime=0.0, - trigger=DetectorTrigger.EDGE_TRIGGER, + exposures_per_collection=5, + collections_per_event=1, + number_of_events=10, exposure_timeout=10.0, - exposures_per_event=5, ) - await eiger_controller.prepare(trigger_info) + +@pytest.mark.skip("What should `num` do in `prepare_internal`?") +@pytest.mark.asyncio +async def test_eiger_controller_prepare_internal(eiger_controller: EigerController) -> None: + await eiger_controller.prepare_internal(num=1, livetime=0.01, deadtime=0.001) + assert await eiger_controller.driver.acquire_time.get_value() == 0.01 + assert ( + await eiger_controller.driver.trigger_mode.get_value() + == EigerTriggerMode.INTERNAL_SERIES + ) + assert await eiger_controller.driver.num_triggers.get_value() == 1 + assert await eiger_controller.driver.image_mode.get_value() == ADImageMode.MULTIPLE + +@pytest.mark.asyncio +async def test_eiger_controller_prepare_edge(eiger_controller: EigerController) -> None: + await eiger_controller.prepare_edge(num=5, livetime=0.0) assert await eiger_controller.driver.acquire_time.get_value() == 0.0 assert ( await eiger_controller.driver.trigger_mode.get_value() == EigerTriggerMode.EXTERNAL_SERIES ) - assert await eiger_controller.driver.num_images.get_value() == 5 + assert await eiger_controller.driver.num_triggers.get_value() == 5 assert await eiger_controller.driver.image_mode.get_value() == ADImageMode.MULTIPLE + +@pytest.mark.skip("Does this test reflect any kind of desired behavior?") +@pytest.mark.asyncio +async def test_eiger_controller_prepare_edge2(eiger_controller: EigerController) -> None: trigger_info = TriggerInfo( number_of_events=0, livetime=None, @@ -478,7 +491,7 @@ async def test_eiger_controller_prepare(eiger_controller: EigerController) -> No exposure_timeout=10.0, exposures_per_event=1, ) - await eiger_controller.prepare(trigger_info) + await eiger_controller.prepare_edge(num=0, livetime=None) assert await eiger_controller.driver.acquire_time.get_value() == 0.0 assert ( await eiger_controller.driver.trigger_mode.get_value() @@ -494,21 +507,39 @@ async def test_eiger_controller_prepare(eiger_controller: EigerController) -> No async def test_eiger_detector(mock_eiger_detector: EigerDetector) -> None: set_mock_value(mock_eiger_detector.driver.num_images, 1) set_mock_value(mock_eiger_detector.driver.acquire_period, 0.001) - set_mock_value(mock_eiger_detector.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.driver.num_images_counter, 0) - async def _simulate_one_trigger(value: bool, wait: bool) -> None: + async def _simulate_one_trigger(value: bool) -> None: await asyncio.sleep(await mock_eiger_detector.driver.acquire_period.get_value()) - array_counter = await mock_eiger_detector.fileio.array_counter.get_value() - set_mock_value(mock_eiger_detector.fileio.array_counter, array_counter + 1) + array_counter = await mock_eiger_detector.data_logic.fileio.array_counter.get_value() + set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, array_counter + 1) + num_images_counter = await mock_eiger_detector.driver.num_images_counter.get_value() + set_mock_value(mock_eiger_detector.driver.num_images_counter, num_images_counter + 1) - callback_on_mock_put(mock_eiger_detector.driver.acquire, _simulate_one_trigger) + callback_on_mock_put(mock_eiger_detector.driver.trigger, _simulate_one_trigger) # Standalone methods + await mock_eiger_detector.prepare( + TriggerInfo( + trigger=DetectorTrigger.INTERNAL, + livetime=0.01, + deadtime=0.001, + exposures_per_collection=1, + collections_per_event=1, + number_of_events=1, + exposure_timeout=10.0, + ) + ) await mock_eiger_detector.describe() # Case 1 - Step Scan: stage, trigger, read, trigger, read, unstage await mock_eiger_detector.stage() await mock_eiger_detector.trigger() + assert ( + await mock_eiger_detector.data_logic.fileio.data_source.get_value() + == EigerDataSource.FILE_WRITER + ) assert ( await mock_eiger_detector.driver.data_source.get_value() == EigerDataSource.FILE_WRITER @@ -518,16 +549,18 @@ async def _simulate_one_trigger(value: bool, wait: bool) -> None: await mock_eiger_detector.read() await mock_eiger_detector.unstage() - set_mock_value(mock_eiger_detector.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.driver.num_images_counter, 0) # Case 2 - Fly Scan: prepare, kickoff, complete await mock_eiger_detector.prepare( TriggerInfo( - number_of_events=1, + trigger=DetectorTrigger.INTERNAL, livetime=0.01, deadtime=0.001, - trigger=DetectorTrigger.INTERNAL, + exposures_per_collection=1, + collections_per_event=1, + number_of_events=1, exposure_timeout=10.0, - exposures_per_event=1, ) ) await mock_eiger_detector.kickoff() @@ -539,31 +572,37 @@ async def test_eiger_detector_with_RE( RE: RunEngine, tiled_client: Container, mock_eiger_detector: EigerDetector ) -> None: RE.subscribe(print) - set_mock_value(mock_eiger_detector.fileio.array_counter, 0) + set_mock_value(mock_eiger_detector.data_logic.fileio.array_counter, 0) - async def _write_file(value: bool, wait: bool) -> None: + async def _write_file(value: bool) -> None: if value: - num_images = await mock_eiger_detector.driver.num_images.get_value() - sequence_id = await mock_eiger_detector.fileio.sequence_id.get_value() + 1 - set_mock_value(mock_eiger_detector.fileio.sequence_id, sequence_id) + sequence_id = await mock_eiger_detector.data_logic.fileio.sequence_id.get_value() + 1 + set_mock_value(mock_eiger_detector.data_logic.fileio.sequence_id, sequence_id) await asyncio.sleep( await mock_eiger_detector.driver.acquire_period.get_value() ) + + num_images = await mock_eiger_detector.driver.num_images.get_value() write_eiger_hdf5_file( num_images=num_images, sequence_id=sequence_id, name="test_eiger", ) - array_counter = await mock_eiger_detector.fileio.array_counter.get_value() + + num_images_counter = await mock_eiger_detector.driver.num_images_counter.get_value() + set_mock_value(mock_eiger_detector.driver.num_images_counter, num_images_counter + num_images) + + array_counter = await mock_eiger_detector.data_logic.fileio.array_counter.get_value() set_mock_value( - mock_eiger_detector.fileio.array_counter, array_counter + num_images + mock_eiger_detector.data_logic.fileio.array_counter, array_counter + num_images ) + set_mock_value(mock_eiger_detector.data_logic.fileio.armed, value) tiled_writer = TiledWriter(tiled_client) RE.subscribe(tiled_writer) - callback_on_mock_put(mock_eiger_detector.driver.acquire, _write_file) + callback_on_mock_put(mock_eiger_detector.driver.trigger, _write_file) - set_mock_value(mock_eiger_detector.fileio.sequence_id, 0) + set_mock_value(mock_eiger_detector.data_logic.fileio.sequence_id, 0) set_mock_value(mock_eiger_detector.driver.num_images, 1) set_mock_value(mock_eiger_detector.driver.acquire_period, 0.001) uid = RE(bp.count([mock_eiger_detector])) @@ -671,7 +710,7 @@ async def _write_file(value: bool, wait: bool) -> None: # == np.uint32 # ) - set_mock_value(mock_eiger_detector.fileio.sequence_id, 2) + set_mock_value(mock_eiger_detector.data_logic.fileio.sequence_id, 2) set_mock_value(mock_eiger_detector.driver.num_images, 1) set_mock_value(mock_eiger_detector.driver.acquire_period, 0.001) uid = RE(bp.count([mock_eiger_detector], num=10))