diff --git a/pixi.lock b/pixi.lock index b2ffcfc4..d41b7c67 100644 --- a/pixi.lock +++ b/pixi.lock @@ -103,7 +103,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/h5py-3.14.0-nompi_py311h0b2f468_101.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.0.0-h15599e2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.1.0-h15599e2_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h6e4c0c1_103.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda @@ -165,8 +165,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.1.0-hb03c661_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.1.0-hb03c661_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.9.0-36_hfef963f_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.2-default_h99862b1_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.2-default_h746c552_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.2-default_h99862b1_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.2-default_h746c552_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.14.1-h332b0f4_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.24-h86f0d12_0.conda @@ -225,7 +225,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzopfli-1.0.3-h9c3ff4c_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.2-h4922eb0_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.2-h4922eb0_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/loguru-0.7.3-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lzo-2.10-h280c20c_1002.conda @@ -251,7 +251,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/nh3-0.3.0-py310h1570de5_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.13.1-mkl_py311h3762c3e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.13.1-mkl_py311h3762c3e_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.3-py311h2e04523_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/objprint-0.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda @@ -292,7 +292,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyproject_hooks-1.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.2-py311he4c1a5a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.3-py311he4c1a5a_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda @@ -313,7 +313,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py311h3778330_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py311h2315fbb_0.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.9.2-h5c1c036_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.3-h5c1c036_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.7.1-h8fae777_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/readchar-4.2.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda @@ -621,7 +621,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzip-1.11.2-h1336266_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzopfli-1.0.3-h9f76cd9_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.2-h4a912ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.2-h4a912ad_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-20-20.1.8-h91fd4e7_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-20.1.8-h855ad52_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/loguru-0.7.3-pyh707e725_0.conda @@ -648,7 +648,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nh3-0.3.0-py310h1620c0a_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.13.1-py311h5890ad2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.13.1-py311h5890ad2_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.3.3-py311h8685306_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/objprint-0.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hbfb3c88_0.conda @@ -856,13 +856,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-25.7.0-py312h7900ff3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-25.9.0-py312h7900ff3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/conda-libmamba-solver-25.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/conda-package-handling-2.4.0-pyh7900ff3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/conda-package-streaming-0.12.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/conda-tree-1.1.1-pyhff2d567_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py312hd9148b4_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cpp-expected-1.1.0-hff21bea_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cpp-expected-1.3.1-h171cf75_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.11-py312hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hd9c7081_0.conda @@ -897,7 +897,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/h5py-3.14.0-nompi_py312ha4f8f14_101.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.0.0-h15599e2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.1.0-h15599e2_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h6e4c0c1_103.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda @@ -952,8 +952,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.1.0-hb03c661_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.1.0-hb03c661_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.9.0-36_hfef963f_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.2-default_h99862b1_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.2-default_h746c552_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.2-default_h99862b1_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.2-default_h746c552_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.14.1-h332b0f4_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.24-h86f0d12_0.conda @@ -981,8 +981,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.9.0-36_h5e43f62_mkl.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm21-21.1.2-hf7376ad_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmamba-2.3.2-hae34dd5_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmambapy-2.3.2-py312h79ae05a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmamba-2.3.2-hae34dd5_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmambapy-2.3.2-py312he1eb750_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.9.3-nompi_h11f7409_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda @@ -1011,7 +1011,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzopfli-1.0.3-h9c3ff4c_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.2-h4922eb0_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.2-h4922eb0_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/loguru-0.7.3-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lzo-2.10-h280c20c_1002.conda @@ -1033,7 +1033,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.11.3-he02047a_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.13.1-mkl_py312hf8315b2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.13.1-mkl_py312hf8315b2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.3-py312h33ff503_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/objprint-0.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda @@ -1067,7 +1067,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.33.2-py312h680f630_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.2-py312h9da60e5_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.3-py312h9da60e5_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.11-h9e4cc4f_0_cpython.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda @@ -1081,7 +1081,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hfb55c3c_0.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.9.2-h5c1c036_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.3-h5c1c036_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.7.1-h8fae777_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.36.2-pyh29332c3_0.conda @@ -1099,7 +1099,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.2-py312h7a1785b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.3-pyh0d859eb_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/simdjson-3.13.0-h84d6215_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/simdjson-4.0.7-hb700be7_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_1.conda @@ -1222,13 +1222,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/conda-25.7.0-py313h8f79df9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/conda-25.9.0-py313h8f79df9_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/conda-libmamba-solver-25.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/conda-package-handling-2.4.0-pyh7900ff3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/conda-package-streaming-0.12.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/conda-tree-1.1.1-pyhff2d567_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/contourpy-1.3.3-py313hc50a443_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cpp-expected-1.1.0-h177bc72_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cpp-expected-1.3.1-h4f10f1e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.7-py313hd8ed1ab_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/dav1d-1.2.1-hb547adb_0.conda @@ -1322,8 +1322,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjxl-0.11.1-h7274d02_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.9.0-36_hd9741b5_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmamba-2.3.2-he5fc5d6_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmambapy-2.3.2-py313he39c48f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmamba-2.3.2-hbdbd6ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmambapy-2.3.2-py313h904c928_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnetcdf-4.9.3-nompi_h80c4520_103.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.67.0-hc438710_0.conda @@ -1342,7 +1342,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzip-1.11.2-h1336266_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzopfli-1.0.3-h9f76cd9_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.2-h4a912ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.2-h4a912ad_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/loguru-0.7.3-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h925e9cb_1002.conda @@ -1363,7 +1363,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nlohmann_json-3.11.3-h00cdb27_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.13.1-py313h73ed539_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.13.1-py313h73ed539_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.3.3-py313h9771d21_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/objprint-0.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hbfb3c88_0.conda @@ -1426,7 +1426,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.16.2-py313h0d10b07_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.3-pyh31c8845_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/simdjson-3.13.0-ha393de7_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/simdjson-4.0.7-ha7d2532_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-hd121638_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_1.conda @@ -1629,6 +1629,7 @@ packages: - anaconda-client >=1.13.0 - anaconda-cloud-cli >=0.3.0 license: BSD-3-Clause + license_family: BSD purls: - pkg:pypi/anaconda-cli-base?source=hash-mapping size: 21592 @@ -2930,9 +2931,9 @@ packages: - pkg:pypi/conda?source=hash-mapping size: 1200846 timestamp: 1736948001512 -- conda: https://conda.anaconda.org/conda-forge/linux-64/conda-25.7.0-py312h7900ff3_0.conda - sha256: bdf45c0aca18b4c0e620afb94b703712e7b2fb406e3f7cdda546da219e73d14b - md5: e1b5199d835f8d70013c04e01fbe51ab +- conda: https://conda.anaconda.org/conda-forge/linux-64/conda-25.9.0-py312h7900ff3_0.conda + sha256: 8c63d1e990e2a75478528de8ee84563b086de84618cedf8f0eded63ad514adaf + md5: f7f12232b45b5d1131377265f699837c depends: - archspec >=0.2.3 - boltons >=23.0.0 @@ -2956,15 +2957,15 @@ packages: - truststore >=0.8.0 - zstandard >=0.19.0 constrains: - - conda-build >=24.3 - - conda-env >=2.6 - conda-content-trust >=0.1.1 + - conda-env >=2.6 + - conda-build >=25.9 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/conda?source=hash-mapping - size: 1220637 - timestamp: 1754405339293 + size: 1205267 + timestamp: 1759350687292 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/conda-24.11.3-py311h267d04e_0.conda sha256: feb5485a926a1944d5bd1b29cea98644458db51405a67f14ef48c0468e676279 md5: 7f8dc0fe2aa126eeea0a13d99b187f65 @@ -3001,9 +3002,9 @@ packages: - pkg:pypi/conda?source=hash-mapping size: 1202581 timestamp: 1736460563069 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/conda-25.7.0-py313h8f79df9_0.conda - sha256: b2cafea8d918398538f7dcaf8813739bf74f7ef68aa87339ff08d585ea2f08fb - md5: 22e90621fbec0a6c739ea97ed81bfa37 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/conda-25.9.0-py313h8f79df9_0.conda + sha256: a562756a068d333f0fa303412fc09ca9fdae5424adc5630c97854801f4eb5059 + md5: 16c7bf308013fa06d889549a7e82a0c0 depends: - archspec >=0.2.3 - boltons >=23.0.0 @@ -3028,15 +3029,15 @@ packages: - truststore >=0.8.0 - zstandard >=0.19.0 constrains: - - conda-build >=24.3 - conda-content-trust >=0.1.1 - conda-env >=2.6 + - conda-build >=25.9 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/conda?source=hash-mapping - size: 1241138 - timestamp: 1754405526295 + size: 1225519 + timestamp: 1759350945350 - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-build-24.5.1-py311h38be061_0.conda sha256: a8ec9f9daa18d171df1f5cfac60fcad78219a27f11eb1e6d9d87d504da6fea54 md5: 2934a4e4d7f4c6de558f071208144e31 @@ -3309,27 +3310,27 @@ packages: - pkg:pypi/coverage?source=compressed-mapping size: 390154 timestamp: 1758501107590 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cpp-expected-1.1.0-hff21bea_1.conda - sha256: 234e423531e0d5f31e8e8b2979c4dfa05bdb4c502cb3eb0a5db865bd831d333e - md5: 54e8e1a8144fd678c5d43905e3ba684d +- conda: https://conda.anaconda.org/conda-forge/linux-64/cpp-expected-1.3.1-h171cf75_0.conda + sha256: 0d9405d9f2de5d4b15d746609d87807aac10e269072d6408b769159762ed113d + md5: d17488e343e4c5c0bd0db18b3934d517 depends: - - libstdcxx >=13 - - libgcc >=13 + - libstdcxx >=14 + - libgcc >=14 - __glibc >=2.17,<3.0.a0 license: CC0-1.0 purls: [] - size: 24113 - timestamp: 1745308833071 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cpp-expected-1.1.0-h177bc72_1.conda - sha256: a41d97157e628947d13bf5920bf0d533f81b8a3ed68dbe4171149f522e99eae6 - md5: 05692bdc7830e860bd32652fa7857705 + size: 24283 + timestamp: 1756734785482 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cpp-expected-1.3.1-h4f10f1e_0.conda + sha256: a7380056125a29ddc4c4efc4e39670bc8002609c70f143d92df801b42e0b486f + md5: 5cb4f9b93055faf7b6ae76da6123f927 depends: - __osx >=11.0 - - libcxx >=18 + - libcxx >=19 license: CC0-1.0 purls: [] - size: 24791 - timestamp: 1745308950557 + size: 24960 + timestamp: 1756734870487 - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.11.13-py311hd8ed1ab_0.conda noarch: generic sha256: ab70477f5cfb60961ba27d84a4c933a24705ac4b1736d8f3da14858e95bbfa7a @@ -4111,9 +4112,9 @@ packages: - pkg:pypi/h5py?source=hash-mapping size: 1173760 timestamp: 1756767793170 -- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.0.0-h15599e2_0.conda - sha256: 72c7db250fa0817ecc4b528f5aea9bba78214b72848b802a03a986976fd6a482 - md5: f51141497ab3059b7529304deb6ef9d7 +- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.1.0-h15599e2_0.conda + sha256: df2a964f5b7dd652b59da018f1d2d9ae402b815c4e5d849384344df358d2a565 + md5: 7704b1edaa8316b8792424f254c1f586 depends: - __glibc >=2.17,<3.0.a0 - cairo >=1.18.4,<2.0a0 @@ -4129,8 +4130,8 @@ packages: license: MIT license_family: MIT purls: [] - size: 2409486 - timestamp: 1759198318078 + size: 2058414 + timestamp: 1759365674184 - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda sha256: 0d09b6dc1ce5c4005ae1c6a19dc10767932ef9a5e9c755cfdbb5189ac8fb0684 md5: bd77f8da987968ec3927990495dc22e4 @@ -5585,9 +5586,9 @@ packages: purls: [] size: 17717 timestamp: 1758397731650 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.2-default_h99862b1_0.conda - sha256: 61aa5bb5f2e61cfdb2a3d66ea72f829a1a7056674acd002f5bf0de1c0e39b29e - md5: 4ddb1793f7c9493e24f2034ff0a19cff +- conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.2-default_h99862b1_1.conda + sha256: 9c41692805b103029334c022b2e0ee52ac03670c639e1c7b657aded1ac9e2536 + md5: 92b248949e98412943a3a15e8f48eb6c depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -5596,11 +5597,11 @@ packages: license: Apache-2.0 WITH LLVM-exception license_family: Apache purls: [] - size: 21082467 - timestamp: 1758875974778 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.2-default_h746c552_0.conda - sha256: f14eb6df9de14f991c4c652794243c2fbd137a77bb93e341f89a426deb30e687 - md5: f1a8955f73d2eb209666739946a8b517 + size: 21063522 + timestamp: 1759415271176 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.2-default_h746c552_1.conda + sha256: d9fb13f0213b9729b7a9fb88ec67c1382f4f5d904c0041d22c84367ceb04d29c + md5: 8f223d9bb7688071729bd5fe1b7eba5d depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -5609,8 +5610,8 @@ packages: license: Apache-2.0 WITH LLVM-exception license_family: Apache purls: [] - size: 12344958 - timestamp: 1758876158891 + size: 12345728 + timestamp: 1759415589603 - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda sha256: cb83980c57e311783ee831832eb2c20ecb41e7dee6e86e8b70b8cef0e43eab55 md5: d4a250da4737ee127fb1fa6452a9002e @@ -6200,30 +6201,29 @@ packages: purls: [] size: 1678454 timestamp: 1749642796037 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libmamba-2.3.2-hae34dd5_0.conda - sha256: 836285252b9a52687dad6254c15c2de7f1bb8bb15a168ef269bd0cfc24ac6b3e - md5: 598e505292d59c184cb881cbfd6e1456 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmamba-2.3.2-hae34dd5_1.conda + sha256: 076c794defe20c4ba4700903b182a210b9f57409ef6accb952debfd84f09e3df + md5: 0d007783a3bc981d98613023c5024b95 depends: - nlohmann_json >=3.11.3,<3.11.4.0a0 - - cpp-expected >=1.1.0,<1.1.1.0a0 + - cpp-expected >=1.3.1,<1.3.2.0a0 - libstdcxx >=14 - libgcc >=14 - __glibc >=2.17,<3.0.a0 - - yaml-cpp >=0.8.0,<0.9.0a0 - - zstd >=1.5.7,<1.6.0a0 - - openssl >=3.5.2,<4.0a0 - - reproc >=14.2,<15.0a0 + - simdjson >=4.0.7,<4.1.0a0 + - fmt >=11.2.0,<11.3.0a0 - libcurl >=8.14.1,<9.0a0 + - yaml-cpp >=0.8.0,<0.9.0a0 - libarchive >=3.8.1,<3.9.0a0 - - fmt >=11.2.0,<11.3.0a0 - - simdjson >=3.13.0,<3.14.0a0 - libsolv >=0.7.35,<0.8.0a0 - reproc-cpp >=14.2,<15.0a0 + - openssl >=3.5.4,<4.0a0 + - reproc >=14.2,<15.0a0 + - zstd >=1.5.7,<1.6.0a0 license: BSD-3-Clause - license_family: BSD purls: [] - size: 2486882 - timestamp: 1756224810884 + size: 2489858 + timestamp: 1759416143198 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmamba-1.5.12-h5bc218b_2.conda sha256: 304a947225ea1cb3f02e4313d221c9f260f6e0b8320a2ecb4795e2fd276d1c6e md5: 090baac1365abcac4d6582be8543e944 @@ -6245,29 +6245,28 @@ packages: purls: [] size: 1222113 timestamp: 1749642666324 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmamba-2.3.2-he5fc5d6_0.conda - sha256: 8b641b77cea90e4bc78cf6f9ec860dff3f52f9ab9ea78f0e4be4508cecd7b1b2 - md5: 04ad172c789ee37f55c38e815c1b622d +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmamba-2.3.2-hbdbd6ab_1.conda + sha256: e00ec157942d5e5a868f52f35d82454a59f1eff887c8d75c717a6e7988a4578b + md5: f0b199b611aaeb4879d39a435ec941f2 depends: - nlohmann_json >=3.11.3,<3.11.4.0a0 - - cpp-expected >=1.1.0,<1.1.1.0a0 + - cpp-expected >=1.3.1,<1.3.2.0a0 - __osx >=11.0 - libcxx >=19 - - reproc-cpp >=14.2,<15.0a0 - - yaml-cpp >=0.8.0,<0.9.0a0 - libarchive >=3.8.1,<3.9.0a0 - - libsolv >=0.7.35,<0.8.0a0 - - simdjson >=3.13.0,<3.14.0a0 + - openssl >=3.5.4,<4.0a0 - fmt >=11.2.0,<11.3.0a0 - - openssl >=3.5.2,<4.0a0 - - reproc >=14.2,<15.0a0 - - zstd >=1.5.7,<1.6.0a0 + - libsolv >=0.7.35,<0.8.0a0 + - yaml-cpp >=0.8.0,<0.9.0a0 + - simdjson >=4.0.7,<4.1.0a0 - libcurl >=8.14.1,<9.0a0 + - zstd >=1.5.7,<1.6.0a0 + - reproc >=14.2,<15.0a0 + - reproc-cpp >=14.2,<15.0a0 license: BSD-3-Clause - license_family: BSD purls: [] - size: 1632963 - timestamp: 1756224815213 + size: 1633892 + timestamp: 1759416203496 - conda: https://conda.anaconda.org/conda-forge/linux-64/libmambapy-1.5.12-py311h5f9230d_2.conda sha256: 7e26a9dfa87bb2cd19044e93d6599ba63ea0d7a26e155a31ca06a46d1278afd8 md5: fd40080d9a13139556d6587d815f6e0b @@ -6288,28 +6287,27 @@ packages: - pkg:pypi/libmambapy?source=hash-mapping size: 328951 timestamp: 1749643230342 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libmambapy-2.3.2-py312h79ae05a_0.conda - sha256: a5c2a2a0bf6d9775dc2290e8b4a700c284d504138fff006e358f073155138bf6 - md5: 6663b51bdcec155f823afb4a8dcf51e9 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmambapy-2.3.2-py312he1eb750_1.conda + sha256: 5d03aa2e56d7ee0092d95237f7c00fcf8970cebbfae830c54585e3f9588879d2 + md5: c0bb0ee98991a17df2ba9473db68b63f depends: - python - - libmamba ==2.3.2 hae34dd5_0 + - libmamba ==2.3.2 hae34dd5_1 - __glibc >=2.17,<3.0.a0 - libstdcxx >=14 - libgcc >=14 + - fmt >=11.2.0,<11.3.0a0 - yaml-cpp >=0.8.0,<0.9.0a0 + - openssl >=3.5.4,<4.0a0 - libmamba >=2.3.2,<2.4.0a0 + - pybind11-abi ==4 - python_abi 3.12.* *_cp312 - - fmt >=11.2.0,<11.3.0a0 - - openssl >=3.5.2,<4.0a0 - zstd >=1.5.7,<1.6.0a0 - - pybind11-abi ==4 license: BSD-3-Clause - license_family: BSD purls: - pkg:pypi/libmambapy?source=hash-mapping - size: 771900 - timestamp: 1756224810885 + size: 773225 + timestamp: 1759416143199 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmambapy-1.5.12-py311h20c71dd_2.conda sha256: 234179d69dc8cf4b1e5006b3e2423af827baa15acf023b64bcd16a29543fcb14 md5: 0d2529c52ff47b0b448838b053ad51f4 @@ -6330,28 +6328,27 @@ packages: - pkg:pypi/libmambapy?source=hash-mapping size: 255521 timestamp: 1749642760665 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmambapy-2.3.2-py313he39c48f_0.conda - sha256: a12c4a7d95dae0004bab49199a46547e8000668f76cdb5b3e9a46835bb6058a7 - md5: 7d92c71f4addd730d0be98ff5a44cfd9 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmambapy-2.3.2-py313h904c928_1.conda + sha256: aae4a297ad5f7264730a2d4888724f2e4a0360b2b653a5ad0adea199aad620fe + md5: 6a5369fa62608b249dc6f877dbfb6399 depends: - python - - libmamba ==2.3.2 he5fc5d6_0 + - libmamba ==2.3.2 hbdbd6ab_1 - python 3.13.* *_cp313 - __osx >=11.0 - libcxx >=19 - - python_abi 3.13.* *_cp313 - libmamba >=2.3.2,<2.4.0a0 - - yaml-cpp >=0.8.0,<0.9.0a0 - zstd >=1.5.7,<1.6.0a0 - - fmt >=11.2.0,<11.3.0a0 - - openssl >=3.5.2,<4.0a0 + - openssl >=3.5.4,<4.0a0 - pybind11-abi ==4 + - fmt >=11.2.0,<11.3.0a0 + - python_abi 3.13.* *_cp313 + - yaml-cpp >=0.8.0,<0.9.0a0 license: BSD-3-Clause - license_family: BSD purls: - - pkg:pypi/libmambapy?source=hash-mapping - size: 653844 - timestamp: 1756224815214 + - pkg:pypi/libmambapy?source=compressed-mapping + size: 654481 + timestamp: 1759416203497 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda sha256: 0a1875fc1642324ebd6c4ac864604f3f18f57fbcf558a8264f6ced028a3c75b2 md5: 85ccccb47823dd9f7a99d2c7f530342f @@ -6972,32 +6969,32 @@ packages: purls: [] size: 147901 timestamp: 1607309166373 -- conda: https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.2-h4922eb0_1.conda - sha256: 89f6a15fe1b7e3c25067dfeb83393f22d8cf529500d9852f94b4a6d5026cbb95 - md5: b57be711428f18bc04feb9700f31f9f0 +- conda: https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.2-h4922eb0_2.conda + sha256: 44a180655e58e33024097d5451d86d9bc80217a6934feec6006edf5ce6982d60 + md5: c37c28468c5310a762bbdf980c157ed4 depends: - __glibc >=2.17,<3.0.a0 constrains: - - intel-openmp <0.0a0 - openmp 21.1.2|21.1.2.* + - intel-openmp <0.0a0 license: Apache-2.0 WITH LLVM-exception license_family: APACHE purls: [] - size: 3324470 - timestamp: 1759257231186 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.2-h4a912ad_1.conda - sha256: 67c7df2cdc08b5a13e72647fbc1294b1288508faf8150a66a48c68154d86fa56 - md5: bff90cf0868f902007fba7a2296f8b92 + size: 3216224 + timestamp: 1759343533216 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.2-h4a912ad_2.conda + sha256: df6d6e539dfb6c4ca4964b7cb9dcb74eb04f42782ee71e5aed06d0eef64c694a + md5: 0203b5856c77eaa4d4b73ea3926a77a0 depends: - __osx >=11.0 constrains: - - intel-openmp <0.0a0 - openmp 21.1.2|21.1.2.* + - intel-openmp <0.0a0 license: Apache-2.0 WITH LLVM-exception license_family: APACHE purls: [] - size: 285742 - timestamp: 1759257497379 + size: 285819 + timestamp: 1759343459194 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-20.1.8-h855ad52_1.conda sha256: 1f94a0336ac63020b8f8c57dca502cae3238f45e9b4cdd6df1fc06d3837ded5e md5: 626404d8b36c1ce1d1c2f84d91d8b99d @@ -7785,9 +7782,9 @@ packages: - bioblend>=1.3.0,<2.0.0 - tomli>=2.0.2,<3.0.0 requires_python: '>=3.10,<4.0' -- conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.13.1-mkl_py311h3762c3e_0.conda - sha256: e44d65b38ef946cf8847e07f4e10cc34021699e7184eea6de26f7f5d75ae1002 - md5: 61a240605d51ac1aa0bbb6aa8c020067 +- conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.13.1-mkl_py311h3762c3e_1.conda + sha256: 3a33466f111665c14263ff9e400952dd7bdaa3c348e1ca280ef5c0ab62070503 + md5: 7dcabf411059079058db434b4eb0ffc8 depends: - __glibc >=2.17,<3.0.a0 - libblas * *mkl @@ -7801,11 +7798,11 @@ packages: license: MIT purls: - pkg:pypi/numexpr?source=hash-mapping - size: 219334 - timestamp: 1759322311040 -- conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.13.1-mkl_py312hf8315b2_0.conda - sha256: 481af2c39b32aec5cde49a617288df99b29af9c86fb39187c2132fd4bb51478d - md5: 9d540917102d3a4fbe2b39e7d8474f5e + size: 220322 + timestamp: 1759426767235 +- conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.13.1-mkl_py312hf8315b2_1.conda + sha256: 4f0cbfd96982d37c91e377371247c13f5c726a28d0164b765e28b795f61b14fa + md5: dba15ca0732033c25c1e665e532c1ed0 depends: - __glibc >=2.17,<3.0.a0 - libblas * *mkl @@ -7818,12 +7815,12 @@ packages: - python_abi 3.12.* *_cp312 license: MIT purls: - - pkg:pypi/numexpr?source=hash-mapping - size: 215055 - timestamp: 1759322314211 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.13.1-py311h5890ad2_0.conda - sha256: 37e5626fcbfdab7e9b5e0e15d159e6a661d044b796d4e825a6f2a3d6ea5b724f - md5: 14186a366efad0dc7e86f3890c77ff2c + - pkg:pypi/numexpr?source=compressed-mapping + size: 215732 + timestamp: 1759426818793 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.13.1-py311h5890ad2_1.conda + sha256: 032ab239b9f8d74aec4e2e4d2da01d96ea6d04bf8662dc4b568afbbbe79fe479 + md5: 9c5170e64228e6cb4079d1f6cd96516e depends: - __osx >=11.0 - libcxx >=19 @@ -7835,11 +7832,11 @@ packages: license: MIT purls: - pkg:pypi/numexpr?source=hash-mapping - size: 201743 - timestamp: 1759322543243 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.13.1-py313h73ed539_0.conda - sha256: fa3f55d743e3cb2e289920d6d0c78454b6518ca4f97d311e57b8a0d8cdb49976 - md5: ed8f5c4176f79f4ccc4df25bf983f314 + size: 200315 + timestamp: 1759427166970 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.13.1-py313h73ed539_1.conda + sha256: fcda2c6039b0248eb6c5ce1fd226c4afab39e273e99408abb770a9e77d583b8b + md5: 0bc50d2a73c58a113f46cac63ab0d002 depends: - __osx >=11.0 - libcxx >=19 @@ -7851,8 +7848,8 @@ packages: license: MIT purls: - pkg:pypi/numexpr?source=hash-mapping - size: 197510 - timestamp: 1759322718010 + size: 198274 + timestamp: 1759427092437 - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.3-py311h2e04523_0.conda sha256: 264528d6e73d5c902a0463d9d138607018d994b86e209df4a51945886233989d md5: 3b0d0a2241770397d3146fdcab3b49f8 @@ -8025,7 +8022,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/orjson?source=compressed-mapping + - pkg:pypi/orjson?source=hash-mapping size: 332944 timestamp: 1756855063149 - conda: https://conda.anaconda.org/conda-forge/linux-64/orjson-3.11.3-py312h868fb18_1.conda @@ -8552,8 +8549,8 @@ packages: timestamp: 1756227402815 - pypi: ./ name: pleiades - version: 0.2.0.dev349+d202509231812 - sha256: da6d142490a01f327dd8238469168d79b0b352863f17c789c9e9e65bafd776f1 + version: 0.2.0.dev356+d202510021821 + sha256: 66b884694eb5ac878d825c2fcd67398225fa2a5d6e06d6405b97936681381972 requires_dist: - numpy - scipy @@ -9074,9 +9071,9 @@ packages: - pkg:pypi/pyproject-hooks?source=hash-mapping size: 15528 timestamp: 1733710122949 -- conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.2-py311he4c1a5a_2.conda - sha256: df39c2a616babf9fd1ca741874d9f611a68760bb907cc88756f7d8a7b02efe00 - md5: 376dec7fa02b4d114e8577097af9b093 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.3-py311he4c1a5a_1.conda + sha256: 6c010613e5e83970a1d2a204e38b5f3af66be252997af9686f285c9d3f77cfe3 + md5: 8c769099c0729ff85aac64f566bcd0d7 depends: - __glibc >=2.17,<3.0.a0 - libclang13 >=21.1.2 @@ -9091,18 +9088,17 @@ packages: - libxslt >=1.1.43,<2.0a0 - python >=3.11,<3.12.0a0 - python_abi 3.11.* *_cp311 - - qt6-main 6.9.2.* - - qt6-main >=6.9.2,<6.10.0a0 + - qt6-main 6.9.3.* + - qt6-main >=6.9.3,<6.10.0a0 license: LGPL-3.0-only - license_family: LGPL purls: - pkg:pypi/pyside6?source=hash-mapping - pkg:pypi/shiboken6?source=hash-mapping - size: 10134131 - timestamp: 1758936072815 -- conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.2-py312h9da60e5_2.conda - sha256: f0a12b1a77b993318120c2cec2ff395477d9fc949f0fe978ad5ac8c8f607fbe3 - md5: 53f6b13482da41e9fe830c2602f7a25a + size: 10130577 + timestamp: 1759402915772 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.3-py312h9da60e5_1.conda + sha256: 31f0d79f4f9c989a9acf566948cbd7d2d1c08e4840a04461f58bc3a734b8332b + md5: 30e8545156cab1f5ff0fe9f0297c77c6 depends: - __glibc >=2.17,<3.0.a0 - libclang13 >=21.1.2 @@ -9117,15 +9113,14 @@ packages: - libxslt >=1.1.43,<2.0a0 - python >=3.12,<3.13.0a0 - python_abi 3.12.* *_cp312 - - qt6-main 6.9.2.* - - qt6-main >=6.9.2,<6.10.0a0 + - qt6-main 6.9.3.* + - qt6-main >=6.9.3,<6.10.0a0 license: LGPL-3.0-only - license_family: LGPL purls: - pkg:pypi/pyside6?source=hash-mapping - pkg:pypi/shiboken6?source=hash-mapping - size: 10135193 - timestamp: 1758935962640 + size: 10161603 + timestamp: 1759403426235 - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda sha256: ba3b032fa52709ce0d9fd388f63d330a026754587a2f461117cac9ab73d8d0d8 md5: 461219d1a5bd61342293efa2c0c90eac @@ -9675,9 +9670,9 @@ packages: purls: [] size: 516376 timestamp: 1720814307311 -- conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.2-h5c1c036_3.conda - sha256: 99b394cb9091949f21a90b44128000c4a887fd37f381043824a0fcf34e9dc1ff - md5: 74c4e82cb1356262f9ce0f39e9f7b045 +- conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.3-h5c1c036_0.conda + sha256: 999ce4a6af5f9570373047f7c61ccc86d17bef294b402b7297b8efc25ec3b5e9 + md5: cc0bffcaf6410aba9c6581dfdc18012d depends: - __glibc >=2.17,<3.0.a0 - alsa-lib >=1.2.14,<1.3.0a0 @@ -9685,11 +9680,11 @@ packages: - double-conversion >=3.3.1,<3.4.0a0 - fontconfig >=2.15.0,<3.0a0 - fonts-conda-ecosystem - - harfbuzz >=11.5.1 + - harfbuzz >=12.0.0 - icu >=75.1,<76.0a0 - krb5 >=1.21.3,<1.22.0a0 - - libclang-cpp21.1 >=21.1.1,<21.2.0a0 - - libclang13 >=21.1.1 + - libclang-cpp21.1 >=21.1.2,<21.2.0a0 + - libclang13 >=21.1.2 - libcups >=2.3.3,<2.4.0a0 - libdrm >=2.4.125,<2.5.0a0 - libegl >=1.7.0,<2.0a0 @@ -9699,7 +9694,7 @@ packages: - libgl >=1.7.0,<2.0a0 - libglib >=2.86.0,<3.0a0 - libjpeg-turbo >=3.1.0,<4.0a0 - - libllvm21 >=21.1.1,<21.2.0a0 + - libllvm21 >=21.1.2,<21.2.0a0 - libpng >=1.6.50,<1.7.0a0 - libpq >=18.0,<19.0a0 - libsqlite >=3.50.4,<4.0a0 @@ -9733,12 +9728,12 @@ packages: - xorg-libxxf86vm >=1.1.6,<2.0a0 - zstd >=1.5.7,<1.6.0a0 constrains: - - qt 6.9.2 + - qt 6.9.3 license: LGPL-3.0-only license_family: LGPL purls: [] - size: 52464414 - timestamp: 1758870923324 + size: 54537863 + timestamp: 1759251856141 - conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.7.1-h8fae777_3.conda sha256: 6e5e704c1c21f820d760e56082b276deaf2b53cf9b751772761c3088a365f6f4 md5: 2c42649888aac645608191ffdc80d13a @@ -10519,29 +10514,29 @@ packages: purls: [] size: 210264 timestamp: 1643442231687 -- conda: https://conda.anaconda.org/conda-forge/linux-64/simdjson-3.13.0-h84d6215_0.conda - sha256: c256cc95f50a5b9f68603c0849b82a3be9ba29527d05486f3e1465e8fed76c4a - md5: f2d511bfca0cc4acca4bb40cd1905dff +- conda: https://conda.anaconda.org/conda-forge/linux-64/simdjson-4.0.7-hb700be7_0.conda + sha256: 5e29efa1927929885e00909c0386b160d13100a73e031432c42e74df2151f775 + md5: cc9c262a71dd584aa5a3a22fc963255c depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - - libstdcxx >=13 + - libgcc >=14 + - libstdcxx >=14 license: Apache-2.0 license_family: APACHE purls: [] - size: 248262 - timestamp: 1749080745183 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/simdjson-3.13.0-ha393de7_0.conda - sha256: 9a34757a186b6931cb123d7b1e56164ac1f55a4083b7d0f942dfed0f06b53d16 - md5: 4ca40a1a4049e3dbd7847200763ac6f5 + size: 267708 + timestamp: 1759262988515 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/simdjson-4.0.7-ha7d2532_0.conda + sha256: a0c961c56ad6606841576ae179172eed30f8b2ae435632e00f91689a6a675dea + md5: 66990c8e1331805f3a553e76b9d1a62a depends: - __osx >=11.0 - - libcxx >=18 + - libcxx >=19 license: Apache-2.0 license_family: APACHE purls: [] - size: 208556 - timestamp: 1749080957534 + size: 225118 + timestamp: 1759263294981 - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d md5: 3339e3b65d58accf4ca4fb8748ab16b3 diff --git a/pyproject.toml b/pyproject.toml index 5186d3a8..1f069c1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,14 @@ markers = [ "gui: tests for the GUI components" ] +# ---------------------------------- # +# ----- Coverage configuration ----- # +# ---------------------------------- # +[tool.coverage.run] +omit = [ + "src/pleiades/processing/normalization_v1.py", # Deprecated legacy code, will be removed +] + # ------------------------------ # # ----- Ruff configuration ----- # # ------------------------------ # diff --git a/src/pleiades/sammy/parameters/__init__.py b/src/pleiades/sammy/parameters/__init__.py index 1451c6fc..b8d95f4f 100644 --- a/src/pleiades/sammy/parameters/__init__.py +++ b/src/pleiades/sammy/parameters/__init__.py @@ -1,3 +1,5 @@ +# Temporary backward compatibility alias - see GitHub issue #146 for naming convention tracking +from pleiades.sammy.io.card_formats.par10_isotopes import Card10 as IsotopeCard # noqa: F401 from pleiades.sammy.parameters.broadening import BroadeningParameterCard # noqa: F401 from pleiades.sammy.parameters.data_reduction import DataReductionCard # noqa: F401 from pleiades.sammy.parameters.external_r import ExternalREntry, ExternalRFunction # noqa: F401 diff --git a/src/pleiades/sammy/parfile.py b/src/pleiades/sammy/parfile.py index 980154ca..75833415 100644 --- a/src/pleiades/sammy/parfile.py +++ b/src/pleiades/sammy/parfile.py @@ -455,7 +455,7 @@ def print_parameters(self) -> None: print("Sammy Parameter File Details:") # check if any cards are present - if all(value is None for value in self.dict().values()): + if all(value is None for value in self.model_dump().values()): print("No cards present in the parameter file.") return else: diff --git a/tests/unit/pleiades/processing/test_helper_ornl.py b/tests/unit/pleiades/processing/test_helper_ornl.py index f53eee02..e24ed9e2 100644 --- a/tests/unit/pleiades/processing/test_helper_ornl.py +++ b/tests/unit/pleiades/processing/test_helper_ornl.py @@ -2,6 +2,8 @@ import os import tempfile +from pathlib import Path +from unittest.mock import patch import numpy as np import pytest @@ -9,6 +11,9 @@ from pleiades.processing.helper_ornl import ( combine_runs, detect_persistent_dead_pixels, + find_nexus_file, + load_multiple_runs, + load_run_from_folder, load_spectra_file, tof_to_energy, ) @@ -172,3 +177,315 @@ def test_corrupted_spectra_file(self): data = load_spectra_file(tmpdir, header=0, sep=",") assert data is None # Should return None on error + + +class TestFindNexusFile: + """Test NeXus file discovery.""" + + def test_find_nexus_in_parent_structure(self): + """Test finding NeXus file in standard ORNL directory structure.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create ORNL-like structure: parent/nexus/VENUS_8022.nxs.h5 + parent = Path(tmpdir) + nexus_dir = parent / "nexus" + nexus_dir.mkdir() + run_dir = parent / "measurements" / "Run_8022" + run_dir.mkdir(parents=True) + + # Create mock NeXus file + nexus_file = nexus_dir / "VENUS_8022.nxs.h5" + nexus_file.touch() + + # Test finding from run folder + result = find_nexus_file(str(run_dir)) + assert result == str(nexus_file) + + def test_find_nexus_with_custom_dir(self): + """Test finding NeXus file with custom nexus directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create custom nexus directory + custom_nexus = Path(tmpdir) / "custom_nexus" + custom_nexus.mkdir() + + # Create NeXus file + nexus_file = custom_nexus / "VENUS_9999.nxs.h5" + nexus_file.touch() + + # Test finding with custom directory + result = find_nexus_file("Run_9999", nexus_dir=str(custom_nexus)) + assert result == str(nexus_file) + + def test_find_nexus_multiple_matches(self): + """Test that first match is returned when multiple files exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + nexus_dir = Path(tmpdir) + + # Create multiple matching files + nexus1 = nexus_dir / "VENUS_8022.nxs.h5" + nexus2 = nexus_dir / "VENUS_8022_corrected.nxs.h5" + nexus1.touch() + nexus2.touch() + + result = find_nexus_file("Run_8022", nexus_dir=str(nexus_dir)) + assert result in [str(nexus1), str(nexus2)] # Should return one of them + + def test_find_nexus_not_found(self): + """Test when NeXus file is not found.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = find_nexus_file("Run_9999", nexus_dir=tmpdir) + assert result is None + + def test_extract_run_number_from_folder(self): + """Test run number extraction from different folder name formats.""" + with tempfile.TemporaryDirectory() as tmpdir: + nexus_dir = Path(tmpdir) + + # Test with underscore format + nexus_file = nexus_dir / "VENUS_1234.nxs.h5" + nexus_file.touch() + + result = find_nexus_file("Run_1234", nexus_dir=str(nexus_dir)) + assert result == str(nexus_file) + + # Test with just number + result = find_nexus_file("1234", nexus_dir=str(nexus_dir)) + assert result == str(nexus_file) + + +class TestLoadRunFromFolder: + """Test loading run data from folder.""" + + @patch("pleiades.processing.helper_ornl.retrieve_list_of_most_dominant_extension_from_folder") + @patch("pleiades.processing.helper_ornl.load") + @patch("pleiades.processing.helper_ornl.load_spectra_file") + @patch("pleiades.processing.helper_ornl.find_nexus_file") + @patch("pleiades.processing.helper_ornl.get_proton_charge") + def test_load_run_complete(self, mock_get_pc, mock_find_nexus, mock_load_spectra, mock_load, mock_retrieve): + """Test loading a complete run with all data available.""" + # Setup mocks + mock_retrieve.return_value = (["img1.tiff", "img2.tiff"], ".tiff") + mock_load.return_value = np.ones((100, 256, 256)) + mock_load_spectra.return_value = np.column_stack([np.arange(100) * 0.001, np.ones(100)]) + mock_find_nexus.return_value = "/path/to/nexus.h5" + mock_get_pc.return_value = 1234567.0 # in pC + + # Load run + run = load_run_from_folder("/path/to/Run_8022") + + # Verify calls + mock_retrieve.assert_called_once_with("/path/to/Run_8022") + mock_load.assert_called_once_with(["img1.tiff", "img2.tiff"], ".tiff") + mock_load_spectra.assert_called_once_with("/path/to/Run_8022") + mock_find_nexus.assert_called_once_with("/path/to/Run_8022", None) + mock_get_pc.assert_called_once_with("/path/to/nexus.h5", units="pc") + + # Check run object + assert run.counts.shape == (100, 256, 256) + assert run.proton_charge == pytest.approx(1.234567) # 1234567 pC / 1e6 = 1.234567 μC + assert run.metadata["folder"] == "/path/to/Run_8022" + assert run.metadata["nexus_path"] == "/path/to/nexus.h5" + assert run.metadata["n_tof"] == 100 + assert len(run.metadata["tof_values"]) == 100 + + @patch("pleiades.processing.helper_ornl.retrieve_list_of_most_dominant_extension_from_folder") + def test_load_run_no_files(self, mock_retrieve): + """Test error when no image files found.""" + mock_retrieve.return_value = ([], "") + + with pytest.raises(ValueError, match="No image files found"): + load_run_from_folder("/empty/folder") + + @patch("pleiades.processing.helper_ornl.retrieve_list_of_most_dominant_extension_from_folder") + @patch("pleiades.processing.helper_ornl.load") + @patch("pleiades.processing.helper_ornl.load_spectra_file") + @patch("pleiades.processing.helper_ornl.find_nexus_file") + def test_load_run_tof_mismatch(self, mock_find_nexus, mock_load_spectra, mock_load, mock_retrieve): + """Test handling of TOF length mismatch.""" + mock_retrieve.return_value = (["img1.tiff"], ".tiff") + mock_load.return_value = np.ones((100, 256, 256)) + # Return wrong number of TOF values + mock_load_spectra.return_value = np.column_stack([np.arange(50) * 0.001, np.ones(50)]) + mock_find_nexus.return_value = None + + run = load_run_from_folder("/path/to/Run_8022") + + # TOF values should be None due to mismatch + assert run.metadata["tof_values"] is None + assert run.metadata["n_tof"] == 100 + + @patch("pleiades.processing.helper_ornl.retrieve_list_of_most_dominant_extension_from_folder") + @patch("pleiades.processing.helper_ornl.load") + @patch("pleiades.processing.helper_ornl.load_spectra_file") + @patch("pleiades.processing.helper_ornl.find_nexus_file") + @patch("pleiades.processing.helper_ornl.get_proton_charge") + def test_load_run_with_custom_nexus_path( + self, mock_get_pc, mock_find_nexus, mock_load_spectra, mock_load, mock_retrieve + ): + """Test loading run with explicitly provided nexus path.""" + mock_retrieve.return_value = (["img1.tiff"], ".tiff") + mock_load.return_value = np.ones((10, 128, 128)) + mock_load_spectra.return_value = None + mock_get_pc.return_value = 5000000.0 # in pC + + # Load with custom nexus path + run = load_run_from_folder("/path/to/Run", nexus_path="/custom/nexus.h5") + + # find_nexus_file should not be called + mock_find_nexus.assert_not_called() + mock_get_pc.assert_called_once_with("/custom/nexus.h5", units="pc") + + assert run.proton_charge == pytest.approx(5.0) # 5000000 pC / 1e6 = 5.0 μC + assert run.metadata["nexus_path"] == "/custom/nexus.h5" + + @patch("pleiades.processing.helper_ornl.retrieve_list_of_most_dominant_extension_from_folder") + @patch("pleiades.processing.helper_ornl.load") + @patch("pleiades.processing.helper_ornl.load_spectra_file") + @patch("pleiades.processing.helper_ornl.find_nexus_file") + @patch("pleiades.processing.helper_ornl.get_proton_charge") + def test_load_run_no_proton_charge(self, mock_get_pc, mock_find_nexus, mock_load_spectra, mock_load, mock_retrieve): + """Test loading run when proton charge is not available.""" + mock_retrieve.return_value = (["img1.tiff"], ".tiff") + mock_load.return_value = np.ones((10, 128, 128)) + mock_load_spectra.return_value = None + mock_find_nexus.return_value = None + mock_get_pc.return_value = None # No proton charge available + + run = load_run_from_folder("/path/to/Run") + + # Should use default proton charge of 1.0 + assert run.proton_charge == 1.0 + assert run.metadata["nexus_path"] is None + + +class TestLoadMultipleRuns: + """Test loading multiple runs.""" + + @patch("pleiades.processing.helper_ornl.load_run_from_folder") + def test_load_multiple_runs_success(self, mock_load_run): + """Test successfully loading multiple runs.""" + # Create mock runs + run1 = Run( + counts=np.ones((100, 256, 256)), + proton_charge=1000.0, + metadata={"run_number": "8022"}, + ) + run2 = Run( + counts=np.ones((100, 256, 256)) * 2, + proton_charge=1500.0, + metadata={"run_number": "8023"}, + ) + run3 = Run( + counts=np.ones((100, 256, 256)) * 3, + proton_charge=2000.0, + metadata={"run_number": "8024"}, + ) + + mock_load_run.side_effect = [run1, run2, run3] + + folders = ["/path/Run_8022", "/path/Run_8023", "/path/Run_8024"] + runs = load_multiple_runs(folders) + + assert len(runs) == 3 + assert runs[0] == run1 + assert runs[1] == run2 + assert runs[2] == run3 + + # Check that each folder was loaded + assert mock_load_run.call_count == 3 + + @patch("pleiades.processing.helper_ornl.load_run_from_folder") + def test_load_multiple_runs_with_nexus_dir(self, mock_load_run): + """Test loading multiple runs with custom nexus directory.""" + run1 = Run(counts=np.ones((10, 10, 10)), proton_charge=100.0) + mock_load_run.return_value = run1 + + folders = ["/path/Run_8022"] + runs = load_multiple_runs(folders, nexus_dir="/custom/nexus") + + mock_load_run.assert_called_once_with("/path/Run_8022", nexus_dir="/custom/nexus") + assert len(runs) == 1 + + @patch("pleiades.processing.helper_ornl.load_run_from_folder") + def test_load_multiple_runs_with_failure(self, mock_load_run): + """Test that failure in one run propagates.""" + mock_load_run.side_effect = ValueError("Failed to load run") + + with pytest.raises(ValueError, match="Failed to load run"): + load_multiple_runs(["/path/Run_8022"]) + + @patch("pleiades.processing.helper_ornl.load_run_from_folder") + def test_load_multiple_runs_empty_list(self, mock_load_run): + """Test loading empty list of runs.""" + runs = load_multiple_runs([]) + + assert len(runs) == 0 + mock_load_run.assert_not_called() + + +class TestIntegrationScenarios: + """Test integration scenarios combining multiple functions.""" + + def test_tof_energy_conversion_roundtrip(self): + """Test TOF to energy conversion consistency.""" + # Create a range of TOF values + tof_original = np.logspace(-4, -1, 100) # 0.1ms to 100ms + + # Convert to energy + energy = tof_to_energy(tof_original, flight_path=25.0) + + # Energy should decrease as TOF increases (slower neutrons) + assert np.all(np.diff(energy) < 0) + + # All energies should be positive (except for zero TOF) + assert np.all(energy[tof_original > 0] > 0) + + def test_dead_pixel_detection_with_noise(self): + """Test dead pixel detection with noisy data.""" + np.random.seed(42) + + # Create data with noise + data = np.random.poisson(10, size=(100, 256, 256)).astype(float) + + # Add some dead pixels + dead_regions = [(10, 20, 30, 40), (100, 110, 150, 160)] + for y1, y2, x1, x2 in dead_regions: + data[:, y1:y2, x1:x2] = 0 + + dead_mask = detect_persistent_dead_pixels(data) + + # Check dead regions are detected + for y1, y2, x1, x2 in dead_regions: + assert np.all(dead_mask[y1:y2, x1:x2]) + + # Check non-dead regions are not marked + assert not np.all(dead_mask) + + def test_combine_runs_preserves_metadata(self): + """Test that combining runs preserves important metadata.""" + tof_values = np.arange(50) * 0.001 + + runs = [ + Run( + counts=np.ones((50, 128, 128)) * i, + proton_charge=100.0 * i, + metadata={ + "run_number": f"802{i}", + "folder": f"/path/Run_802{i}", + "tof_values": tof_values, + "n_tof": 50, + }, + ) + for i in range(1, 4) + ] + + combined = combine_runs(runs) + + # Check metadata preservation + assert combined.metadata["n_runs_combined"] == 3 + assert combined.metadata["source_run_numbers"] == ["8021", "8022", "8023"] + assert np.array_equal(combined.metadata["tof_values"], tof_values) + assert combined.metadata["n_tof"] == 50 + + # Check numerical accuracy + assert combined.proton_charge == 600.0 # 100 + 200 + 300 + assert np.all(combined.counts == 6.0) # 1 + 2 + 3 diff --git a/tests/unit/pleiades/processing/test_normalization_handler.py b/tests/unit/pleiades/processing/test_normalization_handler.py new file mode 100644 index 00000000..8c52b3ee --- /dev/null +++ b/tests/unit/pleiades/processing/test_normalization_handler.py @@ -0,0 +1,673 @@ +"""Comprehensive unit tests for processing/normalization_handler.py module.""" + +import tempfile +from pathlib import Path +from unittest.mock import patch + +import numpy as np +import pytest + +from pleiades.processing import DataType, MasterDictKeys, Roi +from pleiades.processing.normalization_handler import ( + combine_data, + correct_data_for_proton_charge, + correct_data_for_shutter_counts, + get_counts_from_normalized_data, + performing_normalization, + remove_outliers, + update_with_crop, + update_with_data, + update_with_list_of_files, + update_with_rebin, +) + + +class TestUpdateWithListOfFiles: + """Test update_with_list_of_files function.""" + + @patch("pleiades.processing.normalization_handler.retrieve_list_of_most_dominant_extension_from_folder") + def test_update_with_existing_folders(self, mock_retrieve): + """Test updating master dict with list of files from existing folders.""" + mock_retrieve.return_value = (["file1.tiff", "file2.tiff"], ".tiff") + + with tempfile.TemporaryDirectory() as tmpdir: + folder_path = tmpdir + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: {folder_path: {MasterDictKeys.list_images: None}}, + } + + update_with_list_of_files(master_dict) + + assert master_dict[MasterDictKeys.list_folders][folder_path][MasterDictKeys.list_images] == [ + "file1.tiff", + "file2.tiff", + ] + assert master_dict[MasterDictKeys.list_folders][folder_path][MasterDictKeys.ext] == ".tiff" + mock_retrieve.assert_called_once_with(folder_path) + + def test_update_with_nonexistent_folder(self): + """Test that FileNotFoundError is raised for non-existent folders.""" + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: {"/nonexistent/folder": {MasterDictKeys.list_images: None}}, + } + + with pytest.raises(FileNotFoundError, match="Folder /nonexistent/folder does not exist"): + update_with_list_of_files(master_dict) + + @patch("pleiades.processing.normalization_handler.retrieve_list_of_most_dominant_extension_from_folder") + def test_update_multiple_folders(self, mock_retrieve): + """Test updating master dict with multiple folders.""" + mock_retrieve.side_effect = [(["sample1.fits"], ".fits"), (["sample2.fits", "sample3.fits"], ".fits")] + + with tempfile.TemporaryDirectory() as tmpdir: + folder1 = Path(tmpdir) / "folder1" + folder2 = Path(tmpdir) / "folder2" + folder1.mkdir() + folder2.mkdir() + + master_dict = { + MasterDictKeys.data_type: DataType.ob, + MasterDictKeys.list_folders: { + str(folder1): {MasterDictKeys.list_images: None}, + str(folder2): {MasterDictKeys.list_images: None}, + }, + } + + update_with_list_of_files(master_dict) + + assert len(master_dict[MasterDictKeys.list_folders][str(folder1)][MasterDictKeys.list_images]) == 1 + assert len(master_dict[MasterDictKeys.list_folders][str(folder2)][MasterDictKeys.list_images]) == 2 + assert mock_retrieve.call_count == 2 + + +class TestUpdateWithData: + """Test update_with_data function.""" + + @patch("pleiades.processing.normalization_handler.load") + def test_update_with_data_single_folder(self, mock_load): + """Test loading data for single folder.""" + mock_data = np.ones((10, 256, 256)) + mock_load.return_value = mock_data + + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: { + "/path/to/data": { + MasterDictKeys.list_images: ["file1.tiff", "file2.tiff"], + MasterDictKeys.ext: ".tiff", + MasterDictKeys.data: None, + } + }, + } + + update_with_data(master_dict) + + assert np.array_equal(master_dict[MasterDictKeys.list_folders]["/path/to/data"][MasterDictKeys.data], mock_data) + mock_load.assert_called_once_with(["file1.tiff", "file2.tiff"], ".tiff") + + @patch("pleiades.processing.normalization_handler.load") + def test_update_with_data_multiple_folders(self, mock_load): + """Test loading data for multiple folders.""" + mock_data1 = np.ones((5, 128, 128)) + mock_data2 = np.zeros((10, 256, 256)) + mock_load.side_effect = [mock_data1, mock_data2] + + master_dict = { + MasterDictKeys.data_type: DataType.ob, + MasterDictKeys.list_folders: { + "/folder1": {MasterDictKeys.list_images: ["a.fits"], MasterDictKeys.ext: ".fits"}, + "/folder2": {MasterDictKeys.list_images: ["b.fits", "c.fits"], MasterDictKeys.ext: ".fits"}, + }, + } + + update_with_data(master_dict) + + assert master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data].shape == (5, 128, 128) + assert master_dict[MasterDictKeys.list_folders]["/folder2"][MasterDictKeys.data].shape == (10, 256, 256) + assert mock_load.call_count == 2 + + +class TestUpdateWithCrop: + """Test update_with_crop function.""" + + def test_crop_with_valid_roi(self): + """Test cropping with valid ROI.""" + master_dict = {MasterDictKeys.sample_data: {"/folder1": np.ones((10, 256, 256))}} + + roi = Roi(50, 50, 150, 150) + update_with_crop(master_dict, roi) + + # Check that data was cropped + assert master_dict[MasterDictKeys.sample_data]["/folder1"].shape == (10, 100, 100) + + def test_crop_with_none_roi(self): + """Test that None ROI leaves data unchanged.""" + original_data = np.ones((10, 256, 256)) + master_dict = {MasterDictKeys.sample_data: {"/folder1": original_data.copy()}} + + update_with_crop(master_dict, None) + + # Data should be unchanged + assert np.array_equal(master_dict[MasterDictKeys.sample_data]["/folder1"], original_data) + + @patch("pleiades.processing.normalization_handler.crop") + def test_crop_multiple_folders(self, mock_crop): + """Test cropping multiple folders.""" + mock_crop.side_effect = lambda data, roi: data[:, :100, :100] + + master_dict = { + MasterDictKeys.sample_data: {"/folder1": np.ones((5, 256, 256)), "/folder2": np.zeros((10, 512, 512))} + } + + roi = Roi(0, 0, 100, 100) + update_with_crop(master_dict, roi) + + assert mock_crop.call_count == 2 + assert master_dict[MasterDictKeys.sample_data]["/folder1"].shape == (5, 100, 100) + assert master_dict[MasterDictKeys.sample_data]["/folder2"].shape == (10, 100, 100) + + +class TestUpdateWithRebin: + """Test update_with_rebin function.""" + + @patch("pleiades.processing.normalization_handler.rebin") + def test_rebin_with_factor_2(self, mock_rebin): + """Test rebinning with factor 2.""" + input_data = np.ones((10, 256, 256)) + rebinned_data = np.ones((10, 128, 128)) + mock_rebin.return_value = rebinned_data + + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: {"/folder1": {MasterDictKeys.data: input_data}}, + } + + update_with_rebin(master_dict, binning_factor=2) + + mock_rebin.assert_called_once_with(input_data, 2) + assert master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data].shape == (10, 128, 128) + + def test_rebin_with_factor_1_skips(self): + """Test that binning factor 1 skips rebinning.""" + original_data = np.ones((10, 256, 256)) + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: {"/folder1": {MasterDictKeys.data: original_data.copy()}}, + } + + with patch("pleiades.processing.normalization_handler.rebin") as mock_rebin: + update_with_rebin(master_dict, binning_factor=1) + mock_rebin.assert_not_called() + + # Data should be unchanged + assert np.array_equal(master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data], original_data) + + def test_rebin_with_invalid_factor(self): + """Test that invalid binning factor raises ValueError.""" + master_dict = {MasterDictKeys.data_type: DataType.sample, MasterDictKeys.list_folders: {}} + + with pytest.raises(ValueError, match="Binning factor must be positive"): + update_with_rebin(master_dict, binning_factor=0) + + with pytest.raises(ValueError, match="Binning factor must be positive"): + update_with_rebin(master_dict, binning_factor=-1) + + +class TestRemoveOutliers: + """Test remove_outliers function.""" + + @patch("pleiades.processing.normalization_handler.image_processing_remove_outliers") + def test_remove_outliers_basic(self, mock_remove_outliers): + """Test basic outlier removal.""" + input_data = np.ones((10, 256, 256)) + cleaned_data = np.ones((10, 256, 256)) * 0.99 + mock_remove_outliers.return_value = cleaned_data + + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: {"/folder1": {MasterDictKeys.data: input_data}}, + } + + remove_outliers(master_dict, dif=20.0, num_threads=4) + + mock_remove_outliers.assert_called_once_with(input_data, 20.0, 4) + assert np.array_equal(master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data], cleaned_data) + + def test_remove_outliers_invalid_parameters(self): + """Test that invalid parameters raise ValueError.""" + master_dict = {MasterDictKeys.data_type: DataType.sample, MasterDictKeys.list_folders: {}} + + with pytest.raises(ValueError, match="Outlier detection threshold must be positive"): + remove_outliers(master_dict, dif=0, num_threads=4) + + with pytest.raises(ValueError, match="Number of threads must be positive"): + remove_outliers(master_dict, dif=20.0, num_threads=0) + + @patch("pleiades.processing.normalization_handler.image_processing_remove_outliers") + def test_remove_outliers_multiple_folders(self, mock_remove_outliers): + """Test outlier removal on multiple folders.""" + mock_remove_outliers.side_effect = lambda data, dif, threads: data * 0.95 + + master_dict = { + MasterDictKeys.data_type: DataType.ob, + MasterDictKeys.list_folders: { + "/folder1": {MasterDictKeys.data: np.ones((5, 128, 128))}, + "/folder2": {MasterDictKeys.data: np.ones((10, 256, 256))}, + }, + } + + remove_outliers(master_dict, dif=15.0, num_threads=8) + + assert mock_remove_outliers.call_count == 2 + assert np.allclose(master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data], 0.95) + + +class TestCombineData: + """Test combine_data function.""" + + def test_combine_data_no_corrections(self): + """Test combining data without any corrections.""" + ob_dict = { + MasterDictKeys.data_type: DataType.ob, + MasterDictKeys.list_folders: { + "/ob1": {MasterDictKeys.data: np.ones((10, 100, 100)) * 2.0}, + "/ob2": {MasterDictKeys.data: np.ones((10, 100, 100)) * 3.0}, + }, + } + + norm_dict = {} + combine_data(ob_dict, False, False, norm_dict) + + # Should be median of 2.0 and 3.0 = 2.5 + assert MasterDictKeys.obs_data_combined in norm_dict + assert np.allclose(norm_dict[MasterDictKeys.obs_data_combined], 2.5) + + def test_combine_data_with_proton_charge(self): + """Test combining data with proton charge correction.""" + ob_dict = { + MasterDictKeys.data_type: DataType.ob, + MasterDictKeys.list_folders: { + "/ob1": {MasterDictKeys.data: np.ones((10, 50, 50)) * 100.0, MasterDictKeys.proton_charge: 2.0} + }, + } + + norm_dict = {} + combine_data(ob_dict, True, False, norm_dict) + + # Data should be divided by proton charge + assert np.allclose(norm_dict[MasterDictKeys.obs_data_combined], 50.0) + + def test_combine_data_with_shutter_counts(self): + """Test combining data with shutter count correction.""" + ob_dict = { + MasterDictKeys.data_type: DataType.ob, + MasterDictKeys.list_folders: { + "/ob1": {MasterDictKeys.data: np.ones((2, 50, 50)) * 100.0, MasterDictKeys.list_shutters: [2.0, 4.0]} + }, + } + + norm_dict = {} + combine_data(ob_dict, False, True, norm_dict) + + # First frame divided by 2, second by 4 + expected = np.ones((2, 50, 50)) + expected[0] *= 50.0 + expected[1] *= 25.0 + assert np.allclose(norm_dict[MasterDictKeys.obs_data_combined], expected) + + def test_combine_data_removes_zeros(self): + """Test that zero values are replaced with NaN.""" + ob_dict = { + MasterDictKeys.data_type: DataType.ob, + MasterDictKeys.list_folders: {"/ob1": {MasterDictKeys.data: np.zeros((5, 10, 10))}}, + } + + norm_dict = {} + combine_data(ob_dict, False, False, norm_dict) + + # Zero values should become NaN + assert np.all(np.isnan(norm_dict[MasterDictKeys.obs_data_combined])) + + +class TestCorrectDataForProtonCharge: + """Test correct_data_for_proton_charge function.""" + + def test_correct_with_proton_charge_enabled(self): + """Test correction when enabled.""" + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: { + "/folder1": {MasterDictKeys.data: np.ones((10, 100, 100)) * 100.0, MasterDictKeys.proton_charge: 2.5} + }, + } + + correct_data_for_proton_charge(master_dict, True) + + expected = 100.0 / 2.5 + assert np.allclose(master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data], expected) + + def test_correct_with_proton_charge_disabled(self): + """Test that correction is skipped when disabled.""" + original_data = np.ones((10, 100, 100)) * 100.0 + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: { + "/folder1": {MasterDictKeys.data: original_data.copy(), MasterDictKeys.proton_charge: 2.5} + }, + } + + correct_data_for_proton_charge(master_dict, False) + + # Data should be unchanged + assert np.array_equal(master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data], original_data) + + def test_correct_multiple_folders(self): + """Test correction on multiple folders with different proton charges.""" + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: { + "/folder1": {MasterDictKeys.data: np.ones((5, 50, 50)) * 100.0, MasterDictKeys.proton_charge: 2.0}, + "/folder2": {MasterDictKeys.data: np.ones((5, 50, 50)) * 200.0, MasterDictKeys.proton_charge: 4.0}, + }, + } + + correct_data_for_proton_charge(master_dict, True) + + assert np.allclose(master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data], 50.0) + assert np.allclose(master_dict[MasterDictKeys.list_folders]["/folder2"][MasterDictKeys.data], 50.0) + + +class TestCorrectDataForShutterCounts: + """Test correct_data_for_shutter_counts function.""" + + def test_correct_with_shutter_counts_enabled(self): + """Test correction when enabled.""" + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: { + "/folder1": { + MasterDictKeys.data: np.array( + [np.ones((100, 100)) * 100.0, np.ones((100, 100)) * 200.0, np.ones((100, 100)) * 300.0] + ), + MasterDictKeys.list_shutters: [2.0, 4.0, 6.0], + } + }, + } + + correct_data_for_shutter_counts(master_dict, True) + + data = master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data] + assert np.allclose(data[0], 50.0) + assert np.allclose(data[1], 50.0) + assert np.allclose(data[2], 50.0) + + def test_correct_with_shutter_counts_disabled(self): + """Test that correction is skipped when disabled.""" + original_data = np.ones((3, 100, 100)) * 100.0 + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: { + "/folder1": {MasterDictKeys.data: original_data.copy(), MasterDictKeys.list_shutters: [2.0, 4.0, 6.0]} + }, + } + + correct_data_for_shutter_counts(master_dict, False) + + # Data should be unchanged + assert np.array_equal(master_dict[MasterDictKeys.list_folders]["/folder1"][MasterDictKeys.data], original_data) + + +class TestPerformingNormalization: + """Test performing_normalization function.""" + + def test_normalization_without_background(self): + """Test basic normalization without background correction.""" + sample_dict = {MasterDictKeys.list_folders: {"/sample1": {MasterDictKeys.data: np.ones((10, 100, 100)) * 50.0}}} + + norm_dict = {MasterDictKeys.obs_data_combined: np.ones((10, 100, 100)) * 100.0, MasterDictKeys.sample_data: {}} + + performing_normalization(sample_dict, norm_dict, None) + + # Transmission = Sample / OB = 50 / 100 = 0.5 + assert "/sample1" in norm_dict[MasterDictKeys.sample_data] + assert np.allclose(norm_dict[MasterDictKeys.sample_data]["/sample1"], 0.5) + + def test_normalization_with_background(self): + """Test normalization with background ROI correction.""" + # Create sample data with higher values in background region + sample_data = np.ones((5, 100, 100)) * 50.0 + sample_data[:, :10, :10] = 100.0 # Higher values in background ROI + + sample_dict = {MasterDictKeys.list_folders: {"/sample1": {MasterDictKeys.data: sample_data}}} + + # OB data with different background + ob_data = np.ones((5, 100, 100)) * 100.0 + ob_data[:, :10, :10] = 200.0 # Different values in background ROI + + norm_dict = {MasterDictKeys.obs_data_combined: ob_data, MasterDictKeys.sample_data: {}} + + background_roi = Roi(0, 0, 10, 10) + performing_normalization(sample_dict, norm_dict, background_roi) + + # Check that background correction was applied + result = norm_dict[MasterDictKeys.sample_data]["/sample1"] + assert result.shape == (5, 100, 100) + # Background correction coefficient = median(OB_bg) / median(Sample_bg) = 200 / 100 = 2 + # Transmission = (Sample / OB) * coeff = (50 / 100) * 2 = 1.0 + assert np.allclose(result[:, 50, 50], 1.0, rtol=0.1) + + def test_normalization_handles_division_by_zero(self): + """Test that division by zero is handled gracefully.""" + sample_dict = {MasterDictKeys.list_folders: {"/sample1": {MasterDictKeys.data: np.ones((5, 50, 50)) * 10.0}}} + + # OB with some zero values + ob_data = np.ones((5, 50, 50)) * 100.0 + ob_data[0, 10:20, 10:20] = 0.0 # Zero values in OB + + norm_dict = {MasterDictKeys.obs_data_combined: ob_data, MasterDictKeys.sample_data: {}} + + performing_normalization(sample_dict, norm_dict, None) + + result = norm_dict[MasterDictKeys.sample_data]["/sample1"] + # Check that inf/nan values are replaced with 0 + assert np.all(np.isfinite(result)) + assert np.all(result[0, 10:20, 10:20] == 0.0) + + +class TestGetCountsFromNormalizedData: + """Test get_counts_from_normalized_data function.""" + + def test_get_counts_basic(self): + """Test basic count extraction.""" + # Create normalized data (transmission values) + normalized_data = np.ones((10, 100, 100)) * 0.5 + + counts, uncertainties = get_counts_from_normalized_data(normalized_data) + + assert counts.shape == (10,) + assert uncertainties.shape == (10,) + assert np.allclose(counts, 0.5) # Average transmission + + def test_get_counts_with_varying_transmission(self): + """Test count extraction with varying transmission values.""" + normalized_data = np.zeros((5, 50, 50)) + for i in range(5): + normalized_data[i] = (i + 1) * 0.2 # 0.2, 0.4, 0.6, 0.8, 1.0 + + counts, uncertainties = get_counts_from_normalized_data(normalized_data) + + assert counts.shape == (5,) + assert np.allclose(counts, [0.2, 0.4, 0.6, 0.8, 1.0]) + + def test_get_counts_handles_nan_values(self): + """Test that NaN values are handled properly.""" + normalized_data = np.ones((5, 50, 50)) * 0.7 + normalized_data[0, 10, 10] = np.nan + normalized_data[1, :, :] = np.nan # Entire frame is NaN + + counts, uncertainties = get_counts_from_normalized_data(normalized_data) + + assert counts.shape == (5,) + # nanmean should handle NaN values + assert np.isfinite(counts[0]) # Should still compute mean excluding NaN + assert counts[1] == 0.0 # All NaN should result in 0 + + def test_get_counts_invalid_input(self): + """Test that invalid inputs raise appropriate errors.""" + # Not a numpy array + with pytest.raises(TypeError, match="Normalized data must be a numpy array"): + get_counts_from_normalized_data([1, 2, 3]) + + # Wrong dimensions + with pytest.raises(ValueError, match="Expected 3D array"): + get_counts_from_normalized_data(np.ones((10, 10))) + + @patch("pleiades.processing.normalization_handler.logger") + def test_get_counts_warns_about_outliers(self, mock_logger): + """Test that warnings are logged for outliers.""" + normalized_data = np.ones((5, 50, 50)) * 0.8 + normalized_data[0, 10:20, 10:20] = 3.0 # Outliers > 2.0 + + counts, uncertainties = get_counts_from_normalized_data(normalized_data) + + # Should warn about outliers + assert any("transmission > 2.0" in str(call) for call in mock_logger.warning.call_args_list) + + @patch("pleiades.processing.normalization_handler.logger") + def test_get_counts_warns_about_low_transmission(self, mock_logger): + """Test that warnings are logged for very low transmission values.""" + normalized_data = np.ones((5, 50, 50)) * 0.5 + # Set more than 10% of pixels to very low transmission + normalized_data[:, :25, :25] = 0.005 # Below threshold + + counts, uncertainties = get_counts_from_normalized_data(normalized_data) + + # Should warn about low transmission + assert any("very low transmission" in str(call) for call in mock_logger.warning.call_args_list) + + +class TestIntegrationScenarios: + """Test integration scenarios with multiple functions.""" + + def test_full_normalization_workflow(self): + """Test a complete normalization workflow.""" + # Create sample data + sample_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: { + "/sample": {MasterDictKeys.data: np.ones((10, 100, 100)) * 80.0, MasterDictKeys.proton_charge: 2.0} + }, + } + + # Create OB data + ob_dict = { + MasterDictKeys.data_type: DataType.ob, + MasterDictKeys.list_folders: { + "/ob": {MasterDictKeys.data: np.ones((10, 100, 100)) * 100.0, MasterDictKeys.proton_charge: 1.0} + }, + } + + # Apply corrections + correct_data_for_proton_charge(sample_dict, True) + correct_data_for_proton_charge(ob_dict, True) + + # Combine OB data + norm_dict = {MasterDictKeys.sample_data: {}} + combine_data(ob_dict, True, False, norm_dict) + + # Perform normalization + performing_normalization(sample_dict, norm_dict, None) + + # Get counts + transmission = norm_dict[MasterDictKeys.sample_data]["/sample"] + counts, uncertainties = get_counts_from_normalized_data(transmission) + + # Check results + # Sample: 80/2 = 40, OB: 100/1 = 100, Transmission = 40/100 = 0.4 + assert np.allclose(counts, 0.4) + + @patch("pleiades.processing.normalization_handler.retrieve_list_of_most_dominant_extension_from_folder") + @patch("pleiades.processing.normalization_handler.load") + def test_workflow_with_file_discovery(self, mock_load, mock_retrieve): + """Test workflow including file discovery.""" + # Setup mocks + mock_retrieve.return_value = (["sample.tiff"], ".tiff") + mock_load.return_value = np.ones((5, 50, 50)) * 100.0 + + with tempfile.TemporaryDirectory() as tmpdir: + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: {tmpdir: {MasterDictKeys.list_images: None}}, + } + + # File discovery + update_with_list_of_files(master_dict) + + # Load data + update_with_data(master_dict) + + # Apply rebinning + update_with_rebin(master_dict, binning_factor=2) + + # Verify workflow + assert master_dict[MasterDictKeys.list_folders][tmpdir][MasterDictKeys.ext] == ".tiff" + assert MasterDictKeys.data in master_dict[MasterDictKeys.list_folders][tmpdir] + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_empty_master_dict(self): + """Test functions with empty master dictionaries.""" + empty_dict = {MasterDictKeys.data_type: DataType.sample, MasterDictKeys.list_folders: {}} + + # These should handle empty dicts gracefully + update_with_data(empty_dict) + update_with_rebin(empty_dict, 2) + remove_outliers(empty_dict, 20.0, 4) + correct_data_for_proton_charge(empty_dict, True) + correct_data_for_shutter_counts(empty_dict, True) + + # Verify dict remains empty + assert len(empty_dict[MasterDictKeys.list_folders]) == 0 + + def test_division_by_zero_in_corrections(self): + """Test handling of zero values in corrections.""" + master_dict = { + MasterDictKeys.data_type: DataType.sample, + MasterDictKeys.list_folders: { + "/folder": { + MasterDictKeys.data: np.ones((5, 50, 50)) * 100.0, + MasterDictKeys.proton_charge: 0.0, # Zero proton charge + } + }, + } + + # The function doesn't explicitly handle zero division, it will produce inf/nan + correct_data_for_proton_charge(master_dict, True) + + # Check that the result contains inf values + result = master_dict[MasterDictKeys.list_folders]["/folder"][MasterDictKeys.data] + assert np.all(np.isinf(result)) + + def test_mismatched_data_shapes(self): + """Test handling of mismatched data shapes in normalization.""" + sample_dict = {MasterDictKeys.list_folders: {"/sample": {MasterDictKeys.data: np.ones((10, 100, 100))}}} + + # OB with different number of time channels + norm_dict = { + MasterDictKeys.obs_data_combined: np.ones((5, 100, 100)), # Different time channels + MasterDictKeys.sample_data: {}, + } + + # This should handle the mismatch (zip will stop at shortest) + performing_normalization(sample_dict, norm_dict, None) + + result = norm_dict[MasterDictKeys.sample_data]["/sample"] + # Output array matches sample shape, but only first 5 frames are normalized + assert result.shape[0] == 10 # Matches sample size + # First 5 frames should be normalized (1.0 since sample == OB) + assert np.allclose(result[:5], 1.0) + # Remaining frames are uninitialized (empty_like leaves garbage values) diff --git a/tests/unit/pleiades/sammy/data/test_options.py b/tests/unit/pleiades/sammy/data/test_options.py index 5904d551..41b0c97b 100644 --- a/tests/unit/pleiades/sammy/data/test_options.py +++ b/tests/unit/pleiades/sammy/data/test_options.py @@ -1,40 +1,552 @@ +"""Comprehensive unit tests for sammy/data/options.py module.""" + +import tempfile from pathlib import Path +from unittest.mock import patch +import matplotlib.pyplot as plt +import pandas as pd import pytest -from pleiades.sammy.data.options import DataTypeOptions, SammyData +from pleiades.sammy.data.options import HISTOGRAM_BIN_RANGE, RESIDUAL_YLIM, DataTypeOptions, SammyData from pleiades.utils.units import CrossSectionUnitOptions, EnergyUnitOptions -def test_sammy_data_defaults(): - """Test the default values of SammyData.""" - params = SammyData() +class TestDataTypeOptions: + """Test DataTypeOptions enum.""" + + def test_enum_values(self): + """Test that all expected enum values exist.""" + assert DataTypeOptions.TRANSMISSION == "TRANSMISSION" + assert DataTypeOptions.TOTAL_CROSS_SECTION == "TOTAL CROSS SECTION" + assert DataTypeOptions.SCATTERING == "SCATTERING" + assert DataTypeOptions.ELASTIC == "ELASTIC" + assert DataTypeOptions.DIFFERENTIAL_ELASTIC == "DIFFERENTIAL ELASTIC" + assert DataTypeOptions.DIFFERENTIAL_REACTION == "DIFFERENTIAL REACTION" + assert DataTypeOptions.REACTION == "REACTION" + assert DataTypeOptions.INELASTIC_SCATTERING == "INELASTIC SCATTERING" + assert DataTypeOptions.FISSION == "FISSION" + assert DataTypeOptions.CAPTURE == "CAPTURE" + assert DataTypeOptions.SELF_INDICATION == "SELF INDICATION" + assert DataTypeOptions.INTEGRAL == "INTEGRAL" + assert DataTypeOptions.COMBINATION == "COMBINATION" + + def test_enum_is_string(self): + """Test that enum values are strings.""" + assert isinstance(DataTypeOptions.TRANSMISSION.value, str) + assert DataTypeOptions.TRANSMISSION == "TRANSMISSION" + + +class TestSammyDataInitialization: + """Test SammyData initialization.""" + + def test_sammy_data_defaults(self): + """Test the default values of SammyData.""" + params = SammyData() + + assert params.data_file is None + assert params.data_type == DataTypeOptions.TRANSMISSION + assert params.data_format == "LST" + assert params.energy_units == EnergyUnitOptions.eV + assert params.cross_section_units == CrossSectionUnitOptions.barn + assert params.data is None + + def test_sammy_data_custom_values(self): + """Test custom values of SammyData.""" + params = SammyData( + data_type=DataTypeOptions.CAPTURE, + data_format="DAT", + energy_units=EnergyUnitOptions.keV, + cross_section_units=CrossSectionUnitOptions.millibarn, + ) + + assert params.data_type == DataTypeOptions.CAPTURE + assert params.data_format == "DAT" + assert params.energy_units == EnergyUnitOptions.keV + assert params.cross_section_units == CrossSectionUnitOptions.millibarn + + def test_invalid_data_type(self): + """Test invalid data type.""" + with pytest.raises(ValueError): + SammyData(data_file=Path("invalid.dat"), data_type="INVALID_TYPE") + + @patch("pleiades.sammy.data.options.SammyData.load") + def test_init_with_file_loads_automatically(self, mock_load): + """Test that providing a file path triggers automatic loading.""" + test_path = Path("/test/file.lst") + + sammy_data = SammyData(data_file=test_path) + + assert sammy_data.data_file == test_path + mock_load.assert_called_once() + + def test_column_names_defined(self): + """Test that _all_column_names is properly defined.""" + sammy_data = SammyData() + + assert len(sammy_data._all_column_names) == 13 + assert sammy_data._all_column_names[0] == "Energy" + assert "Experimental transmission (dimensionless)" in sammy_data._all_column_names + assert "Final theoretical cross section as evaluated by SAMMY (barns)" in sammy_data._all_column_names + + +class TestSammyDataLoad: + """Test SammyData load method.""" + + def test_load_valid_csv(self): + """Test loading a valid CSV file.""" + csv_content = """1.0 0.5 0.01 0.49 0.51 0.95 0.02 0.94 0.96 +2.0 0.6 0.01 0.59 0.61 0.85 0.02 0.84 0.86 +3.0 0.7 0.01 0.69 0.71 0.75 0.02 0.74 0.76""" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".lst", delete=False) as tmp: + tmp.write(csv_content) + tmp_path = Path(tmp.name) + + try: + sammy_data = SammyData(data_file=tmp_path, data_type=DataTypeOptions.TRANSMISSION) + + # Check data was loaded + assert sammy_data.data is not None + assert len(sammy_data.data) == 3 + assert sammy_data.data.shape[1] == 9 + + # Check column names were assigned + assert "Energy" in sammy_data.data.columns + assert sammy_data.data["Energy"].tolist() == [1.0, 2.0, 3.0] + + finally: + tmp_path.unlink() + + def test_load_with_comments(self): + """Test loading CSV with comment lines.""" + csv_content = """# This is a comment +# Another comment +1.0 0.5 0.01 0.49 0.51 0.95 0.02 0.94 0.96 +2.0 0.6 0.01 0.59 0.61 0.85 0.02 0.84 0.86""" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".lst", delete=False) as tmp: + tmp.write(csv_content) + tmp_path = Path(tmp.name) + + try: + sammy_data = SammyData(data_file=tmp_path, data_type=DataTypeOptions.TRANSMISSION) + + # Comments should be ignored + assert len(sammy_data.data) == 2 + + finally: + tmp_path.unlink() + + def test_load_fewer_columns_raises_validation_error(self): + """Test loading CSV with fewer columns raises validation error for transmission data.""" + csv_content = """1.0 0.5 0.01 +2.0 0.6 0.01 +3.0 0.7 0.01""" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".lst", delete=False) as tmp: + tmp.write(csv_content) + tmp_path = Path(tmp.name) + + try: + sammy_data = SammyData() # Default is TRANSMISSION type + sammy_data.data_file = tmp_path + + # Should raise validation error for missing transmission columns + with pytest.raises(ValueError, match="Missing required transmission column"): + sammy_data.load() + + finally: + tmp_path.unlink() + + def test_load_nonexistent_file(self): + """Test loading a non-existent file.""" + sammy_data = SammyData() + sammy_data.data_file = Path("/nonexistent/file.lst") + + with pytest.raises(FileNotFoundError): + sammy_data.load() + + +class TestValidateColumns: + """Test validate_columns method.""" + + def test_validate_transmission_data_valid(self): + """Test validation of valid transmission data.""" + data = pd.DataFrame( + { + "Energy": [1.0, 2.0], + "Experimental transmission (dimensionless)": [0.9, 0.8], + "Absolute uncertainty in experimental transmission": [0.01, 0.01], + "Zeroth-order theoretical transmission as evaluated by SAMMY (dimensionless)": [0.89, 0.79], + "Final theoretical transmission as evaluated by SAMMY (dimensionless)": [0.91, 0.81], + } + ) + + sammy_data = SammyData(data_type=DataTypeOptions.TRANSMISSION) + sammy_data.data = data + + # Should not raise + sammy_data.validate_columns() + + def test_validate_transmission_data_missing_column(self): + """Test validation fails for transmission data missing required column.""" + data = pd.DataFrame( + { + "Energy": [1.0, 2.0], + "Experimental transmission (dimensionless)": [0.9, 0.8], + "Absolute uncertainty in experimental transmission": [0.01, 0.01], + "Zeroth-order theoretical transmission as evaluated by SAMMY (dimensionless)": [0.89, 0.79], + } + ) + + sammy_data = SammyData(data_type=DataTypeOptions.TRANSMISSION) + sammy_data.data = data + + with pytest.raises(ValueError, match="Missing required transmission column"): + sammy_data.validate_columns() + + def test_validate_cross_section_data_valid(self): + """Test validation of valid cross-section data.""" + data = pd.DataFrame( + { + "Energy": [1.0, 2.0], + "Experimental cross section (barns)": [10.0, 20.0], + "Absolute uncertainty in experimental cross section": [0.5, 0.6], + "Zeroth-order theoretical cross section as evaluated by SAMMY (barns)": [9.5, 19.5], + "Final theoretical cross section as evaluated by SAMMY (barns)": [10.1, 20.1], + } + ) + + sammy_data = SammyData(data_type=DataTypeOptions.TOTAL_CROSS_SECTION) + sammy_data.data = data + + # Should not raise + sammy_data.validate_columns() + + def test_validate_cross_section_with_transmission_columns_fails(self): + """Test validation fails if cross-section data has transmission columns.""" + data = pd.DataFrame( + { + "Energy": [1.0, 2.0], + "Experimental cross section (barns)": [10.0, 20.0], + "Absolute uncertainty in experimental cross section": [0.5, 0.6], + "Zeroth-order theoretical cross section as evaluated by SAMMY (barns)": [9.5, 19.5], + "Final theoretical cross section as evaluated by SAMMY (barns)": [10.1, 20.1], + "Experimental transmission (dimensionless)": [0.9, 0.8], # Should not be here + } + ) + + sammy_data = SammyData(data_type=DataTypeOptions.TOTAL_CROSS_SECTION) + sammy_data.data = data + + with pytest.raises(ValueError, match="Unexpected transmission column"): + sammy_data.validate_columns() + + def test_validate_scattering_data(self): + """Test validation for SCATTERING data type.""" + data = pd.DataFrame( + { + "Energy": [1.0, 2.0], + "Experimental cross section (barns)": [10.0, 20.0], + "Absolute uncertainty in experimental cross section": [0.5, 0.6], + "Zeroth-order theoretical cross section as evaluated by SAMMY (barns)": [9.5, 19.5], + "Final theoretical cross section as evaluated by SAMMY (barns)": [10.1, 20.1], + } + ) + + sammy_data = SammyData(data_type=DataTypeOptions.SCATTERING) + sammy_data.data = data + + # Should not raise + sammy_data.validate_columns() + + +class TestPlotMethods: + """Test plotting methods.""" + + @patch("matplotlib.pyplot.show") + def test_plot_transmission_basic(self, mock_show): + """Test basic transmission plotting.""" + data = pd.DataFrame( + { + "Energy": [1.0, 2.0, 3.0], + "Experimental transmission (dimensionless)": [0.9, 0.8, 0.7], + "Absolute uncertainty in experimental transmission": [0.01, 0.01, 0.01], + "Zeroth-order theoretical transmission as evaluated by SAMMY (dimensionless)": [0.89, 0.79, 0.69], + "Final theoretical transmission as evaluated by SAMMY (dimensionless)": [0.91, 0.81, 0.71], + } + ) + + sammy_data = SammyData() + sammy_data.data = data + + result = sammy_data.plot_transmission(show=True) + + assert result is None + mock_show.assert_called_once() + plt.close("all") + + def test_plot_transmission_returns_figure_when_show_false(self): + """Test that plot returns figure object when show=False.""" + data = pd.DataFrame( + { + "Energy": [1.0, 2.0, 3.0], + "Experimental transmission (dimensionless)": [0.9, 0.8, 0.7], + "Final theoretical transmission as evaluated by SAMMY (dimensionless)": [0.91, 0.81, 0.71], + } + ) + + sammy_data = SammyData() + sammy_data.data = data + + fig = sammy_data.plot_transmission(show=False) + + assert fig is not None + assert isinstance(fig, plt.Figure) + plt.close(fig) + + @patch("matplotlib.pyplot.show") + def test_plot_transmission_with_residuals(self, mock_show): + """Test transmission plotting with residuals.""" + data = pd.DataFrame( + { + "Energy": [1.0, 2.0, 3.0], + "Experimental transmission (dimensionless)": [0.9, 0.8, 0.7], + "Absolute uncertainty in experimental transmission": [0.01, 0.01, 0.01], + "Zeroth-order theoretical transmission as evaluated by SAMMY (dimensionless)": [0.89, 0.79, 0.69], + "Final theoretical transmission as evaluated by SAMMY (dimensionless)": [0.91, 0.81, 0.71], + } + ) + + sammy_data = SammyData() + sammy_data.data = data + + sammy_data.plot_transmission(show_diff=True, show=True) + + mock_show.assert_called_once() + plt.close("all") + + def test_plot_transmission_with_custom_parameters(self): + """Test transmission plotting with custom parameters.""" + data = pd.DataFrame( + { + "Energy": [1.0, 10.0, 100.0], + "Experimental transmission (dimensionless)": [0.9, 0.8, 0.7], + "Final theoretical transmission as evaluated by SAMMY (dimensionless)": [0.91, 0.81, 0.71], + } + ) + + sammy_data = SammyData() + sammy_data.data = data + + fig = sammy_data.plot_transmission( + show_diff=False, + figsize=(10, 8), + title="Test Plot", + xscale="log", + yscale="linear", + data_color="blue", + final_color="red", + show=False, + ) + + assert fig is not None + assert fig.get_size_inches()[0] == 10 + assert fig.get_size_inches()[1] == 8 + plt.close(fig) + + def test_plot_transmission_no_data_raises(self): + """Test that plotting without data raises ValueError.""" + sammy_data = SammyData() + + with pytest.raises(ValueError, match="No data loaded to plot"): + sammy_data.plot_transmission() + + @patch("matplotlib.pyplot.show") + def test_plot_cross_section_basic(self, mock_show): + """Test basic cross-section plotting.""" + data = pd.DataFrame( + { + "Energy": [1.0, 2.0, 3.0], + "Experimental cross section (barns)": [10.0, 20.0, 30.0], + "Final theoretical cross section as evaluated by SAMMY (barns)": [10.1, 20.1, 30.1], + } + ) + + sammy_data = SammyData() + sammy_data.data = data + + sammy_data.plot_cross_section() + + mock_show.assert_called_once() + plt.close("all") + + def test_plot_cross_section_no_data_raises(self): + """Test that plotting cross-section without data raises ValueError.""" + sammy_data = SammyData() + + with pytest.raises(ValueError, match="No data loaded to plot"): + sammy_data.plot_cross_section() + + +class TestProperties: + """Test property accessors.""" + + def test_energy_property(self): + """Test energy property accessor.""" + data = pd.DataFrame({"Energy": [1.0, 2.0, 3.0], "Other": [4.0, 5.0, 6.0]}) + + sammy_data = SammyData() + sammy_data.data = data + + energy = sammy_data.energy + assert energy.tolist() == [1.0, 2.0, 3.0] + + def test_experimental_cross_section_property(self): + """Test experimental_cross_section property.""" + data = pd.DataFrame({"Energy": [1.0, 2.0], "Experimental cross section (barns)": [10.0, 20.0]}) + + sammy_data = SammyData() + sammy_data.data = data + + cross_section = sammy_data.experimental_cross_section + assert cross_section.tolist() == [10.0, 20.0] + + def test_theoretical_cross_section_property(self): + """Test theoretical_cross_section property.""" + data = pd.DataFrame( + {"Energy": [1.0, 2.0], "Final theoretical cross section as evaluated by SAMMY (barns)": [10.1, 20.1]} + ) + + sammy_data = SammyData() + sammy_data.data = data + + cross_section = sammy_data.theoretical_cross_section + assert cross_section.tolist() == [10.1, 20.1] + + def test_experimental_transmission_property(self): + """Test experimental_transmission property.""" + data = pd.DataFrame({"Energy": [1.0, 2.0], "Experimental transmission (dimensionless)": [0.9, 0.8]}) + + sammy_data = SammyData() + sammy_data.data = data + + transmission = sammy_data.experimental_transmission + assert transmission.tolist() == [0.9, 0.8] + + def test_theoretical_transmission_property(self): + """Test theoretical_transmission property.""" + data = pd.DataFrame( + {"Energy": [1.0, 2.0], "Final theoretical transmission as evaluated by SAMMY (dimensionless)": [0.91, 0.81]} + ) + + sammy_data = SammyData() + sammy_data.data = data + + transmission = sammy_data.theoretical_transmission + assert transmission.tolist() == [0.91, 0.81] + + def test_property_returns_none_for_missing_column(self): + """Test that properties return None for missing columns.""" + data = pd.DataFrame({"Energy": [1.0, 2.0]}) + + sammy_data = SammyData() + sammy_data.data = data + + assert sammy_data.experimental_cross_section is None + assert sammy_data.theoretical_transmission is None + + +class TestIntegrationScenarios: + """Test complete workflows.""" + + def test_full_workflow_transmission_data(self): + """Test complete workflow with transmission data.""" + # Column order follows _all_column_names: + # Energy, Exp_Cross, Unc_Cross, Zero_Cross, Final_Cross, Exp_Trans, Unc_Trans, Zero_Trans, Final_Trans + csv_content = """# Column order follows SAMMY standard +1.0 0.0 0.0 0.0 0.0 0.95 0.01 0.94 0.96 +2.0 0.0 0.0 0.0 0.0 0.85 0.02 0.84 0.86 +3.0 0.0 0.0 0.0 0.0 0.75 0.02 0.74 0.76 +4.0 0.0 0.0 0.0 0.0 0.65 0.03 0.64 0.66""" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".lst", delete=False) as tmp: + tmp.write(csv_content) + tmp_path = Path(tmp.name) + + try: + # Load and validate + sammy_data = SammyData(data_file=tmp_path, data_type=DataTypeOptions.TRANSMISSION) + + # Check data loaded correctly + assert len(sammy_data.data) == 4 + assert sammy_data.energy.tolist() == [1.0, 2.0, 3.0, 4.0] + assert sammy_data.experimental_transmission.tolist() == [0.95, 0.85, 0.75, 0.65] + + # Test plotting doesn't crash + fig = sammy_data.plot_transmission(show=False, show_diff=True) + assert fig is not None + plt.close(fig) + + finally: + tmp_path.unlink() + + def test_constants_defined(self): + """Test that module constants are properly defined.""" + assert RESIDUAL_YLIM == (-1, 1) + assert HISTOGRAM_BIN_RANGE == (-1, 1, 0.01) + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_empty_dataframe(self): + """Test handling of empty DataFrame.""" + sammy_data = SammyData() + sammy_data.data = pd.DataFrame() + + # Properties should handle empty DataFrame + assert sammy_data.energy is None + assert sammy_data.experimental_transmission is None + + def test_validation_with_none_data(self): + """Test validation with None data.""" + sammy_data = SammyData() + + # Should handle None data gracefully + with pytest.raises(AttributeError): + sammy_data.validate_columns() - assert params.data_file is None - assert params.data_type == DataTypeOptions.TRANSMISSION - assert params.energy_units == EnergyUnitOptions.eV - assert params.cross_section_units == CrossSectionUnitOptions.barn - assert params.data is None + def test_plot_with_minimal_data(self): + """Test plotting with minimal required columns.""" + data = pd.DataFrame( + { + "Energy": [1.0], + "Experimental transmission (dimensionless)": [0.9], + "Final theoretical transmission as evaluated by SAMMY (dimensionless)": [0.91], + } + ) + sammy_data = SammyData() + sammy_data.data = data -def test_sammy_data_custom_values(): - """Test custom values of SammyData.""" - params = SammyData( - data_type=DataTypeOptions.CAPTURE, - energy_units=EnergyUnitOptions.keV, - cross_section_units=CrossSectionUnitOptions.millibarn, - ) + # Should not crash with single data point + fig = sammy_data.plot_transmission(show=False) + assert fig is not None + plt.close(fig) - assert params.data_type == DataTypeOptions.CAPTURE - assert params.energy_units == EnergyUnitOptions.keV - assert params.cross_section_units == CrossSectionUnitOptions.millibarn + @patch("pandas.read_csv") + def test_load_with_io_error(self, mock_read_csv): + """Test handling of I/O errors during load.""" + mock_read_csv.side_effect = IOError("Cannot read file") + sammy_data = SammyData() + sammy_data.data_file = Path("/test/file.lst") -def test_invalid_data_type(): - """Test invalid data type.""" - with pytest.raises(ValueError): - SammyData(data_file=Path("invalid.dat"), data_type="INVALID_TYPE") + with pytest.raises(IOError, match="Cannot read file"): + sammy_data.load() if __name__ == "__main__": - pytest.main() + pytest.main(["-v", __file__]) diff --git a/tests/unit/pleiades/sammy/io/card_formats/test_par07_radii.py b/tests/unit/pleiades/sammy/io/card_formats/test_par07_radii.py new file mode 100644 index 00000000..7e9b7031 --- /dev/null +++ b/tests/unit/pleiades/sammy/io/card_formats/test_par07_radii.py @@ -0,0 +1,277 @@ +"""Comprehensive unit tests for sammy/io/card_formats/par07_radii.py module.""" + +from typing import List +from unittest.mock import patch + +import pytest + +from pleiades.sammy.fitting.config import FitConfig +from pleiades.sammy.io.card_formats.par07_radii import Card07 + + +class TestCard07ClassMethods: + """Test Card07 class methods.""" + + def test_from_lines_empty_list(self): + """Test from_lines with empty list raises ValueError.""" + with pytest.raises(ValueError, match="No lines provided"): + Card07.from_lines([]) + + def test_from_lines_with_invalid_header(self): + """Test from_lines with invalid header line.""" + # Since is_header_line is not implemented, this will raise AttributeError + lines = ["Invalid header line", "data line 1", "data line 2"] + + with pytest.raises(AttributeError): + # This should fail because is_header_line doesn't exist + Card07.from_lines(lines) + + def test_from_lines_with_invalid_fit_config(self): + """Test from_lines with invalid fit_config type.""" + lines = ["Some header line"] + invalid_config = "not a FitConfig object" + + # Since is_header_line doesn't exist, we'll get AttributeError first + with pytest.raises(AttributeError): + Card07.from_lines(lines, fit_config=invalid_config) + + @patch.object(Card07, "is_header_line", return_value=True, create=True) + def test_from_lines_with_valid_header_but_unsupported_format(self, mock_is_header): + """Test that default format raises NotImplementedError.""" + lines = ["Valid header line according to mock"] + + # Should raise ValueError about unsupported format + with pytest.raises(ValueError, match="Default format for Card 7 is not supported"): + Card07.from_lines(lines) + + mock_is_header.assert_called_once_with(lines[0]) + + @patch.object(Card07, "is_header_line", return_value=False, create=True) + def test_from_lines_with_header_returning_false(self, mock_is_header): + """Test from_lines when is_header_line returns False.""" + lines = ["Some header line"] + + with pytest.raises(ValueError, match="Invalid header line"): + Card07.from_lines(lines) + + mock_is_header.assert_called_once_with(lines[0]) + + @patch.object(Card07, "is_header_line", return_value=True, create=True) + def test_from_lines_with_none_fit_config(self, mock_is_header): + """Test from_lines with None fit_config creates default FitConfig.""" + lines = ["Valid header"] + + # Should still raise unsupported format error + with pytest.raises(ValueError, match="Default format for Card 7 is not supported"): + Card07.from_lines(lines, fit_config=None) + + @patch.object(Card07, "is_header_line", return_value=True, create=True) + def test_from_lines_with_valid_fit_config(self, mock_is_header): + """Test from_lines with valid FitConfig object.""" + lines = ["Valid header"] + fit_config = FitConfig() + + # Should still raise unsupported format error + with pytest.raises(ValueError, match="Default format for Card 7 is not supported"): + Card07.from_lines(lines, fit_config=fit_config) + + @patch.object(Card07, "is_header_line", return_value=True, create=True) + def test_from_lines_with_non_fit_config_object(self, mock_is_header): + """Test that non-FitConfig objects are rejected.""" + lines = ["Valid header"] + + class NotFitConfig: + pass + + invalid_config = NotFitConfig() + + with pytest.raises(ValueError, match="fit_config must be an instance of FitConfig"): + Card07.from_lines(lines, fit_config=invalid_config) + + +class TestCard07Initialization: + """Test Card07 initialization and BaseModel features.""" + + def test_card07_is_basemodel(self): + """Test that Card07 inherits from BaseModel.""" + from pydantic import BaseModel + + assert issubclass(Card07, BaseModel) + + def test_card07_can_be_instantiated(self): + """Test that Card07 can be instantiated.""" + card = Card07() + assert isinstance(card, Card07) + + def test_card07_has_from_lines_method(self): + """Test that Card07 has from_lines class method.""" + assert hasattr(Card07, "from_lines") + assert callable(Card07.from_lines) + + def test_card07_from_lines_is_classmethod(self): + """Test that from_lines is a class method.""" + import inspect + + # Get the actual function from the classmethod descriptor + assert isinstance(inspect.getattr_static(Card07, "from_lines"), classmethod) + + +class TestCard07MissingMethods: + """Test for missing methods that are called but not implemented.""" + + def test_is_header_line_not_implemented(self): + """Test that is_header_line method is not implemented.""" + # This should not exist as a method + assert not hasattr(Card07, "is_header_line") or not callable(getattr(Card07, "is_header_line", None)) + + def test_calling_nonexistent_is_header_line_raises_error(self): + """Test that calling non-existent is_header_line raises AttributeError.""" + with pytest.raises(AttributeError): + Card07.is_header_line("some line") + + +class TestCard07EdgeCases: + """Test edge cases and error conditions.""" + + @patch.object(Card07, "is_header_line", return_value=True, create=True) + def test_from_lines_with_empty_string_in_list(self, mock_is_header): + """Test from_lines with empty string as first line.""" + lines = ["", "data line"] + mock_is_header.return_value = False + + with pytest.raises(ValueError, match="Invalid header line"): + Card07.from_lines(lines) + + @patch.object(Card07, "is_header_line", return_value=True, create=True) + def test_from_lines_with_whitespace_only_header(self, mock_is_header): + """Test from_lines with whitespace-only header.""" + lines = [" \t\n ", "data line"] + mock_is_header.return_value = False + + with pytest.raises(ValueError, match="Invalid header line"): + Card07.from_lines(lines) + + def test_from_lines_with_none_lines(self): + """Test from_lines with None instead of list.""" + # None is falsy, so it will be caught by the "if not lines" check + with pytest.raises(ValueError, match="No lines provided"): + Card07.from_lines(None) + + @patch.object(Card07, "is_header_line", return_value=True, create=True) + def test_from_lines_with_single_line(self, mock_is_header): + """Test from_lines with single line list.""" + lines = ["Single line header"] + + with pytest.raises(ValueError, match="Default format for Card 7 is not supported"): + Card07.from_lines(lines) + + @patch.object(Card07, "is_header_line", create=True) + def test_from_lines_exception_in_is_header_line(self, mock_is_header): + """Test from_lines when is_header_line raises an exception.""" + lines = ["Header line"] + mock_is_header.side_effect = RuntimeError("Unexpected error in is_header_line") + + with pytest.raises(RuntimeError, match="Unexpected error in is_header_line"): + Card07.from_lines(lines) + + +class TestCard07Integration: + """Integration tests for Card07.""" + + def test_typical_usage_pattern_fails_correctly(self): + """Test that typical usage pattern fails with expected error.""" + # This represents how the code might be used + lines = [ + "Card 7 header line", + "Some radius data", + "More radius data", + "", # Blank terminator + ] + + # Should fail because is_header_line doesn't exist + with pytest.raises(AttributeError): + result = Card07.from_lines(lines) + + @patch.object(Card07, "is_header_line", return_value=True, create=True) + def test_with_mocked_header_validation(self, mock_is_header): + """Test with mocked header validation to reach unsupported format error.""" + lines = [ + "Card 7 Radii Parameters", + "1.0 2.0 3.0", + "4.0 5.0 6.0", + ] + + config = FitConfig() + + with pytest.raises(ValueError, match="Default format for Card 7 is not supported"): + Card07.from_lines(lines, fit_config=config) + + assert mock_is_header.called + + @patch.object(Card07, "is_header_line", return_value=True, create=True) + @patch("pleiades.sammy.io.card_formats.par07_radii.logger") + def test_logging_on_errors(self, mock_logger, mock_is_header): + """Test that errors are logged properly.""" + lines = ["Header"] + + with pytest.raises(ValueError, match="Default format for Card 7 is not supported"): + Card07.from_lines(lines) + + # Check that error was logged + mock_logger.error.assert_called() + error_calls = mock_logger.error.call_args_list + assert len(error_calls) > 0 + # The last error should be about unsupported format + assert "Default format for Card 7 is not supported" in str(error_calls[-1]) + + @patch("pleiades.sammy.io.card_formats.par07_radii.logger") + def test_logging_on_empty_lines(self, mock_logger): + """Test that empty lines error is logged.""" + with pytest.raises(ValueError, match="No lines provided"): + Card07.from_lines([]) + + mock_logger.error.assert_called_with("No lines provided") + + +class TestCard07TypeHints: + """Test type hints and annotations.""" + + def test_from_lines_type_hints(self): + """Test that from_lines has proper type hints.""" + import inspect + + sig = inspect.signature(Card07.from_lines) + + # Check parameter types + assert sig.parameters["lines"].annotation == List[str] + assert sig.parameters["fit_config"].annotation == FitConfig or sig.parameters["fit_config"].default is None + + # Check return type (should be None according to the implementation) + assert sig.return_annotation is None or sig.return_annotation is type(None) + + +class TestCard07Properties: + """Test Card07 properties and attributes.""" + + def test_card07_has_no_fields_defined(self): + """Test that Card07 has no pydantic fields defined.""" + card = Card07() + # BaseModel with no fields should have empty dict + assert card.model_dump() == {} + + def test_card07_class_docstring(self): + """Test that Card07 has a proper docstring.""" + assert Card07.__doc__ is not None + assert "Card 7" in Card07.__doc__ + assert "radii" in Card07.__doc__.lower() + + def test_from_lines_docstring(self): + """Test that from_lines has a proper docstring.""" + assert Card07.from_lines.__doc__ is not None + assert "Parse" in Card07.from_lines.__doc__ + assert "Args:" in Card07.from_lines.__doc__ + assert "Raises:" in Card07.from_lines.__doc__ + + +if __name__ == "__main__": + pytest.main(["-v", __file__]) diff --git a/tests/unit/pleiades/sammy/io/test_lpt_manager.py b/tests/unit/pleiades/sammy/io/test_lpt_manager.py new file mode 100644 index 00000000..3e197b4a --- /dev/null +++ b/tests/unit/pleiades/sammy/io/test_lpt_manager.py @@ -0,0 +1,652 @@ +"""Comprehensive unit tests for sammy/io/lpt_manager.py module.""" + +from pathlib import Path +from unittest.mock import MagicMock, mock_open, patch + +import pytest + +from pleiades.sammy.io.lpt_manager import ( + LptManager, + parse_value_and_varied, + split_lpt_values, +) +from pleiades.sammy.results.models import FitResults, RunResults +from pleiades.utils.helper import VaryFlag + + +class TestHelperFunctions: + """Test helper functions.""" + + def test_parse_value_and_varied_without_parenthesis(self): + """Test parsing value without parenthesis (not varied).""" + result = parse_value_and_varied("1.2345E+02") + assert result == (123.45, False) + + def test_parse_value_and_varied_with_parenthesis(self): + """Test parsing value with parenthesis (varied).""" + result = parse_value_and_varied("1.2345E+02( 3)") + assert result == (123.45, True) + + def test_parse_value_and_varied_negative_value(self): + """Test parsing negative value.""" + result = parse_value_and_varied("-5.678E-03") + assert result == (-0.005678, False) + + def test_parse_value_and_varied_with_spaces(self): + """Test parsing value with spaces and parenthesis.""" + result = parse_value_and_varied("2.9660E+02 ( 4)") + assert result == (296.60, True) + + def test_parse_value_and_varied_invalid_format(self): + """Test parsing invalid format raises ValueError.""" + with pytest.raises(ValueError, match="Could not parse value"): + parse_value_and_varied("not_a_number") + + def test_split_lpt_values_single_value(self): + """Test splitting line with single value.""" + result = split_lpt_values("1.2345E+02") + assert result == ["1.2345E+02"] + + def test_split_lpt_values_multiple_values(self): + """Test splitting line with multiple values.""" + result = split_lpt_values("2.9660E+02( 4) 1.1592E-01( 5)") + assert result == ["2.9660E+02( 4)", "1.1592E-01( 5)"] + + def test_split_lpt_values_mixed_formats(self): + """Test splitting line with mixed formats.""" + result = split_lpt_values("3.0000E+02 4.5678E-01( 2) -1.234E+00") + assert result == ["3.0000E+02", "4.5678E-01( 2)", "-1.234E+00"] + + def test_split_lpt_values_empty_string(self): + """Test splitting empty string returns empty list.""" + result = split_lpt_values("") + assert result == [] + + +class TestLptManagerInit: + """Test LptManager initialization.""" + + def test_init_empty(self): + """Test initialization without parameters.""" + manager = LptManager() + assert manager.run_results is not None + assert isinstance(manager.run_results, RunResults) + + def test_init_with_run_results(self): + """Test initialization with RunResults.""" + run_results = RunResults() + manager = LptManager(run_results=run_results) + assert manager.run_results is run_results + + @patch.object(LptManager, "process_lpt_file") + def test_init_with_file_path(self, mock_process): + """Test initialization with file path triggers processing.""" + manager = LptManager(file_path="test.lpt") + mock_process.assert_called_once_with("test.lpt", manager.run_results) + + +class TestExtractIsotopeInfo: + """Test extract_isotope_info method.""" + + def test_extract_isotope_info_success(self): + """Test successful extraction of isotope information.""" + manager = LptManager() + nuclear_data = MagicMock() + nuclear_data.isotopes = [] + + lines = [ + "Some header", + " Isotopic abundance and mass for each nuclide", + " Nuclide Abundance Mass Spin groups", + " 1 1.000 ( 3) 27.9769 1 2 3 4 5", + " 2 4.6700E-02( 4) 28.9765 8 9 10", + "", + "Next section", + ] + + result = manager.extract_isotope_info(lines, nuclear_data) + + assert result is True + assert len(nuclear_data.isotopes) == 2 + + # Check first isotope + isotope1 = nuclear_data.isotopes[0] + assert isotope1.abundance == 1.000 + assert isotope1.vary_abundance == VaryFlag.YES # Has parenthesis + assert len(isotope1.spin_groups) == 5 + + # Check second isotope + isotope2 = nuclear_data.isotopes[1] + assert isotope2.abundance == 0.04670 + assert isotope2.vary_abundance == VaryFlag.YES + assert len(isotope2.spin_groups) == 3 + + def test_extract_isotope_info_no_data(self): + """Test extraction when no isotope data present.""" + manager = LptManager() + nuclear_data = MagicMock() + nuclear_data.isotopes = [] + + lines = ["No isotope information here", "Just random text"] + + result = manager.extract_isotope_info(lines, nuclear_data) + + assert result is False + assert len(nuclear_data.isotopes) == 0 + + def test_extract_isotope_info_without_parenthesis(self): + """Test extraction when abundance has no parenthesis (not varied).""" + manager = LptManager() + nuclear_data = MagicMock() + nuclear_data.isotopes = [] + + lines = [ + " Isotopic abundance and mass for each nuclide", + " Nuclide Abundance Mass Spin groups", + " 1 0.9223 27.9769 1 2", + ] + + result = manager.extract_isotope_info(lines, nuclear_data) + + assert result is True + assert len(nuclear_data.isotopes) == 1 + isotope = nuclear_data.isotopes[0] + assert isotope.abundance == 0.9223 + assert isotope.vary_abundance == VaryFlag.NO # No parenthesis + + +class TestExtractRadiusInfo: + """Test extract_radius_info method.""" + + def test_extract_radius_info_success(self): + """Test successful extraction of radius information.""" + manager = LptManager() + + # Create mock isotopes with spin groups + isotope1 = MagicMock() + isotope1.spin_groups = [ + MagicMock(spin_group_number=1), + MagicMock(spin_group_number=4), + MagicMock(spin_group_number=5), + ] + isotope1.radius_parameters = None + + isotope2 = MagicMock() + isotope2.spin_groups = [ + MagicMock(spin_group_number=2), + MagicMock(spin_group_number=3), + ] + isotope2.radius_parameters = None + + nuclear_data = MagicMock() + nuclear_data.isotopes = [isotope1, isotope2] + + lines = [ + "EFFECTIVE RADIUS TRUE RADIUS SPIN GROUP NUMBER FOR THESE RADII", + " # channel numbers", + " 4.1364200 4.1364200 1 # 1 2 3", + " 4 # 1 2 3", + " 5 # 1 2 3", + " 4.9437200 4.9437200 2 # 1 2 3", + " 3 # 1 2", + "", + "Next section", + ] + + result = manager.extract_radius_info(lines, nuclear_data) + + assert result is True + + # Check isotope1 has radius for spin groups 1, 4, 5 + assert len(isotope1.radius_parameters) == 1 + rad1 = isotope1.radius_parameters[0] + assert rad1.effective_radius == 4.1364200 + assert rad1.true_radius == 4.1364200 + assert len(rad1.spin_groups) == 3 # Groups 1, 4, 5 + + # Check isotope2 has radius for spin groups 2, 3 + assert len(isotope2.radius_parameters) == 1 + rad2 = isotope2.radius_parameters[0] + assert rad2.effective_radius == 4.9437200 + assert rad2.true_radius == 4.9437200 + assert len(rad2.spin_groups) == 2 # Groups 2, 3 + + def test_extract_radius_info_no_data(self): + """Test extraction when no radius data present.""" + manager = LptManager() + nuclear_data = MagicMock() + nuclear_data.isotopes = [] + + lines = ["No radius information here", "Just random text"] + + result = manager.extract_radius_info(lines, nuclear_data) + + assert result is False + + +class TestExtractBroadeningInfo: + """Test extract_broadening_info method.""" + + def test_extract_broadening_info_with_radius(self): + """Test extraction with RADIUS field.""" + manager = LptManager() + physics_data = MagicMock() + physics_data.broadening_parameters = MagicMock() + + lines = [ + " RADIUS TEMPERATURE THICKNESS", + " 6.5000E+00( 1) 3.0000E+02 3.4716E-01( 2)", + "", + " DELTA-L DELTA-T-GAUS DELTA-T-EXP", + " 0.0000E+00 2.5200E-03 0.0000E+00", + ] + + result = manager.extract_broadening_info(lines, physics_data) + + assert result is True + assert physics_data.broadening_parameters.crfn == 6.5 + assert physics_data.broadening_parameters.temp == 300.0 + assert physics_data.broadening_parameters.thick == 0.34716 + assert physics_data.broadening_parameters.deltal == 0.0 + assert physics_data.broadening_parameters.deltag == 0.00252 + assert physics_data.broadening_parameters.deltae == 0.0 + + def test_extract_broadening_info_without_radius(self): + """Test extraction without RADIUS field.""" + manager = LptManager() + physics_data = MagicMock() + physics_data.broadening_parameters = MagicMock() + + lines = [ + " TEMPERATURE THICKNESS", + " 3.0000E+02( 1) 3.4716E-01", + "", + " DELTA-L DELTA-T-GAUS DELTA-T-EXP", + " 1.0000E-03( 2) 2.5200E-03 5.0000E-03( 3)", + ] + + result = manager.extract_broadening_info(lines, physics_data) + + assert result is True + assert physics_data.broadening_parameters.temp == 300.0 + assert physics_data.broadening_parameters.thick == 0.34716 + assert physics_data.broadening_parameters.deltal == 0.001 + assert physics_data.broadening_parameters.deltag == 0.00252 + assert physics_data.broadening_parameters.deltae == 0.005 + + def test_extract_broadening_info_no_data(self): + """Test extraction when no broadening data present.""" + manager = LptManager() + physics_data = MagicMock() + physics_data.broadening_parameters = MagicMock() + + lines = ["No broadening information here"] + + result = manager.extract_broadening_info(lines, physics_data) + + assert result is False + + +class TestExtractNormalizationInfo: + """Test extract_normalization_info method.""" + + def test_extract_normalization_info_success(self): + """Test successful extraction of normalization parameters.""" + manager = LptManager() + physics_data = MagicMock() + physics_data.normalization_parameters = MagicMock() + + lines = [ + " NORMALIZATION BCKG BCKG*E BCKG*SQRT(E)", + " 9.5802E-01( 1) 1.0000E-01 2.0000E-02( 2) 3.0000E-03", + "", + " BCKG*EXP BCKG*EXP(-E)", + " 4.0000E-04( 3) 5.0000E-05", + ] + + result = manager.extract_normalization_info(lines, physics_data) + + assert result is True + norm = physics_data.normalization_parameters + assert norm.anorm == 0.95802 + assert norm.flag_anorm == VaryFlag.YES + assert norm.backa == 0.1 + assert norm.flag_backa == VaryFlag.NO + assert norm.backb == 0.02 + assert norm.flag_backb == VaryFlag.YES + assert norm.backc == 0.003 + assert norm.flag_backc == VaryFlag.NO + assert norm.backd == 0.0004 + assert norm.flag_backd == VaryFlag.YES + assert norm.backf == 0.00005 + assert norm.flag_backf == VaryFlag.NO + + def test_extract_normalization_info_no_data(self): + """Test extraction when no normalization data present.""" + manager = LptManager() + physics_data = MagicMock() + physics_data.normalization_parameters = MagicMock() + + lines = ["No normalization information here"] + + result = manager.extract_normalization_info(lines, physics_data) + + assert result is False + + +class TestExtractChiSquaredInfo: + """Test extract_chi_squared_info method.""" + + def test_extract_chi_squared_info_success(self): + """Test successful extraction of chi-squared information.""" + manager = LptManager() + chi_squared_results = MagicMock() + + lines = [ + "Some text", + "CUSTOMARY CHI SQUARED = 188355.0", + "More text", + "CUSTOMARY CHI SQUARED DIVIDED BY NDAT = 11.9697", + "Even more text", + "Number of experimental data points = 15734", + ] + + result = manager.extract_chi_squared_info(lines, chi_squared_results) + + assert result is True + assert chi_squared_results.chi_squared == 188355.0 + assert chi_squared_results.reduced_chi_squared == 11.9697 + assert chi_squared_results.dof == 15734 + + def test_extract_chi_squared_info_missing_data(self): + """Test extraction when some chi-squared data missing.""" + manager = LptManager() + chi_squared_results = MagicMock() + + lines = [ + "CUSTOMARY CHI SQUARED = 188355.0", + # Missing reduced chi-squared and dof + ] + + result = manager.extract_chi_squared_info(lines, chi_squared_results) + + assert result is False + + def test_extract_chi_squared_info_scientific_notation(self): + """Test extraction with scientific notation.""" + manager = LptManager() + chi_squared_results = MagicMock() + + lines = [ + "CUSTOMARY CHI SQUARED = 5.4752E+04", + "CUSTOMARY CHI SQUARED DIVIDED BY NDAT = 3.4794E+00", + "Number of experimental data points = 15734", + ] + + result = manager.extract_chi_squared_info(lines, chi_squared_results) + + assert result is True + assert chi_squared_results.chi_squared == 54752.0 + assert chi_squared_results.reduced_chi_squared == 3.4794 + assert chi_squared_results.dof == 15734 + + +class TestExtractResultsFromString: + """Test extract_results_from_string method.""" + + @patch.object(LptManager, "extract_chi_squared_info", return_value=True) + @patch.object(LptManager, "extract_normalization_info", return_value=True) + @patch.object(LptManager, "extract_broadening_info", return_value=True) + @patch.object(LptManager, "extract_radius_info", return_value=True) + @patch.object(LptManager, "extract_isotope_info", return_value=True) + def test_extract_results_from_string_all_found( + self, mock_isotope, mock_radius, mock_broadening, mock_norm, mock_chi + ): + """Test extracting all results from string.""" + manager = LptManager() + test_string = "Test LPT block content" + + result = manager.extract_results_from_string(test_string) + + assert isinstance(result, FitResults) + mock_isotope.assert_called_once() + mock_radius.assert_called_once() + mock_broadening.assert_called_once() + mock_norm.assert_called_once() + mock_chi.assert_called_once() + + @patch.object(LptManager, "extract_chi_squared_info", return_value=False) + @patch.object(LptManager, "extract_normalization_info", return_value=False) + @patch.object(LptManager, "extract_broadening_info", return_value=False) + @patch.object(LptManager, "extract_radius_info", return_value=False) + @patch.object(LptManager, "extract_isotope_info", return_value=False) + def test_extract_results_from_string_none_found( + self, mock_isotope, mock_radius, mock_broadening, mock_norm, mock_chi + ): + """Test extracting when no results found logs info messages.""" + manager = LptManager() + test_string = "Empty LPT block" + + with patch("pleiades.sammy.io.lpt_manager.logger") as mock_logger: + result = manager.extract_results_from_string(test_string) + + assert isinstance(result, FitResults) + assert mock_logger.info.call_count == 5 # One for each missing result type + + +class TestSplitLptBlocks: + """Test split_lpt_blocks method.""" + + def test_split_lpt_blocks_single_block(self): + """Test splitting with single block.""" + manager = LptManager() + content = """ +Some header +***** INITIAL VALUES FOR PARAMETERS +Initial block content +More content +""" + + result = manager.split_lpt_blocks(content) + + assert len(result) == 1 + assert result[0][0] == "***** INITIAL VALUES FOR PARAMETERS" + assert "Initial block content" in result[0][1] + + def test_split_lpt_blocks_multiple_blocks(self): + """Test splitting with multiple blocks.""" + manager = LptManager() + content = """ +Header +***** INITIAL VALUES FOR PARAMETERS +Initial content +***** INTERMEDIATE VALUES FOR RESONANCE PARAMETERS +Intermediate content +***** NEW VALUES FOR RESONANCE PARAMETERS +New content +Footer +""" + + result = manager.split_lpt_blocks(content) + + assert len(result) == 3 + assert result[0][0] == "***** INITIAL VALUES FOR PARAMETERS" + assert result[1][0] == "***** INTERMEDIATE VALUES FOR RESONANCE PARAMETERS" + assert result[2][0] == "***** NEW VALUES FOR RESONANCE PARAMETERS" + assert "Initial content" in result[0][1] + assert "Intermediate content" in result[1][1] + assert "New content" in result[2][1] + + def test_split_lpt_blocks_no_delimiters(self): + """Test splitting content with no delimiters.""" + manager = LptManager() + content = "Just some random content without delimiters" + + result = manager.split_lpt_blocks(content) + + assert len(result) == 0 + + +class TestProcessLptFile: + """Test process_lpt_file method.""" + + def test_process_lpt_file_success(self): + """Test successful processing of LPT file.""" + manager = LptManager() + run_results = RunResults() + + test_content = """ +***** INITIAL VALUES FOR PARAMETERS +Initial block +***** INTERMEDIATE VALUES FOR RESONANCE PARAMETERS +Intermediate block +***** NEW VALUES FOR RESONANCE PARAMETERS +New block +""" + + with patch("builtins.open", mock_open(read_data=test_content)): + with patch.object(manager, "extract_results_from_string") as mock_extract: + mock_extract.return_value = FitResults() + + result = manager.process_lpt_file("test.lpt", run_results) + + # Method doesn't return True, just doesn't return False + assert result is not False + assert mock_extract.call_count == 3 + + def test_process_lpt_file_no_run_results(self): + """Test processing fails when no RunResults provided.""" + manager = LptManager() + + with pytest.raises(ValueError, match="A RunResults object must be provided"): + manager.process_lpt_file("test.lpt", None) + + def test_process_lpt_file_file_not_found(self): + """Test processing when file not found.""" + manager = LptManager() + run_results = RunResults() + + with patch("builtins.open", side_effect=FileNotFoundError()): + with patch("pleiades.sammy.io.lpt_manager.logger") as mock_logger: + result = manager.process_lpt_file("nonexistent.lpt", run_results) + + assert result is False + mock_logger.error.assert_called() + + def test_process_lpt_file_generic_exception(self): + """Test processing with generic exception.""" + manager = LptManager() + run_results = RunResults() + + with patch("builtins.open", side_effect=Exception("Read error")): + with patch("pleiades.sammy.io.lpt_manager.logger") as mock_logger: + result = manager.process_lpt_file("test.lpt", run_results) + + assert result is False + mock_logger.error.assert_called() + + +class TestIntegrationWithRealFile: + """Integration tests using real LPT file samples.""" + + def test_process_real_lpt_file(self): + """Test processing a real LPT file if available.""" + # Use the test data file if it exists + test_file = Path("tests/data/ex012/answers/ex012aa.lpt") + + if test_file.exists(): + manager = LptManager() + run_results = RunResults() + + result = manager.process_lpt_file(str(test_file), run_results) + + assert result is not False # Method returns None on success, False on failure + assert len(run_results.fit_results) > 0 + + # Check that some data was extracted + first_result = run_results.fit_results[0] + assert first_result.nuclear_data is not None + assert first_result.physics_data is not None + assert first_result.chi_squared_results is not None + else: + pytest.skip("Test LPT file not available") + + def test_extract_from_real_content(self): + """Test extraction from real LPT content snippet.""" + manager = LptManager() + + # Real content from SAMMY LPT file + real_content = """ + Isotopic abundance and mass for each nuclide -- + Nuclide Abundance Mass Spin groups + 1 1.000 ( 3) 27.9769 1 2 3 4 5 6 7 + 2 4.6700E-02( 4) 28.9765 8 9 10 11 12 13 14 15 16 17 + +EFFECTIVE RADIUS "TRUE" RADIUS SPIN GROUP NUMBER FOR THESE RADII + # channel numbers + 4.1364200 4.1364200 1 # 1 2 3 + 2 # 1 2 3 + + TEMPERATURE THICKNESS + 3.0000E+02 3.4716E-01 + + DELTA-L DELTA-T-GAUS DELTA-T-EXP + 0.0000E+00 2.5200E-03 0.0000E+00 + +CUSTOMARY CHI SQUARED = 188355. +CUSTOMARY CHI SQUARED DIVIDED BY NDAT = 11.9697 +Number of experimental data points = 15734 +""" + + result = manager.extract_results_from_string(real_content) + + # Check isotope extraction + assert len(result.nuclear_data.isotopes) == 2 + assert result.nuclear_data.isotopes[0].abundance == 1.0 + assert result.nuclear_data.isotopes[1].abundance == 0.0467 + + # Check broadening extraction + assert result.physics_data.broadening_parameters.temp == 300.0 + assert result.physics_data.broadening_parameters.thick == 0.34716 + + # Check chi-squared extraction + assert result.chi_squared_results.chi_squared == 188355.0 + assert result.chi_squared_results.reduced_chi_squared == 11.9697 + assert result.chi_squared_results.dof == 15734 + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_parse_value_with_extra_spaces(self): + """Test parsing values with extra spaces.""" + # The function expects properly formatted scientific notation + # Extra leading spaces cause the regex not to match + assert parse_value_and_varied("1.234E+02 ") == (123.4, False) + assert parse_value_and_varied("1.234E+02 ( 5)") == (123.4, True) + + def test_empty_lines_in_extraction(self): + """Test extraction methods handle empty lines gracefully.""" + manager = LptManager() + nuclear_data = MagicMock() + nuclear_data.isotopes = [] + + lines = ["", "", " Isotopic abundance and mass for each nuclide", "", ""] + + result = manager.extract_isotope_info(lines, nuclear_data) + assert result is False # No actual data extracted + + def test_malformed_lines_in_extraction(self): + """Test extraction handles malformed lines.""" + manager = LptManager() + nuclear_data = MagicMock() + nuclear_data.isotopes = [] + + lines = [ + " Isotopic abundance and mass for each nuclide", + " Nuclide Abundance Mass Spin groups", + " 1 INVALID 27.9769 1 2 3", # Invalid abundance + ] + + result = manager.extract_isotope_info(lines, nuclear_data) + assert result is False # Should not crash, just fail to extract diff --git a/tests/unit/pleiades/sammy/io/test_lst_manager.py b/tests/unit/pleiades/sammy/io/test_lst_manager.py new file mode 100644 index 00000000..265d86b8 --- /dev/null +++ b/tests/unit/pleiades/sammy/io/test_lst_manager.py @@ -0,0 +1,315 @@ +"""Comprehensive unit tests for sammy/io/lst_manager.py module.""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from pleiades.sammy.data.options import SammyData +from pleiades.sammy.io.lst_manager import LstManager +from pleiades.sammy.results.models import RunResults + + +class TestLstManagerInitialization: + """Test LstManager initialization scenarios.""" + + def test_init_without_file_path(self): + """Test initialization without providing a file path.""" + manager = LstManager() + + assert manager.run_results is not None + assert isinstance(manager.run_results, RunResults) + assert manager.run_results.data is not None + assert len(manager.run_results.fit_results) == 0 + + def test_init_with_provided_run_results(self): + """Test initialization with a provided RunResults object.""" + custom_run_results = RunResults() + manager = LstManager(run_results=custom_run_results) + + assert manager.run_results is custom_run_results + assert manager.run_results is not RunResults() # Should use provided object + + def test_init_with_nonexistent_file(self): + """Test initialization with a non-existent file path.""" + fake_path = Path("/nonexistent/path/to/file.lst") + + with pytest.raises(FileNotFoundError, match="The file .* does not exist"): + LstManager(lst_file_path=fake_path) + + @patch("pleiades.sammy.io.lst_manager.LstManager.process_lst_file") + def test_init_with_existing_file(self, mock_process): + """Test initialization with an existing file path.""" + with tempfile.NamedTemporaryFile(suffix=".lst", delete=False) as tmp: + tmp_path = Path(tmp.name) + tmp.write(b"Sample LST data\n") + tmp_path = Path(tmp.name) + + try: + manager = LstManager(lst_file_path=tmp_path) + + # Verify process_lst_file was called + mock_process.assert_called_once_with(tmp_path, manager.run_results) + assert manager.run_results is not None + finally: + tmp_path.unlink() # Clean up temp file + + def test_init_with_invalid_path_type(self): + """Test initialization with an invalid path type (not Path object).""" + # String path that doesn't exist won't trigger processing + manager = LstManager(lst_file_path="not_a_path_object.lst") + + # Should create default RunResults but not process anything + assert manager.run_results is not None + assert isinstance(manager.run_results, RunResults) + + def test_init_with_none_path(self): + """Test initialization with None as path.""" + manager = LstManager(lst_file_path=None) + + assert manager.run_results is not None + assert isinstance(manager.run_results, RunResults) + + def test_init_with_both_parameters(self): + """Test initialization with both file path and run_results.""" + custom_run_results = RunResults() + + with tempfile.NamedTemporaryFile(suffix=".lst", delete=False) as tmp: + tmp_path = Path(tmp.name) + tmp.write(b"Sample data\n") + + try: + with patch("pleiades.sammy.io.lst_manager.LstManager.process_lst_file") as mock_process: + manager = LstManager(lst_file_path=tmp_path, run_results=custom_run_results) + + assert manager.run_results is custom_run_results + mock_process.assert_called_once_with(tmp_path, custom_run_results) + finally: + tmp_path.unlink() + + +class TestProcessLstFile: + """Test the process_lst_file method.""" + + @patch("pleiades.sammy.io.lst_manager.SammyData") + def test_process_lst_file_basic(self, mock_sammy_data_class): + """Test basic process_lst_file functionality.""" + # Setup mock + mock_data_instance = MagicMock(spec=SammyData) + mock_sammy_data_class.return_value = mock_data_instance + + # Create manager and process file + manager = LstManager() + test_path = Path("/test/file.lst") + + manager.process_lst_file(test_path, manager.run_results) + + # Verify SammyData was created with correct path + mock_sammy_data_class.assert_called_once_with(data_file=test_path) + + # Verify load was called on the instance + mock_data_instance.load.assert_called_once() + + # Verify data was stored in run_results + assert manager.run_results.data is mock_data_instance + + @patch("pleiades.sammy.io.lst_manager.SammyData") + def test_process_lst_file_with_custom_run_results(self, mock_sammy_data_class): + """Test process_lst_file with a custom RunResults object.""" + mock_data_instance = MagicMock(spec=SammyData) + mock_sammy_data_class.return_value = mock_data_instance + + manager = LstManager() + custom_run_results = RunResults() + test_path = Path("/test/file.lst") + + manager.process_lst_file(test_path, custom_run_results) + + # Verify data was stored in the custom run_results + assert custom_run_results.data is mock_data_instance + mock_data_instance.load.assert_called_once() + + @patch("pleiades.sammy.io.lst_manager.SammyData") + def test_process_lst_file_load_exception(self, mock_sammy_data_class): + """Test process_lst_file when SammyData.load raises an exception.""" + mock_data_instance = MagicMock(spec=SammyData) + mock_data_instance.load.side_effect = ValueError("Invalid data format") + mock_sammy_data_class.return_value = mock_data_instance + + manager = LstManager() + test_path = Path("/test/file.lst") + + with pytest.raises(ValueError, match="Invalid data format"): + manager.process_lst_file(test_path, manager.run_results) + + @patch("pleiades.sammy.io.lst_manager.SammyData") + def test_process_lst_file_multiple_calls(self, mock_sammy_data_class): + """Test calling process_lst_file multiple times.""" + # Create different mock instances for each call + mock_data1 = MagicMock(spec=SammyData) + mock_data2 = MagicMock(spec=SammyData) + mock_sammy_data_class.side_effect = [mock_data1, mock_data2] + + manager = LstManager() + run_results = RunResults() + + # Process first file + test_path1 = Path("/test/file1.lst") + manager.process_lst_file(test_path1, run_results) + assert run_results.data is mock_data1 + + # Process second file (should overwrite) + test_path2 = Path("/test/file2.lst") + manager.process_lst_file(test_path2, run_results) + assert run_results.data is mock_data2 + + # Verify both were loaded + mock_data1.load.assert_called_once() + mock_data2.load.assert_called_once() + + +class TestIntegrationScenarios: + """Test integration scenarios with actual file operations.""" + + def test_full_workflow_with_real_file(self): + """Test complete workflow with a real LST file.""" + # Create a sample LST file with realistic data + lst_content = """# Sample LST file +1.0 0.5 0.01 0.49 0.51 0.95 0.02 0.94 0.96 +2.0 0.6 0.01 0.59 0.61 0.85 0.02 0.84 0.86 +3.0 0.7 0.01 0.69 0.71 0.75 0.02 0.74 0.76 +""" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".lst", delete=False) as tmp: + tmp.write(lst_content) + tmp_path = Path(tmp.name) + + try: + # Create manager and load file + manager = LstManager(lst_file_path=tmp_path) + + # Verify run_results was populated + assert manager.run_results is not None + assert manager.run_results.data is not None + + # Verify data was loaded (SammyData should have processed the file) + assert hasattr(manager.run_results.data, "data_file") + assert manager.run_results.data.data_file == tmp_path + + # Check if data DataFrame was created and has correct shape + if manager.run_results.data.data is not None: + assert len(manager.run_results.data.data) == 3 # 3 data rows + + finally: + tmp_path.unlink() + + def test_workflow_with_empty_file(self): + """Test workflow with an empty LST file.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".lst", delete=False) as tmp: + # Create empty file + tmp_path = Path(tmp.name) + + try: + # This might raise an error depending on SammyData's handling of empty files + # We test that LstManager properly propagates any errors + manager = LstManager(lst_file_path=tmp_path) + + # If it doesn't raise an error, verify structure is created + assert manager.run_results is not None + assert manager.run_results.data is not None + + except Exception: + # If SammyData raises an error for empty files, that's also valid behavior + pass + finally: + tmp_path.unlink() + + def test_multiple_managers_independent(self): + """Test that multiple LstManager instances are independent.""" + manager1 = LstManager() + manager2 = LstManager() + + # Verify they have different RunResults instances + assert manager1.run_results is not manager2.run_results + + # Modify one and verify the other is unchanged + from pleiades.sammy.results.models import FitResults + + fit_result = FitResults() + manager1.run_results.add_fit_result(fit_result) + + assert len(manager1.run_results.fit_results) == 1 + assert len(manager2.run_results.fit_results) == 0 + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + @patch("pleiades.sammy.io.lst_manager.SammyData") + def test_process_none_path(self, mock_sammy_data_class): + """Test process_lst_file with None as path.""" + mock_data_instance = MagicMock(spec=SammyData) + mock_sammy_data_class.return_value = mock_data_instance + + manager = LstManager() + + # This should work - SammyData should handle None + manager.process_lst_file(None, manager.run_results) + + mock_sammy_data_class.assert_called_once_with(data_file=None) + mock_data_instance.load.assert_called_once() + + def test_path_exists_but_not_readable(self): + """Test with a path that exists but is not readable.""" + with tempfile.NamedTemporaryFile(suffix=".lst", delete=False) as tmp: + tmp_path = Path(tmp.name) + tmp.write(b"data") + + try: + # Make file unreadable (Unix-specific) + import os + + os.chmod(tmp_path, 0o000) + + # This should raise some kind of permission error + with pytest.raises(Exception): # Could be PermissionError or other + LstManager(lst_file_path=tmp_path) + + finally: + # Restore permissions and clean up + import os + + os.chmod(tmp_path, 0o644) + tmp_path.unlink() + + @patch("pleiades.sammy.io.lst_manager.SammyData") + def test_sammy_data_creation_failure(self, mock_sammy_data_class): + """Test when SammyData creation fails.""" + mock_sammy_data_class.side_effect = MemoryError("Out of memory") + + manager = LstManager() + test_path = Path("/test/file.lst") + + with pytest.raises(MemoryError, match="Out of memory"): + manager.process_lst_file(test_path, manager.run_results) + + def test_path_object_validation(self): + """Test that only Path objects trigger file processing.""" + # These should not trigger file processing + invalid_paths = [ + "string_path.lst", + 123, + ["list", "of", "paths"], + {"dict": "path"}, + ] + + for invalid_path in invalid_paths: + manager = LstManager(lst_file_path=invalid_path) + # Should create default RunResults without error + assert manager.run_results is not None + assert isinstance(manager.run_results, RunResults) + + +if __name__ == "__main__": + pytest.main(["-v", __file__]) diff --git a/tests/unit/pleiades/sammy/test_parfile.py b/tests/unit/pleiades/sammy/test_parfile.py new file mode 100644 index 00000000..5708f3fc --- /dev/null +++ b/tests/unit/pleiades/sammy/test_parfile.py @@ -0,0 +1,456 @@ +"""Comprehensive unit tests for sammy/parfile.py module.""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from pleiades.sammy.parameters import ( + BroadeningParameterCard, + DataReductionCard, + ExternalRFunction, + IsotopeCard, + NormalizationBackgroundCard, + ORRESCard, + ParamagneticParameters, + RadiusCard, + ResonanceCard, + UnusedCorrelatedCard, + UserResolutionParameters, +) +from pleiades.sammy.parfile import CardOrder, SammyParameterFile + + +class TestCardOrder: + """Test the CardOrder enum functionality.""" + + def test_card_order_values(self): + """Test that all expected card order values exist.""" + assert hasattr(CardOrder, "RESONANCE") + assert hasattr(CardOrder, "FUDGE") + assert hasattr(CardOrder, "EXTERNAL_R") + assert hasattr(CardOrder, "BROADENING") + assert hasattr(CardOrder, "UNUSED_CORRELATED") + assert hasattr(CardOrder, "NORMALIZATION") + assert hasattr(CardOrder, "RADIUS") + assert hasattr(CardOrder, "DATA_REDUCTION") + assert hasattr(CardOrder, "ORRES") + assert hasattr(CardOrder, "ISOTOPE") + assert hasattr(CardOrder, "PARAMAGNETIC") + assert hasattr(CardOrder, "USER_RESOLUTION") + + def test_get_field_name(self): + """Test the get_field_name method for each card type.""" + assert CardOrder.get_field_name(CardOrder.RESONANCE) == "resonance" + assert CardOrder.get_field_name(CardOrder.FUDGE) == "fudge" + assert CardOrder.get_field_name(CardOrder.EXTERNAL_R) == "external_r" + assert CardOrder.get_field_name(CardOrder.BROADENING) == "broadening" + assert CardOrder.get_field_name(CardOrder.UNUSED_CORRELATED) == "unused_correlated" + assert CardOrder.get_field_name(CardOrder.NORMALIZATION) == "normalization" + assert CardOrder.get_field_name(CardOrder.RADIUS) == "radius" + assert CardOrder.get_field_name(CardOrder.DATA_REDUCTION) == "data_reduction" + assert CardOrder.get_field_name(CardOrder.ORRES) == "orres" + assert CardOrder.get_field_name(CardOrder.ISOTOPE) == "isotope" + assert CardOrder.get_field_name(CardOrder.PARAMAGNETIC) == "paramagnetic" + assert CardOrder.get_field_name(CardOrder.USER_RESOLUTION) == "user_resolution" + + def test_card_order_enum_iteration(self): + """Test that CardOrder can be iterated over.""" + card_types = list(CardOrder) + assert len(card_types) == 12 # Should have 12 card types + assert CardOrder.RESONANCE in card_types + assert CardOrder.USER_RESOLUTION in card_types + + +class TestSammyParameterFile: + """Test the SammyParameterFile class functionality.""" + + def test_initialization_empty(self): + """Test creating an empty parameter file.""" + param_file = SammyParameterFile() + assert param_file.fudge is None + assert param_file.resonance is None + assert param_file.external_r is None + assert param_file.broadening is None + assert param_file.unused_correlated is None + assert param_file.normalization is None + assert param_file.radius is None + assert param_file.data_reduction is None + assert param_file.orres is None + assert param_file.paramagnetic is None + assert param_file.user_resolution is None + assert param_file.isotope is None + + def test_initialization_with_fudge(self): + """Test creating a parameter file with fudge factor.""" + param_file = SammyParameterFile(fudge=0.5) + assert param_file.fudge == 0.5 + + def test_fudge_validation(self): + """Test that fudge factor is validated (0.0 <= fudge <= 1.0).""" + # Valid values + param_file = SammyParameterFile(fudge=0.0) + assert param_file.fudge == 0.0 + + param_file = SammyParameterFile(fudge=1.0) + assert param_file.fudge == 1.0 + + # Invalid values + with pytest.raises(ValidationError): + SammyParameterFile(fudge=-0.1) + + with pytest.raises(ValidationError): + SammyParameterFile(fudge=1.1) + + def test_to_string_empty(self): + """Test converting empty parameter file to string.""" + param_file = SammyParameterFile() + result = param_file.to_string() + assert result == "" + + def test_to_string_with_fudge(self): + """Test converting parameter file with fudge factor to string.""" + param_file = SammyParameterFile(fudge=0.75) + result = param_file.to_string() + assert "0.7500" in result + + @patch.object(ResonanceCard, "to_lines") + def test_to_string_with_resonance(self, mock_to_lines): + """Test converting parameter file with resonance card to string.""" + mock_to_lines.return_value = ["RESONANCES are listed next", "1.0 2.0 3.0"] + + mock_resonance = MagicMock(spec=ResonanceCard) + mock_resonance.to_lines = mock_to_lines + + param_file = SammyParameterFile(resonance=mock_resonance) + result = param_file.to_string() + + assert "RESONANCES are listed next" in result + assert "1.0 2.0 3.0" in result + mock_to_lines.assert_called_once() + + def test_from_string_empty_content(self): + """Test parsing empty content raises ValueError.""" + with pytest.raises(ValueError, match="Empty parameter file content"): + SammyParameterFile.from_string("") + + def test_from_string_with_fudge_only(self): + """Test parsing content with only fudge factor.""" + content = "0.5000\n" + param_file = SammyParameterFile.from_string(content) + assert param_file.fudge == 0.5 + assert param_file.resonance is None + + def test_from_string_invalid_fudge(self): + """Test parsing invalid content raises ValueError.""" + # "not_a_number" has characters beyond position 11, so it's treated as resonance data + # This should fail when trying to parse as resonance entry + content = "not_a_number\n" + with pytest.raises(ValueError, match="Failed to parse resonance table"): + SammyParameterFile.from_string(content) + + @patch.object(ResonanceCard, "from_lines") + def test_from_string_with_resonance(self, mock_from_lines): + """Test parsing content with resonance entries.""" + mock_resonance = MagicMock(spec=ResonanceCard) + mock_from_lines.return_value = mock_resonance + + content = "-3661600.00 158770.000 3698500.+3 0.0000 0 0 1 0 1\n" + param_file = SammyParameterFile.from_string(content) + + assert param_file.resonance == mock_resonance + mock_from_lines.assert_called_once() + + def test_from_string_unimplemented_card(self): + """Test parsing content with unimplemented card type raises ValueError.""" + # MISCEllaneous is not in the header dictionary, so it won't be recognized as a header + # and will be treated as resonance data, which will fail parsing + content = "MISCEllaneous parameters follow\nsome data\n" + with pytest.raises(ValueError): + SammyParameterFile.from_string(content) + + def test_get_card_class_with_header(self): + """Test the _get_card_class_with_header method.""" + # Test with a header that should match + with patch.object(BroadeningParameterCard, "is_header_line", return_value=True): + card_type, card_class = SammyParameterFile._get_card_class_with_header( + "BROADening parameters may be varied" + ) + assert card_type == CardOrder.BROADENING + assert card_class == BroadeningParameterCard + + # Test with a header that doesn't match + with patch.object(BroadeningParameterCard, "is_header_line", return_value=False): + card_type, card_class = SammyParameterFile._get_card_class_with_header("Unknown header line") + assert card_type is None + assert card_class is None + + def test_get_card_class(self): + """Test the _get_card_class method.""" + assert SammyParameterFile._get_card_class(CardOrder.RESONANCE) == ResonanceCard + assert SammyParameterFile._get_card_class(CardOrder.EXTERNAL_R) == ExternalRFunction + assert SammyParameterFile._get_card_class(CardOrder.BROADENING) == BroadeningParameterCard + assert SammyParameterFile._get_card_class(CardOrder.UNUSED_CORRELATED) == UnusedCorrelatedCard + assert SammyParameterFile._get_card_class(CardOrder.NORMALIZATION) == NormalizationBackgroundCard + assert SammyParameterFile._get_card_class(CardOrder.RADIUS) == RadiusCard + assert SammyParameterFile._get_card_class(CardOrder.DATA_REDUCTION) == DataReductionCard + assert SammyParameterFile._get_card_class(CardOrder.ORRES) == ORRESCard + assert SammyParameterFile._get_card_class(CardOrder.ISOTOPE) == IsotopeCard + assert SammyParameterFile._get_card_class(CardOrder.PARAMAGNETIC) == ParamagneticParameters + assert SammyParameterFile._get_card_class(CardOrder.USER_RESOLUTION) == UserResolutionParameters + + def test_parse_card_success(self): + """Test successful card parsing.""" + with patch.object(ResonanceCard, "from_lines") as mock_from_lines: + mock_resonance = MagicMock(spec=ResonanceCard) + mock_from_lines.return_value = mock_resonance + + lines = ["test line 1", "test line 2"] + result = SammyParameterFile._parse_card(CardOrder.RESONANCE, lines) + + assert result == mock_resonance + mock_from_lines.assert_called_once_with(lines) + + def test_parse_card_no_parser(self): + """Test parsing card with no parser raises ValueError.""" + # Create a mock CardOrder value that's not in the card map + mock_card_type = MagicMock() + mock_card_type.name = "UNKNOWN_CARD" + + with patch.object(SammyParameterFile, "_get_card_class", return_value=None): + with pytest.raises(ValueError, match="No parser implemented"): + SammyParameterFile._parse_card(mock_card_type, ["test"]) + + def test_parse_card_parsing_error(self): + """Test that parsing errors are caught and re-raised with context.""" + with patch.object(ResonanceCard, "from_lines", side_effect=Exception("Parse error")): + with pytest.raises(ValueError, match="Failed to parse RESONANCE card"): + SammyParameterFile._parse_card(CardOrder.RESONANCE, ["test"]) + + def test_from_file_success(self): + """Test reading parameter file from disk successfully.""" + content = "0.7500\n" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".par", delete=False) as tmp_file: + tmp_file.write(content) + tmp_file_path = Path(tmp_file.name) + + try: + param_file = SammyParameterFile.from_file(tmp_file_path) + assert param_file.fudge == 0.75 + finally: + tmp_file_path.unlink() + + def test_from_file_not_found(self): + """Test that FileNotFoundError is raised for non-existent file.""" + non_existent_path = Path("/path/that/does/not/exist.par") + with pytest.raises(FileNotFoundError, match="Parameter file not found"): + SammyParameterFile.from_file(non_existent_path) + + def test_from_file_unicode_error(self): + """Test handling of files with invalid encoding.""" + with tempfile.NamedTemporaryFile(mode="wb", suffix=".par", delete=False) as tmp_file: + # Write invalid UTF-8 bytes + tmp_file.write(b"\xff\xfe\xfd\xfc") + tmp_file_path = Path(tmp_file.name) + + try: + with pytest.raises(ValueError, match="invalid encoding"): + SammyParameterFile.from_file(tmp_file_path) + finally: + tmp_file_path.unlink() + + def test_from_file_parse_error(self): + """Test handling of files with invalid content.""" + content = "invalid_fudge_value\n" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".par", delete=False) as tmp_file: + tmp_file.write(content) + tmp_file_path = Path(tmp_file.name) + + try: + with pytest.raises(ValueError, match="Failed to parse"): + SammyParameterFile.from_file(tmp_file_path) + finally: + tmp_file_path.unlink() + + def test_to_file_success(self): + """Test writing parameter file to disk successfully.""" + param_file = SammyParameterFile(fudge=0.25) + + with tempfile.TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.par" + param_file.to_file(file_path) + + assert file_path.exists() + content = file_path.read_text() + assert "0.2500" in content + + def test_to_file_creates_parent_directories(self): + """Test that to_file creates parent directories if they don't exist.""" + param_file = SammyParameterFile(fudge=0.5) + + with tempfile.TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "nested" / "dirs" / "test.par" + param_file.to_file(file_path) + + assert file_path.exists() + assert file_path.parent.exists() + + def test_to_file_format_error(self): + """Test handling of formatting errors during file writing.""" + param_file = SammyParameterFile() + + # Mock to_string at the class level to raise an exception + with patch.object(SammyParameterFile, "to_string", side_effect=Exception("Format error")): + with tempfile.TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.par" + with pytest.raises(ValueError, match="Failed to format parameter file content"): + param_file.to_file(file_path) + + def test_print_parameters_empty(self, capsys): + """Test printing empty parameter file.""" + param_file = SammyParameterFile() + param_file.print_parameters() + + captured = capsys.readouterr() + assert "Sammy Parameter File Details:" in captured.out + assert "No cards present in the parameter file." in captured.out + + def test_print_parameters_with_fudge(self, capsys): + """Test printing parameter file with fudge factor.""" + param_file = SammyParameterFile(fudge=0.85) + param_file.print_parameters() + + captured = capsys.readouterr() + assert "Sammy Parameter File Details:" in captured.out + assert "fudge:" in captured.out + assert "0.85" in captured.out + + def test_print_parameters_with_mock_card(self, capsys): + """Test printing parameter file with a mock card.""" + mock_card = MagicMock(spec=ResonanceCard) + mock_card.to_lines = MagicMock(return_value=["Header line", "Data line"]) + mock_card.detect_format = MagicMock(return_value="TestFormat") + + param_file = SammyParameterFile(resonance=mock_card) + param_file.print_parameters() + + captured = capsys.readouterr() + assert "resonance:" in captured.out + assert "Format: TestFormat" in captured.out + assert "Header: Header line" in captured.out + + def test_print_parameters_without_format_detection(self, capsys): + """Test printing parameter file with card that doesn't have format detection.""" + # Create a mock that passes Pydantic validation + mock_card = MagicMock(spec=BroadeningParameterCard) + # Remove the format detection attributes + del mock_card.to_lines + del mock_card.detect_format + + # Create the parameter file with the mock + param_file = SammyParameterFile() + # Manually set the broadening after creation to bypass validation + object.__setattr__(param_file, "broadening", mock_card) + + param_file.print_parameters() + + captured = capsys.readouterr() + assert "broadening:" in captured.out + assert "No format detection available for this card." in captured.out + + def test_main_not_implemented(self): + """Test that the main block exists but is not implemented.""" + # This test verifies the existence of the NotImplementedError in __main__ + # We can't actually run the main block without complex execution context manipulation + import pleiades.sammy.parfile + + # Read the source code to verify the NotImplementedError exists + with open(pleiades.sammy.parfile.__file__, "r") as f: + source = f.read() + + # Check that the main block contains NotImplementedError + assert 'if __name__ == "__main__"' in source + assert "NotImplementedError" in source + + +class TestIntegrationScenarios: + """Integration tests for complex scenarios.""" + + @patch.object(ResonanceCard, "from_lines") + @patch.object(BroadeningParameterCard, "from_lines") + @patch.object(NormalizationBackgroundCard, "from_lines") + def test_complex_parameter_file(self, mock_norm_from_lines, mock_broad_from_lines, mock_res_from_lines): + """Test parsing a complex parameter file with multiple cards.""" + # Setup mocks + mock_resonance = MagicMock(spec=ResonanceCard) + mock_broadening = MagicMock(spec=BroadeningParameterCard) + mock_normalization = MagicMock(spec=NormalizationBackgroundCard) + + mock_res_from_lines.return_value = mock_resonance + mock_broad_from_lines.return_value = mock_broadening + mock_norm_from_lines.return_value = mock_normalization + + # Mock is_header_line for proper card detection + with ( + patch.object(BroadeningParameterCard, "is_header_line") as mock_broad_header, + patch.object(NormalizationBackgroundCard, "is_header_line") as mock_norm_header, + ): + + def broad_header_check(line): + return "BROADening" in line + + def norm_header_check(line): + return "NORMAlization" in line + + mock_broad_header.side_effect = broad_header_check + mock_norm_header.side_effect = norm_header_check + + content = """0.5000 +-3661600.00 158770.000 3698500.+3 0.0000 0 0 1 0 1 + +BROADening parameters may be varied +1.0 2.0 3.0 + +NORMAlization and background +4.0 5.0 6.0 +""" + + param_file = SammyParameterFile.from_string(content) + + assert param_file.fudge == 0.5 + assert param_file.resonance == mock_resonance + assert param_file.broadening == mock_broadening + assert param_file.normalization == mock_normalization + + def test_round_trip_conversion(self): + """Test that a parameter file can be converted to string and back.""" + original = SammyParameterFile(fudge=0.333) + + # Convert to string + content = original.to_string() + + # Parse back from string + parsed = SammyParameterFile.from_string(content) + + # Compare - fudge should be close (accounting for formatting) + assert abs(parsed.fudge - 0.333) < 0.0001 + + def test_file_round_trip(self): + """Test that a parameter file can be written to disk and read back.""" + original = SammyParameterFile(fudge=0.666) + + with tempfile.TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.par" + + # Write to file + original.to_file(file_path) + + # Read back from file + loaded = SammyParameterFile.from_file(file_path) + + # Compare + assert abs(loaded.fudge - 0.666) < 0.0001 diff --git a/tests/unit/pleiades/utils/test_load.py b/tests/unit/pleiades/utils/test_load.py index ae715d2f..cc917dc0 100644 --- a/tests/unit/pleiades/utils/test_load.py +++ b/tests/unit/pleiades/utils/test_load.py @@ -1,11 +1,18 @@ +"""Comprehensive unit tests for utils/load.py module.""" + +from unittest.mock import patch + import astropy.io.fits as fits import numpy as np import pytest import tifffile +from pleiades.utils.load import load, load_fits, load_tiff + @pytest.fixture(scope="function") def data_fixture(tmpdir): + """Create temporary test files for testing.""" # dummy tiff image data data = np.ones((3, 3)) @@ -26,8 +33,258 @@ def data_fixture(tmpdir): return tiff_file_name, fits_file_name +class TestLoadFunction: + """Test the main load function.""" + + @patch("pleiades.utils.load.load_tiff") + def test_load_tiff_files(self, mock_load_tiff): + """Test loading TIFF files through main load function.""" + mock_load_tiff.return_value = np.array([[[1, 2], [3, 4]]]) + + files = ["file1.tiff", "file2.tiff"] + result = load(files, ".tiff") + + mock_load_tiff.assert_called_once_with(files) + assert result.shape == (1, 2, 2) + + @patch("pleiades.utils.load.load_tiff") + def test_load_tif_files(self, mock_load_tiff): + """Test loading TIF files (alternative extension).""" + mock_load_tiff.return_value = np.array([[[1, 2], [3, 4]]]) + + files = ["file1.tif", "file2.tif"] + result = load(files, ".tif") + + mock_load_tiff.assert_called_once_with(files) + assert result.shape == (1, 2, 2) + + @patch("pleiades.utils.load.load_fits") + def test_load_fits_files(self, mock_load_fits): + """Test loading FITS files through main load function.""" + mock_load_fits.return_value = np.array([[[5, 6], [7, 8]]]) + + files = ["file1.fits", "file2.fits"] + result = load(files, ".fits") + + mock_load_fits.assert_called_once_with(files) + assert result.shape == (1, 2, 2) + + def test_load_empty_list(self): + """Test that empty file list raises ValueError.""" + with pytest.raises(ValueError, match="List of files must be provided"): + load([], ".tiff") + + def test_load_unsupported_extension(self): + """Test that unsupported file extension raises ValueError.""" + with pytest.raises(ValueError, match="Unsupported file extension"): + load(["file.jpg"], ".jpg") + + @patch("pleiades.utils.load.logger") + @patch("pleiades.utils.load.load_tiff") + def test_load_logs_info(self, mock_load_tiff, mock_logger): + """Test that load function logs information.""" + mock_load_tiff.return_value = np.zeros((2, 3, 3)) + + files = ["file1.tiff", "file2.tiff", "file3.tiff"] + load(files, ".tiff") + + mock_logger.info.assert_called_once_with("loading 3 files with extension .tiff") + + +class TestLoadTiff: + """Test the load_tiff function.""" + + @patch("pleiades.utils.load.read_tiff") + def test_load_single_tiff(self, mock_read_tiff): + """Test loading a single TIFF file.""" + mock_image = np.array([[1, 2, 3], [4, 5, 6]]) + mock_read_tiff.return_value = mock_image + + files = ["file1.tiff"] + result = load_tiff(files) + + assert result.shape == (1, 2, 3) + assert np.array_equal(result[0], mock_image) + # Called twice - once for shape, once for loading + assert mock_read_tiff.call_count == 2 + + @patch("pleiades.utils.load.read_tiff") + def test_load_multiple_tiff(self, mock_read_tiff): + """Test loading multiple TIFF files.""" + mock_image1 = np.array([[1, 2], [3, 4]]) + mock_image2 = np.array([[5, 6], [7, 8]]) + mock_image3 = np.array([[9, 10], [11, 12]]) + + mock_read_tiff.side_effect = [mock_image1, mock_image1, mock_image2, mock_image3] + + files = ["file1.tiff", "file2.tiff", "file3.tiff"] + result = load_tiff(files) + + assert result.shape == (3, 2, 2) + assert np.array_equal(result[0], mock_image1) + assert np.array_equal(result[1], mock_image2) + assert np.array_equal(result[2], mock_image3) + + @patch("pleiades.utils.load.read_tiff") + def test_load_tiff_with_dtype(self, mock_read_tiff): + """Test loading TIFF files with specified dtype.""" + mock_image = np.array([[1.5, 2.5], [3.5, 4.5]]) + mock_read_tiff.return_value = mock_image + + files = ["file1.tiff"] + result = load_tiff(files, dtype=np.float32) + + assert result.dtype == np.float32 + assert result.shape == (1, 2, 2) + + @patch("pleiades.utils.load.read_tiff") + def test_load_tiff_default_dtype(self, mock_read_tiff): + """Test that default dtype is uint16.""" + mock_image = np.array([[1, 2], [3, 4]]) + mock_read_tiff.return_value = mock_image + + files = ["file1.tiff"] + result = load_tiff(files) + + assert result.dtype == np.uint16 + + def test_load_tiff_real_file(self, data_fixture): + """Test loading a real TIFF file.""" + tiff_file, _ = data_fixture + + result = load_tiff([str(tiff_file)]) + + assert result.shape == (1, 3, 3) + assert np.array_equal(result[0], np.ones((3, 3))) + + +class TestLoadFits: + """Test the load_fits function.""" + + @patch("pleiades.utils.load.read_fits") + def test_load_single_fits(self, mock_read_fits): + """Test loading a single FITS file.""" + mock_image = np.array([[10, 20, 30], [40, 50, 60]]) + mock_read_fits.return_value = mock_image + + files = ["file1.fits"] + result = load_fits(files) + + assert result.shape == (1, 2, 3) + assert np.array_equal(result[0], mock_image) + # Called twice - once for shape, once for loading + assert mock_read_fits.call_count == 2 + + @patch("pleiades.utils.load.read_fits") + def test_load_multiple_fits(self, mock_read_fits): + """Test loading multiple FITS files.""" + mock_image1 = np.array([[1, 2], [3, 4]]) + mock_image2 = np.array([[5, 6], [7, 8]]) + mock_image3 = np.array([[9, 10], [11, 12]]) + + mock_read_fits.side_effect = [mock_image1, mock_image1, mock_image2, mock_image3] + + files = ["file1.fits", "file2.fits", "file3.fits"] + result = load_fits(files) + + assert result.shape == (3, 2, 2) + assert np.array_equal(result[0], mock_image1) + assert np.array_equal(result[1], mock_image2) + assert np.array_equal(result[2], mock_image3) + + @patch("pleiades.utils.load.read_fits") + def test_load_fits_with_dtype(self, mock_read_fits): + """Test loading FITS files with specified dtype.""" + mock_image = np.array([[1.5, 2.5], [3.5, 4.5]]) + mock_read_fits.return_value = mock_image + + files = ["file1.fits"] + result = load_fits(files, dtype=np.float64) + + assert result.dtype == np.float64 + assert result.shape == (1, 2, 2) + + @patch("pleiades.utils.load.read_fits") + def test_load_fits_default_dtype(self, mock_read_fits): + """Test that default dtype is uint16.""" + mock_image = np.array([[1, 2], [3, 4]]) + mock_read_fits.return_value = mock_image + + files = ["file1.fits"] + result = load_fits(files) + + assert result.dtype == np.uint16 + + def test_load_fits_real_file(self, data_fixture): + """Test loading a real FITS file.""" + _, fits_file = data_fixture + + result = load_fits([str(fits_file)]) + + assert result.shape == (1, 3, 3) + assert np.array_equal(result[0], np.ones((3, 3))) + + +class TestIntegrationScenarios: + """Test integration scenarios with actual file operations.""" + + def test_load_workflow_with_tiff(self, data_fixture): + """Test complete workflow with TIFF files.""" + tiff_file, _ = data_fixture + + # Test through main load function + result = load([str(tiff_file)], ".tiff") + + assert result.shape == (1, 3, 3) + assert np.array_equal(result[0], np.ones((3, 3))) + + def test_load_workflow_with_fits(self, data_fixture): + """Test complete workflow with FITS files.""" + _, fits_file = data_fixture + + # Test through main load function + result = load([str(fits_file)], ".fits") + + assert result.shape == (1, 3, 3) + assert np.array_equal(result[0], np.ones((3, 3))) + + @patch("pleiades.utils.load.read_tiff") + def test_load_tiff_memory_error(self, mock_read_tiff): + """Test handling of memory error during loading.""" + mock_read_tiff.side_effect = MemoryError("Out of memory") + + files = ["file1.tiff"] + + with pytest.raises(MemoryError, match="Out of memory"): + load_tiff(files) + + @patch("pleiades.utils.load.read_fits") + def test_load_fits_io_error(self, mock_read_fits): + """Test handling of IO error during loading.""" + mock_read_fits.side_effect = IOError("Cannot read file") + + files = ["file1.fits"] + + with pytest.raises(IOError, match="Cannot read file"): + load_fits(files) + + @patch("pleiades.utils.load.read_tiff") + def test_load_large_stack(self, mock_read_tiff): + """Test loading a large stack of images.""" + mock_image = np.ones((100, 100)) + mock_read_tiff.return_value = mock_image + + # Create list of 100 files + files = [f"file{i}.tiff" for i in range(100)] + result = load_tiff(files) + + assert result.shape == (100, 100, 100) + assert mock_read_tiff.call_count == 101 # First call for shape, then 100 for loading + + +# Keep the original tests for backward compatibility def test_load_tiff(data_fixture): - # Test loading tiff files + """Original test: loading tiff files with tifffile directly.""" generic_tiff, generic_fits = list(map(str, data_fixture)) # Load the tiff files @@ -38,7 +295,7 @@ def test_load_tiff(data_fixture): def test_load_fits(data_fixture): - # Test loading fits files + """Original test: loading fits files with astropy directly.""" generic_tiff, generic_fits = data_fixture # Load the fits files diff --git a/tests/unit/pleiades/utils/test_logger.py b/tests/unit/pleiades/utils/test_logger.py new file mode 100644 index 00000000..f27162c7 --- /dev/null +++ b/tests/unit/pleiades/utils/test_logger.py @@ -0,0 +1,486 @@ +"""Comprehensive unit tests for utils/logger.py module.""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from loguru import logger as loguru_logger + +from pleiades.utils.logger import ( + Logger, + _log_and_raise_error, + configure_logger, + get_logger, +) + + +class TestLogAndRaiseError: + """Test the _log_and_raise_error helper function.""" + + def test_log_and_raise_error_with_valueerror(self): + """Test _log_and_raise_error raises ValueError with message.""" + mock_logger = MagicMock() + message = "Test error message" + + with pytest.raises(ValueError, match=message): + _log_and_raise_error(mock_logger, message, ValueError) + + mock_logger.error.assert_called_once_with(message) + + def test_log_and_raise_error_with_runtimeerror(self): + """Test _log_and_raise_error raises RuntimeError with message.""" + mock_logger = MagicMock() + message = "Runtime error occurred" + + with pytest.raises(RuntimeError, match=message): + _log_and_raise_error(mock_logger, message, RuntimeError) + + mock_logger.error.assert_called_once_with(message) + + def test_log_and_raise_error_with_custom_exception(self): + """Test _log_and_raise_error with custom exception class.""" + + class CustomError(Exception): + pass + + mock_logger = MagicMock() + message = "Custom error message" + + with pytest.raises(CustomError, match=message): + _log_and_raise_error(mock_logger, message, CustomError) + + mock_logger.error.assert_called_once_with(message) + + +class TestConfigureLogger: + """Test the configure_logger function.""" + + @patch("pleiades.utils.logger.loguru_logger") + def test_configure_logger_default_settings(self, mock_loguru): + """Test configure_logger with default settings.""" + configure_logger() + + # Verify logger was cleared and reconfigured + mock_loguru.remove.assert_called() + assert mock_loguru.add.call_count >= 2 # Console and file handlers + + # Verify info message was logged + mock_loguru.info.assert_called() + info_call = mock_loguru.info.call_args[0][0] + assert "Logging configured" in info_call + + @patch("pleiades.utils.logger.loguru_logger") + def test_configure_logger_custom_levels(self, mock_loguru): + """Test configure_logger with custom logging levels.""" + configure_logger(console_level="INFO", file_level="ERROR") + + mock_loguru.remove.assert_called() + # Check that add was called with correct levels + add_calls = mock_loguru.add.call_args_list + assert len(add_calls) >= 2 + + # First call is console with INFO level + assert add_calls[0][1]["level"] == "INFO" + # Second call is file with ERROR level + assert add_calls[1][1]["level"] == "ERROR" + + @patch("pleiades.utils.logger.loguru_logger") + def test_configure_logger_custom_log_file(self, mock_loguru): + """Test configure_logger with custom log file.""" + with tempfile.TemporaryDirectory() as tmpdir: + custom_log = Path(tmpdir) / "custom.log" + configure_logger(log_file=custom_log) + + mock_loguru.remove.assert_called() + # Verify file handler was added with custom path + add_calls = mock_loguru.add.call_args_list + file_handler_call = add_calls[1] # Second call is file handler + assert file_handler_call[0][0] == custom_log + + @patch("pleiades.utils.logger.loguru_logger") + def test_configure_logger_relative_log_file(self, mock_loguru): + """Test configure_logger with relative log file path.""" + configure_logger(log_file="test.log") + + mock_loguru.remove.assert_called() + add_calls = mock_loguru.add.call_args_list + file_handler_call = add_calls[1] + log_path = file_handler_call[0][0] + + # Should be placed in logs directory + assert isinstance(log_path, Path) + assert log_path.name == "test.log" + + @patch("pleiades.utils.logger.loguru_logger") + def test_configure_logger_custom_format(self, mock_loguru): + """Test configure_logger with custom format string.""" + custom_format = "{time} - {level} - {message}" + configure_logger(format_string=custom_format) + + mock_loguru.remove.assert_called() + add_calls = mock_loguru.add.call_args_list + + # Both console and file should use custom format + assert add_calls[0][1]["format"] == custom_format + assert add_calls[1][1]["format"] == custom_format + + @patch("pleiades.utils.logger.loguru_logger") + def test_configure_logger_custom_rotation_retention(self, mock_loguru): + """Test configure_logger with custom rotation and retention.""" + configure_logger(rotation="100 MB", retention="90 days") + + mock_loguru.remove.assert_called() + add_calls = mock_loguru.add.call_args_list + file_handler_call = add_calls[1] + + assert file_handler_call[1]["rotation"] == "100 MB" + assert file_handler_call[1]["retention"] == "90 days" + + +class TestLoggerClass: + """Test the Logger class.""" + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_initialization_default(self, mock_loguru): + """Test Logger initialization with default settings.""" + logger = Logger(name="TestLogger") + + assert logger.name == "TestLogger" + assert logger.level == "DEBUG" + + # Verify initialization message was logged + mock_loguru.bind.assert_called_with(name="TestLogger") + mock_loguru.bind().info.assert_called_with("logging initialized...") + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_initialization_custom_level(self, mock_loguru): + """Test Logger initialization with custom level.""" + logger = Logger(name="TestLogger", level="ERROR") + + assert logger.name == "TestLogger" + assert logger.level == "ERROR" + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_initialization_with_log_file(self, mock_loguru): + """Test Logger initialization with custom log file.""" + with tempfile.TemporaryDirectory() as tmpdir: + log_file = Path(tmpdir) / "test.log" + logger = Logger(name="TestLogger", log_file=str(log_file)) + + # Verify add was called for the custom log file + add_calls = mock_loguru.add.call_args_list + # Find the call that adds the custom log file + found_custom_file = False + for call in add_calls: + if len(call[0]) > 0: + path = call[0][0] + if isinstance(path, Path) and path.name == "test.log": + found_custom_file = True + assert call[1]["level"] == "DEBUG" + break + assert found_custom_file + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_initialization_relative_log_file(self, mock_loguru): + """Test Logger initialization with relative log file path.""" + logger = Logger(name="TestLogger", log_file="relative.log") + + # Verify add was called for the log file + add_calls = mock_loguru.add.call_args_list + found_log_file = False + for call in add_calls: + if len(call[0]) > 0: + path = call[0][0] + if isinstance(path, Path) and path.name == "relative.log": + found_log_file = True + assert hasattr(logger, "log_file") + break + assert found_log_file + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_debug_method(self, mock_loguru): + """Test Logger.debug method.""" + logger = Logger(name="TestLogger") + mock_loguru.bind().info.reset_mock() # Reset after initialization + + message = "Debug message" + logger.debug(message) + + mock_loguru.bind.assert_called_with(name="TestLogger") + mock_loguru.bind().debug.assert_called_once_with(message) + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_info_method(self, mock_loguru): + """Test Logger.info method.""" + logger = Logger(name="TestLogger") + mock_loguru.bind().info.reset_mock() # Reset after initialization + + message = "Info message" + logger.info(message) + + mock_loguru.bind.assert_called_with(name="TestLogger") + mock_loguru.bind().info.assert_called_with(message) + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_warning_method(self, mock_loguru): + """Test Logger.warning method.""" + logger = Logger(name="TestLogger") + mock_loguru.bind().info.reset_mock() # Reset after initialization + + message = "Warning message" + logger.warning(message) + + mock_loguru.bind.assert_called_with(name="TestLogger") + mock_loguru.bind().warning.assert_called_once_with(message) + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_error_method(self, mock_loguru): + """Test Logger.error method.""" + logger = Logger(name="TestLogger") + mock_loguru.bind().info.reset_mock() # Reset after initialization + + message = "Error message" + logger.error(message) + + mock_loguru.bind.assert_called_with(name="TestLogger") + mock_loguru.bind().error.assert_called_once_with(message) + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_critical_method(self, mock_loguru): + """Test Logger.critical method.""" + logger = Logger(name="TestLogger") + mock_loguru.bind().info.reset_mock() # Reset after initialization + + message = "Critical message" + logger.critical(message) + + mock_loguru.bind.assert_called_with(name="TestLogger") + mock_loguru.bind().critical.assert_called_once_with(message) + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_multiple_messages(self, mock_loguru): + """Test Logger with multiple messages of different levels.""" + logger = Logger(name="TestLogger") + mock_loguru.bind().info.reset_mock() # Reset after initialization + + logger.debug("Debug 1") + logger.info("Info 1") + logger.warning("Warning 1") + logger.error("Error 1") + logger.critical("Critical 1") + + # Verify all methods were called + mock_loguru.bind().debug.assert_called_with("Debug 1") + mock_loguru.bind().info.assert_called_with("Info 1") + mock_loguru.bind().warning.assert_called_with("Warning 1") + mock_loguru.bind().error.assert_called_with("Error 1") + mock_loguru.bind().critical.assert_called_with("Critical 1") + + +class TestModuleLevel: + """Test module-level functionality.""" + + def test_logs_directory_creation(self): + """Test that logs directory is created on module import.""" + # The module creates a pleiades_logs directory in cwd + logs_dir = Path(os.getcwd()) / "pleiades_logs" + assert logs_dir.exists() + assert logs_dir.is_dir() + + def test_get_logger_alias(self): + """Test that get_logger is properly aliased to loguru_logger.bind.""" + assert get_logger == loguru_logger.bind + assert callable(get_logger) + + @patch("pleiades.utils.logger.sys.stderr") + def test_default_console_handler(self, mock_stderr): + """Test that default console handler is configured.""" + # Import fresh to trigger module-level initialization + + import pleiades.utils.logger + + # The module should have added handlers during import + # This is hard to test directly due to loguru's internal structure + # But we can verify the module imported successfully + assert hasattr(pleiades.utils.logger, "loguru_logger") + assert hasattr(pleiades.utils.logger, "configure_logger") + + def test_default_log_filename_format(self): + """Test that default log filename follows expected format.""" + + from pleiades.utils.logger import default_log_filename + + # Should follow format: pleiades_YYYYMMDD_HH.log + assert default_log_filename.startswith("pleiades_") + assert default_log_filename.endswith(".log") + + # Check date format (should be YYYYMMDD_HH) + date_part = default_log_filename.replace("pleiades_", "").replace(".log", "") + assert len(date_part) == 11 # YYYYMMDD_HH + assert "_" in date_part + + +class TestIntegrationScenarios: + """Test integration scenarios.""" + + @patch("pleiades.utils.logger.loguru_logger") + def test_multiple_loggers_different_names(self, mock_loguru): + """Test creating multiple Logger instances with different names.""" + logger1 = Logger(name="Logger1") + logger2 = Logger(name="Logger2") + + logger1.info("Message from logger1") + logger2.info("Message from logger2") + + # Verify bind was called with different names + bind_calls = mock_loguru.bind.call_args_list + logger_names = [call[1]["name"] if call[1] else call[0][0].get("name") for call in bind_calls if call] + assert "Logger1" in logger_names + assert "Logger2" in logger_names + + @patch("pleiades.utils.logger.loguru_logger") + def test_configure_then_create_logger(self, mock_loguru): + """Test configuring logger globally then creating Logger instance.""" + configure_logger(console_level="WARNING", file_level="ERROR") + logger = Logger(name="TestLogger", level="INFO") + + # Verify configure was called first + assert mock_loguru.remove.called + assert mock_loguru.add.called + + # Logger should still work + logger.info("Test message") + mock_loguru.bind().info.assert_called() + + def test_logger_with_special_characters_in_name(self): + """Test Logger with special characters in name.""" + special_names = [ + "Logger-With-Dashes", + "Logger_With_Underscores", + "Logger.With.Dots", + "Logger::With::Colons", + "Logger/With/Slashes", + ] + + for name in special_names: + logger = Logger(name=name) + assert logger.name == name + # Should not raise any errors + logger.info(f"Test from {name}") + + @patch("pleiades.utils.logger.loguru_logger") + def test_exception_in_logging_method(self, mock_loguru): + """Test handling when logging method raises an exception.""" + logger = Logger(name="TestLogger") + + # Make the bind().error method raise an exception + mock_loguru.bind().error.side_effect = RuntimeError("Logging failed") + + # Should raise the exception (logger doesn't catch exceptions) + with pytest.raises(RuntimeError, match="Logging failed"): + logger.error("This will fail") + + @patch("pleiades.utils.logger.Path.mkdir") + def test_logs_directory_creation_failure(self, mock_mkdir): + """Test handling when logs directory creation fails.""" + mock_mkdir.side_effect = PermissionError("Cannot create directory") + + # Re-import module to trigger directory creation + import importlib + + # This should not prevent module import + try: + importlib.reload(importlib.import_module("pleiades.utils.logger")) + except PermissionError: + # Expected if directory creation fails + pass + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_empty_name(self, mock_loguru): + """Test Logger with empty string as name.""" + logger = Logger(name="") + assert logger.name == "" + + logger.info("Message with empty name") + mock_loguru.bind.assert_called_with(name="") + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_none_name(self, mock_loguru): + """Test Logger with None as name.""" + # Logger actually accepts None as name (converts to string) + logger = Logger(name=None) + assert logger.name is None + + logger.info("Message with None name") + mock_loguru.bind.assert_called_with(name=None) + + @patch("pleiades.utils.logger.loguru_logger") + def test_configure_logger_invalid_level(self, mock_loguru): + """Test configure_logger with invalid logging level.""" + # Loguru might handle this internally, but test the call + configure_logger(console_level="INVALID_LEVEL") + + # Should still call remove and add + mock_loguru.remove.assert_called() + assert mock_loguru.add.called + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_very_long_message(self, mock_loguru): + """Test logging very long messages.""" + logger = Logger(name="TestLogger") + mock_loguru.bind().info.reset_mock() + + # Create a very long message + long_message = "x" * 10000 + logger.info(long_message) + + mock_loguru.bind().info.assert_called_with(long_message) + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_unicode_messages(self, mock_loguru): + """Test logging messages with unicode characters.""" + logger = Logger(name="TestLogger") + mock_loguru.bind().info.reset_mock() + + unicode_messages = [ + "Message with emoji 😀", + "Message with Chinese 中文", + "Message with symbols ♠♣♥♦", + "Message with math ∑∫∂∇", + ] + + for msg in unicode_messages: + logger.info(msg) + mock_loguru.bind().info.assert_called_with(msg) + + @patch("pleiades.utils.logger.loguru_logger") + def test_logger_with_same_log_file_as_default(self, mock_loguru): + """Test Logger when log_file matches default path.""" + from pleiades.utils.logger import default_log_path + + logger = Logger(name="TestLogger", log_file=str(default_log_path)) + + # When log_file equals default_log_path, it's handled by the condition + # on line 127: if log_file and log_file != str(default_log_path) + # So no additional handler should be added + add_calls = [call for call in mock_loguru.add.call_args_list if call] + + # The Logger class checks if log_file != str(default_log_path) before adding + # So if they match, no new handler is added + # Check that the log_file attribute is not set when it matches default + if hasattr(logger, "log_file"): + # If log_file attribute is set, it should not be the default path + assert logger.log_file != default_log_path + else: + # If they match, log_file attribute shouldn't be set + assert not hasattr(logger, "log_file") + + +if __name__ == "__main__": + pytest.main(["-v", __file__])