diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1aa031a..d064217 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: frozen: true - name: Run type checks - run: pixi run --as-is -e dev ty check src tests + run: pixi run --as-is -e dev type-check test: name: Unit Tests - Python ${{ matrix.python-version }} @@ -79,7 +79,9 @@ jobs: frozen: true - name: Run tests - run: pixi run --as-is -e ${{ steps.pixi.outputs.env }} pytest --cov=src/hextools --cov-report=xml --cov-report=term + # The GUI tests run in their own job: in one process with the rest, Qt objects + # they leave behind crash later tests when garbage-collected. + run: pixi run --as-is -e ${{ steps.pixi.outputs.env }} pytest --ignore=tests/gui --cov=src/hextools --cov-report=xml --cov-report=term gui-test: name: GUI Tests diff --git a/docs/developer.md b/docs/developer.md index 8a334b0..5055205 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -7,9 +7,9 @@ test it, and how to make it available in the GUI. ```bash pixi install -e dev -pixi run -e py313 tests # pytest with coverage -pixi run -e py313 lint # pre-commit hooks (ruff, ...) -pixi run -e py313 type-check # ty +pixi run tests # pytest with coverage +pixi run lint # pre-commit hooks (ruff, ...) +pixi run type-check # ty pixi run start-mock # try your code in a mock IPython session pixi run hex-gui-mock # try it in the GUI ``` diff --git a/docs/getting_started.md b/docs/getting_started.md index eb70003..aba557b 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -18,12 +18,11 @@ pixi install # solves and installs the default environment | Environment | Use it for | | -------------- | -------------------------------------------------------------------------- | -| `terminal` | The IPython command-line session on the beamline (`pixi run start`). | -| `gui` | The HEX Data Acquisition GUI against real hardware (`pixi run hex-gui`). | +| `terminal` | The IPython command-line session (`pixi run start`) and the HEX Data Acquisition GUI (`pixi run hex-gui`) on the beamline. | | `qs` | The Bluesky Queue Server backend and HTTP server. | | `dev` | Development tools, plus the terminal and GUI with simulated devices (`pixi run start-mock`, `pixi run hex-gui-mock`). | -Pixi picks the environment that defines a task automatically, so in most cases +Pixi picks a default environment for each task, so in most cases `pixi run ` is all you need. Pass `-e ` to choose one explicitly. @@ -59,7 +58,7 @@ pixi run qs-server # HTTP server on localhost:60610 pixi run qserver-gui # upstream bluesky-widgets queue monitor # Development -pixi run -e py313 tests -pixi run -e py313 lint -pixi run -e py313 type-check +pixi run tests +pixi run lint +pixi run type-check ``` diff --git a/pixi.lock b/pixi.lock index de11fc7..5d5dbf0 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1623,468 +1623,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/fe/47/deb64c73aec25af7699247e021153a6bfe9a08452f7f7337dcee4aa07a2b/historydict-1.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - gui: - channels: - - url: https://conda.anaconda.org/conda-forge/ - indexes: - - https://pypi.org/simple - packages: - linux-64: - - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-h7cc23a3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/awkward-cpp-57-py313h8debf8b_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.10.4-hb7a77c6_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.14-h2aa3ae6_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.14.2-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.2-h720e601_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.7.1-h6ffeea8_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.11.0-h38ae05a_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.27.3-h6f4d18d_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.16.0-h21f4ec5_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.12.8-h46fcd08_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.7-h720e601_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.10-h720e601_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.40.1-h102d43b_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.11.833-hc7390e0_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-core-cpp-1.16.3-h206d751_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-identity-cpp-1.13.3-h71f81a8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-blobs-cpp-12.18.0-h74b55db_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-common-cpp-12.14.0-hf596fc9_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-files-datalake-cpp-12.16.0-h1f05bef_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.8.0-py313h8f72f4d_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.1-h505cf86_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.1-h9908984_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.1-py313h2fc2bef_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.9-hebe6cf0_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/c-blosc2-3.3.5-hea3f660_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.6-h3c89d7e_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py313ha52865f_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/chardet-7.6.0-py313h2af15c8_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/charset-normalizer-3.5.2-h1114479_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.4.0-py313h2551ef2_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-50.0.2-py311hc91d8b8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cytoolz-1.2.0-py313hd42f317_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-he8c428d_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.22-py313h901f96d_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/fabio-2025.10.0-py313h29aa505_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.3-h4db4eae_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.67.0-py313h6cd9899_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.17-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/gflags-2.3.1-h54a6638_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/glog-0.7.1-hb7133d2_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hfd2156b_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/gmpy2-2.3.2-py313ha4a6282_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-h54a6638_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.5.6-py313h901f96d_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/h5py-3.16.0-nompi_py313hf402d47_105.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5plugin-7.1.0-py313h14eca21_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-h7cc23a3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.1-py313h2551ef2_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h9073bf1_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.2.0-hdb68285_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-25.0.0-hcc2f0d9_4_cpu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-acero-25.0.0-h635bf11_4_cpu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-compute-25.0.0-h53684a4_4_cpu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-dataset-25.0.0-h635bf11_4_cpu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-substrait-25.0.0-h66fbfdd_4_cpu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-11_h4a7cf45_openblas.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.1-h39a168f_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.1-ha411449_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.1-h018ffa1_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-11_h0358290_openblas.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp23.1-23.1.4-default_h0acdd01_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-23.1.4-default_he646a05_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libcrc32c-1.1.2-h9c3ff4c_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.23.0-ha042cf0_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.129-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libevent-2.1.12-h5348a74_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.5-hd2095e1_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h81df57d_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h5e6c136_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.2.0-h69a702a_7.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-16.2.0-h69a702a_7.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.2.0-h6b99dfc_7.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.90.0-h569388d_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-3.8.0-hbc29df5_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-storage-3.8.0-hdbdcf42_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.82.1-h284dbb2_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.6.0-h23af247_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-11_h47877c9_openblas.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm23-23.1.4-h474f4eb_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h74cf4be_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.34-pthreads_hf13c14d_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-1.27.0-h3133023_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-headers-1.27.0-ha770c72_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libparquet-25.0.0-h7376487_4_cpu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.59-h922cc85_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libpq-18.6-h3883810_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-7.35.1-h622638d_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libpsl-0.23.1-hd9e3e90_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.13.17-hd51c5da_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libraqm-0.11.0-h6406941_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libre2-11-2025.11.05-hbb87c76_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.22-hebe6cf0_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h13e7031_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-h6154650_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.2.0-hdf11a46_7.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.22.0-h7d032f7_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-hcc2c06a_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libutf8proc-2.11.3-hfe17d71_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.4-hcfc3c73_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h0e34353_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-hb83e432_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-h51789e4_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.45-h8e12856_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/llvmlite-0.50.0-py313hd9b1b65_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/lxml-6.1.3-py313h003a45f_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-4.4.5-py313h557dd98_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-hee9eb32_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.4-py313h2af15c8_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.2-py313he85fb2c_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/mpc-1.4.0-ha2cb11d_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/mpfr-4.2.2-ha2cb11d_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/msgpack-python-1.2.3-py313h2551ef2_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ndindex-1.10.1-py313hbdba758_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/numba-0.68.0-py313h901f96d_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.14.2-py313h2c65a36_101.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.4-py313hb5f73ae_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-heb1ab33_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.32.0-h2a08dbc_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openpyxl-3.1.5-py313heaa19e1_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/orc-2.3.1-h443056b_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/orjson-3.13.0-py313hc6c575f_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-3.1.0-np2py313h4900a6c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-h8b3dc9c_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-13.0.0-py313h3b26573_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/prometheus-cpp-1.3.0-h26ae035_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py313hd42f317_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-h7cc23a3_1004.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyarrow-25.0.0-py313h78bf25f_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyarrow-core-25.0.0-py313hcba6aca_1_cpu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pycryptodome-3.23.0-py313h6123c0d_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.50.1-py313ha296275_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.11.2-py313hd9d88b3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.17-hf47f18c_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-blosc2-4.14.1-py313h2139695_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pywavelets-1.10.0-py313hd42f317_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py313h3dea7bd_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.2.0-py312h8a5ba0d_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.11.2-pl5321hcf69127_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/re2-2025.11.05-hbf548d4_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/regex-2026.9.29-py313h995f894_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.9.1-py313ha296275_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/s2n-1.7.5-h7e3ee7f_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/scikit-image-0.26.0-np2py313hb172dc5_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.1-py313he2a5f1a_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h34e00bb_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.52-py313h54dd161_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.10-py313h995f894_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hc1c935e_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/wcwidth-0.9.3-py311hd139983_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/websockets-17.3-py313h7f1de9a_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-h280c20c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-h0d788c3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.14-hb83e432_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-h7cc23a3_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-h7cc23a3_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-h7cc23a3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-h7cc23a3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.4-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-h7cc23a3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.13-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.6-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-hebe6cf0_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-hebe6cf0_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h901266b_12.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hce19668_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py313hd42f317_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/_x86_64-microarch-level-1-5_level1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.20.1-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/algotom-1.7.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.5-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ansi2html-1.9.5-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.15.1-pyh5ded981_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/appdirs-1.4.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asteval-1.0.10-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhcf101f3_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/awkward-2.14.0-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/bluesky-tiled-plugins-2.0.12-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/bokeh-3.10.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.19-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-2.0.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-2.0.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.10.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.5.0-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colored-2.3.2-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.17-py313hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/darkdetect-0.8.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/dask-2026.8.0-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2026.8.0-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/dateparser-1.4.3-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/dill-0.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distributed-2026.8.0-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.22.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ecdsa-0.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/entrypoints-0.4.2-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/et_xmlfile-2.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/event-model-1.24.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.141.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2026.9.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/future-1.0.0-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/glymur-0.14.8-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h5netcdf-1.8.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.20-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/imageio-2.38.1-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/imagesize-2.0.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-resources-7.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-7.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.4.0-pyh4a3486f_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.17.1-pyh53cf698_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.1-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/joblib-1.6.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/json-merge-patch-0.2-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpatch-1.33-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.4-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/lazy-loader-0.6-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/linkify-it-py-2.2.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/lmfit-1.3.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.4.3-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mdit-py-plugins-0.6.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mongoquery-1.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mplcursors-0.7.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.27.1-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.3-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/networkx-3.7-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nexpy-2.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nexusformat-2.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nomkl-1.0-h5ca1d4c_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.2.2-pyh145f28c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.13.5-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/plotext-5.3.2-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyasn1-0.6.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.12-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.14.1-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-settings-2.11.0-pyh3cfb1c2_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyjwt-2.15.1-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pylatexenc-2.11-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.3-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-abi3-3.13-h9bebd56_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-3.0.0-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.4-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.17-h4df99d1_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-jose-3.5.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.32-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.5-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2026.5-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/qtconsole-base-5.7.2-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/qtpy-2.4.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ragged-0.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/redis-json-dict-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/redis-py-8.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/roman-numerals-4.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rsa-4.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.2.1-pyhe730653_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-scm-10.3.4-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-3.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sparse-0.19.2-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-9.2.0-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-2.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-2.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-2.0.0-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-2.0.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/stamina-26.1.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-1.9.0-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tblib-3.2.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tenacity-9.2.1-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/textual-8.2.8-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/textual-plotext-1.0.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.7.0-pyhc455866_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tifffile-2026.10.15-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tiled-base-0.2.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tiled-client-0.2.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.2.0-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.27.3-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzlocal-5.4.5-pyhfa0c392_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uc-micro-py-2.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uncertainties-3.2.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.8.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.54.0-pyh53cf698_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/vcs_versioning-2.6.0-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/xarray-2026.9.0-pyh5ded981_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/xyzservices-2026.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zict-3.0.0-pyhd8ed1ab_1.conda - - 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#7672549a145c59d5208ada6cc30c0f8262728b50 - - pypi: git+https://github.com/bluesky/ophyd-async?rev=main#de097ace33ce2fa0ea2eb22572ef81e5af133b16 - - pypi: git+https://github.com/jwlodek/ntnda-qt-viewer?rev=main#778e71444a14eb4adcd92c6417a4362ebea39425 - - 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 - - pypi: https://files.pythonhosted.org/packages/21/bd/4d1f59c9287ec5f93f9d879db3ac06785ba7c4d04a7120678d894e0c53d0/caproto-1.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/22/d5/2a9030f911d1b6c7bbbab34bd2e8ef7a42f0764ad000b6bad6275023e223/pvxslibs-1.5.3-cp313-cp313-manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/23/ef/3b82c9a5c6fe4bcc7ebafbc4a06efe4f631929441bddd5d53541434fb941/pyOlog-4.5.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/26/c2/2ec71837ac862d823de280e72c245686b37de6dde0a18a02c9c18f589c39/setuptools_dso-2.12.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/47/3b/0a007667d2077c197793beede94483f1efe57a23e03ddcf57ee7e2ab26da/pydantic_numpy-9.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.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/5d/32/6e723c9fa2847ba268c02377f75a4ae3432ec82fcf1e9776e2b9ca5f18e7/suitcase_utils-0.5.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5f/85/115246eeec396feedc81bed42f832c97bf433a803688a4bf0e5d763e808a/bluesky_queueserver_api-0.0.13-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/60/3b/e93d177001ac3ba3cb2ebed7727442e1ee61990271f2bf39caef63798ead/nose2-0.16.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/61/81/2e45eb89653a6bdf1056ee701fffda7f6b8ea5774da66f373f6c0b68580d/epicscorelibs-7.0.10.99.0.2-cp313-cp313-manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/66/a3/54b9d35d9f2e6b4a2546212c1b0df4412a97c4323223a1f9ace8ee22689f/bluesky_queueserver-0.0.25-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.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/7c/60/998d447c893f31d8967c6e17cd018423bde995602240bf72b8f0aa58fd83/bluesky_widgets-0.0.18-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9e/9d/872279feea98802a9c3e5f2680cb1b38cd49d7e249cd2220d07a69c7079a/suitcase_mongo-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a4/55/2c393b8fcd3f330f994a139a93b1b81537d6c251911fead40e60c93e9ca0/p4p-4.3.0-cp313-cp313-manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b1/d3/a3e8393cdf6e40e94b3a91ab8afdb010769829b520b2fbfdc4697f31a1c3/bluesky_kafka-0.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b7/d5/01ea71591a29e88926582e3ca7ed32f8b933e9ebc99187a7ab100587242d/pint-0.26.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ca/9f/c0d4811ce61696113b7b0b2f8dfa1218c4b47caad8383ce8269555884d90/bluesky_httpserver-0.0.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/0b/f824a8560311f9614365e97c54e1441bb1d53f5dd00d5440592daff205ac/confluent_kafka-2.16.0-cp313-cp313-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/de/8b/65dccc36873290e89c0d1d3c1121f161ff28c37201e0d008cfa664d606b5/bluesky_live-0.0.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ec/14/1c52a2a3ca612d77550fef0404a496f080bfd600cf4c7c6a2a8ab4be2dc6/pamela-1.2.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ef/80/c6dbf1f8d14fbb5615d2e99b798d61b76eb937f09e1e948d0e8aaf8873b2/ophyd-1.11.2-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/fb/e5/f5c38d4899cd8edd5b522071c5658202077d113c3225484d779cf257970e/numpydoc-1.11.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fe/47/deb64c73aec25af7699247e021153a6bfe9a08452f7f7337dcee4aa07a2b/historydict-1.2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl py311: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -2262,6 +1800,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-4.4.5-py311hb102447_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-hee9eb32_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.4-py311h364832d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.11.2-py311h968eb38_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.2-py311hfea5af0_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/mpc-1.4.0-ha2cb11d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/mpfr-4.2.2-ha2cb11d_1.conda @@ -2579,6 +2118,8 @@ environments: - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - 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#778e71444a14eb4adcd92c6417a4362ebea39425 + - pypi: https://files.pythonhosted.org/packages/08/04/0cfb507bf58ae4b3bc560ad2233c84faa7b7c3777146840af96b6a45dbc6/bluesky_httpserver-0.0.14-py3-none-any.whl - 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 @@ -2589,29 +2130,39 @@ environments: - pypi: https://files.pythonhosted.org/packages/26/c2/2ec71837ac862d823de280e72c245686b37de6dde0a18a02c9c18f589c39/setuptools_dso-2.12.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2e/da/dede62fb799feb8a366f3a5997216bab9259806df26314fa895f9663f6ff/confluent_kafka-2.16.0-cp311-cp311-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/47/3b/0a007667d2077c197793beede94483f1efe57a23e03ddcf57ee7e2ab26da/pydantic_numpy-9.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/c2/384b1242aa51a50b4722e09fbb3fb165b337c5a313d520b8f70f4a116f6e/epicscorelibs-7.0.10.99.0.2-cp311-cp311-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5d/32/6e723c9fa2847ba268c02377f75a4ae3432ec82fcf1e9776e2b9ca5f18e7/suitcase_utils-0.5.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/85/115246eeec396feedc81bed42f832c97bf433a803688a4bf0e5d763e808a/bluesky_queueserver_api-0.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/bd/2b8f68a5a164f7e6fd00ddb920aa933a1f3db3fc34d3a2fc98d9cec57246/p4p-4.3.0-cp311-cp311-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/60/3b/e93d177001ac3ba3cb2ebed7727442e1ee61990271f2bf39caef63798ead/nose2-0.16.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/66/a3/54b9d35d9f2e6b4a2546212c1b0df4412a97c4323223a1f9ace8ee22689f/bluesky_queueserver-0.0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.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/7c/60/998d447c893f31d8967c6e17cd018423bde995602240bf72b8f0aa58fd83/bluesky_widgets-0.0.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/9d/872279feea98802a9c3e5f2680cb1b38cd49d7e249cd2220d07a69c7079a/suitcase_mongo-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/e4/2ddf312f603a620f2cf136599e34809c4a1771629b99a35e12a9054f89b0/pymongo-4.19.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/aa/ed/3fb20a1a96b8dc645d88c4072df481fe06e0289e4d528ebbdcc044ebc8b3/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b1/d3/a3e8393cdf6e40e94b3a91ab8afdb010769829b520b2fbfdc4697f31a1c3/bluesky_kafka-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/de/8b/65dccc36873290e89c0d1d3c1121f161ff28c37201e0d008cfa664d606b5/bluesky_live-0.0.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/14/1c52a2a3ca612d77550fef0404a496f080bfd600cf4c7c6a2a8ab4be2dc6/pamela-1.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/80/c6dbf1f8d14fbb5615d2e99b798d61b76eb937f09e1e948d0e8aaf8873b2/ophyd-1.11.2-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/fb/e5/f5c38d4899cd8edd5b522071c5658202077d113c3225484d779cf257970e/numpydoc-1.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/47/deb64c73aec25af7699247e021153a6bfe9a08452f7f7337dcee4aa07a2b/historydict-1.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl py312: @@ -2780,6 +2331,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-4.4.5-py312hc6a72db_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-hee9eb32_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.4-py312h8b7ec3f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.11.2-py312h6a12a82_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.2-py312hfe9aac7_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/mpc-1.4.0-ha2cb11d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/mpfr-4.2.2-ha2cb11d_1.conda @@ -3099,6 +2651,8 @@ environments: - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - 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#778e71444a14eb4adcd92c6417a4362ebea39425 + - pypi: https://files.pythonhosted.org/packages/08/04/0cfb507bf58ae4b3bc560ad2233c84faa7b7c3777146840af96b6a45dbc6/bluesky_httpserver-0.0.14-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/b5/3515126cb6dce557cb928cc3147a77d5f84b9cade3c8548b802792e6bbe4/redis_json_dict-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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 @@ -3106,6 +2660,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/23/ef/3b82c9a5c6fe4bcc7ebafbc4a06efe4f631929441bddd5d53541434fb941/pyOlog-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/c2/2ec71837ac862d823de280e72c245686b37de6dde0a18a02c9c18f589c39/setuptools_dso-2.12.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/44/1b/e8364fadbc05bff19e67dda4f151e63fb13c58252cd5e1e1750c22cc1b8f/pymongo-4.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -3113,25 +2668,34 @@ environments: - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.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/55/ef/d8d3234b1db8ba4c7cdea909bb90c869c159ca9613796005425f85efb722/epicscorelibs-7.0.10.99.0.2-cp312-cp312-manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5d/32/6e723c9fa2847ba268c02377f75a4ae3432ec82fcf1e9776e2b9ca5f18e7/suitcase_utils-0.5.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/85/115246eeec396feedc81bed42f832c97bf433a803688a4bf0e5d763e808a/bluesky_queueserver_api-0.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/3b/e93d177001ac3ba3cb2ebed7727442e1ee61990271f2bf39caef63798ead/nose2-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/66/a3/54b9d35d9f2e6b4a2546212c1b0df4412a97c4323223a1f9ace8ee22689f/bluesky_queueserver-0.0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.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/7c/60/998d447c893f31d8967c6e17cd018423bde995602240bf72b8f0aa58fd83/bluesky_widgets-0.0.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/9d/872279feea98802a9c3e5f2680cb1b38cd49d7e249cd2220d07a69c7079a/suitcase_mongo-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/75/9e6f9b9326e94bc6f10f9f1fe2c4ad338e255717578fcee9c23351ba0dff/p4p-4.3.0-cp312-cp312-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b1/d3/a3e8393cdf6e40e94b3a91ab8afdb010769829b520b2fbfdc4697f31a1c3/bluesky_kafka-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/d5/01ea71591a29e88926582e3ca7ed32f8b933e9ebc99187a7ab100587242d/pint-0.26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/de/8b/65dccc36873290e89c0d1d3c1121f161ff28c37201e0d008cfa664d606b5/bluesky_live-0.0.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/67/0b8f6929b1b203d6e4f570e5b5a5c02da4a5eb0ab3e3d9da901431ddd7b3/pvxslibs-1.5.3-cp312-cp312-manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ec/14/1c52a2a3ca612d77550fef0404a496f080bfd600cf4c7c6a2a8ab4be2dc6/pamela-1.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/80/c6dbf1f8d14fbb5615d2e99b798d61b76eb937f09e1e948d0e8aaf8873b2/ophyd-1.11.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/02/972fb6e1c987fc5edd09bd3d9510797a69369aa4e1a73ac0880b0b7f684f/confluent_kafka-2.16.0-cp312-cp312-manylinux_2_28_x86_64.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/fb/e5/f5c38d4899cd8edd5b522071c5658202077d113c3225484d779cf257970e/numpydoc-1.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/47/deb64c73aec25af7699247e021153a6bfe9a08452f7f7337dcee4aa07a2b/historydict-1.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl py313: @@ -3300,6 +2864,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-4.4.5-py313h557dd98_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-hee9eb32_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.4-py313h2af15c8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.11.2-py313h4488465_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.2-py313he85fb2c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/mpc-1.4.0-ha2cb11d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/mpfr-4.2.2-ha2cb11d_1.conda @@ -3618,6 +3183,8 @@ environments: - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - 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#778e71444a14eb4adcd92c6417a4362ebea39425 + - pypi: https://files.pythonhosted.org/packages/08/04/0cfb507bf58ae4b3bc560ad2233c84faa7b7c3777146840af96b6a45dbc6/bluesky_httpserver-0.0.14-py3-none-any.whl - 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 @@ -3627,29 +3194,39 @@ environments: - pypi: https://files.pythonhosted.org/packages/23/ef/3b82c9a5c6fe4bcc7ebafbc4a06efe4f631929441bddd5d53541434fb941/pyOlog-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/c2/2ec71837ac862d823de280e72c245686b37de6dde0a18a02c9c18f589c39/setuptools_dso-2.12.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/47/3b/0a007667d2077c197793beede94483f1efe57a23e03ddcf57ee7e2ab26da/pydantic_numpy-9.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.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/5d/32/6e723c9fa2847ba268c02377f75a4ae3432ec82fcf1e9776e2b9ca5f18e7/suitcase_utils-0.5.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/85/115246eeec396feedc81bed42f832c97bf433a803688a4bf0e5d763e808a/bluesky_queueserver_api-0.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/3b/e93d177001ac3ba3cb2ebed7727442e1ee61990271f2bf39caef63798ead/nose2-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/81/2e45eb89653a6bdf1056ee701fffda7f6b8ea5774da66f373f6c0b68580d/epicscorelibs-7.0.10.99.0.2-cp313-cp313-manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/66/a3/54b9d35d9f2e6b4a2546212c1b0df4412a97c4323223a1f9ace8ee22689f/bluesky_queueserver-0.0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.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/7c/60/998d447c893f31d8967c6e17cd018423bde995602240bf72b8f0aa58fd83/bluesky_widgets-0.0.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/9d/872279feea98802a9c3e5f2680cb1b38cd49d7e249cd2220d07a69c7079a/suitcase_mongo-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/55/2c393b8fcd3f330f994a139a93b1b81537d6c251911fead40e60c93e9ca0/p4p-4.3.0-cp313-cp313-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b1/d3/a3e8393cdf6e40e94b3a91ab8afdb010769829b520b2fbfdc4697f31a1c3/bluesky_kafka-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/d5/01ea71591a29e88926582e3ca7ed32f8b933e9ebc99187a7ab100587242d/pint-0.26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/de/0b/f824a8560311f9614365e97c54e1441bb1d53f5dd00d5440592daff205ac/confluent_kafka-2.16.0-cp313-cp313-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/de/8b/65dccc36873290e89c0d1d3c1121f161ff28c37201e0d008cfa664d606b5/bluesky_live-0.0.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/14/1c52a2a3ca612d77550fef0404a496f080bfd600cf4c7c6a2a8ab4be2dc6/pamela-1.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/80/c6dbf1f8d14fbb5615d2e99b798d61b76eb937f09e1e948d0e8aaf8873b2/ophyd-1.11.2-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/fb/e5/f5c38d4899cd8edd5b522071c5658202077d113c3225484d779cf257970e/numpydoc-1.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/47/deb64c73aec25af7699247e021153a6bfe9a08452f7f7337dcee4aa07a2b/historydict-1.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -4107,6 +3684,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/chardet-7.6.0-py313h2af15c8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/charset-normalizer-3.5.2-h1114479_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.4.0-py313h2551ef2_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-50.0.2-py311hc91d8b8_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cytoolz-1.2.0-py313hd42f317_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-he8c428d_2.conda @@ -4120,7 +3698,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/gflags-2.3.1-h54a6638_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/glog-0.7.1-hb7133d2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hfd2156b_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gmpy2-2.3.2-py313ha4a6282_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-h54a6638_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.5.6-py313h901f96d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/h5py-3.16.0-nompi_py313hf402d47_105.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5plugin-7.1.0-py313h14eca21_1.conda @@ -4220,6 +3800,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.4-py313h2af15c8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.11.2-py313h4488465_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.11.2-py313he85fb2c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mpc-1.4.0-ha2cb11d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mpfr-4.2.2-ha2cb11d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/msgpack-python-1.2.3-py313h2551ef2_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ndindex-1.10.1-py313hbdba758_1.conda @@ -4230,6 +3812,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-heb1ab33_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.32.0-h2a08dbc_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openpyxl-3.1.5-py313heaa19e1_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/orc-2.3.1-h443056b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/orjson-3.13.0-py313hc6c575f_0.conda @@ -4260,6 +3843,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/scikit-image-0.26.0-np2py313hb172dc5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.1-py313he2a5f1a_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h34e00bb_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.52-py313h54dd161_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.10-py313h995f894_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hc1c935e_2.conda @@ -4296,6 +3880,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_x86_64-microarch-level-1-5_level1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.20.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/algotom-1.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.5-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda @@ -4307,6 +3893,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/awkward-2.14.0-pyh5ded981_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bluesky-tiled-plugins-2.0.12-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bokeh-3.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.19-hbd8a1cb_0.conda @@ -4327,9 +3914,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/dill-0.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/distributed-2026.8.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.22.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ecdsa-0.19.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/entrypoints-0.4.2-pyh5ded981_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/et_xmlfile-2.0.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/event-model-1.24.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.141.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 @@ -4348,6 +3939,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.20-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/imageio-2.38.1-pyh5ded981_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/imagesize-2.0.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-resources-7.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-7.1.0-pyhd8ed1ab_0.conda @@ -4368,6 +3960,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/linkify-it-py-2.2.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lmfit-1.3.4-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.4.3-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mdit-py-plugins-0.6.1-pyhd8ed1ab_0.conda @@ -4404,6 +3997,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-3.0.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.4-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.17-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-jose-3.5.0-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.32-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.5-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2026.5-pyh5ded981_0.conda @@ -4415,15 +4010,26 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/roman-numerals-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rsa-4.9.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.2.1-pyhe730653_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-scm-10.3.4-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-3.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sparse-0.19.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-9.2.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-2.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-2.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-2.0.0-pyh5ded981_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-2.0.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stamina-26.1.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-1.9.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tblib-3.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tenacity-9.2.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/textual-8.2.8-pyhcf101f3_0.conda @@ -4444,6 +4050,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/uc-micro-py-2.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uncertainties-3.2.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.8.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.54.0-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/vcs_versioning-2.6.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/xarray-2026.9.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/xyzservices-2026.9.1-pyhd8ed1ab_0.conda @@ -4453,6 +4060,7 @@ environments: - pypi: git+https://github.com/NSLS2/nslsii?rev=main#b8c69931660f2d74534f44c210499958959a1a0e - 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#778e71444a14eb4adcd92c6417a4362ebea39425 - 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 @@ -4461,29 +4069,40 @@ environments: - pypi: https://files.pythonhosted.org/packages/23/ef/3b82c9a5c6fe4bcc7ebafbc4a06efe4f631929441bddd5d53541434fb941/pyOlog-4.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/c2/2ec71837ac862d823de280e72c245686b37de6dde0a18a02c9c18f589c39/setuptools_dso-2.12.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/47/3b/0a007667d2077c197793beede94483f1efe57a23e03ddcf57ee7e2ab26da/pydantic_numpy-9.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.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/5d/32/6e723c9fa2847ba268c02377f75a4ae3432ec82fcf1e9776e2b9ca5f18e7/suitcase_utils-0.5.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/85/115246eeec396feedc81bed42f832c97bf433a803688a4bf0e5d763e808a/bluesky_queueserver_api-0.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/3b/e93d177001ac3ba3cb2ebed7727442e1ee61990271f2bf39caef63798ead/nose2-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/81/2e45eb89653a6bdf1056ee701fffda7f6b8ea5774da66f373f6c0b68580d/epicscorelibs-7.0.10.99.0.2-cp313-cp313-manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/66/a3/54b9d35d9f2e6b4a2546212c1b0df4412a97c4323223a1f9ace8ee22689f/bluesky_queueserver-0.0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.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/7c/60/998d447c893f31d8967c6e17cd018423bde995602240bf72b8f0aa58fd83/bluesky_widgets-0.0.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/9d/872279feea98802a9c3e5f2680cb1b38cd49d7e249cd2220d07a69c7079a/suitcase_mongo-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/55/2c393b8fcd3f330f994a139a93b1b81537d6c251911fead40e60c93e9ca0/p4p-4.3.0-cp313-cp313-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b1/d3/a3e8393cdf6e40e94b3a91ab8afdb010769829b520b2fbfdc4697f31a1c3/bluesky_kafka-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/d5/01ea71591a29e88926582e3ca7ed32f8b933e9ebc99187a7ab100587242d/pint-0.26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/9f/c0d4811ce61696113b7b0b2f8dfa1218c4b47caad8383ce8269555884d90/bluesky_httpserver-0.0.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/de/0b/f824a8560311f9614365e97c54e1441bb1d53f5dd00d5440592daff205ac/confluent_kafka-2.16.0-cp313-cp313-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/de/8b/65dccc36873290e89c0d1d3c1121f161ff28c37201e0d008cfa664d606b5/bluesky_live-0.0.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/14/1c52a2a3ca612d77550fef0404a496f080bfd600cf4c7c6a2a8ab4be2dc6/pamela-1.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/80/c6dbf1f8d14fbb5615d2e99b798d61b76eb937f09e1e948d0e8aaf8873b2/ophyd-1.11.2-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/fb/e5/f5c38d4899cd8edd5b522071c5658202077d113c3225484d779cf257970e/numpydoc-1.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/47/deb64c73aec25af7699247e021153a6bfe9a08452f7f7337dcee4aa07a2b/historydict-1.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -8430,6 +8049,38 @@ packages: run_exports: {} size: 27785 timestamp: 1791548971554 +- conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.11.2-py311h968eb38_1.conda + sha256: e38a5154a94e986504c9b79073011a5c0e2c29c5c77f41fe1b257cc0d4acf547 + md5: ef4eeae14a1e8bdf4aa6106dc312f2f4 + depends: + - matplotlib-base >=3.11.2,<3.11.3.0a0 + - pyside6 >=6.7.2 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 + - tornado >=5 + license: PSF-2.0 + license_family: PSF + purls: + - pkg:pypi/matplotlib?source=compressed-mapping + run_exports: {} + size: 14952 + timestamp: 1789824162000 +- conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.11.2-py312h6a12a82_1.conda + sha256: 44001a8f40445bf63e96537d041d0c40d46facb67448d039aec4fee9262f05ab + md5: bfc8bc50a9803addad9bb033bacb80f3 + depends: + - matplotlib-base >=3.11.2,<3.11.3.0a0 + - pyside6 >=6.7.2 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - tornado >=5 + license: PSF-2.0 + license_family: PSF + purls: + - pkg:pypi/matplotlib?source=compressed-mapping + run_exports: {} + size: 14909 + timestamp: 1789824128347 - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.11.2-py313h4488465_1.conda sha256: d22b0671e311dffce2a0b2b1a2139c402a32475c4a66b6d009588bac1618c7da md5: ab3b99976ece0b3426aeac603dd0572f @@ -15578,6 +15229,27 @@ packages: - pillow ; extra == 'codecs' - imagecodecs ; extra == 'codecs' requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/08/04/0cfb507bf58ae4b3bc560ad2233c84faa7b7c3777146840af96b6a45dbc6/bluesky_httpserver-0.0.14-py3-none-any.whl + name: bluesky-httpserver + version: 0.0.14 + sha256: 7f6dfdef72794cbde6299f00e60545de05c9f5d51270be5dcc26205eaa740314 + requires_dist: + - alembic + - bluesky-queueserver + - bluesky-queueserver-api + - fastapi + - ldap3 + - orjson + - pamela + - pydantic + - pydantic-settings + - python-jose + - pyzmq + - sqlalchemy + - starlette + - typing-extensions ; python_full_version < '3.8' + - uvicorn + requires_python: '>=3.7' - 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 1dd9cdd..7445cd1 100644 --- a/pixi.toml +++ b/pixi.toml @@ -46,6 +46,20 @@ matplotlib = ">=3.10.9,<4" qtpy = ">=2.4.3,<3" pyside6 = ">=6.9.0,<7" nexpy = ">=2.0.3,<3" +pexpect = ">=4.9.0,<5" + +# Install the `gui` extra (bluesky-widgets, ntnda-qt-viewer, ...) in this env. +[feature.terminal.pypi-dependencies] +hextools = { path = ".", editable = true, extras = ["gui"] } +ntnda-qt-viewer = { git = "https://github.com/jwlodek/ntnda-qt-viewer", rev = "main" } + +[feature.terminal.tasks.hex-gui] +cmd = "python -m hextools.gui" +default-environment = "terminal" + +[feature.terminal.tasks.qserver-gui] +cmd = "queue-monitor --http-server-uri https://xf27id1-hex-qs1.nsls2.bnl.gov:443" +default-environment = "terminal" [feature.terminal.tasks.start] cmd = "unset SESSION_MANAGER PYTHONPATH PYTHONUSERBASE && MPLBACKEND=qtagg ipython -i src/hextools/profiles/{{ profile }}.py" @@ -54,6 +68,7 @@ args = [ { arg = "profile", default = "collection" }, ] env = { HEXTOOLS_RUNNING_IN_CI = "{% if mock == 'true' %}YES{% endif %}" } +default-environment = "terminal" [feature.py311.dependencies] python = "3.11.*" @@ -87,28 +102,20 @@ greenlet = ">=3.5.6,<4" # Runs the profile in mock mode; mock mode needs dev-only packages (uvicorn via # tiled-server). Sets its own env because task env does not propagate through # `depends-on`. -[feature.mock.tasks.start-mock] +[feature.dev.tasks.start-mock] cmd = "unset SESSION_MANAGER PYTHONPATH PYTHONUSERBASE && MPLBACKEND=qtagg ipython -i src/hextools/profiles/{{ profile }}.py" args = [{ arg = "profile", default = "collection" }] env = { HEXTOOLS_RUNNING_IN_CI = "YES", BEAMLINE_ACRONYM = "HEX" } +default-environment = "dev" -[feature.gui.dependencies] -pyside6 = ">=6.9.0,<7" -qtpy = ">=2.4.3,<3" -pexpect = ">=4.9.0,<5" - -# Install the `gui` extra (bluesky-widgets, ntnda-qt-viewer, ...) in this env. -[feature.gui.pypi-dependencies] -hextools = { path = ".", editable = true, extras = ["gui"] } -ntnda-qt-viewer = { git = "https://github.com/jwlodek/ntnda-qt-viewer", rev = "main" } - +[feature.dev.tasks.hex-gui-mock] +cmd = "python -m hextools.gui --mock" +default-environment = "dev" -[feature.gui.tasks] -qserver-gui = "queue-monitor --http-server-uri https://xf27id1-hex-qs1.nsls2.bnl.gov:443" -hex-gui = "python -m hextools.gui" - -[feature.mock.tasks] -hex-gui-mock = "python -m hextools.gui --mock" +# ty doesn't pick up pixi's environment from CONDA_PREFIX on its own. +[feature.dev.tasks.type-check] +cmd = "ty check --python $CONDA_PREFIX src tests" +default-environment = "dev" [feature.exporter.tasks] auto-exporter = "python -m hextools.exporters.auto_exporter" @@ -136,10 +143,10 @@ args = [ # Base environments default = { solve-group = "default" } # Everything needed to develop, test, and run the terminal and GUI in mock mode. -dev = { features = ["profile", "terminal", "gui", "dev", "mock"], solve-group = "profile" } +dev = { features = ["profile", "terminal", "dev"], solve-group = "profile" } docs = { features = ["docs"], solve-group = "default" } -# Profile environments +# Profile environments (`terminal` also runs the GUI) terminal = {features = ["profile", "terminal"], solve-group = "profile"} qs = { features = ["profile", "qs"], solve-group = "profile" } @@ -147,22 +154,24 @@ qs = { features = ["profile", "qs"], solve-group = "profile" } exporter = { features = ["exporter"], solve-group = "default" } # Python version specific environments for CI -py311 = { features = ["dev", "py311"], solve-group = "py311" } -py312 = { features = ["dev", "py312"], solve-group = "py312" } -py313 = { features = ["dev", "py313"], solve-group = "py313" } +py311 = { features = ["dev", "terminal", "py311"], solve-group = "py311" } +py312 = { features = ["dev", "terminal", "py312"], solve-group = "py312" } +py313 = { features = ["dev", "terminal", "py313"], solve-group = "py313" } -[environments.gui] -features = ["profile", "gui"] -solve-group = "profile" +[feature.dev.tasks.tests] +cmd = "pytest --cov=src/hextools --cov-report=term" +default-environment = "dev" -[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 = """ +[feature.dev.tasks.coverage] +cmd = """ 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" +default-environment = "dev" + +[feature.dev.tasks.lint] +cmd = "pre-commit run --all-files" +default-environment = "dev" diff --git a/src/hextools/detectors/germ.py b/src/hextools/detectors/germ.py index bb57d44..48de6a7 100644 --- a/src/hextools/detectors/germ.py +++ b/src/hextools/detectors/germ.py @@ -1,7 +1,11 @@ """Ophyd async support for the GeRM detector at HEX.""" -from collections.abc import Mapping, Sequence +import asyncio +import logging +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, cast from typing import Annotated as A import numpy as np @@ -11,6 +15,7 @@ DetectorDataLogic, DetectorTrigger, DetectorTriggerLogic, + DeviceMock, PathProvider, SignalDataProvider, SignalR, @@ -19,7 +24,10 @@ StandardReadable, StrictEnum, TriggerInfo, + callback_on_mock_put, + default_mock_class, set_and_wait_for_value, + set_mock_value, soft_signal_r_and_setter, wait_for_value, ) @@ -37,6 +45,11 @@ ) from ophyd_async.epics.core import EpicsDevice, PvSuffix +if TYPE_CHECKING: + from hextools.photon_delivery_system.shutter import Shutter + +logger = logging.getLogger(__name__) + class TDCSlopeTimes(StrictEnum): """TDC slope time setting.""" @@ -371,6 +384,265 @@ def get_hinted_fields(self, datakey_name: str) -> Sequence[str]: return [self.signal.name] if self.hinted else [] +#: Full-scale energy (keV) of each gain setting, for the mock's energy axis. +_GAIN_FULL_SCALE_KEV = { + Gain.GAIN_240KEV: 240.0, + Gain.GAIN_120KEV: 120.0, + Gain.GAIN_60KEV: 60.0, + Gain.GAIN_30KEV: 30.0, +} + + +class GeRMDetectorMock(DeviceMock["GeRMDetector"]): + """Simulates a GeRM count, writing each spectrum frame to HDF5 or TIFF. + + Setting ``acquire`` counts for ``acquire_time`` (updating ``elapsed_time``), + then publishes a fresh ``(num_elements, num_energy_bins)`` frame to the live + signals and ``stats1.total``. Whichever file plugin is capturing writes the frame + to disk as the IOC would: appended to ``.h5`` at + ``/entry/data/data``, or as ``_NNNNNN.tiff``. + + The frame is the sum of the diffraction signal, counted only while every shutter + given to :meth:`set_shutters` is open, and a fixed test pulse peak while + ``test_pulse_enable`` is set; with neither it is all zeros. Functions given to + :meth:`register_frame_callback` are called with the accumulated frame once per + second while counting, and with the final frame. + """ + + #: The HEX GeRM is a 192-strip detector. + num_elements = 192 + #: Energies (keV) and rates (counts/s/bin) of the simulated diffraction lines. + lines_kev = ((11.7, 6.0), (12.4, 3.0), (28.3, 3.0), (30.6, 4.0), (35.0, 10.0)) + #: Full-scale test pulse amplitude, in DAC counts. + test_pulse_full_scale = 1024 + #: Seconds between frame callbacks while counting. + frame_update_period = 1.0 + + def __init__(self, name: str = "", parent: DeviceMock | None = None) -> None: + super().__init__(name, parent) + self._acquisition: asyncio.Task | None = None + self._rng = np.random.default_rng(0) + self._frame_callbacks: list[Callable[[np.ndarray], None]] = [] + self._shutters: tuple[Shutter, ...] = () + self._frame_shape: tuple[int, int] | None = None + + def register_frame_callback(self, callback: Callable[[np.ndarray], None]) -> None: + """Call ``callback`` with each ``(num_elements, num_energy_bins)`` frame. + + It is first called with an all-zero frame, once the detector is connected. + Later calls come from the event loop the detector is connected in. + """ + self._frame_callbacks.append(callback) + if self._frame_shape is not None: + callback(np.zeros(self._frame_shape, dtype=np.int32)) + + def unregister_frame_callback(self, callback: Callable[[np.ndarray], None]) -> None: + """Stop calling a callback given to :meth:`register_frame_callback`.""" + self._frame_callbacks.remove(callback) + + def set_shutters(self, *shutters: "Shutter") -> None: + """Only count the diffraction signal while all of ``shutters`` are open.""" + self._shutters = shutters + + async def connect(self, device: "GeRMDetector") -> None: + driver = device.driver + set_mock_value(driver.num_elements, self.num_elements) + set_mock_value(driver.num_chips, self.num_elements // 32) + set_mock_value(driver.model, "Mock GeRM") + set_mock_value(driver.firmware_version, "mock") + set_mock_value(driver.ip_address, "127.0.0.1") + set_mock_value(driver.acquire_time, 1.0) + set_mock_value(driver.gain, Gain.GAIN_120KEV) + set_mock_value(driver.shaping_time, ShapingTime.ST_1_0_US) + set_mock_value(driver.count_mode, CountMode.TIMED) + set_mock_value(driver.shot_mode, ShotMode.ONE_SHOT) + set_mock_value(driver.high_voltage, -500.0) + set_mock_value(driver.temperature1, -30.0) + set_mock_value(driver.temperature2, -30.0) + set_mock_value(driver.temperature3, -30.0) + set_mock_value(driver.zynq_temperature, 45.0) + set_mock_value(driver.udp_reachable, True) + set_mock_value(driver.test_pulse_amplitude, 100) + set_mock_value(driver.test_pulse_frequency, 1000) + set_mock_value(driver.test_pulse_count, 99999) + set_mock_value(device.hdf.num_frames_chunks, 1) + + for writer in (device.hdf, device.tiff): + set_mock_value(writer.file_path_exists, True) + self._reset_count_on_capture(writer) + + def _on_acquire(value: bool) -> None: + counting = self._acquisition is not None and not self._acquisition.done() + if counting and not value: + # Stopped early: abandon the count without writing a frame. + self._acquisition.cancel() # type: ignore[union-attr] + elif value and not counting: + self._acquisition = asyncio.ensure_future(self._acquire(device)) + + callback_on_mock_put(driver.acquire, _on_acquire) + + self._frame_shape = ( + self.num_elements, + await driver.num_energy_bins.get_value(), + ) + self._notify(np.zeros(self._frame_shape, dtype=np.int32)) + + @staticmethod + def _reset_count_on_capture(writer: NDPluginFileIO) -> None: + def _on_capture(value: bool) -> None: + if value: + set_mock_value(writer.num_captured, 0) + + callback_on_mock_put(writer.capture, _on_capture) + + async def _acquire(self, device: "GeRMDetector") -> None: + driver = device.driver + acquire_time = await driver.acquire_time.get_value() + num_elements = await driver.num_elements.get_value() + num_bins = await driver.num_energy_bins.get_value() + full_scale = _GAIN_FULL_SCALE_KEV[await driver.gain.get_value()] + energies = np.linspace(0.0, full_scale, num_bins) + set_mock_value(driver.spectrum_x, energies) + rate = self._beam_rate(energies, num_elements) + + signal = np.zeros((num_elements, num_bins), dtype=np.int32) + pulses = np.zeros(num_bins) + pulses_sent = 0.0 + since_update = 0.0 + step = min(0.1, acquire_time) + elapsed = 0.0 + set_mock_value(driver.elapsed_time, 0.0) + while elapsed < acquire_time: + dt = min(step, acquire_time - elapsed) + await asyncio.sleep(dt) + elapsed = min(elapsed + dt, acquire_time) + # Shutters and test pulses are checked each step, as they can change. + if await self._shutters_open(): + signal += self._rng.poisson(rate * dt).astype(np.int32) + if await driver.test_pulse_enable.get_value(): + peak, sent = await self._test_pulses(driver, num_bins, dt, pulses_sent) + pulses += peak + pulses_sent += sent + set_mock_value(driver.elapsed_time, elapsed) + since_update += dt + if since_update >= self.frame_update_period - 1e-9 and ( + elapsed < acquire_time + ): + since_update = 0.0 + self._notify(self._frame(signal, pulses)) + + frame = self._frame(signal, pulses) + set_mock_value(driver.mca, frame.ravel()) + set_mock_value(driver.spectrum, frame.sum(axis=0, dtype=np.int32)) + set_mock_value(driver.intensity, frame.sum(axis=1, dtype=np.int32)) + set_mock_value(device.stats1.total, float(frame.sum())) + + for writer in (device.hdf, device.tiff): + if await writer.capture.get_value(): + await self._write_frame(writer, frame) + captured = await writer.num_captured.get_value() + set_mock_value(writer.num_captured, captured + 1) + + self._notify(frame) + set_mock_value(driver.acquire, False) + + def _notify(self, frame: np.ndarray) -> None: + for callback in list(self._frame_callbacks): + try: + callback(frame) + except Exception: + logger.exception("GeRM mock frame callback %r failed", callback) + + @staticmethod + def _frame(signal: np.ndarray, pulses: np.ndarray) -> np.ndarray: + """Add the test pulses counted so far to every strip of ``signal``.""" + return signal + np.rint(pulses).astype(np.int32) + + async def _shutters_open(self) -> bool: + from hextools.photon_delivery_system.shutter import ShutterStatus + + for shutter in self._shutters: + if await shutter.status.get_value() != ShutterStatus.OPEN: + return False + return True + + def _beam_rate(self, energies: np.ndarray, num_elements: int) -> np.ndarray: + """Return the expected counts/s per strip and bin with beam. + + The continuum runs from a ragged low-energy cutoff near 6.5 keV, rising to a + sharp edge near 35.5 keV, with a sparse tail above it. + """ + # Fixed seed: the per-strip geometry must not change between counts. + geometry = np.random.default_rng(1) + strips = np.arange(num_elements)[:, np.newaxis] + low = 6.5 + geometry.normal(0.0, 0.35, (num_elements, 1)) * ( + 1.0 + strips / num_elements + ) + high = ( + 35.5 + + 0.2 * np.sin(strips / 4.0) + + 0.3 * strips / num_elements + + geometry.normal(0.0, 0.08, (num_elements, 1)) + ) + position = np.clip((energies - low) / (high - low), 0.0, 1.0) + above_low = np.clip(energies - low, 0.0, None) + rate = 2.0 + 6.0 * position**3 + 3.0 * np.exp(-above_low / 0.8) + for energy, line_rate in self.lines_kev: + rate = rate + line_rate * np.exp(-0.5 * ((energies - energy) / 0.25) ** 2) + # Logistic edges, written with tanh so they cannot overflow. + rate *= 0.5 * (1.0 + np.tanh((energies - low) / 0.2)) + rate *= 0.5 * (1.0 + np.tanh((high - energies) / 0.16)) + above = np.clip(energies - high, 0.0, None) + rate += np.where(energies > high, 0.01 * np.exp(-above / 12.0), 0.0) + return rate + + async def _test_pulses( + self, driver: GeRMDetectorIO, num_bins: int, dt: float, sent: float + ) -> tuple[np.ndarray, float]: + """Return the pulse peak for ``dt`` seconds at the current settings. + + The peak's bin is set by the amplitude; no more than the pulse count are + sent in one acquisition. Also returns how many pulses the peak holds. + """ + amplitude = await driver.test_pulse_amplitude.get_value() + frequency = await driver.test_pulse_frequency.get_value() + max_pulses = await driver.test_pulse_count.get_value() + count = max(0.0, min(frequency * dt, max_pulses - sent)) + center = amplitude / self.test_pulse_full_scale * (num_bins - 1) + peak = np.exp(-0.5 * ((np.arange(num_bins) - center) / 2.0) ** 2) + peak *= count / peak.sum() if peak.sum() > 0 else 0.0 + return peak, count + + @staticmethod + async def _write_frame(writer: NDPluginFileIO, frame: np.ndarray) -> None: + directory = Path(await writer.file_path.get_value()) + directory.mkdir(parents=True, exist_ok=True) + file_name = await writer.file_name.get_value() + if isinstance(writer, NDFileHDF5IO): + # Imported here so the detector does not require h5py unless mocked. + import h5py + + with h5py.File(directory / f"{file_name}.h5", "a") as f: + if "/entry/data/data" in f: + dataset = cast(h5py.Dataset, f["/entry/data/data"]) + dataset.resize(dataset.shape[0] + 1, axis=0) + dataset[-1] = frame + else: + f.create_dataset( + "/entry/data/data", + data=frame[np.newaxis], + maxshape=(None, *frame.shape), + chunks=(1, *frame.shape), + ) + else: + import tifffile + + file_number = await writer.file_number.get_value() + tifffile.imwrite(directory / f"{file_name}_{file_number:06d}.tiff", frame) + set_mock_value(writer.file_number, file_number + 1) + + +@default_mock_class(GeRMDetectorMock) class GeRMDetector(StandardDetector): """The ophyd class for GeRM detector.""" diff --git a/src/hextools/edxd.py b/src/hextools/edxd.py index 0c7127f..21e8586 100644 --- a/src/hextools/edxd.py +++ b/src/hextools/edxd.py @@ -8,14 +8,20 @@ from bluesky import plans as bp from bluesky import preprocessors as bpp from bluesky.preprocessors import finalize_wrapper +from tiled.structures.core import Spec from hextools.detectors.germ import GeRMDetector from hextools.motors import CollimatorTable, EDXDTable, SampleTower from hextools.photon_delivery_system import Shutter, ensure_shutter_open from hextools.photon_delivery_system.shutter import ensure_shutter_closed +from hextools.specs import EDXD_CALIBRATION_V1, EDXD_SCAN_V1 from hextools.utils import Steppable, ensure_available +def _tiled_specs(spec: Spec) -> list[dict[str, str | None]]: + return [{"name": spec.name, "version": spec.version}] + + def configure_test_pulses( use_test_pulses: bool, test_pulse_amplitude: int = 100, @@ -87,7 +93,10 @@ def edxd_count( yield from bps.mv(germ.driver.acquire_time, count_time) - _md = {"plan_name": "edxd_count"} + _md: dict[str, Any] = { + "plan_name": "edxd_count", + "tiled_specs": _tiled_specs(EDXD_SCAN_V1), + } if description is not None: _md["description"] = description yield from bp.count([germ], num=num, md=_md) @@ -158,8 +167,9 @@ def _body(): else: germ.save_as_hdf() - _md = { + _md: dict[str, Any] = { "plan_name": "edxd_scan", + "tiled_specs": _tiled_specs(EDXD_SCAN_V1), } if description is not None: _md["description"] = description @@ -239,7 +249,10 @@ def _body(): yield from bps.mv(germ.driver.acquire_time, count_time) - _md: dict[str, Any] = {"plan_name": "edxd_custom_pos_list_grid"} + _md: dict[str, Any] = { + "plan_name": "edxd_custom_pos_list_grid", + "tiled_specs": _tiled_specs(EDXD_SCAN_V1), + } if description is not None: _md["description"] = description for i, outer_pos in enumerate(outer_positions_list): @@ -329,7 +342,10 @@ def _body(): yield from bps.mv(germ.driver.acquire_time, count_time) - _md = {"plan_name": "edxd_grid_scan"} + _md: dict[str, Any] = { + "plan_name": "edxd_grid_scan", + "tiled_specs": _tiled_specs(EDXD_SCAN_V1), + } if description is not None: _md["description"] = description yield from bp.grid_scan( @@ -461,7 +477,10 @@ def edxd_calib_scan( fe_shutter = ensure_available(Shutter, fe_shutter=fe_shutter) photon_shutter = ensure_available(Shutter, photon_shutter=photon_shutter) - _md = {"plan_name": "edxd_calib_scan"} + _md: dict[str, Any] = { + "plan_name": "edxd_calib_scan", + "tiled_specs": _tiled_specs(EDXD_CALIBRATION_V1), + } if description is not None: _md["description"] = description diff --git a/src/hextools/exporters/auto_exporter.py b/src/hextools/exporters/auto_exporter.py index 6858f7c..51b24fb 100644 --- a/src/hextools/exporters/auto_exporter.py +++ b/src/hextools/exporters/auto_exporter.py @@ -24,7 +24,12 @@ export_tomo_flyscan_to_h5, ) from hextools.log import configure_logger -from hextools.specs import EDXD_SCAN_V1, TOMO_DARK_FLAT_V1, TOMO_FLYSCAN_V1 +from hextools.specs import ( + EDXD_CALIBRATION_V1, + EDXD_SCAN_V1, + TOMO_DARK_FLAT_V1, + TOMO_FLYSCAN_V1, +) logger = logging.getLogger("hextools.exporters.auto_exporter") @@ -32,6 +37,7 @@ (TOMO_FLYSCAN_V1.name, TOMO_FLYSCAN_V1.version): export_tomo_flyscan_to_h5, (TOMO_DARK_FLAT_V1.name, TOMO_DARK_FLAT_V1.version): export_tomo_dark_flat_to_h5, (EDXD_SCAN_V1.name, EDXD_SCAN_V1.version): export_edxd_run_to_h5, + (EDXD_CALIBRATION_V1.name, EDXD_CALIBRATION_V1.version): export_edxd_run_to_h5, } # Run metadata updates carry the full start document, which can exceed the 1 MB default diff --git a/src/hextools/gui/__main__.py b/src/hextools/gui/__main__.py index 7034f74..ff73f96 100644 --- a/src/hextools/gui/__main__.py +++ b/src/hextools/gui/__main__.py @@ -18,8 +18,9 @@ import atexit import os import time as ttime +from functools import partial from pathlib import Path -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar import IPython from bluesky import RunEngine @@ -57,6 +58,7 @@ QWidget, ) +from hextools.detectors.germ import GeRMDetectorMock from hextools.detectors.phantom import PhantomDetector from hextools.edxd import ( configure_test_pulses, @@ -132,6 +134,14 @@ PLAN_HISTORY_FILE_ENV = "HEXTOOLS_PLAN_HISTORY_FILE" + +def default_plan_history_file() -> Path: + """``$XDG_STATE_HOME/hextools/plan_history[_mock].json``.""" + state_home = os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state" + name = "plan_history_mock.json" if is_running_in_ci() else "plan_history.json" + return Path(state_home) / "hextools" / name + + # Names must match those defined in the profile / Queue Server namespace. EXPECTED_DEVICES: list[tuple[str, type]] = [ ("fe_shutter", PPSShutter), @@ -317,6 +327,27 @@ def __init__(self, re_client: RunEngineClient | RunEngine, *args, **kwargs): self.setLayout(vbox) +def germ_viewer_options(mock: bool) -> dict[str, Any]: + """Options for the live GeRM viewer. + + A mock viewer is in callback mode, so it never subscribes to the real GeRM + PV; the mock's frames reach it through ``show_frame``. + """ + options: dict[str, Any] = { + "raw_waveform": True, + "image_shape": (192, 4096), + "colormap": "JET", + "roi_suffix_pattern": "1:ROI{}:", + "auto_scale_method": "minmax", + "log_scale": True, + "use_ca": True, + "show_profile_lines": True, + } + if mock: + options["push_frame"] = lambda push: None + return options + + class QtEDXDView(QWidget): """Energy-dispersive X-ray diffraction view: the live GeRM viewer and EDXD plans.""" @@ -326,17 +357,9 @@ def __init__(self, re_client: RunEngineClient | RunEngine, *args, **kwargs): top = QHBoxLayout() detectors = QtTabbedDetectorsWidget(re_client) + mock = isinstance(re_client, RunEngine) and is_running_in_ci() detectors.add_detector( - "germ", - "XF:27ID1-ES{GeRM-Det:1}MCA", - raw_waveform=True, - image_shape=(192, 4096), - colormap="JET", - roi_suffix_pattern="1:ROI{}:", - auto_scale_method="minmax", - log_scale=True, - use_ca=True, - show_profile_lines=True, + "germ", "XF:27ID1-ES{GeRM-Det:1}MCA", **germ_viewer_options(mock=mock) ) top.addWidget(detectors, stretch=3) @@ -360,6 +383,34 @@ def __init__(self, re_client: RunEngineClient | RunEngine, *args, **kwargs): vbox.addLayout(top, stretch=1) self.setLayout(vbox) + self._germ_mock: GeRMDetectorMock | None = None + if mock: + self._attach_germ_mock(detectors) + + def _attach_germ_mock(self, detectors: QtTabbedDetectorsWidget) -> None: + """Show the mock GeRM's frames, gated by the namespace's shutters.""" + ipython = IPython.get_ipython() + user_ns = ipython.user_ns if ipython is not None else {} + mock = getattr(user_ns.get("germ"), "_mock", None) + if not isinstance(mock, GeRMDetectorMock): + return + mock.set_shutters( + *( + shutter + for name in ("fe_shutter", "photon_shutter") + if isinstance(shutter := user_ns.get(name), Shutter) + ) + ) + self._show_germ_frame = partial(detectors.show_frame, "germ") + mock.register_frame_callback(self._show_germ_frame) + self._germ_mock = mock + + def closeEvent(self, event): + if self._germ_mock is not None: + self._germ_mock.unregister_frame_callback(self._show_germ_frame) + self._germ_mock = None + super().closeEvent(event) + class QtXRDView(QWidget): """X-ray diffraction view: the live Perkin Elmer area detector viewer.""" @@ -495,7 +546,8 @@ def __init__(self, re_client: RunEngineClientT, *args, **kwargs): tabs.addTab(execution_view, "Plan Execution") self.plan_history = QtPlanHistory( self._plan_monitor, - history_file=os.environ.get(PLAN_HISTORY_FILE_ENV) or None, + history_file=os.environ.get(PLAN_HISTORY_FILE_ENV) + or default_plan_history_file(), ) tabs.addTab(self.plan_history, "History") tabs.addTab(QtPlanLogView(self._plan_monitor, "hextools"), "Log") @@ -902,7 +954,9 @@ def main(): "--history-file", default=None, help="JSON file to save plan history to as plans finish, and to load it" - f" from on startup. Can also be set with {PLAN_HISTORY_FILE_ENV}.", + " from on startup (default: ~/.local/state/hextools/plan_history.json," + " or plan_history_mock.json with --mock). Can also be set" + f" with {PLAN_HISTORY_FILE_ENV}.", ) parser.add_argument( diff --git a/src/hextools/gui/misc/tabbed_detectors_widget.py b/src/hextools/gui/misc/tabbed_detectors_widget.py index aebf381..1069e72 100644 --- a/src/hextools/gui/misc/tabbed_detectors_widget.py +++ b/src/hextools/gui/misc/tabbed_detectors_widget.py @@ -2,6 +2,7 @@ from typing import Any +import numpy as np from bluesky_widgets.models.run_engine_client import RunEngineClient from IPython.core.getipython import get_ipython from ntnda_qt_viewer import NTNDAViewerWidget @@ -29,12 +30,14 @@ class QtTabbedDetectorsWidget(QTabWidget): """ _signal_devices_changed = Signal() + _signal_frame = Signal(str, object) def __init__(self, re_client, *args, **kwargs): super().__init__(*args, **kwargs) self._detectors: dict[str, dict[str, Any]] = {} self._combined: dict[str, list[str]] = {} self._viewers: dict[str, NTNDAViewerWidget] = {} + self._viewers_by_name: dict[str, list[NTNDAViewerWidget]] = {} self._combined_tabs: dict[str, QWidget] = {} self._tab_viewers: dict[QWidget, list[NTNDAViewerWidget]] = {} self._running: set[NTNDAViewerWidget] = set() @@ -42,6 +45,7 @@ def __init__(self, re_client, *args, **kwargs): self.currentChanged.connect(self._sync_running) self._signal_devices_changed.connect(self._refresh) + self._signal_frame.connect(self._on_frame) self._source.subscribe(self._on_devices_changed) def add_detector( @@ -88,6 +92,18 @@ def add_combined(self, label: str, names: list[str]) -> None: self._combined[label] = list(names) self._refresh() + def show_frame(self, name: str, frame: np.ndarray) -> None: + """Display ``frame`` in detector ``name``'s viewers, as if read from its PV. + + Safe to call from any thread. Shown only while the viewers are running. + """ + self._signal_frame.emit(name, frame) + + @Slot(str, object) + def _on_frame(self, name: str, frame: np.ndarray) -> None: + for viewer in self._viewers_by_name.get(name, []): + viewer.push_frame(frame) + @staticmethod def _make_source(re_client) -> DeviceSource: if isinstance(re_client, RunEngineClient): @@ -169,7 +185,9 @@ def hideEvent(self, event): self._sync_running() def _make_viewer(self, name: str, **overrides: Any) -> NTNDAViewerWidget: - return NTNDAViewerWidget(**{**self._detectors[name], **overrides}) + viewer = NTNDAViewerWidget(**{**self._detectors[name], **overrides}) + self._viewers_by_name.setdefault(name, []).append(viewer) + return viewer def _tab_position(self, name: str) -> int: """Index that keeps tabs in the order the detectors were given.""" diff --git a/src/hextools/gui/plan_status.py b/src/hextools/gui/plan_status.py index 7e30f37..2afb9a0 100644 --- a/src/hextools/gui/plan_status.py +++ b/src/hextools/gui/plan_status.py @@ -18,7 +18,7 @@ from bluesky import RunEngine from bluesky.callbacks.best_effort import BestEffortCallback, hinted_fields -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT from matplotlib.figure import Figure from qtpy.QtCore import QObject, Qt, QTimer, Signal, Slot from qtpy.QtGui import QColor, QFont, QFontDatabase, QPainter, QPen @@ -672,7 +672,12 @@ def _make_figure(self, name: str) -> Figure: figure = Figure(layout="constrained") figure.set_label(name) canvas = FigureCanvasQTAgg(figure) - self._plots_layout.addWidget(canvas, stretch=1) + plot = QWidget() + plot_layout = QVBoxLayout(plot) + plot_layout.setContentsMargins(0, 0, 0, 0) + plot_layout.addWidget(NavigationToolbar2QT(canvas, plot)) + plot_layout.addWidget(canvas, stretch=1) + self._plots_layout.addWidget(plot, stretch=1) if not self._plots.isVisible(): self._plots.show() half = max(1, self._splitter.width() // 2) @@ -829,7 +834,11 @@ def __init__( self._rerun_button = QPushButton("Re-run Selected Plan") self._rerun_button.setEnabled(False) self._rerun_button.clicked.connect(self._on_rerun_clicked) + self._clear_button = QPushButton("Clear History") + self._clear_button.setEnabled(False) + self._clear_button.clicked.connect(self._on_clear_clicked) buttons = QHBoxLayout() + buttons.addWidget(self._clear_button) buttons.addStretch() buttons.addWidget(self._rerun_button) @@ -898,6 +907,22 @@ def _on_plan_finished(self, record: PlanRecord): self._add_row(record) self._save() + def clear(self): + """Remove every plan from the table and the history file.""" + self._table.setRowCount(0) + self._records.clear() + self._update_button() + self._save() + + def _on_clear_clicked(self): + answer = QMessageBox.question( + self, + "Clear plan history", + "Remove all plans from the history? This can't be undone.", + ) + if answer == QMessageBox.StandardButton.Yes: + self.clear() + def _add_row(self, record: PlanRecord): scan_ids = [str(s) for s in record.scan_ids if s is not None] cells = ( @@ -925,6 +950,7 @@ def _selected_record(self) -> PlanRecord | None: @Slot() def _update_button(self): + self._clear_button.setEnabled(bool(self._records)) record = self._selected_record() self._rerun_button.setEnabled(record is not None and record.rerunnable) if record is not None and not record.rerunnable: diff --git a/src/hextools/profiles/collection.py b/src/hextools/profiles/collection.py index 68728e0..7398254 100644 --- a/src/hextools/profiles/collection.py +++ b/src/hextools/profiles/collection.py @@ -71,10 +71,15 @@ reset_scan_id, # noqa: F401 - operator namespace show_docs, # noqa: F401 - operator namespace ) +from hextools.utils.magics import HEXBlueskyMagics +from hextools.utils.path_providers import mock_path_provider # Adjust the default timeout for ophyd async to 60s DEFAULT_TIMEOUT = 60.0 +# Where mock detectors write their files, instead of the proposal directories. +MOCK_DETECTOR_DATA_DIR = "/tmp/hextools_dets" + # Environment variables for Redis host and ophyd_async detector state preservation os.environ["REDIS_HOST"] = "xf27id1-hex-redis1.nsls2.bnl.gov" os.environ["OPHYD_ASYNC_PRESERVE_DETECTOR_STATE"] = "YES" @@ -108,6 +113,20 @@ ipython.xmode = "Minimal" autoawait_in_bluesky_event_loop() + # Configure plots to work interactively + from matplotlib import pyplot as plt + + plt.ion() + + # Configure the Bluesky magics with the current RunEngine instance. + HEXBlueskyMagics.RE = RE + ipython.register_magics(HEXBlueskyMagics) + + # Make tracebacks minimal by default to reduce clutter. + # Use %tb verbose to view full traceback. + ipython.run_line_magic("xmode", "minimal") + + # Construct our tiled clients for writing and (in an interactive session) reading. # If we're running in CI, we use a simple client for both. if not is_running_in_ci(): @@ -118,14 +137,19 @@ if ipython is not None and isinstance(ipython, TerminalInteractiveShell): tiled_reading_client = c = from_uri("https://tiled.nsls2.bnl.gov")["hex"]["raw"] else: - tiled_writing_client = tiled_reading_client = c = simple() + tiled_writing_client = tiled_reading_client = c = simple( + readable_storage=MOCK_DETECTOR_DATA_DIR + ) # Subscribe the tiled writer to the RunEngine RE.subscribe(TiledWriter(tiled_writing_client, backup_directory="/tmp/bluesky-docs")) -# Subscribe the best effort callback +# Subscribe the best effort callback, for its plots. The GUI's live table and plan +# narration already cover what its table and heading would print. bec = BestEffortCallback() +bec.disable_table() +bec.disable_heading() RE.subscribe(bec) # Keep track of last dark-flat scan UID @@ -164,13 +188,16 @@ def __call__(self, datakey_name) -> PathInfo: # Define our global default path provider for the beamline -path_provider = HEXPathProvider( - RE.md, - filename_provider=filename_provider, - include_scan_id_dir=True, - granularity="year", - scan_id_padding=5, -) +if is_running_in_ci(): + path_provider = mock_path_provider(MOCK_DETECTOR_DATA_DIR) +else: + path_provider = HEXPathProvider( + RE.md, + filename_provider=filename_provider, + include_scan_id_dir=True, + granularity="year", + scan_id_padding=5, + ) with auto_init_devices(timeout=2.0): # Shutters (Front-end and photon) @@ -268,10 +295,14 @@ def __call__(self, datakey_name) -> PathInfo: # For the Perkin-Elmer detector, we use a separate path provider # Since we need to specify the write directory as a windows path. - pe_path_provider = NSLS2PathProvider( - RE.md, - filename_provider=filename_provider, - base_write_dir=PureWindowsPath("Z:\\proposals"), + pe_path_provider = ( + path_provider + if is_running_in_ci() + else NSLS2PathProvider( + RE.md, + filename_provider=filename_provider, + base_write_dir=PureWindowsPath("Z:\\proposals"), + ) ) perkin_elmer = ContAcqDetector( "XF:27ID1-ES{PE-Det:1}", diff --git a/src/hextools/utils/magics.py b/src/hextools/utils/magics.py new file mode 100644 index 0000000..5520123 --- /dev/null +++ b/src/hextools/utils/magics.py @@ -0,0 +1,28 @@ +"""IPython magics for the HEX session.""" + +from bluesky.magics import BlueskyMagics +from IPython.core.magic import line_magic, magics_class + + +@magics_class +class HEXBlueskyMagics(BlueskyMagics): + """Bluesky magics whose ``%mov`` and ``%movr`` put back the waiting hook. + + bluesky's versions set ``RE.waiting_hook`` to None afterwards, which drops + the GUI's progress bars for every later scan. + """ + + @line_magic + def mov(self, line): + return self._keeping_waiting_hook(BlueskyMagics.mov, line) + + @line_magic + def movr(self, line): + return self._keeping_waiting_hook(BlueskyMagics.movr, line) + + def _keeping_waiting_hook(self, magic, line): + hook = self.RE.waiting_hook + try: + return magic(self, line) + finally: + self.RE.waiting_hook = hook diff --git a/src/hextools/utils/nl_msg_hook.py b/src/hextools/utils/nl_msg_hook.py index 1d2235f..55a9bbc 100644 --- a/src/hextools/utils/nl_msg_hook.py +++ b/src/hextools/utils/nl_msg_hook.py @@ -8,7 +8,7 @@ from datetime import datetime from typing import Any -from bluesky.utils import msg_to_json_safe_dict +from bluesky.utils import Msg, msg_to_json_safe_dict # ---------------------------------------------------------------------------------- # Small formatting helpers @@ -448,6 +448,7 @@ def __init__(self, *, show_timestamps: bool = False, window: float = 60.0) -> No # id. self._pending_group: dict[str, list[dict[str, Any]]] = {} self._group_order: list[str] = [] # arrival order of open groups + self._last_command: str | None = None # -- formatting ------------------------------------------------------------ @@ -556,6 +557,11 @@ def push(self, msg: dict[str, Any]) -> list[str]: t = msg.get("time") out: list[str] = [] + previous, self._last_command = self._last_command, command + # e.g. bp.count: bps.repeat and one_shot each checkpoint before every shot. + if command == "checkpoint" and previous == "checkpoint": + return out + if command in _BATCH_PHRASES: out.extend(self._flush_all_groups()) # a batch ends any pending group value = (msg.get("args") or [None])[0] if command == "prepare" else None @@ -643,8 +649,7 @@ def flush(self) -> list[str]: class MsgHookNarrator: - """ - A bluesky ``RunEngine`` ``msg_hook`` that narrates a plan's activity in English. + """A bluesky ``RunEngine`` ``msg_hook`` that narrates a plan's activity in English. Attach an instance to a RunEngine and it will, for every ``Msg`` the RunEngine processes, serialize the message, feed it to a :class:`StreamNarrator`, and log any @@ -652,9 +657,8 @@ class MsgHookNarrator: RE.msg_hook = MsgHookNarrator() - By default the hook consumes *raw* ``Msg`` objects and serializes them itself. - If you are feeding it messages that are already serialized (dicts of the documented - shape), pass ``serialize=False``. + It accepts raw ``Msg`` objects, which it serializes itself, as well as messages + that are already serialized (dicts of the documented shape). Parameters ---------- @@ -669,10 +673,6 @@ class MsgHookNarrator: window : float, optional Maximum span, in seconds, over which same-kind messages are joined (see :class:`StreamNarrator`). - serialize : bool, optional - If True (default), incoming values are treated as raw ``Msg`` objects and - serialized via :func:`serialize_msg`. If False, they are assumed to already be - serialized dicts. """ def __init__( @@ -683,12 +683,10 @@ def __init__( level: int = logging.INFO, show_timestamps: bool = False, window: float = 60.0, - serialize: bool = True, ) -> None: self._logger = logging.getLogger(logger) if isinstance(logger, str) else logger self._level = level self._emit = emit if emit is not None else self._log - self._serialize = serialize self._narrator = StreamNarrator(show_timestamps=show_timestamps, window=window) self._listeners: list[Callable[[str], Any]] = [] @@ -712,9 +710,14 @@ def _send(self, line: str) -> None: # A broken listener must not fail the running plan. pass - def __call__(self, msg: Any) -> None: - """Consume one message (raw ``Msg`` by default); emit any finalized lines.""" - record = msg_to_json_safe_dict(msg) if self._serialize else msg + def __call__(self, msg: Msg | dict[str, Any]) -> None: + """Consume one ``Msg`` or serialized message; emit any finalized lines.""" + if isinstance(msg, Msg): + record = msg_to_json_safe_dict(msg) + elif isinstance(msg, dict): + record = msg + else: + raise TypeError(f"Expected a Msg or a dict, got {type(msg).__name__}") for line in self._narrator.push(record): self._send(line) diff --git a/src/hextools/utils/path_providers.py b/src/hextools/utils/path_providers.py index 6f70991..4d2758b 100644 --- a/src/hextools/utils/path_providers.py +++ b/src/hextools/utils/path_providers.py @@ -2,9 +2,11 @@ import logging from collections import Counter +from pathlib import Path from typing import Any from nslsii.ophyd_async.providers import TimestampFilenameProvider +from ophyd_async.core import UUIDFilenameProvider, YMDPathProvider logger = logging.getLogger("hextools") @@ -65,3 +67,12 @@ def __call__(self, datakey_name: str | None = None) -> str: self._files_per_datakey[datakey_name] += 1 return f"{prefix}_{filename}" + + +def mock_path_provider(base_directory: str | Path) -> YMDPathProvider: + """Path provider for mock detectors: dated folders, a new file name per call. + + Timestamp names repeat within a second, so two short mock runs could write + to the same file. + """ + return YMDPathProvider(UUIDFilenameProvider(), base_directory) diff --git a/tests/conftest.py b/tests/conftest.py index e79abfd..1b24653 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ set_mock_value, ) +from hextools.detectors.germ import GeRMDetector from hextools.photon_delivery_system.shutter import Shutter, ShutterStatus # Pre-import websockets modules that trigger DeprecationWarnings so they are @@ -35,6 +36,14 @@ _ALLOWED_PYTEST_TASKS = {"async_finalizer", "async_setup", "async_teardown"} +@pytest.fixture(autouse=True) +def _plan_history_in_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep GUIs built in tests away from the user's real plan history file.""" + monkeypatch.setenv( + "HEXTOOLS_PLAN_HISTORY_FILE", str(tmp_path / "plan_history.json") + ) + + # ================================================================================== # Copied from ophyd-async conftest.py # ================================================================================== @@ -83,6 +92,12 @@ def _set_ci_env(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("HEXTOOLS_RUNNING_IN_CI", "YES") +@pytest.fixture(autouse=True) +def _preserve_detector_state(monkeypatch: pytest.MonkeyPatch): + # The profiles set this, so trigger() uses each detector's current settings. + monkeypatch.setenv("OPHYD_ASYNC_PRESERVE_DETECTOR_STATE", "YES") + + @pytest.fixture(autouse=True, scope="function") async def fail_test_on_unclosed_tasks(request: FixtureRequest): """Used on every test to ensure failure if there are pending tasks @@ -191,3 +206,16 @@ def two_shutters(shutter_factory: Callable[[str], Shutter]) -> tuple[Shutter, Sh @pytest.fixture def static_path_provider(tmp_path: Path) -> StaticPathProvider: return StaticPathProvider(UUIDFilenameProvider(), tmp_path) + + +@pytest.fixture +def germ(RE: RunEngine, tmp_path: Path) -> GeRMDetector: + """A mock GeRM writing where the ``tiled_client`` fixture's server reads.""" + data_dir = tmp_path / "data" + data_dir.mkdir(parents=True, exist_ok=True) + with init_devices(mock=True): + germ = GeRMDetector( + "GeRM:", StaticPathProvider(UUIDFilenameProvider(), data_dir), name="germ" + ) + set_mock_value(germ.driver.acquire_time, 0.01) + return germ diff --git a/tests/detectors/test_germ.py b/tests/detectors/test_germ.py index e0af564..72161e6 100644 --- a/tests/detectors/test_germ.py +++ b/tests/detectors/test_germ.py @@ -1,6 +1,14 @@ import asyncio +from pathlib import Path +from types import SimpleNamespace +import h5py +import numpy as np import pytest +import tifffile +from bluesky import RunEngine +from bluesky import plans as bp +from bluesky_tiled_plugins import TiledWriter from ophyd_async.core import ( DetectorTrigger, TriggerInfo, @@ -8,10 +16,13 @@ init_devices, set_mock_value, ) +from tiled.client.container import Container from hextools.detectors.germ import ( GeRMAcquireLogic, + GeRMDetector, GeRMDetectorIO, + GeRMDetectorMock, GeRMTriggerLogic, ) @@ -72,3 +83,253 @@ def _on_acquire(value: bool): assert await germ_io.acquire.get_value() is False await acquire_logic.ensure_stopped() assert await germ_io.acquire.get_value() is False + + +# --- GeRMDetectorMock --------------------------------------------------------------- + +NUM_ELEMENTS = GeRMDetectorMock.num_elements +NUM_BINS = 4096 + + +def _count( + RE: RunEngine, germ: GeRMDetector, client: Container, num: int +) -> tuple[dict[str, list], np.ndarray]: + docs: dict[str, list] = {} + RE.subscribe(TiledWriter(client)) + RE( + bp.count([germ], num=num), + lambda name, doc: docs.setdefault(name, []).append(doc), + ) + run = client.values().last() + return docs, run["primary"]["germ"].read() + + +async def test_germ_mock_defaults(germ: GeRMDetector): + assert await germ.driver.num_elements.get_value() == NUM_ELEMENTS + assert await germ.driver.model.get_value() == "Mock GeRM" + assert await germ.hdf.file_path_exists.get_value() + assert await germ.tiff.file_path_exists.get_value() + + +async def test_germ_mock_count_writes_hdf5( + RE: RunEngine, germ: GeRMDetector, tiled_client: tuple[Path, Container] +): + data_dir, client = tiled_client + docs, data = _count(RE, germ, client, num=3) + + (resource,) = docs["stream_resource"] + assert resource["mimetype"] == "application/x-hdf5" + assert resource["parameters"]["dataset"] == "/entry/data/data" + (h5_file,) = data_dir.glob("*.h5") + with h5py.File(h5_file) as f: + dataset = f["/entry/data/data"] + assert isinstance(dataset, h5py.Dataset) + written: np.ndarray = dataset[()] + assert written.shape == (3, NUM_ELEMENTS, NUM_BINS) + assert written.dtype == np.int32 + + # tiled serves exactly the frames on disk, one per event + np.testing.assert_array_equal(data.reshape(written.shape), written) + totals = [event["data"]["germ-stats1-total"] for event in docs["event"]] + assert totals == [float(frame.sum()) for frame in written] + assert await germ.hdf.num_captured.get_value() == 3 + + +async def test_germ_mock_count_writes_tiffs( + RE: RunEngine, germ: GeRMDetector, tiled_client: tuple[Path, Container] +): + data_dir, client = tiled_client + germ.save_as_tiff() + docs, data = _count(RE, germ, client, num=2) + + (resource,) = docs["stream_resource"] + assert resource["mimetype"] == "multipart/related;type=image/tiff" + tiffs = sorted(data_dir.glob("*.tiff")) + assert [p.name[-12:] for p in tiffs] == ["_000000.tiff", "_000001.tiff"] + written = np.stack([tifffile.imread(p) for p in tiffs]) + assert written.shape == (2, NUM_ELEMENTS, NUM_BINS) + assert not list(data_dir.glob("*.h5")) + + np.testing.assert_array_equal(data.reshape(written.shape), written) + totals = [event["data"]["germ-stats1-total"] for event in docs["event"]] + assert totals == [float(frame.sum()) for frame in written] + + +async def test_germ_mock_count_updates_live_signals(germ: GeRMDetector): + await germ.driver.acquire.set(True) + await asyncio.wait_for(_wait_idle(germ), timeout=5) + + spectrum = await germ.driver.spectrum.get_value() + intensity = await germ.driver.intensity.get_value() + energies = await germ.driver.spectrum_x.get_value() + assert spectrum.shape == (NUM_BINS,) + assert intensity.shape == (NUM_ELEMENTS,) + assert spectrum.sum() == intensity.sum() == await germ.stats1.total.get_value() + # the default 120 keV gain sets the energy axis + assert energies[0] == 0.0 + assert energies[-1] == pytest.approx(120.0) + assert await germ.driver.elapsed_time.get_value() == pytest.approx(0.01) + # nothing was capturing, so nothing was written + assert await germ.hdf.num_captured.get_value() == 0 + + +async def test_germ_mock_stopped_count_writes_no_frame(germ: GeRMDetector): + set_mock_value(germ.driver.acquire_time, 5.0) + set_mock_value(germ.hdf.capture, True) + await germ.driver.acquire.set(True) + await asyncio.sleep(0.05) + await germ.driver.acquire.set(False) + await asyncio.sleep(0.05) + + assert await germ.hdf.num_captured.get_value() == 0 + assert await germ.stats1.total.get_value() == 0.0 + + +async def _wait_idle(germ: GeRMDetector): + while await germ.driver.acquire.get_value(): + await asyncio.sleep(0.01) + + +async def _take_frame(germ: GeRMDetector) -> np.ndarray: + frames: list[np.ndarray] = [] + mock = germ._mock + assert isinstance(mock, GeRMDetectorMock) + mock.register_frame_callback(frames.append) + frames.clear() + await germ.driver.acquire.set(True) + await asyncio.wait_for(_wait_idle(germ), timeout=5) + mock.unregister_frame_callback(frames.append) + (frame,) = frames + assert frame.shape == (NUM_ELEMENTS, NUM_BINS) + assert float(frame.sum()) == await germ.stats1.total.get_value() + return frame + + +@pytest.fixture +def germ_mock(germ: GeRMDetector) -> GeRMDetectorMock: + assert isinstance(germ._mock, GeRMDetectorMock) + return germ._mock + + +async def test_germ_mock_frame_callback_gets_each_frame(germ: GeRMDetector): + frame = await _take_frame(germ) + np.testing.assert_array_equal(await germ.driver.mca.get_value(), frame.ravel()) + assert frame.sum() > 0 + + +async def test_germ_mock_sends_zeros_when_a_callback_registers( + germ_mock: GeRMDetectorMock, +): + frames: list[np.ndarray] = [] + germ_mock.register_frame_callback(frames.append) + (frame,) = frames + assert frame.shape == (NUM_ELEMENTS, NUM_BINS) + assert not frame.any() + + +async def test_germ_mock_frame_updates_each_second_while_counting( + germ: GeRMDetector, germ_mock: GeRMDetectorMock +): + frames: list[np.ndarray] = [] + germ_mock.register_frame_callback(frames.append) + frames.clear() + set_mock_value(germ.driver.acquire_time, 2.5) + await germ.driver.acquire.set(True) + await asyncio.wait_for(_wait_idle(germ), timeout=10) + + # at 1 s, 2 s, then the final frame, each accumulating on the last + assert len(frames) == 3 + assert (frames[1] >= frames[0]).all() and (frames[2] >= frames[1]).all() + assert frames[0].sum() < frames[1].sum() < frames[2].sum() + + +async def test_germ_mock_closed_shutter_gives_zeros( + germ: GeRMDetector, germ_mock: GeRMDetectorMock, two_shutters +): + fe, photon = two_shutters + germ_mock.set_shutters(fe, photon) + await fe.set(True) + assert not (await _take_frame(germ)).any() + + +async def test_germ_mock_open_shutters_count_with_exposure( + germ: GeRMDetector, germ_mock: GeRMDetectorMock, two_shutters +): + germ_mock.set_shutters(*two_shutters) + for shutter in two_shutters: + await shutter.set(True) + short = await _take_frame(germ) + set_mock_value(germ.driver.acquire_time, 0.1) + long = await _take_frame(germ) + assert long.sum() > 5 * short.sum() > 0 + + +async def test_germ_mock_test_pulses( + germ: GeRMDetector, germ_mock: GeRMDetectorMock, two_shutters +): + germ_mock.set_shutters(*two_shutters) + set_mock_value(germ.driver.test_pulse_enable, True) + pulses = await _take_frame(germ) + # same peak in every strip, 1000 Hz for 0.01 s, centred by the amplitude + assert (pulses == pulses[0]).all() + assert pulses[0].sum() == pytest.approx(10, abs=2) + assert abs(int(pulses[0].argmax()) - round(100 / 1024 * (NUM_BINS - 1))) <= 1 + + for shutter in two_shutters: + await shutter.set(True) + both = await _take_frame(germ) + signal = both - pulses + assert (signal >= 0).all() + assert signal.sum() > 0 + + +# Copilot on #109: the last step slept a full 0.1 s, past the exposure. +async def test_germ_mock_sleeps_no_longer_than_the_exposure( + germ: GeRMDetector, monkeypatch +): + import hextools.detectors.germ as germ_module + + requested: list[float] = [] + real_sleep = asyncio.sleep + + async def record(delay: float, *args, **kwargs): + requested.append(delay) + await real_sleep(0) + + proxy = SimpleNamespace(**vars(asyncio)) + proxy.sleep = record + monkeypatch.setattr(germ_module, "asyncio", proxy) + set_mock_value(germ.driver.acquire_time, 0.15) + + await germ.driver.acquire.set(True) + await asyncio.wait_for(_wait_idle(germ), timeout=5) + + assert sum(requested) == pytest.approx(0.15) + assert max(requested) <= 0.1 + + +# Copilot on #109: turning test pulses off mid-count recomputed the pulses +# already counted with the new settings, so they vanished from the frame. +async def test_germ_mock_keeps_pulses_counted_before_they_were_turned_off( + germ: GeRMDetector, germ_mock: GeRMDetectorMock, two_shutters +): + germ_mock.set_shutters(*two_shutters) # closed: only test pulses count + driver = germ.driver + set_mock_value(driver.test_pulse_enable, True) + set_mock_value(driver.acquire_time, 0.4) + frames: list[np.ndarray] = [] + germ_mock.register_frame_callback(frames.append) + + await driver.acquire.set(True) + await asyncio.sleep(0.25) + # what configure_test_pulses(False) leaves behind + set_mock_value(driver.test_pulse_enable, False) + for signal in ( + driver.test_pulse_amplitude, + driver.test_pulse_frequency, + driver.test_pulse_count, + ): + set_mock_value(signal, 0) + await asyncio.wait_for(_wait_idle(germ), timeout=5) + + assert frames[-1].any() diff --git a/tests/detectors/test_phantom.py b/tests/detectors/test_phantom.py index 7c8fcf6..068a1b7 100644 --- a/tests/detectors/test_phantom.py +++ b/tests/detectors/test_phantom.py @@ -330,12 +330,7 @@ async def test_detector_full_stack( RE: RunEngine, phantom_detector_factory: Callable[[Path], PhantomDetector], tiled_client, - monkeypatch, ): - - monkeypatch.setenv( - "OPHYD_ASYNC_PRESERVE_DETECTOR_STATE", "YES" - ) # Ensure detector config is preserved across stages tmp_path, c = tiled_client with init_devices(mock=True): phantom = phantom_detector_factory(tmp_path) diff --git a/tests/gui/test_edxd_mock_viewer.py b/tests/gui/test_edxd_mock_viewer.py new file mode 100644 index 0000000..15c61c5 --- /dev/null +++ b/tests/gui/test_edxd_mock_viewer.py @@ -0,0 +1,49 @@ +import os +from types import SimpleNamespace + +import numpy as np +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("qtpy") +pytest.importorskip("bluesky_widgets") +ntnda = pytest.importorskip("ntnda_qt_viewer") + +from qtpy.QtWidgets import QApplication # noqa: E402 + +from hextools.gui.__main__ import germ_viewer_options # noqa: E402 +from hextools.gui.misc.tabbed_detectors_widget import ( # noqa: E402 + QtTabbedDetectorsWidget, +) + + +# Copilot on #109: in --mock the GeRM viewer still subscribed to the real GeRM PV, +# so on a beamline workstation real frames mixed with the mock's. +@pytest.mark.parametrize("mock", [True, False]) +def test_only_the_mock_germ_viewer_stays_off_the_pv(mock: bool): + app = QApplication.instance() or QApplication([]) + viewer = ntnda.NTNDAViewerWidget( + "XF:27ID1-ES{GeRM-Det:1}MCA", **germ_viewer_options(mock=mock) + ) + try: + assert viewer._use_pv is not mock + finally: + viewer.close() + viewer.deleteLater() + app.processEvents() + + +# Copilot on #109: frames went to the viewer's private _on_new_frame, which +# ignores Stop; push_frame drops frames while the viewer is stopped. +def test_frames_reach_viewers_through_push_frame(): + pushed: list[np.ndarray] = [] + viewer = SimpleNamespace(push_frame=pushed.append) + frame = np.zeros((2, 3)) + + QtTabbedDetectorsWidget._on_frame( + SimpleNamespace(_viewers_by_name={"germ": [viewer]}), # type: ignore + "germ", + frame, + ) + + assert pushed == [frame] diff --git a/tests/gui/test_plan_history.py b/tests/gui/test_plan_history.py new file mode 100644 index 0000000..2e8e509 --- /dev/null +++ b/tests/gui/test_plan_history.py @@ -0,0 +1,83 @@ +import json +import os +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("qtpy") + +from bluesky.plans import count # noqa: E402 +from ophyd.sim import det # noqa: E402 # type: ignore +from qtpy.QtWidgets import QApplication, QMessageBox # noqa: E402 + +from hextools.gui.plan_status import PlanMonitor, QtPlanHistory # noqa: E402 +from hextools.utils.nl_msg_hook import MsgHookNarrator # noqa: E402 + + +def _history(RE, path): + QApplication.instance() or QApplication([]) + monitor = PlanMonitor( + RE, narrator=MsgHookNarrator(emit=lambda _: None), namespace={"count": count} + ) + return monitor, QtPlanHistory(monitor, history_file=path) + + +def _finish_plan(RE, monitor): + RE(count([det])) + QApplication.processEvents() + + +def test_history_is_saved_and_cleared(RE, tmp_path, monkeypatch): + path = tmp_path / "cache" / "history.json" + monitor, history = _history(RE, path) + assert not history._clear_button.isEnabled() + + _finish_plan(RE, monitor) + assert len(json.loads(path.read_text())) == 1 + assert history._clear_button.isEnabled() + + # declining the confirmation keeps everything + monkeypatch.setattr( + QMessageBox, "question", lambda *_: QMessageBox.StandardButton.No + ) + history._on_clear_clicked() + assert history._table.rowCount() == 1 + + monkeypatch.setattr( + QMessageBox, "question", lambda *_: QMessageBox.StandardButton.Yes + ) + history._on_clear_clicked() + assert history._table.rowCount() == 0 + assert json.loads(path.read_text()) == [] + assert not history._clear_button.isEnabled() + monitor.detach() + + +def test_history_is_loaded_on_startup(RE, tmp_path): + path = tmp_path / "history.json" + monitor, _ = _history(RE, path) + _finish_plan(RE, monitor) + monitor.detach() + + monitor, history = _history(RE, path) + assert history._table.rowCount() == 1 + monitor.detach() + + +@pytest.mark.parametrize( + ("mock", "name"), [("NO", "plan_history.json"), ("YES", "plan_history_mock.json")] +) +@pytest.mark.parametrize("xdg_state_home", [None, "/xdg/state"]) +def test_default_history_file(monkeypatch, tmp_path, mock, name, xdg_state_home): + from hextools.gui.__main__ import default_plan_history_file + + monkeypatch.setenv("HEXTOOLS_RUNNING_IN_CI", mock) + monkeypatch.setenv("HOME", str(tmp_path)) + if xdg_state_home is None: + monkeypatch.delenv("XDG_STATE_HOME", raising=False) + state_home = tmp_path / ".local" / "state" + else: + monkeypatch.setenv("XDG_STATE_HOME", xdg_state_home) + state_home = Path(xdg_state_home) + assert default_plan_history_file() == state_home / "hextools" / name diff --git a/tests/test_auto_exporter.py b/tests/test_auto_exporter.py new file mode 100644 index 0000000..a3a3ac3 --- /dev/null +++ b/tests/test_auto_exporter.py @@ -0,0 +1,14 @@ +"""The auto-exporter picks an exporter from the run's Tiled spec.""" + +import pytest + +from hextools.exporters.auto_exporter import EXPORTERS +from hextools.exporters.edxd import export_edxd_run_to_h5 +from hextools.specs import EDXD_CALIBRATION_V1, EDXD_SCAN_V1 + + +# Calibration runs logged "no exporter registered" (2026-10-28); they export +# with the same layout as EDXD scans. +@pytest.mark.parametrize("spec", [EDXD_SCAN_V1, EDXD_CALIBRATION_V1]) +def test_edxd_runs_use_the_edxd_exporter(spec): + assert EXPORTERS[(spec.name, spec.version)] is export_edxd_run_to_h5 diff --git a/tests/test_edxd.py b/tests/test_edxd.py new file mode 100644 index 0000000..1779128 --- /dev/null +++ b/tests/test_edxd.py @@ -0,0 +1,651 @@ +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +from bluesky import Msg, RunEngine +from bluesky import preprocessors as bpp +from bluesky_tiled_plugins import TiledWriter +from bluesky_tiled_plugins.clients.catalog_of_bluesky_runs import CatalogOfBlueskyRuns +from ophyd_async.core import ( + Device, + get_mock_execute, + get_mock_put, + init_devices, + set_mock_value, +) +from ophyd_async.epics.motor import Motor as AsyncEpicsMotor + +from hextools.detectors.germ import GeRMDetector +from hextools.edxd import ( + configure_test_pulses, + edxd_2theta_tilt, + edxd_calib_scan, + edxd_count, + edxd_custom_pos_list_grid, + edxd_grid_scan, + edxd_scan, +) +from hextools.motors import CollimatorTable, EDXDTable, SampleTower +from hextools.photon_delivery_system import Shutter, ShutterStatus +from hextools.specs import EDXD_CALIBRATION_V1, EDXD_SCAN_V1 + +Docs = dict[str, list[Any]] + +HDF5 = "application/x-hdf5" +TIFF = "multipart/related;type=image/tiff" +EDXD_SCAN_SPECS = [{"name": EDXD_SCAN_V1.name, "version": EDXD_SCAN_V1.version}] + + +def _motors_in(device: Device) -> Generator[AsyncEpicsMotor]: + if isinstance(device, AsyncEpicsMotor): + yield device + return + for _, child in device.children(): + yield from _motors_in(child) + + +def _fast(*devices: Device) -> None: + """Speed up the velocity-respecting mock motors so moves take milliseconds.""" + for device in devices: + for motor in _motors_in(device): + set_mock_value(motor.max_velocity, 10000) + set_mock_value(motor.velocity, 1000) + set_mock_value(motor.acceleration_time, 0.001) + + +def _run(RE: RunEngine, plan: Generator[Msg, Any, Any]) -> tuple[Docs, list[float]]: + """Run ``plan``, returning its documents and every sleep it requested. + + Sleeps of a second or more (shutter settling, delays between repeats) are + skipped; shorter ones, like the calibration scan's polling, still run. + """ + docs: Docs = {} + sleeps: list[float] = [] + + def _skip_long_sleeps(msg: Msg) -> Msg: + if msg.command != "sleep": + return msg + sleeps.append(msg.args[0]) + return msg._replace(args=(0,)) if msg.args[0] >= 1 else msg + + RE( + bpp.msg_mutator(plan, _skip_long_sleeps), + lambda name, doc: docs.setdefault(name, []).append(doc), + ) + return docs, sleeps + + +def _events_by_run(docs: Docs) -> list[list[dict[str, Any]]]: + descriptors = {d["uid"]: d["run_start"] for d in docs["descriptor"]} + return [ + [e for e in docs["event"] if descriptors[e["descriptor"]] == start["uid"]] + for start in docs["start"] + ] + + +@pytest.fixture +def stage() -> tuple[AsyncEpicsMotor, AsyncEpicsMotor]: + with init_devices(mock=True): + x = AsyncEpicsMotor("X:") + y = AsyncEpicsMotor("Y:") + _fast(x, y) + return x, y + + +@pytest.fixture +def tiled( + RE: RunEngine, tiled_client: tuple[Path, CatalogOfBlueskyRuns] +) -> CatalogOfBlueskyRuns: + _, client = tiled_client + RE.subscribe(TiledWriter(client)) + return client + + +async def _assert_closed(*shutters: Shutter) -> None: + for shutter in shutters: + assert await shutter.status.get_value() == ShutterStatus.CLOSED + + +def _shutters_opened_before_first_count( + messages: list[Msg], *shutters: Shutter +) -> bool: + first_count = next(i for i, m in enumerate(messages) if m.command == "trigger") + opened = { + m.obj + for m in messages[:first_count] + if m.command == "set" and m.obj in shutters and m.args[0] is True + } + return opened == set(shutters) + + +# --- configure_test_pulses ----------------------------------------------------------- + + +@pytest.mark.parametrize("use_test_pulses", [True, False]) +@pytest.mark.parametrize("initially_enabled", [True, False]) +async def test_configure_test_pulses( + RE: RunEngine, + germ: GeRMDetector, + use_test_pulses: bool, + initially_enabled: bool, +): + set_mock_value(germ.driver.test_pulse_enable, initially_enabled) + + RE( + configure_test_pulses( + use_test_pulses, + test_pulse_amplitude=50, + test_pulse_freq=200, + test_pulse_count=10, + germ=germ, + ) + ) + + driver = germ.driver + enables = [c.args[0] for c in get_mock_put(driver.test_pulse_enable).call_args_list] + # The IOC acts on a change, so an already-matching state is toggled off first. + if initially_enabled == use_test_pulses: + assert enables == [not use_test_pulses, use_test_pulses] + else: + assert enables == [use_test_pulses] + assert await driver.enable_all_tsen.get_value() == use_test_pulses + settings = ( + await driver.test_pulse_amplitude.get_value(), + await driver.test_pulse_frequency.get_value(), + await driver.test_pulse_count.get_value(), + ) + assert settings == ((50, 200, 10) if use_test_pulses else (0, 0, 0)) + + +# --- edxd_count ----------------------------------------------------------------------- + + +@pytest.mark.parametrize("description", [None, "silicon standard"]) +async def test_edxd_count( + RE: RunEngine, + germ: GeRMDetector, + tiled: CatalogOfBlueskyRuns, + description: str | None, +): + docs, _ = _run(RE, edxd_count(0.02, num=3, description=description, germ=germ)) + + (start,) = docs["start"] + assert start["plan_name"] == "edxd_count" + assert start["tiled_specs"] == EDXD_SCAN_SPECS + assert start.get("description") == description + assert docs["stop"][0]["exit_status"] == "success" + assert await germ.driver.acquire_time.get_value() == 0.02 + + # Three spectra frames, each matching its event's stats total. + data = tiled[start["uid"]]["primary"]["germ"].read() + frames = np.asarray(data).reshape(3, -1) + totals = [e["data"]["germ-stats1-total"] for e in docs["event"]] + assert totals == [float(f.sum()) for f in frames] + + +# --- edxd_scan ------------------------------------------------------------------------ + + +@pytest.mark.parametrize(("save_as_tiff", "mimetype"), [(False, HDF5), (True, TIFF)]) +async def test_edxd_scan( + RE: RunEngine, + germ: GeRMDetector, + stage: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + two_shutters: tuple[Shutter, Shutter], + tiled: CatalogOfBlueskyRuns, + save_as_tiff: bool, + mimetype: str, +): + x, _ = stage + fe_shutter, photon_shutter = two_shutters + messages: list[Msg] = [] + RE.msg_hook = messages.append # type: ignore + + docs, _ = _run( + RE, + edxd_scan( + x, 0, 2, 3, + 0.02, + description="strain map", + save_as_tiff=save_as_tiff, + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ), + ) # fmt: skip + + (start,) = docs["start"] + assert start["plan_name"] == "edxd_scan" + assert start["tiled_specs"] == EDXD_SCAN_SPECS + assert start["description"] == "strain map" + assert [e["data"]["x"] for e in docs["event"]] == pytest.approx([0, 1, 2]) + assert {r["mimetype"] for r in docs["stream_resource"]} == {mimetype} + data = tiled[start["uid"]]["primary"]["germ"].read() + assert np.asarray(data).shape[0] == 3 + assert await germ.driver.acquire_time.get_value() == 0.02 + + assert _shutters_opened_before_first_count(messages, fe_shutter, photon_shutter) + await _assert_closed(fe_shutter, photon_shutter) + # reset_position returns the motor to the start of the scan + assert await x.user_readback.get_value() == pytest.approx(0) + + +async def test_edxd_scan_repeats_without_shutters_or_reset( + RE: RunEngine, + germ: GeRMDetector, + stage: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + two_shutters: tuple[Shutter, Shutter], +): + x, _ = stage + fe_shutter, photon_shutter = two_shutters + messages: list[Msg] = [] + RE.msg_hook = messages.append # type: ignore + + docs, sleeps = _run( + RE, + edxd_scan( + x, 0, 2, 3, + 0.02, + num_iterations=2, + time_between_iterations=5.0, + use_shutter=False, + reset_position=False, + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ), + ) # fmt: skip + + assert [len(events) for events in _events_by_run(docs)] == [3, 3] + # the delay, less the time the first iteration took, falls between the runs + commands = [m.command for m in messages] + first_close = commands.index("close_run") + second_open = commands.index("open_run", first_close) + assert "sleep" in commands[first_close:second_open] + assert sleeps[0] == pytest.approx(5.0, abs=1.0) + assert not get_mock_execute(fe_shutter.open_cmd).called + assert not get_mock_execute(photon_shutter.open_cmd).called + assert await x.user_readback.get_value() == pytest.approx(2) + + +# --- edxd_grid_scan ------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("snake", "expected"), + [ + (False, [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]), + (True, [(0, 0), (0, 1), (0, 2), (1, 2), (1, 1), (1, 0)]), + ], +) +async def test_edxd_grid_scan( + RE: RunEngine, + germ: GeRMDetector, + stage: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + two_shutters: tuple[Shutter, Shutter], + snake: bool, + expected: list[tuple[float, float]], +): + x, y = stage + fe_shutter, photon_shutter = two_shutters + # The grid scan always writes HDF5, whatever the detector was set to. + germ.save_as_tiff() + + docs, _ = _run( + RE, + edxd_grid_scan( + x, 0, 1, 2, + y, 0, 2, 3, + 0.02, + snake=snake, + description="grid", + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ), + ) # fmt: skip + + (start,) = docs["start"] + assert start["plan_name"] == "edxd_grid_scan" + assert start["tiled_specs"] == EDXD_SCAN_SPECS + assert start["description"] == "grid" + positions = [(e["data"]["x"], e["data"]["y"]) for e in docs["event"]] + assert positions == [pytest.approx(p) for p in expected] + assert {r["mimetype"] for r in docs["stream_resource"]} == {HDF5} + await _assert_closed(fe_shutter, photon_shutter) + assert await x.user_readback.get_value() == pytest.approx(0) + assert await y.user_readback.get_value() == pytest.approx(0) + + +async def test_edxd_grid_scan_without_reset_stays_at_last_point( + RE: RunEngine, + germ: GeRMDetector, + stage: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + two_shutters: tuple[Shutter, Shutter], +): + x, y = stage + fe_shutter, photon_shutter = two_shutters + + _run( + RE, + edxd_grid_scan( + x, 0, 1, 2, + y, 0, 2, 3, + 0.02, + reset_position=False, + use_shutter=False, + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ), + ) # fmt: skip + + assert await x.user_readback.get_value() == pytest.approx(1) + assert await y.user_readback.get_value() == pytest.approx(2) + assert not get_mock_execute(photon_shutter.open_cmd).called + + +# --- edxd_custom_pos_list_grid -------------------------------------------------------- + + +@pytest.mark.parametrize(("save_as_tiff", "mimetype"), [(False, HDF5), (True, TIFF)]) +async def test_edxd_custom_pos_list_grid( + RE: RunEngine, + germ: GeRMDetector, + stage: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + two_shutters: tuple[Shutter, Shutter], + save_as_tiff: bool, + mimetype: str, +): + x, y = stage + fe_shutter, photon_shutter = two_shutters + for motor, position in ((x, 5.0), (y, -1.0)): + set_mock_value(motor.user_setpoint, position) + set_mock_value(motor.user_readback, position) + + docs, _ = _run( + RE, + edxd_custom_pos_list_grid( + x, [0.0, 1.0], + y, [0.0], [1.0], [3], + 0.02, + save_as_tiff=save_as_tiff, + description="pos list", + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ), + ) # fmt: skip + + # One run per outer position, each a 3-point inner scan from the shared range. + first, second = docs["start"] + assert [s["outer_position"] for s in docs["start"]] == [0.0, 1.0] + assert all(s["plan_name"] == "edxd_custom_pos_list_grid" for s in docs["start"]) + assert all(s["tiled_specs"] == EDXD_SCAN_SPECS for s in docs["start"]) + assert all(s["description"] == "pos list" for s in docs["start"]) + assert "initial_scan_uid" not in first + assert second["initial_scan_uid"] == first["uid"] + for events in _events_by_run(docs): + assert [e["data"]["y"] for e in events] == pytest.approx([0, 0.5, 1]) + assert {r["mimetype"] for r in docs["stream_resource"]} == {mimetype} + + await _assert_closed(fe_shutter, photon_shutter) + # both motors go back to where they were before the scan + assert await x.user_readback.get_value() == pytest.approx(5.0) + assert await y.user_readback.get_value() == pytest.approx(-1.0) + + +async def test_edxd_custom_pos_list_grid_per_position_ranges( + RE: RunEngine, + germ: GeRMDetector, + stage: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + two_shutters: tuple[Shutter, Shutter], +): + x, y = stage + fe_shutter, photon_shutter = two_shutters + + docs, _ = _run( + RE, + edxd_custom_pos_list_grid( + x, [0.0, 1.0], + y, [0.0, 10.0], [1.0, 12.0], [2, 3], + 0.02, + reset_positions=False, + use_shutter=False, + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ), + ) # fmt: skip + + inner = [[e["data"]["y"] for e in events] for events in _events_by_run(docs)] + assert inner == [pytest.approx([0, 1]), pytest.approx([10, 11, 12])] + assert await x.user_readback.get_value() == pytest.approx(1.0) + assert await y.user_readback.get_value() == pytest.approx(12.0) + + +def test_edxd_custom_pos_list_grid_rejects_mismatched_ranges( + RE: RunEngine, + germ: GeRMDetector, + stage: tuple[AsyncEpicsMotor, AsyncEpicsMotor], + two_shutters: tuple[Shutter, Shutter], +): + x, y = stage + fe_shutter, photon_shutter = two_shutters + + with pytest.raises(ValueError, match="same length as the number of outer"): + RE( + edxd_custom_pos_list_grid( + x, [0.0, 1.0], + y, [0.0, 1.0, 2.0], [1.0], [3], + 0.02, + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ) + ) # fmt: skip + assert not get_mock_put(x.user_setpoint).called + + +# --- edxd_2theta_tilt ----------------------------------------------------------------- + + +@pytest.fixture +def tables() -> tuple[EDXDTable, CollimatorTable]: + with init_devices(mock=True): + edxd_table = EDXDTable("EDXD:") + collimator_table = CollimatorTable("COLL:") + _fast(edxd_table, collimator_table) + return edxd_table, collimator_table + + +@pytest.mark.parametrize( + ("theta", "expected_y", "expected_pitch", "expected_y_coarse"), + [ + # reference values for a detector 600 mm downstream (edxd_table.z) + (5.0, -52.49319811555441, 0.9264658388913709, -25.721634367274543), + (0.0, 0.0, -4.073534161108629, 11.715195973507164), + ], +) +async def test_edxd_2theta_tilt( + RE: RunEngine, + tables: tuple[EDXDTable, CollimatorTable], + theta: float, + expected_y: float, + expected_pitch: float, + expected_y_coarse: float, +): + edxd_table, collimator_table = tables + set_mock_value(edxd_table.z.user_readback, 600.0) + + RE( + edxd_2theta_tilt( + theta, edxd_table=edxd_table, collimator_table=collimator_table + ) + ) + + assert await edxd_table.x.user_readback.get_value() == pytest.approx(0.0) + assert await edxd_table.rx.user_readback.get_value() == pytest.approx(theta) + assert await edxd_table.y.user_readback.get_value() == pytest.approx(expected_y) + assert await collimator_table.slits_pitch.user_readback.get_value() == ( + pytest.approx(expected_pitch) + ) + assert await collimator_table.y_coarse.user_readback.get_value() == ( + pytest.approx(expected_y_coarse) + ) + assert await collimator_table.x.user_readback.get_value() == pytest.approx(-54) + # the detector distance is only read + assert not get_mock_put(edxd_table.z.user_setpoint).called + + +async def test_edxd_2theta_tilt_move_out_only_retracts_the_table( + RE: RunEngine, tables: tuple[EDXDTable, CollimatorTable] +): + edxd_table, collimator_table = tables + + RE( + edxd_2theta_tilt( + 10.0, + move_out=True, + edxd_table=edxd_table, + collimator_table=collimator_table, + ) + ) + + assert await edxd_table.x.user_readback.get_value() == pytest.approx(-270.0) + others = [edxd_table.y, edxd_table.rx, *_motors_in(collimator_table)] + assert not any(get_mock_put(m.user_setpoint).called for m in others) + + +# --- edxd_calib_scan ------------------------------------------------------------------ + + +@pytest.fixture +async def sample_tower() -> SampleTower: + with init_devices(mock=True): + sample_tower = SampleTower("TWR:") + _fast(sample_tower) + z1 = sample_tower.z1 + set_mock_value(z1.max_velocity, 100.0) + # A distinct starting velocity, so a missed restore shows. Put, not mocked: a + # mocked value leaves the mock setpoint at 0, and the plan restores the + # setpoint (on the IOC, VELO's setpoint is its value). + await z1.velocity.set(7.0) + get_mock_put(z1.velocity).reset_mock() + return sample_tower + + +def _put_values(motor_signal) -> list[float]: + return [c.args[0] for c in get_mock_put(motor_signal).call_args_list] + + +async def test_edxd_calib_scan_sweeps_during_one_count( + RE: RunEngine, + germ: GeRMDetector, + sample_tower: SampleTower, + two_shutters: tuple[Shutter, Shutter], + tiled: CatalogOfBlueskyRuns, +): + z1 = sample_tower.z1 + fe_shutter, photon_shutter = two_shutters + set_mock_value(z1.user_setpoint, 0.4) + set_mock_value(z1.user_readback, 0.4) + + docs, _ = _run( + RE, + edxd_calib_scan( + 0.0, + 1.0, + iterations=2, + count_time=0.5, + description="CeO2", + sample_tower=sample_tower, + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ), + ) + + (start,) = docs["start"] + assert start["plan_name"] == "edxd_calib_scan" + assert start["tiled_specs"] == [ + {"name": EDXD_CALIBRATION_V1.name, "version": EDXD_CALIBRATION_V1.version} + ] + assert start["description"] == "CeO2" + # A single count, read once the sweep is done. + (event,) = docs["event"] + assert event["data"]["germ-stats1-total"] > 0 + assert np.asarray(tiled[start["uid"]]["primary"]["germ"].read()).shape[0] == 1 + assert await germ.driver.acquire_time.get_value() == 0.5 + + # in range: sweeps from where z1 is, origin -> stop -> start -> origin, at + # two spans per iteration over the count (2 * 1.0 * 2 / 0.5 = 8/s) + assert _put_values(z1.velocity)[0] == pytest.approx(8.0) + assert _put_values(z1.user_setpoint)[:3] == pytest.approx([1.0, 0.0, 0.4]) + get_mock_put(z1.motor_stop).assert_called() + + # z1 speed and position restored, photon shutter closed + assert await z1.velocity.get_value() == 7.0 + assert await z1.user_readback.get_value() == pytest.approx(0.4) + assert await photon_shutter.status.get_value() == ShutterStatus.CLOSED + + +async def test_edxd_calib_scan_moves_in_range_first_at_full_speed( + RE: RunEngine, + germ: GeRMDetector, + sample_tower: SampleTower, + two_shutters: tuple[Shutter, Shutter], +): + z1 = sample_tower.z1 + fe_shutter, photon_shutter = two_shutters + set_mock_value(z1.user_setpoint, 3.0) + set_mock_value(z1.user_readback, 3.0) + + _run( + RE, + edxd_calib_scan( + 0.0, + 1.0, + iterations=1, + count_time=0.5, + sample_tower=sample_tower, + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ), + ) + + # out of range: to the nearer end (stop) at max speed, then sweep from there + # at 2 * 1.0 * 1 / 0.5 = 4/s + assert _put_values(z1.velocity)[:2] == pytest.approx([100.0, 4.0]) + assert _put_values(z1.user_setpoint)[:3] == pytest.approx([1.0, 1.0, 0.0]) + assert await z1.velocity.get_value() == 7.0 + assert await z1.user_readback.get_value() == pytest.approx(3.0) + + +def test_edxd_calib_scan_rejects_a_sweep_faster_than_the_motor( + RE: RunEngine, + germ: GeRMDetector, + sample_tower: SampleTower, + two_shutters: tuple[Shutter, Shutter], +): + fe_shutter, photon_shutter = two_shutters + + # 2 spans of 1 mm in 0.01 s needs 200 mm/s; z1 tops out at 100 + with pytest.raises(ValueError, match="exceeds max velocity"): + RE( + edxd_calib_scan( + 0.0, + 1.0, + iterations=2, + count_time=0.01, + sample_tower=sample_tower, + germ=germ, + fe_shutter=fe_shutter, + photon_shutter=photon_shutter, + ) + ) + assert not get_mock_put(sample_tower.z1.user_setpoint).called + assert not get_mock_put(germ.driver.acquire).called diff --git a/tests/test_magics.py b/tests/test_magics.py new file mode 100644 index 0000000..5bbb932 --- /dev/null +++ b/tests/test_magics.py @@ -0,0 +1,29 @@ +"""%mov and %movr leave the RunEngine's waiting hook as they found it.""" + +import pytest +from bluesky import RunEngine +from IPython.core.interactiveshell import InteractiveShell +from ophyd.sim import motor # type: ignore + +from hextools.utils.magics import HEXBlueskyMagics + + +# Copilot on #109: they set RE.waiting_hook to None afterwards, so the GUI's +# progress bars stopped showing for later scans. +@pytest.mark.parametrize("magic", ["mov", "movr"]) +def test_mov_magics_restore_the_waiting_hook(RE: RunEngine, magic: str): + def gui_hook(statuses): + return None + + shell = InteractiveShell.instance() + try: + shell.user_ns["motor"] = motor + HEXBlueskyMagics.RE = RE + shell.register_magics(HEXBlueskyMagics) + RE.waiting_hook = gui_hook + + shell.run_line_magic(magic, "motor 1") + + assert RE.waiting_hook is gui_hook + finally: + InteractiveShell.clear_instance() diff --git a/tests/test_nl_msg_hook.py b/tests/test_nl_msg_hook.py index 85bf410..309932b 100644 --- a/tests/test_nl_msg_hook.py +++ b/tests/test_nl_msg_hook.py @@ -304,6 +304,29 @@ def test_push_returns_lines_only_once_finalized(): assert narrator.flush() == [] +def test_back_to_back_checkpoints_are_narrated_once(): + checkpoint = "Marking a checkpoint (a safe point to pause or resume)." + lines = narrate_stream( + [_msg("checkpoint"), _msg("checkpoint"), _msg("save"), _msg("checkpoint")] + ) + assert lines == [ + checkpoint, + "Saving the collected readings as a data point.", + checkpoint, + ] + + +def test_count_narrates_one_checkpoint_per_shot(RE): + from bluesky import plans as bp + from ophyd_async.sim import SimMotor + + emitted: list[str] = [] + RE.msg_hook = MsgHookNarrator(emit=emitted.append) + RE(bp.count([SimMotor(name="m")], num=2)) + checkpoints = [line for line in emitted if line.startswith("Marking a checkpoint")] + assert len(checkpoints) == 2 + + def test_window_splits_batches_spanning_too_long(): narrator = StreamNarrator(window=10.0) out = [] @@ -374,14 +397,19 @@ def test_hook_serializes_raw_msgs_and_emits_lines(): def test_hook_accepts_pre_serialized_messages(): emitted: list[str] = [] - hook = MsgHookNarrator(emit=emitted.append, serialize=False) + hook = MsgHookNarrator(emit=emitted.append) hook(_msg("checkpoint")) assert emitted == ["Marking a checkpoint (a safe point to pause or resume)."] +def test_hook_rejects_other_message_types(): + with pytest.raises(TypeError, match="Msg or a dict"): + MsgHookNarrator(emit=lambda _: None)(("checkpoint",)) # type: ignore + + def test_hook_flush_and_due_emit_buffered_lines(): emitted: list[str] = [] - hook = MsgHookNarrator(emit=emitted.append, serialize=False, window=10.0) + hook = MsgHookNarrator(emit=emitted.append, window=10.0) hook(_msg("read", "det", time=0.0)) hook.due(now=20.0) assert emitted == ["Reading the current value of 'det'."] @@ -413,7 +441,7 @@ def log_records(): def test_hook_logs_at_info_to_hextools_logger_by_default(): - hook = MsgHookNarrator(serialize=False) + hook = MsgHookNarrator() assert hook._logger is logging.getLogger("hextools") assert hook._level == logging.INFO @@ -421,7 +449,7 @@ def test_hook_logs_at_info_to_hextools_logger_by_default(): @pytest.mark.parametrize("pass_name", [True, False]) def test_hook_logs_lines_to_given_logger(log_records, pass_name): logger, records = log_records - hook = MsgHookNarrator(logger=logger.name if pass_name else logger, serialize=False) + hook = MsgHookNarrator(logger=logger.name if pass_name else logger) hook(_msg("checkpoint")) assert [(r.levelno, r.getMessage()) for r in records] == [ (logging.INFO, "Marking a checkpoint (a safe point to pause or resume).") @@ -430,7 +458,7 @@ def test_hook_logs_lines_to_given_logger(log_records, pass_name): def test_hook_log_level_is_configurable(log_records): logger, records = log_records - hook = MsgHookNarrator(logger=logger, level=logging.DEBUG, serialize=False) + hook = MsgHookNarrator(logger=logger, level=logging.DEBUG) hook(_msg("checkpoint")) assert [r.levelno for r in records] == [logging.DEBUG] @@ -438,7 +466,7 @@ def test_hook_log_level_is_configurable(log_records): def test_hook_emit_replaces_logging(log_records): logger, records = log_records emitted: list[str] = [] - hook = MsgHookNarrator(emit=emitted.append, logger=logger, serialize=False) + hook = MsgHookNarrator(emit=emitted.append, logger=logger) hook(_msg("checkpoint")) assert emitted == ["Marking a checkpoint (a safe point to pause or resume)."] assert records == [] @@ -446,7 +474,7 @@ def test_hook_emit_replaces_logging(log_records): def test_hook_listeners_receive_lines_and_can_be_removed(): emitted, heard = [], [] - hook = MsgHookNarrator(emit=emitted.append, serialize=False) + hook = MsgHookNarrator(emit=emitted.append) hook.add_listener(heard.append) hook(_msg("checkpoint")) hook.remove_listener(heard.append) @@ -462,7 +490,7 @@ def test_hook_ignores_broken_listeners(): def broken(_line): raise RuntimeError("listener bug") - hook = MsgHookNarrator(emit=emitted.append, serialize=False) + hook = MsgHookNarrator(emit=emitted.append) hook.add_listener(broken) hook.add_listener(heard.append) hook(_msg("checkpoint")) diff --git a/tests/test_path_providers.py b/tests/test_path_providers.py new file mode 100644 index 0000000..5104704 --- /dev/null +++ b/tests/test_path_providers.py @@ -0,0 +1,10 @@ +"""Mock detectors never reuse a file name.""" + +from hextools.utils.path_providers import mock_path_provider + + +# Copilot on #109: second-resolution names let two short runs in the same +# second share an asset file. +def test_mock_path_provider_gives_each_call_a_new_file(tmp_path): + provider = mock_path_provider(tmp_path) + assert provider("germ").filename != provider("germ").filename diff --git a/tests/tomography/test_radiography.py b/tests/tomography/test_radiography.py index ea22e53..52c8cdf 100644 --- a/tests/tomography/test_radiography.py +++ b/tests/tomography/test_radiography.py @@ -24,10 +24,7 @@ async def test_take_radiograph_single_row( RE: RunEngine, kinetix_det_factory: Callable[[int], KinetixDetector], two_shutters: tuple[Shutter, Shutter], - monkeypatch: pytest.MonkeyPatch, ): - # the profile sets this; tests do not load the profile - monkeypatch.setenv("OPHYD_ASYNC_PRESERVE_DETECTOR_STATE", "YES") exposure_time, num_images, num_acquisitions, wait = 0.1, 10, 5, 0.01 fe_shutter, photon_shutter = two_shutters @@ -196,11 +193,8 @@ def test_external_trigger_without_a_panda_fails_before_arming( RE: RunEngine, kinetix_det_factory: Callable[[int], KinetixDetector], two_shutters: tuple[Shutter, Shutter], - monkeypatch: pytest.MonkeyPatch, ): """With nothing pulsing the camera, an external radiograph used to time out.""" - # the profile sets this, so trigger() re-arms with the camera's own (external) state - monkeypatch.setenv("OPHYD_ASYNC_PRESERVE_DETECTOR_STATE", "YES") fe_shutter, photon_shutter = two_shutters ktx = kinetix_det_factory(1) with pytest.raises(ValueError, match="panda"):