From b4f927f7da500a2a3442c5b8afff3fc669b40b36 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 12:33:11 -0400 Subject: [PATCH 01/30] ADD the qc module: figures that publish their own numbers Every figure writes three files under one stem -- the PDF at 300 dpi, the numbers it was drawn from, and its caption -- so a reader can find the number behind a claim without rerunning anything. Covers the knee plot, the gene classes (mito by contig, ribosomal split from its pseudogenes, the readout panel), per-cell fractions over scanpy, a UMI floor, and MAD thresholds read off the channel's own spread. The mudata builder gains --readout-genes, so a targeted panel marks its own genes in gene.var and the targeted fraction is computable. Co-Authored-By: Claude Opus 5 --- pixi.lock | 1123 +++++++++++++++++ pyproject.toml | 6 + .../cli/mudata_builder.py | 9 + src/crispr_analysis_utils/mudata_builder.py | 68 +- src/crispr_analysis_utils/qc.py | 394 ++++++ .../skills/cau-qc/SKILL.md | 118 ++ tests/test_qc.py | 147 +++ tests/test_skills.py | 1 + uv.lock | 867 ++++++++++++- 9 files changed, 2727 insertions(+), 6 deletions(-) create mode 100644 src/crispr_analysis_utils/qc.py create mode 100644 src/crispr_analysis_utils/skills/cau-qc/SKILL.md create mode 100644 tests/test_qc.py diff --git a/pixi.lock b/pixi.lock index 8661dcb..cec533b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -60,14 +60,23 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ + - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/d0/e4479b1916f81b3be32d2edc73e7ddf26958af894dc04cbeccfd1ea0af49/scverse_misc-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/23/e5/688703d357e5393f708d98eb189fd415ae69e39f6de03c6bd4005aef6118/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/35/ba/02b95fafb87b1ff6a4f6a79b06d70bbaf4657f86e95b993ac10104a8f804/pysam-0.24.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/37/62/20edad100b93552ec5c172e509a9db898a73d5043ae701fcb6e9986f9d33/wrapt-2.5.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/64/f33fdfe95aca76601194a7064d14816c7c22c4eccc1b03a5335785895fa3/msgspec-0.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/d6/e95834b29360092376fe4da9956ba41bb7b021869efe6ee9d4172d05cb15/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_x86_64.whl @@ -78,20 +87,38 @@ environments: - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/66/4f/f31be46f6fea9773cf15c6848ee1ea70b1c5c5ab14ce32de7793129d4c3f/ruranges-0.2.7-cp313-cp313-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/41/b5af3c12d874fcbdb45abd67500aff8030de0d350a5d6163da45378609c4/session_info2-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/96/b3bf9a31978d936654a73f2bb1036b92b6515164f170092d162419eb771c/numcodecs-0.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a8/26/939ae9874dd44116f2ecf61cb0caf029e3004ec1ed311a86389dee3450be/fonttools-4.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/aa/b1/0b504ae34d1b79a6482a0ffcbfd1b103dde02329c11525033e02633f7984/numba-0.68.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/d0/13860e6c8a06bb3fccc87e38966ed8ae98535aa44613b5fba0329ead0327/mudata-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ed/2a/f9da79cc98b37846e9e34bb8b093e7810b73d07561a736c9dbc2fb03927c/gtfreader-0.3.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ed/c2/5e9d0773f1589397a3ea3dcfa4bbee36e2855ad938d738dd6ff9f505a59b/llvmlite-0.50.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/08/c70419394e01b65309d4136942fe78d9d31cc14ab9b2074f0d42c4c2d2e1/pyranges1-1.4.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/b6/e76e80fed5f2558273c7839e622f98095c9b36c719c7147e38e3c055cb70/google_crc32c-1.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda @@ -116,39 +143,66 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/rust-1.98.1-h5655b98_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - pypi: ./ + - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/11/c5/907cec40688a34eb489cded74d555e1ee4af8cf49d83e03dba2c2d4cfe27/llvmlite-0.50.0.tar.gz - pypi: https://files.pythonhosted.org/packages/11/f1/1d3d2bcb1240e5000f6647b5b0fd465b2b51ecef180bfa797a85df48cf2f/numcodecs-0.17.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/17/d0/e4479b1916f81b3be32d2edc73e7ddf26958af894dc04cbeccfd1ea0af49/scverse_misc-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/c3/e8a250064a2cce96ca939ac7e93c0a2bd1ede609b3bafd6a5db92e5e6686/ruranges-0.2.7-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/40/dd/24b130d14e32aab94d98c0d4455464e597dbbba8d3a8371fcbf9b4d90f65/pysam-0.24.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/4e/cd/e8280f9ffa30fea9fabc5341223701231fcc5d53a31f51419d42d4bec3a6/numba-0.68.0.tar.gz - pypi: https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/23/f1b1af76bc5a1845d5fcad297e4c460223a7670f1ca15e167d7dcf32aaab/anndata-0.13.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ef/b1a675f79c9babfd4fcd99af62141d3c2d1a78a524e311b0c6b80110445a/pyarrow-26.0.0-cp313-cp313-macosx_12_0_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/62/5374fba2ede0408f4bd8b9b3a6c8464f8d0ea7ae9a2a064bd81ca492bd1e/msgspec-0.22.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/1c/143605a1f6443ad50ebda78a31e5a3a10147fec2590e931584aaa5ff0a09/pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/41/b5af3c12d874fcbdb45abd67500aff8030de0d350a5d6163da45378609c4/session_info2-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/01/135abc57ed3e5dc5325411d8603457e41965ccaf4fcdec5d62b810a25ead/gtfreader-0.3.0.tar.gz + - pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/af/25/3e8e567bd48448e225ea27318ccf2b94e05124e7b8b97b13eaec9e127199/google_crc32c-1.9.0-cp313-cp313-macosx_12_0_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b7/8b/62a6eacea08ea0b6ab0159a7783df7ed421ff9c1ff58fef7c31f58fa5e52/contourpy-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bb/8d/b60d5e7354ff0ff5cc9400e60273696589d87a30b8b2235886a76d80d062/scikit_learn-1.9.1-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bf/ce/fc58855a3d17f6cb8a87b6f9ccd100f65432a3a7ffed57309cbf14c78987/matplotlib-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/6d/cfe55762435f36107815d56a2cfbebe7e3129b593c47a670c6eb1d7917d3/wrapt-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/d0/13860e6c8a06bb3fccc87e38966ed8ae98535aa44613b5fba0329ead0327/mudata-0.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/36/04d74f0c71d93829657a703d680a54968253bbb5c93babc34378eae2087a/fonttools-4.67.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/08/c70419394e01b65309d4136942fe78d9d31cc14ab9b2074f0d42c4c2d2e1/pyranges1-1.4.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda @@ -172,13 +226,26 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.98.1-h4ff7c5d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - pypi: ./ + - pypi: https://files.pythonhosted.org/packages/01/b9/41642877fe741db56d240833c8822188b663c4c5d52beb087964774035d4/wrapt-2.5.1-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/17/d0/e4479b1916f81b3be32d2edc73e7ddf26958af894dc04cbeccfd1ea0af49/scverse_misc-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/2f/81/3c6392c03665d2899457a76e535a9a6f597dddddf3220fd2e1d790da88c5/scikit_learn-1.9.1-cp313-cp313-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/30/b4/cd473e0a48427003733e92bc3e8077081ba537eb33f7c658f2b7bef63776/fonttools-4.67.0-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3f/2f/103cdc5b7077cd569989d3ddb99ee3d4e96ee31bde8df2a6e426b2dc6195/contourpy-1.4.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3f/34/cb484e8b6174f130f8c6dc79c733a9dd8869b410ad6511fb6104c46b973a/google_crc32c-1.9.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4d/35/ca95493712af97c46a312945c8e9d16b21c5fe2f148be5466168d0290505/pyarrow-26.0.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl @@ -188,23 +255,37 @@ environments: - pypi: https://files.pythonhosted.org/packages/64/5d/4c9752e034e9f0e27477efd68273d2835eedcffdb6b19d9a91c427773baa/pysam-0.24.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/64/81/64e2472a8b3a9fa26bccfc7d5fa876770a9027bd5cd77e5b4a7b807a0785/numcodecs-0.17.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/41/b5af3c12d874fcbdb45abd67500aff8030de0d350a5d6163da45378609c4/session_info2-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/01/135abc57ed3e5dc5325411d8603457e41965ccaf4fcdec5d62b810a25ead/gtfreader-0.3.0.tar.gz + - pypi: https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/4d/42754c94f8f909b9981fd44d28292a93bca6429d93f3e1ae58ac7de9b08b/numba-0.68.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a9/29/2a47b700545aca050e0ed73c89ba21e08407e11f5f42a567421b220de6c1/matplotlib-3.11.2-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/1f/1d585b2122bcc9fe1615c0097730baebdef1b80e6acd07fe921ee501576b/llvmlite-0.50.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ca/e8/78c5f74205e582521f8dcf7758702726403bb268524f9769652596de36ee/ruranges-0.2.7-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cc/e3/357baa8d2a9164a98dfd7ef9d3a58125df0ed981be909945bdd337be7194/msgspec-0.22.0-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ea/ca/87f8548f73d452aab35e4a90f8b39ae303295e0f2ef0b4055c44d6b3f1be/pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/d0/13860e6c8a06bb3fccc87e38966ed8ae98535aa44613b5fba0329ead0327/mudata-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/08/c70419394e01b65309d4136942fe78d9d31cc14ab9b2074f0d42c4c2d2e1/pyranges1-1.4.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl dev: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -249,16 +330,25 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/d0/e4479b1916f81b3be32d2edc73e7ddf26958af894dc04cbeccfd1ea0af49/scverse_misc-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/23/e5/688703d357e5393f708d98eb189fd415ae69e39f6de03c6bd4005aef6118/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/35/ba/02b95fafb87b1ff6a4f6a79b06d70bbaf4657f86e95b993ac10104a8f804/pysam-0.24.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/37/62/20edad100b93552ec5c172e509a9db898a73d5043ae701fcb6e9986f9d33/wrapt-2.5.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/64/f33fdfe95aca76601194a7064d14816c7c22c4eccc1b03a5335785895fa3/msgspec-0.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl @@ -273,26 +363,44 @@ environments: - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/66/4f/f31be46f6fea9773cf15c6848ee1ea70b1c5c5ab14ce32de7793129d4c3f/ruranges-0.2.7-cp313-cp313-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/41/b5af3c12d874fcbdb45abd67500aff8030de0d350a5d6163da45378609c4/session_info2-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/96/b3bf9a31978d936654a73f2bb1036b92b6515164f170092d162419eb771c/numcodecs-0.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a8/26/939ae9874dd44116f2ecf61cb0caf029e3004ec1ed311a86389dee3450be/fonttools-4.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/aa/b1/0b504ae34d1b79a6482a0ffcbfd1b103dde02329c11525033e02633f7984/numba-0.68.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/d0/13860e6c8a06bb3fccc87e38966ed8ae98535aa44613b5fba0329ead0327/mudata-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ed/2a/f9da79cc98b37846e9e34bb8b093e7810b73d07561a736c9dbc2fb03927c/gtfreader-0.3.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ed/c2/5e9d0773f1589397a3ea3dcfa4bbee36e2855ad938d738dd6ff9f505a59b/llvmlite-0.50.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/08/c70419394e01b65309d4136942fe78d9d31cc14ab9b2074f0d42c4c2d2e1/pyranges1-1.4.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/b6/e76e80fed5f2558273c7839e622f98095c9b36c719c7147e38e3c055cb70/google_crc32c-1.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda @@ -318,51 +426,78 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/11/c5/907cec40688a34eb489cded74d555e1ee4af8cf49d83e03dba2c2d4cfe27/llvmlite-0.50.0.tar.gz - pypi: https://files.pythonhosted.org/packages/11/f1/1d3d2bcb1240e5000f6647b5b0fd465b2b51ecef180bfa797a85df48cf2f/numcodecs-0.17.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/d0/e4479b1916f81b3be32d2edc73e7ddf26958af894dc04cbeccfd1ea0af49/scverse_misc-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/c3/e8a250064a2cce96ca939ac7e93c0a2bd1ede609b3bafd6a5db92e5e6686/ruranges-0.2.7-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/40/dd/24b130d14e32aab94d98c0d4455464e597dbbba8d3a8371fcbf9b4d90f65/pysam-0.24.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/4e/cd/e8280f9ffa30fea9fabc5341223701231fcc5d53a31f51419d42d4bec3a6/numba-0.68.0.tar.gz - pypi: https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/23/f1b1af76bc5a1845d5fcad297e4c460223a7670f1ca15e167d7dcf32aaab/anndata-0.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ef/b1a675f79c9babfd4fcd99af62141d3c2d1a78a524e311b0c6b80110445a/pyarrow-26.0.0-cp313-cp313-macosx_12_0_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/62/5374fba2ede0408f4bd8b9b3a6c8464f8d0ea7ae9a2a064bd81ca492bd1e/msgspec-0.22.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/1c/143605a1f6443ad50ebda78a31e5a3a10147fec2590e931584aaa5ff0a09/pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/41/b5af3c12d874fcbdb45abd67500aff8030de0d350a5d6163da45378609c4/session_info2-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/01/135abc57ed3e5dc5325411d8603457e41965ccaf4fcdec5d62b810a25ead/gtfreader-0.3.0.tar.gz + - pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/af/25/3e8e567bd48448e225ea27318ccf2b94e05124e7b8b97b13eaec9e127199/google_crc32c-1.9.0-cp313-cp313-macosx_12_0_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b7/8b/62a6eacea08ea0b6ab0159a7783df7ed421ff9c1ff58fef7c31f58fa5e52/contourpy-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bb/8d/b60d5e7354ff0ff5cc9400e60273696589d87a30b8b2235886a76d80d062/scikit_learn-1.9.1-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bf/ce/fc58855a3d17f6cb8a87b6f9ccd100f65432a3a7ffed57309cbf14c78987/matplotlib-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/6d/cfe55762435f36107815d56a2cfbebe7e3129b593c47a670c6eb1d7917d3/wrapt-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/d0/13860e6c8a06bb3fccc87e38966ed8ae98535aa44613b5fba0329ead0327/mudata-0.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/36/04d74f0c71d93829657a703d680a54968253bbb5c93babc34378eae2087a/fonttools-4.67.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/08/c70419394e01b65309d4136942fe78d9d31cc14ab9b2074f0d42c4c2d2e1/pyranges1-1.4.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda @@ -386,17 +521,30 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.98.1-h4ff7c5d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - pypi: ./ + - pypi: https://files.pythonhosted.org/packages/01/b9/41642877fe741db56d240833c8822188b663c4c5d52beb087964774035d4/wrapt-2.5.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/d0/e4479b1916f81b3be32d2edc73e7ddf26958af894dc04cbeccfd1ea0af49/scverse_misc-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/2f/81/3c6392c03665d2899457a76e535a9a6f597dddddf3220fd2e1d790da88c5/scikit_learn-1.9.1-cp313-cp313-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/30/b4/cd473e0a48427003733e92bc3e8077081ba537eb33f7c658f2b7bef63776/fonttools-4.67.0-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3f/2f/103cdc5b7077cd569989d3ddb99ee3d4e96ee31bde8df2a6e426b2dc6195/contourpy-1.4.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3f/34/cb484e8b6174f130f8c6dc79c733a9dd8869b410ad6511fb6104c46b973a/google_crc32c-1.9.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4d/35/ca95493712af97c46a312945c8e9d16b21c5fe2f148be5466168d0290505/pyarrow-26.0.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl @@ -409,22 +557,35 @@ environments: - pypi: https://files.pythonhosted.org/packages/64/5d/4c9752e034e9f0e27477efd68273d2835eedcffdb6b19d9a91c427773baa/pysam-0.24.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/64/81/64e2472a8b3a9fa26bccfc7d5fa876770a9027bd5cd77e5b4a7b807a0785/numcodecs-0.17.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/41/b5af3c12d874fcbdb45abd67500aff8030de0d350a5d6163da45378609c4/session_info2-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/01/135abc57ed3e5dc5325411d8603457e41965ccaf4fcdec5d62b810a25ead/gtfreader-0.3.0.tar.gz + - pypi: https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/4d/42754c94f8f909b9981fd44d28292a93bca6429d93f3e1ae58ac7de9b08b/numba-0.68.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a9/29/2a47b700545aca050e0ed73c89ba21e08407e11f5f42a567421b220de6c1/matplotlib-3.11.2-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/1f/1d585b2122bcc9fe1615c0097730baebdef1b80e6acd07fe921ee501576b/llvmlite-0.50.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/1f/e5182163bcee149dda4eec823d88b0f51f2c0c531b12da896f666f0b80c1/virtualenv-21.14.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/e8/78c5f74205e582521f8dcf7758702726403bb268524f9769652596de36ee/ruranges-0.2.7-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cc/e3/357baa8d2a9164a98dfd7ef9d3a58125df0ed981be909945bdd337be7194/msgspec-0.22.0-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ea/ca/87f8548f73d452aab35e4a90f8b39ae303295e0f2ef0b4055c44d6b3f1be/pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/d0/13860e6c8a06bb3fccc87e38966ed8ae98535aa44613b5fba0329ead0327/mudata-0.4.1-py3-none-any.whl @@ -432,6 +593,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f7/08/c70419394e01b65309d4136942fe78d9d31cc14ab9b2074f0d42c4c2d2e1/pyranges1-1.4.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl docs: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -480,16 +642,24 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/d0/e4479b1916f81b3be32d2edc73e7ddf26958af894dc04cbeccfd1ea0af49/scverse_misc-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/23/e5/688703d357e5393f708d98eb189fd415ae69e39f6de03c6bd4005aef6118/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/35/ba/02b95fafb87b1ff6a4f6a79b06d70bbaf4657f86e95b993ac10104a8f804/pysam-0.24.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/37/62/20edad100b93552ec5c172e509a9db898a73d5043ae701fcb6e9986f9d33/wrapt-2.5.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/64/f33fdfe95aca76601194a7064d14816c7c22c4eccc1b03a5335785895fa3/msgspec-0.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -508,11 +678,16 @@ environments: - pypi: https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/66/4f/f31be46f6fea9773cf15c6848ee1ea70b1c5c5ab14ce32de7793129d4c3f/ruranges-0.2.7-cp313-cp313-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl @@ -521,21 +696,34 @@ environments: - pypi: https://files.pythonhosted.org/packages/9b/41/b5af3c12d874fcbdb45abd67500aff8030de0d350a5d6163da45378609c4/session_info2-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/96/b3bf9a31978d936654a73f2bb1036b92b6515164f170092d162419eb771c/numcodecs-0.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a8/26/939ae9874dd44116f2ecf61cb0caf029e3004ec1ed311a86389dee3450be/fonttools-4.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/aa/b1/0b504ae34d1b79a6482a0ffcbfd1b103dde02329c11525033e02633f7984/numba-0.68.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/d0/13860e6c8a06bb3fccc87e38966ed8ae98535aa44613b5fba0329ead0327/mudata-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ed/2a/f9da79cc98b37846e9e34bb8b093e7810b73d07561a736c9dbc2fb03927c/gtfreader-0.3.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ed/c2/5e9d0773f1589397a3ea3dcfa4bbee36e2855ad938d738dd6ff9f505a59b/llvmlite-0.50.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/08/c70419394e01b65309d4136942fe78d9d31cc14ab9b2074f0d42c4c2d2e1/pyranges1-1.4.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/b6/e76e80fed5f2558273c7839e622f98095c9b36c719c7147e38e3c055cb70/google_crc32c-1.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda @@ -565,22 +753,32 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/11/c5/907cec40688a34eb489cded74d555e1ee4af8cf49d83e03dba2c2d4cfe27/llvmlite-0.50.0.tar.gz - pypi: https://files.pythonhosted.org/packages/11/f1/1d3d2bcb1240e5000f6647b5b0fd465b2b51ecef180bfa797a85df48cf2f/numcodecs-0.17.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/17/d0/e4479b1916f81b3be32d2edc73e7ddf26958af894dc04cbeccfd1ea0af49/scverse_misc-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/c3/e8a250064a2cce96ca939ac7e93c0a2bd1ede609b3bafd6a5db92e5e6686/ruranges-0.2.7-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/40/dd/24b130d14e32aab94d98c0d4455464e597dbbba8d3a8371fcbf9b4d90f65/pysam-0.24.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/4e/cd/e8280f9ffa30fea9fabc5341223701231fcc5d53a31f51419d42d4bec3a6/numba-0.68.0.tar.gz - pypi: https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/23/f1b1af76bc5a1845d5fcad297e4c460223a7670f1ca15e167d7dcf32aaab/anndata-0.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl @@ -588,13 +786,18 @@ environments: - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ef/b1a675f79c9babfd4fcd99af62141d3c2d1a78a524e311b0c6b80110445a/pyarrow-26.0.0-cp313-cp313-macosx_12_0_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/62/5374fba2ede0408f4bd8b9b3a6c8464f8d0ea7ae9a2a064bd81ca492bd1e/msgspec-0.22.0-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/1c/143605a1f6443ad50ebda78a31e5a3a10147fec2590e931584aaa5ff0a09/pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl @@ -605,23 +808,34 @@ environments: - pypi: https://files.pythonhosted.org/packages/9b/41/b5af3c12d874fcbdb45abd67500aff8030de0d350a5d6163da45378609c4/session_info2-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/01/135abc57ed3e5dc5325411d8603457e41965ccaf4fcdec5d62b810a25ead/gtfreader-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/af/25/3e8e567bd48448e225ea27318ccf2b94e05124e7b8b97b13eaec9e127199/google_crc32c-1.9.0-cp313-cp313-macosx_12_0_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b7/8b/62a6eacea08ea0b6ab0159a7783df7ed421ff9c1ff58fef7c31f58fa5e52/contourpy-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bb/8d/b60d5e7354ff0ff5cc9400e60273696589d87a30b8b2235886a76d80d062/scikit_learn-1.9.1-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bf/ce/fc58855a3d17f6cb8a87b6f9ccd100f65432a3a7ffed57309cbf14c78987/matplotlib-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/6d/cfe55762435f36107815d56a2cfbebe7e3129b593c47a670c6eb1d7917d3/wrapt-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/d0/13860e6c8a06bb3fccc87e38966ed8ae98535aa44613b5fba0329ead0327/mudata-0.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/36/04d74f0c71d93829657a703d680a54968253bbb5c93babc34378eae2087a/fonttools-4.67.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/08/c70419394e01b65309d4136942fe78d9d31cc14ab9b2074f0d42c4c2d2e1/pyranges1-1.4.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda @@ -645,20 +859,32 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.98.1-h4ff7c5d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - pypi: ./ + - pypi: https://files.pythonhosted.org/packages/01/b9/41642877fe741db56d240833c8822188b663c4c5d52beb087964774035d4/wrapt-2.5.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/e5/0ae74c83c1cab2c14daadf28bd0143eb14bb81503834af987b66badc7b61/griffelib-2.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/17/d0/e4479b1916f81b3be32d2edc73e7ddf26958af894dc04cbeccfd1ea0af49/scverse_misc-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/2f/81/3c6392c03665d2899457a76e535a9a6f597dddddf3220fd2e1d790da88c5/scikit_learn-1.9.1-cp313-cp313-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/30/b4/cd473e0a48427003733e92bc3e8077081ba537eb33f7c658f2b7bef63776/fonttools-4.67.0-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3f/2f/103cdc5b7077cd569989d3ddb99ee3d4e96ee31bde8df2a6e426b2dc6195/contourpy-1.4.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3f/34/cb484e8b6174f130f8c6dc79c733a9dd8869b410ad6511fb6104c46b973a/google_crc32c-1.9.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4d/35/ca95493712af97c46a312945c8e9d16b21c5fe2f148be5466168d0290505/pyarrow-26.0.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl @@ -674,10 +900,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/64/5d/4c9752e034e9f0e27477efd68273d2835eedcffdb6b19d9a91c427773baa/pysam-0.24.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/64/81/64e2472a8b3a9fa26bccfc7d5fa876770a9027bd5cd77e5b4a7b807a0785/numcodecs-0.17.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl @@ -686,17 +916,26 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/41/b5af3c12d874fcbdb45abd67500aff8030de0d350a5d6163da45378609c4/session_info2-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/01/135abc57ed3e5dc5325411d8603457e41965ccaf4fcdec5d62b810a25ead/gtfreader-0.3.0.tar.gz + - pypi: https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/4d/42754c94f8f909b9981fd44d28292a93bca6429d93f3e1ae58ac7de9b08b/numba-0.68.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a9/29/2a47b700545aca050e0ed73c89ba21e08407e11f5f42a567421b220de6c1/matplotlib-3.11.2-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/1f/1d585b2122bcc9fe1615c0097730baebdef1b80e6acd07fe921ee501576b/llvmlite-0.50.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ca/e8/78c5f74205e582521f8dcf7758702726403bb268524f9769652596de36ee/ruranges-0.2.7-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cc/e3/357baa8d2a9164a98dfd7ef9d3a58125df0ed981be909945bdd337be7194/msgspec-0.22.0-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ea/ca/87f8548f73d452aab35e4a90f8b39ae303295e0f2ef0b4055c44d6b3f1be/pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl @@ -706,6 +945,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f7/08/c70419394e01b65309d4136942fe78d9d31cc14ab9b2074f0d42c4c2d2e1/pyranges1-1.4.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda @@ -1704,7 +1944,17 @@ packages: - pyarrow>=16 - anndata>=0.10 - mudata>=0.3 + - matplotlib>=3.8 + - scanpy>=1.10 requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/01/b9/41642877fe741db56d240833c8822188b663c4c5d52beb087964774035d4/wrapt-2.5.1-cp313-cp313-macosx_11_0_arm64.whl + name: wrapt + version: 2.5.1 + sha256: ab6db7d2a18d366cc57c2228253cf26443190aba0a6dd0939b3c1e8ac6e29e2c + requires_dist: + - pytest ; extra == 'dev' + - setuptools ; extra == 'dev' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl name: distlib version: 0.4.3 @@ -1750,6 +2000,42 @@ packages: requires_dist: - numpy>=1.21.2 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl + name: pillow + version: 12.3.0 + sha256: d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 + requires_dist: + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - setuptools ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/11/c5/907cec40688a34eb489cded74d555e1ee4af8cf49d83e03dba2c2d4cfe27/llvmlite-0.50.0.tar.gz + name: llvmlite + version: 0.50.0 + sha256: f2a2cd6ec9ffcc1b7147dea0d7a49efebf17a2b434e0c2844fe175999d571eb4 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/11/f1/1d3d2bcb1240e5000f6647b5b0fd465b2b51ecef180bfa797a85df48cf2f/numcodecs-0.17.0-cp313-cp313-macosx_10_13_x86_64.whl name: numcodecs version: 0.17.0 @@ -1799,6 +2085,60 @@ packages: - scverse-doc ; extra == 'sphinx' - sphinx>=9 ; extra == 'sphinx' requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl + name: joblib + version: 1.6.0 + sha256: 3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba + requires_dist: + - cloudpickle>=3.0 + - distributed ; extra == 'test' + - lz4 ; extra == 'test' + - numpy ; extra == 'test' + - memory-profiler ; extra == 'test' + - pytest ; extra == 'test' + - pytest-asyncio ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-run-parallel ; extra == 'test' + - pytest-timeout ; extra == 'test' + - threadpoolctl ; extra == 'test' + - distributed ; extra == 'docs' + - lz4 ; extra == 'docs' + - numpy ; extra == 'docs' + - numpydoc ; extra == 'docs' + - matplotlib ; extra == 'docs' + - pandas ; extra == 'docs' + - psutil ; extra == 'docs' + - sphinx ; extra == 'docs' + - pydata-sphinx-theme ; extra == 'docs' + - sphinx-gallery ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-design ; extra == 'docs' + - tqdm ; extra == 'docs' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl + name: umap-learn + version: 0.5.12 + sha256: f2a85d2a2adcb52b541bed9b27a23ca169b56bb1b23283abeebfb8dfb8a42fe5 + requires_dist: + - numpy>=1.23 + - scipy>=1.3.1 + - scikit-learn>=1.6 + - numba>=0.51.2 + - pynndescent>=0.5 + - tqdm + - pandas ; extra == 'plot' + - matplotlib ; extra == 'plot' + - datashader ; extra == 'plot' + - bokeh ; extra == 'plot' + - holoviews ; extra == 'plot' + - colorcet ; extra == 'plot' + - seaborn ; extra == 'plot' + - scikit-image ; extra == 'plot' + - dask ; extra == 'plot' + - tensorflow>=2.1 ; extra == 'parametric-umap' + - tbb>=2019.0 ; extra == 'tbb' + - pytest ; extra == 'test' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: pydantic-core version: 2.50.1 @@ -1806,6 +2146,17 @@ packages: requires_dist: - typing-extensions>=4.16.0 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl + name: patsy + version: 1.0.3 + sha256: d3dbebe8fd5f46e29912d030b63c6268647b59bf788a99e2af28a30234cf357c + requires_dist: + - numpy>=1.4 + - packaging + - pytest ; extra == 'test' + - pytest-cov ; extra == 'test' + - scipy ; extra == 'test' + requires_python: '>=3.6' - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl name: mkdocs version: 1.6.1 @@ -1842,6 +2193,17 @@ packages: - pyyaml==5.1 ; extra == 'min-versions' - watchdog==2.0 ; extra == 'min-versions' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/23/e5/688703d357e5393f708d98eb189fd415ae69e39f6de03c6bd4005aef6118/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: scikit-learn + version: 1.9.1 + sha256: 55e79d6e9b0923f1a978179822bd43d7f5543f45e970a00fe861f43486380aba + requires_dist: + - numpy>=1.24.1 + - scipy>=1.10.0 + - joblib>=1.4.0 + - narwhals>=2.0.1 + - threadpoolctl>=3.5.0 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl name: pytest version: 9.1.1 @@ -1927,6 +2289,51 @@ packages: version: 2.5.3 sha256: 92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/2f/81/3c6392c03665d2899457a76e535a9a6f597dddddf3220fd2e1d790da88c5/scikit_learn-1.9.1-cp313-cp313-macosx_12_0_arm64.whl + name: scikit-learn + version: 1.9.1 + sha256: 61cd968ab831a76d0ecbaf0347ab2270268716da28f94fd022497e3d6f205f13 + requires_dist: + - numpy>=1.24.1 + - scipy>=1.10.0 + - joblib>=1.4.0 + - narwhals>=2.0.1 + - threadpoolctl>=3.5.0 + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/30/b4/cd473e0a48427003733e92bc3e8077081ba537eb33f7c658f2b7bef63776/fonttools-4.67.0-cp313-cp313-macosx_10_13_universal2.whl + name: fonttools + version: 4.67.0 + sha256: 8239e2ca24878715a19f061d065b5721e87da81d145e48b3418f771a469b5a24 + requires_dist: + - lxml>=4.0 ; extra == 'lxml' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - zopfli>=0.1.4 ; extra == 'woff' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'unicode' + - lz4>=1.7.4.2 ; extra == 'graphite' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - pycairo ; extra == 'interpolatable' + - matplotlib ; extra == 'plot' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - uharfbuzz>=0.45.0 ; extra == 'repacker' + - lxml>=4.0 ; extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.45.0 ; extra == 'all' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/35/ba/02b95fafb87b1ff6a4f6a79b06d70bbaf4657f86e95b993ac10104a8f804/pysam-0.24.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl name: pysam version: 0.24.1 @@ -1953,6 +2360,22 @@ packages: - pyyaml - pygments>=2.19.1 ; extra == 'extra' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/37/62/20edad100b93552ec5c172e509a9db898a73d5043ae701fcb6e9986f9d33/wrapt-2.5.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: wrapt + version: 2.5.1 + sha256: f1630201b0e2a96bb26304b7adfbd91a4ef486abb5a4c48377444a0bed749f37 + requires_dist: + - pytest ; extra == 'dev' + - setuptools ; extra == 'dev' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl + name: pyparsing + version: 3.3.3 + sha256: ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4 + requires_dist: + - railroad-diagrams ; extra == 'diagrams' + - jinja2 ; extra == 'diagrams' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/39/c3/e8a250064a2cce96ca939ac7e93c0a2bd1ede609b3bafd6a5db92e5e6686/ruranges-0.2.7-cp313-cp313-macosx_10_13_x86_64.whl name: ruranges version: 0.2.7 @@ -1965,6 +2388,33 @@ packages: version: 2.5.3 sha256: a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321 requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/3f/2f/103cdc5b7077cd569989d3ddb99ee3d4e96ee31bde8df2a6e426b2dc6195/contourpy-1.4.0-cp313-cp313-macosx_11_0_arm64.whl + name: contourpy + version: 1.4.0 + sha256: 196759a3e4db60e1546167e361090ebb6d199aa3aafc9e34ea17a5ac3b23e814 + requires_dist: + - numpy>=2.0 + - myst-parser ; extra == 'docs' + - sphinx ; extra == 'docs' + - sphinx-book-theme ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - bokeh ; extra == 'bokeh' + - selenium ; extra == 'bokeh' + - contourpy[bokeh,docs] ; extra == 'mypy' + - bokeh ; extra == 'mypy' + - docutils-stubs ; extra == 'mypy' + - mypy==2.1.0 ; extra == 'mypy' + - types-pillow ; extra == 'mypy' + - contourpy[test-no-images] ; extra == 'test' + - matplotlib ; extra == 'test' + - pre-commit ; extra == 'test' + - pillow ; extra == 'test' + - pytest ; extra == 'test-no-images' + - pytest-cov ; extra == 'test-no-images' + - pytest-rerunfailures<16 ; extra == 'test-no-images' + - pytest-xdist ; extra == 'test-no-images' + - wurlitzer ; extra == 'test-no-images' + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/3f/34/cb484e8b6174f130f8c6dc79c733a9dd8869b410ad6511fb6104c46b973a/google_crc32c-1.9.0-cp313-cp313-macosx_12_0_arm64.whl name: google-crc32c version: 1.9.0 @@ -2032,6 +2482,59 @@ packages: - pytest ; extra == 'test' - typer<0.14 ; extra == 'test' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl + name: pillow + version: 12.3.0 + sha256: 7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 + requires_dist: + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - setuptools ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl + name: threadpoolctl + version: 3.7.0 + sha256: cd8b60b5641b45c67bbf73c64c843235fc2d8a480c87389f52f5dbee893b86be + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl + name: fast-array-utils + version: 1.5.1 + sha256: 0ce2246f99a4f0755fbd978886be1b4206ccb2c2ad61518f74ebf1b9156856aa + requires_dist: + - array-api-compat + - numpy>=2 + - numba>=0.57 ; extra == 'accel' + - dask>=2023.6.1 ; extra == 'dask' + - dask>=2023.6.1 ; extra == 'full' + - h5py ; extra == 'full' + - numba>=0.57 ; extra == 'full' + - scipy>=1.13 ; extra == 'full' + - zarr ; extra == 'full' + - scipy>=1.13 ; extra == 'sparse' + - packaging ; extra == 'testing' + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl name: pre-commit version: 4.6.2 @@ -2052,6 +2555,11 @@ packages: - tomli-w ; extra == 'toml' - pyyaml ; extra == 'yaml' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl + name: kiwisolver + version: 1.5.1 + sha256: 77a4c8187a5948d7f8795adb765a3c7b553d07d86d88e43038fc32fc1fb9a3f3 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl name: typing-extensions version: 4.16.0 @@ -2067,6 +2575,14 @@ packages: version: 26.0.0 sha256: a6ca849f90cf73fe361f08a5762c783ead9671e4548c1f558cc637b54c9103f2 requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/4e/cd/e8280f9ffa30fea9fabc5341223701231fcc5d53a31f51419d42d4bec3a6/numba-0.68.0.tar.gz + name: numba + version: 0.68.0 + sha256: 8a781de54b980b98f43bff7f1093701b5f07c80d031c7cfa8a87493d8bf73f2d + requires_dist: + - llvmlite>=0.50.0.dev0,<0.51 + - numpy>=1.22,<2.6 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/4f/d6/e95834b29360092376fe4da9956ba41bb7b021869efe6ee9d4172d05cb15/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_x86_64.whl name: pyarrow version: 26.0.0 @@ -2249,6 +2765,11 @@ packages: version: 1.3.1 sha256: adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31 requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl + name: kiwisolver + version: 1.5.1 + sha256: 1ed0f5e49d0ceff8b72190824d9e59c062fbbc02c231b853112c78474b3f5ec2 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl name: mkdocstrings version: 1.0.6 @@ -2341,11 +2862,89 @@ packages: requires_dist: - typing-extensions>=4.15.0 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: contourpy + version: 1.4.0 + sha256: f1219a8898523cba821085f2da8a1b962cc696325763f09d7f93538ba43b2d70 + requires_dist: + - numpy>=2.0 + - myst-parser ; extra == 'docs' + - sphinx ; extra == 'docs' + - sphinx-book-theme ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - bokeh ; extra == 'bokeh' + - selenium ; extra == 'bokeh' + - contourpy[bokeh,docs] ; extra == 'mypy' + - bokeh ; extra == 'mypy' + - docutils-stubs ; extra == 'mypy' + - mypy==2.1.0 ; extra == 'mypy' + - types-pillow ; extra == 'mypy' + - contourpy[test-no-images] ; extra == 'test' + - matplotlib ; extra == 'test' + - pre-commit ; extra == 'test' + - pillow ; extra == 'test' + - pytest ; extra == 'test-no-images' + - pytest-cov ; extra == 'test-no-images' + - pytest-rerunfailures<16 ; extra == 'test-no-images' + - pytest-xdist ; extra == 'test-no-images' + - wurlitzer ; extra == 'test-no-images' + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/69/ef/b1a675f79c9babfd4fcd99af62141d3c2d1a78a524e311b0c6b80110445a/pyarrow-26.0.0-cp313-cp313-macosx_12_0_x86_64.whl name: pyarrow version: 26.0.0 sha256: c2ba350957076b1b3a22f549261dc3e9c67ca20816d8bd5f79d7b9c69be4c4c2 requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl + name: scanpy + version: 1.12.4 + sha256: a12a7ca9500e4b247fbb5fb9318fb204907125e411183bad3e9c62e5b988f37c + requires_dist: + - anndata>=0.10.8 + - certifi + - fast-array-utils[accel,sparse]>=1.4 + - h5py>=3.11 + - joblib + - legacy-api-wrap>=1.5 + - matplotlib>=3.10 + - natsort + - networkx>=2.8.8 + - numba>=0.60 + - numpy>=2 + - packaging>=25 + - pandas>=2.3 + - patsy + - pynndescent>=0.5.13 + - scikit-learn>=1.6 + - scipy>=1.13 + - scverse-misc>=0.0.6 + - seaborn>=0.13.2 + - session-info2 + - statsmodels>=0.14.5 + - tqdm + - typing-extensions ; python_full_version < '3.13' + - umap-learn>=0.5.12 + - bbknn ; extra == 'bbknn' + - anndata[dask] ; extra == 'dask' + - dask[array]>=2024.5.1 ; extra == 'dask' + - anndata[dask] ; extra == 'dask-ml' + - dask-ml ; extra == 'dask-ml' + - dask[array]>=2024.5.1 ; extra == 'dask-ml' + - harmonypy ; extra == 'harmony' + - igraph>=0.10.8 ; extra == 'leiden' + - leidenalg>=0.10.1 ; extra == 'leiden' + - igraph ; extra == 'louvain' + - louvain>=0.8.2 ; extra == 'louvain' + - setuptools ; extra == 'louvain' + - magic-impute>=2.0.4 ; extra == 'magic' + - igraph ; extra == 'paga' + - colour-science ; extra == 'plotting' + - cudf>=0.9 ; extra == 'rapids' + - cugraph>=0.9 ; extra == 'rapids' + - cuml>=0.9 ; extra == 'rapids' + - scanorama ; extra == 'scanorama' + - scikit-image>=0.23.1 ; extra == 'scrublet' + - scikit-misc>=0.5.1 ; extra == 'skmisc' + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl name: pygments version: 2.21.0 @@ -2378,6 +2977,51 @@ packages: version: 2.5.3 sha256: 66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034 requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl + name: networkx + version: '3.7' + sha256: e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0 + requires_dist: + - asv>=0.6.5 ; extra == 'benchmarking' + - py-rattler>=0.21,<0.22 ; extra == 'benchmarking' + - virtualenv ; extra == 'benchmarking' + - numpy>=2.2 ; extra == 'default' + - scipy>=1.15 ; extra == 'default' + - matplotlib>=3.10 ; extra == 'default' + - pandas>=2.3 ; extra == 'default' + - pre-commit>=4.1 ; extra == 'developer' + - mypy>=1.15 ; extra == 'developer' + - sphinx>=8.0 ; extra == 'doc' + - pydata-sphinx-theme>=0.16 ; extra == 'doc' + - sphinx-gallery>=0.18 ; extra == 'doc' + - numpydoc>=1.8.0 ; extra == 'doc' + - pillow>=10 ; extra == 'doc' + - texext>=0.6.7 ; extra == 'doc' + - myst-nb>=1.1 ; extra == 'doc' + - intersphinx-registry ; extra == 'doc' + - osmnx>=2.0.0 ; extra == 'example' + - momepy>=0.7.2 ; extra == 'example' + - contextily>=1.6 ; extra == 'example' + - seaborn>=0.13 ; extra == 'example' + - cairocffi>=1.7 ; extra == 'example' + - igraph>=0.11 ; extra == 'example' + - scikit-learn>=1.6 ; extra == 'example' + - iplotx>=0.9.0 ; extra == 'example' + - hiveplotlib>=0.28 ; extra == 'example' + - lxml>=4.6 ; extra == 'extra' + - pygraphviz>=2.0 ; extra == 'extra' + - pydot>=3.0.1 ; extra == 'extra' + - sympy>=1.10 ; extra == 'extra' + - build>=0.10 ; extra == 'release' + - twine>=4.0 ; extra == 'release' + - wheel>=0.40 ; extra == 'release' + - changelist==0.5 ; extra == 'release' + - pytest>=7.2 ; extra == 'test' + - pytest-cov>=4.0 ; extra == 'test' + - pytest-xdist>=3.0 ; extra == 'test' + - pytest-mpl ; extra == 'test-extras' + - pytest-randomly ; extra == 'test-extras' + requires_python: '>=3.12,!=3.14.1' - pypi: https://files.pythonhosted.org/packages/7f/62/5374fba2ede0408f4bd8b9b3a6c8464f8d0ea7ae9a2a064bd81ca492bd1e/msgspec-0.22.0-cp313-cp313-macosx_10_13_x86_64.whl name: msgspec version: 0.22.0 @@ -2387,6 +3031,34 @@ packages: - tomli-w ; extra == 'toml' - pyyaml ; extra == 'yaml' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl + name: seaborn + version: 0.13.2 + sha256: 636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987 + requires_dist: + - numpy>=1.20,!=1.24.0 + - pandas>=1.2 + - matplotlib>=3.4,!=3.6.1 + - pytest ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - flake8 ; extra == 'dev' + - mypy ; extra == 'dev' + - pandas-stubs ; extra == 'dev' + - pre-commit ; extra == 'dev' + - flit ; extra == 'dev' + - numpydoc ; extra == 'docs' + - nbconvert ; extra == 'docs' + - ipykernel ; extra == 'docs' + - sphinx<6.0.0 ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-issues ; extra == 'docs' + - sphinx-design ; extra == 'docs' + - pyyaml ; extra == 'docs' + - pydata-sphinx-theme==0.10.0rc2 ; extra == 'docs' + - scipy>=1.7 ; extra == 'stats' + - statsmodels>=0.12 ; extra == 'stats' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl name: watchdog version: 6.0.0 @@ -2399,6 +3071,54 @@ packages: version: 1.15.0 sha256: 7b1b9c53269061403fd5f45a8de349f16e7887653328bfa0c5f2d45299ff0a8e requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl + name: statsmodels + version: 0.15.0 + sha256: 09fe34ac80e147cd76cb702ba672559ee3c5dd3ba15c25a232922b46e5ffe70e + requires_dist: + - numpy>=1.23.5,<3 + - scipy>=1.8,!=1.9.2 + - pandas>=1.4,!=2.1.0 + - patsy>=0.5.6 + - packaging>=21.3 + - formulaic>=1.1.0 + - pytest>=8.4.1,<10 ; extra == 'test' + - pytest-randomly ; extra == 'test' + - pytest-run-parallel ; extra == 'test' + - pytest-xdist ; extra == 'test' + - pytest-cov ; extra == 'test' + - coverage[toml] ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - sphinx ; extra == 'doc' + - pandas!=2.1.0,>=2.2.2 ; extra == 'doc' + - pydata-sphinx-theme ; extra == 'doc' + - jupyter ; extra == 'doc' + - notebook ; extra == 'doc' + - nbsphinx ; extra == 'doc' + - nbconvert ; extra == 'doc' + - pyyaml ; extra == 'doc' + - pandas-datareader ; extra == 'doc' + - simplegeneric ; extra == 'doc' + - seaborn ; extra == 'doc' + - numpydoc ; extra == 'doc' + - pymc ; os_name != 'nt' and extra == 'doc' + - arviz ; os_name != 'nt' and extra == 'doc' + - jinja2 ; extra == 'doc' + - pickleshare ; extra == 'doc' + - pytest>=8.4.1,<10 ; extra == 'doc' + - cython>=3.0.10,<4 ; extra == 'dev' + - setuptools-scm~=9.0 ; extra == 'dev' + - meson-python>=0.18.0 ; extra == 'dev' + - meson ; extra == 'dev' + - ninja ; extra == 'dev' + - matplotlib>=3 ; extra == 'dev' + - colorama ; extra == 'dev' + - joblib ; extra == 'dev' + - pywinpty ; os_name == 'nt' and extra == 'dev' + - ruff ; extra == 'dev' + - polars>=1.0.0 ; extra == 'polars' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl name: mkdocs-get-deps version: 0.2.2 @@ -2409,6 +3129,11 @@ packages: - platformdirs>=2.2.0 - pyyaml>=5.1 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl + name: cloudpickle + version: 3.1.2 + sha256: 9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl name: backrefs version: '8.0' @@ -2581,6 +3306,54 @@ packages: - pyzstd ; extra == 'test' - importlib-metadata ; extra == 'test-extras' requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl + name: statsmodels + version: 0.15.0 + sha256: 0ffe08752f420f12a0b03e8f7d8f3a45417615309b1f37f10561f9a53f044737 + requires_dist: + - numpy>=1.23.5,<3 + - scipy>=1.8,!=1.9.2 + - pandas>=1.4,!=2.1.0 + - patsy>=0.5.6 + - packaging>=21.3 + - formulaic>=1.1.0 + - pytest>=8.4.1,<10 ; extra == 'test' + - pytest-randomly ; extra == 'test' + - pytest-run-parallel ; extra == 'test' + - pytest-xdist ; extra == 'test' + - pytest-cov ; extra == 'test' + - coverage[toml] ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - sphinx ; extra == 'doc' + - pandas!=2.1.0,>=2.2.2 ; extra == 'doc' + - pydata-sphinx-theme ; extra == 'doc' + - jupyter ; extra == 'doc' + - notebook ; extra == 'doc' + - nbsphinx ; extra == 'doc' + - nbconvert ; extra == 'doc' + - pyyaml ; extra == 'doc' + - pandas-datareader ; extra == 'doc' + - simplegeneric ; extra == 'doc' + - seaborn ; extra == 'doc' + - numpydoc ; extra == 'doc' + - pymc ; os_name != 'nt' and extra == 'doc' + - arviz ; os_name != 'nt' and extra == 'doc' + - jinja2 ; extra == 'doc' + - pickleshare ; extra == 'doc' + - pytest>=8.4.1,<10 ; extra == 'doc' + - cython>=3.0.10,<4 ; extra == 'dev' + - setuptools-scm~=9.0 ; extra == 'dev' + - meson-python>=0.18.0 ; extra == 'dev' + - meson ; extra == 'dev' + - ninja ; extra == 'dev' + - matplotlib>=3 ; extra == 'dev' + - colorama ; extra == 'dev' + - joblib ; extra == 'dev' + - pywinpty ; os_name == 'nt' and extra == 'dev' + - ruff ; extra == 'dev' + - polars>=1.0.0 ; extra == 'polars' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl name: requests version: 2.34.2 @@ -2593,6 +3366,19 @@ packages: - pysocks>=1.5.6,!=1.5.7 ; extra == 'socks' - chardet>=3.0.2,<8 ; extra == 'use-chardet-on-py3' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl + name: interface-meta + version: 2.0.1 + sha256: f38016bef9a4429b6d0792d809be7b65e9781820c674bf7f463999086b6e6323 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/a2/4d/42754c94f8f909b9981fd44d28292a93bca6429d93f3e1ae58ac7de9b08b/numba-0.68.0-cp313-cp313-macosx_12_0_arm64.whl + name: numba + version: 0.68.0 + sha256: b8b29602f57df06c724fc53b1740887bc4332f202206771d46e47b25b485e904 + requires_dist: + - llvmlite>=0.50.0.dev0,<0.51 + - numpy>=1.22,<2.6 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/a6/82/0dbc9bc77b49cfb9268dc2b2b1dcd04346c8c7ed52e272b76a628a941656/zarr-3.4.1-py3-none-any.whl name: zarr version: 3.4.1 @@ -2612,6 +3398,97 @@ packages: - fsspec>=2023.10.0 ; extra == 'remote' - obstore>=0.5.1 ; extra == 'remote' requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl + name: tqdm + version: 4.70.1 + sha256: c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73 + requires_dist: + - colorama ; sys_platform == 'win32' + - requests ; extra == 'discord' + - envwrap ; extra == 'discord' + - slack-sdk ; extra == 'slack' + - envwrap ; extra == 'slack' + - requests ; extra == 'telegram' + - envwrap ; extra == 'telegram' + - ipywidgets>=6 ; extra == 'notebook' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/a8/26/939ae9874dd44116f2ecf61cb0caf029e3004ec1ed311a86389dee3450be/fonttools-4.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: fonttools + version: 4.67.0 + sha256: 50c41e30aa2e0130b80d1a58ac0f3ea7c02a854a70dbea1ff8d88e0ce524806f + requires_dist: + - lxml>=4.0 ; extra == 'lxml' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - zopfli>=0.1.4 ; extra == 'woff' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'unicode' + - lz4>=1.7.4.2 ; extra == 'graphite' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - pycairo ; extra == 'interpolatable' + - matplotlib ; extra == 'plot' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - uharfbuzz>=0.45.0 ; extra == 'repacker' + - lxml>=4.0 ; extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.45.0 ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/a9/29/2a47b700545aca050e0ed73c89ba21e08407e11f5f42a567421b220de6c1/matplotlib-3.11.2-cp313-cp313-macosx_11_0_arm64.whl + name: matplotlib + version: 3.11.2 + sha256: 2a8285cea8ef4d92aa041d1c33788bcae82248503300f93f1ca2136b9049452f + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.28.2 + - kiwisolver>=1.3.1 + - numpy>=1.25 + - packaging>=20.0 + - pillow>=9 + - pyparsing>=3 + - python-dateutil>=2.7 + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: matplotlib + version: 3.11.2 + sha256: 1944895967f87c84c9b4bad29a707b31f5b36df4a3a2339ea1f8ea3ce5105539 + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.28.2 + - kiwisolver>=1.3.1 + - numpy>=1.25 + - packaging>=20.0 + - pillow>=9 + - pyparsing>=3 + - python-dateutil>=2.7 + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/aa/b1/0b504ae34d1b79a6482a0ffcbfd1b103dde02329c11525033e02633f7984/numba-0.68.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: numba + version: 0.68.0 + sha256: be647fbc60c18c0323b34479f80173879654894eec58ad061f4b1901e294d854 + requires_dist: + - llvmlite>=0.50.0.dev0,<0.51 + - numpy>=1.22,<2.6 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: kiwisolver + version: 1.5.1 + sha256: 74ad5c3dad54a4641b4c28cd15ded70899d04459c6c7aeacafea716be97cce6d + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl name: mkdocs-material version: 9.7.7 @@ -2660,6 +3537,17 @@ packages: version: 6.0.3 sha256: 2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl + name: pynndescent + version: 0.6.0 + sha256: dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef + requires_dist: + - scikit-learn>=0.18 + - scipy>=1.0 + - numba>=0.55.0 + - llvmlite>=0.38 + - joblib>=0.11 + - pytest ; extra == 'testing' - pypi: https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl name: watchdog version: 6.0.0 @@ -2713,11 +3601,54 @@ packages: - ruff>=0.12.0 ; extra == 'dev' - cython-lint>=0.12.2 ; extra == 'dev' requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/b7/8b/62a6eacea08ea0b6ab0159a7783df7ed421ff9c1ff58fef7c31f58fa5e52/contourpy-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl + name: contourpy + version: 1.4.0 + sha256: e21d1d4a9db5b3a793649c7ab0ddb4697f2818929eec871a7b9c45fce7b2a1ea + requires_dist: + - numpy>=2.0 + - myst-parser ; extra == 'docs' + - sphinx ; extra == 'docs' + - sphinx-book-theme ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - bokeh ; extra == 'bokeh' + - selenium ; extra == 'bokeh' + - contourpy[bokeh,docs] ; extra == 'mypy' + - bokeh ; extra == 'mypy' + - docutils-stubs ; extra == 'mypy' + - mypy==2.1.0 ; extra == 'mypy' + - types-pillow ; extra == 'mypy' + - contourpy[test-no-images] ; extra == 'test' + - matplotlib ; extra == 'test' + - pre-commit ; extra == 'test' + - pillow ; extra == 'test' + - pytest ; extra == 'test-no-images' + - pytest-cov ; extra == 'test-no-images' + - pytest-rerunfailures<16 ; extra == 'test-no-images' + - pytest-xdist ; extra == 'test-no-images' + - wurlitzer ; extra == 'test-no-images' + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl name: six version: 1.17.0 sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- pypi: https://files.pythonhosted.org/packages/b8/1f/1d585b2122bcc9fe1615c0097730baebdef1b80e6acd07fe921ee501576b/llvmlite-0.50.0-cp313-cp313-macosx_12_0_arm64.whl + name: llvmlite + version: 0.50.0 + sha256: a32980e3d727b0e56974ad89d0764920048602a75805b8917cc0298e798b0ced + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/bb/8d/b60d5e7354ff0ff5cc9400e60273696589d87a30b8b2235886a76d80d062/scikit_learn-1.9.1-cp313-cp313-macosx_10_13_x86_64.whl + name: scikit-learn + version: 1.9.1 + sha256: 2c2b312fd8c02951a364fa120ea08c1cec10d863466bf1701b013152d7537835 + requires_dist: + - numpy>=1.24.1 + - scipy>=1.10.0 + - joblib>=1.4.0 + - narwhals>=2.0.1 + - threadpoolctl>=3.5.0 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl name: h5py version: 3.16.0 @@ -2725,6 +3656,21 @@ packages: requires_dist: - numpy>=1.21.2 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/bf/ce/fc58855a3d17f6cb8a87b6f9ccd100f65432a3a7ffed57309cbf14c78987/matplotlib-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl + name: matplotlib + version: 3.11.2 + sha256: 7d43ff8cebb50840648cb6429b2228621dbd709010f3117b3740abb20abf21c0 + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.28.2 + - kiwisolver>=1.3.1 + - numpy>=1.25 + - packaging>=20.0 + - pillow>=9 + - pyparsing>=3 + - python-dateutil>=2.7 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl name: platformdirs version: 4.12.3 @@ -2760,6 +3706,54 @@ packages: version: 3.0.4 sha256: 2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: statsmodels + version: 0.15.0 + sha256: bcaa1e4499697c4e023e5fa6fb04459a34e4f4a449c45ae441992e933bde24db + requires_dist: + - numpy>=1.23.5,<3 + - scipy>=1.8,!=1.9.2 + - pandas>=1.4,!=2.1.0 + - patsy>=0.5.6 + - packaging>=21.3 + - formulaic>=1.1.0 + - pytest>=8.4.1,<10 ; extra == 'test' + - pytest-randomly ; extra == 'test' + - pytest-run-parallel ; extra == 'test' + - pytest-xdist ; extra == 'test' + - pytest-cov ; extra == 'test' + - coverage[toml] ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - sphinx ; extra == 'doc' + - pandas!=2.1.0,>=2.2.2 ; extra == 'doc' + - pydata-sphinx-theme ; extra == 'doc' + - jupyter ; extra == 'doc' + - notebook ; extra == 'doc' + - nbsphinx ; extra == 'doc' + - nbconvert ; extra == 'doc' + - pyyaml ; extra == 'doc' + - pandas-datareader ; extra == 'doc' + - simplegeneric ; extra == 'doc' + - seaborn ; extra == 'doc' + - numpydoc ; extra == 'doc' + - pymc ; os_name != 'nt' and extra == 'doc' + - arviz ; os_name != 'nt' and extra == 'doc' + - jinja2 ; extra == 'doc' + - pickleshare ; extra == 'doc' + - pytest>=8.4.1,<10 ; extra == 'doc' + - cython>=3.0.10,<4 ; extra == 'dev' + - setuptools-scm~=9.0 ; extra == 'dev' + - meson-python>=0.18.0 ; extra == 'dev' + - meson ; extra == 'dev' + - ninja ; extra == 'dev' + - matplotlib>=3 ; extra == 'dev' + - colorama ; extra == 'dev' + - joblib ; extra == 'dev' + - pywinpty ; os_name == 'nt' and extra == 'dev' + - ruff ; extra == 'dev' + - polars>=1.0.0 ; extra == 'polars' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/cc/e3/357baa8d2a9164a98dfd7ef9d3a58125df0ed981be909945bdd337be7194/msgspec-0.22.0-cp313-cp313-macosx_11_0_arm64.whl name: msgspec version: 0.22.0 @@ -2774,6 +3768,27 @@ packages: version: 6.0.3 sha256: 8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + name: narwhals + version: 2.27.1 + sha256: d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31 + requires_dist: + - cudf-cu12>=24.10.0 ; sys_platform == 'linux' and extra == 'cudf' + - dask[dataframe]>=2024.8 ; extra == 'dask' + - duckdb>=1.1 ; extra == 'duckdb' + - ibis-framework>=6.0.0 ; extra == 'ibis' + - packaging>=21.3 ; extra == 'ibis' + - pyarrow-hotfix>=0.7 ; extra == 'ibis' + - modin>=0.22.0 ; extra == 'modin' + - pandas>=1.3.4 ; extra == 'pandas' + - polars>=0.20.4 ; extra == 'polars' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - pyspark>=3.5.0 ; extra == 'pyspark' + - pyspark[connect]>=3.5.0 ; extra == 'pyspark-connect' + - narwhals[duckdb] ; extra == 'sql' + - sqlparse>=0.5.5 ; extra == 'sql' + - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl name: colorama version: 0.4.6 @@ -2802,6 +3817,27 @@ packages: - python-discovery>=1.6 - typing-extensions>=4.13.2 ; python_full_version < '3.11' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/e4/6d/cfe55762435f36107815d56a2cfbebe7e3129b593c47a670c6eb1d7917d3/wrapt-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl + name: wrapt + version: 2.5.1 + sha256: f98eaf784cd12bc69c77af398084174531007cd81849c962163ccfc6e791f3ea + requires_dist: + - pytest ; extra == 'dev' + - setuptools ; extra == 'dev' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + name: cycler + version: 0.12.1 + sha256: 85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 + requires_dist: + - ipython ; extra == 'docs' + - matplotlib ; extra == 'docs' + - numpydoc ; extra == 'docs' + - sphinx ; extra == 'docs' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/ea/ca/87f8548f73d452aab35e4a90f8b39ae303295e0f2ef0b4055c44d6b3f1be/pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl name: pandas version: 3.0.6 @@ -2939,6 +3975,45 @@ packages: - numpy - pyarrow ; extra == 'fast-io' requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/ed/c2/5e9d0773f1589397a3ea3dcfa4bbee36e2855ad938d738dd6ff9f505a59b/llvmlite-0.50.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: llvmlite + version: 0.50.0 + sha256: 425845f415a06dc50db08db033c6b568e0d85c4937e932c605a4d49e1514b2da + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ef/36/04d74f0c71d93829657a703d680a54968253bbb5c93babc34378eae2087a/fonttools-4.67.0-cp313-cp313-macosx_10_13_x86_64.whl + name: fonttools + version: 4.67.0 + sha256: 1be99c1f07fca59510d657ef3eae584b5273fa4e203aff2383b3520744e19536 + requires_dist: + - lxml>=4.0 ; extra == 'lxml' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - zopfli>=0.1.4 ; extra == 'woff' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'unicode' + - lz4>=1.7.4.2 ; extra == 'graphite' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - pycairo ; extra == 'interpolatable' + - matplotlib ; extra == 'plot' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - uharfbuzz>=0.45.0 ; extra == 'repacker' + - lxml>=4.0 ; extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.45.0 ; extra == 'all' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl name: natsort version: 8.4.0 @@ -2988,6 +4063,37 @@ packages: - pyranges1[docs] ; extra == 'all' - pyranges1[cli] ; extra == 'all' requires_python: '>=3.12.0' +- pypi: https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: pillow + version: 12.3.0 + sha256: 0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 + requires_dist: + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - setuptools ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl name: pydantic-core version: 2.50.1 @@ -3012,6 +4118,23 @@ packages: requires_dist: - ukkonen ; extra == 'license' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl + name: formulaic + version: 1.2.2 + sha256: 0f84ff49e3fc9dc0e68ab08a0a9427874021aa6c558e66b44dc634a35739b09b + requires_dist: + - interface-meta>=1.2.0 + - narwhals>=1.17 + - numpy>=1.20.0 + - pandas>=1.3 + - scipy>=1.6 + - typing-extensions>=4.2.0 + - wrapt>=1.0 ; python_full_version < '3.13' + - wrapt>=1.17.0rc1 ; python_full_version >= '3.13' + - pyarrow>=1 ; extra == 'arrow' + - sympy>=1.3,!=1.10 ; extra == 'calculus' + - polars>=1 ; extra == 'polars' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/fd/b6/e76e80fed5f2558273c7839e622f98095c9b36c719c7147e38e3c055cb70/google_crc32c-1.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl name: google-crc32c version: 1.9.0 diff --git a/pyproject.toml b/pyproject.toml index d01b48a..322589d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,12 @@ dependencies = [ # inside functions, like pysam, so `cau --help` loads neither. "anndata>=0.10", "mudata>=0.3", + # QC figures. Imported inside functions, so a run that draws nothing + # never loads it. + "matplotlib>=3.8", + # QC metrics. scanpy names them as the IGVF pipeline does, so ours are + # directly comparable with the pipeline's own. + "scanpy>=1.10", ] [project.scripts] diff --git a/src/crispr_analysis_utils/cli/mudata_builder.py b/src/crispr_analysis_utils/cli/mudata_builder.py index fd931a8..59529a9 100644 --- a/src/crispr_analysis_utils/cli/mudata_builder.py +++ b/src/crispr_analysis_utils/cli/mudata_builder.py @@ -78,6 +78,14 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: "channel, joined to every cell's obs and kept in uns." ), ) + run_parser.add_argument( + "--readout-genes", + help=( + "The panel a targeted assay reads out, as a table with a column " + "of ENSEMBL ids. Adds a boolean readout column to gene.var; " + "without it the column is absent." + ), + ) run_parser.add_argument( "--covariates", help=( @@ -115,6 +123,7 @@ def run(args: argparse.Namespace) -> int: genome_accession=args.genome_accession, covariates=args.covariates, metadata=_channel(args.channel), + readout_genes=args.readout_genes, guide_annotation=args.guide_annotation, element_mapping=args.element_mapping, ) diff --git a/src/crispr_analysis_utils/mudata_builder.py b/src/crispr_analysis_utils/mudata_builder.py index afa7d44..84eb5bd 100644 --- a/src/crispr_analysis_utils/mudata_builder.py +++ b/src/crispr_analysis_utils/mudata_builder.py @@ -91,7 +91,40 @@ def read_counts(path: str | os.PathLike[str]): raise BuildError(f"{path} is not a readable h5ad: {error}") from error -def gene_var(store, ids: pd.Index) -> pd.DataFrame: +def read_readout_genes(path: str | os.PathLike[str]) -> pd.Series: + """The genes a targeted assay reads out, as unversioned ENSEMBL ids. + + A TAP-seq panel measures a few hundred genes on purpose; every other + feature in the matrix is off-panel and near-empty. Which is which is not + in the counts, so it comes from the panel's own table: the first column + holding ENSEMBL ids is taken as the gene. + + Returns + ------- + pandas.Series + How many rows of the panel name each gene, indexed by unversioned + ENSEMBL id. Usually one; a gene measured through two amplicons has + two, and its counts are a sum of both. + """ + path = Path(path) + if not path.is_file(): + raise BuildError(f"The readout gene table {path} does not exist.") + frame = pd.read_csv(path, sep="\t" if path.suffix == ".tsv" else ",") + ensembl = [ + column + for column in frame.columns + if frame[column].astype(str).str.startswith("ENSG").any() + ] + if not ensembl: + raise BuildError( + f"{path} has no column of ENSEMBL gene ids, so it cannot say " + "which genes are read out." + ) + genes = _unversioned(frame[ensembl[0]].astype("string")) + return genes.value_counts() + + +def gene_var(store, ids: pd.Index, readout: pd.Series | None = None) -> pd.DataFrame: """The annotation of every gene in the matrix, in the matrix's own order. Parameters @@ -101,6 +134,10 @@ def gene_var(store, ids: pd.Index) -> pd.DataFrame: only the ones guides are annotated against. ids The matrix's gene ids, versioned or not. + readout + The genes the assay reads out, as `read_readout_genes` returns them. + Adds ``readout`` and ``readout_primers``; without it both are + absent, since a whole-transcriptome run reads out everything. Returns ------- @@ -125,8 +162,15 @@ def gene_var(store, ids: pd.Index) -> pd.DataFrame: # kb indexes genes as the GTF names them, with the version; the store # strips it. The rule has to be the store's own, or a PAR_Y gene, whose # id ends in neither, comes back annotated with nothing. - out = annotation.reindex(_unversioned(pd.Series(ids, dtype="string"))) + bare = _unversioned(pd.Series(ids, dtype="string")) + out = annotation.reindex(bare) out.index = pd.Index(ids, name="gene_id") + if readout is not None: + counts = readout.reindex(bare).fillna(0).astype(int) + out["readout"] = (counts > 0).to_numpy() + # Above one, the panel measures the gene through two amplicons and + # the counts are their sum, as n_oligos means on the guide side. + out["readout_primers"] = counts.to_numpy() return out @@ -403,6 +447,7 @@ def run( guide_annotation: str | os.PathLike[str] | None = None, element_mapping: str | os.PathLike[str] | None = None, metadata: dict[str, str] | None = None, + readout_genes: str | os.PathLike[str] | None = None, ) -> RunResult: """Assemble one channel's matrices into a MuData, and write it. @@ -506,6 +551,13 @@ def run( state["inputs"][name] = str(path) state["inputs"][f"{name}_md5"] = file_md5(path) + readout = None + if readout_genes is not None: + readout = read_readout_genes(readout_genes) + state["inputs"]["readout_genes"] = str(readout_genes) + state["inputs"]["readout_genes_md5"] = file_md5(readout_genes) + logger.info("%d genes are read out by the assay", len(readout)) + reference = reference_block( loaded, gtf_accession=gtf_accession, @@ -517,7 +569,7 @@ def run( data = build( rna_counts, guide_counts, - gene_table=gene_var(loaded, rna_counts.var_names), + gene_table=gene_var(loaded, rna_counts.var_names, readout=readout), guide_table=guide_var(guide_metadata, guide_counts.var_names), measurement_set=measurement_set, reference=reference, @@ -539,6 +591,16 @@ def run( "genes": int(data["gene"].n_vars), "guides": int(data["guide"].n_vars), "genes_unannotated": int(data["gene"].var["symbol"].isna().sum()), + "genes_readout": ( + int(data["gene"].var["readout"].sum()) + if "readout" in data["gene"].var + else None + ), + "genes_multi_primer": ( + int((data["gene"].var["readout_primers"] > 1).sum()) + if "readout_primers" in data["gene"].var + else None + ), "guides_unannotated": int(data["guide"].var["spacer"].isna().sum()), "uns_tables": {k: int(len(v)) for k, v in tables.items()}, } diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py new file mode 100644 index 0000000..171997e --- /dev/null +++ b/src/crispr_analysis_utils/qc.py @@ -0,0 +1,394 @@ +"""Quality-control figures, each published as three files. + +A figure nobody can check is an assertion. Every one here writes the numbers +it was drawn from, a caption that says what it shows, and a PDF at print +resolution, under one stem: + + .pdf the figure, 300 dpi, vector text + .tsv.gz the numbers, one row per plotted point + .txt the caption, as it would appear under the figure + +The data file is the point: a reviewer can redraw the figure, and a reader +can find the number behind a claim without rerunning anything. +""" + +from __future__ import annotations + +import gzip +import os +from pathlib import Path + +import numpy as np +import pandas as pd + +DPI = 300 +"""Print resolution, for the raster parts of an otherwise vector PDF.""" + +OKABE_ITO = ( + "#E69F00", + "#56B4E9", + "#009E73", + "#F0E442", + "#0072B2", + "#D55E00", + "#CC79A7", + "#000000", +) +"""Categorical colours, colourblind-safe. `cividis` for continuous scales.""" + + +class FigureError(RuntimeError): + """The figure cannot be drawn from what was given.""" + + +def style(): + """Set the colours and the defaults every figure here shares. + + Returns + ------- + module + `matplotlib.pyplot`, so a caller need not import it separately. + """ + import matplotlib as mpl + import matplotlib.pyplot as plt + + mpl.rcParams.update( + { + "axes.prop_cycle": mpl.cycler(color=list(OKABE_ITO)), + "image.cmap": "cividis", + "axes.spines.top": False, + "axes.spines.right": False, + "axes.grid": True, + "grid.color": "0.8", + "grid.linewidth": 0.6, + "axes.axisbelow": True, + "figure.dpi": 140, + "savefig.dpi": DPI, + "savefig.facecolor": "white", + # Text stays text in the PDF, so a figure can be relabelled + # without redrawing it. + "pdf.fonttype": 42, + } + ) + return plt + + +def publish( + stem: str | os.PathLike[str], + figure, + data: pd.DataFrame, + caption: str, +) -> dict[str, Path]: + """Write a figure, its numbers and its caption under one stem. + + Parameters + ---------- + stem + Path without a suffix. Three files are written beside it. + figure + A matplotlib figure. + data + The numbers the figure was drawn from, one row per plotted point. + caption + One or two sentences saying what the figure shows. Written as given, + so it can go straight under the figure. + + Returns + ------- + dict + The three paths, by suffix. + + Raises + ------ + FigureError + The data is empty, or the caption is blank. Either makes the figure + unpublishable, and failing here is cheaper than finding out later. + """ + if data.empty: + raise FigureError("A figure must be published with the numbers behind it.") + caption = caption.strip() + if not caption: + raise FigureError("A figure must be published with a caption.") + + stem = Path(stem) + stem.parent.mkdir(parents=True, exist_ok=True) + written = { + ".pdf": stem.with_suffix(".pdf"), + ".tsv.gz": stem.with_name(stem.name + ".tsv.gz"), + ".txt": stem.with_suffix(".txt"), + } + figure.savefig(written[".pdf"], dpi=DPI, bbox_inches="tight") + with gzip.open(written[".tsv.gz"], "wt", encoding="utf-8") as handle: + data.to_csv(handle, sep="\t", index=False) + written[".txt"].write_text(caption + "\n", encoding="utf-8") + return written + + +def umis_per_cell(data, modality: str) -> np.ndarray: + """Total UMIs for every cell of the object, zero where it has none. + + A cell the modality does not hold has no UMIs in it, which is not the + same as being absent from the object: the union keeps both, and a knee + plot that dropped them would overstate how deep the channel is. + """ + held = np.asarray(data.obsm[modality]).ravel() + umis = np.zeros(data.n_obs) + umis[held] = np.asarray(data[modality].X.sum(axis=1)).ravel() + return umis + + +def filter_cells(data, min_umis: int, modality: str = "gene"): + """Keep the barcodes whose `modality` UMI count reaches `min_umis`. + + Parameters + ---------- + data : mudata.MuData + The object to filter. + min_umis : int + The floor, read off the knee of this channel. There is no default: + where the knee falls depends on the channel's depth, so a floor + carried over from another one is a guess. + modality : str + The modality whose UMIs decide, `"gene"` by default. A barcode with + real RNA and no guide is kept, since an unassigned cell is a result. + + Returns + ------- + tuple of (mudata.MuData, numpy.ndarray) + The filtered copy, and the boolean mask over the original barcodes. + """ + keep = umis_per_cell(data, modality) >= min_umis + return data[keep].copy(), keep + + +def knee(data, modalities: tuple[str, ...] = ("gene", "guide")): + """Barcodes ranked by UMI count, log on both axes. + + Parameters + ---------- + data + A MuData as `mudata_builder` assembles it. + modalities + Which modalities to draw, each its own curve. + + Returns + ------- + tuple + The figure, the numbers, and the caption, ready for `publish`. + + Raises + ------ + FigureError + No modality has a barcode with a UMI. + """ + plt = style() + figure, axes = plt.subplots(figsize=(6, 4.5)) + + rows = [] + for modality in modalities: + ranked = np.sort(umis_per_cell(data, modality))[::-1] + ranked = ranked[ranked > 0] + if not len(ranked): + continue + rank = np.arange(1, len(ranked) + 1) + axes.plot(rank, ranked, label=modality, linewidth=1.5) + rows.append( + pd.DataFrame( + {"modality": modality, "rank": rank, "umis": ranked.astype(int)} + ) + ) + if not rows: + raise FigureError("No modality has a barcode with a UMI to rank.") + + axes.set( + xscale="log", + yscale="log", + xlabel="barcode rank", + ylabel="UMIs", + ) + axes.legend(frameon=False) + figure.tight_layout() + + numbers = pd.concat(rows, ignore_index=True) + counted = ", ".join( + f"{int((numbers['modality'] == m).sum()):,} {m}" + for m in numbers["modality"].unique() + ) + caption = ( + "Barcodes ranked by total UMIs, both axes logarithmic. Every barcode " + f"the object holds is ranked ({counted} barcodes with at least one " + "UMI); a barcode a modality does not hold counts as zero and so " + "falls outside the curve. The knee is where real cells give way to " + "ambient background, and its position is what a count threshold has " + "to justify itself against." + ) + return figure, numbers, caption + + +def mad_outlier( + values, + nmads: float = 5.0, + side: str = "both", + log: bool = True, +) -> np.ndarray: + """Flag the values that sit more than `nmads` MADs from the median. + + The median absolute deviation is the spread of the channel measured + against itself, so a threshold built from it moves with the channel's + own depth instead of being carried over from another one. + + Parameters + ---------- + values : array-like + One number per cell. `NaN` is never flagged: a cell the modality + does not hold was not measured. + nmads : float + How many MADs from the median is still acceptable, 5 by default. + side : {"both", "upper", "lower"} + Which tail to flag. A count metric is two-sided; a contamination + fraction only has an upper tail worth cutting. + log : bool + Work on `log1p` of the values, true by default. A count + distribution is roughly log-normal, and on the raw scale the MAD + is dominated by the deep cells. + + Returns + ------- + numpy.ndarray + Boolean, true where the value is an outlier. + + Raises + ------ + FigureError + The MAD is zero, so every value equals the median and no threshold + can be read off it. This is what an unfiltered barcode space looks + like: pick the cells first, then measure their spread. + """ + if side not in ("both", "lower", "upper"): + raise FigureError(f"side must be both, upper or lower, not {side!r}") + numbers = np.asarray(values, dtype=float) + measured = ~np.isnan(numbers) + scale = np.log1p(numbers[measured]) if log else numbers[measured] + middle = np.median(scale) + spread = np.median(np.abs(scale - middle)) + if spread == 0: + raise FigureError( + "The MAD is zero, so the threshold would be the median itself. " + "Run this on cells, not on the whole barcode space." + ) + + low, high = middle - nmads * spread, middle + nmads * spread + flagged = np.zeros(numbers.shape, dtype=bool) + if side in ("both", "lower"): + flagged[measured] |= scale < low + if side in ("both", "upper"): + flagged[measured] |= scale > high + return flagged + + +MITO_CONTIG = "chrM" +"""Mitochondrial genes are the ones on the mitochondrial contig.""" + +RIBO_PREFIXES = ("RPS", "RPL") +"""Cytoplasmic ribosomal protein genes, by symbol.""" + + +def gene_classes(var: pd.DataFrame) -> dict[str, np.ndarray]: + """Which genes are mitochondrial, ribosomal and read out by the assay. + + Mitochondrial genes are taken from the contig, not an ``MT`` symbol + prefix. On GENCODE v43 the contig and the ``MT-`` prefix agree exactly, + 37 genes, but a prefix without the hyphen also matches 529 nuclear genes, + mostly metallothioneins, and the MTRNR2L family: nuclear copies of + MT-RNR2 that are highly expressed and would inflate the mitochondrial + share of exactly the cells one would then discard as dying. + + Ribosomal genes are the RPS and RPL families by symbol, where no contig + helps, split by whether the gene is protein-coding: the pseudogenes share + the naming, are transcribed, and belong under their own label rather than + swelling the ribosomal share. + + Parameters + ---------- + var + ``mod['gene'].var``, needing ``gene_chr`` and ``symbol``, and + ``readout`` for the targeted class. + + Returns + ------- + dict + A boolean mask per class, over the genes in order. + """ + symbol = ( + var.get("symbol", pd.Series(index=var.index, dtype="string")) + .astype("string") + .fillna("") + ) + contig = ( + var.get("gene_chr", pd.Series(index=var.index, dtype="string")) + .astype("string") + .fillna("") + ) + # An RPS or RPL name is shared by the ribosomal proteins and by their + # many pseudogenes, which are transcribed and would otherwise inflate the + # ribosomal share. Counted, but under their own label. + ribosomal = symbol.str.upper().str.startswith(RIBO_PREFIXES).to_numpy() + kind = ( + var.get("gene_type", pd.Series(index=var.index, dtype="string")) + .astype("string") + .fillna("") + ) + coding = (kind == "protein_coding").to_numpy() + classes = { + "mito": (contig == MITO_CONTIG).to_numpy(), + "ribo": ribosomal & coding, + "ribo_pseudogene": ribosomal & ~coding, + } + if "readout" in var: + classes["targeted"] = var["readout"].fillna(False).to_numpy().astype(bool) + return classes + + +def cell_fractions(data, modality: str = "gene") -> pd.DataFrame: + """The share of each cell's UMIs coming from each class of gene. + + The arithmetic is scanpy's `calculate_qc_metrics`, so the columns are + named as the IGVF pipeline names them and the two can be compared + directly. What this adds is which genes belong to which class, and the + handling of a cell the modality does not hold. + + Parameters + ---------- + data + A MuData as `mudata_builder` assembles it. + modality + Which modality to measure; the gene counts by default. + + Returns + ------- + pandas.DataFrame + One row per cell of the whole object, indexed by cell: + ``total_counts``, ``n_genes_by_counts`` and a ``pct_counts_`` column + per class. A cell the modality does not hold has nothing measured, + so its percentages are NaN rather than zero. + """ + import scanpy as sc + + counts = data[modality] + classes = gene_classes(counts.var) + + # scanpy reads the class membership off var, by the names given here. + measured = counts.copy() + for name, mask in classes.items(): + measured.var[name] = mask + sc.pp.calculate_qc_metrics( + measured, qc_vars=list(classes), percent_top=None, log1p=False, inplace=True + ) + + wanted = ["total_counts", "n_genes_by_counts"] + [ + f"pct_counts_{name}" for name in classes + ] + frame = measured.obs[wanted] + # Back onto every cell of the object: one the modality does not hold has + # no measurement, which is not a measurement of zero. + return frame.reindex(pd.Index(data.obs_names)) diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md new file mode 100644 index 0000000..9658072 --- /dev/null +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -0,0 +1,118 @@ +--- +name: cau-qc +description: >- + Draw quality-control figures for a CRISPR screen with crispr-analysis-utils + (`crispr_analysis_utils.qc`), each published as a PDF, the numbers behind + it and a caption. Use when a user wants a knee plot or other QC figure from + an assembled MuData, asks how deep a channel is, wants a figure ready for a + paper, or asks where the numbers behind a figure are. Covers the three + files every figure writes, the house style, and why a figure is refused + without its data. +--- + +# QC figures (`crispr_analysis_utils.qc`) + +```python +import mudata as md +from crispr_analysis_utils import qc + +data = md.read_h5mu("results/mudata/mudata.h5mu") +figure, numbers, caption = qc.knee(data) +qc.publish("results/qc/knee_umis_per_barcode", figure, numbers, caption) +``` + +## Every figure is three files + +| File | Holds | +| --- | --- | +| `.pdf` | the figure at 300 dpi, text kept as text | +| `.tsv.gz` | the numbers it was drawn from, one row per plotted point | +| `.txt` | the caption, as it would read under the figure | + +**The data file is the point.** A reviewer can redraw the figure and a reader +can find the number behind a claim without rerunning anything. `publish` +refuses an empty table or a blank caption, because a figure without either is +an assertion rather than a result, and failing at write time is cheaper than +finding out in review. + +## The style is set once + +`qc.style()` puts Okabe-Ito on the colour cycle, `cividis` on images, a grey +grid behind the data, and drops the top and right spines. A figure that names +no colour is already right; reach for `qc.OKABE_ITO` only to override one. +`pdf.fonttype 42` keeps text editable in the PDF, so a figure can be +relabelled without redrawing it. + +## The knee plot + +`qc.knee(data)` ranks every barcode by total UMIs, both axes logarithmic, one +curve per modality. + +- **A barcode a modality does not hold counts as zero**, not as missing. The + assembled object is the union of the two matrices, and dropping the + absentees would make a channel look deeper than it is. +- **The caption carries the real counts**, generated with the figure, so it + cannot drift from what was drawn. +- The knee is where real cells give way to ambient background. Its position + is what a count threshold has to justify itself against; this step draws + it and sets no threshold. + +## The count floor + +`qc.filter_cells(data, min_umis=..., modality="gene")` keeps the barcodes +whose UMI count reaches the floor, returning the filtered copy and the mask. + +- **`min_umis` has no default.** Where the knee falls depends on the + channel's depth, so read it off that channel's own knee plot. A floor + carried over from another channel is a guess. +- **The floor is applied to one modality**, `gene` by default. A barcode with + real RNA and no guide counts is kept: an unassigned cell is a result, not a + failure. +- Fractions computed before filtering describe the whole barcode space, which + is mostly ambient. A mitochondrial median of 0 on an unfiltered object means + the median barcode holds almost no UMIs, not that the channel is clean. + +## Per-cell fractions + +`qc.cell_fractions(data)` returns `total_counts`, `n_genes_by_counts` and a +`pct_counts_` column per class, over `scanpy`'s +`calculate_qc_metrics`, so the names match the IGVF pipeline's own. + +`qc.gene_classes(var)` decides the classes: + +| Class | From | +| --- | --- | +| `mito` | `gene_chr == "chrM"` | +| `ribo` | an `RPS`/`RPL` symbol on a protein-coding gene | +| `ribo_pseudogene` | the same prefixes on anything else | +| `targeted` | the `readout` column, present only for a targeted panel | + +- **Mitochondrial means the contig, not the symbol.** The `MT` prefix also + names the nuclear metallothioneins and the `MTRNR2L` family, which are not + mitochondrial. On GENCODE v43 the contig gives 37 genes and the prefix 566. +- **Ribosomal pseudogenes are counted apart**, since they share the `RPS` and + `RPL` prefixes with the ~80 real ribosomal proteins and are transcribed. +- A cell the modality does not hold gets `NaN`, not zero: it was not + measured. + +## Thresholds from the channel itself + +`qc.mad_outlier(values, nmads=5, side="both", log=True)` flags the cells more +than `nmads` median absolute deviations from the median, on `log1p` by +default. The spread is the channel's own, so the threshold moves with its +depth rather than being carried over. + +- **It cannot pick the first cut.** On the unfiltered barcode space the + median barcode is ambient and the MAD is zero or near it, so the call + raises. The order is: knee for the floor, then MAD on the cells that + survive it. +- **`side="upper"` for a contamination fraction.** A cell with unusually + little mitochondrial signal is not a problem; one with a lot is. +- 5 MADs is the usual starting point. 3 is aggressive, and on a healthy + channel cuts cells that are only deep. + +## Ask before running + +- **Which object?** One channel's MuData, or several concatenated. A knee + plot over pooled channels hides a channel that failed. +- **Where do the figures go?** Nothing is written outside the stem's folder. diff --git a/tests/test_qc.py b/tests/test_qc.py new file mode 100644 index 0000000..04fec08 --- /dev/null +++ b/tests/test_qc.py @@ -0,0 +1,147 @@ +"""QC: what the classes mean, what a floor keeps, and what a figure writes.""" + +from __future__ import annotations + +import gzip + +import numpy as np +import pandas as pd +import pytest + +from crispr_analysis_utils.qc import ( + FigureError, + cell_fractions, + filter_cells, + gene_classes, + knee, + publish, + umis_per_cell, +) + +BARCODES = ["AAA", "CCC", "GGG"] + + +def mudata(): + """Three barcodes: the first gene-only, the last guide-only.""" + import anndata as ad + import mudata as md + + gene_var = pd.DataFrame( + { + # MT-CO1 is on chrM; MT1A is a nuclear metallothionein that the + # symbol prefix would sweep in and the contig does not. + "gene_chr": ["chrM", "chr16", "chr19", "chr1", "chr11"], + "symbol": ["MT-CO1", "MT1A", "RPS2", "RPL5P1", "VWF"], + "gene_type": [ + "protein_coding", + "protein_coding", + "protein_coding", + "processed_pseudogene", + "protein_coding", + ], + "readout": [False, False, False, False, True], + }, + index=pd.Index([f"ENSG{n}" for n in range(5)], name="gene_id"), + ) + gene = ad.AnnData( + X=np.array([[10, 10, 20, 10, 50], [0, 0, 0, 0, 100]], dtype="float32"), + obs=pd.DataFrame(index=pd.Index(BARCODES[:2], name="barcode")), + var=gene_var, + ) + guide = ad.AnnData( + X=np.array([[3, 0], [0, 7]], dtype="float32"), + obs=pd.DataFrame(index=pd.Index(BARCODES[1:], name="barcode")), + var=pd.DataFrame(index=pd.Index(["g1", "g2"], name="guide_id")), + ) + return md.MuData({"gene": gene, "guide": guide}) + + +def test_a_barcode_the_modality_does_not_hold_counts_as_zero_umis(): + data = mudata() + assert umis_per_cell(data, "gene").tolist() == [100.0, 100.0, 0.0] + assert umis_per_cell(data, "guide").tolist() == [0.0, 3.0, 7.0] + + +def test_mito_is_the_contig_not_the_symbol(): + classes = gene_classes(mudata()["gene"].var) + # MT-CO1 only: MT1A shares the prefix and is nuclear. + assert classes["mito"].tolist() == [True, False, False, False, False] + + +def test_ribosomal_pseudogenes_are_counted_under_their_own_label(): + classes = gene_classes(mudata()["gene"].var) + assert classes["ribo"].tolist() == [False, False, True, False, False] + assert classes["ribo_pseudogene"].tolist() == [False, False, False, True, False] + + +def test_the_readout_panel_becomes_a_class_only_when_var_declares_one(): + var = mudata()["gene"].var + assert gene_classes(var)["targeted"].tolist() == [False] * 4 + [True] + assert "targeted" not in gene_classes(var.drop(columns="readout")) + + +def test_fractions_are_percentages_and_a_cell_outside_the_modality_has_none(): + frame = cell_fractions(mudata()) + assert frame.index.tolist() == BARCODES + assert frame.loc["AAA", "total_counts"] == 100.0 + assert frame.loc["AAA", "pct_counts_mito"] == pytest.approx(10.0) + assert frame.loc["AAA", "pct_counts_targeted"] == pytest.approx(50.0) + # Not measured, rather than measured as zero. + assert frame.loc["GGG"].isna().all() + + +def test_a_floor_keeps_a_cell_with_rna_and_no_guide(): + filtered, keep = filter_cells(mudata(), min_umis=50) + assert keep.tolist() == [True, True, False] + assert filtered.n_obs == 2 + # AAA has no guide counts at all and is still a cell. + assert filtered["guide"].n_obs == 1 + + +def test_the_floor_has_no_default_because_the_knee_is_per_channel(): + with pytest.raises(TypeError): + filter_cells(mudata()) + + +def test_a_figure_writes_its_numbers_and_its_caption_beside_it(tmp_path): + figure, numbers, caption = knee(mudata()) + written = publish(tmp_path / "knee", figure, numbers, caption) + assert set(written) == {".pdf", ".tsv.gz", ".txt"} + assert all(path.exists() for path in written.values()) + with gzip.open(written[".tsv.gz"], "rt") as handle: + assert pd.read_csv(handle, sep="\t").equals(numbers) + assert written[".txt"].read_text().strip() == caption.strip() + + +def test_a_figure_without_numbers_or_a_caption_is_refused(tmp_path): + figure, numbers, caption = knee(mudata()) + with pytest.raises(FigureError): + publish(tmp_path / "a", figure, numbers.iloc[:0], caption) + with pytest.raises(FigureError): + publish(tmp_path / "b", figure, numbers, " ") + + +def test_mad_flags_both_tails_and_never_an_unmeasured_cell(): + from crispr_analysis_utils.qc import mad_outlier + + values = np.array([100.0, 110.0, 105.0, 95.0, 100.0, 1e6, np.nan]) + flagged = mad_outlier(values, nmads=3) + assert flagged[5] # the deep outlier + assert not flagged[6] # not measured, so not an outlier + assert not flagged[:5].any() + + +def test_mad_can_be_held_to_one_tail(): + from crispr_analysis_utils.qc import mad_outlier + + values = np.array([100.0, 110.0, 105.0, 95.0, 100.0, 1.0, 1e6]) + assert mad_outlier(values, nmads=3, side="upper").tolist()[-2:] == [False, True] + assert mad_outlier(values, nmads=3, side="lower").tolist()[-2:] == [True, False] + + +def test_a_zero_mad_is_refused_because_it_is_the_unfiltered_barcode_space(): + from crispr_analysis_utils.qc import mad_outlier + + # What the whole barcode space looks like: the median barcode is ambient. + with pytest.raises(FigureError, match="not on the whole barcode space"): + mad_outlier(np.array([2.0] * 1000 + [5000.0])) diff --git a/tests/test_skills.py b/tests/test_skills.py index cb76cfd..2faf5d0 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -33,6 +33,7 @@ "cau-element-mapping": ("element_mapping",), "cau-igvf-export": ("igvf_export",), "cau-mudata-builder": ("mudata_builder",), + "cau-qc": ("qc",), } # Modules that are plumbing rather than analysis, so need no skill. diff --git a/uv.lock b/uv.lock index aed96ba..4c031af 100644 --- a/uv.lock +++ b/uv.lock @@ -5,9 +5,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -230,6 +233,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -239,18 +251,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "contourpy" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8b/62a6eacea08ea0b6ab0159a7783df7ed421ff9c1ff58fef7c31f58fa5e52/contourpy-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e21d1d4a9db5b3a793649c7ab0ddb4697f2818929eec871a7b9c45fce7b2a1ea", size = 297330, upload-time = "2026-09-11T19:03:11.809Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2f/103cdc5b7077cd569989d3ddb99ee3d4e96ee31bde8df2a6e426b2dc6195/contourpy-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:196759a3e4db60e1546167e361090ebb6d199aa3aafc9e34ea17a5ac3b23e814", size = 285037, upload-time = "2026-09-11T19:03:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/bf/25/97001d1fdf940e6f5adfc592d4f5899fff95daf7dceead3988fe09987d2d/contourpy-1.4.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c0e07c691f3b3321913ed9b8161c50ea5f77fadd006f52f5e755359b0dcbfc5", size = 356675, upload-time = "2026-09-11T19:03:15.374Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5e/9a7241a89e2f74402aefa2569e1349ad2750ff77823c04c788eaaedb7fdd/contourpy-1.4.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f861af508fca2384eef392bc1d8377cfa349b2b854cb11b45d89c75780e2561", size = 406990, upload-time = "2026-09-11T19:03:17.44Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/3a4fbbf6a806eada5453a6d16f6c7d96b9f4b3e57a3127368fb545986b8a/contourpy-1.4.0-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a03b32c2e7eda8b17757c022162c13c86f5c3d6f937dddf9ba3c3ff7b513953b", size = 407271, upload-time = "2026-09-11T19:03:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1219a8898523cba821085f2da8a1b962cc696325763f09d7f93538ba43b2d70", size = 384707, upload-time = "2026-09-11T19:03:21.208Z" }, + { url = "https://files.pythonhosted.org/packages/05/07/e5cdf94d68961ee464de6b4fedd3d2f666584f59ccd124bb1290597122ab/contourpy-1.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20da2f86bfaed60dbba729fe738c8241b1dd22b6cf7e8cbbf30df290bd04ba28", size = 1356741, upload-time = "2026-09-11T19:03:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8a/2301d07accb257ac08daeb631875fada06fedd019c20ff7b00ee07e9d211/contourpy-1.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ced1670fafee703b8277ab1645072a226e74f167166eae146fb743916119b67", size = 1426793, upload-time = "2026-09-11T19:03:25.955Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5d/a62648f06259d15c139b14eb8cf3c58fe53cb970a55f2bb77d1070e80a41/contourpy-1.4.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:abd0f7e51ecc52bf8c95713bd80b1c17c516a71fa391c54475308a4377903c2b", size = 117961, upload-time = "2026-09-11T19:03:27.713Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/c5b7771ba6982f319ec90c0cf5c425b36891adfeee2d9f667f90b2d0c166/contourpy-1.4.0-cp313-cp313-win32.whl", hash = "sha256:510f7d93d94cf6ebf4e2cd0640bea16ab8affac7547230175b6745b596ce0599", size = 347438, upload-time = "2026-09-11T19:03:29.829Z" }, + { url = "https://files.pythonhosted.org/packages/68/c1/a59df0754bf83a6f3e153cc19d550a5b01f607e389b1c4d57f436f9139e0/contourpy-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:6507a016976a75a15aee47809a34605de6244ccc056e6164bf9fb14b27eecad4", size = 234084, upload-time = "2026-09-11T19:03:31.657Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/97224f5decb1efa5a610f912195deb45321f73ec9b9dac485cd6fe2d5a6a/contourpy-1.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:423bd5f4b3f11d54a8c597234e513382e3454bb106b8ec7ae32ba4ed63f8d99b", size = 559559, upload-time = "2026-09-11T19:03:33.528Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/b548628c8dddf6e5e0a981b7fa8e4c008024df09c7d17c6a9e271edfa0b3/contourpy-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a3e67bc1a6a4618a1dac7c3053a9ffece5ddfa2046b2670b342cf430bb0b87d1", size = 297354, upload-time = "2026-09-11T19:03:35.017Z" }, + { url = "https://files.pythonhosted.org/packages/04/5a/513484208742f65af2648c519f0b7ab034616e183c4403538024c136a1ab/contourpy-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a702e2e178f4fcf96f04775d0c059e4c917925abf0d3208ebb6865df3c23d", size = 283729, upload-time = "2026-09-11T19:03:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/35/da/5a6562febc994b2c4bf9d01e57a50458ef8a2051bb3e7b35a00333e0fc26/contourpy-1.4.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:404dbcd9233513dfd1323b66ea593fef90e899f658f6b8a9ed8e93bd0ca669db", size = 357819, upload-time = "2026-09-11T19:03:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/6d/99/7b358ce888aaa426f755daa096c99c59d17c352037344e836f20e5d3b957/contourpy-1.4.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61624f6722480aa7e168fd746164e7bfdf48f8ccb6542f1613741200dc37e2b1", size = 407795, upload-time = "2026-09-11T19:03:41.293Z" }, + { url = "https://files.pythonhosted.org/packages/be/02/34d7548adf08c60967435d79d7a90882eacc84f5093435287974c7e99943/contourpy-1.4.0-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e741a39dfc96babe1722561e81351aeec104526c57dbe91c167c1db606c2d55", size = 408552, upload-time = "2026-09-11T19:03:42.728Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8c/9677dad226e5aaac5b823d5b09d881ebc32ff1648af417597649841e516c/contourpy-1.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e439ab450c93455feb0218532ded3e946ec7a9b5f459069f122cfa44b89229f6", size = 384850, upload-time = "2026-09-11T19:03:44.402Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/e3fbeaa489c223629fbb434737cb3de4ecaca9cae4825e77b92490f640f8/contourpy-1.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:417be048f7e122cefbe2a34c51a1f2b0410f8eb35796f794d45475ddb7872d9d", size = 1357601, upload-time = "2026-09-11T19:03:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55fec293d342c222ed58a5d4056df4362e192af2e59527a3d5d822de8828/contourpy-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43d80072a32299bf945de0a6dd4e034ea0162e832261de2e07a274a5adbba9c9", size = 1427340, upload-time = "2026-09-11T19:03:48.745Z" }, + { url = "https://files.pythonhosted.org/packages/86/40/0faabf453edf59b0e3633381d7ca72c3414fb9b72b5cbb08b06ed792d3e5/contourpy-1.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:fa1b787362a3856e63dd2b89449f6c384d55ee1beb8f94f4bcc871b40f02462c", size = 118355, upload-time = "2026-09-11T19:03:50.047Z" }, + { url = "https://files.pythonhosted.org/packages/38/0c/7ea75aa3559ae8a6805015e15a19c92b6480752ecc92526361e5776e88c5/contourpy-1.4.0-cp314-cp314-win32.whl", hash = "sha256:738c44fa71735a617f36da58e32810512407d283d5d1bb5b1cecb00d7eeba7cf", size = 356977, upload-time = "2026-09-11T19:04:11.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/3df8cc14bb572c8b447f9dfbc876703f0f2f2896058285b5c87a1d4a638d/contourpy-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:181bea01bc742734ae672fa00c717d855dd35e1f029536406538c52e7b0cd73d", size = 240101, upload-time = "2026-09-11T19:04:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/66/01/ee6830a5aa4565662a345172f6f35eeeed625d20f1c9d4ecd35318fe4ade/contourpy-1.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:64039341e2d8804f1a13bda8c69083eab935167bbd7e856cf096241f17d5bd45", size = 576359, upload-time = "2026-09-11T19:04:14.983Z" }, + { url = "https://files.pythonhosted.org/packages/51/af/49ee9c9cf012699e505c1b63c531cc99f19df50f205173092c4d541e3ec7/contourpy-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ef47fef9a912c77e3d84b014f701e5a9340f25703e9ed3bcebe37f91fb69dc49", size = 313601, upload-time = "2026-09-11T19:03:51.452Z" }, + { url = "https://files.pythonhosted.org/packages/ad/59/ce411ab2be0f805038626d8f4ca9ce563487ff51ff6c363e7512271564ed/contourpy-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:96019f059bcb3774acc0104be3360a57b36d33eaaa2e1d7f77c800ca8e07618b", size = 302812, upload-time = "2026-09-11T19:03:53.022Z" }, + { url = "https://files.pythonhosted.org/packages/95/b0/390915f9af14c1e2d3b2a9ad99d85b98df4352346a51e368cbb825f813c3/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddf5a2596d716fd3793434844caf31abc7a40b1e8718431420c89858268fb909", size = 348996, upload-time = "2026-09-11T19:03:54.594Z" }, + { url = "https://files.pythonhosted.org/packages/c7/85/e0952576d54322f3e9983b1e049a7a31f42ce82c5c29d01821e6dd3780d7/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd59df9e2fb0aff8bd7fd1adb9f349b7c835245a9d0f18c2f2deeb537190f2b1", size = 400272, upload-time = "2026-09-11T19:03:56.822Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/f13543a5e1598e4a4ad01d9d5e2d65f9c09c37660616429913d606c5977d/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7794ea07cab575633daad8d6e963b662053391022294d3aacc1d9ba9ef54114", size = 412895, upload-time = "2026-09-11T19:03:58.487Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a8/6e5eaff53507dfd2dfc428240eecda32453067277d4b3e7ca71210b2d611/contourpy-1.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c8a74744fa746eeaa12f0f9ed7f8b4add9d8f1b14d95e3b5e133675eac888f", size = 377060, upload-time = "2026-09-11T19:04:00.122Z" }, + { url = "https://files.pythonhosted.org/packages/16/a3/d00e44d35511b3cead5bd794ee672d4eecf30d9de4ceac57a49d6204064f/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:107ec46f7aa1664266d69b1181aadc953d58d39f7d8d648fa5b795d4a060b0de", size = 1345688, upload-time = "2026-09-11T19:04:02.705Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/1e533b0cae32c8edd63fe40435ec3b29345dab87321afcd28722d7f957d1/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a4c89c7f38a0d7a94356d74e3391073c4325c4d47ad2fd065c3fe7dbae16c0c", size = 1416140, upload-time = "2026-09-11T19:04:04.77Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0c/4ee66759f00d6a5d6909782f14f441d639fa18a4a43142e334351efc47f8/contourpy-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:912c6afaa106e2f74ba22b30416b77ef7eb94e3bdc5a4df51ab147845dba9b6d", size = 371982, upload-time = "2026-09-11T19:04:06.233Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b3e5324138c3e3741f526663f4f265f4f69dfdf26415a39428940864a5f6/contourpy-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ef9440f6f8506246269a82734f5ff9e2e4c5e775b3996fc883cc491c5257eca6", size = 262907, upload-time = "2026-09-11T19:04:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" }, + { url = "https://files.pythonhosted.org/packages/cc/74/66d4e6fbea3bcb3fd4e5ae21c039ba31b8fc408b35884b4bc09b88016dce/contourpy-1.4.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:79b06c60d5e569ce12c5da8f0c51217cdc484386e2349fa717c30fafb56d2871", size = 297358, upload-time = "2026-09-11T19:04:16.718Z" }, + { url = "https://files.pythonhosted.org/packages/2e/8d/8f7f25e71d73f15d7859943189900d2d348e740388c9ef52a0180d284762/contourpy-1.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f963ea9f0d68d2b6a02d861e3c0aae09ae25ba21f243fcf281e9fed468b078d", size = 283732, upload-time = "2026-09-11T19:04:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/f0/90/957549e3b03c407c670e3e191db63f4aef60dab6605bc967af3a806b543e/contourpy-1.4.0-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5545ff38229c15d11d822fc6f7415b932da9f4975f53e305b7b96ebfb01f889c", size = 357820, upload-time = "2026-09-11T19:04:20.077Z" }, + { url = "https://files.pythonhosted.org/packages/56/4c/fa8de87d907b41b05d03f4945a51675cd67319650d01b4c927d209ffa6ee/contourpy-1.4.0-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630d32f06cedf37b7f1dd2b6a12e4492826bd477a4c9c7b2e2fee6ce3a2cea76", size = 407804, upload-time = "2026-09-11T19:04:21.781Z" }, + { url = "https://files.pythonhosted.org/packages/04/e7/96bd4f4a4ae285727befbdb4b332d53a2654cc5da4585d2525b47a063e1f/contourpy-1.4.0-cp315-cp315-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:be85d160e7c795113c2a93254dde2fc7e86b375322570eee78b420969426973c", size = 408559, upload-time = "2026-09-11T19:04:23.432Z" }, + { url = "https://files.pythonhosted.org/packages/ae/94/39660e8dc309f96bf7971eb4ec5659f272f84e42b34c9990a96ecce8442c/contourpy-1.4.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:018227e134b73090b06f911e773a526c77c9af426be29065500ce096a5accd4c", size = 384861, upload-time = "2026-09-11T19:04:25.198Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7b/411dccbf73d5d19da49bf7735786b3baf4ddb1456412d48ae4d4ab1fc761/contourpy-1.4.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4bffcb2e8cc5c0e837631acab59dd04265fdac376550e7c3e310523877c68cb7", size = 1357612, upload-time = "2026-09-11T19:04:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/39/bf2442130e7d5aae75a4de4504f0c8aca3ef3da3feac3940ee27ca985d83/contourpy-1.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc87f2ffa84a49a77d76cb792f28a48ff3b05a222aa0b2bddbd839ba78e1d5ca", size = 1427345, upload-time = "2026-09-11T19:04:29.646Z" }, + { url = "https://files.pythonhosted.org/packages/96/60/e5d9196dd83ccd7dd103762982b10ef19a4f72e8ef67ae7157699b9be0bb/contourpy-1.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:c8be3392b84cf43373a488874dc1ebf46f60784a70977f92586e18019d71c7ed", size = 118157, upload-time = "2026-09-11T19:04:31.091Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ae3d90f08ba2ed82189d93b55ffbd41e559760e89af4f9ef54d51869f41d/contourpy-1.4.0-cp315-cp315-win32.whl", hash = "sha256:618137ec5778fb76d494c9ec854b104a377fddba0d128bef7136cce2c3bf0df8", size = 356982, upload-time = "2026-09-11T19:04:52.913Z" }, + { url = "https://files.pythonhosted.org/packages/02/a0/36648fff407dcf8b8482985d33367ffc0fe90be72383869312cd0bd00a1b/contourpy-1.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:ea09dc704029930cbd097f75cb0cc1db9852adbcf1a151fac7e9559f7bcbfd19", size = 240101, upload-time = "2026-09-11T19:04:54.587Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/49e987a96706b8f877596cee00d558d7880793a833addc6b33f80805e5dc/contourpy-1.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:a5e2bb871b90cd7a52bdee63bf80ef7acb46398d27efc66d7679240016efc5aa", size = 576375, upload-time = "2026-09-11T19:04:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/e3/18/820ba24a070fe2a834af73e52245c501960746f5c1777ebc64ec0c0dec12/contourpy-1.4.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:432e89f835cf01f8d2123a130a3126b17ee6e0348e4d62c0973872b30aa873bf", size = 313543, upload-time = "2026-09-11T19:04:32.582Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f61a63d8107e0c1c3fa47d30ce45abad90b2ad48a1a9a4510332db7e0176/contourpy-1.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e0a2392533e5e2abdf277c951047516acc8d2f3c42b2dd9dc34c907c9ebabf2", size = 302702, upload-time = "2026-09-11T19:04:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/93/5af95cb5113ecc6e780ca0c6c79756a2cea8833f971bbae112dd4ad73ded/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:758e7496cec3195fc28a28945eab3ceef348c47fb95c7afc2eca2f3901209c8c", size = 354370, upload-time = "2026-09-11T19:04:36.158Z" }, + { url = "https://files.pythonhosted.org/packages/47/75/5a9942446647202beb7b4d150891bfcb78fb112c73862484bbe4cc031092/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bee6ff96653e0807cd0f8eaef00174a031e3a2effa5c20f49a5a8d574b05af23", size = 402340, upload-time = "2026-09-11T19:04:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4f/bfdb0c67775a584f957211936661289789c4d0ec3e988bca2c8c02f8090e/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a58b5f130afec61a093d743cdda435cc4047748b6791627e34a68f41ec3a9b07", size = 410721, upload-time = "2026-09-11T19:04:39.925Z" }, + { url = "https://files.pythonhosted.org/packages/65/d3/dddf13d6bb26e145c99debd1f2695018b5f3e24452dd9bfc9904c9d2ec89/contourpy-1.4.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9dc493e7924e5aa32d89d0b8e50986280f3d4b284968a792787bd7bcffd3cad", size = 382604, upload-time = "2026-09-11T19:04:41.793Z" }, + { url = "https://files.pythonhosted.org/packages/37/b4/89f0a5f6c6ccd322eae2704c7e933c82a4e594a61773933b4b0a18646866/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c927ec747633e68c9920bcaad48b0bcefe648812b822f18c34e2d0e2270a3d2a", size = 1354996, upload-time = "2026-09-11T19:04:43.856Z" }, + { url = "https://files.pythonhosted.org/packages/5d/29/fc91cc1ac6f3b591a269f4357b656d6ed0c39eb17c148f41f6da2f9729b9/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:80c7fc8e7ac217777ce7ba2db3cf8478652b5a4b335283111012ff9eaac9d842", size = 1425030, upload-time = "2026-09-11T19:04:45.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b8/bb129f524210c5d3eb8616876e02af280fba8b5554e0ac1afad80b51e06b/contourpy-1.4.0-cp315-cp315t-win32.whl", hash = "sha256:90feb8da006803a95ba573fddc9536357d0c3faddc3a4c9e22816ca49af52878", size = 371854, upload-time = "2026-09-11T19:04:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b1/bc774d9d200cfc4f073bba78b1bb1cb3b55bf1c8c345eae41fc2db510459/contourpy-1.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:de503609fdb71f597634ca64fb4bd2f13d27c97f140e912b1e8ed93544840df0", size = 262450, upload-time = "2026-09-11T19:04:49.502Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/eb4ff492e1c3d027617b5a20e4e6d63a035b41b8cf77a95950159556a3a5/contourpy-1.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78ed5c5f962b3e156109f0531b174a65ba80d13cc681a70227b12524c162e184", size = 587064, upload-time = "2026-09-11T19:04:51.148Z" }, +] + [[package]] name = "crispr-analysis-utils" version = "0.1.0" source = { editable = "." } dependencies = [ { name = "anndata" }, + { name = "matplotlib" }, { name = "mudata" }, { name = "numpy" }, { name = "pandas" }, { name = "pyarrow" }, { name = "pyranges1" }, { name = "pysam" }, + { name = "scanpy" }, ] [package.dev-dependencies] @@ -267,12 +361,14 @@ docs = [ [package.metadata] requires-dist = [ { name = "anndata", specifier = ">=0.10" }, + { name = "matplotlib", specifier = ">=3.8" }, { name = "mudata", specifier = ">=0.3" }, { name = "numpy", specifier = ">=1.23" }, { name = "pandas", specifier = ">=1.5" }, { name = "pyarrow", specifier = ">=16" }, { name = "pyranges1", specifier = ">=1.4" }, { name = "pysam", specifier = ">=0.22" }, + { name = "scanpy", specifier = ">=1.10" }, ] [package.metadata.requires-dev] @@ -286,6 +382,15 @@ docs = [ { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -307,6 +412,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592, upload-time = "2024-05-23T14:13:55.283Z" }, ] +[[package]] +name = "fast-array-utils" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "array-api-compat" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/89/49b54e22b4250c8dcb88db55c16156de71e3054748dafb5a6eb0e5767335/fast_array_utils-1.5.1.tar.gz", hash = "sha256:f3059b6915043226377f4153d5ee1b56e3acf9e5ffec34b393d0bc2298a1e88d", size = 338011, upload-time = "2026-09-18T11:55:58.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/1e/28539d717756e661963f136e03cba4b46b2c79d74dff814cd9c8ab3f5e32/fast_array_utils-1.5.1-py3-none-any.whl", hash = "sha256:0ce2246f99a4f0755fbd978886be1b4206ccb2c2ad61518f74ebf1b9156856aa", size = 40636, upload-time = "2026-09-18T11:55:56.698Z" }, +] + +[package.optional-dependencies] +accel = [ + { name = "numba" }, +] +sparse = [ + { name = "scipy" }, +] + [[package]] name = "filelock" version = "4.0.12" @@ -316,6 +442,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" }, ] +[[package]] +name = "fonttools" +version = "4.67.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/36/102e180f8f5dbaee88b26595b01ca8aa80bf4e62128d9aa94265b3996c96/fonttools-4.67.0.tar.gz", hash = "sha256:3cb57e6600ca77c0b1729cf8adc23bc0652633a37f18cfa934d9c7bc3de25519", size = 3750028, upload-time = "2026-10-14T13:20:28.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/50/f674402869f11a89868c4755ae86cd2fcfd67ca6193c6f5d1b479b1267b9/fonttools-4.67.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcb9743140419410161acfe7ec205fb0a8a703acfccb85b586becb5a97c047c9", size = 3108316, upload-time = "2026-10-14T13:18:39.162Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c8/5963603c5f9bbc28bde3a29dd7cdbe0bfcbee414b0f7eccec04ae477e1b6/fonttools-4.67.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ad813967410ba6d24a52850df59b164ee17883f17b96a91b4b0ac6e9d7b5a118", size = 2598450, upload-time = "2026-10-14T13:18:42.136Z" }, + { url = "https://files.pythonhosted.org/packages/25/6d/f8e5924917a6b5c0296fb507f748c139a34972f66e91d89159d5c98e27b2/fonttools-4.67.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:768a33bbe6ec5ba8f19979f938752f06d4e614cb554fd47abd7830f2007660e3", size = 5427391, upload-time = "2026-10-14T13:18:44.248Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e0/ec9e4cc868c514deb02233aa1047a6aeb9350d3ee012862f58eec10ef834/fonttools-4.67.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eb3c98cac93aac4b9f6e3ce2008325340b234cc9b0338ca6b513f31962a1e278", size = 5408233, upload-time = "2026-10-14T13:18:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/fe409cb3ab32f322de92e08e6362cd06bf6dd5f0cee5980d823849e9bd11/fonttools-4.67.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e0ca4c8438dd6320f5850c9bbee3b3980455ee3bac602a9a0299caf9e799a0e8", size = 5364778, upload-time = "2026-10-14T13:18:48.926Z" }, + { url = "https://files.pythonhosted.org/packages/de/5b/2a8dede092113be56329dd210deb6b34c55df2f3d7270934ffece8c7d0bb/fonttools-4.67.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2a09d33a9264a6b29efca9dc633b53969aaedb250a9c8521d60f51280cef65ca", size = 5528522, upload-time = "2026-10-14T13:18:51.297Z" }, + { url = "https://files.pythonhosted.org/packages/6c/de/d3baf686e4ac5726a24819a670747c51571c774dcfa41cc0528e5e8c1a2d/fonttools-4.67.0-cp312-cp312-win32.whl", hash = "sha256:e8a8545cbd58bd29494ffe81e3cb35f8a29332a8e495c42bec334145ce8cd65b", size = 2446090, upload-time = "2026-10-14T13:18:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3a/625a6dd0173e88dbea1826405b4bcbfa06c6ca095310ed720caba36b2e43/fonttools-4.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bfab2f5d1d255dec82f4bd082a1c10e77df808e42210890f50a9c30bf91570e", size = 2497985, upload-time = "2026-10-14T13:18:55.245Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/cd473e0a48427003733e92bc3e8077081ba537eb33f7c658f2b7bef63776/fonttools-4.67.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8239e2ca24878715a19f061d065b5721e87da81d145e48b3418f771a469b5a24", size = 3106141, upload-time = "2026-10-14T13:18:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/ef/36/04d74f0c71d93829657a703d680a54968253bbb5c93babc34378eae2087a/fonttools-4.67.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1be99c1f07fca59510d657ef3eae584b5273fa4e203aff2383b3520744e19536", size = 2598180, upload-time = "2026-10-14T13:18:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e6/b0cbdedb363a49043d704d8c7903543fdd317596409fb8ac2cb604c1e73c/fonttools-4.67.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad8b4f7c754a627e91908fa1a1ccc90b489cd2810c0ba16acd26ea2ff5273db7", size = 5400755, upload-time = "2026-10-14T13:19:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/a8/26/939ae9874dd44116f2ecf61cb0caf029e3004ec1ed311a86389dee3450be/fonttools-4.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50c41e30aa2e0130b80d1a58ac0f3ea7c02a854a70dbea1ff8d88e0ce524806f", size = 5382979, upload-time = "2026-10-14T13:19:03.726Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/35a0a34ab74609d2e8dc7a1f45f6386c81942868fc4fdf8e873878f392fd/fonttools-4.67.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0781fe22583529e1e98bb8a3a33040632e202a4c427ed7e65412c41a21b8ebcb", size = 5343745, upload-time = "2026-10-14T13:19:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/293577941809c3ec5a7f0870c01b3729c682467a858b8978a5c3ea54c226/fonttools-4.67.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36f0fee56227b909c9d1392f17b23803616f1f04efbe020c176d9945cabc0be5", size = 5501573, upload-time = "2026-10-14T13:19:08.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/4e25460f37840c51b3983a7a83ceef7a1efa9ea588aca6f0e3a852f4b120/fonttools-4.67.0-cp313-cp313-win32.whl", hash = "sha256:48696b630069e29b8aa5ea8b034e4f651a2e112073938ec16bd536dadde1debf", size = 2444457, upload-time = "2026-10-14T13:19:10.463Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/39e9461211309965514642c005a8d51e866a1092f69f5f693b16de9c5395/fonttools-4.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:7343cd0ef70edf8be7f4913cb9b55b992fb4e04055b47dcfecddcc2eb045a9d2", size = 2496147, upload-time = "2026-10-14T13:19:12.588Z" }, + { url = "https://files.pythonhosted.org/packages/25/5b/c418f48918e40ef8c3f0f555567fe013c0c8058a8afa8040d6baeec80683/fonttools-4.67.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:846982e89b1861d6c9d7fcd6567aec3fa5a10ad313e7f2076045fcd339cfbd8e", size = 3110155, upload-time = "2026-10-14T13:19:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/30/18/49013c643c3d56fce1b7e909ef7c01c36a5bd906dfb58571c9dcdaa4dc38/fonttools-4.67.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:952eb091689545d86d16e40f719ed7bb086dd810a07dcc9ea2ca0a81004810a3", size = 2599657, upload-time = "2026-10-14T13:19:16.93Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2c/b7f33fa3bd1e4afdf9bf93b760f22486350eda487ce76c47f5931f868957/fonttools-4.67.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b5d511ea012dce7bd6df12b279b7d7a5b01b019865717d03ae679f4b944fa5", size = 5385200, upload-time = "2026-10-14T13:19:18.868Z" }, + { url = "https://files.pythonhosted.org/packages/79/fe/fef04b2cc2930edba11095f9e9b5c2797f8594fc54316195cc39d3c3bc63/fonttools-4.67.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:916836845e4b1c1447bb61390ffb3cb5f2940fd9f5d6de4685539a81806c7764", size = 5328754, upload-time = "2026-10-14T13:19:21.179Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c6/41cd4f6137f61dd059cc0609b73d9556091ecfcc8cb4d3cc543129c8ec24/fonttools-4.67.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:775364ac079e2ea7a2eedb5f9172c57b059d638ff79e2bf8d4257e5805713f32", size = 5327159, upload-time = "2026-10-14T13:19:23.153Z" }, + { url = "https://files.pythonhosted.org/packages/53/5c/08abd0a6d5c36624411e1b934745b4689d4309b03e98d8cf49f9469c63b6/fonttools-4.67.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b3ddf350e74508102b33dc6b32984b6dd751359a7c57732bcd39f9d7cb37d71e", size = 5460332, upload-time = "2026-10-14T13:19:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/b5/0f/59e835023817fe3932653067fde74960a0800fb95535375d8206aa9ecd68/fonttools-4.67.0-cp314-cp314-win32.whl", hash = "sha256:72d6d316dffc92eadb771f697f289ea7b60f689580931328905a267bd170f93b", size = 2448714, upload-time = "2026-10-14T13:19:27.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/5230265a5ff16aead01ce1a432a6b5bbdabe086f433988f41a1395e6dff8/fonttools-4.67.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e2c1586b5b6588a47d02e2588170eefdc996b708f2659c44dbe169bd6fcacb5", size = 2500790, upload-time = "2026-10-14T13:19:29.906Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/d899d7bbbe04d27dd509ac6b8f58f73fc240bb1dfe0ada9a9d33ad3bf9f2/fonttools-4.67.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:84a3aed005de106fb1794372dace82eca50859d52ae26da4bb6c602480a41250", size = 3183808, upload-time = "2026-10-14T13:19:32.015Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f6/4f465a62972e383b3d82205841b93f625a4e5ece6e5693c5be2a691ffe6d/fonttools-4.67.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:64e56d0d6a39780fee86955c758674538387b18f911ea904a4aae8f8e30fa26f", size = 2632826, upload-time = "2026-10-14T13:19:33.854Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/ce1ae8f8baa75feb2320caf6f74d2c228eba210a13b3e0895c0403e5e987/fonttools-4.67.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c21073cfe7129aaa070d94f575c1e2a880ae4aae1dcffd5352f174b96d27d16", size = 5557857, upload-time = "2026-10-14T13:19:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/495fe0a6bb8625e693c1417e178aeac42a11aa47e79efd7611c7bc5fb81e/fonttools-4.67.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:720bcf27727193b0fe1883c2e036dc88e37047916e977f5c3daf6ee4316e9656", size = 5364450, upload-time = "2026-10-14T13:19:38.5Z" }, + { url = "https://files.pythonhosted.org/packages/19/9c/d9730d3dd32e39583d6db929d0867df02042539bb0ebc3ad3d92a52a6aaf/fonttools-4.67.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6c19a770a8d273371a37969003c143eaa629ab893c3db028af8b91d04c6f9a6d", size = 5425231, upload-time = "2026-10-14T13:19:40.659Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c6/d41c1163431828b0fa2172e867798e0c4517ac6606e774b9175e048fb666/fonttools-4.67.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:13d7507252c5a5d7941a5fa1be27d335c378ef07983ea2bb24988bf600eadd5e", size = 5460574, upload-time = "2026-10-14T13:19:43.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/af/14885b78b1c1ff7219f890b79a5a6f76608d907c171b40e43839de995f54/fonttools-4.67.0-cp314-cp314t-win32.whl", hash = "sha256:07a2f36b3263faadf5b7b548f62fd3cac401e490189c82b16f7139ac0df91cd4", size = 2482468, upload-time = "2026-10-14T13:19:45.91Z" }, + { url = "https://files.pythonhosted.org/packages/cf/33/3d660eb850d24a81b4097ed46a1352c4ac0e4c10025526fa115e1871fc64/fonttools-4.67.0-cp314-cp314t-win_amd64.whl", hash = "sha256:fd79e36c2968e9fc3e1b082f2ba7dc63ae88a161a3d8ceaa0746b906455f3617", size = 2532326, upload-time = "2026-10-14T13:19:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/b2/74/ebff33b3c6dfe77d86a1b67b470c3d817f044910203880a1f4e92a08bec2/fonttools-4.67.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:89ad62d116f45bb45873bb92fd69c14a720ba591cba488044731954a5565e194", size = 3104541, upload-time = "2026-10-14T13:19:50.418Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7b3b786447cdda91f8cd06e44bf3b906e71825118f5cbb9b69c099415152/fonttools-4.67.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:1671e5f368b0c136ed9fb62fef26c7e425b4ebb0bb669a1cb7ba453f5bba580b", size = 2598216, upload-time = "2026-10-14T13:19:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/c0c08d8a76b2ed460bf8b63642d98445aa18179a14005cae617bfe9ec732/fonttools-4.67.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451077d2fc61a2a03f5dca54d84fbb01051ad781f48ea137eff35c775a4cb025", size = 5382399, upload-time = "2026-10-14T13:19:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/3c/db/66b5ef9985c7d69f7b3521ee965c3093b1802322fb6c16e8c3da608b747e/fonttools-4.67.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f200cd2cf046a5a0b03babe84ebf8bbc12187d5d57f50bc03f24be89e7c1605", size = 5345746, upload-time = "2026-10-14T13:19:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b0/77d22a73d5cfce9651909583ea3011c7ab26daf155b0eb21f7a3f02ac78a/fonttools-4.67.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bd3239e5709fd4c3343db67245ede46aece610d7f7ef61afb174718122479282", size = 5322591, upload-time = "2026-10-14T13:19:59.539Z" }, + { url = "https://files.pythonhosted.org/packages/97/b8/d3e7b799186fc3213a31d0cfa2c553c5d8eed0a7c7960dc3cf7c0d0497fa/fonttools-4.67.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b274ed3106b8086f237b7dbb1529c28142ba10ae40b9d285be0ae6a44b2946d0", size = 5470360, upload-time = "2026-10-14T13:20:01.876Z" }, + { url = "https://files.pythonhosted.org/packages/b7/89/c9799e81e6de16196d4781dbb81136d354eaef07136607917275a5fe958f/fonttools-4.67.0-cp315-cp315-win32.whl", hash = "sha256:fc6b6b03aa44f504c8734e62ccc3e4dcda9f4b8213a85aa80742e4d1cc9d96ef", size = 2447522, upload-time = "2026-10-14T13:20:04.197Z" }, + { url = "https://files.pythonhosted.org/packages/79/48/40f5591bd0e198d34ee3e25710e730c824750b3c822fc0a65b08e193de80/fonttools-4.67.0-cp315-cp315-win_amd64.whl", hash = "sha256:592d8f72024dea0408739a92599e4f839b960e1e887b25adc76dc87271fdac76", size = 2499770, upload-time = "2026-10-14T13:20:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5c/f98ee788f76ffad100427c20abab3a6213b37c97575dc82e4ccfaaafbc55/fonttools-4.67.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9c38fece8156cbda31b42d49c4a187858056a35932b88233b6fb31eaca5cf67f", size = 3175346, upload-time = "2026-10-14T13:20:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b1/af3016813fd44c0ed32d37f3a12cb707efd99edd8205bd8b73aea1f0f542/fonttools-4.67.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3b34324deb3e09ad648039a0a86d945b83f23a44fe3da74a84e6ada71fe0b650", size = 2629083, upload-time = "2026-10-14T13:20:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/b5/bc/13b45dec208145da2c49c063b6ce73ddb2e6e3bd137ba3613562d686a013/fonttools-4.67.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a19f6d5e1a373f2e4a5bdb9452c8ba212dd9f1e43df2fff042b896e28084e4a", size = 5535751, upload-time = "2026-10-14T13:20:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8c/01f2f16066c802ad2cd6f3321c226240475b30ada91d69d493f7a40445a7/fonttools-4.67.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ccaa87b312219d02cf72a79f1eb2f3ce028882d6fd1b79336141005db83b84e", size = 5357413, upload-time = "2026-10-14T13:20:15.289Z" }, + { url = "https://files.pythonhosted.org/packages/84/e6/d6dff534e9cb8688ec7ecddc353609bca580efef9967334e2289f56bd9da/fonttools-4.67.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:38fc772182ebff3e2ebba7886460476eb65842b601ca0b9221a6a5826136396e", size = 5403722, upload-time = "2026-10-14T13:20:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/39/c8/4de02224adea134666e6705b0137cd3df2df60a03ce100797b2b221a73dd/fonttools-4.67.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f672398385849ff79e7dd50c0a06efe110c8ba23d8890f9b45fbb922bc2f55f6", size = 5450104, upload-time = "2026-10-14T13:20:19.612Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/3a32904bac7c3460e23a86e9e1529b40d0969a69bd4edefa31e2d2f1bae7/fonttools-4.67.0-cp315-cp315t-win32.whl", hash = "sha256:77e0d4096a2ac60aebe43928b5382766df2d148577db8e8ff79b6a50879a6c06", size = 2479606, upload-time = "2026-10-14T13:20:21.996Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c5/8834cfb95383059addca24f591379d152f137689ff63766736c26b0f9b25/fonttools-4.67.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8c58a8a9ad447bead6f91e5f50b23c0e4988538cdbd9bf2f68952b39f5900a84", size = 2527993, upload-time = "2026-10-14T13:20:23.949Z" }, + { url = "https://files.pythonhosted.org/packages/3d/61/4161946319472aaa9b897bd18ad5108a5b10f5ebaa503d921a001ac4fff9/fonttools-4.67.0-py3-none-any.whl", hash = "sha256:4304f03ed7f4ba000a8dcc941ad854bfa52e2f3b6112b8f099b6f431cf98e701", size = 1213142, upload-time = "2026-10-14T13:20:26.258Z" }, +] + +[[package]] +name = "formulaic" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "interface-meta" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "scipy" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/87/e7b9b39d218793f159d7425e264c0ac196da07f1decc333001502fac02fb/formulaic-1.2.2.tar.gz", hash = "sha256:c99e8f11ff7d327eaecaf63855ca69b7fa0da100ad6c0041ef80912fbac667e6", size = 657522, upload-time = "2026-06-02T18:24:42.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl", hash = "sha256:0f84ff49e3fc9dc0e68ab08a0a9427874021aa6c558e66b44dc634a35739b09b", size = 118939, upload-time = "2026-06-02T18:24:41.344Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -449,6 +650,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] +[[package]] +name = "interface-meta" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/5b/202a48a1ccdef721a95357fbe263c54ef87c18c4972df1ab5c22fe0f33d5/interface_meta-2.0.1.tar.gz", hash = "sha256:902bd9a95a12f195f15753a1080075d4eca7a2cb934fac7ac03c9e362b50796a", size = 15281, upload-time = "2026-04-30T22:35:36.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl", hash = "sha256:f38016bef9a4429b6d0792d809be7b65e9781820c674bf7f463999086b6e6323", size = 15330, upload-time = "2026-04-30T22:35:35.201Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -461,6 +671,128 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joblib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/1d/537ab090f302b838943a1b56497dd53059b9a9b46a074936470173a2e207/joblib-1.6.0.tar.gz", hash = "sha256:2ccc96785b12046c08fd6d55839c12857831b54a3c1673ffadd2f04bfc4eda03", size = 327903, upload-time = "2026-08-31T09:39:04.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl", hash = "sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba", size = 306115, upload-time = "2026-08-31T09:39:02.298Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/08/c1/88018321d976f53c421e379c43bc6993e70ce0c8a3ec5edc4bfe102257f6/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b6ae6a0328f0bc035741820fdeecdcd67bf4694eee03972e843663107122f450", size = 62272, upload-time = "2026-08-28T10:25:51.02Z" }, + { url = "https://files.pythonhosted.org/packages/85/d2/712bc17ea4f1d216034928069d612defbc6c95a471c55a7203a39faecb1a/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:886fc26012f0e8b5f69d1cfe6d711f6b11f194621539bf8e6bb1c25c5dc82724", size = 64481, upload-time = "2026-08-28T10:25:52.22Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/6c5380d676f43b6d918033962ea5e72360ca69e5a404154bc496b598ffdb/kiwisolver-1.5.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aefe930d113798330e9462f7874542977869c0613cba3262e2de3a8d5dee8f3a", size = 66260, upload-time = "2026-08-28T10:25:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/7087f5822cc8bb272679641404b1966a42504dac9ee74e2b33840475a0aa/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ca5aebae78a0bc13c1943af4af615d4966c5b650b05d5aa83b50e427196fee", size = 123876, upload-time = "2026-08-28T10:25:54.644Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ed0f5e49d0ceff8b72190824d9e59c062fbbc02c231b853112c78474b3f5ec2", size = 66487, upload-time = "2026-08-28T10:25:55.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:77a4c8187a5948d7f8795adb765a3c7b553d07d86d88e43038fc32fc1fb9a3f3", size = 64673, upload-time = "2026-08-28T10:25:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:74ad5c3dad54a4641b4c28cd15ded70899d04459c6c7aeacafea716be97cce6d", size = 1477992, upload-time = "2026-08-28T10:25:58.479Z" }, + { url = "https://files.pythonhosted.org/packages/ed/40/b1d0369048c79733a32c8abb0f2718532e6630641368e33a81384246e844/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e46b23a2da695c364124817bc01d970effd5483147f8d66a6a7167e3f6b851", size = 1278821, upload-time = "2026-08-28T10:26:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/a1/fa71c1792272ff9461432715ae60ffb7e11a4d7ac3bf68961b9cab6c60cf/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75d9b1cf8258462dbdc1eeda718c96ea7f079324c09067f6daabfcf37712b7fe", size = 1296805, upload-time = "2026-08-28T10:26:01.868Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3f0bd94af8e06ecc47eb834b195a06f05d48711ceb2352c56d6835160f0e/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fca690b00c4c48f6c2a547b0160ed511357093a4e4c9b47e0fadf3128066d89", size = 1346109, upload-time = "2026-08-28T10:26:03.59Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/3afe6ef9cf06d61220953a8963e94eca978491be1d9547cb01d82a1efa08/kiwisolver-1.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:876bbfd276473d3daffe30e8c975df4ed9429967b41a6cb362dbb5155b6f13ad", size = 988252, upload-time = "2026-08-28T10:26:05.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/30/a12bd7a7285a211e1747c3eec77b8c614dbfcc1dad942f7611a1a6921ae5/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f942903fde7363d1d879057ec5de01310efda2597161784d752fa9953a01a71a", size = 2228846, upload-time = "2026-08-28T10:26:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/233e2958abf0dab7b18d07e52f286e02e519d7651bfbbe97af9347564109/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c90d3022d8a94778939cda8638c6c8da8fa757b8958dad7ec868ce29c87681b8", size = 2325583, upload-time = "2026-08-28T10:26:09.093Z" }, + { url = "https://files.pythonhosted.org/packages/42/8e/7673060a27b01405b580058510adef34687069d229800239f5e44682d4d0/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8a34616dc2521cc8dc1d7d081734da63539f021ac0450ce950908340c6e7aa2f", size = 1978221, upload-time = "2026-08-28T10:26:11.127Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/1b882fc1a8b4a0bb8084e7d1d85004116d08c92c0705d31f2928dec607f2/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8bf4df63592c2a66b4f8edc5df2544998c288aa02f96ce0acd880cd1de8c8127", size = 2491819, upload-time = "2026-08-28T10:26:13.348Z" }, + { url = "https://files.pythonhosted.org/packages/39/9c/426deb49e62c5f69464b64bbeca064d3b758a7506b8913d986ef34f4619c/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d09037ca068d784ebc4aec290ef952ca27ac15dd9c0b5801a88c6e1096b83e6b", size = 2295520, upload-time = "2026-08-28T10:26:15.042Z" }, + { url = "https://files.pythonhosted.org/packages/f5/22/deabbb3ad6d918d74b7831b2d8ae7151b09d21c87974e5ee8a456f58c94c/kiwisolver-1.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:dc23390afe9f4ef9ac3bcc72a03a56eebbde03f4c571a32cb38f859cff9a6524", size = 70758, upload-time = "2026-08-28T10:26:16.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/02fa5c2fd92068bb8952847e70ee6c5cb280e7febe11653d17812acc53dd/kiwisolver-1.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:186884a58486651e3c217b6acea0a53eaa9498fdd472057c46f2f0fb5c25aad5", size = 68329, upload-time = "2026-08-28T10:26:17.658Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/2d5dfad0daf17dcc18d98c48ed2332fc3f051cf599e60be6182a30dd4cf1/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0324cd2567259b7a095f6cf18a52b0ffc6f3de9e69528ff1bc0e7a37bd43ff1a", size = 62337, upload-time = "2026-08-28T10:26:18.778Z" }, + { url = "https://files.pythonhosted.org/packages/08/c8/83e1624f15d6262b470dbcc80b09979fd4d5b2ea3ddfc6b6e3327e235726/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:74ea337e0ec3f6f342a36a4f1b5cd94dd9affddcd28ba9aae2905af932ee8c6b", size = 64513, upload-time = "2026-08-28T10:26:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2d/827ec30eb07f528c08d8459ffb318ae91a56d793ee8acbea8b491f0ff906/kiwisolver-1.5.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ee9df1f0d77b9c6e94f4ac0fec533fbddd5ea3a327807f18d7b069ae019ded80", size = 66287, upload-time = "2026-08-28T10:26:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/53/11/5c43a562529dad8def4b81e5e1877c612a7e0298105a5939b3b409d2079c/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc271a6f0a2126958f4090e5507b9da5848927dae331f8f763bd4aa642b3d2cd", size = 123940, upload-time = "2026-08-28T10:26:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/64/db/9bd6c505c95128c258a55236bfbb3a7a3fb6023f863316b6d7d9f3c69052/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b3092d8992a1d69b7a59c3e39f35e1b9be327a17f68a7c35fc17329e337d6f2", size = 66493, upload-time = "2026-08-28T10:26:23.743Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f4/b3007a3ed5c9be73f81161140684cf7d9bdb9c4b632f5f484d2a1c713fb9/kiwisolver-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c2306e8bb53601979fcb3fa09cc65e031876d9ae01eff2fcbcd7a84ef94d5bc1", size = 64720, upload-time = "2026-08-28T10:26:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/a1/13/08188f0cafa3a800403e4ff62b9aad4e7a17f9c4c7e080dc8f18c64794cf/kiwisolver-1.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:18a0cfb124546a4c2e6087c5f3029c7f44b37c85b142e0ced71f73a7599ac208", size = 1475867, upload-time = "2026-08-28T10:26:26.393Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3e/053bdc3c9abdb8f2606225eda398adca25c0c91ab90add8222a69db65ee0/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ec467940442c9943016fb2d4c81d1ba84351eeca2f1a78f8bc87f1ba0d414c", size = 1282865, upload-time = "2026-08-28T10:26:28.118Z" }, + { url = "https://files.pythonhosted.org/packages/5e/64/a44c341b36b610588cc2f1e89b3cae072a3119aa8be578e90987cd640751/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a83ee7107df13abe42a54a6654670eef9bb39425cf2e27f65e0007465e1286ab", size = 1300865, upload-time = "2026-08-28T10:26:30.125Z" }, + { url = "https://files.pythonhosted.org/packages/60/5e/7e7d716dca38c714478b741257a5b4a321d9932b8d851551a136dcaf3984/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bebb89489b279b2f5661bbbb2abcc87bcd4a46607bb4a5c966f04f1db6b8df9a", size = 1348071, upload-time = "2026-08-28T10:26:31.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/1a/2b98fdda8bf45b7be317e48ed12393d44334394d315c74b81f4a14c0e31b/kiwisolver-1.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:509735237ae0d849e8a843551d423d2500d2e0a9ac1611a145658b29c0fb9f85", size = 992191, upload-time = "2026-08-28T10:26:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/d893f5ede0e50f9e3fcf01f6015f42ec7d9cd221e26772701fe4a98745f9/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:824c3d763a05ea9e9003610145186b0e9848c7584a5575c79bac5a8e7cd80bad", size = 2233854, upload-time = "2026-08-28T10:26:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/b1/82/f85f6279555a6ee1639fef7bfe83adb037a03e11a6fc9eaa54b8d0380339/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1fff05e239575b1481b6ed1a782f6fad616efbf1f0b1f44e6e85c4dfe426e483", size = 2330621, upload-time = "2026-08-28T10:26:36.9Z" }, + { url = "https://files.pythonhosted.org/packages/56/31/e11aea078f66fc2fffcc179d38ca90d9da97652a241b64519169742ba46a/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0627b9bceb9c3cdcf12b8a18655eedfed2692b038df27423383c120d0b7dc2d6", size = 1982848, upload-time = "2026-08-28T10:26:39.01Z" }, + { url = "https://files.pythonhosted.org/packages/af/ea/2956b63bf5140ca46aa2c2818e6aa03e2d5754dd2fa41db1c6b28922940c/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8a708a47ade1fe19e8371d5da076bac0dd4b0a5a7985ad6c637f7f7e361b6baa", size = 2494850, upload-time = "2026-08-28T10:26:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/11/d1/3829542258d8b3fc0898d221e7ef0e2c83eca0d348709bb8dbe54f3d4005/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:007a5553dfc4f4e8d184f588a0200e2cd4b63a59cc8796df3c39909e679dc7a0", size = 2298067, upload-time = "2026-08-28T10:26:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/49522e1ab5788cbaf63a26fbd3b851f9028616828c961b8a31b35cb96df8/kiwisolver-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:f4167e87b397f273dc2356fcf1eaf50a6bac51e6105f45103ef7129c8efb0255", size = 72282, upload-time = "2026-08-28T10:26:44.268Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b6/22e7ca5315d363e6f81c9f37c9472e12e7b298731e77c0428e6a911a2c39/kiwisolver-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5c490db2168a508088f59140dd392556a54b8bd1048fc6383c8baff13c359673", size = 69855, upload-time = "2026-08-28T10:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/03a9cfbe871964c8758a816eb03ac96c806da2795a9a7cd9bf9648bfb594/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4d4ca09bf13cff792b1884f64b98ee6c2467930d632233be25c56b442d99f10e", size = 126289, upload-time = "2026-08-28T10:26:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/14ce3041ca79dff9c9d884ca00c7bf32374e76028a865a9ecd99b4f5a517/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:44b8faef94f1857e77fa0238f3390ff1ac51d2ea20a487e2e452a59fd2b5f5ca", size = 67709, upload-time = "2026-08-28T10:26:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/af/c4/45030471a66ec8ef042e9f96ffe1d522c9ab12da180186a0898966fc1385/kiwisolver-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ae70bc59790d2af72a3f76f24b272403e135070340281108b447cb77ea70819", size = 65909, upload-time = "2026-08-28T10:26:49.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/73/17dce073a6ae259bb32cf9d686c4079d2e538868bc45967462bf33df914a/kiwisolver-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43844c1a7ad6d723d5b5b4c4fc7f5bd399c40e288120d16257c7c9e8765c6e85", size = 1584907, upload-time = "2026-08-28T10:26:50.933Z" }, + { url = "https://files.pythonhosted.org/packages/8c/84/ae3c75909f507283cbfcc7e916c7e822579ef962020b97e6882b27b4478f/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5e5aaad6be121f2515765e3b1c444352cb8eb4c86510801db8f2e50757316", size = 1392474, upload-time = "2026-08-28T10:26:52.638Z" }, + { url = "https://files.pythonhosted.org/packages/34/31/8bcc83caad5bce8fa4577152389848bf6bc110e51e573a2b4e7c2aa34c89/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3fa5855898f6d3d01b72ccd48a2d65cbdee301251603fefe34e2025bddba219c", size = 1405246, upload-time = "2026-08-28T10:26:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/55/72/220345537d790cf4ae54f8acfff4b5cc2468e0702a384d651cf7a771c63e/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a64dd5dec136040ec2ae94aa026a912ee60fdd45bc28d3db30037fd809e88", size = 1456099, upload-time = "2026-08-28T10:26:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/3725fd2398f66d18c34b4e0f81a8d03764cd4f4f089f58a527f0b4428086/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9e51c119992ea8820706871c30a4642ec76de20ae82f9b50b9a45517d8e9f810", size = 1073695, upload-time = "2026-08-28T10:26:57.658Z" }, + { url = "https://files.pythonhosted.org/packages/86/cb/28d6e09e66b93e4588b2e6b7d84d020ccefea09e2f4de788510a07efeab7/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:70ed9a45c7484d2b30cdacf60d220f494a1763b9fec1ad03285c6553fa0889f2", size = 2335355, upload-time = "2026-08-28T10:26:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b3/a0f31d5e4e40af7dc97c36b8a74fdd3a36cf3c8bbd098da9a23466ff6a94/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:98b208a7cc42c803445ef551d6753cc42a5ea13e9cab1ee66cd8b9cb70195330", size = 2426524, upload-time = "2026-08-28T10:27:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c9/728f63bd58c72cafdc79fc306abeeac7391bec03b757a48dadeb30906521/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6834b92dd2428e2dd85ef3d85f723d3c12f20aaf43a2ddd4f944ca25d833408", size = 2063430, upload-time = "2026-08-28T10:27:03.06Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/ce188b96f92f9a2c958231da140768918cba53c9713dc887b82f85462118/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5d142e352eb13facc7dd047489aebdff6ba78576c239f1ea04931979caaf0567", size = 2597513, upload-time = "2026-08-28T10:27:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/69/d6/76947c8203768968382e5bd74d9cc95654746703a61ea53015f2c74a2e06/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9b1c4900736e489a812c529100de4b8fb617d4db075e931e213c57424b83d9b", size = 2394488, upload-time = "2026-08-28T10:27:07.423Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d4/14b21e4eb203c4d15425e8b6a2c625a320b4a1f2f7557eead63ffc30ffb7/kiwisolver-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5978c3340f16a35c30f8ab2fa7bcf559973c55f1a5ef6970e1f621acf3c4db13", size = 75404, upload-time = "2026-08-28T10:27:08.892Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f5/53157899fc7f45f76421b77b99eb1639dd0f83f26ff9d76300c96bb4a3b0/kiwisolver-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ca307d6c259e5c98d3cb9ade55342b47a6839762caf2536f3d7b46ee660cc82e", size = 72946, upload-time = "2026-08-28T10:27:10.944Z" }, + { url = "https://files.pythonhosted.org/packages/75/62/f786c3a27f181fa339d851a77e266d208e776b9883cabc40a5b041a31b5a/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:bb7c99f0673c03017a3ee01e54a5c2617a05468b11eabe513b0080e063ed95b1", size = 62420, upload-time = "2026-08-28T10:27:12.308Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/58a91ed25fbad0facdf503297b03768efab04bdf3141e5e3b49a34be7443/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:0d8924877ce22e17326a99a418c3c82037da078df3c6a260b13eca677444e6e7", size = 64577, upload-time = "2026-08-28T10:27:13.502Z" }, + { url = "https://files.pythonhosted.org/packages/f6/5c/d501ef5a0958b226eac28306d24d5e5f114be0ace50e19cabae7b6b3b197/kiwisolver-1.5.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:534f02c1abb31ed6dbd3515545285c330b2f12d00fdb1fdb71658b9ca5a13a6a", size = 66284, upload-time = "2026-08-28T10:27:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/8fbecaf4fc18c02f31f05e47a84c010a80e3ec391ed2f0bdade1d62b5954/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:cea20da04494e662b83c872683bf4ff2345206043d036315ed0e924b652e7294", size = 124031, upload-time = "2026-08-28T10:27:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c6/bf3090d2983b4204347cbdbe952116e7c3b2abf62b4e33e50167a13e75ee/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:7fd82debf43c6acd0a94359d232f6bb516ee13f269a7993736a9ac9f988bb5d9", size = 66489, upload-time = "2026-08-28T10:27:17.506Z" }, + { url = "https://files.pythonhosted.org/packages/85/de/562dddef55fdd7c291da8626d6619e72b5fc0870e6ccca0e149a5731e7f3/kiwisolver-1.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:18170a77ddfecf40ec60d0928268dc95880c881864e015a8f34094ed18b9b9ad", size = 64806, upload-time = "2026-08-28T10:27:18.673Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/ba7b9c0164ce1cf62bf2872db63b8483289cf0f3110d6f9390eb09e409ed/kiwisolver-1.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca7f6fe0f37ca978a1e5eb7a3a68e6413f417e78e838324947ffd420202b198b", size = 1482211, upload-time = "2026-08-28T10:27:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/34da54efb4976616d45c20aae32d70e89d6e7395ed908029154d1609ef22/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b973887ff782cfd6b67c9904ad8ca542e0bc5e4961503408b423b5a688b4d38", size = 1283739, upload-time = "2026-08-28T10:27:21.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3a/30ffb62bee646e266e98a1b5cd276d9c75b6116fbfcb87c1190838c1b6df/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f76fc85bd054c806960f917ec0f329e24e436f1712267d90588e4c39890caa63", size = 1301681, upload-time = "2026-08-28T10:27:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8d/13c70be22a8506880b35fdc38dca36629613bc493405c79f4037f2cd2bb9/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:828f75af2b0080c8a972e75f649ab46af008e92c6104a57a759157200b835b75", size = 1349159, upload-time = "2026-08-28T10:27:25.899Z" }, + { url = "https://files.pythonhosted.org/packages/82/0e/993972b8ec6767f47cd69818fb3a5ff14510557d29f7d1a839be7574fa1b/kiwisolver-1.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:431dc224a1a92a5c8f582d96e505196a3b5997a7271076678da2dfde67b77e9a", size = 997613, upload-time = "2026-08-28T10:27:27.507Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/ca26eddd2eda2420dfc56693449c1f821f78b485da9cbde9904c03af3f93/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:61e9a64c7635095a6bfe483e2ff055d437c59bd45f3617a228b37277f0185d62", size = 2235109, upload-time = "2026-08-28T10:27:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c7/84/97d920881e10840b8d7c7185620298e3e4c88820b05514e3a15a258b08a6/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3c24cd69455e1b00ddf770c13b6e2c33e07d6dc3f2d34add0bf9277c5c6bbd46", size = 2331207, upload-time = "2026-08-28T10:27:32.795Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ce/34d74b8f25acc58800f4c09268371e8d6159cf0f1206f1e4dc7835629b48/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:27add358abe374ebaa3b8763ef380bc99051b5a4b18d94878366a9e4f59efef0", size = 1986696, upload-time = "2026-08-28T10:27:34.628Z" }, + { url = "https://files.pythonhosted.org/packages/0e/01/f892644014612527aef7031d3306a2ffc60b3cb044f802c1561f8e5e14f3/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:255605693a483db7bd5c79f60437f7bf658f7f520d61aa42722e32257c941951", size = 2496400, upload-time = "2026-08-28T10:27:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/32/6d/d8284e66e697026536e5f418b9cfe56567bffd3c775e3ecfbae373605854/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7d38b0c279c3032e8c9cc013b405c6df8e1668dbf15465779aa7f15f61201812", size = 2302967, upload-time = "2026-08-28T10:27:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/46/0a/69a355e27f32ba50d5b6369949b6a1702e122f5277c89bc76d452b81c1c4/kiwisolver-1.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:958254518717542d02d0688d0d20cbf771da5e415e6f49543f92481c850a4540", size = 72283, upload-time = "2026-08-28T10:27:40.491Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/7a95be90c33dcdd52204d4aa6384d731443225b887283bbd8b61e7931f6c/kiwisolver-1.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:da3275833be0edbaf4830fae08bae3dc7219f40ce0c37eaa6c25825957e06612", size = 69860, upload-time = "2026-08-28T10:27:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/31/f8/9bc493e7f5707788ba7f621902c68f82dc3a7ba03c78fbd337b026cef1ed/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:470d420f98d368d6f010633a20659b544c5fdfa5329e6b70219f2ef08fd4a7ef", size = 126336, upload-time = "2026-08-28T10:27:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/3aea86b3f99712db825e9ac5631bf99571e818a8b8961ff98cebd798413e/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:83f78128fa28705fa85d01c59771c72fe81c11bd0e6155edbb9f818983a7d761", size = 67698, upload-time = "2026-08-28T10:27:44.613Z" }, + { url = "https://files.pythonhosted.org/packages/84/7d/8daafc5d2e7f9c47a4f78f8865d86d2a9cf399c2a85f86c44a993594410c/kiwisolver-1.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9506e892bcc3b409831d363c6f53e5985e1c8d1f6f6b0256d00358684ff85378", size = 65945, upload-time = "2026-08-28T10:27:45.932Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c9/51dc974d9130da70a8c47a96160123443d387ffe1b6b833d6f91d9429339/kiwisolver-1.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cea90547bfd93807e0013a004dc76552be44fad3bc1cc2b38610a9e889ed098f", size = 1588030, upload-time = "2026-08-28T10:27:47.678Z" }, + { url = "https://files.pythonhosted.org/packages/e6/86/f3e1a730e7a995149d8d3ff9e313b6d8a17b2cf1d98a8eff139dc30463fb/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8e4d953faaded9ec7ede36824e9814082d22d4c7b1eafbfa079ecba8cd0d076", size = 1390760, upload-time = "2026-08-28T10:27:49.676Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/8ba25a2b5a0d2375e046f1b72de5179513f0be95aba6e7b094c89303929f/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e9c01d3dd7ceba4d1d436cc021d40d592466e40b9bc7f5d83dc4e98a5c9cd8c", size = 1403279, upload-time = "2026-08-28T10:27:51.242Z" }, + { url = "https://files.pythonhosted.org/packages/18/d0/278d5cb8be812740027d5ca0a7eda0c375488a88d6dce0fa60fcc2591ad2/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f801b5d7cc0e5a548921308e059fd2b057bb42972b591cfa3049f95423c4ed", size = 1454429, upload-time = "2026-08-28T10:27:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/94/37/bcbab41063ec284c1d200efe5087cf087798c2f8916960aa8a20dd303290/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:e68e151428b5384f766cd25739bf77c7e4a3dc93b5ded7a12118d9fbfdf78ab6", size = 1073225, upload-time = "2026-08-28T10:27:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c5/ab79dcdf5ae28909a51210ae0a1c579e97ff997b3466414f0d04c0994583/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8f8fddb8e323bd6eee4e54e69a39243beab22689070f4c66b472c4cc88bb89d8", size = 2334335, upload-time = "2026-08-28T10:27:56.78Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8e/71d047468a189041d9c93f3b76844b924f9793b188c44bd149fa258912da/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3cc210010fd2f438a3ed430b45f1b501fd13a8618bf984dc2c5ce5b69b78752e", size = 2424982, upload-time = "2026-08-28T10:27:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/1347461bbea6d0e1f0580b94ef603b18e72c2be5f667fa1653867361a00b/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b5664603a253efd3a75716d793d1d3a6a82723b61dc6db767b2460bbbeec4c0f", size = 2062857, upload-time = "2026-08-28T10:28:00.407Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/c7f0976c9cd0d127643351bc0c9929e0b8899d7f49d4ec238cd909e39c42/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a7b85b2cc6ea45e5f7e8c9a30bc9fabd47cda09106cbb4b967335c3e6c43b69d", size = 2596022, upload-time = "2026-08-28T10:28:02.183Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/59a6f6ae6f938c15076be2c21b6cedea973d71bb1349ec84fa485fab82cf/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ab620eb663952455271ac37f9aaad86b73c969c02f11f53cea405b38e96a4300", size = 2395634, upload-time = "2026-08-28T10:28:03.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2d/8982c1fb7926da5bb7ed60318c3665b5c3f941447271ac982960a11b8637/kiwisolver-1.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:cb6fae641357ed2f6e533c0d3c6504a4a5703621a50c89459e46051d56b61140", size = 75379, upload-time = "2026-08-28T10:28:06.307Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/ba7b6dfa1708b82b373ac056928a30c545d5c1a627df9839dcec3c6c1881/kiwisolver-1.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:b390aec180a7c054919c04898835e1c77bced23ea8383eb2c570213bf25d1a86", size = 73011, upload-time = "2026-08-28T10:28:07.578Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, +] + [[package]] name = "legacy-api-wrap" version = "1.5" @@ -470,6 +802,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl", hash = "sha256:5a8ea50e3e3bcbcdec3447b77034fd0d32cb2cf4089db799238708e4d7e0098d", size = 10182, upload-time = "2025-11-03T13:21:11.102Z" }, ] +[[package]] +name = "llvmlite" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/c5/907cec40688a34eb489cded74d555e1ee4af8cf49d83e03dba2c2d4cfe27/llvmlite-0.50.0.tar.gz", hash = "sha256:f2a2cd6ec9ffcc1b7147dea0d7a49efebf17a2b434e0c2844fe175999d571eb4", size = 194522, upload-time = "2026-09-29T18:44:46.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/1f/2576416b3e9b73f77b8331b7f2e41ce5ae7bbff0489eb16d98099a71693c/llvmlite-0.50.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:55f50a6b7c0b8de88b05d6bc407d70a60486ce024013997dc97e202bd187c75b", size = 40534277, upload-time = "2026-09-29T18:42:56.244Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c4/e86f30b2b09c310c02ffdd8afd00f7e127d365131d163c926c98fc3ece22/llvmlite-0.50.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e8df54380110ea5e9127386e739d2b0829cc6dfa4a24a9195226336c91b06d5", size = 58344485, upload-time = "2026-09-29T18:43:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/4c/72/22b6449e15bec4cc86c62b659e6c625ab777d01e87aaec717ecef440f87a/llvmlite-0.50.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d501e5103076b9a14be885d2574dc2f6793171aa54a853d1244e011d476f1399", size = 59696588, upload-time = "2026-09-29T18:43:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/64/70/f395702c20b514363061055b5bdebe3513e544139e6d412a5c86e8ea0b30/llvmlite-0.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:c20595cc3a76e3c85140fdafbf9246c732ddf8e0e646ba2f4e4881f87567300d", size = 41865553, upload-time = "2026-09-29T18:43:08.29Z" }, + { url = "https://files.pythonhosted.org/packages/a6/86/9cde7ac29e183e994dd2d67c998752c66ff6d714ca61837428e1896c3cc9/llvmlite-0.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:4b78a8b669eda09ca1ff4c1a75003023912092974d3e771d1da0777f1b383bdf", size = 37441845, upload-time = "2026-09-29T18:43:12.054Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1f/1d585b2122bcc9fe1615c0097730baebdef1b80e6acd07fe921ee501576b/llvmlite-0.50.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a32980e3d727b0e56974ad89d0764920048602a75805b8917cc0298e798b0ced", size = 40534276, upload-time = "2026-09-29T18:43:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/21/3e/d5dbbc80bd87c3530bae1127cefce56b36434cc8a7fbbac281309e2af435/llvmlite-0.50.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dde9836d144c446a303b57b2dd906c35308411eb07f1279c1db581d3d774048", size = 58344486, upload-time = "2026-09-29T18:43:20.663Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c2/5e9d0773f1589397a3ea3dcfa4bbee36e2855ad938d738dd6ff9f505a59b/llvmlite-0.50.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:425845f415a06dc50db08db033c6b568e0d85c4937e932c605a4d49e1514b2da", size = 59696589, upload-time = "2026-09-29T18:43:25.605Z" }, + { url = "https://files.pythonhosted.org/packages/d5/17/894321d44cf94fa5cf921eff4e7ff24c7732c3d702236d40d6055b68a693/llvmlite-0.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:266a6a29be71c3e3a22960ddcedf66b4e0388e5abb6cc4991cc093d6df402ad7", size = 41865552, upload-time = "2026-09-29T18:43:29.755Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d7/c3c3a70f057c18313515af3bd970c1faa348121e2545d6074f22011feca9/llvmlite-0.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:1cb21c420a47dcfa56223228d013c6f9d234e05e06e6819a41638d78bbd78e6c", size = 37441843, upload-time = "2026-09-29T18:43:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/b8/08/eecfccb51bc016de4c1fb69da815738076a186158fa61d3cae1458b8f44a/llvmlite-0.50.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:ecdc9fae295da8ac793578a27020515e24d970513143efa227e696582aeb16e6", size = 40534277, upload-time = "2026-09-29T18:43:37.013Z" }, + { url = "https://files.pythonhosted.org/packages/9a/96/011ae57fb82e326a79da1c4767b8206502dbac041068b37f1fbe73893a55/llvmlite-0.50.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:987600ce6f7bd6d808f4bb0ea61a8eff2fd17cf32355691e801eb0a65a7304f0", size = 58344485, upload-time = "2026-09-29T18:43:41.242Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ed/54107648386edf3da7def03d42721c72279f6bc2e17b5274c18955dc5833/llvmlite-0.50.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33ddf12b1e12d7e551e1c1e6ca8087d0aacc931f480019eb33ef2ab77681da4d", size = 59696587, upload-time = "2026-09-29T18:43:46.132Z" }, + { url = "https://files.pythonhosted.org/packages/d1/af/b2e5f9ee84f05a794e62626d83a934e6fccc7a83740918a90cec85df2d6f/llvmlite-0.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:7ae211012c6849528a5f7cd17a78d8b2421a2813c7b4184d6c0b2ffa89a7d296", size = 42986708, upload-time = "2026-09-29T18:43:51.123Z" }, + { url = "https://files.pythonhosted.org/packages/3b/df/6d9ac4237f78bc81e6778d87ec711c6e5ec0fac73f00907b149c414b48b5/llvmlite-0.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:e94f9066f1257a9cef6c832e6c9de0f140e2bb150de2db39f657b2a5996e0f6b", size = 37441844, upload-time = "2026-09-29T18:43:55.097Z" }, + { url = "https://files.pythonhosted.org/packages/d6/23/0f9d73a3603fee0d32a0f66996e00964154f07681c0b0f9c7212e896cb2d/llvmlite-0.50.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:423c8d89d13f7eb4488933d5a86b0fa952927956298cfd0087f6753b5123b5df", size = 40534276, upload-time = "2026-09-29T18:43:59.379Z" }, + { url = "https://files.pythonhosted.org/packages/34/14/45f56e4cf192284ba6cb3020ed775d47dd9c69e7fb605f7523047ab16d7f/llvmlite-0.50.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:944133e9621d1dfbfdaf0fed3234b99f85e6ba27c38f4045acc8f8a5e699a5c0", size = 58344486, upload-time = "2026-09-29T18:44:03.923Z" }, + { url = "https://files.pythonhosted.org/packages/82/f8/45f08fe27bd96fa38a7199024d842d6ef502054f1f824b531d55cd533c81/llvmlite-0.50.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d5b6eac064f201b4aa091030282e6f240d8d322dddd7381840731455c3e664", size = 59696589, upload-time = "2026-09-29T18:44:09.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/68/e00620b48cd6fd71369877ddbfa000854450b843c3631be41226e8b8f7b1/llvmlite-0.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d88c9b325f5fbefc79d95b1daa8fb96018c40bd2958103eea7334e6c8f17fb40", size = 42986716, upload-time = "2026-09-29T18:44:13.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/97/78e51381def071781a5ec9ead92e2a55562da5b78043566865e20f30be77/llvmlite-0.50.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:3f490c0f4800c8ddeee6a607acd037497bf6508586804f4e2f11f53a1ee7fe2d", size = 40534277, upload-time = "2026-09-29T18:44:17.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/1beb6169126cd1a8199bae88eb3a79e3be3dd609eb42896d8fa8c38b10c0/llvmlite-0.50.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d5447a6c39171368edfe28a71f605e6e3edd40a1dc31f5e5c9d50585718ae6d0", size = 58344486, upload-time = "2026-09-29T18:44:21.407Z" }, + { url = "https://files.pythonhosted.org/packages/7e/81/334b11c9ebc52ee5339fe401342b2dc856804996fec3abc5ad70ad053901/llvmlite-0.50.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1ac2b9f699c46219fbbd66b304105f5e1b218f05ffac6fe03cd851f93718e58", size = 59696588, upload-time = "2026-09-29T18:44:25.755Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/f06fe5d262f0cf0f0c85a85b0a4aaa07cbd85a56192861299fd659af4eb7/llvmlite-0.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:51a4a716db98591f0a1bea34c6548cdb4017731ee5e678ded8cf842dca8af3c5", size = 42986709, upload-time = "2026-09-29T18:44:29.203Z" }, + { url = "https://files.pythonhosted.org/packages/be/f9/670bcb2a7214dcf35c48da581ac8d2949ff50255deb83e13c9cbbef46c05/llvmlite-0.50.0-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:e8cc203c1fd509131cd72b7554413d4a3e5527cc5558c5a7ebe19840018c57c1", size = 40534277, upload-time = "2026-09-29T18:44:32.967Z" }, + { url = "https://files.pythonhosted.org/packages/f3/21/3d108d6c9a87142927073fbc3d82d161f2dbfdeb046063a51edb196d1132/llvmlite-0.50.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7d4e2bbb29a860a6e85e22afdb96696241263942a5b214cac3e4b704e1d3abf", size = 58344488, upload-time = "2026-09-29T18:44:36.859Z" }, + { url = "https://files.pythonhosted.org/packages/6e/de/496d19b7a54acc487266ac7fa39d902cddf24998f5266b3aa499c8eacbd6/llvmlite-0.50.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:afd7b438c60e0f60c4368ec603bb9f20d938a203b5f59b80bbe50c749b4b2f16", size = 59696591, upload-time = "2026-09-29T18:44:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/93/73/72553170eada174775d9a738c471c7be4ab3dc2c06368beeee89e002345c/llvmlite-0.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:4da0e8c6e6f144b433672a632f75d6b4da7bd4fdb5c3e9981d6ea6741319aeae", size = 42986722, upload-time = "2026-09-29T18:44:44.491Z" }, +] + [[package]] name = "markdown" version = "3.11" @@ -592,6 +959,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, ] +[[package]] +name = "matplotlib" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ce/fc58855a3d17f6cb8a87b6f9ccd100f65432a3a7ffed57309cbf14c78987/matplotlib-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d43ff8cebb50840648cb6429b2228621dbd709010f3117b3740abb20abf21c0", size = 9476987, upload-time = "2026-09-11T19:03:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/2a47b700545aca050e0ed73c89ba21e08407e11f5f42a567421b220de6c1/matplotlib-3.11.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a8285cea8ef4d92aa041d1c33788bcae82248503300f93f1ca2136b9049452f", size = 9306050, upload-time = "2026-09-11T19:03:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1944895967f87c84c9b4bad29a707b31f5b36df4a3a2339ea1f8ea3ce5105539", size = 9861060, upload-time = "2026-09-11T19:03:46.044Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b9/b978de6d43d47f1d4d8e653fac32c16d8d2e5f46bbbb822e26d7cb1adece/matplotlib-3.11.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af2661f6ac6bbd1d081996f54fdd9385715c625ace8cec0869055c0cfbf38981", size = 10670885, upload-time = "2026-09-11T19:03:48.942Z" }, + { url = "https://files.pythonhosted.org/packages/a3/80/d72f61bb2631ac15d17fada1f17e0f99fecba69d8376d34f17cf93b7eddd/matplotlib-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e5a90f8a707ebb6004a713b1cff091a40cc5df4c7c1cb165e5a505ebc11c292", size = 10811222, upload-time = "2026-09-11T19:03:51.665Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/6d1d33f242c705b5c6c6fb3f0fc141add51cb1abb62912e59de0835d28c4/matplotlib-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:bc067c462a86f0e57bf52fc6e058d90171a5420007dac00c46e90153050c69a7", size = 9349539, upload-time = "2026-09-11T19:03:54.534Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bb/c0aa86a7f4c0e62f1c3c0711e361f6f23f87eb9018732a5e11994bd1b7ec/matplotlib-3.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:3e8576f7c47e02fd4f21f44171302d2d1d58d4471d46da3d71fe8899d19539d9", size = 9028295, upload-time = "2026-09-11T19:03:57.261Z" }, + { url = "https://files.pythonhosted.org/packages/54/b4/4facdb700bc236e4c676495ca3798d8134ab8668cbfe0f6b2e9ebf962a38/matplotlib-3.11.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9721f81275499da1feeb36a2cf8192ea086283b3bd16b7dc4c9d7aedb7396d6", size = 9478976, upload-time = "2026-09-11T19:04:00.12Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a0/bba05f0a25bfeb0475c2f2a87958b31103aa6b7a5386017afb1a96212825/matplotlib-3.11.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a3040b209f3968b4e84161df7b174f07a9fad33b0f2d7e48ea3bbd3075e2863", size = 9307932, upload-time = "2026-09-11T19:04:02.976Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/81d5cba4bb39b41b0998efc880e129f2d7cdb94256ca49678fdcd9c21be5/matplotlib-3.11.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3fe71fbfb8ec0e310e0bc8537c3405a01f38f25f9394ed2135e6202fed542b", size = 10674370, upload-time = "2026-09-11T19:04:06.912Z" }, + { url = "https://files.pythonhosted.org/packages/29/5e/52f56f93d5b20815ee0ae352f7a38eff25580edb180b4fa7df87cfeba68f/matplotlib-3.11.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c8255de28f986d935a64c9ca71c0ec2d2f41d355691f5ea684725dc91413f71", size = 10954597, upload-time = "2026-09-11T19:04:09.948Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f8918a1b564db49c53cd603eb3c8748c5767e4ce0fb2cf1d7c38653e4c5d/matplotlib-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8756cc73d9af9a7fe0deb54ea2e75ef73b01d9e575877e72acad5458e660943", size = 10812583, upload-time = "2026-09-11T19:04:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/4315ef0d63937fe56f451887b4a0be3b8e8b22cf1d92a8d157caac54570d/matplotlib-3.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:ecea603dd2fbf8242fd31a305a8b12a4ece2de28096870c65fdd0d1e35b8d9a6", size = 9505972, upload-time = "2026-09-11T19:04:15.788Z" }, + { url = "https://files.pythonhosted.org/packages/d5/00/02398b0a1a62ef73d9af0a957fe1773363eec9a6ca7e433c72ae2790fbce/matplotlib-3.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:01dc8eaaab5a9fce9ff615eca82345728f289e4715b186ee10c6d85272fc26bb", size = 9184534, upload-time = "2026-09-11T19:04:18.691Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c5/1e6ca10baa7e04f113c37856aadb4adc3dbb1c55fc7da6dc830bba1292d7/matplotlib-3.11.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79a258f58253dfa025af80a9e9bb228d75fced007f0e93ae7423fefbde81a74d", size = 9526415, upload-time = "2026-09-11T19:04:21.39Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/7561727af07ccc84747953c6a33eee3981cc436d04f465032e707ca0e429/matplotlib-3.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c0b83f044ce10a98027b105b3931548719a6e8c7ef986b4362651e0b5367c8dc", size = 9361064, upload-time = "2026-09-11T19:04:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/c1/40/ce270ef2a6794d94a409fe28b8dabedffc07c07f9e5dbb89e0f605bd157d/matplotlib-3.11.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e43b188f0a5b75447bcc197728258166aa64365770ed1caa36595a5e1ca4bbba", size = 10684626, upload-time = "2026-09-11T19:04:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/396e0307dbf1c1f2d0a3654caa6dbc780dd5379f5f11257369cf8d21d9fe/matplotlib-3.11.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3304eb5a59442a8867f6920d484591c0fa09ffc29e9260be2feec3351e25869", size = 10960602, upload-time = "2026-09-11T19:04:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1c/97/4defb84695477340aa88b27c9482cc1ca4de11f36f66b6c8c0ddd685def1/matplotlib-3.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:02329432ae5c6af87cf208ee575b701d698bdf0b1a3bb28cc6d53c36e967e575", size = 10822603, upload-time = "2026-09-11T19:04:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e7/eab54551c1ec6b72dcd678a078b6c6da8d4c85ec1ff0016e81fe52866304/matplotlib-3.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2ac30cf5eb5dff1b584627ae0b0e1186551a4f69ae3c75073911da497a29170", size = 9551206, upload-time = "2026-09-11T19:04:35.811Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/04698032cb4d8fb1e30d8ffec5ded20f6f582b03c8c58b2385c95245f158/matplotlib-3.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:cf41ecd1b0c0b6f7177ed965a54c2afbe888715c7cf6054dc12d53bc1494002c", size = 9234977, upload-time = "2026-09-11T19:04:38.972Z" }, + { url = "https://files.pythonhosted.org/packages/44/a9/ca827c184b87364378964e36e7c0ae23651d146fdbc4000635205490cf82/matplotlib-3.11.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:894a9cbbecbe30ae6787d464df2e8fc7a8d475cfc68f87c3029f7c11152899b3", size = 9478997, upload-time = "2026-09-11T19:04:41.914Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/01aaafc9c3159f3fae9a65bbcddaa4a6d4d72b0aeffdd636d7f6d00a5c8d/matplotlib-3.11.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:bbf1062991d826ed27e2144f3afa4461afbb8ff56e8f703043e191a8163b1ee9", size = 9307947, upload-time = "2026-09-11T19:04:44.834Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/6aa72c7cd7cf4dfac652744d7a3310007cec3ce8e5ab9be607bec7096471/matplotlib-3.11.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:399fef672f7046ef7d6a57572b2a6f9845f3f5afcff04e7b2df7a363a9f42190", size = 10674370, upload-time = "2026-09-11T19:04:47.745Z" }, + { url = "https://files.pythonhosted.org/packages/a1/97/d7e90225ac145046787a2ee0fa4cdc7e4d5cb6772ea232130b10f8a4095a/matplotlib-3.11.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc82dde2a0d3e3ad472edce04897ad7146b8d8bfd1df8a32992eebb81af18fdc", size = 10954581, upload-time = "2026-09-11T19:04:50.712Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/89cf056a9d2cae01f4b8baf01c7e809d613244fcdfa2a2acf28c1e0d79fa/matplotlib-3.11.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:930efb28f59fda124e39265d177bab302625297bb147d2910de725a2fc2aef54", size = 10812605, upload-time = "2026-09-11T19:04:53.552Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/c61b9f4c34af802d4d84e58803e009013d626b5f468bafe1416131863b5b/matplotlib-3.11.2-cp315-cp315-win_amd64.whl", hash = "sha256:3da3bc0cbf7245e7db72cc6d29d12c5abef72cb73059c73b945f14e3545f3eb2", size = 9506068, upload-time = "2026-09-11T19:04:56.519Z" }, + { url = "https://files.pythonhosted.org/packages/03/7b/047f2fa8708d47430d8e7fa45e9fffb0aee5d4e300f978e087be2ee11fbc/matplotlib-3.11.2-cp315-cp315-win_arm64.whl", hash = "sha256:f25446b2981717dca9786bac841cb3fd7efb568e3e3c755dd980481c5cb9228d", size = 9184576, upload-time = "2026-09-11T19:04:59.318Z" }, + { url = "https://files.pythonhosted.org/packages/03/6e/ad9c5989950785a98642667df265a47c6637e924cb69b5dcf68c3299ae7c/matplotlib-3.11.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fea03cf56568cc1cba08b470be6a0559e71c3a5b688d54b7179bb35ba23d0821", size = 9525988, upload-time = "2026-09-11T19:05:02.304Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c3/25c61701fb655dd412428b15eee2591ce554cdd593e39a045a153373f428/matplotlib-3.11.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:254d4ddb2fa8df3b4c689c0c306063aee10521df82cfb438185e499c75fe37c1", size = 9360774, upload-time = "2026-09-11T19:05:05.092Z" }, + { url = "https://files.pythonhosted.org/packages/58/5a/4552878fce8f9d249a5e264c16f5c267c27eac08fb039fa3e207a06feff5/matplotlib-3.11.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60cf3047a51edecdc4535a9196bbd6a732936b8e9f8184aabf4d16165884aaf", size = 10685892, upload-time = "2026-09-11T19:05:07.686Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/b3b19ec0124fc4aafd8a4b3bae859c562a7ff0fd8ae3b2099947f78bde19/matplotlib-3.11.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3712dc9b464793de0a4e42a7313d50293c751f94bddaf7332a1bc71bccdda9", size = 10964946, upload-time = "2026-09-11T19:05:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/7b/30/687ccce22f66a57fed85942450e88062773bcbf1a42914e70c7148ec71dc/matplotlib-3.11.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:75b6d88402770e181b5d06a67dda4129c31da7d05004d63a21c310ec78d1b83c", size = 10824960, upload-time = "2026-09-11T19:05:13.9Z" }, + { url = "https://files.pythonhosted.org/packages/d3/34/117fe35a8d05054b4b34d2d29287099927c24c3841d36e06d9fa2f20c1cf/matplotlib-3.11.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a6939df7567114b6bac7f4c5e06c84a67f197c1b2f2e4b234d4eecb3bfec9482", size = 9551204, upload-time = "2026-09-11T19:05:16.48Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/9f440e7bec8b80d0af2a65d1165b2f4e718982dad785531f62255d9ff19b/matplotlib-3.11.2-cp315-cp315t-win_arm64.whl", hash = "sha256:d480038c83691532ed52ff3147db51fa902fc78cb2d8349993a1cdb684435bff", size = 9233946, upload-time = "2026-09-11T19:05:19.063Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -807,6 +1235,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/d0/13860e6c8a06bb3fccc87e38966ed8ae98535aa44613b5fba0329ead0327/mudata-0.4.1-py3-none-any.whl", hash = "sha256:adcaeecec06aec0d7576f5f18ea474b530d661759d775dbed67fcb91fb3c4c41", size = 43919, upload-time = "2026-08-17T11:40:28.672Z" }, ] +[[package]] +name = "narwhals" +version = "2.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/21/f64d6b2dbea7bf3f8c38cdc786dcc6ef012ca3d173ad208c782c9a7bedf6/narwhals-2.27.1.tar.gz", hash = "sha256:aed93076a3ea42d9c32c88e4eb5ea422a21937011cbe1f480f9572a523c82094", size = 735013, upload-time = "2026-10-10T06:52:18.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl", hash = "sha256:d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31", size = 483211, upload-time = "2026-10-10T06:52:16.32Z" }, +] + [[package]] name = "natsort" version = "8.4.0" @@ -816,6 +1253,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, ] +[[package]] +name = "networkx" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl", hash = "sha256:e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0", size = 2142205, upload-time = "2026-09-21T16:45:14.609Z" }, +] + [[package]] name = "nodeenv" version = "1.11.0" @@ -825,6 +1271,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] +[[package]] +name = "numba" +version = "0.68.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/cd/e8280f9ffa30fea9fabc5341223701231fcc5d53a31f51419d42d4bec3a6/numba-0.68.0.tar.gz", hash = "sha256:8a781de54b980b98f43bff7f1093701b5f07c80d031c7cfa8a87493d8bf73f2d", size = 2855363, upload-time = "2026-09-30T15:05:44.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/cb/b6a39189f1f342baa04ad1055bb5f63ec4061ec1f80f6b34e90c68fe1e7f/numba-0.68.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:0fdaa2f0256862ebbcd9632ef01ba2a4b94e6d116029e5051a92340d4050a501", size = 2760509, upload-time = "2026-09-30T15:04:53.181Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/aa2cefeef784c5695790931938944f76ee66d3c7c640f62326f64642f1c6/numba-0.68.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3ee1f49b62efbbb804f731f2bd602bd1f8b8d3cc13009f25d69955675f82407", size = 3600404, upload-time = "2026-09-30T15:04:55.11Z" }, + { url = "https://files.pythonhosted.org/packages/6f/40/2211b4ff48cccfb21d4c38fb56788d7a975189883efb8d549be9d51aba7d/numba-0.68.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fe913a70fe9a7a0b193757ff977a9e96c82ae936ae388aec8990814fffdf9d", size = 3888027, upload-time = "2026-09-30T15:04:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/7e/2b/1b1f8b118cec28513665d8a53ff4f037d6c05720bd9e6f32f947c93c367f/numba-0.68.0-cp312-cp312-win_amd64.whl", hash = "sha256:530961dc7e41ee358eca2b828baf7b645ce6fa466d778bb9dc73855dd103c4f7", size = 2830891, upload-time = "2026-09-30T15:04:59.747Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/02626d27333ce1f67516a059e22d65f8f2309f227d3b828d2599183d5dc9/numba-0.68.0-cp312-cp312-win_arm64.whl", hash = "sha256:25aa7021e163701f9b3e8e77be81836a4b399500eef073d75bc906ad5eff46e9", size = 2812331, upload-time = "2026-09-30T15:05:01.802Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4d/42754c94f8f909b9981fd44d28292a93bca6429d93f3e1ae58ac7de9b08b/numba-0.68.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:b8b29602f57df06c724fc53b1740887bc4332f202206771d46e47b25b485e904", size = 2760360, upload-time = "2026-09-30T15:05:04.386Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1c/8bae32109a826a49666a9645012b98d6e09ad496932a877c97a2c39dde50/numba-0.68.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df6f881c5695f472873d0979bab54261959b3174b6c98a71f6f8a43c3e088985", size = 3560908, upload-time = "2026-09-30T15:05:06.832Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/0b504ae34d1b79a6482a0ffcbfd1b103dde02329c11525033e02633f7984/numba-0.68.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be647fbc60c18c0323b34479f80173879654894eec58ad061f4b1901e294d854", size = 3848615, upload-time = "2026-09-30T15:05:08.976Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/06d1dd4553dcc71a3a18defe9e6e26e3c011b566bc9060d4f6e4bca0e0ed/numba-0.68.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf7435c81912e271a28a19c348ada5b3986e2409f95a067533c5f4aab8709295", size = 2830730, upload-time = "2026-09-30T15:05:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/93/d8/6b01de5fa7b4c3866c0fb680833fd58b4fc48d1e7febb46e992f0b0f0e7b/numba-0.68.0-cp313-cp313-win_arm64.whl", hash = "sha256:50e3c81d8bf6956c7d7330a985bf1468efaa9e4c4539c9fa0ac6c7866ea6e369", size = 2812090, upload-time = "2026-09-30T15:05:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/71/a9031907dd0fba6cfce34004398a05f090b692be811dd1f38fdd874dd4e1/numba-0.68.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bfc890c9ca517823dfae0444595ef50d883ade9d3e17759d9a7650e5d128d950", size = 2760551, upload-time = "2026-09-30T15:05:15.753Z" }, + { url = "https://files.pythonhosted.org/packages/74/70/c03aebc576ded2204e5bde9b86b215f0590a81261af333d4239b9f0aed0f/numba-0.68.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ccf54fd9c1d5f4ba00073b81bc492a681f5437c62917fe29813f457564e312", size = 3561561, upload-time = "2026-09-30T15:05:18.266Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5f/2bd2fd4b99b0b5e76fea2f1fe149e05a7ec19a9a177758688bb82c7e3126/numba-0.68.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea11c865265e39a6019e2f0fe62743825127b3b7bc4815916f5d5121fd9b262b", size = 3848766, upload-time = "2026-09-30T15:05:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/0c/41/3e3528f3b0f9ffae69310d2e71f81ff74d272ee3b6c0600c4f4abaa31a80/numba-0.68.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c03de7085f08ba11ab2444f252e822c14cee5fa02b73e84d5afd5e28b2bce0f", size = 2832584, upload-time = "2026-09-30T15:05:22.621Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9d/1fe8be8f3a43d339222a4aed59be0b8f4920f10465d4606c0428250c63f7/numba-0.68.0-cp314-cp314-win_arm64.whl", hash = "sha256:f58c13a6e9bfef062311cb0d3c19f6c159b901213daa325e1db473946010cec7", size = 2812334, upload-time = "2026-09-30T15:05:24.848Z" }, + { url = "https://files.pythonhosted.org/packages/89/3b/e0e31617568553ca2b18bdf43844c44893dfb6620bde9a88296c257c5a81/numba-0.68.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:79160dc2a3ff0e02aaada2c385faa6de73d71a11f06419d29bb0a90042d243a3", size = 2763380, upload-time = "2026-09-30T15:05:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/405b416800424b005c179c5b6417eee2aac1933839257ca50c855397774f/numba-0.68.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a3aa5558ba1c316020a0c2f6042be6ae063cfc6eb0c7badb3a0c77d2b5308b7", size = 3604721, upload-time = "2026-09-30T15:05:29.164Z" }, + { url = "https://files.pythonhosted.org/packages/e1/52/fc100dc163e12ba6a8df4c4f6e34f55d24dc6e97095f935996406d8cc946/numba-0.68.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a08750c81fd5c2d9f2c169a73114efb907159401dde9ef4a3b629fa45e097cb7", size = 3887891, upload-time = "2026-09-30T15:05:31.234Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/f2e074c5bf26f236c34075d390e77ed2a787c7350791b39b099b151e2033/numba-0.68.0-cp314-cp314t-win_amd64.whl", hash = "sha256:cad7d5f6fe8eb42a69c500d36c94a61d094f3b91a7a5581a31d1df2eb925d33a", size = 2838113, upload-time = "2026-09-30T15:05:33.274Z" }, + { url = "https://files.pythonhosted.org/packages/a5/85/d7cee7a6c65634bd25cb0109585785e5c8338f44db4b191c30291d9c7968/numba-0.68.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:39f935bc854be87784675d9674f5503e56df5a501c95c95bdfb6b3c0b4b9ed1b", size = 2760868, upload-time = "2026-09-30T15:05:35.662Z" }, + { url = "https://files.pythonhosted.org/packages/d6/79/312e0cf6e835f700d42a223c1bd4a24b232892bded1ddf5e40bb3a329f55/numba-0.68.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cec6809fe93824e243a8a8c93966b0bb5874a3b7c24c1194c3bafee0ab11f39", size = 3568127, upload-time = "2026-09-30T15:05:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/5e/05/f31cd9e40f6d4ec6de38959e4736a917aa9d115fecc4a1979aceedcc083b/numba-0.68.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f1180e0332ad5143905288325485b52ac76102330811dc6f2c10088cf4cedc", size = 3853913, upload-time = "2026-09-30T15:05:40.247Z" }, + { url = "https://files.pythonhosted.org/packages/6c/28/059b2d1ea5616a5712fd722b2ec8e8278d14e4e4eb8845d36fe1658e6be8/numba-0.68.0-cp315-cp315-win_amd64.whl", hash = "sha256:a2d21bb9c4b4818a1e71721ebd19172f488591d548f08453593348b7048ba1fb", size = 2831865, upload-time = "2026-09-30T15:05:42.306Z" }, +] + [[package]] name = "numcodecs" version = "0.17.0" @@ -1019,6 +1500,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "patsy" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/e3/950e513affdfe1c1864397b4f40d6ed06ec1b54f608e462c27f151dd72df/patsy-1.0.3.tar.gz", hash = "sha256:79ebf4c93ff4d296e58a9d5be2b2ee31bd49d737cf11d70ffbd8a44b2de42e65", size = 399992, upload-time = "2026-08-29T21:35:32.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl", hash = "sha256:d3dbebe8fd5f46e29912d030b63c6268647b59bf788a99e2af28a30234cf357c", size = 233318, upload-time = "2026-08-29T21:35:30.913Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.12.3" @@ -1261,6 +1826,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl", hash = "sha256:4a254b771acfcddc6c110a7f4590d818f1f849e2d5aacf69b4b07c8dd2a9700d", size = 277069, upload-time = "2026-09-23T00:08:40.069Z" }, ] +[[package]] +name = "pynndescent" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "llvmlite" }, + { name = "numba" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/fb/7f58c397fb31666756457ee2ac4c0289ef2daad57f4ae4be8dec12f80b03/pynndescent-0.6.0.tar.gz", hash = "sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5", size = 2992987, upload-time = "2026-01-08T21:29:58.943Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + [[package]] name = "pyranges1" version = "1.4.5" @@ -1450,6 +2040,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/4f/f31be46f6fea9773cf15c6848ee1ea70b1c5c5ab14ce32de7793129d4c3f/ruranges-0.2.7-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:42247151c66333e89694760d1ad58c8759ac5280bc061c6776cbcbf2fdea3888", size = 531409, upload-time = "2026-08-13T14:27:57.264Z" }, ] +[[package]] +name = "scanpy" +version = "1.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anndata" }, + { name = "certifi" }, + { name = "fast-array-utils", extra = ["accel", "sparse"] }, + { name = "h5py" }, + { name = "joblib" }, + { name = "legacy-api-wrap" }, + { name = "matplotlib" }, + { name = "natsort" }, + { name = "networkx" }, + { name = "numba" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "pynndescent" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "scverse-misc" }, + { name = "seaborn" }, + { name = "session-info2" }, + { name = "statsmodels" }, + { name = "tqdm" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "umap-learn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/87/ed936582014c002c3cc05e74318d01d9ed8f55aa05ef2772cdc28f47c202/scanpy-1.12.4.tar.gz", hash = "sha256:b87a5b1413ab33b69691df69c2084ff4ea8b389728e1542b22145825f88b5037", size = 20093589, upload-time = "2026-08-27T11:58:48.094Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/5c/b8cd4faf65463e7ba3a78d8dd4f60dab9e1b330eb395bd8f654c3b194b63/scanpy-1.12.4-py3-none-any.whl", hash = "sha256:a12a7ca9500e4b247fbb5fb9318fb204907125e411183bad3e9c62e5b988f37c", size = 2157501, upload-time = "2026-08-27T11:58:45.953Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/eb/eaf5e07fcc0da7149b0e084f24e54edd7441b9a89ce7e034032ae97fe3a0/scikit_learn-1.9.1.tar.gz", hash = "sha256:629cada3e33e2b9bf376cdc7614a47a4140b8aedc1d836579e359736fbd82977", size = 7786908, upload-time = "2026-09-10T18:34:04.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/a7/25f0a43d2fde306e8ef45f45121192f687b79beaf4bae8c21607c46c5e63/scikit_learn-1.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c0f8b5d09b44101cea2767f300680bada1ea27f976fe4b48b83950a4f55a49a", size = 8775209, upload-time = "2026-09-10T18:32:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/60/ea/57e57539ce175d774fc291ed091b0a6d756854b92cd92554c6bb4d0ae498/scikit_learn-1.9.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:8c14ce41d561f7749f990b41d6703fe02c4669fbc485e598e069e0a1967b488e", size = 8295541, upload-time = "2026-09-10T18:32:45.069Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/5721a174406bfba49bce20ae997b3b64cf355c3f623a2638284ab6a82156/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4c20a6c017d820faa7ac8c783e3d0c6a9a2e297bf9f55332ca17cdf7fd4d04d", size = 8871592, upload-time = "2026-09-10T18:32:46.999Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/a50162f3d29feb979ab6347c6debda506dfb525bcff3c50dd17606651c7e/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5d7b18a5b9dca241a74695f3275fa4c895a9dadc72b3d8df5fa9d1083c9b83e", size = 9166043, upload-time = "2026-09-10T18:32:49.343Z" }, + { url = "https://files.pythonhosted.org/packages/72/8d/27c054166bac671770d1ea0ef7716134fe8119c0df224a58c89fd735a61c/scikit_learn-1.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4b59abb30618121cc46b45972d6bf53a7128b4df4cd346c6ca6f4d5f9031e49c", size = 8262238, upload-time = "2026-09-10T18:32:51.679Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/493086006ea0c68ad62c40a8dece1961b61bf503f45400f133d47f56e5be/scikit_learn-1.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:d5945a2908be62350e2978344e62b56c1552c2ca4f844ebf6277c94944d647dd", size = 7902102, upload-time = "2026-09-10T18:32:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8d/b60d5e7354ff0ff5cc9400e60273696589d87a30b8b2235886a76d80d062/scikit_learn-1.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c2b312fd8c02951a364fa120ea08c1cec10d863466bf1701b013152d7537835", size = 8739300, upload-time = "2026-09-10T18:32:55.483Z" }, + { url = "https://files.pythonhosted.org/packages/2f/81/3c6392c03665d2899457a76e535a9a6f597dddddf3220fd2e1d790da88c5/scikit_learn-1.9.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:61cd968ab831a76d0ecbaf0347ab2270268716da28f94fd022497e3d6f205f13", size = 8262364, upload-time = "2026-09-10T18:32:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/0f/35/a15b8653499692879821301d48059376d6e68e8b65cd0f22d19b6ee83cd9/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5990f9c69e431bfaddcde1a6d7c5355243e026bc9b9e560c13893b90dab53fb4", size = 8823698, upload-time = "2026-09-10T18:33:00.632Z" }, + { url = "https://files.pythonhosted.org/packages/23/e5/688703d357e5393f708d98eb189fd415ae69e39f6de03c6bd4005aef6118/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e79d6e9b0923f1a978179822bd43d7f5543f45e970a00fe861f43486380aba", size = 9121732, upload-time = "2026-09-10T18:33:02.825Z" }, + { url = "https://files.pythonhosted.org/packages/96/45/a10add34c08184d373be9384660c75758128ca881ed27b503b6f6a742478/scikit_learn-1.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:2070f271e5375dc42c6bb93b461ab1c0aa5841d4009267e0cfd95a39dca94a43", size = 8237244, upload-time = "2026-09-10T18:33:05.26Z" }, + { url = "https://files.pythonhosted.org/packages/9e/08/7a89bcdadd1fff0d464d01056417b646c9abcbc54f7297a0a1203bba5ebb/scikit_learn-1.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:613f0a783ca05aa844a4e1ac42d48425058f2c52be73f40f8cd98b7cd111acd6", size = 7875398, upload-time = "2026-09-10T18:33:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/64/e3/b58e45082dcf3dcf0eb1192ee03545ec43d8c98441dfe20e88afca8442ce/scikit_learn-1.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5d117952769b563067656784e03c75a2d8235a7a05cf7fffa78a311e75aac08", size = 8747414, upload-time = "2026-09-10T18:33:10.698Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ed/d68115577c8b42b0442ebd8180945d4008880a33176094640e00b585128e/scikit_learn-1.9.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:8893bc6331f60f18d4ac75e12ed356e2dcf6a564bf767918b5b7ca54c8c8be49", size = 8279175, upload-time = "2026-09-10T18:33:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6c/06c7eb61a438e389cbf3f7210897069bec5a883dbe03c189ae792781e11a/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5492cf2df5226691c32611de8734bcf42148c6547ae53c7f4e6b847793addc0", size = 8881706, upload-time = "2026-09-10T18:33:14.741Z" }, + { url = "https://files.pythonhosted.org/packages/86/4e/0bab75490ca4b85fad8388739c7ebc71d9db553f8c69e39943ee8db0aaae/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993d332ff80e62efae9e39603b7e872297c418d780f01a01855269a3489c950f", size = 9152030, upload-time = "2026-09-10T18:33:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/9a/13/31c6f8ba1b7eecef9dd9558576c752d2ec5785fd0e456bb9fc59305be23f/scikit_learn-1.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:ca9051447455dae341d4d591eece7deb2d8e3d1020298fc87a81fc51e4da8f53", size = 8377024, upload-time = "2026-09-10T18:33:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/6d3cb8a45f5228f915acd66b819dd6b8232ccbe24532f51d278e3991df31/scikit_learn-1.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:90de6573f733a9fb79476ff1371af52a397d41c8b35f9146e20923db010d67b6", size = 8014196, upload-time = "2026-09-10T18:33:22.126Z" }, + { url = "https://files.pythonhosted.org/packages/66/6e/6befb2d5961490d18d9dbc16a5df37aa121d08bc9893316a6a363977a903/scikit_learn-1.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b5cad1624de8b75e5b9ccb7b0ce1ff1d01306340a3efc56d5529c5ba92392eb", size = 9066066, upload-time = "2026-09-10T18:33:24.396Z" }, + { url = "https://files.pythonhosted.org/packages/cb/18/11271f2f7db337db01f598e358721b1e83989407131272e5dd64214c28a8/scikit_learn-1.9.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:d137ce8a6142029fb5c35bd82f470c40cd9e760e5e2f7694b362c497c4ab3fa2", size = 8647654, upload-time = "2026-09-10T18:33:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/e7/04/9c15d201e1b6a2e81b8215865df7646c5a360f560831769c8dc92ac1ab9a/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66f852f7325b5070bc28329005aca76055a2def78faac039548ae889aeaa45a6", size = 8889070, upload-time = "2026-09-10T18:33:28.95Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5a/4cb6c85160af4a639e87a3b7bf8b1c25cfc3b504c5af710ca416a6dcfc5f/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748bcb0a4cc04aec470652c9e5ec68450948e867387e7dfade647107ade68d25", size = 9131227, upload-time = "2026-09-10T18:33:31.008Z" }, + { url = "https://files.pythonhosted.org/packages/00/0e/361440972ae3d19b90ea88a84791138a51de0e8432a770ba741b2c8d9ced/scikit_learn-1.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd925e893e5539be704d5edc64dbe081aacdab6b89d8c2977c1f6a7a453ce5", size = 8683001, upload-time = "2026-09-10T18:33:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/e2/8f/a9f405c5c0e2df6f343a871b40c97fb32969e3ccc38e3033dd118f3c261e/scikit_learn-1.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b01e5b01735d38474127ca3f49319b592506225a87793b27559816b5c75cea39", size = 8257996, upload-time = "2026-09-10T18:33:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c5/74a83ea39cef7cd07f53e06cc1cf51e79f35df74f81db956835d59ec34b1/scikit_learn-1.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dec64f31a6e0ec826aca6c1b39a51e16d946e400d4c0904316f3ca72ccfb825", size = 8747991, upload-time = "2026-09-10T18:33:37.4Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/cc93e8a7fe204e43d70e96e5eb89871643be20025eea05eb4fdaf19afe39/scikit_learn-1.9.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e1b468241f4a7a9a7a0d6479ad3cc47681cc151a4046c530f2777c3d68f08942", size = 8279340, upload-time = "2026-09-10T18:33:39.705Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/0c6080f0d356fb966053009e7f25e9bff6cf74b0b16195ccf0c3757d1ae8/scikit_learn-1.9.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ca869d0080a5723cde2d5a8b54a2da1ff7e68735a9e9adb3da1243183a0fa01", size = 8881917, upload-time = "2026-09-10T18:33:41.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/bb/98a31f10fffbd39edcc2f8bf4119b29652248bb110b7d45c84e68aa293ab/scikit_learn-1.9.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6754b7cabfc3df0b1f7b38f7a344f559bbae9d82f0ac5e3d48cccbd19fdcefdf", size = 9152805, upload-time = "2026-09-10T18:33:44.23Z" }, + { url = "https://files.pythonhosted.org/packages/f6/48/858ceff52213cfd97c0a069362071756bcb70b9fac9771b38d87c4cf7f17/scikit_learn-1.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:52cfdb1fed3a34362dbc0bd96f2e761a66fd5724d6901629f5a558f1f3bd9849", size = 8376719, upload-time = "2026-09-10T18:33:46.492Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1e/5337a871bdea53effbd154b61429df048f2665653251de74a3bd8a6dea9e/scikit_learn-1.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:ae6571a4828c6f5019bcd2b4125e5b18c0af3dbc9c99726c891f45f41335ec8e", size = 8014341, upload-time = "2026-09-10T18:33:48.762Z" }, + { url = "https://files.pythonhosted.org/packages/0e/35/150383a42d83ec4c7b39f9c50bd68408ecf04c19fc30ea5198fa42e67d9c/scikit_learn-1.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:48fefd8eb42bd4eec3e2d348149368ccd6d71987e20c30706a56a24eb86a6e73", size = 9059953, upload-time = "2026-09-10T18:33:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/9f/dd/aa0d738808540f7eaacfab93e01982db8ef1c1c7473ef0ad38193e6aebd1/scikit_learn-1.9.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:09f4d73049cd63575157f6b1060e06a8c83a4bd3488dbfaeedf35ccba7aad712", size = 8646602, upload-time = "2026-09-10T18:33:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/7e/cc/687ae4214c2f598906c3b9fa5f86fbaf834b35e20360625528ff1b713f06/scikit_learn-1.9.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3da53831534214322d9cb240fa6f390b36cf69eba727a6d4bd3238677630d70", size = 8880366, upload-time = "2026-09-10T18:33:55.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/03/82215cb78ad1c513a4498777571fb28444621ad26ef636287551767b7732/scikit_learn-1.9.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caae15634feceafa2612566b109a3082d3293167fac388eedaf77bff66b51983", size = 9129640, upload-time = "2026-09-10T18:33:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/4b7af4efd7909a4a0524a9f18457e4eb2eb60616eff2c7627eda8e3cdceb/scikit_learn-1.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ffbcbbbb44202fbe9bc64bced25a145759adb9ef010b3d37a8064958ac13df2a", size = 8678083, upload-time = "2026-09-10T18:34:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/31/27/068e484d4b83004302e0d9cfc1faca69bcc010d76fbb66a642446095af1b/scikit_learn-1.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:800dd22dd87fe97dcea484c24e85dd93cf1734d86bd74e668ad18f7967f4d1b5", size = 8259434, upload-time = "2026-09-10T18:34:02.678Z" }, +] + [[package]] name = "scipy" version = "1.18.1" @@ -1540,6 +2216,20 @@ settings = [ { name = "python-dotenv" }, ] +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + [[package]] name = "session-info2" version = "0.4.2" @@ -1558,6 +2248,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "statsmodels" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "formulaic" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/1f/a3cbf6ed7cf6286afec694bfef561f3e51483197716717fb8476f1f0c50e/statsmodels-0.15.0.tar.gz", hash = "sha256:5d257fe58d0772bc46a557880ca78e2a8e07fec7bfd9d11074aef8e33e1aecbc", size = 15736853, upload-time = "2026-08-27T10:45:21.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/17/14121b56377f2881ce94c0d0a01466585468923340d94841337d324f6be6/statsmodels-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e8deb1e3d4ab89fec2a52815f1ec924920c4e0cdb307421a4e47cfed87c03ff", size = 11654329, upload-time = "2026-08-27T15:09:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f5/6b550b5c936dd9ec5c859477698b4fcbccc826e9a4a4a52847fa51673357/statsmodels-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6e20788141df134b83ffb43b8cccf135a876188f84124835c86f011f138adee", size = 11527231, upload-time = "2026-08-27T10:35:42.669Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/0e7e87e0dedc68bfeecc24bea92876023db09bac1c0fe50584a7d0819fb5/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f90dc4989bd82837c80059fdce132976d4e5a80100119d16e1b00537eb55867", size = 11747428, upload-time = "2026-08-27T14:32:28.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/c7/aeadca282baf51d7b9c135674bac79ded91f4db79c69c188575d22234342/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f2bd48915cc9b3b95c3c7f6ac363b6f8aaa38172962ba5f7f6885f0dfe57f2", size = 12042643, upload-time = "2026-08-27T14:33:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/9c/44/7d3eb3836b3c054280baca7ec91bd12858825de437c6319c1212c2531d94/statsmodels-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:733887a09ff878a2e29504eda684488b26bd134ba3539ac58d2136a58914c65a", size = 12079497, upload-time = "2026-08-27T14:33:47.147Z" }, + { url = "https://files.pythonhosted.org/packages/03/e3/d863228849da191d37c54bdd72634fbbcfadd12e910e29e4bdca1b952bb6/statsmodels-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba89eb3ccb1c9c1e400d5081157305495b09350b3a65f2d66d3424ad1d3c4209", size = 11322106, upload-time = "2026-08-27T10:35:58.135Z" }, + { url = "https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09fe34ac80e147cd76cb702ba672559ee3c5dd3ba15c25a232922b46e5ffe70e", size = 11696848, upload-time = "2026-08-27T10:36:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ffe08752f420f12a0b03e8f7d8f3a45417615309b1f37f10561f9a53f044737", size = 11556170, upload-time = "2026-08-27T10:36:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/482701d2dc7540897b13ead827cf8499b11c7325753b8cc4ff1a82c87a18/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a7ab98634d47048c6b7c168b3f8f71369e256d7314bc386168526f34c7699d7", size = 11752604, upload-time = "2026-08-27T10:47:50.809Z" }, + { url = "https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcaa1e4499697c4e023e5fa6fb04459a34e4f4a449c45ae441992e933bde24db", size = 12055386, upload-time = "2026-08-27T10:49:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/19/a8/298e9b5c993cad3b58913d61b7e7cb0e2fba12e39d3114cd8484f922a5d1/statsmodels-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88dbe345ecf317862481b3c86305f42133082e5b366a3a8cf240a6caac404daf", size = 12088356, upload-time = "2026-08-27T10:48:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/28/f5/386c6cfda27c47df81643dc9466b3e28dc2a82152e524131977275a88e0c/statsmodels-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:ac9bce461d0c8f529f8ed331a469eaa5dd7c3213c06b62623a2e5438a6383093", size = 9809418, upload-time = "2026-08-27T10:36:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/15/b4/a8a4d89918599309bebfc09a2f793a910279f3900671bc959ac8c810edd1/statsmodels-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:081adf7e5f2da63f63cbd90491447de5d4c4c2921f19814f235bcf3e736edd3c", size = 11312877, upload-time = "2026-08-27T10:37:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/7a/79/66b57afdcbf9a8b21b1a8e4414ddb006e819b2a81243cd74c12f3301eca3/statsmodels-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3e5f870037ca154a4d177aeac68704a784b24cf2c45d86b046fc28e37a2a5584", size = 10812282, upload-time = "2026-08-27T10:37:26.708Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/daf737efb6002905bd006d296f9ad031ed3861b640075bde3b734706b49d/statsmodels-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:04e65ee05475aeceb9cb954cf96c0f9361b9cdfd497b8aae47afbab0c7c786dd", size = 11698848, upload-time = "2026-08-27T10:37:46.312Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/ca42c91e427db43917d4ab5cc6991e7e50b574bcfdf6c4c8eb9590af71d3/statsmodels-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f1443cc1ff7683f3d7a9054e6dccd92040fbd4324616a0b6dbb8af1ce7889fb", size = 11571229, upload-time = "2026-08-27T10:38:04.723Z" }, + { url = "https://files.pythonhosted.org/packages/5b/04/d803e8d014d4643d9c0a5eec78343073fbfff1c1018f776dd416c07fc076/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a45026596c71afa18543ec2af40b895609c181fc3649433c1cded5ce23ec5cd4", size = 11801588, upload-time = "2026-08-27T15:00:31.603Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/40d7678858065e9ec8efd9f37f04227b22a66632ba194e4aa43fda023f6a/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:edd803f4606061af8c7a83e2326ea294cbfa802a908dfbda4a88347301fb6820", size = 12062254, upload-time = "2026-08-27T15:00:49.803Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/16c73c3c09f4b8d85f125d7d3ae0f7092d4736a83d24c0159e4fb21632ec/statsmodels-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eb7fc1147cd8d17132190db7a8e63cd76a06145dec502816d8f3c20619923f2b", size = 12101259, upload-time = "2026-08-27T15:01:08.755Z" }, + { url = "https://files.pythonhosted.org/packages/23/4c/1138aad8e3c27ebf941d732788488cce7c28d76404a7b7fc1186faad2890/statsmodels-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:c3c1138b4d0e5b0c2387b17dafd2a0fd137c67bd869f70d4ee56feecc918cb0d", size = 11348219, upload-time = "2026-08-27T15:11:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/3a/bd/53546d8c9ab013d9a0513706d2a09a03aff12034e6f898987e8ce0f38257/statsmodels-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:74c0b63448f03020cc27a8ee698fbcf2ba91e61a9f9eee7978c0c4ed33a9b17f", size = 10866645, upload-time = "2026-08-27T10:38:20.886Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/0d38328643d4704c8de7867518c3f52ec145ff62537b2ca1e14cdeecf51b/statsmodels-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6197b4cca69fd7bc23ab9430abf1c479764cfe74767467f51964a633dfade7a5", size = 11870287, upload-time = "2026-08-27T10:38:38.271Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/fac7e03895f24a0b4883ff9fc69f9421a5df1b60e7edd7e68d49f4327593/statsmodels-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5538a734dd28efd450bb38a5d5fc94d13ee709bc497e3e4c0a24b3803e3eac39", size = 11755467, upload-time = "2026-08-27T10:38:54.683Z" }, + { url = "https://files.pythonhosted.org/packages/4c/38/5321b9f6065d1c5861e87853720603d56ae81795a83d3040410ca2684305/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dde0657d18e322eedb2540a84c0582e97afd0f78c370b45eebb0d4ac228a7a83", size = 11636274, upload-time = "2026-08-27T15:03:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/56/c75e984c492509d9ab2ba5f2c0e4eab7177cd2cec424cef9dafb42f0cc70/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90e75e726be2c9fc1d1d4c43337503a35864bdf5b4693773a08c6ee92d04a153", size = 11865818, upload-time = "2026-08-27T15:04:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/9e03975701c808767c19f394f670941a962204a4f6a00eeaf56b560348e8/statsmodels-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65e64afc2655a486ea44a5ffe4e2b05ee3abbcb10ea61d1c02d3e9e519338d9b", size = 11907800, upload-time = "2026-08-27T15:05:49.786Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/c467019a8bf1ab6a07ca88004f0a1d4ea2dae89642aa804e49437b256243/statsmodels-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:edee066ac9b171d95c3de925225331a21de1deb5b6452f252bc853e44566e72b", size = 11559083, upload-time = "2026-08-27T15:06:05.872Z" }, + { url = "https://files.pythonhosted.org/packages/54/10/dc78352367cd5bb298340b57c390c944b66236f6d026cc238bc8264c7633/statsmodels-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:40b2737456e75d96866943e33017a1dffc9166025ee33b7eb373fbe8c7a85e09", size = 11029613, upload-time = "2026-08-27T10:39:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/de/cb/a1f5ad730cb66c7d911ebb45413880333d837f1cffff990a4c6d2c541b95/statsmodels-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d48ba61db0d13d3033330e6bd1f864dfa9b48c0b952b3b0f7570206b35d552e5", size = 11702125, upload-time = "2026-08-30T16:04:07.635Z" }, + { url = "https://files.pythonhosted.org/packages/ce/db/fb95f242adce112c1507e2062ad6b7b00aea338925f2a03fbe47973c4d72/statsmodels-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:24b9917a09bbe0227047e512a40652c4b9117c2cfbf19c4a5b9971a8944a8606", size = 11571622, upload-time = "2026-08-30T16:04:29.558Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/f4cc7e5f4d6c8f46551aee63e59d9b964881070f552400ebb76667b39ad9/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333d0c70f269ff98b647f157d2ae7fbcf0617e6f9a5b1817fc6609766502292c", size = 11780713, upload-time = "2026-08-30T07:40:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/e9/20/fa315a013f70efa6674adfaca59365ea7562371914c408551fc3c399792f/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b946d7c922f46f045f25e50daf13439f79c05e403ba50081cad509892c8b26", size = 12070117, upload-time = "2026-08-30T07:40:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/91/43/a63f7880050c2c24056fa8d663b7c8a1816b25864660abbc73afff3f3558/statsmodels-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cf897abce5616e2763ab7d7a7fa914677df87a649971f877b83a358c98f6e291", size = 12105153, upload-time = "2026-08-30T07:40:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d4/523ddb13862c228407297754486b6c9e8777e5114f4532b667b47728c544/statsmodels-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:511a4f9cadbf91a690bfa863bb3e25dce583d8140046886fdf35fbfabfbce401", size = 11352844, upload-time = "2026-08-30T16:04:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/53/59/f3058214e61b15582ef17d761c9333def46960ee521313409b2705791029/statsmodels-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:0cb6228713fe47046b606daee63ed4473a7c246fa97cf0a6d6f20d3b0c96c051", size = 10866685, upload-time = "2026-08-30T16:05:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2d/43089b37f4bbbf7373929cd198de6cbdb0b5a4580a6e5e32bf9fe74fccab/statsmodels-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:27e9467fae83ff327c9d16ae5ea7e771bcdd66a682f49aa324eeefea5584f2d5", size = 11858149, upload-time = "2026-08-30T16:05:20.585Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/ca88421b81666cdcfc3d0eb12c714f80f2be327669ececbb5930b68a71c0/statsmodels-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ecf84918dcd410ab34d3d85d34e0b16ebb10319c9fc77dbf9a1f1a49324c59f8", size = 11739931, upload-time = "2026-08-30T16:05:37.286Z" }, + { url = "https://files.pythonhosted.org/packages/ab/64/98b9e2b1fbfdc4e2392bff349718d8dc796403006a3106ec375c59a5d3f8/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5891ad077b36d6401e44967739df3d2cdd380a0e6e9e46dadec7e3d3a828af55", size = 11657996, upload-time = "2026-08-30T07:41:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/34/6d/d8c9e73013ee854031218e8a044ba679535268bc61cf59466f19ac8d980c/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af80f9658c48792947fc754a026ffbebca61a8b483253781973f604a67cf1f8c", size = 11893402, upload-time = "2026-08-30T07:41:27.553Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f5/b9b47f3c688a8af1b199af96e098b8f20897a60b45187c57ce6fcde77a5f/statsmodels-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:583eb2a46f7c7a5c34a26715c8b6d92199bfb4f04623db5fff9902a3c7c832d3", size = 11934786, upload-time = "2026-08-30T07:41:46.809Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/608871f18bc18c6cba0b484e81fd25a023ef3ca6541f6793ec839fa49e1f/statsmodels-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:38038823fbe86f11e09433b4a0f4af5f102327c3137a1604b002ce3cf722a31f", size = 11549124, upload-time = "2026-08-30T16:05:53.179Z" }, + { url = "https://files.pythonhosted.org/packages/e3/eb/90a506dbee5cde669e2ef28565ff6b7a0791ab415eab2da82f92d033cccc/statsmodels-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:90df413d7e09474f7e7cbf8807440c1bf76298cdd9daabf9141f76d7627c9151", size = 11021454, upload-time = "2026-08-30T16:06:08.124Z" }, +] + [[package]] name = "tabulate" version = "0.10.0" @@ -1567,6 +2315,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/dc/6c58154c1c65f758ea979e7139cb76993a9cfc662d14e9be3c4a667cfb77/threadpoolctl-3.7.0.tar.gz", hash = "sha256:61348cfb77d53b9242e0017029244b559b810c142ced65b4e21eeca1843959a7", size = 31961, upload-time = "2026-09-15T15:46:20.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl", hash = "sha256:cd8b60b5641b45c67bbf73c64c843235fc2d8a480c87389f52f5dbee893b86be", size = 26362, upload-time = "2026-09-15T15:46:19.168Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -1597,6 +2366,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, ] +[[package]] +name = "umap-learn" +version = "0.5.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba" }, + { name = "numpy" }, + { name = "pynndescent" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/ee/af4171241117f85c74b5ca6448ea1033cc28d599c13651d67289bacd4083/umap_learn-0.5.12.tar.gz", hash = "sha256:6aff02ecac5f2aad9f3c65ee518d7ae93e1a985ae38721fdcffceee4232c33c7", size = 96672, upload-time = "2026-04-08T20:03:54.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl", hash = "sha256:f2a85d2a2adcb52b541bed9b27a23ca169b56bb1b23283abeebfb8dfb8a42fe5", size = 91849, upload-time = "2026-04-08T20:03:52.561Z" }, +] + [[package]] name = "urllib3" version = "2.8.0" @@ -1646,6 +2432,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "wrapt" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/22/89e2f3bdae5cb34e0cab0cd86d7172dbf418de4b46c9b17b9c7a560dfa44/wrapt-2.5.1.tar.gz", hash = "sha256:f595bb0185aab3e9dc31950c95d914f56ea8278810c3b928f3426e12ed6d27bc", size = 184455, upload-time = "2026-10-14T00:39:39.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/9b/c7f97d5493a33b5ed01d3c85745f9bdfdd2e5c2785471b8d8b55a3c273d6/wrapt-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6e3eff05ae616671b40d7ad0a504210329e4adc9fb91415663570aca93c5f5cc", size = 106452, upload-time = "2026-10-14T00:37:18.951Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b0/335b0af2930938678fcde954b29780b26308961b93df5e0192fc182e8b7e/wrapt-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c44dd9881626da7d621c23805f26726f6b023cf3e9755f48d092bc9cbef4a8e7", size = 106087, upload-time = "2026-10-14T00:37:20.392Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5a/2a34ba5a468e9d3d6e5b0733280e1ae3c850bfc5f1d681fc0e97f564d1f2/wrapt-2.5.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bfaa998ceeea4d0aa72b40cdd0023d19409504e244b439ff2aa9f01729341c5f", size = 250438, upload-time = "2026-10-14T00:37:21.882Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d5/3d4ad322af74d3ab2a14f69ba844cdd3edefb555edfc1c1976ec0112d5c4/wrapt-2.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6d274ec50a5b208be75596dc44ea253e65deaa6ee3a600babc86dafbb957dfc", size = 250594, upload-time = "2026-10-14T00:37:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/37/1a/3cbf48425ec2c66aa9645218458da1e19e315abb9766604d3c49e579076c/wrapt-2.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1a96e2671c60f9f09ae547b5a815cecb29af16caa68d73693387d0028788cb32", size = 229856, upload-time = "2026-10-14T00:37:25.029Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e7/b2ea57f4c51258659200565af8617d76992b0fe65e6aad7162dd5720ef05/wrapt-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:729d644b6acaf4846a4ef81b037857b66a01dea6d227f827c6d71c0b6d656d6c", size = 247153, upload-time = "2026-10-14T00:37:26.67Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c1/4714743e672ed1084a035a2a4f0edeef7838399753b4856a0dc46ef9487d/wrapt-2.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:859f67bfc31eb7ab55f237b629cd4ab0441b075912446481f910f7d02066811e", size = 226726, upload-time = "2026-10-14T00:37:28.425Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/c00401bcc3485eb9937c3fe4a1cc8fc3b61800b1378ea3a143ea1c30f6f6/wrapt-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:29b62e87fcd6a1893f669abfd02a596a7fc5cfa79fa57e42c4e650a6c170c67b", size = 238843, upload-time = "2026-10-14T00:37:30.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/c16759fb0721e63df92b576c2222ce1f11690a8b300fd91b49c55436865c/wrapt-2.5.1-cp312-cp312-win32.whl", hash = "sha256:f1c911818fb076910ef509f2298dfcb966a54a6ff068eebd459632102cf589fb", size = 100488, upload-time = "2026-10-14T00:37:31.625Z" }, + { url = "https://files.pythonhosted.org/packages/22/d5/39d5a704650f18799f37841442b464edb81cf2015f006eaef26068acc6ea/wrapt-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:c39c7130ea0702c4ab0faf12da1df1e02d5174305c17edf02309e2f058c4114f", size = 106048, upload-time = "2026-10-14T00:37:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/bf/65743adeeb5476920c62dad6cded7bc8789e19bd4f9a336d4ac812adb8de/wrapt-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:e089a22ff5af1290b8c759a610830bdb2a829ef9c3d7797e4ee32c2f795ed482", size = 103200, upload-time = "2026-10-14T00:37:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/cfe55762435f36107815d56a2cfbebe7e3129b593c47a670c6eb1d7917d3/wrapt-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f98eaf784cd12bc69c77af398084174531007cd81849c962163ccfc6e791f3ea", size = 106068, upload-time = "2026-10-14T00:37:36.087Z" }, + { url = "https://files.pythonhosted.org/packages/01/b9/41642877fe741db56d240833c8822188b663c4c5d52beb087964774035d4/wrapt-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ab6db7d2a18d366cc57c2228253cf26443190aba0a6dd0939b3c1e8ac6e29e2c", size = 106190, upload-time = "2026-10-14T00:37:37.768Z" }, + { url = "https://files.pythonhosted.org/packages/37/62/20edad100b93552ec5c172e509a9db898a73d5043ae701fcb6e9986f9d33/wrapt-2.5.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1630201b0e2a96bb26304b7adfbd91a4ef486abb5a4c48377444a0bed749f37", size = 248519, upload-time = "2026-10-14T00:37:39.321Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e9/8d81185bc9a40cfb43d91fc70a1e80ecde752c95dc98f5452cae82037976/wrapt-2.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d800c7689154622b0ba2922ceca44a3cf2ef61c3b9a4c4eeb1d8b3050d7ededa", size = 248488, upload-time = "2026-10-14T00:37:40.96Z" }, + { url = "https://files.pythonhosted.org/packages/8a/88/8431df4fd81f0dfa83e8ede463eed311d083c5a279a56891dc0396b07b0e/wrapt-2.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b53000b424dc2133eaaf22838a2352d3497f5d7c2e7d9a2acfe675ab7225bb1", size = 227392, upload-time = "2026-10-14T00:37:42.599Z" }, + { url = "https://files.pythonhosted.org/packages/db/8a/ee6f8542eeccad6874faf0b7b2e129952c527a482f1d28940e2111fec2d6/wrapt-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76f230a9b07e3cb66646d265398f579abb6128b1bb4cb97c74b1ae5d09e96f31", size = 244972, upload-time = "2026-10-14T00:37:44.209Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1f/32c59e7fd522409f3863dfecdab5315ee9ba37f96020b6f0adee9d223310/wrapt-2.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd3f878a4aac3c262447ddf43c5f4c18fc67dfc3ba69c4fb1c7a4c4af96abe7e", size = 225316, upload-time = "2026-10-14T00:37:45.948Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d6/1b9abc1244592034c5db744571e17d663f0f1b0ce6c8ba279c60f6f9c3a8/wrapt-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c9480bdee340a1602cae5a777146ab4be3e384fdcb569fffdf8721032314645", size = 237145, upload-time = "2026-10-14T00:37:47.535Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ce/8f3b5482f768c1d60fd2557d049c766543fef5ec707037cb410a57eb65ee/wrapt-2.5.1-cp313-cp313-win32.whl", hash = "sha256:dc401274fcc7b15b3b2c12df2ff34024a11925243a7d3daee91c6d7d14f9addf", size = 100462, upload-time = "2026-10-14T00:37:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/7b/dc/6a5735874ea79816f85c1ec9d92139d7073c20d1881c15ff2108c211354b/wrapt-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:09b1893ee4063706574c1813abf479b8b51926633fbdb6f96aab8dc7b0976668", size = 105870, upload-time = "2026-10-14T00:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/08/83/a4e8b5a5a32f8dfc5dad8344f1e2b908f7d8d84b11c3c336bf7f79a5144a/wrapt-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f280c115ea64eff3dcbd68a668ce3f63476a4ba386bbabb318017e286196ea2c", size = 102903, upload-time = "2026-10-14T00:37:52.323Z" }, + { url = "https://files.pythonhosted.org/packages/25/3d/ec1937283863bbe0d90528e09f2b27cfc0dd7e608fc2b65c804967dee369/wrapt-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cf63fffcdcd8c60f223d3967bb92cc4fc2e8b46f09e75b67a6a75e6f47c0fc43", size = 106439, upload-time = "2026-10-14T00:37:53.853Z" }, + { url = "https://files.pythonhosted.org/packages/93/39/cca8afb80dbb9fce6103e59db507a9415291c4dbe97ea055875ff62901fb/wrapt-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f0750cbc2e29e4f3c9529d3587d4e7ed8f60638ceafb80b87a95833b0c5acd9", size = 106378, upload-time = "2026-10-14T00:37:55.386Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a0/e784d7a9fd277a2ee395490ec4df96608b7fe218bb1ef7dced2a1caea490/wrapt-2.5.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3cf273b7e8d2038abb7f0a8c6550aff4f617b9d486a9965c8e8acc96a3a04de9", size = 251505, upload-time = "2026-10-14T00:37:57.022Z" }, + { url = "https://files.pythonhosted.org/packages/81/56/01ebc86b88056f5782b9d50f962fb398c6b82aa11efc98f50c64896d94e3/wrapt-2.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380f72610181883f66b41442cfc7c0f7552b42169efb2113def26e6380013d37", size = 250744, upload-time = "2026-10-14T00:37:58.652Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/0e8eaaf31e2d6e7bf13c6eae2fd5b85eaa24e21e06466e6e7a0f35532689/wrapt-2.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef2a8f006410b6134a0d273ec037fea8cc7a6a914f1bd7555ad9788ad788c6e", size = 231598, upload-time = "2026-10-14T00:38:00.505Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6b/6a3e257e65de6cc0027e78b423942697c74451520ba3797fd86455ec4df8/wrapt-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9bad4dbb4e61624fcce5f301e37f9e743ecae4f1259a3777b3207eb7eba3dccd", size = 247998, upload-time = "2026-10-14T00:38:02.63Z" }, + { url = "https://files.pythonhosted.org/packages/22/38/b2b8f3ee22b05f33f5aefb052844a36a0d7edd1eaff2ff4249f97792bea8/wrapt-2.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9a34640eb6295f33ca23462977de275fe8f3a50ab339b8918b96d69a7451e2e1", size = 228795, upload-time = "2026-10-14T00:38:04.317Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/e6c86552286ac27b855042fb9c229c580ed2d4c3ced5d0a1dad5f5ee8c11/wrapt-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26313f38d18d40a9975123a4ebff9da125ec63ab9ece4f05320a3d8d37d2c1fe", size = 239090, upload-time = "2026-10-14T00:38:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/aea209f64e894573935b17de26ecfa0efe139b63f170a60be9f13734e0f5/wrapt-2.5.1-cp314-cp314-win32.whl", hash = "sha256:0591e6eace0d186c9ef1ecd1244be5a04e98041424cfca425b684ffe4f0d8030", size = 100919, upload-time = "2026-10-14T00:38:07.874Z" }, + { url = "https://files.pythonhosted.org/packages/57/24/847096aa49d42990137ed3b940743c8a6da806d39f6c455317114e8ebfde/wrapt-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:25ed8b1b39234140d5b5c6a273130c7595e0abece417c3ca3cb378fcea5cd0fe", size = 106243, upload-time = "2026-10-14T00:38:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ff/1cdc742133b9fb8558cdf42b2a6c2699bd7c72f7d0606286ec2f9142e20a/wrapt-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:6201c7e122f40060a9b50696d80deec8f93b1a235ec0443f51d7a8a42f7044a6", size = 103974, upload-time = "2026-10-14T00:38:11.354Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6f/c32dc64900f1970a7f991ff5d06788cd636ca2f3ee2f99709d82577ca198/wrapt-2.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:da847332447db5505162759a4cd5ac374eb8b74841fe97a98ef3de14edd2586d", size = 109552, upload-time = "2026-10-14T00:38:12.965Z" }, + { url = "https://files.pythonhosted.org/packages/e9/73/a9c8cc82b166e3de42f5fbd88089d2ef9b72e87aac7d6cdddb070335c20b/wrapt-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f437dd704abc4ee1bd03bb2d796d362d0e75915e8f3113a7900b3b7ec5f8b47", size = 109853, upload-time = "2026-10-14T00:38:14.565Z" }, + { url = "https://files.pythonhosted.org/packages/f6/48/f341d82e69ae47df2755847af1c744ff0732543482dbc2373e5e57676621/wrapt-2.5.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03aa7d2256309b57ddbf317bff2cae5f47e50ea9ae8d582780ebe0b554347b42", size = 284103, upload-time = "2026-10-14T00:38:16.23Z" }, + { url = "https://files.pythonhosted.org/packages/fd/50/b87c6374377b08ee0783b6c5c31cd41a5e103bc54e7e857e800a0a965550/wrapt-2.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fcccaa1484f7dd1091602970988ab741491f9f974013c844f70e45ac1196b80d", size = 293883, upload-time = "2026-10-14T00:38:17.986Z" }, + { url = "https://files.pythonhosted.org/packages/38/e0/6d0810ae73f7a5180ec366577588ab3ad55fc1bc7e3623e82d5f8528dd2a/wrapt-2.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8078186f719a92693199f1e06c4ec72e1e6d374c2e459da18ed5c39d6966d727", size = 263097, upload-time = "2026-10-14T00:38:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d3/c890a46f4d395a7935e5a7436f374ceefa362eb612fdd39376dd775e0283/wrapt-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425fcf0e70b27053bd610d57bae975856e7897e3f6ba1456d2b80b9d7fd15d1", size = 289045, upload-time = "2026-10-14T00:38:21.913Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c6/042e30e0d851ca6ea743e6978902527f9166da42d736f074245d1cc54c8f/wrapt-2.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b238e955ba34ef2b8897f358b7b868b41b9a02ffd338014b62985fa91898cc4a", size = 260378, upload-time = "2026-10-14T00:38:23.662Z" }, + { url = "https://files.pythonhosted.org/packages/31/a4/5e65f90bf414c2f1c7eefc3d26c33af01d87ba33c4b879db4e1d4ba7fc3b/wrapt-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25eb4d928a9abeaf70ca786a35861b46d1ab37cc4ce49ea70a070dacdead4dfe", size = 278746, upload-time = "2026-10-14T00:38:25.523Z" }, + { url = "https://files.pythonhosted.org/packages/4b/86/17a85475e218e05225a4f6d65237b12b280596e04520df0e3d8841c4eae3/wrapt-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:df6e3a36170cda0d313be50fe5065948e7f12f3a181b38cbc262e9f2ee4824e1", size = 103415, upload-time = "2026-10-14T00:38:27.409Z" }, + { url = "https://files.pythonhosted.org/packages/27/1c/495b3aebbbe5aebf52ae5f9e8ddd0e072a412b9f5a9bc68e5eba43fa26ba/wrapt-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bc5c0203d383403043fb86c964bd0bab4fcbfb26004ff4bb9c6d02ebc1d608ae", size = 109940, upload-time = "2026-10-14T00:38:29.145Z" }, + { url = "https://files.pythonhosted.org/packages/28/4d/030ecd98da4d052c264290c4fb9f984706c9a19026154c833580e8624a05/wrapt-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a424e8a9776c06aef6313af1d0e3fe6e0838af4241d0c09eb0a3b46f2c9a5ff3", size = 106214, upload-time = "2026-10-14T00:38:30.795Z" }, + { url = "https://files.pythonhosted.org/packages/90/2b/eec5745baaad284fa19232f47796914134e1ea2dd21e289b012aa7981323/wrapt-2.5.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a18e63910252eb75d8806b4baefbc3a03612502f63eab042e3741b00b719f043", size = 106441, upload-time = "2026-10-14T00:38:32.504Z" }, + { url = "https://files.pythonhosted.org/packages/29/f3/976b0f014a08654289358d41a799c2d24642151b091cfe18a8b91766ee28/wrapt-2.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:183bf0bb893f783c9d22f953cb01fababb9f618e098763f8e66337b575b0647a", size = 106411, upload-time = "2026-10-14T00:38:34.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f4/4b94583d9bec0ff0573a5f10fab295675e3b9a64b701c4609b1a1982c390/wrapt-2.5.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1e823aecb3746b8f9e0aee2e1413887871ee2f5c502a3e0ef8d466dbd4adde1", size = 252017, upload-time = "2026-10-14T00:38:36.142Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3c/4f9ba033343b2935a453188f97866f0bd4f7748748ab307aaefb18be3a0a/wrapt-2.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bde5d1b37101b1e9dd3da1f35072e2e7028e9c5e3511f7d76d3fdd4d071b7663", size = 251030, upload-time = "2026-10-14T00:38:38.003Z" }, + { url = "https://files.pythonhosted.org/packages/69/a1/704c761913be404ed893d05702eeda5ff96c5d8448271c28b80101202bcb/wrapt-2.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12d3d2b9d6553df6e2421ab99e1cc5413509076788f57fcb3169f5ce100a19d1", size = 232875, upload-time = "2026-10-14T00:38:40.124Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/779ca20fb8c70238069efd0a2b60ea3da450e57c4f103748db0247f52f3a/wrapt-2.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:521bd5ef2a33171fac08a0a302d51a983c19c3519406c1ee8da7ce29285488da", size = 248302, upload-time = "2026-10-14T00:38:42.138Z" }, + { url = "https://files.pythonhosted.org/packages/03/02/80e13786204ce8e1002edb66d06a3194906c0dbbc038bce7bbda426d0f2b/wrapt-2.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:129cab3c7b21e68e693c2819a95c47f3b1c41a834b931154688c83b6aef6bdab", size = 229839, upload-time = "2026-10-14T00:38:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d1/14c0d041375ae5d0a12445b5c5bc61b109df954cd5bfb852736cd4281cbd/wrapt-2.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:8a7c078323e6e1534968cb85488c5eb7ee2b9bbd0f8a291095213a763da40dab", size = 239634, upload-time = "2026-10-14T00:38:45.896Z" }, + { url = "https://files.pythonhosted.org/packages/97/6e/dacc92526fbed1013eb903406ce961da6ff2a1e66b7349a253f439b979aa/wrapt-2.5.1-cp315-cp315-win32.whl", hash = "sha256:736c1de0230c6d24327b14684794214167b2c5ebb6332e28a10f504641b600df", size = 100931, upload-time = "2026-10-14T00:38:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e4/84bbd88554052958ecbbb481a75559b1e40753aba52ec86e3e1efb50ccf0/wrapt-2.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:69fd0fbb3daf7c8c6f5e062847a0061f880f347374d74cf1daba57220fb64cd0", size = 106252, upload-time = "2026-10-14T00:38:49.511Z" }, + { url = "https://files.pythonhosted.org/packages/b7/98/98d4c4524e8af70ccf35b66864be29ea9d232e5a918efc1dbcf5c87a039d/wrapt-2.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:051220e5071fdfb1a6678707c8abb7bbf4824d40f99758394b2b4d64855fb284", size = 103980, upload-time = "2026-10-14T00:38:51.384Z" }, + { url = "https://files.pythonhosted.org/packages/75/d9/4b242519d6d29eabb73cb9e50e645e014eb2c13f022601151953b3e81946/wrapt-2.5.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:711e73da3d7983547fc9dd208973b6b0c52640822f5d477910ba24622df6ba64", size = 109538, upload-time = "2026-10-14T00:38:53.124Z" }, + { url = "https://files.pythonhosted.org/packages/58/05/e434f56fcceaafb251cc56c03107ae278e99158466b49f4146f7b33a2532/wrapt-2.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:5be9816d9de88f02fce23cf55f392403411d9bd9c7ae57fdc965a43b22e2de5e", size = 109874, upload-time = "2026-10-14T00:38:54.941Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/d2c0b34d02804018225279a157307b873c8d0f8452790efdd7f0004387a8/wrapt-2.5.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b3f410c416752e1dba53d361e2e6562f22c2c3ec855740dfa5836e061b22571", size = 284866, upload-time = "2026-10-14T00:38:57.081Z" }, + { url = "https://files.pythonhosted.org/packages/71/6f/2b56319c0565d9a6b63eeba2f11f3e699dc324aaa460f1aae079bd4507b0/wrapt-2.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:094b847491b813b6e6c1775e03770930d75078c0821adf929ac712830951ef25", size = 295154, upload-time = "2026-10-14T00:38:59.007Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1b/9ac4238a1a839457d6b687b9e9c35d57ba1b2050a42d6bf8c93176bc1bdf/wrapt-2.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26d8ea2ec6818aeb656bd8a9e745a6f1fb0edfcd8f54291ccd94f62eb5f5e3bd", size = 264914, upload-time = "2026-10-14T00:39:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/4f/95/9faed8e5f6e5431edd36b2cfb4f305df520c197ba3639e1c78d11c70a068/wrapt-2.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a526227efe17dd94bd16b123d170f879bce42c15f10eb92495a745f54caa943", size = 290238, upload-time = "2026-10-14T00:39:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/b5/52/cae26590ef8ee46b55aa8b211c507f6e5ec0fbd241a6730bf7da024b5dab/wrapt-2.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:36d7d0ad593c4f1a651e4032de834db59aee1a929ee396cd483895b673328e51", size = 262208, upload-time = "2026-10-14T00:39:05.008Z" }, + { url = "https://files.pythonhosted.org/packages/00/f3/34e5008307be4169592e99de52946cd8800b98097e2a145da11484c7b3e4/wrapt-2.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:89d9a8607b7028054bb6fd01d437f205534a5d59d53c3665d15949a99a2fce0d", size = 280005, upload-time = "2026-10-14T00:39:07.048Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f9/64e000aa84a88c52a481c7c8b011c80a8ae60bd5f68598e567a918a624a0/wrapt-2.5.1-cp315-cp315t-win32.whl", hash = "sha256:ad81bf81b0a0b6c6ec74169638202851962843e86749570c463eecc55072f93b", size = 103378, upload-time = "2026-10-14T00:39:09.336Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/69efa7c6e8535c5188e041ac278079949fb2daaa97e6f08beb91cf31b3d1/wrapt-2.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d5b665a43fe0d3b390cbdd3c003d61c92fa07bd5e3fb1ed3f47920c2d03cd9fd", size = 109928, upload-time = "2026-10-14T00:39:11.156Z" }, + { url = "https://files.pythonhosted.org/packages/13/77/6e414b3388b9f1ecb76107ef4a2aae501f1bdfcab85c8e34ef78f7db22db/wrapt-2.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:6405ff2160af9d59132ebb076eda0304db44d9d09809582932412ef7c0788a36", size = 106226, upload-time = "2026-10-14T00:39:13.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0c/7da7513ddcc8f1d831ec4bfbedc9f7f174ecb91042bc16916fc1e0d06b22/wrapt-2.5.1-py3-none-any.whl", hash = "sha256:c6e6c226b1ca5402d7ae5fb34a0d21f1b49124fe4200e5884d1e19e53c47ac1d", size = 81849, upload-time = "2026-10-14T00:39:37.441Z" }, +] + [[package]] name = "zarr" version = "3.4.1" From 0a659b6727cb989ceb40da7095122aab7f66912b Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 12:37:09 -0400 Subject: [PATCH 02/30] UPDATE mad_outlier to a sigma-scaled MAD on log10 scale="normal" rescales the MAD by 1.4826, so nmads reads as standard deviations rather than raw MADs and the same number cuts a third less tightly. log defaults off, since a count wants it and a percentage does not. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 17 ++++++++++------- .../skills/cau-qc/SKILL.md | 15 +++++++++++---- tests/test_qc.py | 11 +++++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 171997e..a64214f 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -229,13 +229,15 @@ def mad_outlier( values, nmads: float = 5.0, side: str = "both", - log: bool = True, + log: bool = False, ) -> np.ndarray: """Flag the values that sit more than `nmads` MADs from the median. The median absolute deviation is the spread of the channel measured against itself, so a threshold built from it moves with the channel's - own depth instead of being carried over from another one. + own depth instead of being carried over from another one. The MAD is + rescaled to a standard deviation (`scale="normal"`), so `nmads` reads + as sigmas on a normal distribution. Parameters ---------- @@ -248,9 +250,8 @@ def mad_outlier( Which tail to flag. A count metric is two-sided; a contamination fraction only has an upper tail worth cutting. log : bool - Work on `log1p` of the values, true by default. A count - distribution is roughly log-normal, and on the raw scale the MAD - is dominated by the deep cells. + Work on `log10(x + 1)` rather than the raw value. Off by default: + a count distribution wants it, a percentage does not. Returns ------- @@ -264,13 +265,15 @@ def mad_outlier( can be read off it. This is what an unfiltered barcode space looks like: pick the cells first, then measure their spread. """ + from scipy.stats import median_abs_deviation + if side not in ("both", "lower", "upper"): raise FigureError(f"side must be both, upper or lower, not {side!r}") numbers = np.asarray(values, dtype=float) measured = ~np.isnan(numbers) - scale = np.log1p(numbers[measured]) if log else numbers[measured] + scale = np.log10(numbers[measured] + 1) if log else numbers[measured] middle = np.median(scale) - spread = np.median(np.abs(scale - middle)) + spread = median_abs_deviation(scale, scale="normal") if spread == 0: raise FigureError( "The MAD is zero, so the threshold would be the median itself. " diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index 9658072..49a5dcb 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -97,10 +97,17 @@ whose UMI count reaches the floor, returning the filtered copy and the mask. ## Thresholds from the channel itself -`qc.mad_outlier(values, nmads=5, side="both", log=True)` flags the cells more -than `nmads` median absolute deviations from the median, on `log1p` by -default. The spread is the channel's own, so the threshold moves with its -depth rather than being carried over. +`qc.mad_outlier(values, nmads=5, side="both", log=False)` flags the cells +more than `nmads` median absolute deviations from the median. The spread is +the channel's own, so the threshold moves with its depth rather than being +carried over. + +- **The MAD is rescaled to a sigma** (`scale="normal"`, a factor of 1.4826), + so `nmads` reads as standard deviations. A raw MAD would make the same + number cut a third more tightly. +- **`log=True` for a count**, which is roughly log-normal; off for a + percentage. The base does not matter: a median and a MAD rescale together, + so the band in the original units is the same either way. - **It cannot pick the first cut.** On the unfiltered barcode space the median barcode is ambient and the MAD is zero or near it, so the call diff --git a/tests/test_qc.py b/tests/test_qc.py index 04fec08..a92f1b6 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -145,3 +145,14 @@ def test_a_zero_mad_is_refused_because_it_is_the_unfiltered_barcode_space(): # What the whole barcode space looks like: the median barcode is ambient. with pytest.raises(FigureError, match="not on the whole barcode space"): mad_outlier(np.array([2.0] * 1000 + [5000.0])) + + +def test_the_mad_is_rescaled_to_a_sigma_so_the_band_is_wider_than_the_raw_mad(): + from crispr_analysis_utils.qc import mad_outlier + + # Median 0, raw MAD 1, so scale="normal" puts one MAD at 1.4826. + values = np.array([-1.2] + [-1.0] * 500 + [0.0] + [1.0] * 500 + [1.2]) + flagged = mad_outlier(values, nmads=1) + # A raw MAD would flag both probes; a sigma-scaled one does not. + assert not flagged.any() + assert mad_outlier(values, nmads=0.8).tolist()[0] is True From fff86b6e3e1e8033a188e1cd730928a9a9ae7488 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 12:40:58 -0400 Subject: [PATCH 03/30] FIX the skill's account of why the MAD needs cells first It said the MAD collapses to zero on an unfiltered barcode space. It does not: on a real channel it is 0.33 on log10, and the band simply centres on ambient, reaching 131 UMIs so that every real cell is an outlier. The reason to filter first is the population, not a degenerate statistic. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/skills/cau-qc/SKILL.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index 49a5dcb..e33fc18 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -109,10 +109,14 @@ carried over. percentage. The base does not matter: a median and a MAD rescale together, so the band in the original units is the same either way. -- **It cannot pick the first cut.** On the unfiltered barcode space the - median barcode is ambient and the MAD is zero or near it, so the call - raises. The order is: knee for the floor, then MAD on the cells that - survive it. +- **It cannot pick the first cut.** The MAD describes whatever population + it is given, and on an unfiltered barcode space that population is ambient + droplets. On one real channel the median barcode held 2 UMIs and the + 5-MAD band on `log10` reached 131, so every actual cell was an outlier. + The order is: knee for the floor, then MAD on the cells that survive it. +- **`log=True` for a count, always.** On the raw scale the band is symmetric + around the median and its lower bound goes negative, so only the upper + tail ever cuts anything. - **`side="upper"` for a contamination fraction.** A cell with unusually little mitochondrial signal is not a problem; one with a lot is. - 5 MADs is the usual starting point. 3 is aggressive, and on a healthy From 91f35d578557607beb6fae3821cd7a844ecb3ef6 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 12:44:17 -0400 Subject: [PATCH 04/30] ADD metric_distributions, one histogram per QC metric Drawn with matplotlib directly. Counts go on a log axis and fractions on a linear one, and an optional flagged mask stacks the rejected cells on the kept ones, so a threshold is read against the distribution it came from. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 127 ++++++++++++++++++ .../skills/cau-qc/SKILL.md | 13 ++ tests/test_qc.py | 27 ++++ 3 files changed, 167 insertions(+) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index a64214f..de00ba0 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -289,6 +289,133 @@ def mad_outlier( return flagged +LOG_METRICS = ("total_counts", "n_genes_by_counts") +"""Metrics whose distribution is read on a log axis, because they are counts.""" + + +def metric_distributions( + frame: pd.DataFrame, + flagged=None, + log: tuple[str, ...] = LOG_METRICS, + bins: int = 60, +): + """One histogram per QC metric, over the cells the frame describes. + + Parameters + ---------- + frame : pandas.DataFrame + One column per metric, one row per cell, as `cell_fractions` + returns. Rows that are entirely `NaN` are cells the modality does + not hold, and are dropped rather than drawn as zero. + flagged : array-like of bool, optional + True for the cells a threshold rejects. Drawn in a second colour on + top of the kept ones, so what a cut removes is visible against what + it leaves. + log : tuple of str + Metrics binned and drawn on a log axis. A count distribution spans + orders of magnitude; a percentage does not. + bins : int + Bins per panel. + + Returns + ------- + tuple of (matplotlib.figure.Figure, pandas.DataFrame, str) + The figure, one row per bin per metric, and the caption. + + Raises + ------ + FigureError + The frame has no columns, or no cell survives the `NaN` drop. + """ + plt = style() + + if frame.shape[1] == 0: + raise FigureError("No metric to draw.") + measured = frame.dropna(how="all") + if measured.empty: + raise FigureError("No measured cell to draw.") + cut = ( + np.zeros(len(measured), dtype=bool) + if flagged is None + else np.asarray(flagged, dtype=bool)[frame.index.isin(measured.index)] + ) + + metrics = list(measured.columns) + columns = min(3, len(metrics)) + rows = -(-len(metrics) // columns) + figure, axes = plt.subplots( + rows, columns, figsize=(4.0 * columns, 3.0 * rows), squeeze=False + ) + + records = [] + for axis, metric in zip(axes.ravel(), metrics, strict=False): + values = measured[metric].to_numpy(dtype=float) + if metric in log: + # A zero has no place on a log axis, and the smallest count is 1. + floor = max(np.nanmin(values[values > 0], initial=1.0), 1.0) + edges = np.logspace(np.log10(floor), np.log10(np.nanmax(values)), bins + 1) + axis.set_xscale("log") + else: + edges = np.linspace(np.nanmin(values), np.nanmax(values), bins + 1) + + kept_counts, _ = np.histogram(values[~cut], bins=edges) + cut_counts, _ = np.histogram(values[cut], bins=edges) + axis.stairs(kept_counts, edges, fill=True, color=OKABE_ITO[4], label="kept") + if cut.any(): + axis.stairs( + cut_counts, + edges, + baseline=kept_counts, + fill=True, + color=OKABE_ITO[5], + label="flagged", + ) + axis.axvline(np.nanmedian(values), color="0.3", linewidth=1.0, linestyle="--") + axis.set(xlabel=metric, ylabel="cells") + axis.grid(which="major", color="0.85", linewidth=0.6) + axis.set_axisbelow(True) + + records.append( + pd.DataFrame( + { + "metric": metric, + "bin_left": edges[:-1], + "bin_right": edges[1:], + "n_kept": kept_counts, + "n_flagged": cut_counts, + } + ) + ) + + for axis in axes.ravel()[len(metrics) :]: + axis.set_visible(False) + if cut.any(): + axes.ravel()[0].legend(frameon=False, fontsize="small") + figure.tight_layout() + + numbers = pd.concat(records, ignore_index=True) + medians = ", ".join( + f"{metric} {measured[metric].median():,.2f}" for metric in metrics + ) + counted = ( + f"{int((~cut).sum()):,} kept and {int(cut.sum()):,} flagged" + if cut.any() + else f"{len(measured):,}" + ) + caption = ( + f"Distribution of each quality-control metric over {counted} cells. " + "Counts are binned and drawn on a log axis, fractions on a linear " + "one; the dashed line is the median (" + medians + "). The flagged " + "cells are stacked on the kept ones, so a threshold's effect is " + "read against the distribution it was taken from." + if cut.any() + else f"Distribution of each quality-control metric over {counted} " + "cells. Counts are binned and drawn on a log axis, fractions on a " + "linear one; the dashed line is the median (" + medians + ")." + ) + return figure, numbers, caption + + MITO_CONTIG = "chrM" """Mitochondrial genes are the ones on the mitochondrial contig.""" diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index e33fc18..7fe4f37 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -95,6 +95,19 @@ whose UMI count reaches the floor, returning the filtered copy and the mask. - A cell the modality does not hold gets `NaN`, not zero: it was not measured. +## The metric distributions + +`qc.metric_distributions(frame, flagged=None)` draws one histogram per +column of a `cell_fractions` frame, with matplotlib directly rather than +through scanpy's plotting. + +- **Counts go on a log axis**, fractions on a linear one (`LOG_METRICS`). +- **`flagged` stacks the rejected cells on the kept ones**, so a threshold + is read against the distribution it was taken from rather than beside it. +- The dashed line is the median, and the caption carries it, so a figure + cannot drift from its own numbers. +- A cell the modality does not hold is dropped, never binned as zero. + ## Thresholds from the channel itself `qc.mad_outlier(values, nmads=5, side="both", log=False)` flags the cells diff --git a/tests/test_qc.py b/tests/test_qc.py index a92f1b6..86e582d 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -156,3 +156,30 @@ def test_the_mad_is_rescaled_to_a_sigma_so_the_band_is_wider_than_the_raw_mad(): # A raw MAD would flag both probes; a sigma-scaled one does not. assert not flagged.any() assert mad_outlier(values, nmads=0.8).tolist()[0] is True + + +def test_the_distributions_draw_one_panel_per_metric_and_bin_every_cell(tmp_path): + from crispr_analysis_utils.qc import metric_distributions + + frame = cell_fractions(mudata()) + flagged = np.array([False, True, False]) + figure, numbers, caption = metric_distributions(frame, flagged=flagged, bins=4) + + metrics = list(frame.columns) + assert sorted(numbers["metric"].unique()) == sorted(metrics) + # The unmeasured cell is dropped, the other two are binned exactly once. + per_metric = numbers.groupby("metric")[["n_kept", "n_flagged"]].sum().sum(axis=1) + assert (per_metric == 2).all() + assert "flagged" in caption + written = publish(tmp_path / "metrics", figure, numbers, caption) + assert written[".pdf"].exists() + + +def test_the_distributions_need_a_metric_and_a_measured_cell(): + from crispr_analysis_utils.qc import metric_distributions + + frame = cell_fractions(mudata()) + with pytest.raises(FigureError, match="No metric"): + metric_distributions(frame[[]]) + with pytest.raises(FigureError, match="No measured cell"): + metric_distributions(frame.loc[["GGG"]]) From f5935818b78225d1007ba63378cf653717cd4d6f Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 12:45:43 -0400 Subject: [PATCH 05/30] FIX the log bins starting at 1 rather than at the data np.nanmin(..., initial=1.0) takes the minimum of the array and the initial value, so the floor was always 1. On filtered cells that put most of the range under the data, leaving the panel empty. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 6 ++++-- tests/test_qc.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index de00ba0..fe08836 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -351,8 +351,10 @@ def metric_distributions( for axis, metric in zip(axes.ravel(), metrics, strict=False): values = measured[metric].to_numpy(dtype=float) if metric in log: - # A zero has no place on a log axis, and the smallest count is 1. - floor = max(np.nanmin(values[values > 0], initial=1.0), 1.0) + # A zero has no place on a log axis, so the range starts at the + # smallest value actually present. + positive = values[values > 0] + floor = float(positive.min()) if positive.size else 1.0 edges = np.logspace(np.log10(floor), np.log10(np.nanmax(values)), bins + 1) axis.set_xscale("log") else: diff --git a/tests/test_qc.py b/tests/test_qc.py index 86e582d..7f2e823 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -183,3 +183,13 @@ def test_the_distributions_need_a_metric_and_a_measured_cell(): metric_distributions(frame[[]]) with pytest.raises(FigureError, match="No measured cell"): metric_distributions(frame.loc[["GGG"]]) + + +def test_a_log_panel_bins_from_the_smallest_value_not_from_one(): + from crispr_analysis_utils.qc import metric_distributions + + frame = pd.DataFrame({"total_counts": [1000.0, 2000.0, 8000.0]}) + _, numbers, _ = metric_distributions(frame, bins=4) + # Otherwise three of the four bins would sit under the data. + assert numbers["bin_left"].min() == pytest.approx(1000.0) + assert numbers["n_kept"].sum() == 3 From 1f4e16341a1bcfd71bf07faf9518a3605bffe341 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 12:46:46 -0400 Subject: [PATCH 06/30] UPDATE the distribution colours: blue kept, grey removed The kept cells carry the colour and the removed ones recede, so the eye goes to what the analysis goes on with. The median line turns black so it is not read as a third grey category. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 13 ++++++++++--- src/crispr_analysis_utils/skills/cau-qc/SKILL.md | 2 ++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index fe08836..c44508c 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -289,6 +289,12 @@ def mad_outlier( return flagged +KEPT = OKABE_ITO[4] +"""Blue, for what a threshold keeps: the cells the analysis goes on with.""" + +REMOVED = "0.7" +"""Grey, for what it removes. Neutral, so the eye reads the kept cells first.""" + LOG_METRICS = ("total_counts", "n_genes_by_counts") """Metrics whose distribution is read on a log axis, because they are counts.""" @@ -362,17 +368,18 @@ def metric_distributions( kept_counts, _ = np.histogram(values[~cut], bins=edges) cut_counts, _ = np.histogram(values[cut], bins=edges) - axis.stairs(kept_counts, edges, fill=True, color=OKABE_ITO[4], label="kept") + axis.stairs(kept_counts, edges, fill=True, color=KEPT, label="kept") if cut.any(): axis.stairs( cut_counts, edges, baseline=kept_counts, fill=True, - color=OKABE_ITO[5], + color=REMOVED, label="flagged", ) - axis.axvline(np.nanmedian(values), color="0.3", linewidth=1.0, linestyle="--") + # Black, so the median is not read as another grey category. + axis.axvline(np.nanmedian(values), color="black", linewidth=1.0, linestyle="--") axis.set(xlabel=metric, ylabel="cells") axis.grid(which="major", color="0.85", linewidth=0.6) axis.set_axisbelow(True) diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index 7fe4f37..4b70dff 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -104,6 +104,8 @@ through scanpy's plotting. - **Counts go on a log axis**, fractions on a linear one (`LOG_METRICS`). - **`flagged` stacks the rejected cells on the kept ones**, so a threshold is read against the distribution it was taken from rather than beside it. + Kept is blue (`qc.KEPT`) and removed is grey (`qc.REMOVED`), so the eye + goes to the cells the analysis keeps. - The dashed line is the median, and the caption carries it, so a figure cannot drift from its own numbers. - A cell the modality does not hold is dropped, never binned as zero. From 5bd9434f13ae1cbd56df5084dec398725b481808 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 12:48:28 -0400 Subject: [PATCH 07/30] FIX cells on the outermost value going uncounted logspace rounds its endpoints, so a cell sitting exactly on the smallest or largest value fell outside its own range: two of 10,296 went missing on a real channel. The outer edges are pinned back onto the data. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 8 +++++++- tests/test_qc.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index c44508c..581ff7a 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -364,7 +364,13 @@ def metric_distributions( edges = np.logspace(np.log10(floor), np.log10(np.nanmax(values)), bins + 1) axis.set_xscale("log") else: - edges = np.linspace(np.nanmin(values), np.nanmax(values), bins + 1) + floor = float(np.nanmin(values)) + edges = np.linspace(floor, np.nanmax(values), bins + 1) + # logspace rounds its endpoints, so a cell sitting exactly on the + # smallest or largest value can fall outside its own range and go + # uncounted. Pin the outer edges back onto the data. + edges[0] = min(edges[0], floor) + edges[-1] = max(edges[-1], float(np.nanmax(values))) kept_counts, _ = np.histogram(values[~cut], bins=edges) cut_counts, _ = np.histogram(values[cut], bins=edges) diff --git a/tests/test_qc.py b/tests/test_qc.py index 7f2e823..0a326ef 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -193,3 +193,12 @@ def test_a_log_panel_bins_from_the_smallest_value_not_from_one(): # Otherwise three of the four bins would sit under the data. assert numbers["bin_left"].min() == pytest.approx(1000.0) assert numbers["n_kept"].sum() == 3 + + +def test_a_cell_on_the_smallest_or_largest_value_is_still_counted(): + from crispr_analysis_utils.qc import metric_distributions + + # logspace rounds these endpoints just inside the data. + frame = pd.DataFrame({"total_counts": [271.0, 1000.0, 2321.0, 12150.0]}) + _, numbers, _ = metric_distributions(frame, bins=60) + assert numbers["n_kept"].sum() == 4 From fc363fb02b71f1e156f79801ad77d7582c6ba114 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 12:50:24 -0400 Subject: [PATCH 08/30] FIX the flagged layer painting over the kept cells stairs fills between its baseline and its values, so passing the flagged count against a baseline of the kept count filled downward across the whole bar: every panel came out grey. The stacked layer is kept + flagged. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 5 ++++- tests/test_qc.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 581ff7a..48d6d33 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -376,8 +376,11 @@ def metric_distributions( cut_counts, _ = np.histogram(values[cut], bins=edges) axis.stairs(kept_counts, edges, fill=True, color=KEPT, label="kept") if cut.any(): + # Stacked, so the top of the grey is kept + flagged. Passing + # the flagged count alone fills between it and the baseline, + # which paints over the kept cells instead of sitting on them. axis.stairs( - cut_counts, + kept_counts + cut_counts, edges, baseline=kept_counts, fill=True, diff --git a/tests/test_qc.py b/tests/test_qc.py index 0a326ef..8de0b69 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -202,3 +202,25 @@ def test_a_cell_on_the_smallest_or_largest_value_is_still_counted(): frame = pd.DataFrame({"total_counts": [271.0, 1000.0, 2321.0, 12150.0]}) _, numbers, _ = metric_distributions(frame, bins=60) assert numbers["n_kept"].sum() == 4 + + +def test_the_flagged_layer_sits_on_the_kept_one_rather_than_over_it(): + from matplotlib.patches import StepPatch + + from crispr_analysis_utils.qc import metric_distributions + + # One flagged cell among many kept: the grey must be a sliver on top. + frame = pd.DataFrame({"pct_counts_mito": [1.0] * 50 + [9.0]}) + flagged = np.array([False] * 50 + [True]) + figure, numbers, _ = metric_distributions(frame, flagged=flagged, bins=4) + + axis = figure.axes[0] + patches = [a for a in axis.get_children() if isinstance(a, StepPatch)] + assert len(patches) == 2 + kept = patches[0].get_data().values + stacked = patches[1].get_data().values + baseline = patches[1].get_data().baseline + # The grey starts where the blue ends and only ever goes up from there. + assert np.allclose(baseline, kept) + assert (stacked >= kept).all() + assert np.allclose(stacked - kept, numbers["n_flagged"].to_numpy()) From 50aaef67c5d8c7640855187cdd770bf5b20e96c2 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 12:56:47 -0400 Subject: [PATCH 09/30] REWRITE metric_distributions to split at each metric's threshold The line was the median and the colouring came from a mask passed in, so every panel greyed the same union of outliers -- in the total_counts panel the grey cells were the ones the mitochondrial test rejected. Now each panel computes its own MAD band through the new mad_bounds, draws it, and colours blue within and grey beyond, so the line on the figure is the cut. mad_outlier keeps its behaviour and is now a thin wrapper over the bounds, which is what stops the two from drifting apart. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 164 ++++++++++++------ .../skills/cau-qc/SKILL.md | 27 +-- tests/test_qc.py | 53 ++++-- 3 files changed, 173 insertions(+), 71 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 48d6d33..75542bb 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -265,13 +265,51 @@ def mad_outlier( can be read off it. This is what an unfiltered barcode space looks like: pick the cells first, then measure their spread. """ + numbers = np.asarray(values, dtype=float) + measured = ~np.isnan(numbers) + low, high = mad_bounds(numbers[measured], nmads=nmads, side=side, log=log) + + flagged = np.zeros(numbers.shape, dtype=bool) + flagged[measured] |= numbers[measured] < low + flagged[measured] |= numbers[measured] > high + return flagged + + +def mad_bounds( + values, + nmads: float = 5.0, + side: str = "both", + log: bool = False, +) -> tuple[float, float]: + """The thresholds `mad_outlier` applies, in the values' own units. + + A figure draws these and colours by them, so the line on the plot and + the cut in the table cannot disagree. + + Parameters + ---------- + values : array-like + One number per cell, with no `NaN`. + nmads, side, log + As `mad_outlier`. + + Returns + ------- + tuple of float + Lower and upper threshold. A tail that `side` does not cut comes + back as `-inf` or `inf`. + + Raises + ------ + FigureError + `side` is not one of the three, or the MAD is zero. + """ from scipy.stats import median_abs_deviation if side not in ("both", "lower", "upper"): raise FigureError(f"side must be both, upper or lower, not {side!r}") numbers = np.asarray(values, dtype=float) - measured = ~np.isnan(numbers) - scale = np.log10(numbers[measured] + 1) if log else numbers[measured] + scale = np.log10(numbers + 1) if log else numbers middle = np.median(scale) spread = median_abs_deviation(scale, scale="normal") if spread == 0: @@ -281,12 +319,12 @@ def mad_outlier( ) low, high = middle - nmads * spread, middle + nmads * spread - flagged = np.zeros(numbers.shape, dtype=bool) - if side in ("both", "lower"): - flagged[measured] |= scale < low - if side in ("both", "upper"): - flagged[measured] |= scale > high - return flagged + if log: + low, high = 10**low - 1, 10**high - 1 + return ( + float(low) if side in ("both", "lower") else -np.inf, + float(high) if side in ("both", "upper") else np.inf, + ) KEPT = OKABE_ITO[4] @@ -301,11 +339,15 @@ def mad_outlier( def metric_distributions( frame: pd.DataFrame, - flagged=None, + nmads: float = 5.0, log: tuple[str, ...] = LOG_METRICS, + sides: dict[str, str] | None = None, bins: int = 60, ): - """One histogram per QC metric, over the cells the frame describes. + """One histogram per QC metric, split at that metric's own threshold. + + Every panel computes its MAD band, draws it, and colours by it, so the + line on the figure is the cut the table would make. Parameters ---------- @@ -313,20 +355,23 @@ def metric_distributions( One column per metric, one row per cell, as `cell_fractions` returns. Rows that are entirely `NaN` are cells the modality does not hold, and are dropped rather than drawn as zero. - flagged : array-like of bool, optional - True for the cells a threshold rejects. Drawn in a second colour on - top of the kept ones, so what a cut removes is visible against what - it leaves. + nmads : float + Width of each band, in MADs. log : tuple of str - Metrics binned and drawn on a log axis. A count distribution spans - orders of magnitude; a percentage does not. + Metrics binned, thresholded and drawn on a log axis. A count + distribution spans orders of magnitude; a percentage does not. + sides : dict, optional + Which tail each metric cuts. The default is `"both"` for a count + and `"upper"` for everything else, since a cell with unusually + little mitochondrial signal is not a problem. bins : int Bins per panel. Returns ------- tuple of (matplotlib.figure.Figure, pandas.DataFrame, str) - The figure, one row per bin per metric, and the caption. + The figure, one row per bin per metric carrying both thresholds, + and the caption. Raises ------ @@ -340,13 +385,9 @@ def metric_distributions( measured = frame.dropna(how="all") if measured.empty: raise FigureError("No measured cell to draw.") - cut = ( - np.zeros(len(measured), dtype=bool) - if flagged is None - else np.asarray(flagged, dtype=bool)[frame.index.isin(measured.index)] - ) metrics = list(measured.columns) + sides = sides or {} columns = min(3, len(metrics)) rows = -(-len(metrics) // columns) figure, axes = plt.subplots( @@ -354,9 +395,21 @@ def metric_distributions( ) records = [] + unthresholded: list[str] = [] for axis, metric in zip(axes.ravel(), metrics, strict=False): values = measured[metric].to_numpy(dtype=float) - if metric in log: + on_log = metric in log + side = sides.get(metric, "both" if on_log else "upper") + try: + low, high = mad_bounds(values, nmads=nmads, side=side, log=on_log) + except FigureError: + # No spread, so no threshold. The panel still shows the + # distribution rather than taking the whole figure down. + low, high = -np.inf, np.inf + unthresholded.append(metric) + within = (values >= low) & (values <= high) + + if on_log: # A zero has no place on a log axis, so the range starts at the # smallest value actually present. positive = values[values > 0] @@ -372,23 +425,24 @@ def metric_distributions( edges[0] = min(edges[0], floor) edges[-1] = max(edges[-1], float(np.nanmax(values))) - kept_counts, _ = np.histogram(values[~cut], bins=edges) - cut_counts, _ = np.histogram(values[cut], bins=edges) + kept_counts, _ = np.histogram(values[within], bins=edges) + cut_counts, _ = np.histogram(values[~within], bins=edges) axis.stairs(kept_counts, edges, fill=True, color=KEPT, label="kept") - if cut.any(): - # Stacked, so the top of the grey is kept + flagged. Passing - # the flagged count alone fills between it and the baseline, - # which paints over the kept cells instead of sitting on them. + if (~within).any(): + # Stacked, so the top of the grey is kept + cut. Passing the cut + # count alone fills between it and the baseline, which paints + # over the kept cells instead of sitting on them. axis.stairs( kept_counts + cut_counts, edges, baseline=kept_counts, fill=True, color=REMOVED, - label="flagged", + label="removed", ) - # Black, so the median is not read as another grey category. - axis.axvline(np.nanmedian(values), color="black", linewidth=1.0, linestyle="--") + for threshold in (low, high): + if np.isfinite(threshold): + axis.axvline(threshold, color="black", linewidth=1.0, linestyle="--") axis.set(xlabel=metric, ylabel="cells") axis.grid(which="major", color="0.85", linewidth=0.6) axis.set_axisbelow(True) @@ -400,37 +454,47 @@ def metric_distributions( "bin_left": edges[:-1], "bin_right": edges[1:], "n_kept": kept_counts, - "n_flagged": cut_counts, + "n_removed": cut_counts, + "threshold_low": low, + "threshold_high": high, } ) ) for axis in axes.ravel()[len(metrics) :]: axis.set_visible(False) - if cut.any(): - axes.ravel()[0].legend(frameon=False, fontsize="small") + axes.ravel()[0].legend(frameon=False, fontsize="small") figure.tight_layout() numbers = pd.concat(records, ignore_index=True) - medians = ", ".join( - f"{metric} {measured[metric].median():,.2f}" for metric in metrics - ) - counted = ( - f"{int((~cut).sum()):,} kept and {int(cut.sum()):,} flagged" - if cut.any() - else f"{len(measured):,}" + thresholds = ", ".join( + f"{metric} " + + " to ".join( + f"{bound:,.2f}" + for bound in ( + numbers.loc[numbers["metric"] == metric, "threshold_low"].iloc[0], + numbers.loc[numbers["metric"] == metric, "threshold_high"].iloc[0], + ) + if np.isfinite(bound) + ) + for metric in metrics ) + removed = int(numbers["n_removed"].sum()) caption = ( - f"Distribution of each quality-control metric over {counted} cells. " - "Counts are binned and drawn on a log axis, fractions on a linear " - "one; the dashed line is the median (" + medians + "). The flagged " - "cells are stacked on the kept ones, so a threshold's effect is " - "read against the distribution it was taken from." - if cut.any() - else f"Distribution of each quality-control metric over {counted} " + f"Distribution of each quality-control metric over {len(measured):,} " "cells. Counts are binned and drawn on a log axis, fractions on a " - "linear one; the dashed line is the median (" + medians + ")." + "linear one. The dashed lines are that metric's own " + f"{nmads:g}-MAD thresholds ({thresholds}); cells beyond them are " + f"grey. Each panel cuts on its own metric, so the {removed:,} grey " + "bars summed across panels double-count a cell that several " + "metrics reject." ) + if unthresholded: + caption += ( + " No threshold is drawn for " + + ", ".join(unthresholded) + + ", whose values have no spread to measure one from." + ) return figure, numbers, caption diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index 4b70dff..c57f01c 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -97,17 +97,22 @@ whose UMI count reaches the floor, returning the filtered copy and the mask. ## The metric distributions -`qc.metric_distributions(frame, flagged=None)` draws one histogram per -column of a `cell_fractions` frame, with matplotlib directly rather than -through scanpy's plotting. - -- **Counts go on a log axis**, fractions on a linear one (`LOG_METRICS`). -- **`flagged` stacks the rejected cells on the kept ones**, so a threshold - is read against the distribution it was taken from rather than beside it. - Kept is blue (`qc.KEPT`) and removed is grey (`qc.REMOVED`), so the eye - goes to the cells the analysis keeps. -- The dashed line is the median, and the caption carries it, so a figure - cannot drift from its own numbers. +`qc.metric_distributions(frame, nmads=5)` draws one histogram per column +of a `cell_fractions` frame, with matplotlib directly rather than through +scanpy's plotting. + +- **Every panel thresholds on its own metric.** It calls `qc.mad_bounds`, + draws the result as a dashed black line, and colours by it: blue + (`qc.KEPT`) within the band, grey (`qc.REMOVED`) beyond it. The line and + the cut are one computation, so the figure cannot disagree with the + table. +- **Counts are two-sided and go on a log axis**, fractions are upper-tail + only and linear (`LOG_METRICS`, overridable with `sides`). +- **The grey bars do not sum to the cells a filter drops.** Each panel + counts its own rejects, so a cell that two metrics reject is grey twice. + `mad_outlier` per metric, combined with `any`, is what gives the cells. +- A metric with no spread loses its threshold, not the figure: the panel is + drawn with no line and the caption says so. - A cell the modality does not hold is dropped, never binned as zero. ## Thresholds from the channel itself diff --git a/tests/test_qc.py b/tests/test_qc.py index 8de0b69..7717c48 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -162,15 +162,14 @@ def test_the_distributions_draw_one_panel_per_metric_and_bin_every_cell(tmp_path from crispr_analysis_utils.qc import metric_distributions frame = cell_fractions(mudata()) - flagged = np.array([False, True, False]) - figure, numbers, caption = metric_distributions(frame, flagged=flagged, bins=4) + figure, numbers, caption = metric_distributions(frame, bins=4) metrics = list(frame.columns) assert sorted(numbers["metric"].unique()) == sorted(metrics) # The unmeasured cell is dropped, the other two are binned exactly once. - per_metric = numbers.groupby("metric")[["n_kept", "n_flagged"]].sum().sum(axis=1) + per_metric = numbers.groupby("metric")[["n_kept", "n_removed"]].sum().sum(axis=1) assert (per_metric == 2).all() - assert "flagged" in caption + assert "MAD" in caption written = publish(tmp_path / "metrics", figure, numbers, caption) assert written[".pdf"].exists() @@ -204,15 +203,15 @@ def test_a_cell_on_the_smallest_or_largest_value_is_still_counted(): assert numbers["n_kept"].sum() == 4 -def test_the_flagged_layer_sits_on_the_kept_one_rather_than_over_it(): +def test_the_removed_layer_sits_on_the_kept_one_rather_than_over_it(): from matplotlib.patches import StepPatch from crispr_analysis_utils.qc import metric_distributions - # One flagged cell among many kept: the grey must be a sliver on top. - frame = pd.DataFrame({"pct_counts_mito": [1.0] * 50 + [9.0]}) - flagged = np.array([False] * 50 + [True]) - figure, numbers, _ = metric_distributions(frame, flagged=flagged, bins=4) + # One outlier among many: the grey must be a sliver on top. + rng = np.random.default_rng(0) + frame = pd.DataFrame({"pct_counts_mito": np.r_[rng.normal(1.0, 0.1, 50), 9.0]}) + figure, numbers, _ = metric_distributions(frame, bins=4) axis = figure.axes[0] patches = [a for a in axis.get_children() if isinstance(a, StepPatch)] @@ -223,4 +222,38 @@ def test_the_flagged_layer_sits_on_the_kept_one_rather_than_over_it(): # The grey starts where the blue ends and only ever goes up from there. assert np.allclose(baseline, kept) assert (stacked >= kept).all() - assert np.allclose(stacked - kept, numbers["n_flagged"].to_numpy()) + assert np.allclose(stacked - kept, numbers["n_removed"].to_numpy()) + + +def test_the_line_is_the_threshold_and_the_colour_changes_at_it(): + from crispr_analysis_utils.qc import mad_bounds, metric_distributions + + rng = np.random.default_rng(0) + values = np.r_[rng.normal(1.0, 0.1, 50), 9.0] + frame = pd.DataFrame({"pct_counts_mito": values}) + figure, numbers, caption = metric_distributions(frame, bins=4) + + # Upper tail only for a fraction, so one line, at the upper bound. + _, high = mad_bounds(values, side="upper") + lines = [x.get_xdata()[0] for x in figure.axes[0].get_lines()] + assert lines == pytest.approx([high]) + assert numbers["threshold_high"].iloc[0] == pytest.approx(high) + assert not np.isfinite(numbers["threshold_low"].iloc[0]) + # Everything grey lies beyond the line, everything blue short of it. + beyond = numbers[numbers["n_removed"] > 0] + assert (beyond["bin_right"] > high).all() + assert f"{high:,.2f}" in caption + + +def test_a_metric_with_no_spread_loses_its_threshold_not_the_whole_figure(): + from crispr_analysis_utils.qc import metric_distributions + + frame = pd.DataFrame( + {"pct_counts_mito": [1.0] * 50, "pct_counts_ribo": [1.0, 2.0] * 25} + ) + figure, numbers, caption = metric_distributions(frame, bins=4) + + assert len(figure.axes) >= 2 + flat = numbers[numbers["metric"] == "pct_counts_mito"] + assert not np.isfinite(flat["threshold_high"]).any() + assert "pct_counts_mito" in caption.split("No threshold is drawn for")[1] From cb1b6a20f240fcea555d24cb06271500b4c08aea Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:06:23 -0400 Subject: [PATCH 10/30] ADD metric_scatter, two QC metrics against each other A cell can be poor on either metric alone and the pair says which, so the thresholds are drawn as lines and the quadrant counts go in the caption. The thresholds are arguments rather than derived: a MAD band is one way to reach them and a closely QCed library is another. The points are rasterised and the text is not, which is what keeps the PDF openable: the same figure drawn in R came to 39 MB. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 139 ++++++++++++++++++ .../skills/cau-qc/SKILL.md | 15 ++ tests/test_qc.py | 33 +++++ 3 files changed, 187 insertions(+) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 75542bb..dbf4eb6 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -498,6 +498,145 @@ def metric_distributions( return figure, numbers, caption +def metric_scatter( + frame: pd.DataFrame, + x: str = "total_counts", + y: str = "pct_counts_targeted", + x_threshold: float | None = None, + y_threshold: float | None = None, + log: tuple[str, ...] = LOG_METRICS, + marginals: bool = True, +): + """Two QC metrics against each other, cut into quadrants by thresholds. + + A cell can be poor on either metric alone, and the pair says which. + The thresholds are given, not derived: a MAD band is one way to reach + them and a library that has been looked at closely is another. + + Parameters + ---------- + frame : pandas.DataFrame + One row per cell, carrying at least the two columns named. + x, y : str + Columns to draw. Rows with `NaN` in either are dropped. + x_threshold, y_threshold : float, optional + Cuts, drawn as dashed lines. A cell is kept when it is at or above + both; a threshold left out keeps that axis whole. + log : tuple of str + Metrics drawn on a log axis. + marginals : bool + Draw each metric's own histogram against its axis. + + Returns + ------- + tuple of (matplotlib.figure.Figure, pandas.DataFrame, str) + The figure, one row per cell with its quadrant, and the caption. + + Raises + ------ + FigureError + A column is missing, or no cell has both metrics. + """ + plt = style() + + for name in (x, y): + if name not in frame: + raise FigureError(f"{name!r} is not a column of the frame.") + measured = frame[[x, y]].dropna() + if measured.empty: + raise FigureError("No cell has both metrics.") + + xs = measured[x].to_numpy(dtype=float) + ys = measured[y].to_numpy(dtype=float) + above_x = xs >= x_threshold if x_threshold is not None else np.ones(len(xs), bool) + above_y = ys >= y_threshold if y_threshold is not None else np.ones(len(ys), bool) + kept = above_x & above_y + + if marginals: + figure = plt.figure(figsize=(6.0, 6.0)) + grid = figure.add_gridspec( + 2, 2, width_ratios=(5, 1), height_ratios=(1, 5), wspace=0.05, hspace=0.05 + ) + axis = figure.add_subplot(grid[1, 0]) + top = figure.add_subplot(grid[0, 0], sharex=axis) + right = figure.add_subplot(grid[1, 1], sharey=axis) + else: + figure, axis = plt.subplots(figsize=(5.5, 4.5)) + top = right = None + + # Rasterised: one point per cell stays a vector object otherwise, and a + # channel of 10,000 cells turns into a PDF nobody can open. + for mask, colour, label in ((kept, KEPT, "kept"), (~kept, REMOVED, "removed")): + if mask.any(): + axis.scatter( + xs[mask], + ys[mask], + s=2, + alpha=0.4, + linewidths=0, + color=colour, + label=label, + rasterized=True, + ) + if x in log: + axis.set_xscale("log") + if y in log: + axis.set_yscale("log") + if x_threshold is not None: + axis.axvline(x_threshold, color="black", linewidth=1.0, linestyle="--") + if y_threshold is not None: + axis.axhline(y_threshold, color="black", linewidth=1.0, linestyle="--") + axis.set(xlabel=x, ylabel=y) + axis.grid(which="major", color="0.85", linewidth=0.6) + axis.set_axisbelow(True) + axis.legend(frameon=False, fontsize="small", loc="lower right") + + for panel, values, name, orientation in ( + (top, xs, x, "vertical"), + (right, ys, y, "horizontal"), + ): + if panel is None: + continue + edges = ( + np.logspace(np.log10(values.min()), np.log10(values.max()), 60) + if name in log + else 60 + ) + panel.hist(values, bins=edges, color="0.8", orientation=orientation) + panel.axis("off") + + counts = { + "kept": int(kept.sum()), + "low_" + x: int((~above_x & above_y).sum()), + "low_" + y: int((above_x & ~above_y).sum()), + "low_both": int((~above_x & ~above_y).sum()), + } + numbers = pd.DataFrame( + { + "cell": measured.index, + x: xs, + y: ys, + "above_" + x: above_x, + "above_" + y: above_y, + "kept": kept, + } + ) + stated = ", ".join( + f"{name} at {value:,.4g}" + for name, value in ((x, x_threshold), (y, y_threshold)) + if value is not None + ) + caption = ( + f"{x} against {y} for {len(measured):,} cells, one point each. " + + (f"Dashed lines are the thresholds ({stated}). " if stated else "") + + "Cells at or above every threshold are blue and the rest grey: " + + ", ".join(f"{label} {number:,}" for label, number in counts.items()) + + ". Histograms on the margins are each metric's own distribution " + "over the same cells." + ) + return figure, numbers, caption + + MITO_CONTIG = "chrM" """Mitochondrial genes are the ones on the mitochondrial contig.""" diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index c57f01c..b1f145d 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -115,6 +115,21 @@ scanpy's plotting. drawn with no line and the caption says so. - A cell the modality does not hold is dropped, never binned as zero. +## Two metrics at once + +`qc.metric_scatter(frame, x, y, x_threshold=..., y_threshold=...)` draws one +point per cell with the thresholds as dashed lines, each metric's histogram +on its margin, and the quadrant counts in the caption. + +- **The thresholds are given, not derived.** A MAD band is one way to reach + them; a library someone has QCed closely is another, and a fixed number + chosen that way is a legitimate input. +- **Blue is at or above every threshold**, grey is everything else, so the + kept quadrant reads at a glance. +- **The points are rasterised** and the text is not. One vector point per + cell makes a PDF that will not open: the same figure in R came to 39 MB. +- A cell missing either metric is dropped, not drawn at zero. + ## Thresholds from the channel itself `qc.mad_outlier(values, nmads=5, side="both", log=False)` flags the cells diff --git a/tests/test_qc.py b/tests/test_qc.py index 7717c48..4e2233a 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -257,3 +257,36 @@ def test_a_metric_with_no_spread_loses_its_threshold_not_the_whole_figure(): flat = numbers[numbers["metric"] == "pct_counts_mito"] assert not np.isfinite(flat["threshold_high"]).any() assert "pct_counts_mito" in caption.split("No threshold is drawn for")[1] + + +def test_the_scatter_cuts_into_quadrants_and_keeps_only_the_upper_right(): + from crispr_analysis_utils.qc import metric_scatter + + frame = pd.DataFrame( + { + "total_counts": [500.0, 2000.0, 500.0, 2000.0], + "pct_counts_targeted": [10.0, 10.0, 60.0, 60.0], + } + ) + figure, numbers, caption = metric_scatter(frame, x_threshold=1000, y_threshold=50) + assert numbers["kept"].tolist() == [False, False, False, True] + assert "kept 1" in caption + assert "low_both 1" in caption + # One line per threshold, on the scatter panel. + scatter_axis = figure.axes[0] + assert len(scatter_axis.get_lines()) == 2 + + +def test_the_scatter_drops_a_cell_missing_either_metric_and_refuses_an_empty_one(): + from crispr_analysis_utils.qc import metric_scatter + + frame = pd.DataFrame( + {"total_counts": [1000.0, np.nan], "pct_counts_targeted": [60.0, 60.0]} + ) + _, numbers, _ = metric_scatter(frame, marginals=False) + assert len(numbers) == 1 + + with pytest.raises(FigureError, match="not a column"): + metric_scatter(frame, x="missing") + with pytest.raises(FigureError, match="No cell has both"): + metric_scatter(frame.iloc[1:2], y="total_counts", x="pct_counts_targeted") From 9440132756f6af35152a51a26a68b31a3991e6c7 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:07:42 -0400 Subject: [PATCH 11/30] UPDATE the scatter to drop its legend when nothing is removed A single-entry legend labelling every point is noise, and it landed on the threshold line. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 4 +++- tests/test_qc.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index dbf4eb6..8ee635e 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -589,7 +589,9 @@ def metric_scatter( axis.set(xlabel=x, ylabel=y) axis.grid(which="major", color="0.85", linewidth=0.6) axis.set_axisbelow(True) - axis.legend(frameon=False, fontsize="small", loc="lower right") + # Only worth a legend when there are two groups to tell apart. + if kept.any() and not kept.all(): + axis.legend(frameon=False, fontsize="small", loc="lower right") for panel, values, name, orientation in ( (top, xs, x, "vertical"), diff --git a/tests/test_qc.py b/tests/test_qc.py index 4e2233a..e037a2e 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -290,3 +290,13 @@ def test_the_scatter_drops_a_cell_missing_either_metric_and_refuses_an_empty_one metric_scatter(frame, x="missing") with pytest.raises(FigureError, match="No cell has both"): metric_scatter(frame.iloc[1:2], y="total_counts", x="pct_counts_targeted") + + +def test_the_scatter_drops_the_legend_when_nothing_is_removed(): + from crispr_analysis_utils.qc import metric_scatter + + frame = pd.DataFrame( + {"total_counts": [2000.0, 3000.0], "pct_counts_targeted": [60.0, 61.0]} + ) + figure, _, _ = metric_scatter(frame, x_threshold=1000, y_threshold=25) + assert figure.axes[0].get_legend() is None From 578fca6333f282fe6659c149a9d4c18d4e697d68 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:09:40 -0400 Subject: [PATCH 12/30] ADD extra gene classes to cell_fractions A threshold set against another analysis's definition of a class only applies to that definition. Passing the mask measures it alongside the built-in classes instead of silently comparing against a different number. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 19 ++++++++++++++++++- .../skills/cau-qc/SKILL.md | 3 +++ tests/test_qc.py | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 8ee635e..4e79598 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -702,7 +702,11 @@ def gene_classes(var: pd.DataFrame) -> dict[str, np.ndarray]: return classes -def cell_fractions(data, modality: str = "gene") -> pd.DataFrame: +def cell_fractions( + data, + modality: str = "gene", + extra: dict[str, np.ndarray] | None = None, +) -> pd.DataFrame: """The share of each cell's UMIs coming from each class of gene. The arithmetic is scanpy's `calculate_qc_metrics`, so the columns are @@ -716,6 +720,11 @@ def cell_fractions(data, modality: str = "gene") -> pd.DataFrame: A MuData as `mudata_builder` assembles it. modality Which modality to measure; the gene counts by default. + extra + Further classes, as a mapping of name to a boolean mask over the + modality's genes. A class defined elsewhere -- an older analysis's + own definition of ribosomal, say -- is measured alongside the + built-in ones, so a threshold set against it still applies. Returns ------- @@ -729,6 +738,14 @@ def cell_fractions(data, modality: str = "gene") -> pd.DataFrame: counts = data[modality] classes = gene_classes(counts.var) + for name, mask in (extra or {}).items(): + mask = np.asarray(mask, dtype=bool) + if mask.shape != (counts.n_vars,): + raise FigureError( + f"The mask for {name!r} covers {mask.shape[0]:,} genes, " + f"but the modality holds {counts.n_vars:,}." + ) + classes[name] = mask # scanpy reads the class membership off var, by the names given here. measured = counts.copy() diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index b1f145d..d432b88 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -94,6 +94,9 @@ whose UMI count reaches the floor, returning the filtered copy and the mask. `RPL` prefixes with the ~80 real ribosomal proteins and are transcribed. - A cell the modality does not hold gets `NaN`, not zero: it was not measured. +- **`extra={name: mask}` adds a class of someone else's definition.** A + threshold set against an older analysis's idea of ribosomal only applies + to that definition, so measure it rather than assume the two agree. ## The metric distributions diff --git a/tests/test_qc.py b/tests/test_qc.py index e037a2e..c0d47b1 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -300,3 +300,21 @@ def test_the_scatter_drops_the_legend_when_nothing_is_removed(): ) figure, _, _ = metric_scatter(frame, x_threshold=1000, y_threshold=25) assert figure.axes[0].get_legend() is None + + +def test_an_extra_gene_class_is_measured_alongside_the_built_in_ones(): + data = mudata() + # Another analysis's own definition, wider than this module's. + wider = np.array([False, True, True, True, False]) + frame = cell_fractions(data, extra={"ribo_wide": wider}) + + assert "pct_counts_ribo_wide" in frame + # AAA: 10 + 20 + 10 of 100 UMIs. + assert frame.loc["AAA", "pct_counts_ribo_wide"] == pytest.approx(40.0) + # The built-in classes are untouched. + assert frame.loc["AAA", "pct_counts_ribo"] == pytest.approx(20.0) + + +def test_an_extra_mask_of_the_wrong_length_is_refused(): + with pytest.raises(FigureError, match="covers 2 genes"): + cell_fractions(mudata(), extra={"wrong": np.array([True, False])}) From c13d850c3ff0769622e1c521687a68063980eb5a Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:11:54 -0400 Subject: [PATCH 13/30] ADD panel_expression and mean_upm A targeted assay works when its panel dominates, and comparing genes rather than cells is what separates enrichment across the whole panel from a few genes carrying it. mean_upm does the normalisation on the sparse matrix, since counts_per_million is for the dense guide-by-sample table of a bulk screen and would densify a 10,000 by 62,757 one. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 118 ++++++++++++++++++ .../skills/cau-qc/SKILL.md | 14 +++ tests/test_qc.py | 49 ++++++++ 3 files changed, 181 insertions(+) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 4e79598..7a0ed0d 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -639,6 +639,124 @@ def metric_scatter( return figure, numbers, caption +def mean_upm(counts) -> np.ndarray: + """Mean UMIs per million per gene, over the cells of one modality. + + Each cell is scaled to a million UMIs first, so a deep cell does not + outweigh a shallow one, and the mean is taken across cells. + + `normalization.counts_per_million` is the same arithmetic for the dense + guide-by-sample tables of a bulk screen; a single-cell matrix is sparse + and far too large to densify, so it is done here instead. + + Parameters + ---------- + counts : anndata.AnnData + One modality. Cells in rows, genes in columns. + + Returns + ------- + numpy.ndarray + One value per gene. A cell with no UMIs contributes nothing rather + than dividing by zero. + """ + import scipy.sparse as sp + + matrix = counts.X + totals = np.asarray(matrix.sum(axis=1)).ravel() + # A cell with no UMIs has no composition to measure; scaling it by zero + # keeps it in the denominator without inventing counts for it. + scale = np.divide( + 1e6, totals, out=np.zeros_like(totals, dtype=float), where=totals > 0 + ) + if sp.issparse(matrix): + scaled = sp.diags(scale) @ matrix + else: + scaled = np.asarray(matrix) * scale[:, None] + return np.asarray(scaled.mean(axis=0)).ravel() + + +def panel_expression(data, modality: str = "gene", flag: str = "readout"): + """How much more a targeted panel's genes are seen than everything else. + + A targeted assay works when its panel dominates. This compares the two + groups gene by gene rather than cell by cell, which is what says whether + the enrichment comes from the panel as a whole or from a few genes. + + Parameters + ---------- + data + A MuData as `mudata_builder` assembles it. + modality : str + Which modality holds the genes. + flag : str + The boolean column of `var` marking the panel. + + Returns + ------- + tuple of (matplotlib.figure.Figure, pandas.DataFrame, str) + The figure, one row per gene, and the caption. + + Raises + ------ + FigureError + `var` has no such column, or it marks no gene. + """ + plt = style() + + counts = data[modality] + if flag not in counts.var: + raise FigureError(f"{flag!r} is not a column of {modality}.var.") + in_panel = counts.var[flag].fillna(False).to_numpy().astype(bool) + if not in_panel.any(): + raise FigureError(f"{flag!r} marks no gene, so there is no panel.") + + values = mean_upm(counts) + numbers = pd.DataFrame( + { + "gene": counts.var_names, + "symbol": counts.var.get("symbol", pd.Series(index=counts.var_names)), + "mean_upm": values, + "in_panel": in_panel, + } + ).reset_index(drop=True) + + figure, axis = plt.subplots(figsize=(4.0, 4.5)) + groups = [values[in_panel], values[~in_panel]] + # +1 so a gene seen in no cell stays on a log axis instead of vanishing. + boxes = axis.boxplot( + [group + 1 for group in groups], + tick_labels=["panel", "background"], + widths=0.5, + patch_artist=True, + flierprops={"markersize": 2, "markeredgewidth": 0, "alpha": 0.3}, + ) + for patch, colour in zip(boxes["boxes"], (KEPT, REMOVED), strict=False): + patch.set_facecolor(colour) + for median in boxes["medians"]: + median.set_color("black") + axis.set_yscale("log") + axis.set_ylabel("mean UMIs per million + 1") + axis.grid(axis="y", which="major", color="0.85", linewidth=0.6) + axis.set_axisbelow(True) + figure.tight_layout() + + panel_median = float(np.median(values[in_panel])) + background_median = float(np.median(values[~in_panel])) + fold = panel_median / background_median if background_median else float("inf") + caption = ( + f"Mean expression per gene, in units of UMIs per million, for the " + f"{int(in_panel.sum()):,} genes of the panel against the " + f"{int((~in_panel).sum()):,} others. Medians are " + f"{panel_median:,.1f} and {background_median:,.1f}, a " + f"{fold:,.0f}-fold difference. One is added before taking the log so " + "a gene seen in no cell still appears. Comparing genes rather than " + "cells is what separates enrichment across the whole panel from a " + "few genes carrying it." + ) + return figure, numbers, caption + + MITO_CONTIG = "chrM" """Mitochondrial genes are the ones on the mitochondrial contig.""" diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index d432b88..83f719f 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -133,6 +133,20 @@ on its margin, and the quadrant counts in the caption. cell makes a PDF that will not open: the same figure in R came to 39 MB. - A cell missing either metric is dropped, not drawn at zero. +## Does the panel dominate? + +`qc.panel_expression(data)` boxplots the mean expression per gene, in UMIs +per million, for the genes `var["readout"]` marks against every other gene. + +- **Gene by gene, not cell by cell.** `pct_counts_targeted` says how much of + a cell is the panel; this says whether that comes from the panel as a + whole or from a few genes carrying it. +- `qc.mean_upm(counts)` is the number behind it: each cell scaled to a + million UMIs, then averaged across cells, computed on the sparse matrix. + `normalization.counts_per_million` is the same arithmetic for the dense + guide-by-sample table of a bulk screen and would densify this one. +- One is added before the log, so a gene seen in no cell still appears. + ## Thresholds from the channel itself `qc.mad_outlier(values, nmads=5, side="both", log=False)` flags the cells diff --git a/tests/test_qc.py b/tests/test_qc.py index c0d47b1..f71441e 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -318,3 +318,52 @@ def test_an_extra_gene_class_is_measured_alongside_the_built_in_ones(): def test_an_extra_mask_of_the_wrong_length_is_refused(): with pytest.raises(FigureError, match="covers 2 genes"): cell_fractions(mudata(), extra={"wrong": np.array([True, False])}) + + +def test_mean_upm_scales_each_cell_before_averaging(): + from crispr_analysis_utils.qc import mean_upm + + data = mudata() + values = mean_upm(data["gene"]) + # AAA is 10/10/20/10/50 of 100; CCC is 0/0/0/0/100 of 100. + assert values.tolist() == pytest.approx( + [50_000.0, 50_000.0, 100_000.0, 50_000.0, 750_000.0] + ) + + +def test_a_cell_with_no_umis_does_not_divide_by_zero(): + import anndata as ad + import mudata as md + + from crispr_analysis_utils.qc import mean_upm + + empty = ad.AnnData( + X=np.array([[1.0, 1.0], [0.0, 0.0]], dtype="float32"), + obs=pd.DataFrame(index=pd.Index(["a", "b"], name="barcode")), + var=pd.DataFrame(index=pd.Index(["g1", "g2"], name="gene_id")), + ) + values = mean_upm(md.MuData({"gene": empty})["gene"]) + # The empty cell contributes zero, so the mean is halved, not infinite. + assert values.tolist() == pytest.approx([250_000.0, 250_000.0]) + + +def test_the_panel_boxplot_splits_on_the_readout_flag(tmp_path): + from crispr_analysis_utils.qc import panel_expression + + figure, numbers, caption = panel_expression(mudata()) + assert numbers["in_panel"].sum() == 1 + assert len(numbers) == 5 + assert "1 genes of the panel against the 4 others" in caption + written = publish(tmp_path / "panel", figure, numbers, caption) + assert written[".tsv.gz"].exists() + + +def test_the_panel_boxplot_needs_a_flag_that_marks_something(): + from crispr_analysis_utils.qc import panel_expression + + data = mudata() + with pytest.raises(FigureError, match="not a column"): + panel_expression(data, flag="missing") + data["gene"].var["readout"] = False + with pytest.raises(FigureError, match="marks no gene"): + panel_expression(data) From e1dedf6ad8f0849e98feb52e1519f2f0006e32af Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:13:25 -0400 Subject: [PATCH 14/30] FIX the panel caption reading "a inf-fold difference" Half an unfiltered GENCODE reference is never seen, so the background median is zero and a ratio says nothing. The caption now gives how many background genes are seen at all, which is the informative number. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 19 +++++++++++++------ tests/test_qc.py | 12 ++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 7a0ed0d..b4cd9c4 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -743,16 +743,23 @@ def panel_expression(data, modality: str = "gene", flag: str = "readout"): panel_median = float(np.median(values[in_panel])) background_median = float(np.median(values[~in_panel])) - fold = panel_median / background_median if background_median else float("inf") + seen = int((values[~in_panel] > 0).sum()) + difference = ( + f"a {panel_median / background_median:,.0f}-fold difference" + if background_median + # Half the reference is never seen, so its median is zero and a + # ratio says nothing. The share that is seen does. + else f"the background median being zero, with {seen:,} of " + f"{int((~in_panel).sum()):,} background genes seen at all" + ) caption = ( f"Mean expression per gene, in units of UMIs per million, for the " f"{int(in_panel.sum()):,} genes of the panel against the " f"{int((~in_panel).sum()):,} others. Medians are " - f"{panel_median:,.1f} and {background_median:,.1f}, a " - f"{fold:,.0f}-fold difference. One is added before taking the log so " - "a gene seen in no cell still appears. Comparing genes rather than " - "cells is what separates enrichment across the whole panel from a " - "few genes carrying it." + f"{panel_median:,.1f} and {background_median:,.1f}, {difference}. " + "One is added before taking the log so a gene seen in no cell still " + "appears. Comparing genes rather than cells is what separates " + "enrichment across the whole panel from a few genes carrying it." ) return figure, numbers, caption diff --git a/tests/test_qc.py b/tests/test_qc.py index f71441e..6b800ec 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -367,3 +367,15 @@ def test_the_panel_boxplot_needs_a_flag_that_marks_something(): data["gene"].var["readout"] = False with pytest.raises(FigureError, match="marks no gene"): panel_expression(data) + + +def test_the_panel_caption_says_what_a_zero_background_median_means(): + from crispr_analysis_utils.qc import panel_expression + + data = mudata() + # Most of an unfiltered reference is never seen, so its median is zero. + data["gene"].X = np.array([[0, 0, 0, 0, 50], [0, 0, 0, 0, 100]], dtype="float32") + _, _, caption = panel_expression(data) + assert "inf" not in caption + assert "background median being zero" in caption + assert "0 of 4 background genes seen" in caption From b02b4b7da5a47397d92457d50a1b31f0def7f273 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:15:35 -0400 Subject: [PATCH 15/30] ADD a unit to metric_scatter so it serves a per-gene comparison The same figure -- two measures, thresholds, quadrant counts -- compares genes against a reference as readily as it compares cells. Only the caption and the index column named a cell. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 16 +++++++++++----- tests/test_qc.py | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index b4cd9c4..003e7ee 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -506,6 +506,7 @@ def metric_scatter( y_threshold: float | None = None, log: tuple[str, ...] = LOG_METRICS, marginals: bool = True, + unit: str = "cells", ): """Two QC metrics against each other, cut into quadrants by thresholds. @@ -526,6 +527,9 @@ def metric_scatter( Metrics drawn on a log axis. marginals : bool Draw each metric's own histogram against its axis. + unit : str + What a point is, for the caption. Cells by default; the same figure + serves a per-gene comparison. Returns ------- @@ -544,7 +548,9 @@ def metric_scatter( raise FigureError(f"{name!r} is not a column of the frame.") measured = frame[[x, y]].dropna() if measured.empty: - raise FigureError("No cell has both metrics.") + raise FigureError( + f"No {unit[:-1] if unit.endswith('s') else unit} has both metrics." + ) xs = measured[x].to_numpy(dtype=float) ys = measured[y].to_numpy(dtype=float) @@ -615,7 +621,7 @@ def metric_scatter( } numbers = pd.DataFrame( { - "cell": measured.index, + unit.rstrip("s"): measured.index, x: xs, y: ys, "above_" + x: above_x, @@ -629,12 +635,12 @@ def metric_scatter( if value is not None ) caption = ( - f"{x} against {y} for {len(measured):,} cells, one point each. " + f"{x} against {y} for {len(measured):,} {unit}, one point each. " + (f"Dashed lines are the thresholds ({stated}). " if stated else "") - + "Cells at or above every threshold are blue and the rest grey: " + + "Points at or above every threshold are blue and the rest grey: " + ", ".join(f"{label} {number:,}" for label, number in counts.items()) + ". Histograms on the margins are each metric's own distribution " - "over the same cells." + + f"over the same {unit}." ) return figure, numbers, caption diff --git a/tests/test_qc.py b/tests/test_qc.py index 6b800ec..dadf913 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -379,3 +379,25 @@ def test_the_panel_caption_says_what_a_zero_background_median_means(): assert "inf" not in caption assert "background median being zero" in caption assert "0 of 4 background genes seen" in caption + + +def test_the_scatter_serves_a_per_gene_comparison_too(): + from crispr_analysis_utils.qc import metric_scatter + + frame = pd.DataFrame( + {"reference_upm": [5.0, 100.0], "mean_umi": [0.2, 3.0]}, + index=pd.Index(["ENSG1", "ENSG2"], name="gene"), + ) + _, numbers, caption = metric_scatter( + frame, + x="reference_upm", + y="mean_umi", + x_threshold=30, + y_threshold=1, + log=("reference_upm", "mean_umi"), + unit="genes", + ) + assert "2 genes, one point each" in caption + assert "over the same genes" in caption + assert numbers.columns[0] == "gene" + assert numbers["kept"].tolist() == [False, True] From da685e566040f2e49a31bf2f89fd415c7eda3bc8 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:17:33 -0400 Subject: [PATCH 16/30] FIX a zero vanishing from a log-axis scatter without saying so matplotlib drops a non-positive point on a log axis silently. It now stays in the data file marked by a drawn column, still counts in its quadrant, and the caption reports how many there were. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 23 ++++++++++++++++--- .../skills/cau-qc/SKILL.md | 5 ++++ tests/test_qc.py | 17 ++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 003e7ee..f90013d 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -557,6 +557,12 @@ def metric_scatter( above_x = xs >= x_threshold if x_threshold is not None else np.ones(len(xs), bool) above_y = ys >= y_threshold if y_threshold is not None else np.ones(len(ys), bool) kept = above_x & above_y + # A log axis cannot show a zero, and matplotlib drops one without + # saying so. Keep it in the numbers and account for it in the caption. + drawn = np.ones(len(xs), dtype=bool) + for name, values in ((x, xs), (y, ys)): + if name in log: + drawn &= values > 0 if marginals: figure = plt.figure(figsize=(6.0, 6.0)) @@ -572,7 +578,10 @@ def metric_scatter( # Rasterised: one point per cell stays a vector object otherwise, and a # channel of 10,000 cells turns into a PDF nobody can open. - for mask, colour, label in ((kept, KEPT, "kept"), (~kept, REMOVED, "removed")): + for mask, colour, label in ( + (kept & drawn, KEPT, "kept"), + (~kept & drawn, REMOVED, "removed"), + ): if mask.any(): axis.scatter( xs[mask], @@ -605,12 +614,13 @@ def metric_scatter( ): if panel is None: continue + shown = values[drawn] edges = ( - np.logspace(np.log10(values.min()), np.log10(values.max()), 60) + np.logspace(np.log10(shown.min()), np.log10(shown.max()), 60) if name in log else 60 ) - panel.hist(values, bins=edges, color="0.8", orientation=orientation) + panel.hist(shown, bins=edges, color="0.8", orientation=orientation) panel.axis("off") counts = { @@ -627,6 +637,7 @@ def metric_scatter( "above_" + x: above_x, "above_" + y: above_y, "kept": kept, + "drawn": drawn, } ) stated = ", ".join( @@ -642,6 +653,12 @@ def metric_scatter( + ". Histograms on the margins are each metric's own distribution " + f"over the same {unit}." ) + if not drawn.all(): + caption += ( + f" {int((~drawn).sum()):,} of the {unit} hold a zero on a log " + "axis and so cannot be drawn; they are in the data file, marked " + "by the drawn column, and are counted in the quadrants above." + ) return figure, numbers, caption diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index 83f719f..fb5f831 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -132,6 +132,11 @@ on its margin, and the quadrant counts in the caption. - **The points are rasterised** and the text is not. One vector point per cell makes a PDF that will not open: the same figure in R came to 39 MB. - A cell missing either metric is dropped, not drawn at zero. +- **A zero cannot sit on a log axis**, and matplotlib drops one without + saying so. Such a point stays in the data file with `drawn` false, still + counts in its quadrant, and the caption says how many there were. +- `unit="genes"` makes the same figure a per-gene comparison against a + reference, thresholds and quadrant counts included. ## Does the panel dominate? diff --git a/tests/test_qc.py b/tests/test_qc.py index dadf913..4c652cb 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -401,3 +401,20 @@ def test_the_scatter_serves_a_per_gene_comparison_too(): assert "over the same genes" in caption assert numbers.columns[0] == "gene" assert numbers["kept"].tolist() == [False, True] + + +def test_a_zero_on_a_log_axis_is_reported_rather_than_silently_dropped(): + from crispr_analysis_utils.qc import metric_scatter + + frame = pd.DataFrame( + { + "total_counts": [0.0, 1000.0, 3000.0], + "pct_counts_targeted": [60.0, 60.0, 60.0], + } + ) + _, numbers, caption = metric_scatter(frame, x_threshold=500, y_threshold=25) + + assert numbers["drawn"].tolist() == [False, True, True] + # Still counted: it is below the threshold, and the quadrant says so. + assert numbers["kept"].tolist() == [False, True, True] + assert "1 of the cells hold a zero on a log axis" in caption From bc56065c3b65c421d1d00d0e16a51c625745aba0 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:18:43 -0400 Subject: [PATCH 17/30] UPDATE the scatter's points to follow how many there are Points sized for ten thousand cells are invisible for a few hundred genes, and the same figure now serves both. Co-Authored-By: Claude Opus 5 --- tests/test_qc.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_qc.py b/tests/test_qc.py index 4c652cb..713b864 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -418,3 +418,17 @@ def test_a_zero_on_a_log_axis_is_reported_rather_than_silently_dropped(): # Still counted: it is below the threshold, and the quadrant says so. assert numbers["kept"].tolist() == [False, True, True] assert "1 of the cells hold a zero on a log axis" in caption + + +def test_the_point_size_follows_how_many_points_there_are(): + from crispr_analysis_utils.qc import metric_scatter + + few = pd.DataFrame( + {"total_counts": [1000.0] * 10, "pct_counts_targeted": [60.0] * 10} + ) + many = pd.DataFrame( + {"total_counts": [1000.0] * 3000, "pct_counts_targeted": [60.0] * 3000} + ) + small = metric_scatter(many, marginals=False)[0].axes[0].collections[0].get_sizes() + large = metric_scatter(few, marginals=False)[0].axes[0].collections[0].get_sizes() + assert large[0] > small[0] From b965e5af98dfd58fc4da639ad3b9480ef9c04b76 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:19:48 -0400 Subject: [PATCH 18/30] FIX the adaptive point size the previous commit only tested The edit that was meant to add it matched a pre-formatter version of the call and never applied, so the test landed without the behaviour. Points are now sized by how many there are: invisible at 2pt for a few hundred genes, a solid block at 14pt for ten thousand cells. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index f90013d..31571be 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -564,6 +564,10 @@ def metric_scatter( if name in log: drawn &= values > 0 + # A few hundred genes and ten thousand cells want different points: one + # needs to be seen, the other needs not to be a solid block. + size, alpha = (2.0, 0.4) if len(measured) > 2_000 else (14.0, 0.7) + if marginals: figure = plt.figure(figsize=(6.0, 6.0)) grid = figure.add_gridspec( @@ -586,8 +590,8 @@ def metric_scatter( axis.scatter( xs[mask], ys[mask], - s=2, - alpha=0.4, + s=size, + alpha=alpha, linewidths=0, color=colour, label=label, From be7f41af9004b1b69cb8611ca778f31d72b5282e Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:43:02 -0400 Subject: [PATCH 19/30] ADD groups to metric_scatter, colouring by label not by threshold Comparing panel genes against a reference filters nothing, so labelling the points below a line "removed" was wrong. With groups the lines stay as markers of where things were expected to fall and the colour carries what each point is, with unlabelled points grey and behind the rest. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 35 ++++++++++--- .../skills/cau-qc/SKILL.md | 5 ++ tests/test_qc.py | 52 +++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 31571be..06ceedb 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -507,6 +507,7 @@ def metric_scatter( log: tuple[str, ...] = LOG_METRICS, marginals: bool = True, unit: str = "cells", + groups=None, ): """Two QC metrics against each other, cut into quadrants by thresholds. @@ -530,6 +531,12 @@ def metric_scatter( unit : str What a point is, for the caption. Cells by default; the same figure serves a per-gene comparison. + groups : array-like, optional + A label per row. Given, the colour carries the label rather than + whether the point clears the thresholds, which stay as lines. A row + with no label is drawn grey and behind the rest. Use it where + nothing is being filtered and the thresholds only mark where things + were expected to fall. Returns ------- @@ -580,12 +587,21 @@ def metric_scatter( figure, axis = plt.subplots(figsize=(5.5, 4.5)) top = right = None + if groups is None: + layers = [(kept & drawn, KEPT, "kept"), (~kept & drawn, REMOVED, "removed")] + else: + labels = pd.Series(groups, index=frame.index).reindex(measured.index) + named = [str(name) for name in pd.unique(labels.dropna())] + # Unlabelled first, so the classes being compared sit on top of it. + layers = [(labels.isna().to_numpy() & drawn, REMOVED, None)] + layers += [ + ((labels == name).to_numpy() & drawn, OKABE_ITO[i % len(OKABE_ITO)], name) + for i, name in enumerate(named) + ] + # Rasterised: one point per cell stays a vector object otherwise, and a # channel of 10,000 cells turns into a PDF nobody can open. - for mask, colour, label in ( - (kept & drawn, KEPT, "kept"), - (~kept & drawn, REMOVED, "removed"), - ): + for mask, colour, label in layers: if mask.any(): axis.scatter( xs[mask], @@ -609,7 +625,7 @@ def metric_scatter( axis.grid(which="major", color="0.85", linewidth=0.6) axis.set_axisbelow(True) # Only worth a legend when there are two groups to tell apart. - if kept.any() and not kept.all(): + if groups is not None or (kept.any() and not kept.all()): axis.legend(frameon=False, fontsize="small", loc="lower right") for panel, values, name, orientation in ( @@ -644,6 +660,8 @@ def metric_scatter( "drawn": drawn, } ) + if groups is not None: + numbers["group"] = labels.to_numpy() stated = ", ".join( f"{name} at {value:,.4g}" for name, value in ((x, x_threshold), (y, y_threshold)) @@ -652,7 +670,12 @@ def metric_scatter( caption = ( f"{x} against {y} for {len(measured):,} {unit}, one point each. " + (f"Dashed lines are the thresholds ({stated}). " if stated else "") - + "Points at or above every threshold are blue and the rest grey: " + + ( + "Colour is the group each point belongs to, and a point in no " + "group is grey. Counts by quadrant: " + if groups is not None + else "Points at or above every threshold are blue and the rest grey: " + ) + ", ".join(f"{label} {number:,}" for label, number in counts.items()) + ". Histograms on the margins are each metric's own distribution " + f"over the same {unit}." diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index fb5f831..b5a4c4f 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -137,6 +137,11 @@ on its margin, and the quadrant counts in the caption. counts in its quadrant, and the caption says how many there were. - `unit="genes"` makes the same figure a per-gene comparison against a reference, thresholds and quadrant counts included. +- **`groups=` colours by a label instead of by the thresholds.** Where + nothing is being filtered, calling the points below a line "removed" is + wrong: the lines then only mark where things were expected to fall, and + the colour is free to carry what the point is. A point with no label is + grey and drawn behind the rest. ## Does the panel dominate? diff --git a/tests/test_qc.py b/tests/test_qc.py index 713b864..4d3ee90 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -432,3 +432,55 @@ def test_the_point_size_follows_how_many_points_there_are(): small = metric_scatter(many, marginals=False)[0].axes[0].collections[0].get_sizes() large = metric_scatter(few, marginals=False)[0].axes[0].collections[0].get_sizes() assert large[0] > small[0] + + +def test_groups_colour_the_points_instead_of_the_threshold(): + from crispr_analysis_utils.qc import OKABE_ITO, REMOVED, metric_scatter + + frame = pd.DataFrame( + {"reference_upm": [5.0, 100.0, 200.0], "mean_umi": [0.2, 3.0, 4.0]}, + index=pd.Index(["a", "b", "c"], name="gene"), + ) + groups = pd.Series(["Day 0", "Day 4", None], index=frame.index) + figure, numbers, caption = metric_scatter( + frame, + x="reference_upm", + y="mean_umi", + x_threshold=30, + y_threshold=1, + log=("reference_upm", "mean_umi"), + unit="genes", + groups=groups, + marginals=False, + ) + + assert numbers["group"].tolist()[:2] == ["Day 0", "Day 4"] + assert pd.isna(numbers["group"].iloc[2]) + labels = [t.get_text() for t in figure.axes[0].get_legend().get_texts()] + assert labels == ["Day 0", "Day 4"] + # Nothing is being filtered, so the caption must not say so. + assert "removed" not in caption + assert "Colour is the group" in caption + # The unlabelled point is grey and drawn first, behind the classes. + collections = figure.axes[0].collections + assert collections[0].get_facecolor()[0][0] == pytest.approx(float(REMOVED)) + assert len(collections) == 3 + assert OKABE_ITO[0].lstrip("#") + + +def test_groups_still_report_the_quadrant_counts(): + from crispr_analysis_utils.qc import metric_scatter + + frame = pd.DataFrame({"reference_upm": [5.0, 100.0], "mean_umi": [0.2, 3.0]}) + _, _, caption = metric_scatter( + frame, + x="reference_upm", + y="mean_umi", + x_threshold=30, + y_threshold=1, + log=("reference_upm", "mean_umi"), + unit="genes", + groups=["low", "high"], + marginals=False, + ) + assert "kept 1" in caption and "low_both 1" in caption From a046717567db73df62da2da2f24c640773751b33 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:47:20 -0400 Subject: [PATCH 20/30] FIX group colours depending on the order the rows arrived in A label now keeps its colour across figures: a Categorical sets its own order and anything else is sorted, since row order is not a meaning. The class that is Day 0 in one figure was a different colour in the next. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 9 ++++++- tests/test_qc.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 06ceedb..de151ca 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -591,7 +591,14 @@ def metric_scatter( layers = [(kept & drawn, KEPT, "kept"), (~kept & drawn, REMOVED, "removed")] else: labels = pd.Series(groups, index=frame.index).reindex(measured.index) - named = [str(name) for name in pd.unique(labels.dropna())] + # Deterministic, so a label keeps its colour across figures. A + # Categorical says the order it wants; anything else is sorted, + # since row order is not a meaning. + named = ( + [str(name) for name in labels.cat.categories] + if isinstance(labels.dtype, pd.CategoricalDtype) + else sorted(str(name) for name in pd.unique(labels.dropna())) + ) # Unlabelled first, so the classes being compared sit on top of it. layers = [(labels.isna().to_numpy() & drawn, REMOVED, None)] layers += [ diff --git a/tests/test_qc.py b/tests/test_qc.py index 4d3ee90..7997128 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -21,6 +21,15 @@ BARCODES = ["AAA", "CCC", "GGG"] +@pytest.fixture(autouse=True) +def _close_figures(): + """pyplot keeps every figure until it is closed, and this file draws many.""" + yield + import matplotlib.pyplot as plt + + plt.close("all") + + def mudata(): """Three barcodes: the first gene-only, the last guide-only.""" import anndata as ad @@ -484,3 +493,40 @@ def test_groups_still_report_the_quadrant_counts(): marginals=False, ) assert "kept 1" in caption and "low_both 1" in caption + + +def test_a_label_keeps_its_colour_whatever_order_the_rows_arrive_in(): + from crispr_analysis_utils.qc import metric_scatter + + def legend(order): + frame = pd.DataFrame( + {"reference_upm": [10.0, 20.0, 30.0], "mean_umi": [1.0, 2.0, 3.0]} + ) + figure, _, _ = metric_scatter( + frame, + x="reference_upm", + y="mean_umi", + log=("reference_upm", "mean_umi"), + groups=order, + marginals=False, + ) + return [t.get_text() for t in figure.axes[0].get_legend().get_texts()] + + assert legend(["Day 4", "Day 0", "Day 2"]) == legend(["Day 0", "Day 2", "Day 4"]) + + +def test_a_categorical_sets_its_own_legend_order(): + from crispr_analysis_utils.qc import metric_scatter + + frame = pd.DataFrame({"reference_upm": [10.0, 20.0], "mean_umi": [1.0, 2.0]}) + wanted = ["Housekeeping", "Day 0"] + groups = pd.Categorical(["Day 0", "Housekeeping"], categories=wanted) + figure, _, _ = metric_scatter( + frame, + x="reference_upm", + y="mean_umi", + log=("reference_upm", "mean_umi"), + groups=groups, + marginals=False, + ) + assert [t.get_text() for t in figure.axes[0].get_legend().get_texts()] == wanted From 5201bb123e86fad3bd0462d2de9fbf2412f2c02e Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 13:49:18 -0400 Subject: [PATCH 21/30] UPDATE grouped points to draw solid over a see-through background The alpha that keeps a cloud of ten thousand cells readable made a handful of labelled genes barely visible. The background keeps it; the classes being compared no longer do. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 23 ++++++++++++++----- .../skills/cau-qc/SKILL.md | 4 +++- tests/test_qc.py | 17 ++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index de151ca..1cc6f2a 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -588,7 +588,10 @@ def metric_scatter( top = right = None if groups is None: - layers = [(kept & drawn, KEPT, "kept"), (~kept & drawn, REMOVED, "removed")] + layers = [ + (kept & drawn, KEPT, "kept", alpha), + (~kept & drawn, REMOVED, "removed", alpha), + ] else: labels = pd.Series(groups, index=frame.index).reindex(measured.index) # Deterministic, so a label keeps its colour across figures. A @@ -599,22 +602,30 @@ def metric_scatter( if isinstance(labels.dtype, pd.CategoricalDtype) else sorted(str(name) for name in pd.unique(labels.dropna())) ) - # Unlabelled first, so the classes being compared sit on top of it. - layers = [(labels.isna().to_numpy() & drawn, REMOVED, None)] + # Unlabelled first and see-through, so the classes being compared + # sit on top of it. Those are drawn solid: a handful of points at + # the alpha that keeps a cloud of ten thousand readable is barely + # visible. + layers = [(labels.isna().to_numpy() & drawn, REMOVED, None, alpha)] layers += [ - ((labels == name).to_numpy() & drawn, OKABE_ITO[i % len(OKABE_ITO)], name) + ( + (labels == name).to_numpy() & drawn, + OKABE_ITO[i % len(OKABE_ITO)], + name, + 1.0, + ) for i, name in enumerate(named) ] # Rasterised: one point per cell stays a vector object otherwise, and a # channel of 10,000 cells turns into a PDF nobody can open. - for mask, colour, label in layers: + for mask, colour, label, opacity in layers: if mask.any(): axis.scatter( xs[mask], ys[mask], s=size, - alpha=alpha, + alpha=opacity, linewidths=0, color=colour, label=label, diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index b5a4c4f..baacd2c 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -141,7 +141,9 @@ on its margin, and the quadrant counts in the caption. nothing is being filtered, calling the points below a line "removed" is wrong: the lines then only mark where things were expected to fall, and the colour is free to carry what the point is. A point with no label is - grey and drawn behind the rest. + grey, see-through and drawn behind the rest; a labelled one is solid, + since the alpha that keeps a cloud of ten thousand readable makes a + handful of points invisible. ## Does the panel dominate? diff --git a/tests/test_qc.py b/tests/test_qc.py index 7997128..4f0b5c6 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -530,3 +530,20 @@ def test_a_categorical_sets_its_own_legend_order(): marginals=False, ) assert [t.get_text() for t in figure.axes[0].get_legend().get_texts()] == wanted + + +def test_a_grouped_point_is_drawn_solid_and_the_background_is_not(): + from crispr_analysis_utils.qc import metric_scatter + + frame = pd.DataFrame({"reference_upm": [10.0, 20.0], "mean_umi": [1.0, 2.0]}) + figure, _, _ = metric_scatter( + frame, + x="reference_upm", + y="mean_umi", + log=("reference_upm", "mean_umi"), + groups=[None, "Day 4"], + marginals=False, + ) + background, named = figure.axes[0].collections + assert background.get_alpha() < 1.0 + assert named.get_alpha() == 1.0 From 92ebe74d9ef446a3e24979d38b6b38c4f7f202ef Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 15:16:56 -0400 Subject: [PATCH 22/30] ADD a thin dark outline to the labelled points Okabe-Ito's yellow is a pale fill on a white page, so a Housekeeping gene was hard to find even drawn solid. The outline gives every labelled point an edge of its own; the see-through background keeps none, since an edge on ten thousand points is mud. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 20 ++++++++++++++----- .../skills/cau-qc/SKILL.md | 7 ++++--- tests/test_qc.py | 18 +++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 1cc6f2a..f2b7df5 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -333,6 +333,12 @@ def mad_bounds( REMOVED = "0.7" """Grey, for what it removes. Neutral, so the eye reads the kept cells first.""" +EDGE = "0.2" +"""Near-black, for the outline a light fill needs to be seen on a white page.""" + +EDGE_WIDTH = 0.4 +"""Points, thin enough that it outlines the fill rather than replacing it.""" + LOG_METRICS = ("total_counts", "n_genes_by_counts") """Metrics whose distribution is read on a log axis, because they are counts.""" @@ -589,8 +595,8 @@ def metric_scatter( if groups is None: layers = [ - (kept & drawn, KEPT, "kept", alpha), - (~kept & drawn, REMOVED, "removed", alpha), + (kept & drawn, KEPT, "kept", alpha, 0.0), + (~kept & drawn, REMOVED, "removed", alpha, 0.0), ] else: labels = pd.Series(groups, index=frame.index).reindex(measured.index) @@ -606,27 +612,31 @@ def metric_scatter( # sit on top of it. Those are drawn solid: a handful of points at # the alpha that keeps a cloud of ten thousand readable is barely # visible. - layers = [(labels.isna().to_numpy() & drawn, REMOVED, None, alpha)] + # The outline is what makes a pale fill findable: Okabe-Ito's yellow + # on a white page has almost no edge of its own. + layers = [(labels.isna().to_numpy() & drawn, REMOVED, None, alpha, 0.0)] layers += [ ( (labels == name).to_numpy() & drawn, OKABE_ITO[i % len(OKABE_ITO)], name, 1.0, + EDGE_WIDTH, ) for i, name in enumerate(named) ] # Rasterised: one point per cell stays a vector object otherwise, and a # channel of 10,000 cells turns into a PDF nobody can open. - for mask, colour, label, opacity in layers: + for mask, colour, label, opacity, edge in layers: if mask.any(): axis.scatter( xs[mask], ys[mask], s=size, alpha=opacity, - linewidths=0, + linewidths=edge, + edgecolors=EDGE, color=colour, label=label, rasterized=True, diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index baacd2c..197f737 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -141,9 +141,10 @@ on its margin, and the quadrant counts in the caption. nothing is being filtered, calling the points below a line "removed" is wrong: the lines then only mark where things were expected to fall, and the colour is free to carry what the point is. A point with no label is - grey, see-through and drawn behind the rest; a labelled one is solid, - since the alpha that keeps a cloud of ten thousand readable makes a - handful of points invisible. + grey, see-through and drawn behind the rest; a labelled one is solid + and thinly outlined, since the alpha that keeps a cloud of ten thousand + readable makes a handful of points invisible, and Okabe-Ito's yellow is + a pale fill on a white page. ## Does the panel dominate? diff --git a/tests/test_qc.py b/tests/test_qc.py index 4f0b5c6..4501e5c 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -547,3 +547,21 @@ def test_a_grouped_point_is_drawn_solid_and_the_background_is_not(): background, named = figure.axes[0].collections assert background.get_alpha() < 1.0 assert named.get_alpha() == 1.0 + + +def test_a_grouped_point_is_outlined_so_a_pale_fill_still_reads(): + from crispr_analysis_utils.qc import metric_scatter + + frame = pd.DataFrame({"reference_upm": [10.0, 20.0], "mean_umi": [1.0, 2.0]}) + figure, _, _ = metric_scatter( + frame, + x="reference_upm", + y="mean_umi", + log=("reference_upm", "mean_umi"), + groups=[None, "Day 4"], + marginals=False, + ) + background, named = figure.axes[0].collections + assert background.get_linewidths()[0] == 0 + assert named.get_linewidths()[0] > 0 + assert named.get_edgecolors()[0].tolist() != named.get_facecolors()[0].tolist() From 05720ce2cc595962c044a770594764d499bc96cc Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 15:27:54 -0400 Subject: [PATCH 23/30] UPDATE the distribution panels to say what their lines cut A line with no label cannot be checked, and a cut that removes a handful of cells leaves no grey tall enough to see, so a panel of one colour read as a panel saying nothing. Each line now carries the rule and the value, each panel is titled with how many cells it put beyond the band, and the legend that named only the blue is gone. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 37 +++++++++++++++++-- .../skills/cau-qc/SKILL.md | 4 ++ tests/test_qc.py | 36 ++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index f2b7df5..9efb8b8 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -327,6 +327,11 @@ def mad_bounds( ) +def _label(value: float) -> str: + """A number short enough to sit on a threshold line.""" + return f"{value:,.0f}" if abs(value) >= 100 else f"{value:.3g}" + + KEPT = OKABE_ITO[4] """Blue, for what a threshold keeps: the cells the analysis goes on with.""" @@ -446,9 +451,34 @@ def metric_distributions( color=REMOVED, label="removed", ) - for threshold in (low, high): - if np.isfinite(threshold): - axis.axvline(threshold, color="black", linewidth=1.0, linestyle="--") + for threshold, upper in ((low, False), (high, True)): + if not np.isfinite(threshold): + continue + axis.axvline(threshold, color="black", linewidth=1.0, linestyle="--") + # A line nobody can read is a line nobody can check. The label + # sits inside the band, so it never leaves the panel. + axis.annotate( + f"{nmads:g} MAD {_label(threshold)}", + xy=(threshold, 1.0), + xycoords=axis.get_xaxis_transform(), + xytext=(3 if upper else -3, -3), + textcoords="offset points", + rotation=90, + ha="left" if upper else "right", + va="top", + fontsize="x-small", + clip_on=True, + ) + # The panel's own result, since a cut this small leaves no grey + # tall enough to see: with no count, one colour says nothing. + axis.set_title( + f"{int((~within).sum()):,} of {len(values):,} beyond" + if metric not in unthresholded + else "no threshold", + loc="right", + fontsize="small", + color="0.35", + ) axis.set(xlabel=metric, ylabel="cells") axis.grid(which="major", color="0.85", linewidth=0.6) axis.set_axisbelow(True) @@ -469,7 +499,6 @@ def metric_distributions( for axis in axes.ravel()[len(metrics) :]: axis.set_visible(False) - axes.ravel()[0].legend(frameon=False, fontsize="small") figure.tight_layout() numbers = pd.concat(records, ignore_index=True) diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index 197f737..e9d4cb4 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -109,6 +109,10 @@ scanpy's plotting. (`qc.KEPT`) within the band, grey (`qc.REMOVED`) beyond it. The line and the cut are one computation, so the figure cannot disagree with the table. +- **Each line is labelled with the rule and the value it drew**, and each + panel is titled with how many cells it put beyond the band. A cut that + removes a handful leaves no grey tall enough to see, so without the count + a one-colour panel looks like a panel that failed to say anything. - **Counts are two-sided and go on a log axis**, fractions are upper-tail only and linear (`LOG_METRICS`, overridable with `sides`). - **The grey bars do not sum to the cells a filter drops.** Each panel diff --git a/tests/test_qc.py b/tests/test_qc.py index 4501e5c..317fb16 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -254,6 +254,41 @@ def test_the_line_is_the_threshold_and_the_colour_changes_at_it(): assert f"{high:,.2f}" in caption +def test_every_line_is_labelled_with_the_rule_and_the_value_it_drew(): + from crispr_analysis_utils.qc import mad_bounds, metric_distributions + + rng = np.random.default_rng(0) + values = np.r_[rng.normal(1.0, 0.1, 50), 9.0] + figure, _, _ = metric_distributions( + pd.DataFrame({"pct_counts_mito": values}), nmads=3.0, bins=4 + ) + + _, high = mad_bounds(values, nmads=3.0, side="upper") + labels = [t.get_text() for t in figure.axes[0].texts] + assert labels == [f"3 MAD {high:.3g}"] + + +def test_the_panel_says_how_many_it_put_beyond_the_band(): + from crispr_analysis_utils.qc import metric_distributions + + rng = np.random.default_rng(0) + frame = pd.DataFrame({"pct_counts_mito": np.r_[rng.normal(1.0, 0.1, 50), 9.0, 9.1]}) + figure, numbers, _ = metric_distributions(frame, bins=4) + + assert figure.axes[0].get_title(loc="right") == "2 of 52 beyond" + assert int(numbers["n_removed"].sum()) == 2 + + +def test_a_panel_with_one_layer_carries_no_legend_naming_it(): + from crispr_analysis_utils.qc import metric_distributions + + # Nothing is beyond the band, so a legend would name only the blue. + figure, _, _ = metric_distributions( + pd.DataFrame({"pct_counts_mito": [1.0, 1.1, 1.2, 1.3]}), bins=4 + ) + assert all(axis.get_legend() is None for axis in figure.axes) + + def test_a_metric_with_no_spread_loses_its_threshold_not_the_whole_figure(): from crispr_analysis_utils.qc import metric_distributions @@ -263,6 +298,7 @@ def test_a_metric_with_no_spread_loses_its_threshold_not_the_whole_figure(): figure, numbers, caption = metric_distributions(frame, bins=4) assert len(figure.axes) >= 2 + assert figure.axes[0].get_title(loc="right") == "no threshold" flat = numbers[numbers["metric"] == "pct_counts_mito"] assert not np.isfinite(flat["threshold_high"]).any() assert "pct_counts_mito" in caption.split("No threshold is drawn for")[1] From d22e79b1d0c3d53eec5ea547e267a41f4cde566b Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 15:29:44 -0400 Subject: [PATCH 24/30] FIX the line labels, which sat outside the band and were cut off The upper threshold is usually a few points from the panel's right edge, so a label drawn to its right lost half its characters. Both labels now sit on the kept side of their line, where there is room. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 4 ++-- tests/test_qc.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 9efb8b8..be27539 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -461,10 +461,10 @@ def metric_distributions( f"{nmads:g} MAD {_label(threshold)}", xy=(threshold, 1.0), xycoords=axis.get_xaxis_transform(), - xytext=(3 if upper else -3, -3), + xytext=(-3 if upper else 3, -3), textcoords="offset points", rotation=90, - ha="left" if upper else "right", + ha="right" if upper else "left", va="top", fontsize="x-small", clip_on=True, diff --git a/tests/test_qc.py b/tests/test_qc.py index 317fb16..629af88 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -268,6 +268,24 @@ def test_every_line_is_labelled_with_the_rule_and_the_value_it_drew(): assert labels == [f"3 MAD {high:.3g}"] +def test_a_line_label_sits_inside_the_band_not_against_the_panel_edge(): + from crispr_analysis_utils.qc import metric_distributions + + rng = np.random.default_rng(0) + figure, _, _ = metric_distributions( + pd.DataFrame({"total_counts": rng.lognormal(7.0, 0.4, 500)}), bins=10 + ) + axis = figure.axes[0] + figure.canvas.draw() + lower, upper = sorted(line.get_xdata()[0] for line in axis.get_lines()) + # The loop draws the lower line first, so the labels come in that order. + below, above = (text.get_window_extent() for text in axis.texts) + # Both fall between the lines, where the kept cells are: outside them + # the panel edge is a few points away and the label is cut in half. + assert below.x0 >= axis.transData.transform((lower, 0))[0] + assert above.x1 <= axis.transData.transform((upper, 0))[0] + + def test_the_panel_says_how_many_it_put_beyond_the_band(): from crispr_analysis_utils.qc import metric_distributions From a76b231909cfd9a8e9c373c240b1a1ac34636bc1 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 15:31:05 -0400 Subject: [PATCH 25/30] FIX the caption, which counted cells and called them bars Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index be27539..bdc45da 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -520,9 +520,9 @@ def metric_distributions( "cells. Counts are binned and drawn on a log axis, fractions on a " "linear one. The dashed lines are that metric's own " f"{nmads:g}-MAD thresholds ({thresholds}); cells beyond them are " - f"grey. Each panel cuts on its own metric, so the {removed:,} grey " - "bars summed across panels double-count a cell that several " - "metrics reject." + "grey, and each panel's title counts its own. Each panel cuts on " + f"its own metric, so those counts add to {removed:,}, which " + "double-counts a cell that several metrics reject." ) if unthresholded: caption += ( From c1dc6a481cc467b713082c5e599bfc2284fa901a Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 15:34:20 -0400 Subject: [PATCH 26/30] FIX the log tick labels, which overlapped on a narrow span Over about a decade matplotlib labels the minor ticks too, and four superscripts inside an inch ran together into one smear. Label 1, 2 and 5 per decade, in the same digits the caption uses. The panel's own count now comes from the bins, like the table, rather than from the mask. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index bdc45da..7bf9817 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -389,6 +389,8 @@ def metric_distributions( FigureError The frame has no columns, or no cell survives the `NaN` drop. """ + from matplotlib import ticker + plt = style() if frame.shape[1] == 0: @@ -427,6 +429,17 @@ def metric_distributions( floor = float(positive.min()) if positive.size else 1.0 edges = np.logspace(np.log10(floor), np.log10(np.nanmax(values)), bins + 1) axis.set_xscale("log") + # Over a span this narrow matplotlib labels minor ticks too, and + # four superscripts in an inch run into each other. Label 1, 2 + # and 5 per decade instead, in the digits the caption uses. + decades = np.log10(edges[-1]) - np.log10(edges[0]) + axis.xaxis.set_major_locator( + ticker.LogLocator(subs=(1.0,) if decades > 3 else (1.0, 2.0, 5.0)) + ) + axis.xaxis.set_major_formatter( + ticker.FuncFormatter(lambda value, _: _label(value)) + ) + axis.xaxis.set_minor_formatter(ticker.NullFormatter()) else: floor = float(np.nanmin(values)) edges = np.linspace(floor, np.nanmax(values), bins + 1) @@ -472,7 +485,7 @@ def metric_distributions( # The panel's own result, since a cut this small leaves no grey # tall enough to see: with no count, one colour says nothing. axis.set_title( - f"{int((~within).sum()):,} of {len(values):,} beyond" + f"{int(cut_counts.sum()):,} of {int(kept_counts.sum() + cut_counts.sum()):,} beyond" if metric not in unthresholded else "no threshold", loc="right", From 27d4dc890bb696a08e611cf445660a978e3a5f97 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 15:43:57 -0400 Subject: [PATCH 27/30] ADD the filtering step and the filtered object it writes The module drew the figures and stopped short of the thing they are drawn for. `cell_filters` turns the metric frame into one column per rule, a band measured from the channel or a limit someone decided; `apply_filters` keeps the cells that pass every one and writes the h5mu, with every threshold that decided it under `uns["qc"]`, so the file says how it was made. The floor writes itself down in `filter_cells` rather than being restated later. Both the panel and the filter now reach `mad_bounds` through one helper, which also drops the NaNs that would otherwise make the band NaN and fail every cell. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 212 +++++++++++++++++- .../skills/cau-qc/SKILL.md | 47 +++- tests/test_qc.py | 142 ++++++++++++ 3 files changed, 390 insertions(+), 11 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 7bf9817..38a6c89 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -41,6 +41,10 @@ class FigureError(RuntimeError): """The figure cannot be drawn from what was given.""" +class FilterError(RuntimeError): + """The filter cannot be applied to what was given.""" + + def style(): """Set the colours and the defaults every figure here shares. @@ -158,7 +162,18 @@ def filter_cells(data, min_umis: int, modality: str = "gene"): The filtered copy, and the boolean mask over the original barcodes. """ keep = umis_per_cell(data, modality) >= min_umis - return data[keep].copy(), keep + filtered = data[keep].copy() + # The step that cuts writes down what it cut, so the record cannot be + # left to whoever writes the file. + record = dict(filtered.uns.get("qc", {})) + record["floor"] = { + "min_umis": int(min_umis), + "modality": modality, + "n_cells_in": int(data.n_obs), + "n_cells_out": int(filtered.n_obs), + } + filtered.uns["qc"] = record + return filtered, keep def knee(data, modalities: tuple[str, ...] = ("gene", "guide")): @@ -348,6 +363,25 @@ def _label(value: float) -> str: """Metrics whose distribution is read on a log axis, because they are counts.""" +def _band(values, metric, nmads, log, sides): + """One metric's MAD band, `(-inf, inf)` when it has none. + + The side rule and the NaN drop live here rather than in each caller, + so the panel and the filter cut at the same place. + """ + on_log = metric in log + side = sides.get(metric, "both" if on_log else "upper") + measured = values[np.isfinite(values)] + if measured.size == 0: + return -np.inf, np.inf + try: + return mad_bounds(measured, nmads=nmads, side=side, log=on_log) + except FigureError: + # No spread, so no threshold. That is a metric to report, not a + # reason to take the figure or the filter down. + return -np.inf, np.inf + + def metric_distributions( frame: pd.DataFrame, nmads: float = 5.0, @@ -412,13 +446,8 @@ def metric_distributions( for axis, metric in zip(axes.ravel(), metrics, strict=False): values = measured[metric].to_numpy(dtype=float) on_log = metric in log - side = sides.get(metric, "both" if on_log else "upper") - try: - low, high = mad_bounds(values, nmads=nmads, side=side, log=on_log) - except FigureError: - # No spread, so no threshold. The panel still shows the - # distribution rather than taking the whole figure down. - low, high = -np.inf, np.inf + low, high = _band(values, metric, nmads, log, sides) + if (low, high) == (-np.inf, np.inf): unthresholded.append(metric) within = (values >= low) & (values <= high) @@ -1007,3 +1036,170 @@ def cell_fractions( # Back onto every cell of the object: one the modality does not hold has # no measurement, which is not a measurement of zero. return frame.reindex(pd.Index(data.obs_names)) + + +def cell_filters( + frame: pd.DataFrame, + nmads: float | None = 5.0, + limits: dict[str, tuple[float | None, float | None]] | None = None, + log: tuple[str, ...] = LOG_METRICS, + sides: dict[str, str] | None = None, +): + """Which cells pass each quality-control rule. + + Two kinds of rule: a band this channel's own spread decides, and a + limit someone else did. A metric can carry both, so the floor the knee + gave it survives next to its band. + + Parameters + ---------- + frame : pandas.DataFrame + One column per metric, one row per cell, as `cell_fractions` + returns, measured on cells already past the count floor. The MAD + describes whatever population it is given, and on raw barcodes + that population is ambient. + nmads : float, optional + Width of each band, in MADs. `None` measures no band, leaving only + `limits`. + limits : dict, optional + `{metric: (low, high)}` in the metric's own units, `None` for an + open side. A threshold from outside this channel goes here. + log : tuple of str + Metrics whose band is measured on a log scale. + sides : dict, optional + Which tail each band cuts. The default is `"both"` for a count and + `"upper"` for everything else. + + Returns + ------- + tuple of (pandas.DataFrame, pandas.DataFrame) + A boolean frame, one row per cell and one column per rule, `True` + where the cell passes it; and the rules themselves, one row each, + carrying the metric, where the bounds came from and how many cells + the rule rejected. + + Raises + ------ + FilterError + A limit names a metric the frame does not hold, or nothing is left + to apply. + + Notes + ----- + A cell whose metric is `NaN` fails that rule: the modality did not + measure it, and a metric a cell was never measured for is not one it + passed. + """ + sides = sides or {} + limits = limits or {} + unknown = [metric for metric in limits if metric not in frame.columns] + if unknown: + raise FilterError( + "No such metric to limit: " + + ", ".join(unknown) + + ". The frame holds " + + ", ".join(frame.columns) + + "." + ) + + passes: dict[str, np.ndarray] = {} + rules = [] + for metric in frame.columns: + values = frame[metric].to_numpy(dtype=float) + bounds = [] + if nmads is not None: + low, high = _band(values, metric, nmads, log, sides) + if (low, high) != (-np.inf, np.inf): + bounds.append((f"{metric}_mad", f"{nmads:g} MAD", low, high)) + if metric in limits: + low, high = limits[metric] + bounds.append( + ( + f"{metric}_limit", + "limit", + -np.inf if low is None else float(low), + np.inf if high is None else float(high), + ) + ) + for name, source, low, high in bounds: + within = (values >= low) & (values <= high) + passes[name] = within + rules.append( + { + "rule": name, + "metric": metric, + "source": source, + "low": low, + "high": high, + "n_failed": int((~within).sum()), + } + ) + + if not rules: + raise FilterError( + "No rule to apply: no metric has the spread to measure a band, " + "and no limit was given." + ) + return ( + pd.DataFrame(passes, index=frame.index), + pd.DataFrame( + rules, columns=["rule", "metric", "source", "low", "high", "n_failed"] + ), + ) + + +def apply_filters(data, passes: pd.DataFrame, rules: pd.DataFrame, path=None): + """Keep the cells that pass every rule, and record the rules in `uns`. + + Parameters + ---------- + data : mudata.MuData + The object the rules were measured on. + passes, rules : pandas.DataFrame + What `cell_filters` returned. + path : str or pathlib.Path, optional + Where to write the filtered object. Nothing is written without one. + + Returns + ------- + mudata.MuData + The filtered copy, with every threshold that decided it under + `uns["qc"]`, so the file says how it was made. + + Raises + ------ + FilterError + The rules do not cover exactly this object's cells, or no cell + passes them all. + """ + if list(passes.index) != list(data.obs_names): + raise FilterError( + f"The rules cover {len(passes.index):,} cells and the object " + f"holds {data.n_obs:,}. Measure them on the object being " + "filtered: a cell no rule saw would pass every one of them." + ) + keep = passes.to_numpy(dtype=bool).all(axis=1) + if not keep.any(): + raise FilterError("No cell passes every rule.") + + filtered = data[keep].copy() + record = dict(filtered.uns.get("qc", {})) + record["n_cells_in"] = int(data.n_obs) + record["n_cells_out"] = int(filtered.n_obs) + record["rules"] = { + row.rule: { + "metric": row.metric, + "source": row.source, + "low": float(row.low), + "high": float(row.high), + "n_failed": int(row.n_failed), + } + for row in rules.itertuples() + } + filtered.uns["qc"] = record + + if path is not None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + filtered.write_h5mu(path) + return filtered diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index e9d4cb4..7fb8f3a 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -12,13 +12,19 @@ description: >- # QC figures (`crispr_analysis_utils.qc`) +One pass over a channel: find the floor, measure the cells, cut, write. + ```python import mudata as md from crispr_analysis_utils import qc data = md.read_h5mu("results/mudata/mudata.h5mu") -figure, numbers, caption = qc.knee(data) -qc.publish("results/qc/knee_umis_per_barcode", figure, numbers, caption) +qc.publish("results/qc/knee", *qc.knee(data)) +cells, _ = qc.filter_cells(data, min_umis=1000) # read off that knee +fractions = qc.cell_fractions(cells) +qc.publish("results/qc/metrics", *qc.metric_distributions(fractions)) +passes, rules = qc.cell_filters(fractions, nmads=5) +clean = qc.apply_filters(cells, passes, rules, path="results/qc/filtered.h5mu") ``` ## Every figure is three files @@ -191,8 +197,43 @@ carried over. - 5 MADs is the usual starting point. 3 is aggressive, and on a healthy channel cuts cells that are only deep. +## The rules, and the file they make + +`qc.cell_filters(frame, nmads=5, limits=...)` turns the metric frame into one +boolean column per rule, and a table of the rules themselves. +`qc.apply_filters(data, passes, rules, path=...)` keeps the cells that pass +every one, records them, and writes the object. + +- **Two kinds of rule, kept apart.** A band is measured from this channel's + own spread; a limit is a number someone decided, `{metric: (low, high)}` + with `None` for an open side. `total_counts_mad` and `total_counts_limit` + are separate columns, so a metric carries the floor the knee gave it and + its band at once, and the record says which one cut what. +- **The band is the one the panel drew.** `metric_distributions` and + `cell_filters` reach `qc.mad_bounds` through the same helper, so the + figure cannot disagree with the file. +- **Measure the rules on the object being filtered.** `apply_filters` + refuses a frame that does not cover exactly its cells: a cell no rule saw + would otherwise pass every one of them. +- **A `NaN` fails its rule.** The modality did not measure that cell, and a + metric a cell was never measured for is not a metric it passed. +- **`uns["qc"]` is the record** the file carries: `floor`, written by + `filter_cells` itself; `rules`, every threshold with its source and how + many cells it rejected; and the counts in and out. An open bound is an + infinity, since `None` does not survive HDF5. +- `nmads=None` leaves only the limits, for a screen whose thresholds were + settled elsewhere. + ## Ask before running - **Which object?** One channel's MuData, or several concatenated. A knee plot over pooled channels hides a channel that failed. -- **Where do the figures go?** Nothing is written outside the stem's folder. +- **Is this a targeted assay, and is the panel flagged?** `cell_fractions` + adds `pct_counts_targeted` only when `var` carries the `readout` column, + and `panel_expression` needs it too. Without the flag a TAP channel is + QCed as if it were whole-transcriptome, and a `pct_counts_targeted` limit + is a rule that cannot be written. +- **What is the floor?** `min_umis` has no default; read it off this + channel's knee. +- **Where do the figures and the filtered object go?** Nothing is written + outside the stem's folder, and `apply_filters` writes one `.h5mu`. diff --git a/tests/test_qc.py b/tests/test_qc.py index 629af88..4db08ba 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -619,3 +619,145 @@ def test_a_grouped_point_is_outlined_so_a_pale_fill_still_reads(): assert background.get_linewidths()[0] == 0 assert named.get_linewidths()[0] > 0 assert named.get_edgecolors()[0].tolist() != named.get_facecolors()[0].tolist() + + +def test_a_rule_is_measured_from_the_channel_or_given_as_a_limit(): + from crispr_analysis_utils.qc import cell_filters + + rng = np.random.default_rng(0) + frame = pd.DataFrame( + { + "total_counts": rng.lognormal(7.0, 0.3, 200), + "pct_counts_mito": rng.normal(5.0, 1.0, 200), + } + ) + passes, rules = cell_filters(frame, limits={"pct_counts_mito": (None, 6.0)}) + + # Both kinds on one metric, and the band's own on the other. + assert list(rules["rule"]) == [ + "total_counts_mad", + "pct_counts_mito_mad", + "pct_counts_mito_limit", + ] + assert list(passes.columns) == list(rules["rule"]) + assert rules.set_index("rule").loc["pct_counts_mito_limit", "source"] == "limit" + assert rules.set_index("rule").loc["total_counts_mad", "source"] == "5 MAD" + limit = passes["pct_counts_mito_limit"].to_numpy() + assert limit.tolist() == (frame["pct_counts_mito"] <= 6.0).tolist() + assert rules["n_failed"].iloc[-1] == int((~limit).sum()) + + +def test_the_band_the_filter_cuts_on_is_the_one_the_panel_drew(): + from crispr_analysis_utils.qc import cell_filters, metric_distributions + + rng = np.random.default_rng(1) + frame = pd.DataFrame({"total_counts": rng.lognormal(7.0, 0.3, 300)}) + _, numbers, _ = metric_distributions(frame, nmads=4.0, bins=8) + _, rules = cell_filters(frame, nmads=4.0) + + assert rules["low"].iloc[0] == pytest.approx(numbers["threshold_low"].iloc[0]) + assert rules["high"].iloc[0] == pytest.approx(numbers["threshold_high"].iloc[0]) + + +def test_a_cell_the_modality_never_measured_fails_the_rule(): + from crispr_analysis_utils.qc import cell_filters + + frame = pd.DataFrame({"pct_counts_mito": [1.0, 1.1, 1.2, np.nan]}) + passes, rules = cell_filters(frame, limits={"pct_counts_mito": (0.0, 2.0)}) + + assert not passes.iloc[3].any() + # The NaN is dropped before the band, so it cannot poison the median. + assert np.isfinite(rules["high"]).all() + + +def test_a_limit_on_a_metric_the_frame_does_not_hold_is_refused(): + from crispr_analysis_utils.qc import FilterError, cell_filters + + frame = pd.DataFrame({"pct_counts_mito": [1.0, 2.0, 3.0]}) + with pytest.raises(FilterError, match="pct_counts_targeted"): + cell_filters(frame, limits={"pct_counts_targeted": (25.0, None)}) + + +def test_a_flat_metric_with_no_limit_leaves_nothing_to_apply(): + from crispr_analysis_utils.qc import FilterError, cell_filters + + with pytest.raises(FilterError, match="no limit was given"): + cell_filters(pd.DataFrame({"pct_counts_mito": [1.0] * 10})) + + +def test_the_filtered_object_keeps_only_passing_cells_and_says_why(tmp_path): + from crispr_analysis_utils.qc import apply_filters, cell_filters + + data = mudata() + + passes, rules = cell_filters( + pd.DataFrame({"pct_counts_mito": [1.0, 9.0, 1.0]}, index=list(data.obs_names)), + nmads=None, + limits={"pct_counts_mito": (None, 5.0)}, + ) + path = tmp_path / "qc" / "filtered.h5mu" + filtered = apply_filters(data, passes, rules, path=path) + + assert filtered.n_obs == 2 + assert list(filtered.obs_names) == [BARCODES[0], BARCODES[2]] + assert filtered.uns["qc"]["n_cells_in"] == 3 + assert filtered.uns["qc"]["n_cells_out"] == 2 + assert filtered.uns["qc"]["rules"]["pct_counts_mito_limit"] == { + "metric": "pct_counts_mito", + "source": "limit", + "low": -np.inf, + "high": 5.0, + "n_failed": 1, + } + assert path.is_file() + + +def test_the_written_file_carries_the_thresholds_that_made_it(tmp_path): + import mudata as md + + from crispr_analysis_utils.qc import apply_filters, cell_filters, filter_cells + + data = mudata() + + cells, _ = filter_cells(data, min_umis=1) + passes, rules = cell_filters( + pd.DataFrame({"pct_counts_mito": [1.0, 2.0]}, index=list(cells.obs_names)), + nmads=None, + limits={"pct_counts_mito": (None, 5.0)}, + ) + path = tmp_path / "filtered.h5mu" + apply_filters(cells, passes, rules, path=path) + + record = md.read_h5mu(path).uns["qc"] + # The floor is the first cut, and it writes itself down. + assert record["floor"]["min_umis"] == 1 + assert record["floor"]["n_cells_in"] == 3 + assert record["rules"]["pct_counts_mito_limit"]["high"] == 5.0 + + +def test_rules_measured_on_another_object_are_refused(): + from crispr_analysis_utils.qc import FilterError, apply_filters, cell_filters + + data = mudata() + + passes, rules = cell_filters( + pd.DataFrame({"pct_counts_mito": [1.0, 2.0]}, index=list(data.obs_names)[:2]), + nmads=None, + limits={"pct_counts_mito": (None, 5.0)}, + ) + with pytest.raises(FilterError, match="would pass every one"): + apply_filters(data, passes, rules) + + +def test_a_filter_that_keeps_nothing_is_refused(): + from crispr_analysis_utils.qc import FilterError, apply_filters, cell_filters + + data = mudata() + + passes, rules = cell_filters( + pd.DataFrame({"pct_counts_mito": [9.0, 9.0, 9.0]}, index=list(data.obs_names)), + nmads=None, + limits={"pct_counts_mito": (None, 5.0)}, + ) + with pytest.raises(FilterError, match="No cell passes"): + apply_filters(data, passes, rules) From f5b8b03dbb293307f52710e20e31bc61ced29247 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 15:45:16 -0400 Subject: [PATCH 28/30] ADD band, to measure a band for only some of the metrics Every column by default, but not every metric earns a cut: a ribosomal fraction with a long tail loses real cells to a band nobody chose. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 14 +++++++++++--- src/crispr_analysis_utils/skills/cau-qc/SKILL.md | 3 +++ tests/test_qc.py | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 38a6c89..2e1602c 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -1042,6 +1042,7 @@ def cell_filters( frame: pd.DataFrame, nmads: float | None = 5.0, limits: dict[str, tuple[float | None, float | None]] | None = None, + band: tuple[str, ...] | None = None, log: tuple[str, ...] = LOG_METRICS, sides: dict[str, str] | None = None, ): @@ -1064,6 +1065,9 @@ def cell_filters( limits : dict, optional `{metric: (low, high)}` in the metric's own units, `None` for an open side. A threshold from outside this channel goes here. + band : tuple of str, optional + The metrics to measure a band for. Every column by default; name a + subset when only some of them are worth cutting on. log : tuple of str Metrics whose band is measured on a log scale. sides : dict, optional @@ -1092,10 +1096,14 @@ def cell_filters( """ sides = sides or {} limits = limits or {} - unknown = [metric for metric in limits if metric not in frame.columns] + unknown = [ + metric + for metric in list(limits) + list(band or ()) + if metric not in frame.columns + ] if unknown: raise FilterError( - "No such metric to limit: " + "No such metric: " + ", ".join(unknown) + ". The frame holds " + ", ".join(frame.columns) @@ -1107,7 +1115,7 @@ def cell_filters( for metric in frame.columns: values = frame[metric].to_numpy(dtype=float) bounds = [] - if nmads is not None: + if nmads is not None and (band is None or metric in band): low, high = _band(values, metric, nmads, log, sides) if (low, high) != (-np.inf, np.inf): bounds.append((f"{metric}_mad", f"{nmads:g} MAD", low, high)) diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index 7fb8f3a..76f900a 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -221,6 +221,9 @@ every one, records them, and writes the object. `filter_cells` itself; `rules`, every threshold with its source and how many cells it rejected; and the counts in and out. An open bound is an infinity, since `None` does not survive HDF5. +- **`band=` names the metrics worth a band**, every column by default. Not + every metric deserves one: a ribosomal fraction with a long tail loses + real cells to a band nobody chose. - `nmads=None` leaves only the limits, for a screen whose thresholds were settled elsewhere. diff --git a/tests/test_qc.py b/tests/test_qc.py index 4db08ba..4543833 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -670,12 +670,28 @@ def test_a_cell_the_modality_never_measured_fails_the_rule(): assert np.isfinite(rules["high"]).all() +def test_a_band_can_be_restricted_to_the_metrics_worth_cutting_on(): + from crispr_analysis_utils.qc import cell_filters + + rng = np.random.default_rng(2) + frame = pd.DataFrame( + { + "total_counts": rng.lognormal(7.0, 0.3, 200), + "pct_counts_ribo": rng.normal(15.0, 3.0, 200), + } + ) + _, rules = cell_filters(frame, band=("total_counts",)) + assert list(rules["rule"]) == ["total_counts_mad"] + + def test_a_limit_on_a_metric_the_frame_does_not_hold_is_refused(): from crispr_analysis_utils.qc import FilterError, cell_filters frame = pd.DataFrame({"pct_counts_mito": [1.0, 2.0, 3.0]}) with pytest.raises(FilterError, match="pct_counts_targeted"): cell_filters(frame, limits={"pct_counts_targeted": (25.0, None)}) + with pytest.raises(FilterError, match="n_genes_by_counts"): + cell_filters(frame, band=("n_genes_by_counts",)) def test_a_flat_metric_with_no_limit_leaves_nothing_to_apply(): From 029f3e2ddf822fad935e578df019bbfc04c38a97 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 16:01:40 -0400 Subject: [PATCH 29/30] UPDATE the panels to draw the rules the file is cut on The figure measured its own band while the filter applied bands and limits, so four of seven panels drew a line the file was never cut on: a ribosomal panel reported 25 cells beyond a band nobody filtered on while the file dropped 2 on a limit of 6. `metric_distributions` now calls `cell_filters` for its rules and takes the same arguments, so a panel can draw several lines, each labelled with the rule that set it. The guard against applying no rule at all moves to `apply_filters`, where keeping every cell and calling it filtered is the danger; drawing a metric no rule covers is not. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 99 +++++++++++-------- .../skills/cau-qc/SKILL.md | 29 +++--- tests/test_qc.py | 42 +++++++- 3 files changed, 111 insertions(+), 59 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index 2e1602c..a10ff74 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -384,15 +384,18 @@ def _band(values, metric, nmads, log, sides): def metric_distributions( frame: pd.DataFrame, - nmads: float = 5.0, + nmads: float | None = 5.0, + limits: dict[str, tuple[float | None, float | None]] | None = None, + band: tuple[str, ...] | None = None, log: tuple[str, ...] = LOG_METRICS, sides: dict[str, str] | None = None, bins: int = 60, ): - """One histogram per QC metric, split at that metric's own threshold. + """One histogram per QC metric, split at the rules it is cut on. - Every panel computes its MAD band, draws it, and colours by it, so the - line on the figure is the cut the table would make. + The rules come from `cell_filters`, so pass this the same `nmads`, + `limits` and `band` as the filter: the lines on the figure are then + the cuts the file was made with, not a second opinion. Parameters ---------- @@ -400,15 +403,19 @@ def metric_distributions( One column per metric, one row per cell, as `cell_fractions` returns. Rows that are entirely `NaN` are cells the modality does not hold, and are dropped rather than drawn as zero. - nmads : float - Width of each band, in MADs. + nmads : float, optional + Width of each band, in MADs. `None` draws only `limits`. + limits : dict, optional + `{metric: (low, high)}`, as `cell_filters` takes them. + band : tuple of str, optional + The metrics to measure a band for, every column by default. log : tuple of str Metrics binned, thresholded and drawn on a log axis. A count distribution spans orders of magnitude; a percentage does not. sides : dict, optional - Which tail each metric cuts. The default is `"both"` for a count - and `"upper"` for everything else, since a cell with unusually - little mitochondrial signal is not a problem. + Which tail each band cuts. The default is `"both"` for a count and + `"upper"` for everything else, since a cell with unusually little + mitochondrial signal is not a problem. bins : int Bins per panel. @@ -434,7 +441,11 @@ def metric_distributions( raise FigureError("No measured cell to draw.") metrics = list(measured.columns) - sides = sides or {} + # The same call the filter makes, so a panel cannot draw a line the + # file was not cut on. + passes, rules = cell_filters( + measured, nmads=nmads, limits=limits, band=band, log=log, sides=sides + ) columns = min(3, len(metrics)) rows = -(-len(metrics) // columns) figure, axes = plt.subplots( @@ -446,10 +457,15 @@ def metric_distributions( for axis, metric in zip(axes.ravel(), metrics, strict=False): values = measured[metric].to_numpy(dtype=float) on_log = metric in log - low, high = _band(values, metric, nmads, log, sides) - if (low, high) == (-np.inf, np.inf): + mine = rules[rules["metric"] == metric] + if mine.empty: unthresholded.append(metric) - within = (values >= low) & (values <= high) + within = np.ones(len(values), dtype=bool) + else: + within = passes[list(mine["rule"])].to_numpy(dtype=bool).all(axis=1) + # What the panel cut on, which is the tightest of its rules. + low = float(mine["low"].max()) if not mine.empty else -np.inf + high = float(mine["high"].min()) if not mine.empty else np.inf if on_log: # A zero has no place on a log axis, so the range starts at the @@ -493,14 +509,19 @@ def metric_distributions( color=REMOVED, label="removed", ) - for threshold, upper in ((low, False), (high, True)): + edges_of = [ + (bound, upper, rule.source) + for rule in mine.itertuples() + for bound, upper in ((rule.low, False), (rule.high, True)) + ] + for threshold, upper, source in edges_of: if not np.isfinite(threshold): continue axis.axvline(threshold, color="black", linewidth=1.0, linestyle="--") # A line nobody can read is a line nobody can check. The label # sits inside the band, so it never leaves the panel. axis.annotate( - f"{nmads:g} MAD {_label(threshold)}", + f"{source} {_label(threshold)}", xy=(threshold, 1.0), xycoords=axis.get_xaxis_transform(), xytext=(-3 if upper else 3, -3), @@ -516,7 +537,7 @@ def metric_distributions( axis.set_title( f"{int(cut_counts.sum()):,} of {int(kept_counts.sum() + cut_counts.sum()):,} beyond" if metric not in unthresholded - else "no threshold", + else "no rule", loc="right", fontsize="small", color="0.35", @@ -545,32 +566,28 @@ def metric_distributions( numbers = pd.concat(records, ignore_index=True) thresholds = ", ".join( - f"{metric} " + f"{rule.metric} {rule.source} " + " to ".join( - f"{bound:,.2f}" - for bound in ( - numbers.loc[numbers["metric"] == metric, "threshold_low"].iloc[0], - numbers.loc[numbers["metric"] == metric, "threshold_high"].iloc[0], - ) - if np.isfinite(bound) + f"{bound:,.2f}" for bound in (rule.low, rule.high) if np.isfinite(bound) ) - for metric in metrics + for rule in rules.itertuples() ) removed = int(numbers["n_removed"].sum()) caption = ( f"Distribution of each quality-control metric over {len(measured):,} " "cells. Counts are binned and drawn on a log axis, fractions on a " - "linear one. The dashed lines are that metric's own " - f"{nmads:g}-MAD thresholds ({thresholds}); cells beyond them are " - "grey, and each panel's title counts its own. Each panel cuts on " - f"its own metric, so those counts add to {removed:,}, which " - "double-counts a cell that several metrics reject." + "linear one. The dashed lines are the rules each panel is cut on, " + "a band measured from this channel or a limit given to it " + f"({thresholds}); cells beyond them are grey, and each panel's " + "title counts its own. Each panel cuts on its own metric, so those " + f"counts add to {removed:,}, which double-counts a cell that " + "several metrics reject." ) if unthresholded: caption += ( " No threshold is drawn for " + ", ".join(unthresholded) - + ", whose values have no spread to measure one from." + + ", which no rule covers." ) return figure, numbers, caption @@ -1049,8 +1066,8 @@ def cell_filters( """Which cells pass each quality-control rule. Two kinds of rule: a band this channel's own spread decides, and a - limit someone else did. A metric can carry both, so the floor the knee - gave it survives next to its band. + limit someone else did. A metric can carry both, so a threshold the + screen settled on elsewhere sits next to the band measured here. Parameters ---------- @@ -1085,8 +1102,7 @@ def cell_filters( Raises ------ FilterError - A limit names a metric the frame does not hold, or nothing is left - to apply. + A limit or a band names a metric the frame does not hold. Notes ----- @@ -1143,11 +1159,6 @@ def cell_filters( } ) - if not rules: - raise FilterError( - "No rule to apply: no metric has the spread to measure a band, " - "and no limit was given." - ) return ( pd.DataFrame(passes, index=frame.index), pd.DataFrame( @@ -1177,8 +1188,8 @@ def apply_filters(data, passes: pd.DataFrame, rules: pd.DataFrame, path=None): Raises ------ FilterError - The rules do not cover exactly this object's cells, or no cell - passes them all. + There is no rule, the rules do not cover exactly this object's + cells, or no cell passes them all. """ if list(passes.index) != list(data.obs_names): raise FilterError( @@ -1186,6 +1197,12 @@ def apply_filters(data, passes: pd.DataFrame, rules: pd.DataFrame, path=None): f"holds {data.n_obs:,}. Measure them on the object being " "filtered: a cell no rule saw would pass every one of them." ) + if passes.shape[1] == 0: + raise FilterError( + "No rule to apply: no metric has the spread to measure a band, " + "and no limit was given. Filtering on nothing would keep every " + "cell and say it passed." + ) keep = passes.to_numpy(dtype=bool).all(axis=1) if not keep.any(): raise FilterError("No cell passes every rule.") diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index 76f900a..0305847 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -110,13 +110,14 @@ whose UMI count reaches the floor, returning the filtered copy and the mask. of a `cell_fractions` frame, with matplotlib directly rather than through scanpy's plotting. -- **Every panel thresholds on its own metric.** It calls `qc.mad_bounds`, - draws the result as a dashed black line, and colours by it: blue - (`qc.KEPT`) within the band, grey (`qc.REMOVED`) beyond it. The line and - the cut are one computation, so the figure cannot disagree with the - table. -- **Each line is labelled with the rule and the value it drew**, and each - panel is titled with how many cells it put beyond the band. A cut that +- **Give it the same rules as the filter.** `nmads`, `limits` and `band` + are `cell_filters`' own arguments, and the panel calls it to get them, so + the lines are the cuts the file was made with rather than a second + opinion. Blue (`qc.KEPT`) passes every rule the metric carries, grey + (`qc.REMOVED`) fails one. +- **Each line is labelled with the rule and the value it drew** -- `5 MAD + 13,063`, `limit 6` -- and each panel is titled with how many cells it + put beyond them. A metric can carry several lines. A cut that removes a handful leaves no grey tall enough to see, so without the count a one-colour panel looks like a panel that failed to say anything. - **Counts are two-sided and go on a log axis**, fractions are upper-tail @@ -124,8 +125,8 @@ scanpy's plotting. - **The grey bars do not sum to the cells a filter drops.** Each panel counts its own rejects, so a cell that two metrics reject is grey twice. `mad_outlier` per metric, combined with `any`, is what gives the cells. -- A metric with no spread loses its threshold, not the figure: the panel is - drawn with no line and the caption says so. +- A metric no rule covers loses its lines, not the figure: the panel is + drawn without them and the caption says so. - A cell the modality does not hold is dropped, never binned as zero. ## Two metrics at once @@ -207,11 +208,11 @@ every one, records them, and writes the object. - **Two kinds of rule, kept apart.** A band is measured from this channel's own spread; a limit is a number someone decided, `{metric: (low, high)}` with `None` for an open side. `total_counts_mad` and `total_counts_limit` - are separate columns, so a metric carries the floor the knee gave it and - its band at once, and the record says which one cut what. -- **The band is the one the panel drew.** `metric_distributions` and - `cell_filters` reach `qc.mad_bounds` through the same helper, so the - figure cannot disagree with the file. + are separate columns, so a threshold the screen settled on elsewhere sits + next to the band measured here, and the record says which one cut what. +- **The panel draws these rules.** `metric_distributions` calls + `cell_filters` for them, so pass both the same arguments and the figure + cannot disagree with the file. - **Measure the rules on the object being filtered.** `apply_filters` refuses a frame that does not cover exactly its cells: a cell no rule saw would otherwise pass every one of them. diff --git a/tests/test_qc.py b/tests/test_qc.py index 4543833..aca2d17 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -316,12 +316,39 @@ def test_a_metric_with_no_spread_loses_its_threshold_not_the_whole_figure(): figure, numbers, caption = metric_distributions(frame, bins=4) assert len(figure.axes) >= 2 - assert figure.axes[0].get_title(loc="right") == "no threshold" + assert figure.axes[0].get_title(loc="right") == "no rule" flat = numbers[numbers["metric"] == "pct_counts_mito"] assert not np.isfinite(flat["threshold_high"]).any() assert "pct_counts_mito" in caption.split("No threshold is drawn for")[1] +def test_a_limit_is_drawn_and_labelled_beside_the_band_it_joins(): + from crispr_analysis_utils.qc import metric_distributions + + rng = np.random.default_rng(3) + frame = pd.DataFrame( + { + "total_counts": rng.lognormal(7.0, 0.3, 300), + "pct_counts_ribo": rng.normal(15.0, 3.0, 300), + } + ) + figure, numbers, caption = metric_distributions( + frame, + band=("total_counts",), + limits={"pct_counts_ribo": (6.0, None)}, + bins=8, + ) + counts, ribo = figure.axes[:2] + + # A band on the one named, a limit on the one given, nothing doubled up. + assert [t.get_text().split()[0] for t in counts.texts] == ["5", "5"] + assert [t.get_text() for t in ribo.texts] == ["limit 6"] + assert numbers.loc[numbers["metric"] == "pct_counts_ribo", "threshold_low"].iloc[ + 0 + ] == pytest.approx(6.0) + assert "pct_counts_ribo limit 6.00" in caption + + def test_the_scatter_cuts_into_quadrants_and_keeps_only_the_upper_right(): from crispr_analysis_utils.qc import metric_scatter @@ -695,10 +722,17 @@ def test_a_limit_on_a_metric_the_frame_does_not_hold_is_refused(): def test_a_flat_metric_with_no_limit_leaves_nothing_to_apply(): - from crispr_analysis_utils.qc import FilterError, cell_filters + from crispr_analysis_utils.qc import FilterError, apply_filters, cell_filters - with pytest.raises(FilterError, match="no limit was given"): - cell_filters(pd.DataFrame({"pct_counts_mito": [1.0] * 10})) + data = mudata() + # A metric with no spread yields no rule. Drawing that is fine; applying + # it would keep every cell and call them filtered. + passes, rules = cell_filters( + pd.DataFrame({"pct_counts_mito": [1.0] * 3}, index=list(data.obs_names)) + ) + assert passes.shape[1] == 0 and rules.empty + with pytest.raises(FilterError, match="No rule to apply"): + apply_filters(data, passes, rules) def test_the_filtered_object_keeps_only_passing_cells_and_says_why(tmp_path): From 6809bb5521220bf2c74c758e131f9e4398436ba3 Mon Sep 17 00:00:00 2001 From: Eugenio Mattei Date: Thu, 15 Oct 2026 18:21:09 -0400 Subject: [PATCH 30/30] ADD the guide counts and the guides a channel never saw guide_counts gives every guide its UMIs and the cells it reached; guide_abundance bins them with the never-seen in a bar of their own, on the linear strip of a symlog axis, since a log scale has no place for a zero and dropping them would answer a different question. The bin edges are rounded to integers: a log bin narrower than a count leaves every other one empty and draws a comb. Co-Authored-By: Claude Opus 5 --- src/crispr_analysis_utils/qc.py | 174 ++++++++++++++++++ .../skills/cau-qc/SKILL.md | 29 ++- tests/test_qc.py | 116 ++++++++++++ 3 files changed, 317 insertions(+), 2 deletions(-) diff --git a/src/crispr_analysis_utils/qc.py b/src/crispr_analysis_utils/qc.py index a10ff74..b1c057e 100644 --- a/src/crispr_analysis_utils/qc.py +++ b/src/crispr_analysis_utils/qc.py @@ -1228,3 +1228,177 @@ def apply_filters(data, passes: pd.DataFrame, rules: pd.DataFrame, path=None): path.parent.mkdir(parents=True, exist_ok=True) filtered.write_h5mu(path) return filtered + + +def guide_counts(data, modality: str = "guide") -> pd.DataFrame: + """Every guide's UMIs and the number of cells it was seen in. + + Parameters + ---------- + data + A MuData as `mudata_builder` assembles it. + modality + The modality holding the guide counts. + + Returns + ------- + pandas.DataFrame + One row per guide, indexed by guide id, with `umis` and `n_cells`. + `attrs` carries the modality and how many cells it was summed over, + for a caption to quote. + + Notes + ----- + Both numbers are summed over the cells the modality holds, which under a + union is not every cell of the object. + """ + counts = data[modality] + matrix = counts.X + frame = pd.DataFrame( + { + "umis": np.asarray(matrix.sum(axis=0)).ravel(), + "n_cells": np.asarray((matrix > 0).sum(axis=0)).ravel(), + }, + index=pd.Index(counts.var_names, name="guide_id"), + ) + frame.attrs = {"modality": modality, "n_cells": int(counts.n_obs)} + return frame + + +#: What each `guide_counts` column is called on an axis. +GUIDE_LABELS = {"umis": "UMIs per guide", "n_cells": "cells per guide"} + + +def guide_abundance( + counts: pd.DataFrame, + column: str = "umis", + groups=None, + bins: int = 60, +): + """How much of the library this channel saw, and how often. + + The guides never seen get a bar of their own left of the axis. A zero + has no place on a log scale, and dropping it would answer a different + question than the one the figure is asked. + + Parameters + ---------- + counts : pandas.DataFrame + One row per guide, as `guide_counts` returns. + column : str + Which of its columns to bin. + groups : optional + A label per guide, aligned to `counts.index`. Each class is drawn + as its own outline, over the share of that class rather than the + count: a library's classes differ in size by two orders of + magnitude, and raw counts would show only the largest. + bins : int + Logarithmic bins between one and the largest count, rounded to + integer edges so no bin is narrower than a count. + + Returns + ------- + tuple + The figure, the numbers, and the caption, ready for `publish`. + + Raises + ------ + FigureError + No guide was seen at all, so there is nothing to bin. + """ + from matplotlib import ticker + + plt = style() + if column not in counts.columns: + raise FigureError(f"The frame has no {column!r} to bin.") + values = counts[column].to_numpy(dtype=float) + seen = values[values > 0] + if seen.size == 0: + raise FigureError(f"No guide has a {column} above zero to bin.") + + labels = pd.Series("every guide" if groups is None else groups, index=counts.index) + named = ( + [str(name) for name in labels.cat.categories] + if isinstance(labels.dtype, pd.CategoricalDtype) + else sorted(str(name) for name in pd.unique(labels.dropna())) + ) + labels = labels.astype(str) + + # A count is an integer, so a log bin narrower than one alternates + # full and empty and draws a comb. Rounding the edges collapses those + # into single-count bins and leaves the rest logarithmic. + edges = np.unique(np.round(np.logspace(0.0, np.log10(seen.max()), bins + 1))) + edges[-1] = max(edges[-1], float(seen.max())) + + figure, axis = plt.subplots(figsize=(6.5, 4.0)) + # The never-seen bars share the linear strip the symlog axis keeps for + # zero, side by side so one class does not hide another. + span = 0.8 / len(named) + records = [] + for index, name in enumerate(named): + mine = values[(labels == name).to_numpy()] + if mine.size == 0: + continue + colour = KEPT if groups is None else OKABE_ITO[index % len(OKABE_ITO)] + scale = float(mine.size) if groups is not None else 1.0 + binned = np.histogram(mine[mine > 0], bins=edges)[0] / scale + never = float((mine == 0).sum()) / scale + if groups is None: + axis.stairs(binned, edges, fill=True, color=colour) + else: + axis.stairs(binned, edges, color=colour, linewidth=1.4, label=name) + axis.bar( + 0.1 + span * (index + 0.5), + never, + width=span * 0.9, + color=REMOVED if groups is None else colour, + edgecolor=EDGE, + linewidth=EDGE_WIDTH, + ) + drawn = np.concatenate([[never], binned]) * scale + records.append( + pd.DataFrame( + { + "class": name, + "panel": ["never seen"] + ["binned"] * (len(edges) - 1), + "bin_left": np.concatenate([[np.nan], edges[:-1]]), + "bin_right": np.concatenate([[np.nan], edges[1:]]), + "n_guides": drawn.astype(int), + "share": drawn / mine.size, + } + ) + ) + + axis.set_xscale("symlog", linthresh=1.0, linscale=0.35) + axis.set_yscale("log") + axis.set( + xlabel=GUIDE_LABELS.get(column, column.replace("_", " ")), + ylabel="guides" if groups is None else "share of the class", + ) + axis.xaxis.set_major_formatter(ticker.FuncFormatter(lambda v, _: _label(v))) + axis.xaxis.set_minor_formatter(ticker.NullFormatter()) + if groups is not None: + axis.legend(frameon=False, fontsize="small") + + never = int((values == 0).sum()) + axis.set_title( + f"{never:,} of {len(values):,} never seen", + loc="right", + fontsize="small", + color="0.35", + ) + figure.tight_layout() + + numbers = pd.concat(records, ignore_index=True) + over = counts.attrs.get("n_cells") + what = GUIDE_LABELS.get(column, column.replace("_", " ")) + caption = ( + f"{what[0].upper()}{what[1:]}, binned logarithmically" + + (f" over {over:,} cells" if over else "") + + f". The bar left of the axis is the {never:,} of {len(values):,} " + "guides never seen, which a log scale cannot place at zero. A " + "channel holds far fewer cells than a library holds guides, so a " + "guide missing here is not a guide missing from the library: that " + "question needs the channels pooled." + ) + return figure, numbers, caption diff --git a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md index 0305847..363c05d 100644 --- a/src/crispr_analysis_utils/skills/cau-qc/SKILL.md +++ b/src/crispr_analysis_utils/skills/cau-qc/SKILL.md @@ -4,8 +4,9 @@ description: >- Draw quality-control figures for a CRISPR screen with crispr-analysis-utils (`crispr_analysis_utils.qc`), each published as a PDF, the numbers behind it and a caption. Use when a user wants a knee plot or other QC figure from - an assembled MuData, asks how deep a channel is, wants a figure ready for a - paper, or asks where the numbers behind a figure are. Covers the three + an assembled MuData, asks how deep a channel is, asks which guides a + channel saw, wants a figure ready for a paper, or asks where the numbers + behind a figure are. Covers the three files every figure writes, the house style, and why a figure is refused without its data. --- @@ -25,6 +26,7 @@ fractions = qc.cell_fractions(cells) qc.publish("results/qc/metrics", *qc.metric_distributions(fractions)) passes, rules = qc.cell_filters(fractions, nmads=5) clean = qc.apply_filters(cells, passes, rules, path="results/qc/filtered.h5mu") +qc.publish("results/qc/guides", *qc.guide_abundance(qc.guide_counts(clean))) ``` ## Every figure is three files @@ -228,6 +230,29 @@ every one, records them, and writes the object. - `nmads=None` leaves only the limits, for a screen whose thresholds were settled elsewhere. +## The guides the channel saw + +`qc.guide_counts(data)` gives every guide its UMIs and the cells it reached; +`qc.guide_abundance(counts)` bins them. + +- **A guide missing from a channel is not a guide missing from the library.** + Ten thousand cells cannot sample forty thousand guides, so most of a + library is absent from any one channel by arithmetic. Pool the channels + before calling a guide lost, and compare the count against what the cell + count and the MOI predict. +- **The guides never seen get a bar of their own**, on the linear strip of a + symlog axis. A log scale has no place for a zero, and leaving them out + would answer a different question than the one the figure is asked. +- **The bins are rounded to integer edges**, so no bin is narrower than a + count. Without that the low end draws a comb rather than a distribution. +- **Two modes are the shape to expect**: ambient at a handful of UMIs, and + the guides really in cells at thousands. Where the trough falls is what a + per-cell assignment has to separate. +- **`groups=` compares the classes as shares**, not counts, since a library + is mostly targeting guides and its few hundred controls would otherwise be + a sliver. +- `column="n_cells"` bins the cells a guide reached instead of its UMIs. + ## Ask before running - **Which object?** One channel's MuData, or several concatenated. A knee diff --git a/tests/test_qc.py b/tests/test_qc.py index aca2d17..0386eec 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -811,3 +811,119 @@ def test_a_filter_that_keeps_nothing_is_refused(): ) with pytest.raises(FilterError, match="No cell passes"): apply_filters(data, passes, rules) + + +def guide_library(): + """Six guides over four cells: one no cell carries, one in every cell.""" + import anndata as ad + import mudata as md + + counts = np.array( + [ + [0, 1, 0, 40, 900, 0], + [0, 0, 2, 0, 800, 0], + [0, 0, 0, 50, 0, 0], + [0, 1, 0, 0, 700, 0], + ], + dtype="float32", + ) + var = pd.DataFrame( + {"type": ["targeting"] * 4 + ["non-targeting"] * 2}, + index=pd.Index([f"g{n}" for n in range(6)], name="guide_id"), + ) + guide = ad.AnnData( + X=counts, + obs=pd.DataFrame(index=pd.Index([f"c{n}" for n in range(4)], name="barcode")), + var=var, + ) + return md.MuData({"guide": guide}) + + +def test_a_guide_no_cell_carries_is_counted_rather_than_dropped(): + from crispr_analysis_utils.qc import guide_counts + + counts = guide_counts(guide_library()) + assert counts.index.tolist() == [f"g{n}" for n in range(6)] + assert counts["umis"].tolist() == [0.0, 2.0, 2.0, 90.0, 2400.0, 0.0] + assert counts["n_cells"].tolist() == [0, 2, 1, 2, 3, 0] + + +def test_the_counts_remember_how_many_cells_they_were_summed_over(): + from crispr_analysis_utils.qc import guide_counts + + # A caption cannot say it otherwise: under a union the guide modality + # holds fewer cells than the object does. + assert guide_counts(guide_library()).attrs == {"modality": "guide", "n_cells": 4} + + +def test_the_guides_never_seen_get_the_bar_a_log_axis_cannot_give_them(): + from crispr_analysis_utils.qc import guide_abundance, guide_counts + + figure, numbers, caption = guide_abundance(guide_counts(guide_library())) + unseen = numbers[numbers["panel"] == "never seen"] + assert unseen["n_guides"].tolist() == [2] + assert figure.axes[0].get_xscale() == "symlog" + assert "2 of 6" in figure.axes[0].get_title(loc="right") + assert "2 of 6 guides never seen" in caption + + +def test_every_guide_that_was_seen_lands_in_a_bin(): + from crispr_analysis_utils.qc import guide_abundance, guide_counts + + _, numbers, _ = guide_abundance(guide_counts(guide_library())) + assert int(numbers.loc[numbers["panel"] == "binned", "n_guides"].sum()) == 4 + + +def test_a_count_is_never_split_across_two_bins(): + from crispr_analysis_utils.qc import guide_abundance, guide_counts + + # Logarithmic edges below ten are narrower than one, and would leave + # every other bin empty: a comb, not a distribution. + _, numbers, _ = guide_abundance(guide_counts(guide_library()), bins=60) + binned = numbers[numbers["panel"] == "binned"] + widths = binned["bin_right"] - binned["bin_left"] + assert (widths >= 1.0).all() + + +def test_a_class_is_drawn_as_its_share_so_a_small_one_still_reads(): + from crispr_analysis_utils.qc import guide_abundance, guide_counts + + data = guide_library() + _, numbers, _ = guide_abundance( + guide_counts(data), groups=data["guide"].var["type"] + ) + unseen = numbers[numbers["panel"] == "never seen"].set_index("class") + # One of four targeting guides, one of two controls: the same count, + # and the share is what separates them. + assert unseen.loc["targeting", "n_guides"] == 1 + assert unseen.loc["non-targeting", "n_guides"] == 1 + assert unseen.loc["targeting", "share"] == pytest.approx(0.25) + assert unseen.loc["non-targeting", "share"] == pytest.approx(0.5) + + +def test_the_same_figure_bins_the_cells_a_guide_reached(): + from crispr_analysis_utils.qc import guide_abundance, guide_counts + + figure, numbers, caption = guide_abundance( + guide_counts(guide_library()), column="n_cells" + ) + assert figure.axes[0].get_xlabel() == "cells per guide" + assert caption.startswith("Cells per guide") + assert int(numbers.loc[numbers["panel"] == "binned", "n_guides"].sum()) == 4 + + +def test_a_library_no_cell_saw_is_refused_rather_than_drawn_empty(): + from crispr_analysis_utils.qc import guide_abundance, guide_counts + + data = guide_library() + data["guide"].X[:] = 0 + with pytest.raises(FigureError, match="above zero"): + guide_abundance(guide_counts(data)) + + +def test_the_caption_refuses_to_call_a_channels_gap_a_library_gap(): + from crispr_analysis_utils.qc import guide_abundance, guide_counts + + _, _, caption = guide_abundance(guide_counts(guide_library())) + assert "over 4 cells" in caption + assert "channels pooled" in caption