From ae84c1b1a38fc5ad456fc33152e6e628bf98090e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 10:37:01 +0200 Subject: [PATCH 01/28] docs: migrate to Zensical and overhaul documentation - replace mkdocs with Zensical (zensical.toml), mkdocstrings API reference with griffe-fieldz, mermaid, glightbox, abbreviations - new structure: getting started, user guide, tutorials, API, about - figures generated at build time by scripts/make_doc_figures.py (tutorial figures come from executing the tutorial page's code) - version-pinning warnings on home, installation, settings, saving, README - docstring fixes and additions across the schema - move tutorial notebook to examples/notebooks - CI builds with --strict and deploys via ghp-import Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 31 +- .gitignore | 3 + README.md | 16 +- docs/about/citing.md | 23 + docs/about/contributing.md | 51 + docs/api.md | 93 -- docs/api/detectors.md | 34 + docs/api/index.md | 32 + docs/api/modality.md | 13 + docs/api/optical-config.md | 35 + docs/api/psf.md | 20 + docs/api/sample.md | 19 + docs/api/settings.md | 7 + docs/api/simulation.md | 14 + docs/api/space.md | 20 + docs/api/spectrum.md | 3 + docs/api/util.md | 10 + docs/concept.md | 103 -- docs/getting-started/first-simulation.md | 164 ++++ docs/getting-started/installation.md | 81 ++ docs/guide/channels.md | 199 ++++ docs/guide/detectors.md | 261 +++++ docs/guide/index.md | 152 +++ docs/guide/modalities.md | 152 +++ docs/guide/photometry.md | 180 ++++ docs/guide/samples.md | 246 +++++ docs/guide/saving.md | 93 ++ docs/guide/settings.md | 122 +++ docs/guide/spaces.md | 130 +++ docs/images/au02.png | Bin 19253 -> 0 bytes docs/images/favicon.svg | 14 + docs/images/logo.svg | 14 + docs/index.md | 226 +++-- docs/javascripts/mathjax.js | 4 + docs/stages.md | 418 -------- docs/stylesheets/extra.css | 154 ++- docs/tutorials/index.md | 37 + docs/tutorials/point-scanning-detectors.md | 91 ++ docs/tutorials/two-color-cell.md | 310 ++++++ docs/tutorials/your-own-ground-truth.md | 101 ++ {docs => examples/notebooks}/tutorial.ipynb | 14 +- includes/abbreviations.md | 20 + mkdocs.yml | 76 -- pyproject.toml | 13 +- scripts/make_doc_figures.py | 552 +++++++++++ src/microsim/schema/detectors/_camera.py | 4 +- src/microsim/schema/detectors/_detector.py | 2 +- src/microsim/schema/detectors/_point.py | 4 +- src/microsim/schema/lens.py | 29 + src/microsim/schema/modality/_simple_psf.py | 15 +- src/microsim/schema/optical_config/config.py | 37 + src/microsim/schema/optical_config/filter.py | 62 ++ .../schema/sample/_distributions/cosem.py | 10 + .../schema/sample/_distributions/matslines.py | 18 + src/microsim/schema/sample/fluorophore.py | 25 + src/microsim/schema/sample/sample.py | 25 + src/microsim/schema/settings.py | 33 + src/microsim/schema/simulation.py | 33 +- src/microsim/schema/space.py | 57 ++ src/microsim/schema/spectrum.py | 14 + tests/test_docs.py | 27 +- uv.lock | 900 ++++-------------- zensical.toml | 210 ++++ 63 files changed, 4233 insertions(+), 1623 deletions(-) create mode 100644 docs/about/citing.md create mode 100644 docs/about/contributing.md delete mode 100644 docs/api.md create mode 100644 docs/api/detectors.md create mode 100644 docs/api/index.md create mode 100644 docs/api/modality.md create mode 100644 docs/api/optical-config.md create mode 100644 docs/api/psf.md create mode 100644 docs/api/sample.md create mode 100644 docs/api/settings.md create mode 100644 docs/api/simulation.md create mode 100644 docs/api/space.md create mode 100644 docs/api/spectrum.md create mode 100644 docs/api/util.md delete mode 100644 docs/concept.md create mode 100644 docs/getting-started/first-simulation.md create mode 100644 docs/getting-started/installation.md create mode 100644 docs/guide/channels.md create mode 100644 docs/guide/detectors.md create mode 100644 docs/guide/index.md create mode 100644 docs/guide/modalities.md create mode 100644 docs/guide/photometry.md create mode 100644 docs/guide/samples.md create mode 100644 docs/guide/saving.md create mode 100644 docs/guide/settings.md create mode 100644 docs/guide/spaces.md delete mode 100644 docs/images/au02.png create mode 100644 docs/images/favicon.svg create mode 100644 docs/images/logo.svg delete mode 100644 docs/stages.md create mode 100644 docs/tutorials/index.md create mode 100644 docs/tutorials/point-scanning-detectors.md create mode 100644 docs/tutorials/two-color-cell.md create mode 100644 docs/tutorials/your-own-ground-truth.md rename {docs => examples/notebooks}/tutorial.ipynb (99%) create mode 100644 includes/abbreviations.md delete mode 100644 mkdocs.yml create mode 100644 scripts/make_doc_figures.py create mode 100644 zensical.toml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b36f9fe3..ef39c027 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,18 +10,27 @@ concurrency: cancel-in-progress: true jobs: - build: - if: github.event_name == 'pull_request' + docs: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - - run: uv run --group docs mkdocs build --strict - - deploy: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - - run: uv run --group docs mkdocs gh-deploy --force + # PSFs, ground truth, and OpenOrganelle downloads used by the figures + - uses: actions/cache@v4 + with: + path: ~/.cache/microsim + key: microsim-docs-${{ hashFiles('scripts/make_doc_figures.py', 'uv.lock') }} + restore-keys: microsim-docs- + - name: Generate figures + run: uv run --group docs python scripts/make_doc_figures.py + - name: Build + run: uv run --group docs zensical build --strict + # zensical has no gh-deploy; push the built site to the gh-pages branch + - name: Deploy + if: github.ref == 'refs/heads/main' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + uvx ghp-import --no-jekyll --push --force site diff --git a/.gitignore b/.gitignore index 3d9998da..f6e74c75 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,6 @@ microsim/_version.py microsim/**/*.c jrc_* dask-worker-space* + +# documentation figures, made by scripts/make_doc_figures.py +docs/images/generated/ diff --git a/README.md b/README.md index 18bb0c71..e8e9a747 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,16 @@ The goal of this library is to generate highly realistic simulated data such as ## Documentation -Start with the [tutorial](https://www.talleylambert.com/microsim/tutorial/), and read about the -[simulation stages](https://www.talleylambert.com/microsim/stages/), then see the -[API Reference]() for details -on the `Simulation` object and options for all of the fields. +Documentation is at . Start with +[First simulation](https://www.talleylambert.com/microsim/getting-started/first-simulation/), +read the [User guide](https://www.talleylambert.com/microsim/guide/) for how each part +of the simulation works, and see the +[API reference](https://www.talleylambert.com/microsim/api/) for every option. + +> [!WARNING] +> microsim is in early development and changes quickly. Releases may break the API +> or change simulation results for the same parameters. If you depend on it, pin a +> specific version and record it with your results. ## Installation @@ -67,7 +73,7 @@ paying attention to your GPU requirements. Support for torch is planned. ## Usage Construct and run a -[`microsim.Simulation`](https://www.talleylambert.com/microsim/api/#microsim.schema.simulation.Simulation) +[`microsim.Simulation`](https://www.talleylambert.com/microsim/api/simulation/) object. ```python diff --git a/docs/about/citing.md b/docs/about/citing.md new file mode 100644 index 00000000..fcae85f3 --- /dev/null +++ b/docs/about/citing.md @@ -0,0 +1,23 @@ +--- +icon: lucide/quote +--- + +# Citing microsim + +If you use microsim in published work, please cite the software through its +Zenodo record: + +[![DOI](https://zenodo.org/badge/483645397.svg)](https://doi.org/10.5281/zenodo.18942661) + +The Zenodo page has citation text in several formats, and a DOI for each +release. Since results can differ between versions, cite the version you used. + +The ground truth from OpenOrganelle and the spectra from FPbase come from +other projects; if you use them, please also cite: + +- Heinrich, L. et al. "Whole-cell organelle segmentation in volume electron + microscopy." *Nature* 599, 141–146 (2021). + [doi:10.1038/s41586-021-03977-3](https://doi.org/10.1038/s41586-021-03977-3) +- Lambert, T. J. "FPbase: a community-editable fluorescent protein database." + *Nature Methods* 16, 277–278 (2019). + [doi:10.1038/s41592-019-0352-8](https://doi.org/10.1038/s41592-019-0352-8) diff --git a/docs/about/contributing.md b/docs/about/contributing.md new file mode 100644 index 00000000..52e2e628 --- /dev/null +++ b/docs/about/contributing.md @@ -0,0 +1,51 @@ +--- +icon: lucide/git-pull-request +--- + +# Contributing + +Bug reports, questions, and pull requests are welcome on +[GitHub](https://github.com/tlambert03/microsim). Contributions of new ground +truth generators, modalities, and detector models are particularly useful. + +## Development setup + +```bash +git clone https://github.com/tlambert03/microsim +cd microsim +uv sync +uv run pytest +``` + +Code style is enforced with [ruff](https://docs.astral.sh/ruff/) and type +checking with mypy, run through [prek](https://github.com/j178/prek) (or +pre-commit): + +```bash +uv run prek run -a +``` + +## Documentation + +The docs are built with [Zensical](https://zensical.org) from the Markdown +files in `docs/`, configured in `zensical.toml`. The API reference is generated +from docstrings with [mkdocstrings](https://mkdocstrings.github.io). + +```bash +uv run --group docs zensical serve # live preview at http://localhost:8000 +uv run --group docs zensical build # build to site/ +``` + +Most figures are made by microsim when the docs are built, and are not +checked in. Generate them once before previewing (this needs network access +for FPbase and OpenOrganelle, and takes a few minutes the first time): + +```bash +uv run --group docs python scripts/make_doc_figures.py # all figures +uv run --group docs python scripts/make_doc_figures.py psf spectra # some +``` + +They are written to `docs/images/generated/`, which is ignored by git. The +figures of the [two-color tutorial](../tutorials/two-color-cell.md) are made by +running the code blocks of that page, so the code shown always matches the +images. diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index dd44d882..00000000 --- a/docs/api.md +++ /dev/null @@ -1,93 +0,0 @@ -# API Reference - -All classes here are importable from the `microsim.schema` module. - -## Simulation - -The top level object is the `Simulation` object. This -object contains all the information needed to run a simulation. - -::: microsim.schema.simulation - -## Spaces - -Spaces define the shape and scale of various spaces in the simulation. -They can be defined in many different ways, but they all either directly -declare or calculate the `shape`, `scale`, `extent` and `axes` names of -the space in which the simulation is run. - -For example, you might define the ground truth fluorophore distribution -in a highly sampled space, and then downscale the resulting simulation -to a realistic microscope image sampling. - -```python -from microsim import Simulation -import microsim.schema as ms - -Simulation( - # ground truth with z-step 20nm and XY pixel size 10nm - truth_space=ms.ShapeScaleSpace(shape=(256, 1024, 1024), scale=(0.02, 0.01, 0.01)), - # downscale output to 160nm x 80nm x 80nm pixels with shape (32, 128, 128) - output_space=ms.DownscaledSpace(downscale=8), - sample=ms.Sample(labels=[]), # ... -) -``` - -If one of the spaces is defined using a relative space such as `DownscaledSpace`, -the other *must* be an absolute space such as `ShapeScaleSpace`. - -::: microsim.schema.space - -## Objective Lens - -Properties of the objective lens used in the simulation. Will help -to determine the PSF and other optical properties of the simulation. - -::: microsim.schema.lens - -## Optical Configs - -Optical configurations define the filters and wavelengths used -in the simulation. - -::: microsim.schema.optical_config - -## Samples - -Samples define the distribution and properties of fluorophores in -the simulation. A `Sample` is a set of `labels`, each of which -is a combination of a `Fluorophore` and a `FluorophoreDistribution`. - -The `FluorophoreDistribution` is the primary object that determines -what the sample will look like. - -::: microsim.schema.sample - -## Modality - -Modalities define the imaging modality used in the simulation, -such as "widefield", "confocal", "3D-SIM", etc. This object -has a lot of control over how other parts of the simulation -are combined to render the final image. - -::: microsim.schema.modality - -## Settings - -Various global settings for the simulation, such as the calculation -backend (`numpy`, `cupy`, `jax`, etc...) and an optional random seed, -which can be used to reproduce the same simulation multiple times. - -Note that all of these settings can also be defined using environment -variables prefixed with `MICROSIM_`. For example, to globally -disallow caching, you can set `MICROSIM_CACHE=0`. Nested settings -can be defined using `__` as a separator, such as `MICROSIM_CACHE__WRITE=0`, -which would disable writing to the cache (but still allow reading). - -::: microsim.schema.settings - -## Other Types - -Types used in the schema that may be used across categories. - -::: microsim.schema.spectrum diff --git a/docs/api/detectors.md b/docs/api/detectors.md new file mode 100644 index 00000000..aa0d96e6 --- /dev/null +++ b/docs/api/detectors.md @@ -0,0 +1,34 @@ +# Detectors + +See [Detectors](../guide/detectors.md) in the user guide. The steps shared by +all detectors are described in +[What every detector does](../guide/detectors.md#what-every-detector-does). + +## Cameras + +::: microsim.schema.CameraCCD + +::: microsim.schema.CameraEMCCD + +::: microsim.schema.CameraCMOS + +## Point detectors + +::: microsim.schema.PMT + +::: microsim.schema.HyD + +## Built-in detectors + +::: microsim.schema.detectors.lib + options: + show_if_no_docstring: true + members: + - ICX285 + - PMT_GAASP + - PMT_MULTIALKALI + - HYD_SP8 + - POWER_HYD_R + - GAASP_PMT_QE + - GAASP_HPD_QE + - RED_GAASP_HPD_QE diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 00000000..a820216b --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,32 @@ +--- +icon: lucide/braces +--- + +# API reference + +All the models used to define a simulation can be imported from +`microsim.schema`, which is usually imported as `ms`: + +```py +from microsim import schema as ms + +ms.Simulation, ms.Confocal, ms.CameraCMOS, ... +``` + +Every model is a [pydantic](https://docs.pydantic.dev) model. Fields are +validated when the object is created and when they are assigned, and every +field that takes a model also accepts a dictionary with the same keys. +Unknown fields raise an error. + +| Page | Contents | +| ------------------------------------- | --------------------------------------------------------------- | +| [Simulation](simulation.md) | `Simulation`, the top-level model and its stage methods | +| [Spaces](space.md) | `ShapeScaleSpace`, `DownscaledSpace`, ... | +| [Samples](sample.md) | `Sample`, `FluorophoreDistribution`, `Fluorophore`, distributions | +| [Optical configs](optical-config.md) | `OpticalConfig`, filters, `LightSource`, built-in filter sets | +| [Modalities](modality.md) | `Widefield`, `Confocal`, `Identity`, `ObjectiveLens` | +| [Detectors](detectors.md) | cameras, point detectors, built-in detectors | +| [Settings](settings.md) | `Settings` | +| [Spectrum](spectrum.md) | `Spectrum` | +| [PSF](psf.md) | `microsim.psf`: PSF functions | +| [Utilities](util.md) | plotting and cache helpers | diff --git a/docs/api/modality.md b/docs/api/modality.md new file mode 100644 index 00000000..0c36664b --- /dev/null +++ b/docs/api/modality.md @@ -0,0 +1,13 @@ +# Modalities + +See [Modalities and the PSF](../guide/modalities.md) in the user guide. + +::: microsim.schema.Widefield + +::: microsim.schema.Confocal + +::: microsim.schema.Identity + +## Objective lens + +::: microsim.schema.ObjectiveLens diff --git a/docs/api/optical-config.md b/docs/api/optical-config.md new file mode 100644 index 00000000..28ba93a3 --- /dev/null +++ b/docs/api/optical-config.md @@ -0,0 +1,35 @@ +# Optical configurations + +See [Channels](../guide/channels.md) in the user guide. + +::: microsim.schema.OpticalConfig + +::: microsim.schema.LightSource + +## Filters + +::: microsim.schema.Bandpass + +::: microsim.schema.Longpass + +::: microsim.schema.Shortpass + +::: microsim.schema.SpectrumFilter + +::: microsim.schema.optical_config.Placement + +## Built-in configurations + +::: microsim.schema.optical_config.lib + options: + show_if_no_docstring: true + members: + - DAPI + - ECFP + - FITC + - EYFP + - TRITC + - DSRED + - CY5 + - CY7 + - spectral_detector diff --git a/docs/api/psf.md b/docs/api/psf.md new file mode 100644 index 00000000..caa9510b --- /dev/null +++ b/docs/api/psf.md @@ -0,0 +1,20 @@ +# PSF + +Functions to compute point spread functions. Simulations call these through +their [modality](modality.md); they can also be used on their own. + +```python +from microsim.psf import make_psf +from microsim.schema import ObjectiveLens + +psf = make_psf(nz=65, nx=65, dx=0.04, dz=0.04, objective=ObjectiveLens(), em_wvl_nm=525) +``` + +::: microsim.psf + options: + show_root_heading: false + members: + - make_psf + - make_confocal_psf + - vectorial_psf + - vectorial_psf_centered diff --git a/docs/api/sample.md b/docs/api/sample.md new file mode 100644 index 00000000..726930bb --- /dev/null +++ b/docs/api/sample.md @@ -0,0 +1,19 @@ +# Samples + +See [Samples](../guide/samples.md) in the user guide. + +::: microsim.schema.Sample + +::: microsim.schema.FluorophoreDistribution + +::: microsim.schema.Fluorophore + +## Distributions + +::: microsim.schema.MatsLines + +::: microsim.schema.sample._distributions.cosem.CosemLabel + options: + members: false + +::: microsim.schema.sample.BaseDistribution diff --git a/docs/api/settings.md b/docs/api/settings.md new file mode 100644 index 00000000..3242cee2 --- /dev/null +++ b/docs/api/settings.md @@ -0,0 +1,7 @@ +# Settings + +See [Settings](../guide/settings.md) in the user guide. + +::: microsim.schema.Settings + +::: microsim.schema.settings.CacheSettings diff --git a/docs/api/simulation.md b/docs/api/simulation.md new file mode 100644 index 00000000..6c1d7040 --- /dev/null +++ b/docs/api/simulation.md @@ -0,0 +1,14 @@ +# Simulation + +::: microsim.Simulation + options: + members: + - run + - ground_truth + - filtered_emission_rates + - emission_flux + - optical_image + - optical_image_per_fluor + - digital_image + - from_ground_truth + - plot diff --git a/docs/api/space.md b/docs/api/space.md new file mode 100644 index 00000000..98875a21 --- /dev/null +++ b/docs/api/space.md @@ -0,0 +1,20 @@ +# Spaces + +See [Spaces](../guide/spaces.md) in the user guide. + +## Concrete spaces + +::: microsim.schema.ShapeScaleSpace + +::: microsim.schema.ExtentScaleSpace + +::: microsim.schema.ShapeExtentSpace + +## Relative spaces + +A relative space is defined with respect to the other space of the +simulation, which must be concrete. + +::: microsim.schema.DownscaledSpace + +::: microsim.schema.space.UpscaledSpace diff --git a/docs/api/spectrum.md b/docs/api/spectrum.md new file mode 100644 index 00000000..4c2ba6af --- /dev/null +++ b/docs/api/spectrum.md @@ -0,0 +1,3 @@ +# Spectrum + +::: microsim.schema.Spectrum diff --git a/docs/api/util.md b/docs/api/util.md new file mode 100644 index 00000000..6cb5d893 --- /dev/null +++ b/docs/api/util.md @@ -0,0 +1,10 @@ +# Utilities + +::: microsim.util + options: + show_root_heading: false + members: + - ortho_plot + - ndview + - microsim_cache + - clear_cache diff --git a/docs/concept.md b/docs/concept.md deleted file mode 100644 index 6fd937fa..00000000 --- a/docs/concept.md +++ /dev/null @@ -1,103 +0,0 @@ -# Conceptual Overview - -The goal of microsim is to generate highly realistic simulated light microscopy -images. The top level `microsim.Simulation` object contains all of the -parameters that define the simulation, such as the shape and scale of the space -in which the simulation is run, the sample to be imaged, the modality and -optical configuration of the microscope, the detector, and the shape/scale of -the final output image. Parameters are designed to be swappable and extensible, -so it should be easy to "image" the same sample on various microscope setups. - -Properties of fluorophores and optical configurations for microscopes can be -loaded from external sources such as [FPbase](https://www.fpbase.org/). - -The simulation can be run on a CPU or GPU, and can be accelerated with Jax, -Cupy, or PyTorch. - -Possible applications include: - -- Teaching microscopy concepts -- Generating realistic training data for machine learning models -- Generating test data for image processing pipelines -- Generating challenge/benchmark datasets with known ground truth. - -## Simulation Parameter Objects - -Microsim is designed as a set of pydantic objects. In addition to -runtime type checking and coercion, this allows for easy serialization and -deserialization of the simulation parameters. - -### Simulation - -All simulations start with a [`microsim.Simulation`][] object, and the -simulation is run by calling the [`run()`][microsim.Simulation.run] method on this -object. - -### Spaces - -Spaces define the shape and scale of the space in which the simulation is run. -They can either be "concrete" spaces that provide the actual shape and scale, -or relative spaces that provide a shape and scale downsampled or upsampled -relative to another space. - -### Samples - -Samples are declared as a list of -[`FluorophoreDistribution`][microsim.schema.FluorophoreDistribution] objects, -each of which specifies the distribution of fluorophores in the sample, and the -type of fluorophore (e.g. EGFP, mCherry, etc.). Changing the fluorophore may -change spectral properties, or signal to noise ratio, while changing the -distribution changes the spatial properties of the sample. - -### Modalities - -The modality controls much of the image formation process, its `render()` method -does much of the heavy lifting in the simulation. The modality is responsible -for generating the raw image data from ground truth fluorophore positions. -Example modalities include confocal, widefield, structured illumination, etc... - -### Objective Lenses - -Objective lenses control the properties of the objective lens, such as numerical -aperture. Currently, this object is a bit overloaded as it also includes things -like coverslip thickness. - -### Channels - -Channels control the arrangement of filters in the microscope: excitation, emission, -and dichroic filters. Channels can be used by the modality to determine signal, -background, bleedthrough, etc... - -### Detectors - -Detectors control the properties of the detector, such as quantum efficiency, -dark current, read noise, etc... The detector properties are generally applied -after downsampling the image to the output (pixelated) space. - -### Settings - -The settings object controls various global parameters of the simulation, such -as the numpy-like backend to use (numpy, jax, cupy, pytorch), a random seed which -can be used to make the simulation reproducible, and other general parameters. - -## Additional Functionality - -### Point Spread Functions - -There is no top-level point spread function object in the simulation: most -modalities will be responsible for generating their own point spread function -based on the objective lens and other parameter that are passed into their -`render()` method. The `microsim.psf` module contains some common point spread -functions, with `make_psf()` being the most common entrypoint. - -### FPbase - -The `microsim.fpbase` module contains functions for loading fluorophore and -optical configuration data from [FPbase](https://www.fpbase.org/). - -### Open organelle - -The `cosem` folder contains functions for loading data from -, which is an incredibly rich source of -ground truth data. Open organelle has a database of FIB-SEM data collected -at 4nm resolution, and segmented into organelles. (This is a work in progress) diff --git a/docs/getting-started/first-simulation.md b/docs/getting-started/first-simulation.md new file mode 100644 index 00000000..2db73ef7 --- /dev/null +++ b/docs/getting-started/first-simulation.md @@ -0,0 +1,164 @@ +--- +icon: lucide/play +--- + +# First simulation + +This page builds a simulation, runs it, and looks at the result of each stage. +It needs the `view` extra for plotting (included in `microsim[all]`). + +## Build and run it + +```python +from microsim import schema as ms +from microsim.util import ortho_plot + +sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace( # (1)! + shape=(128, 512, 512), + scale=(0.02, 0.01, 0.01), + ), + output_space={"downscale": 8}, # (2)! + sample=ms.Sample( # (3)! + labels=[ms.MatsLines(density=0.5, length=30, azimuth=5)] + ), + detector=ms.CameraCMOS(), # (4)! +) + +result = sim.run() # (5)! +ortho_plot(result) +``` + +1. The **truth space** is the grid on which the sample is drawn and the + optics are computed: 128 × 512 × 512 voxels (Z, Y, X), each 20 × 10 × 10 nm. + That is a 2.56 × 5.12 × 5.12 µm volume. Lengths are in micrometers. +2. The **output space** is the pixel grid of the final image. `downscale: 8` + bins the truth space by 8 along each axis, giving 16 × 64 × 64 pixels of + 160 × 80 × 80 nm. +3. The **sample** is a list of labels. [`MatsLines`][microsim.schema.MatsLines] + draws randomly oriented line segments, one voxel wide, with one + fluorophore per voxel. +4. The **detector** converts photons to gray values and adds noise. This is a + CMOS camera with default settings. +5. [`run()`][microsim.Simulation.run] executes every stage and returns the + digital image. + +Everything not given has a default value. In this case: + +| Parameter | Default | +| ---------------- | -------------------------------------------------------------------------------------------------------- | +| `modality` | [`Widefield()`][microsim.schema.Widefield] | +| `objective_lens` | 1.4 NA oil immersion ([`ObjectiveLens()`][microsim.schema.ObjectiveLens]) | +| `channels` | one FITC/GFP filter set, with 1 W/cm² of white light through the excitation filter | +| fluorophore | a placeholder fluorophore with flat spectra (see [Samples](../guide/samples.md#fluorophores)) | +| `exposure_ms` | 100 ms | +| `settings` | NumPy backend (or CuPy if installed), float32, and a new random seed each time the `Simulation` is created | + +!!! tip "Dictionaries work too" + + Any parameter can be given as a dictionary (or loaded from JSON) instead + of an object. These are equivalent: + + ```python + output_space = ms.DownscaledSpace(downscale=8) + output_space = {"downscale": 8} + ``` + + When a field accepts several types, a `"type"` key selects one, as in + `{"type": "confocal", "pinhole_au": 1}`. Unknown keys raise an error rather + than being ignored. + +## Look at each stage + +`run()` is shorthand for the last of four methods. Each one can be called +separately, and each returns an [`xarray.DataArray`][xarray.DataArray] with +named dimensions and physical coordinates (µm): + +| Method | Dimensions | Units | +| ------------------------------------------------------------------------------ | ------------------- | --------------------------- | +| [`ground_truth()`][microsim.Simulation.ground_truth] | `(f, z, y, x)` | fluorophores per voxel | +| [`filtered_emission_rates()`][microsim.Simulation.filtered_emission_rates] | `(c, f, w)` | photons/s per fluorophore | +| [`optical_image()`][microsim.Simulation.optical_image] | `(c, z, y, x)` | photons/s per voxel | +| [`digital_image()`][microsim.Simulation.digital_image] | `(c, z, y, x)` | gray values | + +Dimension names: `f` fluorophore, `c` channel, `w` wavelength (nm), and `z`, +`y`, `x` in µm. + +```python +truth = sim.ground_truth() # the sample +optical = sim.optical_image() # the light reaching the detector +digital = sim.digital_image(optical) # (1)! +``` + +1. Passing `optical` in avoids computing it again. This is useful to try + several detectors or exposure times on one optical image. + +![Ground truth, optical image, and digital image](../images/generated/first-simulation.png) +/// caption +Left to right: the ground truth (maximum projection), one plane of the optical +image in the truth space, and one plane of the digital image in the output +space. +/// + +The optical image is blurred but noise-free. The digital image is pixelated and +noisy, and it is dim: the brightest pixel is about 10 gray values above the +camera offset of 100. + +## Make it brighter + +The simulation works in physical units, so brightness follows from labeling +density, illumination intensity, and exposure time, as on a real microscope. +Here the sample is sparse (a single fluorophore per 10 nm voxel along each line) and +the illumination is weak. Raising the irradiance of the channel to 10 W/cm² +gives ten times as many photons: + +```python +from microsim.schema.optical_config import lib + +sim.channels = [lib.FITC.model_copy(update={"power": 10})] # (1)! +result = sim.run() +``` + +1. `lib.FITC` is the default channel. `model_copy` makes a modified copy; + setting `lib.FITC.power` directly would change it for every simulation + in the session. `power` is in W/cm² at the sample. + +A confocal microscope focuses the excitation light to a diffraction-limited +spot and scans it, so its irradiance is far higher (kW/cm² to MW/cm² at the +focus) and the time per pixel is far shorter (microseconds). For point-scanning +modalities `power` is the peak irradiance at the focus and `exposure_ms` is the +pixel dwell time: + +```python +sim.modality = ms.Confocal(pinhole_au=0.5) +sim.channels = [lib.FITC.model_copy(update={"power": 50_000})] # W/cm² at focus +sim.exposure_ms = 0.01 # 10 µs per pixel +result = sim.run() +``` + +![The same sample at higher irradiance, and in confocal](../images/generated/first-simulation-brighter.png) +/// caption +The digital image at the default 1 W/cm², at 10 W/cm², and as a confocal image +with a 0.5 AU pinhole. Each panel is scaled to its own range. +/// + +## Reproducibility + +The random lines and the noise come from a random number generator. To get the +same result every time, set a seed: + +```py +sim = ms.Simulation(..., settings=ms.Settings(random_seed=42)) +``` + +Without it, each new `Simulation` picks a random seed. + +## Next steps + +- [Overview](../guide/index.md) of how the parts of a simulation fit together. +- [Samples](../guide/samples.md): use real fluorophores, several labels, or your + own ground truth. +- [Saving and loading](../guide/saving.md): write results to disk and store + simulations as JSON. +- [Tutorial: two-color cell](../tutorials/two-color-cell.md): a longer example + with realistic ground truth. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 00000000..90e14c25 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,81 @@ +--- +icon: lucide/download +--- + +# Installation + +microsim requires Python 3.12 or later. + +## From PyPI + +```bash +pip install "microsim[all]" +``` + +!!! warning "Pin a specific version" + + microsim changes quickly, and releases can break the API or change + simulation results for the same parameters. For any project you want to + reproduce later, pin the exact version: + + ```bash + pip install "microsim[all]==0.x.y" + ``` + + You can check the installed version with `microsim.__version__`. + +The `[all]` extra installs the plotting, file I/O, and data-download +dependencies used throughout these docs. The base package (`pip install +microsim`) can run every simulation, but cannot plot results, write TIFF files, +or fetch ground truth from OpenOrganelle. + +??? info "What each extra installs" + + | Extra | Adds | Needed for | + | ------- | ---------------------------------------------- | -------------------------------------------------------------------------- | + | `view` | matplotlib, [ndv](https://github.com/pyapp-kit/ndv) | [`ortho_plot`][microsim.util.ortho_plot], [`Simulation.plot`][microsim.Simulation.plot], interactive viewing | + | `io` | tifffile, imageio | writing `.tif` output | + | `cosem` | boto3, s3fs, supabase | [`CosemLabel`](../guide/samples.md#openorganelle-cosem) ground truth | + | `jax` | jax | the `jax` backend | + | `torch` | torch | the `torch` backend | + | `all` | `view`, `io`, `cosem`, and rich | | + +## Latest development version + +```bash +pip install "microsim[all] @ git+https://github.com/tlambert03/microsim" +``` + +The development version changes often. + +## GPU acceleration + +Most of the computation time goes into PSF generation and convolution. These +can run on a GPU through [CuPy](https://docs.cupy.dev/en/stable/install.html) +or [JAX](https://docs.jax.dev/en/latest/installation.html). Neither is +installed by default; follow their installation instructions for your GPU and +CUDA version, then select the backend: + +```py +sim = ms.Simulation(..., settings=ms.Settings(np_backend="cupy")) +``` + +See [Settings](../guide/settings.md#array-backends) for details. A `torch` +backend also exists, but is incomplete. + +## Development install + +microsim uses [uv](https://docs.astral.sh/uv/) for development: + +```bash +git clone https://github.com/tlambert03/microsim +cd microsim +uv sync +uv run pytest +``` + +To build these docs locally: + +```bash +uv run --group docs zensical serve +``` diff --git a/docs/guide/channels.md b/docs/guide/channels.md new file mode 100644 index 00000000..84908092 --- /dev/null +++ b/docs/guide/channels.md @@ -0,0 +1,199 @@ +--- +icon: lucide/filter +--- + +# Channels + +A channel is one optical configuration of the microscope: the light source +and the filters it uses to excite the sample and to select the emitted light. +`Simulation.channels` is a list of [`OpticalConfig`][microsim.schema.OpticalConfig] +objects, and the output has one image per channel along the `c` dimension. + +The default is a single FITC (GFP) channel. + +```py +from microsim.schema.optical_config import lib + +sim = ms.Simulation(..., channels=[lib.FITC, lib.TRITC]) +``` + +![EGFP spectra and the FITC filter set](../images/generated/spectra-egfp-fitc-light.png#only-light) +![EGFP spectra and the FITC filter set](../images/generated/spectra-egfp-fitc-dark.png#only-dark) +/// caption +Top: excitation and emission spectra of EGFP. Bottom: the filters of +`lib.FITC`. The excitation filter selects the light that reaches the sample, the +emission filter selects the light that reaches the detector, and the dichroic +mirror separates the two paths. +/// + +## Built-in filter sets + +[`microsim.schema.optical_config.lib`][microsim.schema.optical_config.lib] +has models of common Chroma ET filter sets, built from ideal bandpass and +longpass filters: + +| Name | Excitation | Dichroic | Emission | +| ------- | ----------- | -------- | ---------- | +| `DAPI` | 350/50 | 400 LP | 460/50 | +| `ECFP` | 436/20 | 455 LP | 480/40 | +| `FITC` | 470/40 | 495 LP | 525/50 | +| `EYFP` | 500/20 | 515 LP | 535/30 | +| `TRITC` | 545/25 | 565 LP | 605/70 | +| `DSRED` | 545/30 | 570 LP | 620/60 | +| `CY5` | 620/60 | 660 LP | 700/75 | +| `CY7` | 710/75 | 760 LP | 810/90 | + +Bandpass filters are written as center/width in nm. + +## Filter sets from FPbase + +[FPbase](https://www.fpbase.org/microscopes) has measured spectra for many +microscopes and commercial filter sets. Load an optical configuration with a +string of the form `"::"`: + +```py +sim = ms.Simulation(..., channels=["wKqWbg::Widefield Green"]) +# equivalent: +ms.OpticalConfig.from_fpbase("wKqWbg", "Widefield Green") +``` + +The microscope ID is in the URL of its FPbase page, e.g. +`fpbase.org/microscope/wKqWbgApvguSNDSRZNSfpN/` (the first few characters are +enough). Configuration names are not case-sensitive. Some FPbase microscopes +with filter catalogs: + +- [Chroma filter sets](https://www.fpbase.org/microscope/PMtA2nB6Ld2Y2XvrF5zUuP/) +- [Semrock filter sets](https://www.fpbase.org/microscope/HGtCWRnyn8joPY5WF2t3zW/) +- [Nikon filter sets](https://www.fpbase.org/microscope/up3K5Tp3jwLWXtoTt8T9vB/) +- [Zeiss filter sets](https://www.fpbase.org/microscope/VgeWjEPrGiSL6saRi9myA8/) + +## Building your own + +```py +ms.OpticalConfig( + name="GFP, 488 laser", + lights=[ms.LightSource.laser(488)], + filters=[ + ms.Longpass(cuton=495, placement="BS"), + ms.Bandpass(bandcenter=525, bandwidth=50, placement="EM"), + ], + power=2000, # W/cm² at the sample +) +``` + +### Filters + +| Filter | Parameters | +| --------------------------------------------------- | --------------------------------------------------------- | +| [`Bandpass`][microsim.schema.Bandpass] | `bandcenter`, `bandwidth` (nm), peak `transmission` | +| [`Longpass`][microsim.schema.Longpass] | `cuton` (nm), edge `slope`, `transmission` | +| [`Shortpass`][microsim.schema.Shortpass] | `cutoff` (nm), edge `slope`, `transmission` | +| [`SpectrumFilter`][microsim.schema.SpectrumFilter] | a measured `transmission` [`Spectrum`][microsim.schema.Spectrum] | + +`placement` says where a filter sits in the light path: + +| `placement` | Excitation path | Emission path | Typical use | +| ----------- | ----------------------- | ----------------------- | ------------------------------ | +| `"EX"` | transmission | — | excitation filter | +| `"EM"` | — | transmission | emission filter | +| `"BS"` | 1 − transmission | transmission | dichroic that reflects excitation | +| `"BSi"` | transmission | 1 − transmission | dichroic that reflects emission | +| `"ALL"` | transmission | transmission | e.g. a neutral density filter | + +The defaults are `"EX"` for `Shortpass`, `"EM"` for `Longpass`, and `"ALL"` +for `Bandpass` and `SpectrumFilter`, so set `placement` explicitly for +bandpass filters. Filters in the same path are multiplied together. + +### Light sources + +`lights` is a list of [`LightSource`][microsim.schema.LightSource] objects, +each with an emission `spectrum`. [`LightSource.laser(488)`][microsim.schema.LightSource.laser] +makes a narrow line at 488 nm. With no light source, the channel is +illuminated by flat white light, so the excitation filters alone set the +spectrum. + +### Illumination power + +`OpticalConfig.power` is the irradiance at the sample in W/cm², summed over +all wavelengths after the excitation filters. If it is not set, the irradiance +is 1 W/cm². + +!!! warning "`LightSource.power` sets relative power only" + + When a channel has several light sources, their `power` values set how the + total is divided between them. The total is always + `OpticalConfig.power` (or 1 W/cm²). Set `power` on the + `OpticalConfig` to change brightness. + +Rough orders of magnitude for the irradiance at the sample: + +| Microscope | Irradiance | +| ----------------------------------- | ----------------------- | +| widefield, arc lamp or LED | 0.1–10 W/cm² | +| widefield, laser | 10–1000 W/cm² | +| point-scanning confocal, at focus | 1–100 kW/cm² and above | + +For point-scanning modalities, `power` is the peak irradiance at the center of +the focused spot. See [Modalities](modalities.md#confocal). + +### Per-channel exposure + +`OpticalConfig.exposure_ms` overrides `Simulation.exposure_ms` for one +channel: + +```py +channels = [ + lib.FITC.model_copy(update={"exposure_ms": 50}), + lib.TRITC.model_copy(update={"exposure_ms": 200}), +] +``` + +!!! tip "Modifying the library configurations" + + The objects in `lib` are shared. Use `model_copy(update={...})` to make a + modified copy, rather than assigning to `lib.FITC.power` directly, which + would change it everywhere in your session. + +## Bleedthrough and cross-excitation + +microsim computes every fluorophore in every channel, so crosstalk needs no +extra setup. A fluorophore excited by another channel's light, or whose +emission tail passes another channel's filter, appears in that channel in +proportion to the overlap of the spectra. + +To see where the signal in each channel comes from, use +[`optical_image_per_fluor()`][microsim.Simulation.optical_image_per_fluor], +which keeps the fluorophore dimension: + +```py +per_fluor = sim.optical_image_per_fluor() # (c, f, z, y, x) +per_fluor.sum(["z", "y", "x"]) # total photons/s from each fluorophore in each channel +``` + +## Plotting spectra + +These need the `view` extra: + +```py +lib.FITC.plot() # all filter spectra of one channel +lib.FITC.plot_excitation() # excitation path, with the combined photon flux +lib.FITC.plot_emission() # emission path, with the combined transmission +sim.plot() # summary of every fluorophore in every channel +``` + +[`Simulation.plot()`][microsim.Simulation.plot] shows, for each channel: +absorption spectra, the excitation path, absorption and emission rates, the +emission path, and the photons collected from each fluorophore. + +## Spectral detection + +[`lib.spectral_detector`][microsim.schema.optical_config.lib.spectral_detector] +returns a list of channels that split an emission range into equal bins, as +on a spectral confocal: + +```py +channels = lib.spectral_detector(bins=8, min_wave=480, max_wave=600, lasers=[488, 561]) +``` + +This is a simplified model: each bin is an ideal rectangular filter, and a +dichroic reflects a 10 nm band around each laser line. diff --git a/docs/guide/detectors.md b/docs/guide/detectors.md new file mode 100644 index 00000000..5b43a37f --- /dev/null +++ b/docs/guide/detectors.md @@ -0,0 +1,261 @@ +--- +icon: lucide/cctv +--- + +# Detectors + +The detector turns the optical image (photons per second at each pixel) into +the digital image (integer gray values). This is where the exposure time +enters, and where most of the noise comes from. + +```py +sim = ms.Simulation(..., detector=ms.CameraCMOS(qe=0.8, read_noise=1.5)) +``` + +| Detector | `camera_type` | Typical use | +| -------------------------------------------- | ------------- | ------------------------------------------ | +| [`CameraCCD`][microsim.schema.CameraCCD] | `"CCD"` | widefield; interline CCD cameras | +| [`CameraEMCCD`][microsim.schema.CameraEMCCD] | `"EMCCD"` | widefield or spinning disk at very low light | +| [`CameraCMOS`][microsim.schema.CameraCMOS] | `"CMOS"` | widefield; sCMOS cameras | +| [`PMT`][microsim.schema.PMT] | `"PMT"` | point-scanning confocal | +| [`HyD`][microsim.schema.HyD] | `"HyD"` | point-scanning confocal, photon counting | + +In dictionary form, `camera_type` selects the class: +`detector={"camera_type": "EMCCD", "em_gain": 300}`. + +!!! note "Without a detector" + + If `detector` is `None` (the default), the digital image is the expected + number of photons per pixel: the optical image times the exposure time, + binned to the output space, **with no noise of any kind**. Add a detector + to get shot noise. + +![One dim optical image recorded with different cameras](../images/generated/detectors.png) +/// caption +The same optical image, about 20 photons at the brightest pixel, recorded by +three cameras (all with QE = 1). With 6 e⁻ of read noise the CCD image is +dominated by read noise. The sCMOS image is close to the photon shot noise +limit. The EMCCD amplifies each photoelectron above its read noise, at the +cost of extra gain noise. +/// + +## Exposure time + +`Simulation.exposure_ms` is the integration time per pixel, in milliseconds: + +- for a camera, the exposure time of each frame; +- for a point scanner, the pixel dwell time (typically 0.0005–0.01 ms). + +A channel can override it with +[`OpticalConfig.exposure_ms`](channels.md#per-channel-exposure), and +[`digital_image(exposure_ms=...)`][microsim.Simulation.digital_image] +overrides both, which is a quick way to compare exposure times on one optical +image: + +```py +optical = sim.optical_image() +short = sim.digital_image(optical, exposure_ms=10) +long = sim.digital_image(optical, exposure_ms=1000) +``` + +## What every detector does + +All detectors run the same sequence of steps. They differ in their +parameters, and in which steps do nothing for them. + +1. **Expected signal.** Photons per second × integration time gives the mean + number of photoelectrons per pixel. Dark current (and clock-induced charge, + for cameras) is added. +2. **Shot noise.** The number of photoelectrons is drawn from a Poisson + distribution with that mean. +3. **Full well.** A camera pixel cannot hold more than `full_well` electrons. +4. **Multiplication.** EMCCDs and PMTs multiply each electron by a random gain + before readout. The randomness adds noise. +5. **Readout.** The amplifier adds Gaussian read noise, the ADC converts + electrons to gray values, the `offset` is added, and the result is rounded + and clipped to the range of `bit_depth`. + +The parameters shared by all detectors: + +| Parameter | Meaning | +| --------------- | ------------------------------------------------------------------------------- | +| `qe` | quantum efficiency, 0–1: a number, or a [`Spectrum`][microsim.schema.Spectrum] over wavelength | +| `dark_current` | thermally generated electrons (or dark counts) per pixel per second | +| `read_noise` | electrons rms, added once per frame | +| `offset` | gray value added to every pixel | +| `bit_depth` | output gray values are clipped to 0 … 2bit_depth − 1 | +| `averaging` | number of frames (or scan passes) averaged into each image | +| `electrons_per_adu` | electrons per gray value, as on a camera datasheet | +| `relative_gain` | analog gain relative to the setting at which `full_well` maps to the maximum gray value (cameras only) | + +Give either `electrons_per_adu` or `relative_gain`, not both. For cameras the +default is `relative_gain=1`, which maps a full pixel to the top of the ADC +range. + +!!! warning "QE is applied in the optical image" + + The detector's `qe` is applied per wavelength when the emission rates are + computed ([Photon budget](photometry.md#the-details)), not in the + detector step. If you swap detectors on an existing optical image, the + new detector's QE is not used. Recompute the optical image, or compare + detectors with similar QE. See + [#152](https://github.com/tlambert03/microsim/issues/152). + +??? abstract "Noise in more detail" + + **Shot noise.** Photons arrive at random, so the count in a pixel is + Poisson distributed: its variance equals its mean \(n\), and the best + possible signal-to-noise ratio is \(\sqrt{n}\). + + **Read noise** \(\sigma_r\) is added once per readout, independent of the + signal, so the SNR of a camera without gain is + + \[ + \text{SNR} = \frac{n}{\sqrt{n + n_\text{dark} + \sigma_r^2}} + \] + + Read noise dominates when \(n \lesssim \sigma_r^2\). + + **Excess noise.** A random gain \(g\) per electron multiplies the variance + by the excess noise factor \(F = \langle g^2 \rangle / \langle g \rangle^2\). + After the gain, read noise is negligible but + + \[ + \text{SNR} = \sqrt{\frac{n}{F}} + \] + + so gain noise acts like dividing the QE by \(F\). microsim draws the + multiplied signal from a gamma distribution with the right mean and + variance. This is exact for an EMCCD at high gain, and approximate (in + the shape of the distribution, not its mean or variance) at low gain. + + **Quantization.** Rounding to integer gray values adds a uniform error of + variance 1/12 gray value². It matters when `electrons_per_adu` is large + compared with the read noise. + + **Averaging.** With `averaging=N`, the signal and dark current are + integrated for N times the exposure, read noise is added N times, and the + result is divided by N. Brightness is unchanged, and noise is reduced. + +## Cameras + +### CCD + +[`CameraCCD`][microsim.schema.CameraCCD]: all pixels share one output +amplifier. Typical values: QE 0.5–0.7, read noise 5–10 e⁻, full well +15,000–20,000 e⁻. + +### EMCCD + +[`CameraEMCCD`][microsim.schema.CameraEMCCD] adds an electron-multiplying +register before the amplifier. Each of its `em_stages` cells (604 by default) +multiplies the charge by a small random amount, for a mean gain `em_gain` +(typically 100–1000). The signal is lifted far above the read noise, so single +photons become detectable, but the excess noise factor rises toward 2: at high +gain an EMCCD has the SNR of a noise-free detector with half its QE. + +```py +ms.CameraEMCCD(qe=0.95, em_gain=300, read_noise=50, electrons_per_adu=20) +``` + +With an EMCCD, `electrons_per_adu` is usually easier to reason about than +`relative_gain`, because `relative_gain` refers to the pixel full well, before +the EM gain. + +### CMOS and sCMOS + +[`CameraCMOS`][microsim.schema.CameraCMOS]: each pixel has its own amplifier. +Typical values for a scientific CMOS: QE 0.7–0.95, read noise 1–2 e⁻, +`electrons_per_adu` around 0.5 at 16 bits. + +The model applies the same gain, offset, and read noise to every pixel. Real +sCMOS sensors vary from pixel to pixel, and some pixels are much noisier than +average; that is not modeled yet. + +!!! example "Default camera settings" + + All cameras default to QE 1, read noise 6 e⁻, full well 18,000 e⁻, 12 bits, + offset 100, and dark current 0.001 e⁻/pixel/s. These are not meant to + match any particular camera. Set the values from your camera's datasheet, + or start from the library model [`lib.ICX285`](#detector-library). + +## Point detectors + +A point scanner records one pixel at a time, so its detectors have no pixels +to hold charge, no full well, and no `relative_gain`. Set their gain with +`electrons_per_adu`, or the parameters below. `exposure_ms` is the pixel dwell +time. + +![PMT and HyD on a confocal image](../images/generated/point-detectors.png) +/// caption +The same confocal optical image, recorded with 1 µs pixel dwell by a GaAsP +PMT at two control voltages and by a HyD in photon-counting mode, without and +with 4× averaging. At 0.9 V many PMT pixels clip at the top of the 12-bit +range. The HyD gives raw photon counts. Source: +`examples/point_detectors.py`. +/// + +### PMT + +[`PMT`][microsim.schema.PMT] models a photomultiplier tube read out in analog +mode. A photoelectron from the photocathode is multiplied by a chain of +dynodes, and the resulting current is integrated over the dwell time. + +- **Gain.** Set the high voltage `hv` as on the microscope. The gain follows a + power law, `ref_gain × (hv / ref_hv) ** hv_exponent`. The defaults model a + Hamamatsu GaAsP photosensor module controlled by a 0.5–0.9 V signal, with a + gain of 5 × 10⁵ at 0.8 V. To set the gain directly, use `hv_gain` instead. +- **Gain noise.** The excess noise factor follows from the gain per dynode, and + increases as the HV is lowered (about 1.3 at 0.9 V, 1.5 at 0.5 V for the + default tube). +- **Digital gain.** `digital_gain` multiplies the digitized signal, like the + digital gain setting in some acquisition software. It amplifies signal and + noise equally. +- **Saturation.** If `max_output_current_ua` is set, the gain drops as the + average anode current approaches that limit. The current is averaged over + `divider_time_constant_ms` along the scan, so a bright region dims itself + and the lines that follow it. +- **Amplifier bandwidth.** If `time_constant_us` > 0, the signal of each + pixel leaks into the next few pixels along X, producing a streak at short + dwell times. + +![EMCCD and PMT excess noise, and PMT gain](../images/generated/detector-curves-light.png#only-light) +![EMCCD and PMT excess noise, and PMT gain](../images/generated/detector-curves-dark.png#only-dark) +/// caption +Left: excess noise factor of an EMCCD with 604 stages, from the model in +[`CameraEMCCD.excess_noise_factor`][microsim.schema.CameraEMCCD.excess_noise_factor]. +Center and right: gain and excess noise factor of the `lib.PMT_GAASP` preset +against control voltage. +/// + +### HyD + +[`HyD`][microsim.schema.HyD] models a hybrid photodetector (as in Leica HyD +detectors) in photon-counting mode. Every photon gives nearly the same pulse, +so pulses are counted: no gain noise and no read noise. With the default +`electrons_per_adu=1`, the output is the photon count. + +After each pulse the detector is blind for `dead_time_ns` (1.5 ns by +default), so at high count rates some photons are missed: a true rate \(n\) is +recorded as \(n / (1 + n\tau)\), about 6% low at 40 million counts per second. +Dead time also makes the counts more regular than Poisson. + +## Detector library + +[`microsim.schema.detectors.lib`][microsim.schema.detectors.lib] has +detectors with published or measured parameters: + +| Name | Type | Notes | +| -------------------- | ----- | ------------------------------------------------------------ | +| `ICX285` | CCD | Sony ICX285 sensor, with its QE spectrum | +| `PMT_GAASP` | PMT | GaAsP photocathode, QE spectrum from FPbase, 2 µA max. output | +| `PMT_MULTIALKALI` | PMT | multialkali photocathode, QE 0.2, 100 µA max. output | +| `HYD_SP8` | HyD | GaAsP hybrid detector | +| `POWER_HYD_R` | HyD | red-extended GaAsP hybrid detector | + +The source code of +[`detectors/lib.py`](https://github.com/tlambert03/microsim/blob/main/src/microsim/schema/detectors/lib.py) +lists where each value comes from. + +The detector docstrings in the [API reference](../api/detectors.md) describe +each model in more detail, including what is not modeled. diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 00000000..7963a79f --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,152 @@ +--- +icon: lucide/map +--- + +# Overview + +A microsim simulation follows the light from the sample to the detector. This +page describes the parts of a simulation and the order in which they are used. +The following pages cover each part in detail. + +## The `Simulation` object + +All parameters live on one [`Simulation`][microsim.Simulation] object. Its +fields map onto parts of a real experiment: + +| Field | What it describes | Guide | +| ---------------- | -------------------------------------------------------------- | --------------------------------------- | +| `truth_space` | the grid on which the sample and optics are computed | [Spaces](spaces.md) | +| `output_space` | the pixel grid of the final image | [Spaces](spaces.md) | +| `sample` | where the fluorophores are, and which fluorophores they are | [Samples](samples.md) | +| `channels` | light sources and filters, one entry per channel | [Channels](channels.md) | +| `objective_lens` | numerical aperture, refractive indices, coverslip | [Modalities](modalities.md#objective-lens) | +| `modality` | how the image is formed: widefield, confocal, ... | [Modalities](modalities.md) | +| `detector` | camera or point detector, and its noise | [Detectors](detectors.md) | +| `exposure_ms` | camera exposure, or pixel dwell time for point scanners | [Detectors](detectors.md#exposure-time) | +| `settings` | compute backend, precision, random seed, caching | [Settings](settings.md) | +| `output_path` | where to save the result | [Saving and loading](saving.md) | + +Only `truth_space` and `sample` are required. + +## The stages + +[`Simulation.run()`][microsim.Simulation.run] computes four things in order. +Each is also available as a method, so you can stop at any stage, inspect the +intermediate result, or change a parameter and repeat only the later stages. + +``` mermaid +flowchart TB + subgraph S1 ["1 ground_truth()"] + direction LR + sp[truth_space] --> gt["fluorophores per voxel
(f, z, y, x)"] + sa[sample] --> gt + end + subgraph S2 ["2 filtered_emission_rates()"] + direction LR + fl[fluorophore spectra] --> em["photons/s per fluorophore
(c, f, w)"] + ch[channels] --> em + ob1[objective NA] --> em + dq[detector QE] --> em + end + subgraph S3 ["3 optical_image()"] + direction LR + mo[modality + objective → PSF] --> oi["photons/s per voxel
(c, z, y, x)"] + end + subgraph S4 ["4 digital_image()"] + direction LR + os[output_space] --> di["gray values
(c, z, y, x)"] + de[detector + exposure] --> di + end + S1 --> S3 + S2 --> S3 + S3 --> S4 +``` + +1 **Ground truth.** Each label in the sample is +drawn into the truth space. The result counts fluorophores per voxel, with one +layer per label (`f`). See [Samples](samples.md). + +2 **Emission rates.** For every pair of +fluorophore and channel, microsim computes how many photons per second one +fluorophore emits and how many reach the detector, as a function of wavelength +(`w`). This includes the light source, excitation and emission filters, the +fluorophore's absorption and emission spectra, extinction coefficient and +quantum yield, the fraction of light the objective collects, and the +detector's quantum efficiency. It has no spatial dimensions. Because every +fluorophore is computed in every channel, bleedthrough and cross-excitation +follow automatically. See [Channels](channels.md) and +[Photon budget](photometry.md). + +3 **Optical image.** The ground truth of each +fluorophore is multiplied by its emission rate and convolved with the +microscope's point spread function (PSF), then the fluorophores are summed +within each channel. The result is the noise-free light distribution at the +detector, in photons per second, still on the truth-space grid. See +[Modalities](modalities.md). + +4 **Digital image.** The optical image is +binned to the output space, multiplied by the exposure time, and passed +through the detector model, which adds shot noise, dark current, gain, read +noise, and digitization. See [Detectors](detectors.md). + +![The three spatial stages, for a two-color simulation of a HeLa cell](../images/generated/pipeline.png) +/// caption +Ground truth (left), optical image (center), and digital image (right) for +endoplasmic reticulum labeled with EGFP (green) and mitochondria labeled with +mCherry (magenta). The ground truth is from OpenOrganelle; see the +[two-color tutorial](../tutorials/two-color-cell.md). +/// + +There are two more methods for analysis. +[`optical_image_per_fluor()`][microsim.Simulation.optical_image_per_fluor] +returns the optical image before fluorophores are summed, as +`(c, f, z, y, x)`, which shows how much of each channel comes from each +fluorophore. [`emission_flux()`][microsim.Simulation.emission_flux] returns the +emitted photons per voxel before blurring. + +## Units + +microsim uses one unit per kind of quantity: + +| Quantity | Unit | +| ---------------------------- | ------------------------------ | +| lengths (space scale, extent) | µm | +| wavelengths | nm | +| irradiance (`power`) | W/cm² | +| exposure and dwell time | ms | +| extinction coefficient | M⁻¹ cm⁻¹ | +| fluorescence lifetime | ns | + +## Outputs are labeled arrays + +Every stage returns an [`xarray.DataArray`][xarray.DataArray]. Its dimensions +have names, and `z`, `y`, `x` carry coordinates in µm, so you can select and +reduce by name: + +```py +digital = sim.run() +digital.sel(c="FITC") # select a channel by name +digital.isel(z=8).plot() # plot a plane (needs matplotlib) +sim.ground_truth().max("z") # maximum projection +``` + +Use `digital.values` (or `numpy.asarray(digital)`) for a plain NumPy array. +With a GPU backend, `digital.data` is a GPU array; see +[Settings](settings.md#array-backends). + +## What is (and isn't) modeled + +microsim aims to make each modeled step physically correct, and to state the +approximations. The guide pages list the details for each part. In summary, +these are not modeled at present: + +- photobleaching, blinking, and other time-dependent fluorophore behavior + (emission is a steady-state rate) +- triplet and other dark states, and stimulated emission +- scattering, absorption, and aberrations from the sample (other than the + refractive index mismatch handled by the PSF model) +- out-of-focus background other than what the PSF produces, and + autofluorescence +- pixel-to-pixel variation of sCMOS cameras +- the camera's physical pixel size and magnification (the output pixel size + is set by `output_space`) diff --git a/docs/guide/modalities.md b/docs/guide/modalities.md new file mode 100644 index 00000000..7fda7394 --- /dev/null +++ b/docs/guide/modalities.md @@ -0,0 +1,152 @@ +--- +icon: lucide/aperture +--- + +# Modalities and the PSF + +The modality decides how the optical image is formed from the ground truth. +For the modalities available now, that means choosing a point spread function +(PSF) and convolving each fluorophore's emission with it. + +```py +sim = ms.Simulation(..., modality=ms.Confocal(pinhole_au=1)) +``` + +| Modality | PSF | Image formed by | +| ------------------------------------------ | ------------------------------------------------ | ---------------------- | +| [`Widefield`][microsim.schema.Widefield] (default) | emission PSF | camera | +| [`Confocal`][microsim.schema.Confocal] | excitation PSF × (emission PSF ⊗ pinhole) | point scanning | +| [`Identity`][microsim.schema.Identity] | none | (ground truth is already an image) | + +![The same sample in widefield and confocal](../images/generated/modalities.png) +/// caption +The same random lines in widefield and in confocal with a 1 AU pinhole (XY +plane through the middle of the volume, and an XZ section). The confocal +removes most of the out-of-focus light. +/// + +## Widefield + +The whole field is illuminated uniformly and imaged onto a camera. Each +fluorophore's emission is convolved with the emission PSF. Every plane of the +PSF sums to about 1, so a defocused point source spreads its light over more +pixels without losing it. + +## Confocal + +A focused laser spot is scanned across the sample, and the emission passes a +pinhole before reaching a point detector. The confocal PSF is the product of + +- the **excitation PSF**: the relative irradiance of the focused spot, 1 at + the center, and +- the **effective detection PSF**: the emission PSF convolved with the pinhole, + which is the fraction of collected light that passes the pinhole for an + emitter at each position. + +`pinhole_au` is the pinhole diameter in Airy units, where 1 AU is +1.22 λem / NA, measured in the sample. Smaller pinholes reject more +out-of-focus light and slightly improve resolution, at the cost of signal. + +For a point-scanning modality: + +- `power` on the channel is the **peak irradiance** at the center of the spot + (often kW/cm² or more), not an average over the field. +- `exposure_ms` is the **pixel dwell time** (often 0.0005–0.01 ms), and is not + divided among pixels. +- [Saturation](photometry.md#excitation-saturation) is applied at each position + of the excitation spot rather than to the emission rate. +- Downsampling to the output space averages rather than sums + ([Spaces](spaces.md#how-the-downsampling-works)). +- A point detector ([PMT or HyD](detectors.md#point-detectors)) is the natural + choice, though any detector works. + +![Widefield and confocal PSFs](../images/generated/psf-comparison.png) +/// caption +Widefield and confocal PSFs at 1.4 NA, 525 nm emission and 488 nm excitation, +in XY (top) and XZ (bottom). Square-root display. With the pinhole closed +to 0.25 AU, the confocal PSF approaches the product of the excitation and +emission PSFs. +/// + +!!! info "Excitation wavelength" + + In a simulation, the confocal excitation PSF is currently computed at the + emission wavelength, not at the wavelength of the light source. This makes + the simulated excitation spot slightly larger than it should be (by the + ratio of the wavelengths, about 7% for GFP). + +## Identity + +[`Identity`][microsim.schema.Identity] applies no PSF. Use it when the ground +truth is already a microscope image (for example, a real widefield image) and +you are interested in the spectral and detector parts of the simulation: +fluorophores, filters, bleedthrough, and noise. It must be given as an object, +`modality=ms.Identity()`. + +## Objective lens + +[`ObjectiveLens`][microsim.schema.ObjectiveLens] sets the numerical aperture +and the refractive indices used by the PSF model. The default is a 1.4 NA oil +immersion objective. + +```py +ms.Simulation( + ..., + objective_lens=ms.ObjectiveLens( + numerical_aperture=1.2, + immersion_medium_ri=1.333, + immersion_medium_ri_spec=1.333, + specimen_ri=1.333, + ), +) +``` + +| Parameter | Default | Meaning | +| ----------------------------- | ------- | ------------------------------------------------ | +| `numerical_aperture` | 1.4 | NA; cannot exceed the immersion refractive index | +| `immersion_medium_ri` | 1.515 | refractive index of the immersion medium | +| `specimen_ri` | 1.47 | refractive index of the sample | +| `coverslip_ri` | 1.515 | refractive index of the coverslip | +| `coverslip_thickness_um` | 170 | coverslip thickness | +| `*_spec` versions | same | the values the objective was designed for | +| `working_distance_um` | 150 | design working distance | + +The `*_spec` parameters describe the conditions the objective is corrected +for. When the actual values differ (a coverslip of the wrong thickness, or +the wrong immersion oil), the PSF shows the resulting spherical aberration. + +The NA also sets the [collection efficiency](photometry.md) (the fraction of +emitted light that enters the objective), so a lower NA gives a dimmer +image as well as a larger PSF. + +??? note "The PSF model" + + PSFs are computed with a vectorial model of a high-NA objective that + includes the Gibson–Lanni optical path difference for mismatched + coverslip, immersion, and specimen refractive indices + ([`microsim.psf`](../api/psf.md)). + + The same PSF is used for the whole volume (it is spatially invariant), and + is computed for a point source at the coverslip surface. Aberrations that + grow with imaging depth into a mismatched specimen are therefore not + included. + +## Emission spectrum and the PSF + +The PSF depends on wavelength, and a fluorophore emits over a range of +wavelengths. By default, microsim computes one PSF per fluorophore and +channel, at the centroid wavelength of the detected emission. For a more +accurate result, it can split the detected emission into several wavelength +bins and sum one PSF per bin, weighted by the photons in that bin: + +```py +ms.Settings(spectral_bins_per_emission_channel=3) +``` + +This takes proportionally longer. See [Settings](settings.md#accuracy-vs-speed). + +## PSF caching + +PSFs are cached in memory and on disk, keyed by every parameter that affects +them, so repeating a simulation with a different sample or detector does not +recompute them. See [Settings](settings.md#caching). diff --git a/docs/guide/photometry.md b/docs/guide/photometry.md new file mode 100644 index 00000000..eea3b2cf --- /dev/null +++ b/docs/guide/photometry.md @@ -0,0 +1,180 @@ +--- +icon: lucide/sigma +--- + +# Photon budget + +This page explains how microsim calculates the number of photons a fluorophore +emits and how many of them reach the detector. These calculations happen in +[`filtered_emission_rates()`][microsim.Simulation.filtered_emission_rates], +before anything spatial is computed. + +![Absorbed, emitted, filtered, and collected photons for EGFP in the FITC channel](../images/generated/photon-budget-light.png#only-light) +![Absorbed, emitted, filtered, and collected photons for EGFP in the FITC channel](../images/generated/photon-budget-dark.png#only-dark) +/// caption +One EGFP molecule in the FITC channel at the default irradiance of 1 W/cm², +with no detector (QE = 1). Of 392 photons absorbed per second, 235 are emitted +(quantum yield 0.6), 166 fall within the emission filter, and 58 enter a 1.4 NA +objective. In a 100 ms exposure, that is about 6 photons. +/// + +## In short + +For each fluorophore in each channel: + +1. The light source and excitation filters give a photon flux at the sample. +2. The fluorophore's absorption cross section turns that into an **absorption + rate**. +3. The quantum yield and emission spectrum turn that into an **emission + rate**, per wavelength. +4. The emission filters, the detector's QE, and the fraction of light the + objective collects reduce it to the **detected rate**. + +The result is an array with dimensions `(c, f, w)`: photons per second, per +fluorophore, for every channel, fluorophore, and wavelength. Keeping the +wavelength lets the next stage use a PSF for the actual emission color. +Keeping every channel–fluorophore pair is what produces bleedthrough. + +You can compute each step on its own: + +```py +oc, fluor = lib.FITC, ms.Fluorophore.from_fpbase("EGFP") + +oc.illumination_flux_density # photons/cm²/s, per wavelength +fluor.absorption_cross_section # cm², per wavelength +oc.absorption_rate(fluor) # photons/s absorbed +oc.total_emission_rate(fluor) # photons/s emitted +oc.filtered_emission_rate(fluor) # photons/s through the emission path +sim.filtered_emission_rates() # all pairs, with QE and collection efficiency +``` + +## The details + +??? abstract "1. Absorption cross section" + + The excitation spectrum from FPbase (or your + [`Fluorophore`](samples.md#fluorophores)) is normalized to 1 at its peak and + multiplied by the extinction coefficient \(\varepsilon\) to give + \(\varepsilon(\lambda)\) in M⁻¹ cm⁻¹. The absorption cross section of one + molecule, in cm², is + + \[ + \sigma(\lambda) = \frac{\ln(10) \cdot 10^3 \cdot \varepsilon(\lambda)}{N_A} + \] + + where \(N_A\) is Avogadro's number. For EGFP + (\(\varepsilon = 55{,}900\) M⁻¹ cm⁻¹) the peak cross section is about + \(2.1 \times 10^{-16}\) cm². + +??? abstract "2. Illumination" + + The illumination spectrum is the product of the light source spectra (or + flat white light, if there are none) and the transmission of every filter + in the excitation path. It is scaled so that its sum over wavelength equals + the channel's `power`, \(P\) (1 W/cm² if not set). This gives the + irradiance \(E(\lambda)\) in W/cm² per wavelength bin, which is converted to + a photon flux density, in photons/cm²/s: + + \[ + \Phi(\lambda) = \frac{E(\lambda) \, \lambda}{h c} + \] + +??? abstract "3. Absorption rate" + + The rate at which one molecule absorbs photons is the overlap of the cross + section and the photon flux, summed over wavelength: + + \[ + k = \sum_\lambda \sigma(\lambda) \, \Phi(\lambda) + \] + +??? abstract "4. Emission rate" + + The emission spectrum \(I_\text{em}(\lambda)\) is normalized to unit sum, + and scaled by the quantum yield \(\phi\) and the absorption rate: + + \[ + R_\text{em}(\lambda) = \phi \, k \, \frac{I_\text{em}(\lambda)}{\sum_{\lambda'} I_\text{em}(\lambda')} + \] + + (With [saturation](#excitation-saturation), \(k\) is replaced by + \(k / (1 + k\tau)\).) + +??? abstract "5. Emission path and detector" + + The emitted light is multiplied by the combined transmission + \(T_\text{em}(\lambda)\) of every filter in the emission path, and by the + detector's quantum efficiency \(\text{QE}(\lambda)\), if a detector is set: + + \[ + R_\text{det}(\lambda) = R_\text{em}(\lambda) \, T_\text{em}(\lambda) \, \text{QE}(\lambda) + \] + + QE is applied here, per wavelength, rather than in the detector model. + The optical image therefore depends on the detector, so if you change to + a detector with a different QE, compute the optical image again. + ([#152](https://github.com/tlambert03/microsim/issues/152) tracks this.) + +??? abstract "6. Collection efficiency" + + A fluorophore emits in all directions, and the objective collects only the + light within its acceptance cone. The fraction collected is the solid angle + of the cone divided by that of a sphere: + + \[ + \eta = \frac{1 - \cos\theta}{2}, \qquad \sin\theta = \min\left(\frac{\text{NA}}{n_s}, 1\right) + \] + + where \(n_s\) is the refractive index of the specimen. The angle is measured + in the specimen, where the light is emitted. For a 1.4 NA objective and + \(n_s = 1.47\), \(\eta = 0.35\); for a 0.8 NA objective and \(n_s = 1.33\), 0.10. + + This ignores reflection losses at interfaces, the dipole emission pattern, + supercritical-angle fluorescence near the coverslip, and the transmission + of the objective and other optics. + +## Excitation saturation + +A fluorophore that has absorbed a photon cannot absorb another until it +returns to the ground state, on average after its fluorescence lifetime +\(\tau\). At high irradiance, emission therefore grows more slowly than the +excitation. For a two-level system at steady state, the emission rate is +proportional to + +\[ +\frac{k}{1 + k\tau} +\] + +The product \(s = k\tau\) is the saturation parameter +([`OpticalConfig.saturation_parameter`][microsim.schema.OpticalConfig.saturation_parameter]). +When \(s \ll 1\), emission is proportional to irradiance. At \(s = 1\) it is +half of what a linear response would give. + +How saturation is applied depends on the modality: + +- **Widefield**: the irradiance is uniform, so the emission rates are reduced + by \(1 / (1 + s)\). +- **Confocal**: the irradiance varies across the focused spot, so saturation + is applied at each position of the excitation PSF. The peak saturates first, + which broadens the effective PSF. Here `power` is the irradiance at the + center of the spot. + +Saturation requires `lifetime_ns` on the fluorophore. Fluorophores from FPbase +usually have one. + +!!! info "Not modeled" + + Triplet and other dark states, stimulated emission, the orientation of + the absorption dipole relative to polarized excitation, and pulsed + excitation. With pulsed lasers or with dark-state buildup, real + fluorophores saturate at lower average irradiance than this model gives. + +## From rates to images + +The emission rate of each fluorophore is per molecule. The +[next stage](modalities.md) multiplies it by the number of fluorophores in each +voxel and convolves with the PSF. The widefield PSF is normalized so that each +plane sums to about 1: a camera focused at any depth records all the light that +the objective collected from a point source. The confocal PSF is scaled by the +relative excitation intensity at each position and by the fraction of +collected light that passes the pinhole. diff --git a/docs/guide/samples.md b/docs/guide/samples.md new file mode 100644 index 00000000..587e093e --- /dev/null +++ b/docs/guide/samples.md @@ -0,0 +1,246 @@ +--- +icon: lucide/dna +--- + +# Samples + +The sample says where fluorophores are and which fluorophores they are. It is +a list of **labels**. Each label combines + +- a **distribution**: how many fluorophores are in each voxel of the truth + space, and +- a **fluorophore**: its spectra, brightness, and lifetime. + +```py +sample = ms.Sample( + labels=[ + ms.FluorophoreDistribution( + distribution=ms.MatsLines(density=0.5), + fluorophore="EGFP", + ), + ms.FluorophoreDistribution( + distribution=ms.CosemLabel(dataset="jrc_hela-3", label="mito-mem_pred"), + fluorophore="mCherry", + concentration=10, + ), + ] +) +``` + +[`ground_truth()`][microsim.Simulation.ground_truth] draws each label into the +truth space and stacks them along the `f` dimension. The values are +fluorophores per voxel. + +!!! tip "Shorthand" + + `sample` also accepts a plain list, and a label can be a bare distribution. + These are equivalent: + + ```py + sample = ms.Sample(labels=[ms.FluorophoreDistribution(distribution=ms.MatsLines())]) + sample = [ms.MatsLines()] + ``` + + A label without a fluorophore gets a placeholder (see + [below](#the-placeholder-fluorophore)). + +## Distributions + +### Random lines + +[`MatsLines`][microsim.schema.MatsLines] draws randomly oriented, +one-voxel-wide line segments, with one fluorophore per voxel. It is quick to +compute and useful for testing. + +| Parameter | Effect | +| --------- | -------------------------------------------------------------------------------------------- | +| `density` | number of lines, as a fraction of the X size in voxels (512 voxels × 0.5 = 256 lines) | +| `length` | scales the random line length, relative to the size of the field of view | +| `azimuth` | limits the tilt out of the XY plane: larger values give flatter lines | +| `max_r` | lines are only drawn inside an ellipsoid of this relative radius (1 = touching the edges) | + +### OpenOrganelle (COSEM) + +[`CosemLabel`][microsim.schema.CosemLabel] uses segmentations of organelles +from FIB-SEM volumes of cells, published by Janelia's COSEM project at +[OpenOrganelle](https://openorganelle.janelia.org/datasets). These give +realistic shapes for the endoplasmic reticulum, mitochondria, nuclear envelope, +microtubules, and more. + +```py +ms.CosemLabel(dataset="jrc_hela-3", label="er-mem_pred") +``` + +- `dataset` is the dataset ID from the OpenOrganelle website (e.g. + `jrc_hela-3`, `jrc_cos7-11`). +- `label` is the name of a segmentation or prediction layer in that dataset + (e.g. `er-mem_pred`, `mito-mem_pred`, `ne_pred`), or its description. +- `position` (optional) selects the region to extract. By default the region + is centered in the dataset. + +The data are downloaded on first use and cached. This needs the `cosem` extra +and network access. + +!!! warning "Voxel size" + + The source data are stored at 4 nm voxels and at a series of 2× + downsampled levels (8, 16, 32 nm, ...). microsim reads the level that + matches the truth space without interpolating, so use the same scale on + all three axes, equal to 4 nm times a power of two (e.g. `scale=0.064`). + +### Your own array + +If you already have a ground truth as an array, for example from a +segmentation, pass it directly with +[`Simulation.from_ground_truth`][microsim.Simulation.from_ground_truth]: + +```py +sim = ms.Simulation.from_ground_truth( + my_array, # (Z, Y, X), fluorophores per voxel + scale=(0.04, 0.02, 0.02), # voxel size in µm + output_space={"downscale": 4}, + modality=ms.Confocal(), +) +``` + +This creates a truth space with the array's shape and a single label with the +placeholder fluorophore. To choose the fluorophore, or to combine several +arrays, build the labels yourself with a `"fixed-array"` distribution: + +```py +sim = ms.Simulation( + truth_space={"shape": nuclei.shape, "scale": (0.04, 0.02, 0.02)}, + sample=[ + { + "distribution": {"type": "fixed-array", "array": nuclei}, + "fluorophore": "mTagBFP2", + }, + { + "distribution": {"type": "fixed-array", "array": actin}, + "fluorophore": "EGFP", + }, + ], +) +``` + +Each array must have exactly the shape of the truth space. + +The [own ground truth tutorial](../tutorials/your-own-ground-truth.md) has a +complete example. + +### Your own generator + +Any object with a `render(space, xp)` method can be used as a distribution. +`space` is an all-zero [`xarray.DataArray`][xarray.DataArray] with the shape and +coordinates of the truth space; return it with your fluorophore counts added: + +```python +import numpy as np +from microsim import schema as ms + + +class Beads: + """Point emitters at random positions.""" + + def __init__(self, n: int = 100, fluors_per_bead: int = 500): + self.n = n + self.fluors_per_bead = fluors_per_bead + + def render(self, space, xp=None): + idx = tuple(np.random.randint(0, s, self.n) for s in space.shape) + data = np.zeros(space.shape, dtype=space.dtype) + np.add.at(data, idx, self.fluors_per_bead) + return space + data + + +sample = [ms.FluorophoreDistribution(distribution=Beads(), fluorophore="mCherry")] +``` + +`np.random` is seeded from +[`Settings.random_seed`](settings.md#reproducibility) before rendering, so this +example is reproducible. Generators like this cannot be saved to JSON. To make +one that can, subclass +[`BaseDistribution`][microsim.schema.sample.BaseDistribution] (a pydantic +model). + +!!! tip "Contributions welcome" + + Realistic ground truth makes for realistic simulations. If you write a + generator others could use, please + [open a pull request](https://github.com/tlambert03/microsim/pulls). + +## Fluorophores + +The simplest way to choose a fluorophore is by name. The spectra, extinction +coefficient, quantum yield, and lifetime are then loaded from +[FPbase](https://www.fpbase.org): + +```py +ms.FluorophoreDistribution(distribution=..., fluorophore="mNeonGreen") +``` + +Any fluorescent protein or dye on FPbase with spectra can be used. This needs +network access the first time. + +To define a fluorophore yourself, give its spectra and photophysical +properties: + +```py +ms.Fluorophore( + name="MyDye", + excitation_spectrum=ms.Spectrum(wavelength=wl_ex, intensity=ex), # nm, any scale + emission_spectrum=ms.Spectrum(wavelength=wl_em, intensity=em), + extinction_coefficient=80_000, # M⁻¹ cm⁻¹, at the excitation peak + quantum_yield=0.6, + lifetime_ns=3.5, +) +``` + +What each property does: + +| Property | Used for | +| ------------------------ | ------------------------------------------------------------------------------------------------ | +| `excitation_spectrum` | the shape of the absorption spectrum; it is normalized to 1 at its peak | +| `extinction_coefficient` | scales the absorption spectrum to an absorption cross section; required | +| `quantum_yield` | the fraction of absorbed photons that are emitted (treated as 1 if not set) | +| `emission_spectrum` | the distribution of emitted photons over wavelength; normalized to unit area | +| `lifetime_ns` | [excitation saturation](photometry.md#excitation-saturation); no saturation if not set | +| `bleaching_half_life_s` | not used yet | + +### The placeholder fluorophore + +A label without a fluorophore uses `mockFluorophore`: flat excitation and +emission spectra from 300 to 800 nm, an extinction coefficient of +50,000 M⁻¹ cm⁻¹, a quantum yield of 1, and a 1 ns lifetime. It is excited by +any light source and passes any emission filter. Use it for testing; for +realistic brightness, color, and bleedthrough, use a real fluorophore. + +## Concentration + +`concentration` scales a label's distribution. A number multiplies it, so +`concentration=10` puts ten fluorophores wherever the distribution had one. A +function receives the rendered distribution and returns a new one, for example +to add labeling variability: + +```py +def patchy(truth): + rng = np.random.default_rng() + return truth * rng.poisson(5, size=truth.shape) + + +ms.FluorophoreDistribution(distribution=..., fluorophore="EGFP", concentration=patchy) +``` + +Distributions such as `MatsLines` and `CosemLabel` put one fluorophore in each +labeled voxel. Brightness depends strongly on voxel size: a 10 nm voxel with +one fluorophore is a very sparse label, while real labeled structures can carry +hundreds to thousands of fluorophores per cubic micrometer. Use +`concentration` to bring the labeling density in line with your experiment. + +## Caching + +Generating some ground truths is slow (`CosemLabel` downloads data), so +`MatsLines` and `CosemLabel` results are cached on disk, keyed by their +parameters, the truth space, the concentration, and (for random +distributions) the random seed. See [Settings](settings.md#caching) to clear +or disable the cache. diff --git a/docs/guide/saving.md b/docs/guide/saving.md new file mode 100644 index 00000000..a2e21ed1 --- /dev/null +++ b/docs/guide/saving.md @@ -0,0 +1,93 @@ +--- +icon: lucide/save +--- + +# Saving and loading + +## Saving the result + +Set `output_path` and [`run()`][microsim.Simulation.run] writes the digital +image to disk: + +```py +sim = ms.Simulation(..., output_path="result.zarr") +sim.run() +``` + +| Extension | Format | Stores the simulation parameters | Requires | +| ----------------- | ------- | -------------------------------- | -------------- | +| `.zarr` | Zarr | yes | | +| `.nc` | NetCDF | yes | | +| `.tif`, `.tiff` | TIFF | no (pixel data only) | `io` extra | + +Zarr and NetCDF files keep the dimension names, coordinates (µm), and channel +names, and store the full simulation as JSON in the +`microsim.Simulation` attribute. Open them with xarray: + +```py +import xarray as xr + +result = xr.open_dataarray("result.zarr", engine="zarr") +sim = ms.Simulation.model_validate_json(result.attrs["microsim.Simulation"]) +``` + +!!! tip "Prefer Zarr" + + Without the `netCDF4` package installed, xarray writes NetCDF files in the + older NetCDF-3 format, which has no unsigned integer types. Gray values + above 32,767 (possible with a 16-bit detector) do not survive the round + trip. Zarr has no such limit. + +To save intermediate stages, use xarray directly, for example +`sim.optical_image().to_netcdf("optical.nc")`. + +## Saving the simulation + +A `Simulation` is a [pydantic](https://docs.pydantic.dev) model, so it can be +written to and read from JSON: + +```py +from pathlib import Path + +Path("sim.json").write_text(sim.model_dump_json(indent=2)) + +sim = ms.Simulation.model_validate_json(Path("sim.json").read_text()) +``` + +The JSON is complete: fluorophores loaded from FPbase are saved with their +spectra, so loading does not need network access. It also includes the random +seed, so loading and running the simulation in the same environment reproduces +the result. + +You can also write simulations as JSON (or YAML, or anything that loads into +a dictionary) by hand. Only the fields you want to change from the defaults are +needed: + +```json title="confocal.json" +{ + "truth_space": {"shape": [128, 512, 512], "scale": [0.02, 0.01, 0.01]}, + "output_space": {"downscale": 8}, + "sample": [ + {"distribution": {"type": "matslines", "density": 0.5, "length": 30}, "fluorophore": "EGFP"} + ], + "channels": ["wKqWbg::Widefield Green"], + "modality": {"type": "confocal", "pinhole_au": 0.5}, + "detector": {"camera_type": "CMOS", "qe": 0.8, "read_noise": 1.5}, + "settings": {"random_seed": 42}, + "output_path": "confocal.zarr" +} +``` + +!!! note "What cannot be serialized" + + Custom distribution objects (a class with a `render` method that is not a + pydantic model) and functions used as `concentration` cannot be written to + JSON. When writing the output file, microsim then stores the simulation + without the `sample`. + +!!! warning "Record the version" + + A saved simulation describes the parameters, not the implementation. + microsim is changing quickly: a newer version may reject a saved JSON file, + or produce a different image from it. Keep `microsim.__version__` with + your saved simulations and results. diff --git a/docs/guide/settings.md b/docs/guide/settings.md new file mode 100644 index 00000000..e912bdd2 --- /dev/null +++ b/docs/guide/settings.md @@ -0,0 +1,122 @@ +--- +icon: lucide/settings +--- + +# Settings + +[`Settings`][microsim.schema.Settings] holds options that affect how a +simulation is computed, but not what it represents. + +```py +sim = ms.Simulation(..., settings=ms.Settings(random_seed=42, np_backend="jax")) +``` + +| Setting | Default | Effect | +| ------------------------------------ | --------- | -------------------------------------------------------------- | +| `random_seed` | random | seed for the ground truth and noise | +| `np_backend` | `"auto"` | array library used for computation | +| `float_dtype` | `float32` | floating-point precision | +| `max_psf_radius_aus` | 6 | lateral size limit of each PSF, in Airy units | +| `spectral_bins_per_emission_channel` | 1 | number of PSFs used per fluorophore and channel | +| `spectral_bin_threshold_percentage` | 1 | part of the emission spectrum that is ignored when binning | +| `cache` | on | read and write the on-disk ground-truth cache | + +## Reproducibility + +Random numbers are used to generate some ground truths (such as +[`MatsLines`][microsim.schema.MatsLines]) and to draw every kind of noise. +When the `Simulation` is created without a `random_seed`, a random one is +chosen. It is stored on the object, so `sim.settings.random_seed` tells you +which seed was used, and saving the simulation to JSON records it. + +With a fixed seed, the same simulation run twice in the same environment gives +the same result. Different backends use different random number generators, +so results are not identical across backends. + +!!! warning "Reproducing results across versions" + + microsim is under active development, and the same parameters and seed may + give different results in a different version of microsim, as models are + corrected and extended. Record the version (`microsim.__version__`) with + your results, and pin it in your environment. + +## Array backends + +Computation is done with NumPy by default. Most of the time goes into PSF +calculation and FFT convolution, which can run on a GPU: + +| `np_backend` | Library | +| ------------ | ------------------------------------------- | +| `"numpy"` | NumPy and SciPy (CPU) | +| `"cupy"` | [CuPy](https://cupy.dev) (NVIDIA GPU) | +| `"jax"` | [JAX](https://docs.jax.dev) (CPU or GPU) | +| `"torch"` | PyTorch (incomplete) | +| `"auto"` | CuPy if it can be imported, otherwise NumPy | + +The backend can also be chosen with the `MICROSIM_BACKEND` environment +variable, which is used when `np_backend` is `"auto"`. + +With a GPU backend, the arrays inside the returned `DataArray` live on the +GPU. Use `.values` or `numpy.asarray()` to get a NumPy array. + +## Accuracy vs. speed + +**`max_psf_radius_aus`** limits the lateral size of each PSF to this many Airy +radii from the center. A smaller value makes convolution much faster but +drops the faint outer parts of the PSF, which in widefield carry a noticeable +fraction of the out-of-focus light. Set it to `None` to use PSFs as large as +the truth space. + +**`spectral_bins_per_emission_channel`** sets how many wavelengths are used to +represent each fluorophore's detected emission. With 1 (the default), one PSF +at the centroid wavelength is used. With more, the emission spectrum (after +dropping the parts below `spectral_bin_threshold_percentage` of its peak) is +split into that many bins, and one PSF is computed per bin and weighted by +its photons. Time and memory grow with the number of bins. + +**`float_dtype`** can be set to `float64` for higher precision, at twice the +memory. + +## Caching + +microsim caches results that are slow to compute and depend only on their +parameters: + +- **PSFs**, in memory (the most recent 64) and on disk. +- **Ground truth** from `MatsLines` and `CosemLabel`, on disk, keyed by the + distribution's parameters, the truth space, the concentration, and the random + seed. +- **OpenOrganelle downloads**, on disk. + +The disk cache is in the user cache directory given by +[platformdirs](https://github.com/tox-dev/platformdirs) (for example +`~/Library/Caches/microsim` on macOS, `~/.cache/microsim` on Linux): + +```py +from microsim.util import microsim_cache, clear_cache + +microsim_cache() # path to the cache directory +clear_cache() # delete everything +clear_cache("psf/*") # delete matching entries +``` + +To turn caching off: + +- `Settings(cache=False)` disables reading and writing the ground-truth cache. + `Settings(cache={"read": True, "write": False})` controls the two + separately. +- The environment variable `MICROSIM_CACHE=0` disables both the ground-truth + and the PSF disk caches. + +## Environment variables + +Every setting can be set with an environment variable named `MICROSIM_` +followed by the setting name. Nested settings use `__` as a separator: + +```bash +export MICROSIM_RANDOM_SEED=42 +export MICROSIM_NP_BACKEND=jax +export MICROSIM_CACHE__WRITE=0 # read the cache, but don't write to it +``` + +Values passed in code take precedence over environment variables. diff --git a/docs/guide/spaces.md b/docs/guide/spaces.md new file mode 100644 index 00000000..415dd356 --- /dev/null +++ b/docs/guide/spaces.md @@ -0,0 +1,130 @@ +--- +icon: lucide/grid-3x3 +--- + +# Spaces + +A simulation uses two grids: + +- The **truth space** is where the sample is drawn and the optical image is + computed. It should be fine enough to represent the structures in your + sample and to sample the PSF well, typically 10–50 nm per voxel. +- The **output space** is the pixel grid of the final image, as recorded by + the detector. A typical camera pixel, projected onto the sample, is + 60–160 nm. + +Both have the same physical extent. The optical image is computed in the truth +space and then binned down to the output space just before the detector +model. + +![Optical image in the truth space, and the digital image in the output space](../images/generated/spaces-crop.png) +/// caption +A detail of the [overview figure](index.md#the-stages): the optical image on the +64 nm truth grid (left), and the digital image after binning 2 × 2 × 2 to the +128 nm output grid (right). +/// + +## Defining a space + +A space has a `shape` (number of voxels along Z, Y, X), a `scale` (voxel size +in µm), and an `extent` (`shape × scale`). Give any two and the third is +computed: + +=== "shape and scale" + + ```py + ms.ShapeScaleSpace(shape=(64, 512, 512), scale=(0.04, 0.02, 0.02)) + # dict form: + {"shape": (64, 512, 512), "scale": (0.04, 0.02, 0.02)} + ``` + +=== "extent and scale" + + ```py + ms.ExtentScaleSpace(extent=(2.56, 10.24, 10.24), scale=(0.04, 0.02, 0.02)) + # dict form: + {"extent": (2.56, 10.24, 10.24), "scale": (0.04, 0.02, 0.02)} + ``` + +=== "shape and extent" + + ```py + ms.ShapeExtentSpace(shape=(64, 512, 512), extent=(2.56, 10.24, 10.24)) + # dict form: + {"shape": (64, 512, 512), "extent": (2.56, 10.24, 10.24)} + ``` + +A single number for `scale` applies to all axes: `scale=0.02` is the same as +`scale=(0.02, 0.02, 0.02)`. A space can also be 2D, with a `shape` of length 2. + +## Relative spaces + +Usually one space is given explicitly and the other is defined relative to it: + +```py +# the output pixels are 8x larger than the truth voxels, along every axis +ms.Simulation( + truth_space={"shape": (128, 512, 512), "scale": (0.02, 0.01, 0.01)}, + output_space={"downscale": 8}, # -> shape (16, 64, 64), scale (0.16, 0.08, 0.08) + ... +) + +# or: start from the image you want, and compute the truth space from it +ms.Simulation( + truth_space={"upscale": 4}, # -> shape (64, 256, 256), scale (0.04, 0.02, 0.02) + output_space={"shape": (16, 64, 64), "scale": (0.16, 0.08, 0.08)}, + ... +) +``` + +`downscale` and `upscale` take one integer for all axes, or one per axis +(e.g. `{"downscale": (4, 8, 8)}`). At most one of the two spaces may be +relative. + +`output_space` may be omitted, in which case the digital image has the shape +and scale of the truth space. + +## How the downsampling works + +When the optical image is binned into the output space, Y and X are handled +differently for cameras and point scanners: + +- A **camera** pixel collects all the light that falls on its area, so the + truth voxels in each output pixel are **summed** in Y and X. +- A **point scanner** records the signal at each scan position, so the truth + voxels are **averaged**. The dwell time, not the pixel area, sets the signal. + +In Z, voxels are always averaged: each output plane is a separate exposure at +one focal position, and does not collect light from neighboring planes. + +As a consequence, changing `output_space` changes the camera's signal per pixel +(larger pixels collect more light) but not the point scanner's. + +## Choosing the sizes + +??? tip "Guidelines" + + - **Truth voxel size.** The truth grid must resolve the PSF: at 1.4 NA and + 500 nm emission the lateral resolution is about 200 nm, so 20–50 nm + voxels are adequate for the optics. Finer voxels are needed only to + represent fine structure in the sample itself. + - **Integer ratio.** The output scale should be an integer multiple of the + truth scale along each axis. + - **Memory and time.** The truth space dominates both. A + 128 × 1024 × 1024 float32 volume is 512 MB, and the simulation holds + several arrays of this size (one per fluorophore and channel) at once. + Start small and increase the size when the parameters are right. + - **PSF size.** The lateral size of the PSF is limited by + [`Settings.max_psf_radius_aus`](settings.md#accuracy-vs-speed), so a + large field of view does not make each PSF larger. + - **Edges.** The convolution treats everything outside the volume as + empty. Light from structures just outside the field of view is missing, + and the top and bottom planes have less out-of-focus light than they would + in a thicker sample. + +!!! info "Camera pixel size and magnification" + + The output pixel size is set directly by `output_space`. The camera's + physical pixel size and the objective's magnification are not used yet. + For a camera with 6.5 µm pixels behind a 60× objective, set the output + scale to 6.5 / 60 ≈ 0.108 µm. diff --git a/docs/images/au02.png b/docs/images/au02.png deleted file mode 100644 index a1e17a73ce184dde65bd4798140f4ef56b709d01..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19253 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

DSW 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 Yo8RrMcKJ(E3qbljUHx3vIVCg!0Mm_p_W%F@ diff --git a/docs/images/favicon.svg b/docs/images/favicon.svg new file mode 100644 index 00000000..5803f56b --- /dev/null +++ b/docs/images/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs/images/logo.svg b/docs/images/logo.svg new file mode 100644 index 00000000..5803f56b --- /dev/null +++ b/docs/images/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md index 397a2754..d84bf8fe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,133 +1,123 @@ +--- +icon: lucide/microscope +hide: + - navigation +--- + # microsim -Microscope simulation library for generating realistic microscope images. +

+microsim simulates fluorescence microscopy images. You describe a sample, a +microscope, and a detector, and it calculates the photons the sample emits, the +image the optics form, and the noisy digital image a camera or point detector +records. +

+ +
+![Simulated confocal images of a HeLa cell](https://github.com/tlambert03/microsim/assets/1609449/4bc9eb85-b275-4315-b60d-2cb3d003b7f1){ .off-glb } +

+Simulated images of nucleus, mitochondria, and endoplasmic reticulum. The +ground truth comes from FIB-SEM segmentations of cell jrc_hela-3 +at OpenOrganelle. +

+
+ +## What it's for + +- **Ground truth for image analysis.** Every simulated image comes with the + exact fluorophore distribution that produced it. You can use the pairs to + train or benchmark segmentation, denoising, deconvolution, or + super-resolution methods. +- **Planning experiments.** Ask what a different objective, filter set, + fluorophore, exposure time, or detector would do to your images before you + change them on the microscope. +- **Teaching.** Each step of image formation can be computed and plotted on its + own: absorption spectra, filter transmission, PSFs, shot noise, read noise. + +## How it works + +A simulation is a single [`Simulation`][microsim.Simulation] object that holds +every parameter. Running it takes the sample through four stages: + +``` mermaid +flowchart LR + A["Sample
fluorophores per voxel"] --> B["Emission
photons/s per fluorophore,
per channel and wavelength"] + B --> C["Optical image
photons/s, blurred
by the PSF"] + C --> D["Digital image
gray values, with
pixelation and noise"] +``` + +![Ground truth, optical image, and digital image of the same cell](images/generated/pipeline.png) -## Installation +Each stage has its own method, and returns a labeled +[`xarray.DataArray`][xarray.DataArray] that you can inspect, plot, or save. The +[User guide](guide/index.md) explains each stage. -### from PyPI +## A first example -```bash -pip install "microsim[all]" +```python +from microsim import schema as ms +from microsim.util import ortho_plot + +sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(128, 512, 512), scale=(0.02, 0.01, 0.01)), + output_space={"downscale": 8}, + sample=ms.Sample(labels=[ms.MatsLines(density=0.5, length=30, azimuth=5)]), + detector=ms.CameraCMOS(), +) +result = sim.run() # (C, Z, Y, X) array of gray values +ortho_plot(result) ``` -!!! note - At the moment, this library is a bit more "application" than it - is "library". If you are following the docs or tutorials, its - probably best to install the full `[all]` extra, which brings in - io, visualization, and other data-fetching dependencies. However, - the bare minimal install is fully functional for generating data. +[First simulation](getting-started/first-simulation.md) goes through this +example line by line. -### From github +## Where to go next -To get the bleeding edge version, which will change rapidly, you can install from github. +
-```bash -pip install "microsim[all] @ git+https://github.com/tlambert03/microsim" -``` +- :lucide-download:{ .lg .middle } **Install** -### With GPU support - -If available, microsim can use either Jax or Cupy to accelerate computations. -These are not installed by default, see the -[jax](https://jax.readthedocs.io/en/latest/installation.html) -or [cupy](https://docs.cupy.dev/en/stable/install.html) installation instructions, -paying attention to your GPU requirements. Support for torch is not fully -implemented yet, but is planned. - -## Quickstart - -Construct and run a [`microsim.Simulation`][] object. - -=== "using objects" - - ```python - from microsim import schema as ms - from microsim.util import ortho_plot - - sim = ms.Simulation( - truth_space=ms.ShapeScaleSpace(shape=(128, 512, 512), scale=(0.02, 0.01, 0.01)), - output_space={"downscale": 8}, - sample=ms.Sample(labels=[ms.MatsLines(density=0.5, length=30, azimuth=5, max_r=1)]), - modality=ms.Confocal(pinhole_au=0.2), - ) - result = sim.run() - ortho_plot(result) - ``` - -=== "using dicts" - - If you don't like importing all the names, you can also use - dictionaries, and pydantic will convert them to the correct - objects. - - ```python - from microsim import Simulation - from microsim.util import ortho_plot - - sim = Simulation( - truth_space={"shape": (128, 512, 512), "scale": (0.02, 0.01, 0.01)}, - output_space={"downscale": 8}, - sample=dict( - labels=[ - { - "type": "matslines", - "density": 0.5, - "length": 30, - "azimuth": 5, - "max_r": 1, - } - ] - ), - modality={"type": "confocal", "pinhole_au": 0.2}, - ) - result = sim.run() - ortho_plot(result) - ``` - -![Image](./images/au02.png) - -Note that you can also construct a simulation from a JSON object. -This is particularly useful with the `output` parameter, to dump -the result of the simulation to a file. - -```json title="confocal.json" -{ - "truth_space": { - "shape": [128, 512, 512], - "scale": [0.02, 0.01, 0.01] - }, - "output_space": { - "downscale": 8 - }, - "sample": { - "labels": [ - { - "type": "matslines", - "density": 0.5, - "length": 30, - "azimuth": 5, - "max_r": 1 - } - ] - }, - "modality": { - "type": "confocal", - "pinhole_au": 0.2 - }, - "output_path": "au02.tiff" -} -``` + --- -... and then load a `Simulation` from that file and [`run()`][microsim.Simulation.run]. + `pip install "microsim[all]"`, optional GPU backends, and a development + setup. -```python -from microsim import Simulation -from pathlib import Path + [:octicons-arrow-right-24: Installation](getting-started/installation.md) -spec = Path("confocal.json").read_text() -sim = Simulation.model_validate_json(spec) -sim.run() -``` +- :lucide-play:{ .lg .middle } **First simulation** + + --- + + Build a simulation step by step and look at the output of each stage. + + [:octicons-arrow-right-24: First simulation](getting-started/first-simulation.md) + +- :lucide-book-open:{ .lg .middle } **User guide** + + --- + + Samples, filters, light sources, PSFs, and detectors: what each part + models and how to configure it. + + [:octicons-arrow-right-24: User guide](guide/index.md) + +- :lucide-braces:{ .lg .middle } **API reference** + + --- + + Every model, field, and default value. + + [:octicons-arrow-right-24: API reference](api/index.md) + +
+ +!!! warning "Pin your version" + + microsim is in early development and changes quickly. New releases may + include breaking API changes, and the same parameters may produce + different simulation results as the physical models are corrected and + extended. If you depend on microsim for a project, pin a specific version + (e.g. `microsim==0.x.y`) and record it alongside your results. -For more details, see the [tutorial](./tutorial.ipynb), [conceptual -overview](./concept.md) and the [API docs](./api) +Issues and pull requests are welcome on +[GitHub](https://github.com/tlambert03/microsim). diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js index 5209b3c1..79464978 100644 --- a/docs/javascripts/mathjax.js +++ b/docs/javascripts/mathjax.js @@ -17,3 +17,7 @@ document$.subscribe(() => { MathJax.texReset(); MathJax.typesetPromise(); }); + +component$.subscribe(({ ref }) => { + if (ref.classList.contains("md-annotation")) MathJax.typesetPromise([ref]); +}); diff --git a/docs/stages.md b/docs/stages.md deleted file mode 100644 index e971dcdf..00000000 --- a/docs/stages.md +++ /dev/null @@ -1,418 +0,0 @@ -# Simulation Stages - -The simulation is divided into several stages, each of which is responsible for a specific part of the simulation process. The stages are executed in the following order: - -1. [Establishing the 3D space in which the simulation is performed](#space-creation) -2. [Generation of the ground truth target/fluorophore positions](#ground-truth) -3. [Calculation of emission photon fluxes](#fluorophore-emission-collection) -4. [Forming the optical image](#optical-image) -5. [Noise and downsampling in the digital image](#digital-image) - -## Space creation - -- **value units**: N/A -- **dimensions**: - - **`Z`**: planes along the optical axis (units length). - - **`Y`**: rows (units length). - - **`X`**: columns (units length). - -The simulation starts with the declaration of a discrete 3D volume in which the -simulation will take place. The volume is defined by its shape and scale (voxel -size in physical units). Typically, this volume will be many times larger than -the final image size (in terms of shape) so as to be able to represent very fine -ground truth structures that will be blurred by the imaging system. At a later -stage, this volume will be downsampled to the final image shape and scale -(representing the detector pixel sizes). - -!!! example - - The following example creates a 3D volume of shape (512, 1024, 1024) with voxel - sizes of (0.04, 0.02, 0.02) microns (i.e. a volume of 20.48 x 20.48 x 20.48 µm). - The `output_space` parameter specifies that the final image will be downsampled - by a factor of 4, into a volume of shape (128, 256, 256) with voxel sizes of - (0.16, 0.08, 0.08) microns. Note that the *extents* of the ground truth and - output volumes are the same. - - ```python - from microsim import Simulation - - sim = Simulation( - truth_space={"shape": (512, 1024, 1024), "scale": (0.04, 0.02, 0.02)}, - output_space={"downscale": 4}, - ) - ``` - -## Ground Truth - -- **method**: [`Simulation.ground_truth`][microsim.Simulation.ground_truth] -- **value units**: fluorophore counts -- **dimensions**: - - **`F`**: Fluorophore (categorical unit [`Fluorophore`][microsim.schema.Fluorophore]) - - **`Z`**: planes along the optical axis (units length). - - **`Y`**: rows (units length). - - **`X`**: columns (units length) - -The ground truth stage is responsible for generating the positions and number of -the fluorophores in the 3D space. Each -[`FluorophoreDistribution`][microsim.schema.FluorophoreDistribution] object in -the simulation specifies the position, number, species (e.g. mEGFP) of -fluorophores in the volume. The ground truth will have dimensions `(F, Z, Y, X)` -where `len(F)` is determined by the number of `labels` in the `sample` field of -the `Simulation`; the values in the ground truth array represent the number of -fluorophores present at each voxel in the volume. - -!!! example - - Here is an example using a ground-truth generator - [`MatsLines`][microsim.schema.sample.MatsLines] that draws random lines in the - volume. The fluorophore is specified to be `EGFP`. - - *When specifying the fluorophore as a string, microsim will load properties from - [FPbase](https://www.fpbase.org/). Otherwise, you may directly create a - [`Fluorophore`][microsim.schema.Fluorophore] object.* - - ```python - sim = Simulation( - # ..., - sample={ - "labels": [ - { - "distribution": { - "type": "matslines", - "density": 0.5, - "length": 30, - "azimuth": 5, - }, - "fluorophore": "EGFP", - } - ], - }, - ) - ``` - -!!! tip "Have a cool ground truth generator?" - - This is obviously a very important stage, as the quality of the ground truth - will directly affect the realism of the simulation. We provide several built-in - ground truth generators, but *we would love to expand this with additional - user-contributed methods*. Please [open a - PR](https://github.com/tlambert03/microsim/pulls)! - We will help :slightly_smiling_face: - -## Fluorophore Emission & Collection - -- **method**: [`Simulation.filtered_emission_rates`][microsim.Simulation.filtered_emission_rates] -- **value units**: photons / second -- **dimensions**: - - **`C`**: Channel (categorical unit [`OpticalConfig`][microsim.schema.OpticalConfig]) - - **`F`**: Fluorophore (categorical unit [`Fluorophore`][microsim.schema.Fluorophore]) - - **`W`**: wavelength (units length) - -In this stage, we calculate the rate of photon emission & collection for each -combination of fluorophore and optical configuration in the simulation, as a -function of wavelength. This is still considered to be in the "pre-optical" -domain, before the effects of the detection optics are applied, but *after* any -effects of the emission optics and QE are applied. (i.e. it's slightly out of -order, but it allows us to more accurately calculate wavelength effects). - -This array is *not* spatially aware, but will be combined with the ground truth -array of fluorophore concentration to determine the spatial distribution of -emitted photons. - -This stage includes a number of calculations, and will generally depend on: - -- the excitation spectrum of the fluorophore -- the spectrum and irradiance (W/cm^2) of the excitation light source and filters -- the molecular brightness of the fluorophore (extinction coefficient and quantum yield). -- the spectral transmission of the emission filters and detector QE. - -### Absorption cross section - -The absorption cross section $\sigma$ (in $cm^2$) of the fluorophore at -each wavelength is given by: - -$$ -\sigma(\lambda) = \log(10) \frac{\epsilon(\lambda) \cdot 10^3}{N_A} -$$ - -where: - -- $\epsilon(\lambda)$ is the Molar extinction coefficient at each wavelength ($\text{M}^{-1} \text{cm}^{-1}$) -- $N_A$ is Avogadro's number ($6.022 \times 10^{23} / \text{mol}$) - -### Irradiance Flux Density - -The flux of excitation photons $\Phi_{\text{ex}}$ (in $photons/cm^2/sec$) -at each wavelength is given by: - -$$ -\Phi_{\text{ex}}(\lambda) = \frac{P(\lambda) \cdot \lambda}{hc} -$$ - -where: - -- $P(\lambda)$ The spectrum of the light source and its irradiance ($W/cm^2$) -- $h$ is Planck's constant ($6.626 \times 10^{-34} J \cdot s$) -- $c$ is the speed of light ($3 \times 10^8$ $m/s$) -- $\lambda$: the wavelength of the excitation photons ($m$). - -*Note that in the simulation, $P(\lambda)$ will also include the effects of the -excitation filters, which will reduce the irradiance at specific wavelengths.* - -### Absorption Rate - -The effective rate of photon absorption $\Phi_{\text{abs}}$ (in $photons/sec$) -at each wavelength is the product of the excitation flux and the absorption cross -section: - -$$ -\Phi_{\text{abs}}(\lambda) = \Phi_{\text{ex}}(\lambda) \times \sigma(\lambda) -$$ - -Summed over all wavelengths, this gives the total absorption rate $\Phi_{\text{abs,total}}$: - -$$ -\Phi_{\text{abs,total}} = \sum_{\lambda} \Phi_{\text{abs}}(\lambda) -$$ - -### Emission Rate - -To convert the absorption rate into an emission rate, we need to consider the -quantum yield $\eta$ of the fluorophore, and the emission spectrum of the -fluorophore. First, we normalize the emission spectrum $I_{\text{em}}(\lambda)$ -so that the integral over all wavelengths is 1: - -$$ - \tilde{I}_{\text{em}}(\lambda) = \frac{I_{\text{em}}(\lambda)}{\sum_{\lambda} I_{\text{em}}(\lambda)} -$$ - -The emission rate $\Phi_{\text{em}}$ (in $photons/sec$) at each -wavelength is calculated by multiplying the normalized emission spectrum by the -absorption rate and the quantum yield $\eta$: - -$$ - \Phi_{\text{em}}(\lambda) = \eta \times \Phi_{\text{abs, total}} \times \tilde{I}_{\text{em}}(\lambda) -$$ - -where: - -- $\eta$ is the quantum yield of the fluorophore. - -### Filtered Emission Flux - -The emission rate $\Phi_{\text{em}}$ is then multiplied by the combined -transmission of the emission filters and the quantum efficiency of the detector -to give the final emission rate $\Phi_{\text{em, filtered}}$: - -$$ -\Phi_{\text{em, filtered}}(\lambda) = \Phi_{\text{em}}(\lambda) \times T_{\text{em}}(\lambda) \times \text{QE}(\lambda) -$$ - -where: - -- $T_{\text{em}}(\lambda)$ is the transmission of the emission filters at each wavelength. -- $\text{QE}(\lambda)$ is the quantum efficiency of the detector at each wavelength. - -The output of this stage (given by -[`Simulation.filtered_emission_rates`][microsim.Simulation.filtered_emission_rates]) -is a 3D array with dimensions `(C, F, W)`, but lacking spatial information: - -- The channel (`C`) dimension has coordinates representing the different optical - configurations (e.g. filter sets) in the simulation, and `len(C)` is determined - by the number of `channels` in the simulation. -- The fluorophore (`F`) dimension has coordinates representing the different - fluorophores in the simulation, and `len(F)` is determined by the number of - unique fluorophores in the `sample.labels` field of the `Simulation`. -- The wavelength (`W`) dimension has coordinates representing the wavelength of - the emitted photons, with nanometer resolution. - -It's worth pointing out that we need to calculate the emission spectral flux for -*every* combination of fluorophore and channel in order to be able to include -bleedthrough and crosstalk effects in the simulation. For example `data[{'F': 0, -'C': 1}]` would represent the emission flux of fluorophore 0 when excited by -channel 1. This is why both F and C remain at this stage. - -!!! examples - - Each channel in the simulation is an arrangement of optical filters - and light sources. (If the light source is omitted, it is assumed to be - a flat white light source, and the excitation filter spectra are used directly). - - ```python - sim = Simulation( - # ..., - channels=[ - { - "name": "Green", - "filters": [ - { - "type": "bandpass", - "bandcenter": 470, - "bandwidth": 40, - "placement": "EX", - }, - {"type": "longpass", "cuton": 495, "placement": "BS"}, - { - "type": "bandpass", - "bandcenter": 525, - "bandwidth": 50, - "placement": "EM", - }, - ], - } - ], - ) - ``` - -!!! note "TODO" - - Things like fluorophore lifetime, ground state depletion, and photobleaching - rate also need to be considered in this stage, but are not yet implemented. In - order to properly simulate these effects, the simulation will need to also take - into account the temporal aspects of the illumination pattern even *within* a - single image. That's a lot of complexity. So for now, everything will be - simulated as a steady-state emission flux. - -!!! info "Builtin library of common filter sets" - - `microsim` provides a library of common optical configs. For example, - the above filter set arrangement shown above is a very common FITC filter set, - which can be loaded from the library as follows: - - ```python - from microsim.schema.optical_config import lib - - sim = Simulation( - # ..., - channels=[lib.FITC], - ) - ``` - -!!! info "Optical Configurations from FPbase" - - You can also load optical configurations from [FPbase - microscope](https://www.fpbase.org/microscopes) using the syntax - `microscope_id::config_name`. For example, to load the "Widefield Green" config - from the [Example Simple Widefield microscope on - FPbase](https://www.fpbase.org/microscope/wKqWbgApvguSNDSRZNSfpN/?c=Widefield%20Green), - you would grab the microscope id from the URL (in this case `wKqWbgAp`) and add - the config name (`Widefield Green`), separated by two colons (`::`): - - ```python - sim = Simulation( - # ..., - channels=["wKqWbgAp::Widefield Green"], - ) - ``` - -!!! tip - - Here are some useful FPbase microscopes with brand-specific filter set catalogs: - - - [Chroma Filter Sets](https://www.fpbase.org/microscope/PMtA2nB6Ld2Y2XvrF5zUuP/) - - [Semrock Filter Sets](https://www.fpbase.org/microscope/HGtCWRnyn8joPY5WF2t3zW/) - - [Nikon Filter Sets](https://www.fpbase.org/microscope/up3K5Tp3jwLWXtoTt8T9vB/) - - [Zeiss Filter Sets](https://www.fpbase.org/microscope/VgeWjEPrGiSL6saRi9myA8/) - -## Optical Image - -- **method**: [`Simulation.optical_image`][microsim.Simulation.optical_image] -- **value units**: photons / second -- **dimensions**: - - **`C`**: Channel (categorical unit [`OpticalConfig`][microsim.schema.OpticalConfig]) - - **`Z`**: planes along the optical axis (units length). - - **`Y`**: rows (units length). - - **`X`**: columns (units length) - -In this stage, the fluorophore distributions are scaled by the respective -emission photon fluxes, and then convolved with the optical point spread -function (PSF) of the microscope to form the (noise free) optical image. - -Within each channel, the contributions of individual fluorophores are summed -together to form the final image (allowing for crosstalk and bleedthrough). If -you want access to the individual fluorophore contributions, you can use -[`Simulation.optical_image_per_fluor`][microsim.Simulation.optical_image_per_fluor], -which retains the full `(C, F, Z, Y, X)` dimensions. - -!!! info "PSF" - - The [point spread function](https://en.wikipedia.org/wiki/Point_spread_function) - describes the response of the imaging system to a point source. In the context of - fluorescence microscopy, the PSF describes how light emitted from a point - emitter (i.e. a single fluorophore) is "spread out" or blurred in the image due - to diffraction. - - In microsim, the PSF largely be determined by the [`ObjectiveLens`][microsim.schema.ObjectiveLens] and the - [`Modality`](api.md#modality) of the simulation - -The output of this stage is a 4D array with dimensions `(C, Z, Y, X)` where the -wavelength and fluorophore dimensions have been collapsed into the channel. Note -that each channel dimension will contain the emission of *all* fluorophores that -emit in the wavelength range of that channel. This is important for simulating -"crosstalk" or "bleedthrough". Even for the case of a spectral detection system, -the `W` dimension will be removed, but there would be a channel for each wavelength -bin in the detector. - -!!! example - - This example sets up a simulation with a confocal microscope with a 1.4 NA - objective lens and a 1.2 AU pinhole. The simulation is set up to use the "491" - optical configuration from the [Example Yokogawa - Setup](https://www.fpbase.org/microscope/4yL4ggAo/?c=491) (spinning disc confocal) - microscope on FPbase. - - ```python - sim = Simulation( - # ..., - channels=["4yL4ggAo::491"], - objective_lens={ - "numerical_aperture": 1.4, - "immersion_medium_ri": 1.515, - "specimen_ri": 1.33, - }, - modality={"type": "confocal", "pinhole_au": 1.2}, - ) - ``` - -## Digital Image - -- **method**: [`Simulation.digital_image`][microsim.Simulation.digital_image] -- **value units**: gray levels -- **dimensions**: - - **`C`**: Channel (categorical unit [`OpticalConfig`][microsim.schema.OpticalConfig]) - - **`Z`**: planes along the optical axis (units length). - - **`Y`**: rows (units length). - - **`X`**: columns (units length) - -The final stage of the simulation is the conversion of the filtered optical -image into a digital image. This stage includes the addition of noise -([photon or shot noise](https://en.wikipedia.org/wiki/Shot_noise) due to the -discrete nature of photons, [read -noise](https://hamamatsu.magnet.fsu.edu/articles/ccdsnr.html) due to imprecision -in the detector, and other sources) as well as downsampling to the final image -resolution. - -The output of this stage is a 4D array with dimensions `(C, Z, Y, X)` where the -values are in units of gray levels (integers). The values in the digital image -will depend on the settings of the `detector` and the `output_space`. - -!!! example - - This example sets up a simulation with a 16-bit CCD detector with a read noise of - 2 electrons rms, and a quantum efficiency of 0.82. Note that, for now, - the "pixel size" is implicitly determined by the `output_space` parameter. - But that will change in the future. - - ```python - sim = Simulation( - # ..., - output_space={"downscale": 4}, - detector={"camera_type": "CCD", "bit_depth": 16, "read_noise": 2, "qe": 0.82}, - ) - ``` - -!!! note "TODO" - - We don't yet model pixel-specific characteristics of sCMOS cameras. Ideally, - we will generate a random "instance" of an sCMOS camera with noise, gain, - and offset distribution pulled from a typical CMOS. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index e91bd595..5c22240d 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,69 +1,133 @@ -.md-typeset .admonition, -.md-typeset details { - border-width: 0; - border-left-width: 4px; +/* --------------------------------------------------------------------------- + Colors: a fluorescein-ish green on neutral surfaces +--------------------------------------------------------------------------- */ + +:root, +[data-md-color-scheme="default"] { + --md-primary-fg-color: #12805a; + --md-primary-fg-color--light: #1baf7a; + --md-primary-fg-color--dark: #0b5c40; + --md-accent-fg-color: #0f9a69; + --md-accent-fg-color--transparent: #0f9a691a; + --ms-figure-border: #e4e3df; + --ms-muted: #6b6a65; } -.md-typeset h1, .md-typeset h2 { - font-weight: 400; +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #3ccf95; + --md-primary-fg-color--light: #6fe0b2; + --md-primary-fg-color--dark: #1baf7a; + --md-accent-fg-color: #6fe0b2; + --md-accent-fg-color--transparent: #6fe0b21a; + --md-typeset-a-color: #4fd8a0; + --ms-figure-border: #2e2e2c; + --ms-muted: #a3a29a; } -.md-typeset .doc-children { - font-size: 0.7rem; +/* --------------------------------------------------------------------------- + Typography +--------------------------------------------------------------------------- */ + +.md-typeset h1, +.md-typeset h2 { + font-weight: 500; } -/* Wrap long functions */ -.md-typeset { - overflow-x: auto; - column-width: 1800px; +.md-typeset .lead { + font-size: 1.05rem; + line-height: 1.6; + color: var(--md-default-fg-color--light); } -/* name of a method */ -code.highlight span.n:first-child { - color: red; +/* --------------------------------------------------------------------------- + Figures +--------------------------------------------------------------------------- */ + +.md-typeset figure { + margin: 1.5em auto; } -code.highlight span.n:not(:first-child) { - color: #666; + +.md-typeset figure img { + border-radius: 6px; } -/* Indentation. */ -div.doc-contents:not(.first) { - padding-left: 25px; - border-left: 0.05rem solid var(--md-typeset-table-color); +.md-typeset figure figcaption { + max-width: 44rem; + margin: 0.6em auto 0; + font-size: 0.68rem; + line-height: 1.5; + color: var(--ms-muted); + text-align: left; } -/* Mark external links as such. */ -a.autorefs-external::after { - /* https://primer.style/octicons/arrow-up-right-24 */ - background-image: url('data:image/svg+xml,'); - content: " "; +/* matplotlib output with a white background (tutorial pages) */ +.md-typeset img.plot { + background: #fff; + border: 1px solid var(--ms-figure-border); + border-radius: 6px; + padding: 4px; +} - display: inline-block; - position: relative; - top: 0.1em; - margin-left: 0.2em; - margin-right: 0.1em; +/* --------------------------------------------------------------------------- + Home page hero +--------------------------------------------------------------------------- */ + +.ms-hero { + margin: 0 0 1.5rem; +} - height: 1em; - width: 1em; - border-radius: 100%; - background-color: var(--md-typeset-a-color); +.ms-hero img { + width: 100%; + border-radius: 8px; + display: block; } -a.autorefs-external:hover::after { - background-color: var(--md-accent-fg-color); + +.ms-hero p.caption { + font-size: 0.66rem; + color: var(--ms-muted); + margin-top: 0.4em; } +/* stage labels in the pipeline overview */ +.md-typeset .ms-stage { + display: inline-block; + min-width: 1.5em; + padding: 0 0.45em; + margin-right: 0.3em; + border-radius: 1em; + font-size: 0.75em; + font-weight: 600; + text-align: center; + color: var(--md-primary-bg-color); + background: var(--md-primary-fg-color); +} + +/* --------------------------------------------------------------------------- + API reference (mkdocstrings) +--------------------------------------------------------------------------- */ + +.md-typeset .doc-heading code { + font-weight: 600; +} + +.md-typeset .doc-contents:not(.first) { + padding-left: 1rem; + border-left: 0.05rem solid var(--md-typeset-table-color); +} -/* Remove In [] from jupyter notebook */ -.jupyter-wrapper .jp-CodeCell .jp-Cell-inputWrapper .jp-InputPrompt, -.jupyter-wrapper .jp-CodeCell .jp-Cell-outputWrapper .jp-OutputPrompt { - display: none !important; +.md-typeset .doc-section-title { + font-weight: 600; } -.jupyter-wrapper .jp-CodeCell .jp-Cell-outputWrapper .jp-RenderedText[data-mime-type="application/vnd.jupyter.stderr"] { - display: none !important; +/* field tables: keep the "default" column compact */ +.md-typeset .doc-param-default code { + white-space: nowrap; } -.jupyter-wrapper .jp-Cell-inputWrapper { - padding-bottom: 1rem !important; +/* external links get a small arrow */ +.md-typeset a.autorefs-external::after { + content: "↗"; + font-size: 0.75em; + margin-left: 0.1em; + opacity: 0.6; } diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md new file mode 100644 index 00000000..0591ba78 --- /dev/null +++ b/docs/tutorials/index.md @@ -0,0 +1,37 @@ +--- +icon: lucide/graduation-cap +--- + +# Tutorials + +Longer worked examples. Each one builds a complete simulation and explains the +choices along the way. + +
+ +- :lucide-microscope:{ .lg .middle } **[Two-color imaging of a cell](two-color-cell.md)** + + --- + + Realistic ground truth from OpenOrganelle, fluorophores and filter sets + from FPbase, and every step of the photon budget plotted. Widefield, then + confocal. + +- :lucide-shapes:{ .lg .middle } **[Using your own ground truth](your-own-ground-truth.md)** + + --- + + Simulate any 3D array of fluorophore counts, in widefield and confocal. + +- :lucide-scan-line:{ .lg .middle } **[Point-scanning detectors](point-scanning-detectors.md)** + + --- + + PMT high voltage and clipping, photon counting with a HyD, and line + averaging, on one confocal image. + +
+ +More examples are in the +[`examples/`](https://github.com/tlambert03/microsim/tree/main/examples) +folder of the repository. diff --git a/docs/tutorials/point-scanning-detectors.md b/docs/tutorials/point-scanning-detectors.md new file mode 100644 index 00000000..26e5d7e4 --- /dev/null +++ b/docs/tutorials/point-scanning-detectors.md @@ -0,0 +1,91 @@ +--- +icon: lucide/scan-line +--- + +# Point-scanning detectors + +This tutorial compares a photomultiplier tube (PMT) and a hybrid detector (HyD) +on the same confocal image. It shows how the PMT high voltage trades signal +against clipping, and how photon counting and line averaging affect noise. +The complete script is +[`examples/point_detectors.py`](https://github.com/tlambert03/microsim/blob/main/examples/point_detectors.py). + +## The confocal simulation + +A single confocal plane of random lines labeled with EGFP, with 80 nm pixels: + +```python +from microsim import schema as ms +from microsim.schema.detectors import lib +from microsim.schema.optical_config.lib import FITC + +sim = ms.Simulation( + truth_space={"upscale": 4}, + output_space=ms.ShapeScaleSpace(shape=(1, 128, 128), scale=(0.16, 0.08, 0.08)), + sample=[ + ms.FluorophoreDistribution( + distribution=ms.MatsLines(density=0.5, length=30, azimuth=5, max_r=1), + fluorophore="EGFP", + ) + ], + channels=[FITC.model_copy(update={"power": 2e4})], # (1)! + modality=ms.Confocal(pinhole_au=1), + settings=ms.Settings(random_seed=100, max_psf_radius_aus=8), + detector=lib.HYD_SP8, # (2)! +) +optical_image = sim.optical_image() +``` + +1. 20 kW/cm² at the focus, about 25 µW in a diffraction-limited spot. The + brightest pixels receive tens of millions of photons per second. EGFP is + only slightly saturated at this irradiance (\(k\tau \approx 0.03\)). +2. The detector's QE is applied when the optical image is computed. All the + detectors compared below have GaAsP photocathodes with nearly identical QE + spectra, so one optical image serves for all of them. + +## Four detector settings + +`digital_image` accepts an existing optical image and an exposure time, so +each detector only repeats the last stage. For a point scanner, `exposure_ms` +is the pixel dwell time; here 1 µs. + +```python +detectors = { + "GaAsP PMT, 0.7 V": lib.PMT_GAASP.model_copy(update={"hv": 0.7}), + "GaAsP PMT, 0.9 V": lib.PMT_GAASP.model_copy(update={"hv": 0.9}), + "HyD (counting)": lib.HYD_SP8, + "HyD, 4× averaging": lib.HYD_SP8.model_copy(update={"averaging": 4}), +} + +for name, detector in detectors.items(): + sim.detector = detector + img = sim.digital_image(optical_image, exposure_ms=0.001) +``` + +![PMT and HyD images of the same confocal optical image](../images/generated/point-detectors.png) + +What to notice: + +- **PMT high voltage.** `hv` is the control voltage of a Hamamatsu + photosensor module, 0.5–0.9 V. At 0.7 V the brightest pixels stay below the + 12-bit maximum of 4095. At 0.9 V the gain is about 5 times higher, and + many pixels clip. Raising the HV barely changes the signal-to-noise ratio + of the photon signal, which is set by the number of photoelectrons; it + scales the signal up relative to the amplifier noise and the digitization + step. +- **Photon counting.** The HyD reports photon counts directly (the maximum + here is a few dozen), with no gain noise and no read noise. For the same + photons its images are less noisy than the PMT's, mainly because the PMT's + dynode gain is random (excess noise factor about 1.3). +- **Averaging.** With `averaging=4`, each line is scanned four times and the + counts are averaged. The brightness stays the same, and the noise falls by + about a factor of 2. + +## Going further + +The [`PMT`][microsim.schema.PMT] model also includes output-current +saturation (`max_output_current_ua`, set in the library presets) and the +amplifier's time constant (`time_constant_us`), which smears signal along the +scan direction at short dwell times. The [`HyD`][microsim.schema.HyD] model +includes count losses from dead time. See [Detectors](../guide/detectors.md) +for the details. diff --git a/docs/tutorials/two-color-cell.md b/docs/tutorials/two-color-cell.md new file mode 100644 index 00000000..156d3175 --- /dev/null +++ b/docs/tutorials/two-color-cell.md @@ -0,0 +1,310 @@ +--- +icon: lucide/microscope +--- + +# Two-color imaging of a cell + +This tutorial simulates a widefield image of a HeLa cell with the endoplasmic +reticulum (ER) labeled with EGFP and the mitochondria labeled with mCherry. +Along the way it plots each step of the photon budget, so you can see where +the signal and the crosstalk in each channel come from. + +It needs the `cosem` and `view` extras (both in `microsim[all]`) and network +access, to download the ground truth from OpenOrganelle and spectra from +FPbase. The complete notebook is +[`examples/notebooks/tutorial.ipynb`](https://github.com/tlambert03/microsim/blob/main/examples/notebooks/tutorial.ipynb). + +## A first sample + +The simplest sample is a set of random lines in a 64 × 256 × 256 volume of +10 nm voxels: + +```python +from microsim import schema as ms +from microsim.util import ortho_plot + +sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(64, 256, 256), scale=0.01), + sample=[ms.MatsLines()], +) + +ortho_plot(sim.ground_truth(), mip=True) +``` + +![Maximum projections of random lines](../images/generated/tutorial/tut-01.png){ .plot } + +[`ortho_plot`][microsim.util.ortho_plot] shows XY, XZ, and YZ views, either +through the center of the volume or (with `mip=True`) as maximum projections. + +## Realistic ground truth from OpenOrganelle + +[OpenOrganelle](https://openorganelle.janelia.org) publishes FIB-SEM volumes +of whole cells, with organelles segmented at 4–8 nm resolution. +[`CosemLabel`][microsim.schema.CosemLabel] turns a segmentation layer into a +ground truth. Here we use the ER membrane and mitochondrial membrane +predictions from cell `jrc_hela-3`, on a 64 nm grid: + +```python +sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(64, 512, 512), scale=(0.064, 0.064, 0.064)), + sample=[ + ms.CosemLabel(dataset="jrc_hela-3", label="er-mem_pred"), + ms.CosemLabel(dataset="jrc_hela-3", label="mito-mem_pred"), + ], +) + +ortho_plot(sim.ground_truth(), z=18) +``` + +![The ER and mitochondria ground truth, one plane](../images/generated/tutorial/tut-02.png){ .plot } + +Or as maximum projections: + +```python +ortho_plot(sim.ground_truth(), mip=True) +``` + +![The ER and mitochondria ground truth, maximum projection](../images/generated/tutorial/tut-03.png){ .plot } + +### The optical image + +With no other configuration, the simulation uses a widefield microscope with a +1.4 NA objective and a FITC filter set: + +```python +ortho_plot(sim.optical_image(), z=18) +``` + +![The widefield optical image, with both labels in one channel](../images/generated/tutorial/tut-04.png){ .plot } + +This looks like a widefield image of a cell, but the ER and mitochondria +appear together in the one channel. Neither label has a fluorophore, so both +use the placeholder fluorophore, which is excited by any light and passes any +filter. + +## Fluorophores and channels + +Next we give each label a fluorophore and define two channels. The +fluorophore properties come from [FPbase](https://www.fpbase.org), and so do +the two channels: the "Widefield Green" and "Widefield Red" configurations of +the [Example Simple Widefield](https://www.fpbase.org/microscope/wKqWbg/) +microscope, which use an LED light source and Semrock filters. + +```python +sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(64, 512, 512), scale=(0.064, 0.064, 0.064)), + sample=[ + ms.FluorophoreDistribution( + distribution=ms.CosemLabel(dataset="jrc_hela-3", label="er-mem_pred"), + fluorophore="EGFP", + ), + ms.FluorophoreDistribution( + distribution=ms.CosemLabel(dataset="jrc_hela-3", label="mito-mem_pred"), + fluorophore="mCherry", + ), + ], + channels=[ + ms.OpticalConfig.from_fpbase( + microscope_id="wKqWbg", config_name="Widefield Green" + ), + ms.OpticalConfig.from_fpbase( + microscope_id="wKqWbg", config_name="Widefield Red" + ), + ], +) +``` + +How many photons a fluorophore contributes to a channel depends on two +overlaps: between its absorption spectrum and the excitation light, and +between its emission spectrum and the emission filters and detector. The next +sections follow the photons through both. The +[Photon budget](../guide/photometry.md) page has the equations. + +### Absorption + +```python +from matplotlib import pyplot as plt + +plt.figure(figsize=(12, 4)) +for lbl in sim.sample.labels: + fluor = lbl.fluorophore + fluor.absorption_cross_section.plot.line(label=fluor.name) +plt.legend() +``` + +![Absorption cross sections of EGFP and mCherry](../images/generated/tutorial/tut-05.png){ .plot } + +The excitation spectra from FPbase, scaled by each fluorophore's extinction +coefficient and converted to an absorption cross section in cm² per molecule. + +### Excitation light + +```python +fig, axes = plt.subplots(1, len(sim.channels), figsize=(12, 3)) +for ax, oc in zip(axes, sim.channels): + oc.plot_excitation(ax=ax) +``` + +![The excitation path of each channel](../images/generated/tutorial/tut-06.png){ .plot } + +The colored lines are the LED spectrum and the excitation-side filters +(right-hand scale, relative). The black line is the photon flux reaching the +sample (left-hand scale, photons/cm²/s). The channel's `power` is not set, so +the total irradiance is the default 1 W/cm². Change it with +`oc.power = `. + +### Absorption rate + +The product of cross section and photon flux is the absorption rate: photons +absorbed per second, per molecule. + +```python +fig, axes = plt.subplots(1, len(sim.channels), figsize=(12, 3)) +for ax, oc in zip(axes, sim.channels): + for lbl in sim.sample.labels: + abs_rate = oc.absorption_rate(lbl.fluorophore) + abs_rate.plot( + ax=ax, label=f"{lbl.fluorophore.name} ({abs_rate.sum():.0f} photons/s)" + ) + ax.set_xlim(400, 700) + ax.legend(loc="center right") + ax.set_title(oc.name) +``` + +![Absorption rates of each fluorophore in each channel](../images/generated/tutorial/tut-07.png){ .plot } + +EGFP is excited mostly by the green channel and mCherry by the red one. The +green channel also excites mCherry a little (21 vs. 530 photons/s): this is +**cross-excitation**, one source of crosstalk. + +### Emission + +Each absorbed photon is re-emitted with a probability equal to the quantum +yield, with wavelengths distributed according to the emission spectrum: + +```python +fig, axes = plt.subplots(1, len(sim.channels), figsize=(12, 3)) +for ax, oc in zip(axes, sim.channels): + for lbl in sim.sample.labels: + fluor = lbl.fluorophore + em_rate = oc.total_emission_rate(fluor) + em_rate.plot( + ax=ax, + label=f"{fluor.name} (QY: {fluor.quantum_yield}) ({em_rate.sum():.0f} photons/s)", + ) + ax.set_xlim(400, 800) + ax.legend(fontsize=8) + ax.set_title(oc.name) +``` + +![Emission rates of each fluorophore in each channel](../images/generated/tutorial/tut-08.png){ .plot } + +### Emission path + +The emitted light then passes the dichroic and emission filter. Their combined +transmission (black) selects the light that reaches the detector: + +```python +fig, axes = plt.subplots(1, len(sim.channels), figsize=(12, 3)) +for ax, oc in zip(axes, sim.channels): + oc.plot_emission(ax=ax) +``` + +![The emission path of each channel](../images/generated/tutorial/tut-09.png){ .plot } + +### Detected photons + +Multiplying the emission rate by the transmission of the emission path gives +the photons per second that pass the filters, for each fluorophore in each +channel: + +```python +fig, axes = plt.subplots(1, len(sim.channels), figsize=(12, 3)) +for ax, oc in zip(axes, sim.channels): + for lbl in sim.sample.labels: + fluor = lbl.fluorophore + oc.total_emission_rate(fluor).plot(ax=ax, color="k", alpha=0.1) + filtered = oc.filtered_emission_rate(fluor) + filtered.plot(ax=ax, label=f"{fluor.name} ({filtered.sum():.0f} photons/s)") + ax.set_xlim(400, 800) + ax.legend(fontsize=8) + ax.set_title(oc.name) +``` + +![Photons passing the emission filters](../images/generated/tutorial/tut-10.png){ .plot } + +!!! info "Collection efficiency" + + These per-channel plots assume that every emitted photon reaches the + filters. In the simulation, the rates are also multiplied by the fraction + of light the objective collects, about 35% for this 1.4 NA objective, and + by the detector's QE. `sim.filtered_emission_rates()` returns the rates with + both applied. See [Photon budget](../guide/photometry.md#the-details). + +### The optical image, again + +Now each fluorophore's light is assigned to the channels in proportion to the +rates above: + +```python +ortho_plot(sim.optical_image(), z=18) +``` + +![Two-channel optical image](../images/generated/tutorial/tut-11.png){ .plot } + +## Detector and pixels + +To get a digital image we add a detector, which determines the noise, and an +output space, which sets the pixel size. Here, the +[`ICX285`](../guide/detectors.md#detector-library) CCD and pixels twice the +size of the truth voxels (128 nm): + +```python +sim.detector = ms.detectors.lib.ICX285 +sim.output_space = ms.DownscaledSpace(downscale=2) + +ortho_plot(sim.digital_image(exposure_ms=100), z=9) +``` + +![Digital image, 100 ms exposure](../images/generated/tutorial/tut-12.png){ .plot } + +With a ten times shorter exposure, there are ten times fewer photons, and the +shot noise and read noise are more visible: + +```python +ortho_plot(sim.digital_image(exposure_ms=10), z=9) +``` + +![Digital image, 10 ms exposure](../images/generated/tutorial/tut-13.png){ .plot } + +Raising the illumination to 10 W/cm² brings the signal back: + +```python +sim.channels[0].power = 10 +sim.channels[1].power = 10 +ortho_plot(sim.digital_image(exposure_ms=10), z=9) +``` + +![Digital image, 10 ms exposure, 10 W/cm²](../images/generated/tutorial/tut-14.png){ .plot } + +## Confocal + +Changing the modality to [`Confocal`][microsim.schema.Confocal] removes most of +the out-of-focus light. For a point-scanning modality, `power` is the peak +irradiance at the focus of the laser spot and `exposure_ms` is the dwell time +per pixel, so both are set to typical confocal values: + +```python +sim.modality = ms.Confocal(pinhole_au=1) +sim.channels[0].power = 5_000 # W/cm² at the focus +sim.channels[1].power = 5_000 + +ortho_plot(sim.digital_image(exposure_ms=0.01), z=9) # 10 µs pixel dwell +``` + +![Confocal digital image](../images/generated/tutorial/tut-15.png){ .plot } + +The channels still use the LED spectrum from the widefield configuration; +only the irradiance and the PSF changed. A CCD camera is also not what a +point-scanning confocal would use; see +[point-scanning detectors](point-scanning-detectors.md). diff --git a/docs/tutorials/your-own-ground-truth.md b/docs/tutorials/your-own-ground-truth.md new file mode 100644 index 00000000..13e95864 --- /dev/null +++ b/docs/tutorials/your-own-ground-truth.md @@ -0,0 +1,101 @@ +--- +icon: lucide/shapes +--- + +# Using your own ground truth + +If you already have a 3D array that describes your sample (a segmentation, a +mesh you voxelized, or shapes you generated), you can simulate it directly. + +## Make an array + +The array holds the number of fluorophores in each voxel. Here: a dozen +hollow spherical shells with random centers and radii, ten fluorophores per +voxel of shell. + +```python +import numpy as np + +shape = (64, 256, 256) # Z, Y, X +z, y, x = np.ogrid[: shape[0], : shape[1], : shape[2]] +rng = np.random.default_rng(0) + +truth = np.zeros(shape, dtype=np.float32) +for _ in range(12): + cz, cy, cx = rng.integers(16, 48), rng.integers(30, 226), rng.integers(30, 226) + r = rng.integers(8, 28) + # distance from the center; Z voxels are twice as large as Y and X + d = np.sqrt(((z - cz) * 2) ** 2 + (y - cy) ** 2 + (x - cx) ** 2) + truth += (np.abs(d - r) < 1.5) * 10 +``` + +## Simulate it + +[`Simulation.from_ground_truth`][microsim.Simulation.from_ground_truth] takes +the array and its voxel size in µm, plus any other simulation parameters: + +```python +from microsim import schema as ms +from microsim.schema.optical_config import lib + +common = dict( + scale=(0.04, 0.02, 0.02), # voxel size of the array, in µm + output_space={"downscale": 4}, # 160 x 80 x 80 nm pixels + detector=ms.CameraCMOS(qe=0.8, read_noise=1.5), + settings=ms.Settings(random_seed=0), +) + +widefield = ms.Simulation.from_ground_truth(truth, **common).run() + +confocal = ms.Simulation.from_ground_truth( + truth, + modality=ms.Confocal(), + channels=[lib.FITC.model_copy(update={"power": 5000})], # W/cm² at focus + exposure_ms=0.01, # 10 µs dwell + **common, +).run() +``` + +![The array, and its simulated widefield and confocal images](../images/generated/own-truth.png) +/// caption +A plane through the array (left), and the same plane in the widefield (center) +and confocal (right) digital images. Each panel is scaled to its own range. +/// + +The widefield image shows rings from shells centered above and below this +plane as blurred discs. The confocal image rejects most of that light. + +## Choosing a fluorophore + +`from_ground_truth` uses the placeholder fluorophore. To use a real one, or to +combine several arrays as separate labels, use a `"fixed-array"` +distribution: + +```python +sim = ms.Simulation( + truth_space={"shape": truth.shape, "scale": (0.04, 0.02, 0.02)}, + sample=[ + { + "distribution": {"type": "fixed-array", "array": truth}, + "fluorophore": "mScarlet", + } + ], + channels=[lib.TRITC], +) +``` + +Every array must have the shape of the truth space. + +## Things to keep in mind + +- **Values are fluorophore counts.** A binary mask gives one fluorophore per + voxel, which is usually very dim. Scale the array to a realistic labeling + density (see [Concentration](../guide/samples.md#concentration)). +- **Voxel size.** The array should be sampled finely enough for the optics: + 50 nm or less laterally for a high-NA objective. If your data are coarser, + upsample them first. +- **Anisotropy.** `scale` can differ between axes, as here, where Z voxels + are twice as large. +- **Identity modality.** If your array is already a microscope image and you + only want the spectral and detector parts of the simulation, use + `modality=ms.Identity()`, which skips the PSF. diff --git a/docs/tutorial.ipynb b/examples/notebooks/tutorial.ipynb similarity index 99% rename from docs/tutorial.ipynb rename to examples/notebooks/tutorial.ipynb index 282f2e42..68600070 100644 --- a/docs/tutorial.ipynb +++ b/examples/notebooks/tutorial.ipynb @@ -265,7 +265,7 @@ "\n", "## Fluorophores, Spectra, and Optical Configurations\n", "\n", - "Here we'll turn the three `CosemLabel` objects into `FluorophoreDistribution`\n", + "Here we'll turn the two `CosemLabel` objects into `FluorophoreDistribution`\n", "objects, each with a specified `fluorophore` (the information for each\n", "fluorophore is pulled from [FPbase](https://fpbase.org)). We'll also specify\n", "the `channels` in the simulation, which is a list of `OpticalConfiguration`\n", @@ -759,14 +759,12 @@ "id": "2ace7a7d", "metadata": {}, "source": [ - "

Don't forget the optics

\n", + "

Collection efficiency

\n", "\n", - "Note that this is not the end of the story!\n", - "\n", - "The math so far assumes we collect photons emitted in every direction. In\n", - "reality, the number of photons detected is additionally affected by numerical\n", - "aperture of the detector, transmission efficiency of the optics, and other\n", - "factors. We may deal with those later.\n", + "These per-channel plots assume that every emitted photon reaches the filters.\n", + "In the simulation, the rates are also multiplied by the fraction of light the\n", + "objective collects (about 35% for a 1.4 NA objective) and by the detector QE.\n", + "`sim.filtered_emission_rates()` returns the rates with both applied.\n", "

\n", "\n", "### Optical Image, Revisited\n", diff --git a/includes/abbreviations.md b/includes/abbreviations.md new file mode 100644 index 00000000..e29834db --- /dev/null +++ b/includes/abbreviations.md @@ -0,0 +1,20 @@ +*[PSF]: Point spread function: the image of a single point emitter +*[AU]: Airy unit: the diameter of the first dark ring of the Airy disk, 1.22 λ / NA +*[NA]: Numerical aperture of the objective lens, n·sin(θ) +*[QE]: Quantum efficiency: the probability that a photon creates a photoelectron +*[QY]: Quantum yield: the fraction of absorbed photons that are re-emitted as fluorescence +*[ADU]: Analog-to-digital unit, also called a gray value +*[ADC]: Analog-to-digital converter +*[CCD]: Charge-coupled device camera +*[EMCCD]: Electron-multiplying charge-coupled device camera +*[sCMOS]: Scientific complementary metal-oxide-semiconductor camera +*[CMOS]: Complementary metal-oxide-semiconductor camera +*[PMT]: Photomultiplier tube +*[HyD]: Hybrid photodetector (photocathode + avalanche diode), e.g. Leica HyD +*[GaAsP]: Gallium arsenide phosphide, a high-QE photocathode material +*[HV]: High voltage, which sets the gain of a PMT +*[CIC]: Clock-induced charge +*[ENF]: Excess noise factor +*[FPbase]: The fluorescent protein database, fpbase.org +*[COSEM]: Cell Organelle Segmentation in Electron Microscopy (Janelia) +*[FIB-SEM]: Focused ion beam scanning electron microscopy diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index b2765d3e..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,76 +0,0 @@ -site_name: microsim -site_url: https://tlambert03.github.io/microsim -site_description: Pure python extensions of the C++ micro-manager core -# strict: true - -repo_name: tlambert03/microsim -repo_url: https://github.com/tlambert03/microsim -edit_uri: edit/main/docs/ - -watch: - - src/microsim - - -theme: - name: material - icon: - logo: fontawesome/solid/microscope - repo: fontawesome/brands/github - features: - - content.tabs.link - - content.code.annotate - - content.code.copy - - navigation.footer # next/previous links in footer - -markdown_extensions: - - tables - - pymdownx.snippets # lets you include code snippets from other files - - toc: - permalink: "#" - - admonition - - pymdownx.highlight - - pymdownx.extra - - pymdownx.details - - pymdownx.superfences - - pymdownx.tilde - - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg - - pymdownx.tabbed: - alternate_style: true - - pymdownx.arithmatex: - generic: true - - -# mkdocs.yml -plugins: - - mkdocstrings: - handlers: - python: - import: - - https://docs.python.org/3/objects.inv - - https://numpy.org/doc/stable/objects.inv - - https://pymmcore-plus.github.io/useq-schema/objects.inv - options: - extensions: - - griffe_fieldz - show_root_heading: true # default is false - heading_level: 2 # default is 1 - docstring_style: "numpy" - show_source: false # default is true - - mkdocs-jupyter: - remove_tag_config: - # https://nbconvert.readthedocs.io/en/latest/removing_cells.html - remove_all_outputs_tags: [remove_output] - remove_input_tags: [remove_input] - remove_cell_tags: [remove_cell] - # execute: true - include_requirejs: true - custom_mathjax_url: "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/latest.js?config=TeX-AMS_CHTML-full,Safe" - -extra_css: - - stylesheets/extra.css - -extra_javascript: - - javascripts/mathjax.js - - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js diff --git a/pyproject.toml b/pyproject.toml index 86340dd3..bc1badd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,14 +106,11 @@ dev = [ "boto3-stubs[s3]>=1.43.34", ] docs = [ - "mkdocs >=1.5.3", - "mkdocs-material==9.5.17", - "mkdocs-autorefs==1.1.0", - "mkdocstrings ==0.24.3", - "mkdocstrings-python ==1.9.2", - "mkdocs-jupyter==0.24.8", - "griffe==0.45.3", - "griffe-fieldz==0.1.2", # 0.5.0 needs a newer griffe than the pin above + "zensical>=0.0.69", + "mkdocstrings-python>=2.0.9", + "griffe-fieldz>=0.5.0", + "markdown-exec[ansi]>=1.12.4", + "matplotlib>=3.10.7", ] [tool.uv] diff --git a/scripts/make_doc_figures.py b/scripts/make_doc_figures.py new file mode 100644 index 00000000..ec11ba1d --- /dev/null +++ b/scripts/make_doc_figures.py @@ -0,0 +1,552 @@ +"""Generate the figures used in the documentation. + +Every figure is produced by microsim itself, so the docs show what the code does. +Output goes to `docs/images/generated/`. Line plots are written twice, with +`-light` and `-dark` suffixes, for the two color schemes of the docs. + +Usage (some figures fetch data from FPbase and OpenOrganelle, so need network): + + uv run --group docs python scripts/make_doc_figures.py [figure ...] +""" + +from __future__ import annotations + +# ruff: noqa: RUF001 +import os +import re +import sys +import warnings +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np + +from microsim import schema as ms +from microsim.psf import make_psf +from microsim.schema.detectors import lib as det_lib +from microsim.schema.optical_config import lib as oc_lib + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + +mpl.use("Agg") +warnings.filterwarnings("ignore") + +DOCS = Path(__file__).parents[1] / "docs" +OUT = DOCS / "images" / "generated" +FIGURES: dict[str, Callable[[], None]] = {} + +# categorical palette (fixed order), and text inks, for light and dark schemes +THEMES = { + "light": { + "series": ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4"], + "text": "#0b0b0b", + "muted": "#52514e", + "grid": "#e4e3df", + "fill": "#8a8984", + }, + "dark": { + "series": ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181"], + "text": "#f0efec", + "muted": "#c3c2b7", + "grid": "#3a3a38", + "fill": "#9a998f", + }, +} +# image panels are dark in both schemes, so their labels use a neutral gray +IMG_LABEL = "#9a998f" + + +def figure(func: Callable[[], None]) -> Callable[[], None]: + FIGURES[func.__name__] = func + return func + + +def themed(name: str) -> Iterator[dict]: + """Yield a palette per color scheme, with matching rcParams active.""" + for scheme, t in THEMES.items(): + rc = { + "font.family": "sans-serif", + "font.size": 10, + "text.color": t["text"], + "axes.labelcolor": t["muted"], + "axes.edgecolor": t["grid"], + "axes.facecolor": "none", + "figure.facecolor": "none", + "savefig.facecolor": "none", + "xtick.color": t["muted"], + "ytick.color": t["muted"], + "axes.spines.top": False, + "axes.spines.right": False, + "axes.grid": True, + "grid.color": t["grid"], + "grid.linewidth": 0.6, + "lines.linewidth": 2, + "legend.frameon": False, + "legend.labelcolor": t["text"], + } + with mpl.rc_context(rc): + yield {**t, "path": OUT / f"{name}-{scheme}.png"} + + +def save(fig: plt.Figure, path: Path) -> None: + fig.savefig(path, dpi=160, bbox_inches="tight", transparent=True) + plt.close(fig) + print("wrote", path.relative_to(OUT.parents[2])) + + +def norm(img: np.ndarray, lo: float = 0.1, hi: float = 99.9) -> np.ndarray: + a, b = np.percentile(img, (lo, hi)) + return np.clip((img - a) / (b - a + 1e-12), 0, 1) + + +def composite(channels: list[np.ndarray], colors: list[str]) -> np.ndarray: + """Additively combine normalized grayscale channels into an RGB image.""" + rgb = np.zeros((*channels[0].shape, 3)) + for img, color in zip(channels, colors, strict=False): + rgb += norm(img)[..., None] * np.array(mpl.colors.to_rgb(color)) + return np.clip(rgb, 0, 1) + + +def image_axes(ax: plt.Axes, title: str = "") -> None: + ax.set_xticks([]) + ax.set_yticks([]) + for s in ax.spines.values(): + s.set_visible(False) + if title: + ax.set_title(title, color=IMG_LABEL, fontsize=10, loc="left") + + +def _np(x: object) -> np.ndarray: + data = getattr(x, "data", x) + if hasattr(data, "get"): + data = data.get() + return np.asarray(data) + + +# --------------------------------------------------------------------------- +# Figures + + +def _cell_sim() -> ms.Simulation: + return ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(64, 512, 512), scale=0.064), + output_space={"downscale": 2}, + sample=[ + ms.FluorophoreDistribution( + distribution=ms.CosemLabel(dataset="jrc_hela-3", label="er-mem_pred"), + fluorophore="EGFP", + ), + ms.FluorophoreDistribution( + distribution=ms.CosemLabel(dataset="jrc_hela-3", label="mito-mem_pred"), + fluorophore="mCherry", + ), + ], + channels=[oc_lib.FITC, oc_lib.TRITC], + detector=det_lib.ICX285, + exposure_ms=5, + settings=ms.Settings(random_seed=0), + ) + + +@figure +def pipeline() -> None: + """Ground truth -> optical image -> digital image, for one z-plane.""" + sim = _cell_sim() + z = 18 + truth = _np(sim.ground_truth())[:, z] + optical = sim.optical_image() + digital = _np(sim.digital_image(optical))[:, z // 2] + optical = _np(optical)[:, z] + + colors = ["#33ff66", "#ff33cc"] + panels = [ + ("1 ground truth (fluorophores / voxel)", composite(list(truth), colors)), + ("2 optical image (photons / s)", composite(list(optical), colors)), + ("3 digital image (gray values)", composite(list(digital), colors)), + ] + fig, axes = plt.subplots(1, 3, figsize=(12, 4.3)) + for ax, (title, img) in zip(axes, panels, strict=True): + ax.imshow(img, interpolation="nearest") + image_axes(ax, title) + fig.subplots_adjust(wspace=0.03) + save(fig, OUT / "pipeline.png") + + # zoomed crop, showing pixelation in the output space + fig, axes = plt.subplots(1, 2, figsize=(8, 4.3)) + crop = np.s_[200:280, 330:410] + crop2 = np.s_[100:140, 165:205] + axes[0].imshow(panels[1][1][crop], interpolation="nearest") + image_axes(axes[0], "optical image, truth space (64 nm voxels)") + axes[1].imshow(panels[2][1][crop2], interpolation="nearest") + image_axes(axes[1], "digital image, output space (128 nm pixels)") + fig.subplots_adjust(wspace=0.03) + save(fig, OUT / "spaces-crop.png") + + +@figure +def spectra() -> None: + """EGFP spectra and the FITC filter set.""" + egfp = ms.Fluorophore.from_fpbase("EGFP") + fitc = oc_lib.FITC + ex = egfp.excitation_spectrum + em = egfp.emission_spectrum + for t in themed("spectra-egfp-fitc"): + fig, (ax1, ax2) = plt.subplots( + 2, 1, figsize=(8, 4.6), sharex=True, gridspec_kw={"hspace": 0.15} + ) + s = t["series"] + ax1.plot(ex.wavelength, ex.intensity / ex.intensity.max(), color=s[0]) + ax1.plot(em.wavelength, em.intensity / em.intensity.max(), color=s[2]) + ax1.text(445, 0.82, "EGFP excitation", color=t["text"], ha="right") + ax1.text(545, 0.82, "EGFP emission", color=t["text"], ha="left") + ax1.set_ylabel("relative intensity") + ax1.set_ylim(0, 1.08) + + styles = {"EX": (s[0], "-"), "BS": (t["fill"], "--"), "EM": (s[2], "-")} + for f in fitc.filters: + color, ls = styles[f.placement.value] + sp = f.spectrum + ax2.plot(sp.wavelength, sp.intensity, color=color, linestyle=ls, lw=1.5) + if ls == "-": + ax2.fill_between(sp.wavelength, sp.intensity, color=color, alpha=0.15) + ax2.text( + 470, + 0.5, + "excitation\nfilter", + color=t["text"], + ha="center", + va="center", + fontsize=9, + ) + ax2.text( + 525, + 0.5, + "emission\nfilter", + color=t["text"], + ha="center", + va="center", + fontsize=9, + ) + ax2.text( + 560, + 0.72, + "dichroic: reflects excitation,\ntransmits emission", + color=t["muted"], + ha="left", + va="top", + fontsize=9, + ) + ax2.set_ylabel("transmission") + ax2.set_ylim(0, 1.15) + ax2.set_xlim(380, 720) + ax2.set_xlabel("wavelength (nm)") + save(fig, t["path"]) + + +@figure +def photon_budget() -> None: + """Absorption, emission, and filtered emission rates for EGFP in FITC.""" + egfp = ms.Fluorophore.from_fpbase("EGFP") + fitc = oc_lib.FITC + obj = ms.ObjectiveLens(numerical_aperture=1.4) + absr = fitc.absorption_rate(egfp).squeeze() + emr = fitc.total_emission_rate(egfp).squeeze() + filt = fitc.filtered_emission_rate(egfp).squeeze() + coll = filt * obj.collection_efficiency + for t in themed("photon-budget"): + fig, ax = plt.subplots(figsize=(8, 3.4)) + s = t["series"] + rows = [ + (absr, s[0], "absorbed"), + (emr, s[1], "emitted"), + (filt, s[2], "passes emission filters"), + (coll, s[4], "collected by a 1.4 NA objective"), + ] + for da, color, label in rows: + w = da.coords["w"].values + ax.plot(w, da.values, color=color, label=f"{label}: {float(da.sum()):.0f}") + ax.set_xlim(400, 650) + ax.set_xlabel("wavelength (nm)") + ax.set_ylabel("photons / s / nm") + ax.legend( + title="per EGFP molecule, total photons/s", + alignment="left", + fontsize=9, + title_fontsize=9, + loc="upper right", + ) + save(fig, t["path"]) + + +@figure +def psf() -> None: + """Widefield vs confocal PSFs (XZ and XY sections).""" + obj = ms.ObjectiveLens(numerical_aperture=1.4) + kw = {"nz": 65, "nx": 65, "dx": 0.04, "dz": 0.04, "objective": obj} + psfs = { + "widefield": make_psf(em_wvl_nm=525, **kw), + "confocal, 1 AU": make_psf(ex_wvl_nm=488, em_wvl_nm=525, pinhole_au=1, **kw), + "confocal, 0.25 AU": make_psf( + ex_wvl_nm=488, em_wvl_nm=525, pinhole_au=0.25, **kw + ), + } + fig, axes = plt.subplots( + 2, 3, figsize=(9, 6.2), gridspec_kw={"hspace": 0.12, "wspace": 0.05} + ) + for col, (name, p) in enumerate(psfs.items()): + p = _np(p) + p = p / p.max() + mid = p.shape[0] // 2 + kws = {"cmap": "magma", "vmin": 0, "vmax": 1, "interpolation": "bilinear"} + axes[0, col].imshow(p[mid] ** 0.5, **kws) + image_axes(axes[0, col], f"{name} · XY") + axes[1, col].imshow(p[:, mid] ** 0.5, **kws) + image_axes(axes[1, col], "XZ") + # 1 µm scale bar + axes[1, 0].plot([5, 30], [60, 60], color="white", lw=2) + axes[1, 0].text(17.5, 57, "1 µm", color="white", ha="center", fontsize=8) + save(fig, OUT / "psf-comparison.png") + + +def _lines_sim(**kwargs: object) -> ms.Simulation: + return ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(64, 256, 256), scale=(0.04, 0.02, 0.02)), + output_space={"downscale": 4}, + sample=[ms.MatsLines(density=0.4, length=30, azimuth=5, max_r=1)], + settings=ms.Settings(random_seed=1, max_psf_radius_aus=6), + **kwargs, + ) + + +@figure +def modalities() -> None: + """The same sample through widefield and confocal (XY and XZ).""" + sims = { + "widefield": _lines_sim(), + "confocal, 1 AU": _lines_sim(modality=ms.Confocal(pinhole_au=1)), + } + fig, axes = plt.subplots( + 2, 2, figsize=(8, 5.8), gridspec_kw={"height_ratios": [4, 1.3], "hspace": 0.1} + ) + for col, (name, sim) in enumerate(sims.items()): + img = _np(sim.optical_image())[0] + mid = img.shape[0] // 2 + axes[0, col].imshow(norm(img[mid]), cmap="gray") + image_axes(axes[0, col], f"{name} · XY") + xz = img[:, img.shape[1] // 2] + axes[1, col].imshow(norm(xz), cmap="gray", aspect=2) + image_axes(axes[1, col], "XZ") + save(fig, OUT / "modalities.png") + + +@figure +def detectors() -> None: + """One dim optical image through different cameras.""" + sim = _lines_sim() + optical = sim.optical_image() + # scale so the brightest output pixel expects ~20 photons in the exposure + expected = sim.digital_image(optical, with_detector_noise=False) + optical = optical * (20 / float(expected.max())) + cams = { + "expected photons (no noise)": None, + "CCD, 6 e⁻ read noise": ms.CameraCCD( + qe=1, read_noise=6, electrons_per_adu=1, bit_depth=16 + ), + "sCMOS, 1.5 e⁻ read noise": ms.CameraCMOS( + qe=1, read_noise=1.5, electrons_per_adu=0.5, bit_depth=16 + ), + "EMCCD, EM gain 300": ms.CameraEMCCD( + qe=1, read_noise=50, em_gain=300, electrons_per_adu=20, bit_depth=16 + ), + } + fig, axes = plt.subplots(1, 4, figsize=(13, 3.6), gridspec_kw={"wspace": 0.04}) + for ax, (name, det) in zip(axes, cams.items(), strict=True): + sim.detector = det + img = _np(sim.digital_image(optical, with_detector_noise=det is not None)) + ax.imshow(norm(img[0, img.shape[1] // 2], 0.5, 99.8), cmap="gray") + image_axes(ax, name) + save(fig, OUT / "detectors.png") + + +@figure +def detector_curves() -> None: + """EMCCD excess noise factor vs gain, and PMT gain vs HV.""" + gains = np.logspace(0, 3, 200) + enf = [ms.CameraEMCCD(em_gain=g).excess_noise_factor for g in gains] + hv = np.linspace(0.5, 0.9, 100) + pmt = det_lib.PMT_GAASP + pmt_gain = [pmt.model_copy(update={"hv": v}).multiplication_gain for v in hv] + pmt_enf = [pmt.model_copy(update={"hv": v}).excess_noise_factor for v in hv] + for t in themed("detector-curves"): + s = t["series"] + fig, (a1, a2, a3) = plt.subplots( + 1, 3, figsize=(11, 3.2), gridspec_kw={"wspace": 0.35} + ) + a1.semilogx(gains, enf, color=s[0]) + a1.set_xlabel("EM gain") + a1.set_ylabel("excess noise factor F") + a1.set_title("EMCCD", loc="left", color=t["text"]) + a1.set_ylim(0.9, 2.1) + a2.semilogy(hv, pmt_gain, color=s[1]) + a2.set_xlabel("control voltage (V)") + a2.set_ylabel("mean gain") + a2.set_title("GaAsP PMT: gain", loc="left", color=t["text"]) + a3.plot(hv, pmt_enf, color=s[1]) + a3.set_xlabel("control voltage (V)") + a3.set_ylabel("excess noise factor F") + a3.set_title("GaAsP PMT: noise", loc="left", color=t["text"]) + a3.set_ylim(0.9, 2.1) + save(fig, t["path"]) + + +@figure +def point_detectors() -> None: + """PMT vs HyD on a confocal simulation (as in examples/point_detectors.py).""" + sim = ms.Simulation( + truth_space={"upscale": 4}, + output_space=ms.ShapeScaleSpace(shape=(1, 128, 128), scale=(0.16, 0.08, 0.08)), + sample=[ + ms.FluorophoreDistribution( + distribution=ms.MatsLines(density=0.5, length=30, azimuth=5, max_r=1), + fluorophore="EGFP", + concentration=1, + ) + ], + channels=[oc_lib.FITC.model_copy(update={"power": 2e4})], + modality=ms.Confocal(pinhole_au=1), + settings=ms.Settings(random_seed=100, max_psf_radius_aus=8), + detector=det_lib.HYD_SP8, + ) + optical = sim.optical_image() + dets = { + "GaAsP PMT, 0.7 V": det_lib.PMT_GAASP.model_copy(update={"hv": 0.7}), + "GaAsP PMT, 0.9 V": det_lib.PMT_GAASP.model_copy(update={"hv": 0.9}), + "HyD (counting)": det_lib.HYD_SP8, + "HyD, 4× averaging": det_lib.HYD_SP8.model_copy(update={"averaging": 4}), + } + fig, axes = plt.subplots(1, 4, figsize=(13, 3.6), gridspec_kw={"wspace": 0.04}) + for ax, (name, det) in zip(axes, dets.items(), strict=True): + sim.detector = det + img = _np(sim.digital_image(optical, exposure_ms=0.001)) + ax.imshow(img[0, 0], cmap="gray") + image_axes(ax, f"{name}\nmax gray value: {int(img.max())}") + save(fig, OUT / "point-detectors.png") + + +@figure +def own_truth() -> None: + """A user-provided ground truth array, simulated in widefield and confocal.""" + shape = (64, 256, 256) + z, y, x = np.ogrid[: shape[0], : shape[1], : shape[2]] + rng = np.random.default_rng(0) + truth = np.zeros(shape, dtype=np.float32) + for _ in range(12): + cz, cy, cx = rng.integers(16, 48), rng.integers(30, 226), rng.integers(30, 226) + r = rng.integers(8, 28) + d = np.sqrt(((z - cz) * 2) ** 2 + (y - cy) ** 2 + (x - cx) ** 2) + truth += (np.abs(d - r) < 1.5) * 10 # hollow shells, 10 fluors / voxel + + common = { + "scale": (0.04, 0.02, 0.02), + "output_space": {"downscale": 4}, + "detector": ms.CameraCMOS(qe=0.8, read_noise=1.5), + "settings": ms.Settings(random_seed=0), + } + wf = ms.Simulation.from_ground_truth(truth, **common).run() + cf_img = ms.Simulation.from_ground_truth( + truth, + modality=ms.Confocal(), + channels=[oc_lib.FITC.model_copy(update={"power": 5000})], + exposure_ms=0.01, + **common, + ).run() + fig, axes = plt.subplots(1, 3, figsize=(11, 3.9), gridspec_kw={"wspace": 0.04}) + axes[0].imshow(norm(truth[32]), cmap="gray") + image_axes(axes[0], "your array (z = 32)") + axes[1].imshow(norm(_np(wf)[0, 8]), cmap="gray") + image_axes(axes[1], "widefield") + axes[2].imshow(norm(_np(cf_img)[0, 8]), cmap="gray") + image_axes(axes[2], "confocal") + save(fig, OUT / "own-truth.png") + + +@figure +def first_simulation() -> None: + """The example from the home page and the "First simulation" page.""" + sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(128, 512, 512), scale=(0.02, 0.01, 0.01)), + output_space={"downscale": 8}, + sample=ms.Sample(labels=[ms.MatsLines(density=0.5, length=30, azimuth=5)]), + detector=ms.CameraCMOS(), + settings=ms.Settings(random_seed=42), + ) + truth = sim.ground_truth() + optical = sim.optical_image() + digital = sim.digital_image(optical) + print(digital.shape, float(digital.min()), float(digital.max())) + panels = [ + ("sim.ground_truth() · max projection", _np(truth)[0].max(0)), + ("sim.optical_image() · z = 64", _np(optical)[0, 64]), + ("sim.digital_image() · z = 8", _np(digital)[0, 8]), + ] + fig, axes = plt.subplots(1, 3, figsize=(12, 4.3), gridspec_kw={"wspace": 0.04}) + for ax, (title, img) in zip(axes, panels, strict=True): + ax.imshow(norm(img), cmap="gray", interpolation="nearest") + image_axes(ax, title) + save(fig, OUT / "first-simulation.png") + + # brighter illumination, then confocal + imgs = [("widefield, 1 W/cm², 100 ms (defaults)", _np(digital)[0, 8])] + sim.channels = [oc_lib.FITC.model_copy(update={"power": 10})] + imgs.append( + ("widefield, 10 W/cm², 100 ms", _np(sim.digital_image(optical * 10))[0, 8]) + ) + sim.modality = ms.Confocal(pinhole_au=0.5) + sim.exposure_ms = 0.01 + sim.channels = [oc_lib.FITC.model_copy(update={"power": 50_000})] + img = sim.digital_image() + imgs.append(("confocal, 50 kW/cm², 10 µs dwell", _np(img)[0, 8])) + for title, im in imgs: + print(title, im.max()) + fig, axes = plt.subplots(1, 3, figsize=(12, 4.3), gridspec_kw={"wspace": 0.04}) + for ax, (title, img) in zip(axes, imgs, strict=True): + ax.imshow(norm(img), cmap="gray", interpolation="nearest") + image_axes(ax, title) + save(fig, OUT / "first-simulation-brighter.png") + + +@figure +def tutorial() -> None: + """Run the code blocks of the two-color tutorial, and save each figure made.""" + page = DOCS / "tutorials" / "two-color-cell.md" + text = page.read_text() + blocks = re.findall(r"```python([^`]*)```", text, re.DOTALL) + out = OUT / "tutorial" + out.mkdir(exist_ok=True) + # the page doesn't set a seed; fix it here so builds are reproducible + os.environ.setdefault("MICROSIM_RANDOM_SEED", "0") + namespace: dict = {} + n = 0 + for block in blocks: + exec(dedent(block), namespace) + for num in plt.get_fignums(): + n += 1 + fig = plt.figure(num) + fig.savefig(out / f"tut-{n:02d}.png", dpi=100, bbox_inches="tight") + plt.close(fig) + print("wrote", (out / f"tut-{n:02d}.png").relative_to(DOCS.parent)) + expected = len(re.findall(r"generated/tutorial/tut-\d+\.png", text)) + if n != expected: + raise RuntimeError(f"tutorial made {n} figures, but the page shows {expected}") + + +if __name__ == "__main__": + OUT.mkdir(parents=True, exist_ok=True) + names = sys.argv[1:] or list(FIGURES) + for name in names: + print(f"--- {name}") + FIGURES[name]() diff --git a/src/microsim/schema/detectors/_camera.py b/src/microsim/schema/detectors/_camera.py index 65a3eb26..4a32633a 100644 --- a/src/microsim/schema/detectors/_camera.py +++ b/src/microsim/schema/detectors/_camera.py @@ -21,7 +21,7 @@ class _Camera(_Detector): A camera has an array of pixels, each storing charge during the exposure. - Attributes + Parameters ---------- full_well : int Maximum number of electrons a pixel can hold. @@ -114,7 +114,7 @@ class CameraEMCCD(_Camera): amplifier. `relative_gain` is relative to the pixel `full_well`, so at high EM gain `electrons_per_adu` is usually more convenient. - Attributes + Parameters ---------- em_gain : float Mean gain of the EM register. diff --git a/src/microsim/schema/detectors/_detector.py b/src/microsim/schema/detectors/_detector.py index fb820ce3..4c59bf7b 100644 --- a/src/microsim/schema/detectors/_detector.py +++ b/src/microsim/schema/detectors/_detector.py @@ -92,7 +92,7 @@ def _per_channel(value: "Any", ndim: int) -> "Any": class _Detector(SimBaseModel): """Base detector model (see module docstring for the simulation steps). - Attributes + Parameters ---------- camera_type : str Type of detector, for discriminated union. diff --git a/src/microsim/schema/detectors/_point.py b/src/microsim/schema/detectors/_point.py index 3b8838d0..585b3244 100644 --- a/src/microsim/schema/detectors/_point.py +++ b/src/microsim/schema/detectors/_point.py @@ -96,7 +96,7 @@ class PMT(_Detector): - Temperature dependence, drift, and hysteresis of the gain (~1%). - Bidirectional scanning (which flips the streak on alternate lines). - Attributes + Parameters ---------- hv : float, optional HV (or control voltage) setting. Defaults to `ref_hv`. @@ -225,7 +225,7 @@ class HyD(_Detector): `electrons_per_adu = 1 / gain` here. The linearization Leica applies in that mode is not published, and is not modeled. - Attributes + Parameters ---------- dead_time_ns : float Time after each pulse during which no other photon is counted (Leica: < 1.5 diff --git a/src/microsim/schema/lens.py b/src/microsim/schema/lens.py index da6774c6..01d9ac80 100644 --- a/src/microsim/schema/lens.py +++ b/src/microsim/schema/lens.py @@ -20,6 +20,35 @@ class ObjectiveKwargs(TypedDict, total=False): class ObjectiveLens(SimBaseModel): + """Objective lens and immersion conditions. + + The `*_spec` values are those the objective is designed for; differences + between them and the actual values cause spherical aberration in the PSF. + + Parameters + ---------- + numerical_aperture : float + Numerical aperture. Cannot exceed the immersion medium refractive index. + coverslip_ri : float + Refractive index of the coverslip. + coverslip_ri_spec : float + Design refractive index of the coverslip. + immersion_medium_ri : float + Refractive index of the immersion medium. + immersion_medium_ri_spec : float + Design refractive index of the immersion medium. + specimen_ri : float + Refractive index of the specimen. + working_distance_um : float + Design working distance, in µm. + coverslip_thickness_um : float + Coverslip thickness, in µm. + coverslip_thickness_spec_um : float + Design coverslip thickness, in µm. + magnification : float + Magnification. Not used yet. + """ + _renamed_fields: ClassVar[dict[str, str]] = {"na": "numerical_aperture"} numerical_aperture: float = 1.4 diff --git a/src/microsim/schema/modality/_simple_psf.py b/src/microsim/schema/modality/_simple_psf.py index d5344a03..7ba5a2da 100644 --- a/src/microsim/schema/modality/_simple_psf.py +++ b/src/microsim/schema/modality/_simple_psf.py @@ -182,6 +182,11 @@ class Confocal(_PSFModality): (dwell) time (it is not divided by the number of pixels). Excitation saturation is applied locally (per position in the excitation PSF), rather than to the emission rates. + + Parameters + ---------- + pinhole_au : float + Pinhole diameter in Airy units (1 AU = 1.22 * emission wavelength / NA). """ type: Literal["confocal"] = "confocal" @@ -226,14 +231,20 @@ def psf( class Widefield(_PSFModality): + """Widefield: uniform illumination, imaged onto a camera. + + The optical image is the emission of each fluorophore convolved with the + emission PSF. + """ + type: Literal["widefield"] = "widefield" class Identity(_PSFModality): """Optical modality in which PSF is not applied. - The idea is to use this modality when the ground truth flurophore distribution is - generated from a light micorscope image, i.e., the PSF convolution is already + The idea is to use this modality when the ground truth fluorophore distribution + is generated from a light microscope image, i.e., the PSF convolution is already applied on the image. This is useful primarily when you are more interested in the spectral properties (fluorophores, filters, bleedthrough, etc.) than the spatial properties (PSF, modality, etc.) in the simulation. diff --git a/src/microsim/schema/optical_config/config.py b/src/microsim/schema/optical_config/config.py index 44f81b8d..7a0e8744 100644 --- a/src/microsim/schema/optical_config/config.py +++ b/src/microsim/schema/optical_config/config.py @@ -20,6 +20,19 @@ class LightSource(SimBaseModel): + """A light source. + + Parameters + ---------- + name : str + Name of the light source. + spectrum : Spectrum + Emission spectrum of the light source. + power : float, optional + Relative power, used to weight light sources in the same `OpticalConfig`. + The total irradiance is set by `OpticalConfig.power`. + """ + name: str = "" spectrum: Spectrum power: float | None = None # W/cm^2 @@ -51,6 +64,30 @@ def scaled_spectrum(self) -> Spectrum: class OpticalConfig(SimBaseModel): + """An optical configuration (channel): light sources and filters. + + A string `"::"` may be passed wherever an + `OpticalConfig` is expected; the configuration is then loaded from FPbase. + + Parameters + ---------- + name : str + Name of the channel. + filters : list[Filter] + Filters in the excitation and/or emission path (see `placement`). + lights : list[LightSource] + Light sources. If empty, flat white light through the excitation filters. + detector : Detector, optional + Used only for its QE, and only if the simulation has no detector. + exposure_ms : float, optional + Integration time per pixel for this channel, overriding + `Simulation.exposure_ms`. + power : float, optional + Irradiance at the sample, in W/cm^2, summed over wavelength after the + excitation filters. 1 W/cm^2 if not set. For point-scanning modalities, + the peak irradiance at the focus. + """ + name: str = "" filters: list[Filter] = Field(default_factory=list) lights: list[LightSource] = Field(default_factory=list) diff --git a/src/microsim/schema/optical_config/filter.py b/src/microsim/schema/optical_config/filter.py index 62be9a10..f925fc0c 100644 --- a/src/microsim/schema/optical_config/filter.py +++ b/src/microsim/schema/optical_config/filter.py @@ -18,6 +18,8 @@ class Placement(Enum): + """Position of a filter in the light path.""" + EX_PATH = "EX" EM_PATH = "EM" BS = "BS" @@ -77,6 +79,22 @@ def plot(self) -> None: class Bandpass(_FilterBase): + """Bandpass filter with smooth edges. + + Parameters + ---------- + bandcenter : float + Center wavelength, in nm. + bandwidth : float + Full width of the passband, in nm. + transmission : float + Peak transmission, from 0 to 1. + placement : Placement + Where the filter sits: `"EX"`, `"EM"`, `"BS"`, `"BSi"`, or `"ALL"`. + name : str + Name of the filter. + """ + type: Literal["bandpass"] = "bandpass" bandcenter: float # nm bandwidth: float # nm @@ -101,6 +119,22 @@ def _get_spectrum(self) -> Spectrum: class Shortpass(_FilterBase): + """Shortpass filter (transmits below `cutoff`). + + Parameters + ---------- + cutoff : float + Edge wavelength, in nm. + slope : float, optional + Steepness of the edge. Defaults to 5. + transmission : float + Maximum transmission, from 0 to 1. + placement : Placement + Where the filter sits. Defaults to the excitation path. + name : str + Name of the filter. + """ + type: Literal["shortpass"] = "shortpass" cutoff: float # nm slope: float | None = None @@ -127,6 +161,22 @@ def _get_spectrum(self) -> Spectrum: class Longpass(_FilterBase): + """Longpass filter (transmits above `cuton`). + + Parameters + ---------- + cuton : float + Edge wavelength, in nm. + slope : float, optional + Steepness of the edge. Defaults to 5. + transmission : float + Maximum transmission, from 0 to 1. + placement : Placement + Where the filter sits. Defaults to the emission path. + name : str + Name of the filter. + """ + type: Literal["longpass"] = "longpass" cuton: float # nm slope: float | None = None @@ -153,6 +203,18 @@ def _get_spectrum(self) -> Spectrum: class SpectrumFilter(_FilterBase): + """Filter defined by a measured transmission spectrum. + + Parameters + ---------- + transmission : Spectrum + Transmission (0 to 1) as a function of wavelength. + placement : Placement + Where the filter sits: `"EX"`, `"EM"`, `"BS"`, `"BSi"`, or `"ALL"`. + name : str + Name of the filter. + """ + type: Literal["spectrum"] = "spectrum" transmission: Spectrum = Field(..., repr=False) # because of spectrum on super() diff --git a/src/microsim/schema/sample/_distributions/cosem.py b/src/microsim/schema/sample/_distributions/cosem.py index 0637f135..d6a925c3 100644 --- a/src/microsim/schema/sample/_distributions/cosem.py +++ b/src/microsim/schema/sample/_distributions/cosem.py @@ -30,6 +30,16 @@ class CosemLabel(BaseDistribution): Go to https://openorganelle.janelia.org/datasets/ to find a dataset, and then chose a label from the dataset to render. + + Parameters + ---------- + dataset : str + Dataset ID, e.g. `"jrc_hela-3"`. + label : str + Name (or description) of a segmentation or prediction layer in the dataset, + e.g. `"er-mem_pred"`. + position : tuple[float, float, float], optional + Position of the region to extract. By default, the center of the dataset. """ type: Literal["cosem"] = "cosem" diff --git a/src/microsim/schema/sample/_distributions/matslines.py b/src/microsim/schema/sample/_distributions/matslines.py index dffec69c..7f2da301 100644 --- a/src/microsim/schema/sample/_distributions/matslines.py +++ b/src/microsim/schema/sample/_distributions/matslines.py @@ -14,6 +14,24 @@ class MatsLines(BaseDistribution): + """Randomly oriented, one-voxel-wide line segments. + + Each voxel on a line holds one fluorophore. + + Parameters + ---------- + density : float + Number of lines, as a fraction of the size of the space along X (in voxels). + length : int + Scales the random length of the lines, relative to the field of view. + azimuth : int + Limits the tilt of the lines out of the XY plane: larger values give + flatter lines. + max_r : float + Lines are only drawn within an ellipsoid of this radius, relative to the + size of the space (1 touches the edges). + """ + type: Literal["matslines"] = "matslines" density: float = 1 length: int = 10 diff --git a/src/microsim/schema/sample/fluorophore.py b/src/microsim/schema/sample/fluorophore.py index cf8b00be..a1207884 100644 --- a/src/microsim/schema/sample/fluorophore.py +++ b/src/microsim/schema/sample/fluorophore.py @@ -10,6 +10,31 @@ class Fluorophore(SimBaseModel): + """A fluorophore: spectra and photophysical properties. + + A string (e.g. `"EGFP"`) may be passed wherever a `Fluorophore` is expected; + its properties are then loaded from FPbase. + + Parameters + ---------- + name : str + Name of the fluorophore. + excitation_spectrum : Spectrum + Excitation (absorption) spectrum. Normalized to 1 at its peak when used. + emission_spectrum : Spectrum + Emission spectrum. Normalized to unit area when used. + bleaching_half_life_s : float, optional + Not used yet. + extinction_coefficient : float, optional + Molar extinction coefficient at the excitation peak, in M^-1 cm^-1. + Required to compute absorption. + quantum_yield : float, optional + Fraction of absorbed photons that are emitted. Treated as 1 if not set. + lifetime_ns : float, optional + Fluorescence lifetime in ns, used for excitation saturation. No + saturation is applied if not set. + """ + name: str excitation_spectrum: Spectrum emission_spectrum: Spectrum diff --git a/src/microsim/schema/sample/sample.py b/src/microsim/schema/sample/sample.py index 4a44b8ea..8c609d94 100644 --- a/src/microsim/schema/sample/sample.py +++ b/src/microsim/schema/sample/sample.py @@ -40,6 +40,21 @@ class FluorophoreDistribution(SimBaseModel): + """A label: a spatial distribution of one fluorophore. + + Parameters + ---------- + distribution : MatsLines | CosemLabel | FixedArrayTruth | Renderable + Generator of fluorophore counts per voxel. Any object with a + `render(space, xp)` method can be used. + fluorophore : Fluorophore | str + The fluorophore. A string is looked up on FPbase. Defaults to a + placeholder with flat spectra. + concentration : float | Callable, optional + A number to multiply the distribution by, or a function that takes the + rendered distribution and returns a modified one. + """ + distribution: AnyDistribution = Field(union_mode="left_to_right") fluorophore: Fluorophore = MOCK_FLUOR # either a scalar that will be multiplied by the distribution @@ -85,6 +100,16 @@ def __str__(self) -> str: class Sample(SimBaseModel): + """The sample: a list of labels. + + A plain list may be passed wherever a `Sample` is expected. + + Parameters + ---------- + labels : list[FluorophoreDistribution] + The labels. Each becomes one layer along the `f` axis of the ground truth. + """ + labels: list[FluorophoreDistribution] @model_validator(mode="before") diff --git a/src/microsim/schema/settings.py b/src/microsim/schema/settings.py index aa43bb69..b1bc8be4 100644 --- a/src/microsim/schema/settings.py +++ b/src/microsim/schema/settings.py @@ -26,6 +26,20 @@ class InMemoryCacheSizes(SimBaseModel): class CacheSettings(SimBaseModel): + """On-disk cache settings for ground truth. + + A boolean may be passed instead, to set both `read` and `write`. + + Parameters + ---------- + read : bool + Read cached ground truth, if available. + write : bool + Write generated ground truth to the cache. + in_mem_size : InMemoryCacheSizes + Sizes of the in-memory caches. + """ + read: bool = True write: bool = True in_mem_size: InMemoryCacheSizes = Field(default_factory=InMemoryCacheSizes) @@ -41,6 +55,25 @@ def _vmodel(cls, value: Any) -> Any: class Settings(SimBaseModel, BaseSettings): + """Settings that affect how a simulation is computed. + + Every setting can also be set with an environment variable `MICROSIM_` + (nested settings separated by `__`, e.g. `MICROSIM_CACHE__WRITE=0`). + + Parameters + ---------- + np_backend : str + Array library: `"numpy"`, `"cupy"`, `"jax"`, `"torch"`, or `"auto"` (CuPy + if it can be imported, otherwise NumPy). + device : str + Not used yet. + random_seed : int, optional + Seed for random ground truth and noise. A random seed is chosen if not + given. + cache : CacheSettings | bool + Whether to read and write the on-disk ground truth cache. + """ + np_backend: BackendName = "auto" device: DeviceName = "auto" float_dtype: FloatDtype = Field( diff --git a/src/microsim/schema/simulation.py b/src/microsim/schema/simulation.py index d05c23a9..8cd167a8 100644 --- a/src/microsim/schema/simulation.py +++ b/src/microsim/schema/simulation.py @@ -52,7 +52,38 @@ def _check_extensions(path: Path) -> Path: class Simulation(SimBaseModel): - """Top level Simulation object.""" + """Top level Simulation object. + + Holds every parameter of a simulation. Call `run()` to compute the digital + image, or the stage methods (`ground_truth`, `filtered_emission_rates`, + `optical_image`, `digital_image`) to compute intermediate results. + + Parameters + ---------- + truth_space : Space + Grid on which the sample is drawn and the optical image is computed. + output_space : Space, optional + Pixel grid of the digital image. May be relative to `truth_space` (e.g. + `{"downscale": 4}`). If `None`, the truth space is used. + sample : Sample + The fluorophore distributions (labels) to image. + modality : Modality + How the optical image is formed (e.g. `Widefield`, `Confocal`). + objective_lens : ObjectiveLens + Numerical aperture and refractive indices used for the PSF. + channels : list[OpticalConfig] + Optical configurations (light sources and filters), one per channel. + detector : Detector, optional + Detector model. If `None`, the digital image is the expected number of + photons per pixel, without noise. + exposure_ms : float + Integration time per pixel in ms: camera exposure, or pixel dwell time for + point-scanning modalities. Can be overridden per channel. + settings : Settings + Computation settings (backend, random seed, caching, ...). + output_path : Path, optional + If set, `run()` writes the result here (`.zarr`, `.nc`, `.tif`, `.tiff`). + """ truth_space: Space output_space: Space | None = None diff --git a/src/microsim/schema/space.py b/src/microsim/schema/space.py index ad2e70d2..b838db6c 100644 --- a/src/microsim/schema/space.py +++ b/src/microsim/schema/space.py @@ -141,6 +141,19 @@ def _validate_axes_space(self) -> "Self": class ShapeScaleSpace(_AxesSpace): + """Space defined by its shape and voxel size. + + Parameters + ---------- + shape : tuple[int, ...] + Number of voxels along each axis (Z, Y, X). + scale : tuple[float, ...] | float + Voxel size along each axis, in µm. A single number applies to all axes. + Defaults to 1. + axes : tuple[Axis, ...] + Axis names, by default the last `len(shape)` of (Z, Y, X). + """ + shape: tuple[int, ...] scale: tuple[float, ...] = () @@ -164,6 +177,18 @@ def extent(self) -> tuple[float, ...]: class ExtentScaleSpace(_AxesSpace): + """Space defined by its physical extent and voxel size. + + Parameters + ---------- + extent : tuple[float, ...] + Size of the space along each axis, in µm. + scale : tuple[float, ...] | float + Voxel size along each axis, in µm. A single number applies to all axes. + axes : tuple[Axis, ...] + Axis names, by default the last `len(extent)` of (Z, Y, X). + """ + extent: tuple[float, ...] scale: tuple[float, ...] @@ -187,6 +212,18 @@ def shape(self) -> tuple[int, ...]: class ShapeExtentSpace(_AxesSpace): + """Space defined by its shape and physical extent. + + Parameters + ---------- + shape : tuple[int, ...] + Number of voxels along each axis (Z, Y, X). + extent : tuple[float, ...] + Size of the space along each axis, in µm. + axes : tuple[Axis, ...] + Axis names, by default the last `len(shape)` of (Z, Y, X). + """ + shape: tuple[int, ...] extent: tuple[float, ...] @@ -218,6 +255,16 @@ def axes(self) -> tuple[str, ...]: class DownscaledSpace(_RelativeSpace): + """Space with voxels `downscale` times larger than the other space. + + Parameters + ---------- + downscale : int | tuple[int, ...] + Integer factor, for all axes or per axis. + reference : Space, optional + The space this one is relative to. Set automatically by `Simulation`. + """ + downscale: tuple[int, ...] | int def rescale(self, img: xrDataArray, mean_axes: Collection[str] = ()) -> xrDataArray: @@ -257,6 +304,16 @@ def scale(self) -> tuple[float, ...]: class UpscaledSpace(_RelativeSpace): + """Space with voxels `upscale` times smaller than the other space. + + Parameters + ---------- + upscale : int | tuple[float, ...] + Factor, for all axes or per axis. + reference : Space, optional + The space this one is relative to. Set automatically by `Simulation`. + """ + upscale: tuple[float, ...] | int @computed_field # type: ignore diff --git a/src/microsim/schema/spectrum.py b/src/microsim/schema/spectrum.py index 445ab6f3..0803f09c 100644 --- a/src/microsim/schema/spectrum.py +++ b/src/microsim/schema/spectrum.py @@ -26,6 +26,20 @@ def __repr__(self) -> str: class Spectrum(SimBaseModel): + """Intensity as a function of wavelength. + + A sequence of `(wavelength, intensity)` pairs may be passed instead. + + Parameters + ---------- + wavelength : array + Wavelengths, in nm. + intensity : array + Intensity at each wavelength. Negative values are clipped to 0. + scalar : float + Not used in calculations. + """ + wavelength: NumpyNdarray # nanometers intensity: NumpyNdarray # normalized to 1 scalar: float = 1 # scalar to multiply intensity by, such as EC or QY diff --git a/tests/test_docs.py b/tests/test_docs.py index 16ea5c95..ef037b79 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -13,11 +13,21 @@ CODE_BLOCK = re.compile(r"```python([^`]*)```", re.DOTALL) JSON_BLOCK = re.compile(r"```json([^`]*)```", re.DOTALL) +# Only ```python blocks are executed; ```py blocks are illustrative fragments. +# pages that download large datasets (OpenOrganelle) are not run +SKIP = {"two-color-cell.md"} +# pages that fetch fluorophores or optical configs from FPbase +NEEDS_INTERNET = { + "point-scanning-detectors.md", + "samples.md", + "your-own-ground-truth.md", +} +DOCS_MDS = [p for p in DOCS_MDS if p.name not in SKIP] if not HAVE_INTERNET: - DOCS_MDS = [p for p in DOCS_MDS if p.name not in {"stages.md"}] + DOCS_MDS = [p for p in DOCS_MDS if p.name not in NEEDS_INTERNET] -@pytest.mark.parametrize("doc", DOCS_MDS, ids=lambda p: p.name) +@pytest.mark.parametrize("doc", DOCS_MDS, ids=lambda p: str(p.relative_to(DOCS.parent))) def test_docs(doc: Path, tmp_path: Path) -> None: pytest.importorskip("matplotlib") os.chdir(tmp_path) @@ -39,8 +49,6 @@ def test_docs(doc: Path, tmp_path: Path) -> None: namespace: dict[str, object] = {} for n, block in enumerate(blocks): lines = [x for x in block.splitlines() if not x.lstrip().startswith("#")] - if doc.name == "stages.md": - lines = _fill_in_stages(lines) block = "\n".join(lines) try: exec(block, namespace) @@ -51,14 +59,3 @@ def test_docs(doc: Path, tmp_path: Path) -> None: f"{block}\n" f"\n--------------------------\n{e}" ) from e - - -def _fill_in_stages(lines: list[str]) -> list[str]: - # complete Simulation definition in stages.md - # this is just to allow the Simulation object to be instantiated... - # as a smoke test to make sure our examples still work - if not any(x.lstrip().startswith("sample") for x in lines): - lines.insert(-1, " sample={'labels': []},") - if not any(x.lstrip().startswith("truth_space") for x in lines): - lines.insert(-1, " truth_space={'shape': (1,1,1), 'scale': (1,1,1)},") - return lines diff --git a/uv.lock b/uv.lock index aeb5d0e2..fd07a373 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -39,15 +39,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, ] -[[package]] -name = "appnope" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, -] - [[package]] name = "ast-serialize" version = "0.5.0" @@ -97,54 +88,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "babel" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, -] - -[[package]] -name = "beautifulsoup4" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, -] - -[[package]] -name = "bleach" -version = "6.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, -] - -[package.optional-dependencies] -css = [ - { name = "tinycss2" }, -] - [[package]] name = "boto3" version = "1.43.36" @@ -375,15 +318,6 @@ 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 = "comm" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, -] - [[package]] name = "contourpy" version = "1.3.3" @@ -574,7 +508,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, + { name = "cuda-pathfinder", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, @@ -605,34 +539,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "(python_full_version >= '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, ] cufft = [ - { name = "nvidia-cufft", marker = "(python_full_version >= '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, ] cufile = [ { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "(python_full_version >= '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, ] curand = [ - { name = "nvidia-curand", marker = "(python_full_version >= '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "(python_full_version >= '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "(python_full_version >= '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version >= '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "(python_full_version >= '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "(python_full_version >= '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" }, + { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, ] [[package]] @@ -644,27 +578,6 @@ 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 = "debugpy" -version = "1.8.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, - { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, - { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, - { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, - { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, - { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, - { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, - { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, - { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, -] - [[package]] name = "decorator" version = "5.3.1" @@ -675,12 +588,12 @@ wheels = [ ] [[package]] -name = "defusedxml" -version = "0.7.1" +name = "deepmerge" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/6e/5cb3548b4d3112fea529375e55e6f3cdc52b8054e3a66f203b1f888ba885/deepmerge-3.0.1.tar.gz", hash = "sha256:35b39a4cb92cf328d6eca61cbbf65f68a37c2ceb3085f0f853cbb2e52a59fc23", size = 22328, upload-time = "2026-09-01T14:09:44.383Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/600003aaad107e27553fbc9cbfc57e96fa37e0223d2ef9d7d3a0e8d8d070/deepmerge-3.0.1-py3-none-any.whl", hash = "sha256:35c96f6a68fcf90719a5b31d9f8042ecef6c00fb56836660d33455d0f5cfda65", size = 14909, upload-time = "2026-09-01T14:09:43.364Z" }, ] [[package]] @@ -728,15 +641,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/c3/6f0e3896f193528bbd2b4d2122d4be8108a37efab0b8475855556a8c4afa/fancycompleter-0.11.1-py3-none-any.whl", hash = "sha256:44243d7fab37087208ca5acacf8f74c0aa4d733d04d593857873af7513cdf8a6", size = 11207, upload-time = "2025-05-26T12:59:09.857Z" }, ] -[[package]] -name = "fastjsonschema" -version = "2.21.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, -] - [[package]] name = "fieldz" version = "0.2.0" @@ -895,28 +799,25 @@ wheels = [ ] [[package]] -name = "griffe" -version = "0.45.3" +name = "griffe-fieldz" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama" }, + { name = "fieldz" }, + { name = "griffelib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/4a/03376f5df8fdb9587cf96c606fe5f6c2f5a7aad1f4da4876ad9852535cd8/griffe-0.45.3.tar.gz", hash = "sha256:02ee71cc1a5035864b97bd0dbfff65c33f6f2c8854d3bd48a791905c2b8a44b9", size = 102296, upload-time = "2024-06-09T11:16:54.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/7f/b57a27807536590f0cabdd2e6c0faac78c7d6a740e096684c51c503b7003/griffe_fieldz-0.5.0.tar.gz", hash = "sha256:dcf237a0def9f5c15e15aa5cf07b3d0a4edf4f7d6a2d713a45afe29c8af9bc8e", size = 14250, upload-time = "2026-02-24T16:22:23.374Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/42/b31be87eebd91bde9f3557ae746e48da4f9142c7d01794492e78f2e8abf4/griffe-0.45.3-py3-none-any.whl", hash = "sha256:ed1481a680ae3e28f91a06e0d8a51a5c9b97555aa2527abc2664447cc22337d6", size = 120344, upload-time = "2024-06-09T11:16:51.403Z" }, + { url = "https://files.pythonhosted.org/packages/ce/66/9daa3118d9bf4443778d6ee31b024c79766eeb116b275eca71597adae43a/griffe_fieldz-0.5.0-py3-none-any.whl", hash = "sha256:d363e56a7468019a7184ea3995277a1f20507bb9a345408c1f7014ae06878ebf", size = 9629, upload-time = "2026-02-24T16:22:22.094Z" }, ] [[package]] -name = "griffe-fieldz" -version = "0.1.2" +name = "griffelib" +version = "2.3.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fieldz" }, - { name = "griffe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/27/113e076108a4ea651d0a4814775100971444eca4394bebee78f650e68f50/griffe_fieldz-0.1.2.tar.gz", hash = "sha256:7a109c0f3fc38cce2107f1ae6270045f937406587bd6c20a1d1e7565c9d8c57d", size = 8301, upload-time = "2023-12-17T14:08:36.045Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/27/b55f1a5278918be765fb2fd8b20966bc72bbdd3f789f031937cceea7834a/griffelib-2.3.2.tar.gz", hash = "sha256:df00c7a0dee3d86268d76788997a1859272cb1fb7b865658e043d2c0c3d52e60", size = 234729, upload-time = "2026-10-06T09:54:37.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/11/ca440a51583162b3b3edfe52c166ce03be35450a21a6af9714728ccc70d7/griffe_fieldz-0.1.2-py3-none-any.whl", hash = "sha256:2528af30f5a1ff91c3a34e73225e3d173b80c28b3ba019bcadfde119b6344d98", size = 5400, upload-time = "2023-12-17T14:08:34.208Z" }, + { url = "https://files.pythonhosted.org/packages/04/e5/0ae74c83c1cab2c14daadf28bd0143eb14bb81503834af987b66badc7b61/griffelib-2.3.2-py3-none-any.whl", hash = "sha256:8e710afededd5607f95bf3c8ccc175ee306e7084459faf27b9f9f44ef2a7a274", size = 169420, upload-time = "2026-10-06T09:54:32.038Z" }, ] [[package]] @@ -1079,30 +980,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "ipykernel" -version = "6.31.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a5/1d/d5ba6edbfe6fae4c3105bca3a9c889563cc752c7f2de45e333164c7f4846/ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6", size = 167493, upload-time = "2025-10-20T11:42:39.948Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af", size = 117003, upload-time = "2025-10-20T11:42:37.502Z" }, -] - [[package]] name = "ipython" version = "9.14.1" @@ -1216,88 +1093,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "jupyter-client" -version = "8.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-core" }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, -] - -[[package]] -name = "jupyter-core" -version = "5.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "platformdirs" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, -] - -[[package]] -name = "jupyterlab-pygments" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, -] - -[[package]] -name = "jupytext" -version = "1.19.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "mdit-py-plugins" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/52/e014296ac8f40ca783aeb73dae52e65edbb0eaae0dcdc1ea41bfaa8aebf7/jupytext-1.19.4.tar.gz", hash = "sha256:739bcd4bc12aa4fe298a38017cdb5ae27b08a6ba3a5470728d2fe9e04b155db1", size = 4581977, upload-time = "2026-06-21T21:48:58.32Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-py3-none-any.whl", hash = "sha256:032d4ef4bd2e96addcac780b9b1d6b5a266ca39beceaaca95bfb4f06e0b77029", size = 170889, upload-time = "2026-06-21T21:48:56.352Z" }, -] - [[package]] name = "kiwisolver" version = "1.5.0" @@ -1453,6 +1248,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] +[[package]] +name = "markdown-exec" +version = "1.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/4484c13c38c293cb485fb730790ace11f898dd98ff758c27647369574ecd/markdown_exec-1.12.4.tar.gz", hash = "sha256:9eb8d535f69bbc85dc70b62dd9d04e65f29144de57448a87e25788c61667fbd8", size = 80857, upload-time = "2026-10-06T09:31:59.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/97/912d221d873cfd6b03a76957503897b31a3e40ce6df7dcf4ed2efdd332cc/markdown_exec-1.12.4-py3-none-any.whl", hash = "sha256:a688e1e91bc84d8b08a26e6b8380b6ca0d83a7235f81c1609de9d832ee1b49c8", size = 54712, upload-time = "2026-10-06T09:31:58.116Z" }, +] + +[package.optional-dependencies] +ansi = [ + { name = "pygments-ansi-color" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -1594,18 +1407,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] -[[package]] -name = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1693,14 +1494,11 @@ dev = [ { name = "types-tqdm" }, ] docs = [ - { name = "griffe" }, { name = "griffe-fieldz" }, - { name = "mkdocs" }, - { name = "mkdocs-autorefs" }, - { name = "mkdocs-jupyter" }, - { name = "mkdocs-material" }, - { name = "mkdocstrings" }, + { name = "markdown-exec", extra = ["ansi"] }, + { name = "matplotlib" }, { name = "mkdocstrings-python" }, + { name = "zensical" }, ] test = [ { name = "pytest" }, @@ -1771,32 +1569,20 @@ dev = [ { name = "types-tqdm", specifier = ">=4.68.0.20260608" }, ] docs = [ - { name = "griffe", specifier = "==0.45.3" }, - { name = "griffe-fieldz", specifier = "==0.1.2" }, - { name = "mkdocs", specifier = ">=1.5.3" }, - { name = "mkdocs-autorefs", specifier = "==1.1.0" }, - { name = "mkdocs-jupyter", specifier = "==0.24.8" }, - { name = "mkdocs-material", specifier = "==9.5.17" }, - { name = "mkdocstrings", specifier = "==0.24.3" }, - { name = "mkdocstrings-python", specifier = "==1.9.2" }, + { name = "griffe-fieldz", specifier = ">=0.5.0" }, + { name = "markdown-exec", extras = ["ansi"], specifier = ">=1.12.4" }, + { name = "matplotlib", specifier = ">=3.10.7" }, + { name = "mkdocstrings-python", specifier = ">=2.0.9" }, + { name = "zensical", specifier = ">=0.0.69" }, ] test = [ { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=6.0.0" }, ] -[[package]] -name = "mistune" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/5f/007786743f962224423753b78f7d7acb0f2ade46d1604f2e0fa2bedf9020/mistune-3.3.2.tar.gz", hash = "sha256:e12ee4f1e74336e91aa1141e35f913b337c40bdf7c0cc49f21fb853a27e8b62f", size = 111284, upload-time = "2026-06-23T00:29:28.568Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/43/894c2cbbcbdf53b57d1257a249811abe2ee9ab7ef76af301b40f1c054533/mistune-3.3.2-py3-none-any.whl", hash = "sha256:a678a56387d487db7368ede4647cb2ba1deff22ce61f92343e4ebe0ddfce4f2d", size = 61554, upload-time = "2026-06-23T00:29:27.088Z" }, -] - [[package]] name = "mkdocs" -version = "1.5.3" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1806,110 +1592,75 @@ dependencies = [ { name = "markdown" }, { name = "markupsafe" }, { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, { name = "packaging" }, { name = "pathspec" }, - { name = "platformdirs" }, { name = "pyyaml" }, { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/bb/24a22f8154cf79b07b45da070633613837d6e59c7d870076f693b7b1c556/mkdocs-1.5.3.tar.gz", hash = "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2", size = 3654364, upload-time = "2023-09-18T21:26:11.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/58/aa3301b23966a71d7f8e55233f467b3cec94a651434e9cd9053811342539/mkdocs-1.5.3-py3-none-any.whl", hash = "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1", size = 3694750, upload-time = "2023-09-18T21:26:09.089Z" }, + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] [[package]] name = "mkdocs-autorefs" -version = "1.1.0" +version = "1.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "markupsafe" }, { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/24/7d09b72b470d5dd33ed0c6722c7038ece494ab7dc5e72adbfeaf945276f6/mkdocs_autorefs-1.1.0.tar.gz", hash = "sha256:f2fd43b11f66284bd014f9b542a05c8ecbfaad4e0d7b30b68584788217b6c656", size = 36989, upload-time = "2024-08-20T15:54:11.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/84/8e4dd669766f864482d2edcc44c7f07f4f91d73414c9a3e33b230a59f2cf/mkdocs_autorefs-1.1.0-py3-none-any.whl", hash = "sha256:492ac42f50214e81565e968f8cb0df9aba9d981542b9e7121b8f8ae9407fe6eb", size = 14417, upload-time = "2024-08-20T15:54:09.007Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, ] [[package]] -name = "mkdocs-jupyter" -version = "0.24.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipykernel" }, - { name = "jupytext" }, - { name = "mkdocs" }, - { name = "mkdocs-material" }, - { name = "nbconvert" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/91/817bf07f4b1ce9b50d7d33e059e6cd5792951971a530b64665dd6cbf1324/mkdocs_jupyter-0.24.8.tar.gz", hash = "sha256:09a762f484d540d9c0e944d34b28cb536a32869e224b460e2fc791b143f76940", size = 1531510, upload-time = "2024-07-02T22:42:16.457Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/48/1e320da0e16e926ba4a9a8800df48963fce27b1287c8d1859041a2f85e26/mkdocs_jupyter-0.24.8-py3-none-any.whl", hash = "sha256:36438a0a653eee2c27c6a8f7006e645f18693699c9b8ac44ffde830ddb08fa16", size = 1444481, upload-time = "2024-07-02T22:42:14.242Z" }, -] - -[[package]] -name = "mkdocs-material" -version = "9.5.17" +name = "mkdocs-get-deps" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "babel" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "regex" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/43/4c53396722f4b4a8121be0af191081aced0e1c91d7782be7b5478f51526f/mkdocs_material-9.5.17.tar.gz", hash = "sha256:06ae1275a72db1989cf6209de9e9ecdfbcfdbc24c58353877b2bb927dbe413e4", size = 4062418, upload-time = "2024-04-02T05:14:51.99Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/97/70a38b943fafa69b8f66fafa96c3280bc9fea3c826d7e035a4aada83f5b1/mkdocs_material-9.5.17-py3-none-any.whl", hash = "sha256:14a2a60119a785e70e765dd033e6211367aca9fc70230e577c1cf6a326949571", size = 8742149, upload-time = "2024-04-02T05:14:45.651Z" }, + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, ] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, ] [[package]] name = "mkdocstrings" -version = "0.24.3" +version = "1.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, { name = "jinja2" }, { name = "markdown" }, { name = "markupsafe" }, { name = "mkdocs" }, { name = "mkdocs-autorefs" }, - { name = "platformdirs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/0b/424efa5498ede474a8104f57dc35666b7e2f0025c370e469b9ea00ab23d9/mkdocstrings-0.24.3.tar.gz", hash = "sha256:f327b234eb8d2551a306735436e157d0a22d45f79963c60a8b585d5f7a94c1d2", size = 31936, upload-time = "2024-04-05T14:56:35.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/e8/445b19834bf9559ebbbaa76897e965665b83d2861461eed8d8de4d122057/mkdocstrings-1.0.7.tar.gz", hash = "sha256:0951b147f352118a45a3db85f0f3b660833d34cb5120a2751315b8d08dafd159", size = 103739, upload-time = "2026-10-10T13:50:59.957Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/c2/5d2fa50f67b270d67f867fe2c8814ffa910c2d49fda1910428a55cb6df54/mkdocstrings-0.24.3-py3-none-any.whl", hash = "sha256:5c9cf2a32958cd161d5428699b79c8b0988856b0d4a8c5baf8395fc1bf4087c3", size = 28478, upload-time = "2024-04-05T14:56:33.417Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/9062531735ff1fd27f3a79c353c4cf4d822eeffd062fd90b2c553a96f4bb/mkdocstrings-1.0.7-py3-none-any.whl", hash = "sha256:9563828667eeb35256f87a6b61fe857676ac628ec1abbce9aea9839a502c9cb1", size = 41597, upload-time = "2026-10-10T13:50:58.43Z" }, ] [[package]] name = "mkdocstrings-python" -version = "1.9.2" +version = "2.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffe" }, + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/48/f085a737da288ff09a2444e8826b2ec76d76a9e890527162aa0e0bf7ffbd/mkdocstrings_python-1.9.2.tar.gz", hash = "sha256:8546a103c9b22e1778c72c887696acc39a6635fedde3c912ce00f967518a8847", size = 34002, upload-time = "2024-04-02T19:28:10.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/11/640067d1ad713ea99c233e53e57a4c300d9885f904a61371d7ef90dea549/mkdocstrings_python-2.0.9.tar.gz", hash = "sha256:ae945637dc0618c6beedbee169f10c0234b77f322c53db3fc9fb4c29b3013038", size = 203006, upload-time = "2026-09-22T13:43:27.712Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/8e/fb4d184f916ad3a6e5155b5e478aa0dd30ca5c25f588374fbaa7dae4957e/mkdocstrings_python-1.9.2-py3-none-any.whl", hash = "sha256:96d82f6424e08db6245e4a15ca95619f4ecd0ddd254c0aa590d4181814e16ee5", size = 58541, upload-time = "2024-04-02T19:28:08.554Z" }, + { url = "https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl", hash = "sha256:c0233eff3f84d78110df50541918e7ec2bcff8e1614faddea34290a90321a416", size = 105698, upload-time = "2026-09-22T13:43:26.345Z" }, ] [[package]] @@ -2118,61 +1869,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "nbclient" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "nbformat" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" }, -] - -[[package]] -name = "nbconvert" -version = "7.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "bleach", extra = ["css"] }, - { name = "defusedxml" }, - { name = "jinja2" }, - { name = "jupyter-core" }, - { name = "jupyterlab-pygments" }, - { name = "markupsafe" }, - { name = "mistune" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pandocfilters" }, - { name = "pygments" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, -] - -[[package]] -name = "nbformat" -version = "5.10.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastjsonschema" }, - { name = "jsonschema" }, - { name = "jupyter-core" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, -] - [[package]] name = "ndv" version = "0.5.0" @@ -2201,15 +1897,6 @@ vispy = [ { name = "vispy" }, ] -[[package]] -name = "nest-asyncio" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, -] - [[package]] name = "networkx" version = "3.6.1" @@ -2302,7 +1989,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -2341,7 +2028,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -2353,7 +2040,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -2383,9 +2070,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -2397,7 +2084,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -2467,15 +2154,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, -] - [[package]] name = "pandas" version = "3.0.3" @@ -2528,15 +2206,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] -[[package]] -name = "pandocfilters" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, -] - [[package]] name = "parso" version = "0.8.7" @@ -2560,8 +2229,8 @@ name = "pdbpp" version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fancycompleter", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, - { name = "pygments", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, + { name = "fancycompleter", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "pygments", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/8d/dbc2c26f9c947e3d6df82d3ee7711fb52a92000177cea22fd3e428dc0429/pdbpp-0.12.1.tar.gz", hash = "sha256:932cc5963760105e33607f44a1a3b83d28096d7dbf055b77b527c5c70a1dafef", size = 77358, upload-time = "2026-02-23T14:23:44.993Z" } wheels = [ @@ -2573,7 +2242,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "python_full_version >= '3.14' or sys_platform != 'win32'" }, + { name = "ptyprocess", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -3031,6 +2700,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pygments-ansi-color" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/f9/7f417aaee98a74b4f757f2b72971245181fcf25d824d2e7a190345669eaf/pygments-ansi-color-0.3.0.tar.gz", hash = "sha256:7018954cf5b11d1e734383a1bafab5af613213f246109417fee3f76da26d5431", size = 7317, upload-time = "2023-05-18T22:44:35.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/17/8306a0bcd8c88d7761c2e73e831b0be026cd6873ce1f12beb3b4c9a03ffa/pygments_ansi_color-0.3.0-py3-none-any.whl", hash = "sha256:7eb063feaecadad9d4d1fd3474cbfeadf3486b64f760a8f2a00fc25392180aba", size = 10242, upload-time = "2023-05-18T22:44:34.287Z" }, +] + [[package]] name = "pyjwt" version = "2.13.0" @@ -3047,15 +2728,15 @@ crypto = [ [[package]] name = "pymdown-extensions" -version = "10.21.3" +version = "12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/0a/d8c8535346f63ff85c8743383438a74b5c0e3394d90e898b2cd1a2fac705/pymdown_extensions-12.2.tar.gz", hash = "sha256:02da896c362b548d85123e7a377a40024ea6ea938b4e45ca064e408afd994b64", size = 870226, upload-time = "2026-10-10T13:01:20.494Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, + { url = "https://files.pythonhosted.org/packages/79/a5/30b1a3d82219cbe5e6661cd7053791dc3a3743547ebf31f3366429d84516/pymdown_extensions-12.2-py3-none-any.whl", hash = "sha256:caea66e916b4cfe987b5dabb0a78558e3506988f6353812b0b0288be8f976c16", size = 277518, upload-time = "2026-10-10T13:01:18.601Z" }, ] [[package]] @@ -3275,49 +2956,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] -[[package]] -name = "pyzmq" -version = "27.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "implementation_name == 'pypy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, -] - [[package]] name = "qtpy" version = "2.4.3" @@ -3344,108 +2982,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/60/164246615e8b059f6d53d34648a0784260421ec98a07eb1e45160f063221/realtime-2.31.0-py3-none-any.whl", hash = "sha256:f6e494b53d6a6e80b6efcee6711c8dd40413a52e766271de1bce8ced6c36cc1d", size = 22374, upload-time = "2026-06-04T13:37:21.162Z" }, ] -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - -[[package]] -name = "regex" -version = "2026.5.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, - { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, - { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, - { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, - { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, - { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, - { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, - { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, - { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, - { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, - { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, - { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, - { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, - { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, - { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, - { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, - { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, - { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, - { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, - { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, - { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, - { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, - { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, - { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, - { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, - { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, - { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, - { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, - { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, - { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, - { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, - { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, - { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, - { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, - { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, - { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, - { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, - { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, - { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, - { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, - { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, - { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, - { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, - { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, -] - [[package]] name = "requests" version = "2.34.2" @@ -3474,116 +3010,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] -[[package]] -name = "rpds-py" -version = "2026.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, - { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, - { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, - { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, - { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, - { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, - { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, - { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, - { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, - { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, - { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, - { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, - { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, - { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, - { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, - { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, - { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, - { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, - { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, - { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, - { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, - { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, - { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, - { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, - { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, - { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, - { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, - { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, - { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, - { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, - { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, - { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, - { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, - { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, - { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, - { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, - { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, - { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, - { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, - { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, - { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, - { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, - { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, - { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, - { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, - { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, - { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, - { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, - { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, - { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, - { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, - { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, - { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, - { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, - { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, - { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, - { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, - { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, - { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, - { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, - { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, - { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, -] - [[package]] name = "ruff" version = "0.15.19" @@ -3703,15 +3129,6 @@ 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 = "soupsieve" -version = "2.8.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, -] - [[package]] name = "stack-data" version = "0.6.3" @@ -3899,15 +3316,66 @@ wheels = [ ] [[package]] -name = "tinycss2" -version = "1.5.1" +name = "tomli" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, ] [[package]] @@ -3977,23 +3445,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/f7/ee5da8c03f1a3c7662c6c6a119f24a4b3e646da94be56dce3201e3a6ee9b/torchaudio-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:88fb5e29f670a33d9bac6aabb1d2734460cf6e461bde5cdc352826035851b16d", size = 328661, upload-time = "2026-03-23T18:13:20.1Z" }, ] -[[package]] -name = "tornado" -version = "6.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, - { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, - { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, - { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, -] - [[package]] name = "tqdm" version = "4.68.3" @@ -4179,15 +3630,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, ] -[[package]] -name = "webencodings" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, -] - [[package]] name = "websockets" version = "15.0.1" @@ -4331,3 +3773,31 @@ sdist = { url = "https://files.pythonhosted.org/packages/93/8d/aeb164004f87543b0 wheels = [ { url = "https://files.pythonhosted.org/packages/88/0a/469e2bd01be1490336e6c8707386845655d59261543315778a3ccc7e8019/zarr-3.2.1-py3-none-any.whl", hash = "sha256:f78cdd3d9687ad0e9f9cba2c5683b64f0c52589c19f685eeabe872e93cc0d2c7", size = 319617, upload-time = "2026-05-05T12:37:20.66Z" }, ] + +[[package]] +name = "zensical" +version = "0.0.69" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pathspec" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/1d/e8a66a0e37982d4a8e9e2b57d0b54177b03b5f232559109efc024baf4fe3/zensical-0.0.69.tar.gz", hash = "sha256:1c9d25243249a4db16acabbfe18cc178016fd88cdacfbc33e399ffeade91932e", size = 4312939, upload-time = "2026-10-08T15:36:59.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/7b/aa9f69525a430c146cd42bcaa60e29ea3c9e963fd3f30cf6480155546c81/zensical-0.0.69-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2a1858b34e8b97e95d30409d602c8e4b67abd042317afaddadbaf594ee201f12", size = 18108147, upload-time = "2026-10-08T15:36:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/51/db/005b2c05d6be4f478dee031d50cf4cf9f98948e2e12136904836a360a0c4/zensical-0.0.69-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:72e5956b565b6cc81b766e3a2efefbe730ce8d115186dd0531a927ba3ff8dffc", size = 17636936, upload-time = "2026-10-08T15:36:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/86/32/c0fe3e0128873021f58ac2613966cba7278f17ed7ac929b202e4a3474f76/zensical-0.0.69-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40c8aa708c5d39c8cea9fec38d2fe4c6e08acf80585c00309f298c33c6da8a01", size = 18146896, upload-time = "2026-10-08T15:36:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/e0e6447da8e0c55549f399dfdbb38e18eb64e367e779ae97e08f6d4e5cdc/zensical-0.0.69-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d930b2ca0ef611446a566040c72469e59c87907029fbfb3dc5eed88738ea63b0", size = 18084079, upload-time = "2026-10-08T15:36:42.728Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c6/5b56c8ae4d9ff5dd84dbf0fa7356441dc02a87e8d45d6a69b8c98fd97fec/zensical-0.0.69-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a335af5e74e0b6f488ab57e137f86e48cef44df98ac200342e8235e45cda34bd", size = 18623988, upload-time = "2026-10-08T15:36:45.711Z" }, + { url = "https://files.pythonhosted.org/packages/fc/67/02e9904f0b0c1b0ec7fe76e5a71594b972a5907c61e4723f9a19e39cff7c/zensical-0.0.69-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68f0e5a4d2228c213a9bd208b89fb52c39c766235a99a7a773c052521fa2053c", size = 18311730, upload-time = "2026-10-08T15:36:48.491Z" }, + { url = "https://files.pythonhosted.org/packages/c4/7e/b33ec1fa3d19bffc222c932a1d3b8c5d943aab945fe6d8a1c45d40af2f7d/zensical-0.0.69-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3c516e050f645849dc5c9e09574ebfa295ad42f5baad1f8419210c51b83e3bc4", size = 18881453, upload-time = "2026-10-08T15:36:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b0/378c51119f3a1896caac1abbab3c2722810dbf2fb3e5fe8209b011a73613/zensical-0.0.69-cp311-abi3-win_amd64.whl", hash = "sha256:3bbabba57db1ee0ca1f7e64ee2ed9ef78d29e74854b2e5b1b06056a3a99972a4", size = 18806917, upload-time = "2026-10-08T15:36:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/be/03/2dfe983d1e20005011a230724c2a907c29881884f69700992c0d62e49161/zensical-0.0.69-cp311-abi3-win_arm64.whl", hash = "sha256:5bd01df59abb2a5434658b3abf1f5b7a3bc4448b8b81f15f80a8fb9efccd02f3", size = 18221915, upload-time = "2026-10-08T15:36:57.107Z" }, +] diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 00000000..0d1f4ca1 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,210 @@ +[project] +site_name = "microsim" +site_url = "https://www.talleylambert.com/microsim/" +site_description = "Physically grounded simulation of fluorescence microscopy images, in Python" +site_author = "Talley Lambert" +copyright = "Copyright © 2024 - 2026 Talley Lambert" +repo_name = "tlambert03/microsim" +repo_url = "https://github.com/tlambert03/microsim" +edit_uri = "edit/main/docs/" +docs_dir = "docs" + +extra_css = ["stylesheets/extra.css"] +extra_javascript = [ + "javascripts/mathjax.js", + "https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js", +] + +nav = [ + { "Home" = "index.md" }, + { "Getting started" = [ + "getting-started/installation.md", + "getting-started/first-simulation.md", + ] }, + { "User guide" = [ + "guide/index.md", + "guide/spaces.md", + "guide/samples.md", + "guide/channels.md", + "guide/photometry.md", + "guide/modalities.md", + "guide/detectors.md", + "guide/settings.md", + "guide/saving.md", + ] }, + { "Tutorials" = [ + "tutorials/index.md", + "tutorials/two-color-cell.md", + "tutorials/your-own-ground-truth.md", + "tutorials/point-scanning-detectors.md", + ] }, + { "API reference" = [ + "api/index.md", + "api/simulation.md", + "api/space.md", + "api/sample.md", + "api/optical-config.md", + "api/modality.md", + "api/detectors.md", + "api/settings.md", + "api/spectrum.md", + "api/psf.md", + "api/util.md", + ] }, + { "About" = [ + "about/contributing.md", + "about/citing.md", + ] }, +] + +[project.extra] +generator = false + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/tlambert03/microsim" + +[[project.extra.social]] +icon = "fontawesome/brands/python" +link = "https://pypi.org/project/microsim/" + +# ---------------------------------------------------------------------------- +# Theme + +[project.theme] +variant = "modern" +language = "en" +logo = "images/logo.svg" +favicon = "images/favicon.svg" +features = [ + "content.action.edit", + "content.code.annotate", + "content.code.copy", + "content.code.select", + "content.footnote.tooltips", + "content.tabs.link", + "content.tooltips", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.instant.progress", + "navigation.path", + "navigation.sections", + "navigation.tabs", + "navigation.top", + "navigation.tracking", + "search.highlight", + "toc.follow", +] + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "custom" +accent = "custom" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "custom" +accent = "custom" +toggle.icon = "lucide/moon" +toggle.name = "Switch to system preference" + +[project.theme.icon] +repo = "fontawesome/brands/github" +edit = "lucide/pencil" + +# ---------------------------------------------------------------------------- +# Markdown + +[project.markdown_extensions] +abbr = {} +admonition = {} +attr_list = {} +def_list = {} +footnotes = {} +md_in_html = {} +tables = {} +toc.permalink = true +toc.toc_depth = 3 +pymdownx.arithmatex.generic = true +pymdownx.betterem = {} +pymdownx.blocks.caption = {} +pymdownx.caret = {} +pymdownx.details = {} +pymdownx.emoji.emoji_generator = "zensical.extensions.emoji.to_svg" +pymdownx.emoji.emoji_index = "zensical.extensions.emoji.twemoji" +pymdownx.highlight.anchor_linenums = true +pymdownx.highlight.line_spans = "__span" +pymdownx.highlight.pygments_lang_class = true +pymdownx.inlinehilite = {} +pymdownx.keys = {} +pymdownx.magiclink = { repo_url_shorthand = true, user = "tlambert03", repo = "microsim" } +pymdownx.mark = {} +pymdownx.smartsymbols = {} +pymdownx.snippets = { auto_append = ["includes/abbreviations.md"], check_paths = true } +pymdownx.superfences.custom_fences = [ + { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" }, +] +pymdownx.tabbed.alternate_style = true +pymdownx.tabbed.combine_header_slug = true +pymdownx.tasklist.custom_checkbox = true +pymdownx.tilde = {} + +[project.markdown_extensions."zensical.extensions.glightbox"] +auto_caption = false + +[[project.markdown_extensions."zensical.extensions.preview".configurations]] +targets.include = ["guide/*", "api/*"] + +# ---------------------------------------------------------------------------- +# Plugins + +[project.plugins.search] + +[project.plugins.redirects.redirect_maps] +"concept.md" = "guide/index.md" +"stages.md" = "guide/index.md" +"tutorial.md" = "tutorials/two-color-cell.md" + +[project.plugins.mkdocstrings.handlers.python] +inventories = [ + "https://docs.python.org/3/objects.inv", + "https://numpy.org/doc/stable/objects.inv", + "https://docs.xarray.dev/en/stable/objects.inv", + "https://docs.pydantic.dev/latest/objects.inv", +] +paths = ["src"] + +[project.plugins.mkdocstrings.handlers.python.options] +docstring_style = "numpy" +extensions = [ + { griffe_fieldz = { include_inherited = true, remove_fields_from_members = true, strip_annotated = true } }, +] +heading_level = 2 +show_root_heading = true +show_root_full_path = false +show_bases = false +show_source = false +show_symbol_type_heading = true +show_symbol_type_toc = true +show_signature_annotations = true +separate_signature = true +signature_crossrefs = true +merge_init_into_class = true +members_order = "source" +filters = ["!^_", "!^apply_(pre|post)_quantization_binning$", "!^model_", "!^cache_key$"] +summary = true +docstring_section_style = "table" +show_if_no_docstring = true + +[project.plugins.markdown-exec] From 67c779d1244c9426e67c8680b6c01521ecb66b49 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 11:10:08 +0200 Subject: [PATCH 02/28] docs: interactive spectra chart (Observable Plot), JS photometry tested against microsim - docs/javascripts/physics/photometry.js mirrors the photon budget calculations, with pointers to the Python; tests/test_js_physics.py runs it in node - charts mount on Zensical's document$ (instant navigation), load Plot and d3 from jsDelivr (pinned), and use CSS variables for light/dark - spectra and filter data exported at build time to docs/data/generated/ Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + docs/guide/channels.md | 13 +- docs/javascripts/charts/index.js | 26 ++ docs/javascripts/charts/lib.js | 301 ++++++++++++++++++++ docs/javascripts/charts/spectra.js | 171 ++++++++++++ docs/javascripts/physics/photometry.js | 171 ++++++++++++ docs/stylesheets/charts.css | 366 +++++++++++++++++++++++++ scripts/make_doc_figures.py | 178 +++++++----- tests/js/photometry.mjs | 30 ++ tests/test_js_physics.py | 153 +++++++++++ zensical.toml | 3 +- 11 files changed, 1344 insertions(+), 69 deletions(-) create mode 100644 docs/javascripts/charts/index.js create mode 100644 docs/javascripts/charts/lib.js create mode 100644 docs/javascripts/charts/spectra.js create mode 100644 docs/javascripts/physics/photometry.js create mode 100644 docs/stylesheets/charts.css create mode 100644 tests/js/photometry.mjs create mode 100644 tests/test_js_physics.py diff --git a/.gitignore b/.gitignore index f6e74c75..a27d0d9f 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,4 @@ dask-worker-space* # documentation figures, made by scripts/make_doc_figures.py docs/images/generated/ +docs/data/generated/ diff --git a/docs/guide/channels.md b/docs/guide/channels.md index 84908092..1939895b 100644 --- a/docs/guide/channels.md +++ b/docs/guide/channels.md @@ -17,13 +17,14 @@ from microsim.schema.optical_config import lib sim = ms.Simulation(..., channels=[lib.FITC, lib.TRITC]) ``` -![EGFP spectra and the FITC filter set](../images/generated/spectra-egfp-fitc-light.png#only-light) -![EGFP spectra and the FITC filter set](../images/generated/spectra-egfp-fitc-dark.png#only-dark) +
/// caption -Top: excitation and emission spectra of EGFP. Bottom: the filters of -`lib.FITC`. The excitation filter selects the light that reaches the sample, the -emission filter selects the light that reaches the detector, and the dichroic -mirror separates the two paths. +A fluorophore's absorption and emission spectra (normalized to their peaks), the +excitation light that reaches the sample, and the transmission of the emission +path. The emission path combines every filter in it: the dichroic mirror +reflects the excitation light toward the sample and transmits the emission to +the detector. Shaded: the absorption spectrum weighted by the excitation light, +and the emission that passes the emission path. Hover for values. /// ## Built-in filter sets diff --git a/docs/javascripts/charts/index.js b/docs/javascripts/charts/index.js new file mode 100644 index 00000000..26f6bd20 --- /dev/null +++ b/docs/javascripts/charts/index.js @@ -0,0 +1,26 @@ +// Mounts the interactive charts. A chart is a placeholder in the markdown, +// +//
+// +// and a module `./spectra.js` whose default export `(el) => void` renders into it. +// Zensical's instant navigation swaps page content without reloading, so mount on +// every `document$` emission, not just once. + +const mounted = new WeakSet(); + +function mountAll() { + for (const el of document.querySelectorAll(".ms-chart[data-chart]")) { + if (mounted.has(el)) continue; + mounted.add(el); + import(`./${el.dataset.chart}.js`) + .then((module) => module.default(el)) + .catch((err) => { + el.classList.add("ms-chart--error"); + el.textContent = "This interactive chart failed to load."; + console.error(`chart ${el.dataset.chart}:`, err); + }); + } +} + +if (window.document$) window.document$.subscribe(mountAll); +else mountAll(); diff --git a/docs/javascripts/charts/lib.js b/docs/javascripts/charts/lib.js new file mode 100644 index 00000000..45533214 --- /dev/null +++ b/docs/javascripts/charts/lib.js @@ -0,0 +1,301 @@ +// Shared pieces for the interactive charts: libraries, data loading, controls, +// layout, crosshair tooltips, and wavelength colors. +// +// Colors are CSS variables (docs/stylesheets/extra.css), so charts follow the +// light/dark toggle without redrawing. + +// Pinned exactly. Plot imports this same d3 URL, so d3 is loaded once. +export * as d3 from "https://cdn.jsdelivr.net/npm/d3@7.9.0/+esm"; +export * as Plot from "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.17/+esm"; +import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7.9.0/+esm"; + +export const C = { + series: [1, 2, 3, 4, 5].map((i) => `var(--ms-c${i})`), + ink: "var(--ms-ink)", + muted: "var(--ms-ink-muted)", + faint: "var(--ms-ink-faint)", + grid: "var(--ms-grid)", + surface: "var(--ms-surface)", +}; + +// --------------------------------------------------------------------------- +// Data, written by scripts/make_doc_figures.py + +const cache = new Map(); +export function loadData(name) { + if (!cache.has(name)) { + const url = new URL(`../../data/generated/${name}.json`, import.meta.url); + cache.set( + name, + fetch(url).then((r) => { + if (!r.ok) throw new Error(`${url}: ${r.status}`); + return r.json(); + }), + ); + } + return cache.get(name); +} + +// --------------------------------------------------------------------------- +// Layout + +function h(tag, className, parent) { + const el = document.createElement(tag); + if (className) el.className = className; + if (parent) parent.append(el); + return el; +} + +// The parts of a chart, top to bottom: controls, legend, plot, stats. +export function shell(el) { + el.replaceChildren(); + const controls = h("div", "ms-chart__controls", el); + const legend = h("div", "ms-chart__legend", el); + const plot = h("div", "ms-chart__plot", el); + const stats = h("div", "ms-chart__stats", el); + return { el, controls, legend, plot, stats }; +} + +let uid = 0; +const nextId = (prefix) => `${prefix}-${++uid}`; + +// A labeled square-root display`; + ui.controls.append(gammaSel); + const sqrt = gammaSel.querySelector("input"); + sqrt.addEventListener("input", () => update()); + + const panels = document.createElement("div"); + panels.className = "ms-panels"; + panels.style.setProperty("--cols", 4); + ui.legend.after(panels); + const views = { + wfXY: imagePanel(panels, "widefield · XY"), + wfXZ: imagePanel(panels, "widefield · XZ"), + cfXY: imagePanel(panels, ""), + cfXZ: imagePanel(panels, ""), + }; + for (const v of Object.values(views)) v.canvas.parentElement.classList.add("ms-panel--smooth"); + + const size = `${fmt.sig(data.image_dx_um * (widefield.xy.width - 1), 2)} µm`; + const note = document.createElement("span"); + note.className = "ms-legend__note"; + note.textContent = `images ${size} square, z vertical in XZ`; + ui.legend.append(note); + legendItem(ui.legend, { label: "widefield", color: C.muted, swatch: "dash" }); + legendItem(ui.legend, { label: "confocal", color: C.series[1] }); + + const setThru = stat(ui.stats, { + label: "light through the pinhole", + note: "for a point at the focus, of the light the objective collects", + }); + const setLat = stat(ui.stats, { label: "lateral FWHM" }); + const setAx = stat(ui.stats, { label: "axial FWHM" }); + + const draw = (view, img) => + drawImage(view.canvas, img.data, img.width, img.height, { gamma: sqrt.checked ? 0.5 : 1, lut: LUT.magma }); + const zOf = (p) => p.plane_sums.map((v, i) => ({ z: (i - Math.floor(p.plane_sums.length / 2)) * data.dz_um, v })); + + const chart = responsive(ui.plot, (width) => { + const cur = confocal[state.i]; + const w = width > 520 ? Math.floor((width - 24) / 2) : width; + const sectioning = Plot.plot({ + ...baseOptions(w), + height: 200, + marginLeft: 44, + marginBottom: 38, + x: { label: "distance from focus (µm)", labelAnchor: "center", labelArrow: "none", labelOffset: 34 }, + y: { domain: [0, 1.05], label: "signal from a thin plane", labelArrow: "none", grid: true, tickFormat: ".0%" }, + marks: [ + yRangeMark(), + Plot.lineY(zOf(widefield), { x: "z", y: "v", stroke: C.muted, strokeWidth: 1.5, strokeDasharray: "5,4" }), + Plot.lineY(zOf(cur), { x: "z", y: "v", stroke: C.series[1], strokeWidth: 2.25 }), + ], + }); + const rows = confocal.flatMap((p) => [ + { au: p.pinhole_au, nm: p.fwhm_lateral_nm, kind: "lateral" }, + { au: p.pinhole_au, nm: p.fwhm_axial_nm, kind: "axial" }, + ]); + const resolution = Plot.plot({ + ...baseOptions(w), + height: 200, + marginLeft: 44, + marginBottom: 38, + x: { label: "pinhole (AU)", labelAnchor: "center", labelArrow: "none", labelOffset: 34 }, + y: { domain: [0, 560], label: "FWHM (nm)", labelArrow: "none", grid: true }, + marks: [ + Plot.ruleY([widefield.fwhm_lateral_nm, widefield.fwhm_axial_nm], { stroke: C.muted, strokeDasharray: "5,4" }), + Plot.lineY(rows, { x: "au", y: "nm", z: "kind", stroke: C.series[1], strokeWidth: 2 }), + Plot.text(rows.filter((r) => r.au === 3), { x: "au", y: "nm", text: "kind", dy: -9, textAnchor: "end", fill: C.muted }), + Plot.dot(rows.filter((r) => r.au === cur.pinhole_au), { x: "au", y: "nm", r: 4.5, fill: C.series[1], stroke: C.surface, strokeWidth: 2 }), + ], + }); + const box = document.createElement("div"); + box.className = "ms-chart__panels"; + box.append(sectioning, resolution); + ui.plot.replaceChildren(box); + crosshair(ui.plot, sectioning, { + x: sectioning.scale("x"), + content: (z) => { + const at = (p) => zOf(p).reduce((a, b) => (Math.abs(b.z - z) < Math.abs(a.z - z) ? b : a)); + return tooltipHTML(`${fmt.sig(at(cur).z, 2)} µm from focus`, [ + { label: "widefield", value: fmt.pct(at(widefield).v), color: C.muted }, + { label: `confocal, ${cur.pinhole_au} AU`, value: fmt.pct(at(cur).v), color: C.series[1] }, + ]); + }, + }); + }); + + function update() { + const cur = confocal[state.i]; + draw(views.wfXY, widefield.xy); + draw(views.wfXZ, widefield.xz); + draw(views.cfXY, cur.xy); + draw(views.cfXZ, cur.xz); + views.cfXY.label.textContent = `confocal ${cur.pinhole_au} AU · XY`; + views.cfXZ.label.textContent = `confocal ${cur.pinhole_au} AU · XZ`; + setThru(fmt.pct(cur.peak)); + setLat(`${fmt.int(cur.fwhm_lateral_nm)} nm (widefield ${fmt.int(widefield.fwhm_lateral_nm)})`); + setAx(`${fmt.int(cur.fwhm_axial_nm)} nm (widefield ${fmt.int(widefield.fwhm_axial_nm)})`); + chart.redraw(); + } + update(); +} diff --git a/docs/javascripts/physics/detectors.js b/docs/javascripts/physics/detectors.js index 434e7b07..bcc4796f 100644 --- a/docs/javascripts/physics/detectors.js +++ b/docs/javascripts/physics/detectors.js @@ -5,6 +5,8 @@ // other, and run `uv run pytest tests/test_js_physics.py`. See // .claude/skills/check-js-physics/SKILL.md. +import * as R from "./random.js"; + // CameraEMCCD.excess_noise_factor (src/microsim/schema/detectors/_camera.py): // F = 2 (G - 1) G^(-(N + 1) / N) + 1 / G, for mean gain G over N stages export function emccdExcessNoiseFactor(gain, stages = 604) { @@ -50,3 +52,31 @@ export function snr({ electrons, dark = 0, readNoise = 0, gain = 1, enf = 1, ave const read = (readNoise ** 2 * averaging) / gain ** 2; return signal / Math.sqrt(shot + read); } + +// One frame from a camera, following _Detector.simulate step by step, for an image +// of `expected` mean photoelectrons per pixel (QE and exposure already applied). +// Not modeled here: averaging, binning, dark current, and clock-induced charge. +// 1-2. shot noise: Poisson +// 3. full well: clip at `fullWell` electrons +// 4. multiplication (apply_multiplication_gain): Gamma(n / (F - 1), G (F - 1)), +// rounded; exactly n * G if F = 1 +// 5. readout: Gaussian read noise, / electrons_per_adu, round, clip to the bit +// depth, + offset, clip at 0 and at the maximum +export function simulateCamera(expected, p, rand) { + const { readNoise = 6, gain = 1, enf = 1, electronsPerAdu = 1, offset = 100 } = p; + const { bitDepth = 16, fullWell = Infinity } = p; + const maxGray = 2 ** bitDepth - 1; + const out = new Float64Array(expected.length); + for (let i = 0; i < expected.length; i++) { + let e = R.poisson(rand, Math.max(expected[i], 0)); + e = Math.min(e, fullWell); + if (gain !== 1) { + if (enf - 1 < 1e-9) e = Math.round(e * gain); + else if (e > 0) e = Math.round(R.gamma(rand, e / (enf - 1), gain * (enf - 1))); + } + const voltage = e + readNoise * R.normal(rand); + const signal = Math.min(Math.round(voltage / electronsPerAdu), maxGray); + out[i] = Math.min(Math.max(signal + offset, 0), maxGray); + } + return out; +} diff --git a/docs/javascripts/physics/random.js b/docs/javascripts/physics/random.js new file mode 100644 index 00000000..d31c85b7 --- /dev/null +++ b/docs/javascripts/physics/random.js @@ -0,0 +1,85 @@ +// Random number generators for docs/javascripts/physics/detectors.js. Same +// distributions as the scipy/numpy samplers microsim uses +// (src/microsim/schema/backend.py: poisson_rvs, norm_rvs; scipy.stats.gamma in +// detectors/_detector.py apply_multiplication_gain), not the same streams. + +// mulberry32: a small, fast, seedable uniform generator on [0, 1) +export function rng(seed = 0) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// standard normal, Box-Muller +export function normal(rand) { + let u = 0; + while (u === 0) u = rand(); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rand()); +} + +// log(k!) via Stirling's series, exact table for small k +const LOG_FACT = [0, 0, 0.6931471805599453, 1.791759469228055, 3.1780538303479458, + 4.787491742782046, 6.579251212010101, 8.525161361065415, 10.60460290274525, + 12.801827480081469]; +function logFactorial(k) { + if (k < LOG_FACT.length) return LOG_FACT[k]; + const x = k + 1; + return (x - 0.5) * Math.log(x) - x + 0.5 * Math.log(2 * Math.PI) + 1 / (12 * x) - 1 / (360 * x ** 3); +} + +// Poisson: multiplication for small means, PTRS (Hörmann 1993) for large, as numpy +export function poisson(rand, lam) { + if (lam <= 0) return 0; + if (lam < 10) { + const l = Math.exp(-lam); + let k = 0; + let p = rand(); + while (p > l) { + k++; + p *= rand(); + } + return k; + } + const slam = Math.sqrt(lam); + const loglam = Math.log(lam); + const b = 0.931 + 2.53 * slam; + const a = -0.059 + 0.02483 * b; + const invalpha = 1.1239 + 1.1328 / (b - 3.4); + const vr = 0.9277 - 3.6224 / (b - 2); + for (;;) { + const u = rand() - 0.5; + const v = rand(); + const us = 0.5 - Math.abs(u); + const k = Math.floor(((2 * a) / us + b) * u + lam + 0.43); + if (us >= 0.07 && v <= vr) return k; + if (k < 0 || (us < 0.013 && v > us)) continue; + if (Math.log(v) + Math.log(invalpha) - Math.log(a / (us * us) + b) <= -lam + k * loglam - logFactorial(k)) { + return k; + } + } +} + +// Gamma(shape, scale): Marsaglia & Tsang 2000, boosted for shape < 1 +export function gamma(rand, shape, scale = 1) { + if (shape < 1) return gamma(rand, shape + 1, scale) * rand() ** (1 / shape); + const d = shape - 1 / 3; + const c = 1 / Math.sqrt(9 * d); + for (;;) { + let x; + let v; + do { + x = normal(rand); + v = 1 + c * x; + } while (v <= 0); + v = v ** 3; + const u = rand(); + if (u < 1 - 0.0331 * x ** 4 || Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) { + return d * v * scale; + } + } +} diff --git a/docs/stylesheets/charts.css b/docs/stylesheets/charts.css index 9bcb8b7e..21fd79c8 100644 --- a/docs/stylesheets/charts.css +++ b/docs/stylesheets/charts.css @@ -470,3 +470,79 @@ flex-wrap: wrap; gap: 0.5rem 24px; } + +/* --- image panels and buttons ------------------------------------------- */ + +.ms-panels { + display: grid; + grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); + gap: 0.6rem; +} + +.ms-panel canvas { + display: block; + width: 100%; + height: auto; + border-radius: 6px; + background: #000; + image-rendering: pixelated; +} + +.ms-panel--smooth canvas { + image-rendering: auto; +} + +.ms-panel__label { + margin-top: 0.3rem; + font-size: 0.62rem; + color: var(--ms-ink-muted); + font-variant-numeric: tabular-nums; +} + +.ms-button { + font: inherit; + font-size: 0.66rem; + font-weight: 600; + color: var(--ms-ink); + background: var(--ms-control); + border: 1px solid var(--ms-control-border); + border-radius: 6px; + padding: 0.32rem 0.7rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.ms-button:hover, +.ms-button:focus-visible { + border-color: var(--md-accent-fg-color); + color: var(--md-accent-fg-color); + outline: none; +} + +.ms-control[hidden] { + display: none; +} + +.ms-check { + flex-direction: row; + align-items: center; + gap: 0.4rem; + min-width: 0; + font-size: 0.62rem; + color: var(--ms-ink-muted); + cursor: pointer; +} + +.ms-check input { + accent-color: var(--md-accent-fg-color); +} + +.ms-control--button { + justify-content: flex-end; + align-items: flex-start; + min-width: 0; +} + +.ms-legend__note { + margin-right: auto; +} diff --git a/scripts/make_doc_figures.py b/scripts/make_doc_figures.py index 9169fe07..b88f8b73 100644 --- a/scripts/make_doc_figures.py +++ b/scripts/make_doc_figures.py @@ -12,6 +12,7 @@ from __future__ import annotations +import base64 import json import os import re @@ -79,6 +80,12 @@ def spectrum_json(spec: ms.Spectrum) -> dict: return {"w": w.tolist(), "v": v} +def image_json(img: np.ndarray) -> dict: + """An image normalized to its maximum, as base64 little-endian uint16.""" + q = np.round(np.clip(img / img.max(), 0, 1) * 65535).astype(" np.ndarray: a, b = np.percentile(img, (lo, hi)) return np.clip((img - a) / (b - a + 1e-12), 0, 1) @@ -318,36 +325,6 @@ def detector_data() -> None: ) -@figure -def psf() -> None: - """Widefield vs confocal PSFs (XZ and XY sections).""" - obj = ms.ObjectiveLens(numerical_aperture=1.4) - kw = {"nz": 65, "nx": 65, "dx": 0.04, "dz": 0.04, "objective": obj} - psfs = { - "widefield": make_psf(em_wvl_nm=525, **kw), - "confocal, 1 AU": make_psf(ex_wvl_nm=488, em_wvl_nm=525, pinhole_au=1, **kw), - "confocal, 0.25 AU": make_psf( - ex_wvl_nm=488, em_wvl_nm=525, pinhole_au=0.25, **kw - ), - } - fig, axes = plt.subplots( - 2, 3, figsize=(9, 6.2), gridspec_kw={"hspace": 0.12, "wspace": 0.05} - ) - for col, (name, p) in enumerate(psfs.items()): - p = _np(p) - p = p / p.max() - mid = p.shape[0] // 2 - kws = {"cmap": "magma", "vmin": 0, "vmax": 1, "interpolation": "bilinear"} - axes[0, col].imshow(p[mid] ** 0.5, **kws) - image_axes(axes[0, col], f"{name} · XY") - axes[1, col].imshow(p[:, mid] ** 0.5, **kws) - image_axes(axes[1, col], "XZ") - # 1 µm scale bar - axes[1, 0].plot([5, 30], [60, 60], color="white", lw=2) - axes[1, 0].text(17.5, 57, "1 µm", color="white", ha="center", fontsize=8) - save(fig, OUT / "psf-comparison.png") - - def _lines_sim(**kwargs: object) -> ms.Simulation: return ms.Simulation( truth_space=ms.ShapeScaleSpace(shape=(64, 256, 256), scale=(0.04, 0.02, 0.02)), @@ -380,32 +357,71 @@ def modalities() -> None: @figure -def detectors() -> None: - """One dim optical image through different cameras.""" - sim = _lines_sim() - optical = sim.optical_image() - # scale so the brightest output pixel expects ~20 photons in the exposure - expected = sim.digital_image(optical, with_detector_noise=False) - optical = optical * (20 / float(expected.max())) - cams = { - "expected photons (no noise)": None, - "CCD, 6 e⁻ read noise": ms.CameraCCD( - qe=1, read_noise=6, electrons_per_adu=1, bit_depth=16 - ), - "sCMOS, 1.5 e⁻ read noise": ms.CameraCMOS( - qe=1, read_noise=1.5, electrons_per_adu=0.5, bit_depth=16 - ), - "EMCCD, EM gain 300": ms.CameraEMCCD( - qe=1, read_noise=50, em_gain=300, electrons_per_adu=20, bit_depth=16 - ), - } - fig, axes = plt.subplots(1, 4, figsize=(13, 3.6), gridspec_kw={"wspace": 0.04}) - for ax, (name, det) in zip(axes, cams.items(), strict=True): - sim.detector = det - img = _np(sim.digital_image(optical, with_detector_noise=det is not None)) - ax.imshow(norm(img[0, img.shape[1] // 2], 0.5, 99.8), cmap="gray") - image_axes(ax, name) - save(fig, OUT / "detectors.png") +def noise_data() -> None: + """Noise-free expected image (ER channel of the HeLa cell), for the noise chart.""" + sim = _cell_sim() + expected = sim.digital_image(sim.optical_image(), with_detector_noise=False) + write_json("noise", {"image": image_json(_np(expected)[0, 9].astype(float))}) + + +PINHOLES_AU = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3] + + +def _fwhm(profile: np.ndarray, step: float) -> float: + """Full width at half maximum of a peaked 1D profile, linearly interpolated.""" + half = profile.max() / 2 + i = int(np.argmax(profile)) + lo = i + while lo > 0 and profile[lo - 1] > half: + lo -= 1 + hi = i + while hi < len(profile) - 1 and profile[hi + 1] > half: + hi += 1 + left = lo - (profile[lo] - half) / (profile[lo] - profile[lo - 1]) + right = hi + (profile[hi] - half) / (profile[hi] - profile[hi + 1]) + return float((right - left) * step) + + +@figure +def pinhole_data() -> None: + """Widefield and confocal PSFs across pinhole sizes, for the pinhole chart.""" + obj = ms.ObjectiveLens(numerical_aperture=1.4) + # fine enough to resolve small pinholes, and wide enough (6 µm) that defocused + # widefield planes (up to ±0.96 µm) keep their light + dx, dz = 0.02, 0.04 + kw = {"nz": 49, "nx": 301, "dx": dx, "dz": dz, "objective": obj, "em_wvl_nm": 525} + psfs = {"widefield": make_psf(**kw)} + for au in PINHOLES_AU: + psfs[au] = make_psf(ex_wvl_nm=488, pinhole_au=au, **kw) + + entries = [] + for key, psf in psfs.items(): + p = _np(psf).astype(float) + cz, cy = p.shape[0] // 2, p.shape[1] // 2 + crop = slice(cy - 40, cy + 41, 2) # 1.6 µm, displayed at 40 nm + planes = p.sum(axis=(1, 2)) + entries.append( + { + "pinhole_au": None if key == "widefield" else key, + "peak": float(p[cz, cy, cy]), + "fwhm_lateral_nm": 1000 * _fwhm(p[cz, cy, :], dx), + "fwhm_axial_nm": 1000 * _fwhm(p[:, cy, cy], dz), + "plane_sums": [round(float(x), 5) for x in planes / planes.max()], + "xy": image_json(p[cz, crop, crop]), + "xz": image_json(p[cz - 20 : cz + 21, cy, crop]), # 1.6 µm square + } + ) + write_json( + "pinhole", + { + "na": obj.numerical_aperture, + "ex_nm": 488, + "em_nm": 525, + "image_dx_um": 2 * dx, + "dz_um": dz, + "psfs": entries, + }, + ) @figure diff --git a/tests/js/camera.mjs b/tests/js/camera.mjs new file mode 100644 index 00000000..b2a180f3 --- /dev/null +++ b/tests/js/camera.mjs @@ -0,0 +1,16 @@ +// Moments of docs/javascripts/physics/detectors.js simulateCamera on flat images, +// for tests/test_js_physics.py. +import { readFileSync } from "node:fs"; +import * as D from "../../docs/javascripts/physics/detectors.js"; +import { rng } from "../../docs/javascripts/physics/random.js"; + +const quantile = (sorted, q) => sorted[Math.floor(q * (sorted.length - 1))]; +const input = JSON.parse(readFileSync(0, "utf8")); +const out = input.map(({ electrons, params, size }, i) => { + const img = D.simulateCamera(new Float64Array(size).fill(electrons), params, rng(i + 1)); + const mean = img.reduce((s, x) => s + x, 0) / size; + const variance = img.reduce((s, x) => s + (x - mean) ** 2, 0) / size; + const sorted = Float64Array.from(img).sort(); + return { mean, var: variance, quantiles: [0.05, 0.5, 0.95].map((q) => quantile(sorted, q)) }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/tests/test_js_physics.py b/tests/test_js_physics.py index 64fffd84..0d398fd9 100644 --- a/tests/test_js_physics.py +++ b/tests/test_js_physics.py @@ -220,3 +220,48 @@ def test_detectors() -> None: _measured_snr(det, n) for det, _ in SNR_CASES for n in (0.5, 5, 50, 500) ] np.testing.assert_allclose(js["snr"], measured, rtol=0.03) + + +CAMERA_CASES = [ + # (electrons per pixel, camera) + (20, ms.CameraCCD(read_noise=6, electrons_per_adu=1, bit_depth=16)), + (3, ms.CameraCMOS(read_noise=1.5, electrons_per_adu=0.5, bit_depth=16)), + (200, ms.CameraCMOS(read_noise=0, electrons_per_adu=1, bit_depth=16)), + (120, ms.CameraCCD(full_well=100, read_noise=2, electrons_per_adu=1)), + (0.5, ms.CameraEMCCD(em_gain=300, read_noise=50, electrons_per_adu=20)), + (5, ms.CameraEMCCD(em_gain=300, read_noise=50, electrons_per_adu=20)), + (5, ms.CameraEMCCD(em_gain=2, read_noise=1, electrons_per_adu=1)), +] + + +def _camera_params(cam: ms.Detector) -> dict: + return { + "readNoise": cam.read_noise, + "gain": cam.multiplication_gain, + "enf": cam.excess_noise_factor, + "electronsPerAdu": cam.conversion_factor, + "offset": cam.offset, + "bitDepth": cam.bit_depth, + "fullWell": cam.full_well, + } + + +def test_camera_simulation() -> None: + size = 200_000 + payload = [ + {"electrons": n, "params": _camera_params(cam), "size": size} + for n, cam in CAMERA_CASES + ] + js = _run("camera.mjs", payload) + for (n, cam), result in zip(CAMERA_CASES, js, strict=True): + cam = cam.model_copy(update={"dark_current": 0}) + xp = NumpyAPI() + xp.set_random_seed(0) + image = DataArray(np.full((1, 1, 1, size), n), dims=("c", "z", "y", "x")) + py = np.asarray(cam.render(image, exposure_ms=1000, xp=xp), dtype=float) + assert result["mean"] == pytest.approx(py.mean(), rel=0.01), cam + assert result["var"] == pytest.approx(py.var(), rel=0.04), cam + expected_q = np.quantile(py, [0.05, 0.5, 0.95]) + np.testing.assert_allclose( + result["quantiles"], expected_q, atol=1.01, rtol=0.02 + ) From 6b56f31962e45d32c885f1dabb87e44c87b7d8db Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 11:38:49 +0200 Subject: [PATCH 08/28] docs: update check-js-physics skill for the camera simulation Co-Authored-By: Claude Opus 5.5 --- .claude/skills/check-js-physics/SKILL.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.claude/skills/check-js-physics/SKILL.md b/.claude/skills/check-js-physics/SKILL.md index bfa3c470..ea99cfe2 100644 --- a/.claude/skills/check-js-physics/SKILL.md +++ b/.claude/skills/check-js-physics/SKILL.md @@ -11,8 +11,9 @@ code is a copy, and copies drift. This skill is how to check it. ## Layout -- `docs/javascripts/physics/*.js`: the copied physics. Pure functions, no DOM, no - imports. Every function has a comment naming the Python it mirrors. +- `docs/javascripts/physics/*.js`: the copied physics. Pure functions, no DOM, and + no imports from outside `physics/`. Every function has a comment naming the + Python it mirrors. - `docs/javascripts/charts/*.js`: rendering. These should contain **no physics**: they call the physics modules, and only choose parameters, axes, and labels. - `scripts/make_doc_figures.py`: exports the data the charts load @@ -48,6 +49,13 @@ code is a copy, and copies drift. This skill is how to check it. | `detectors.js` `pmtGain`, `pmtExcessNoiseFactor` | `detectors/_point.py` `PMT.multiplication_gain`, `.excess_noise_factor` | | `detectors.js` `hydCountingEfficiency` | `detectors/_point.py` `HyD._counting_efficiency` | | `detectors.js` `snr` | derived from `detectors/_detector.py` `_Detector.simulate` and `apply_multiplication_gain` (tested against a Monte Carlo run of `render`) | + | `detectors.js` `simulateCamera` | `_Detector.simulate` steps 1-5 for cameras, and `apply_multiplication_gain` (tested statistically against `render`: mean, variance, quantiles) | + | `random.js` `poisson`, `normal`, `gamma` | the distributions of `backend.py` `poisson_rvs`, `norm_rvs`, and `scipy.stats.gamma` (same distributions, not the same random streams) | + + Charts that only display arrays computed by microsim (`pinhole.js`, the noise-free + image in `noise.js`) have nothing to mirror; check instead that the export + function in `scripts/make_doc_figures.py` uses microsim, and that captions + describe what was computed. If the Python has gained a step (a new factor, a new parameter that changes the result), the JavaScript needs it too, or the chart's caption must say what it From 07346fa2c470cfda27d04d8e3b3f60917eb0b74c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 14:35:11 +0200 Subject: [PATCH 09/28] docs: review fixes (wording, tables, crosstalk example, chart tweaks) - wording fixes across getting started, channels, samples, modalities - correct what CosemLabel values mean; FPbase name matching; EGFP size - API parameter tables fit the page; prose tables don't wrap names - bleedthrough: interactive crosstalk matrix, and an EGFP/tdTomato example with bandpass vs longpass emission filters - example output of the spectra plotting methods - fix swapped axis labels in OpticalConfig.plot_emission - confocal PSF equation; pinhole explorer can show the signal lost - photon budget: drop specimen RI slider; saturation chart ranges Co-Authored-By: Claude Opus 5.5 --- docs/getting-started/first-simulation.md | 11 +- docs/getting-started/installation.md | 2 - docs/guide/channels.md | 102 ++++++++++++--- docs/guide/modalities.md | 26 +++- docs/guide/photometry.md | 11 +- docs/guide/samples.md | 50 +++++-- docs/index.md | 2 +- docs/javascripts/charts/crosstalk.js | 129 +++++++++++++++++++ docs/javascripts/charts/photon-budget.js | 11 +- docs/javascripts/charts/pinhole.js | 22 +++- docs/javascripts/charts/saturation.js | 6 +- docs/javascripts/charts/spectra.js | 10 -- docs/stylesheets/charts.css | 57 ++++++++ docs/stylesheets/extra.css | 45 ++++++- scripts/make_doc_figures.py | 82 ++++++++++++ src/microsim/schema/optical_config/config.py | 3 +- 16 files changed, 486 insertions(+), 83 deletions(-) create mode 100644 docs/javascripts/charts/crosstalk.js diff --git a/docs/getting-started/first-simulation.md b/docs/getting-started/first-simulation.md index 2db73ef7..3537c843 100644 --- a/docs/getting-started/first-simulation.md +++ b/docs/getting-started/first-simulation.md @@ -56,8 +56,8 @@ Everything not given has a default value. In this case: !!! tip "Dictionaries work too" - Any parameter can be given as a dictionary (or loaded from JSON) instead - of an object. These are equivalent: + As usual with pydantic models, any parameter can be given as a dictionary + (or loaded from JSON) instead of an object. These are equivalent: ```python output_space = ms.DownscaledSpace(downscale=8) @@ -70,8 +70,8 @@ Everything not given has a default value. In this case: ## Look at each stage -`run()` is shorthand for the last of four methods. Each one can be called -separately, and each returns an [`xarray.DataArray`][xarray.DataArray] with +`run()` computes four stages in order and returns the last one. Each stage +also has its own method, and each returns an [`xarray.DataArray`][xarray.DataArray] with named dimensions and physical coordinates (µm): | Method | Dimensions | Units | @@ -144,7 +144,8 @@ with a 0.5 AU pinhole. Each panel is scaled to its own range. ## Reproducibility -The random lines and the noise come from a random number generator. To get the +The random lines in the sample and the noise come from a random number +generator. To get the same result every time, set a seed: ```py diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 90e14c25..cae487e3 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -4,8 +4,6 @@ icon: lucide/download # Installation -microsim requires Python 3.12 or later. - ## From PyPI ```bash diff --git a/docs/guide/channels.md b/docs/guide/channels.md index 1939895b..97a0de96 100644 --- a/docs/guide/channels.md +++ b/docs/guide/channels.md @@ -30,8 +30,9 @@ and the emission that passes the emission path. Hover for values. ## Built-in filter sets [`microsim.schema.optical_config.lib`][microsim.schema.optical_config.lib] -has models of common Chroma ET filter sets, built from ideal bandpass and -longpass filters: +has idealized versions of common filter sets, built from smooth bandpass and +longpass edges. They are not models of real filters, and are meant for quick +simulations; for real filters, load them from FPbase (below). | Name | Excitation | Dichroic | Emission | | ------- | ----------- | -------- | ---------- | @@ -48,9 +49,12 @@ Bandpass filters are written as center/width in nm. ## Filter sets from FPbase -[FPbase](https://www.fpbase.org/microscopes) has measured spectra for many -microscopes and commercial filter sets. Load an optical configuration with a -string of the form `"::"`: +FPbase lets you +[enter the optical configurations of your own microscope](https://www.fpbase.org/microscope/create/), +with measured spectra of its light sources, filters, and detectors, and has +[many microscopes](https://www.fpbase.org/microscopes/) entered by others. +These configurations can be loaded in microsim with a string of the form +`"::"`: ```py sim = ms.Simulation(..., channels=["wKqWbg::Widefield Green"]) @@ -59,7 +63,7 @@ ms.OpticalConfig.from_fpbase("wKqWbg", "Widefield Green") ``` The microscope ID is in the URL of its FPbase page, e.g. -`fpbase.org/microscope/wKqWbgApvguSNDSRZNSfpN/` (the first few characters are +[`fpbase.org/microscope/wKqWbgApvguSNDSRZNSfpN/`](https://www.fpbase.org/microscope/wKqWbgApvguSNDSRZNSfpN/) (the first few characters are enough). Configuration names are not case-sensitive. Some FPbase microscopes with filter catalogs: @@ -95,8 +99,8 @@ ms.OpticalConfig( | `placement` | Excitation path | Emission path | Typical use | | ----------- | ----------------------- | ----------------------- | ------------------------------ | -| `"EX"` | transmission | — | excitation filter | -| `"EM"` | — | transmission | emission filter | +| `"EX"` | transmission | (not used) | excitation filter | +| `"EM"` | (not used) | transmission | emission filter | | `"BS"` | 1 − transmission | transmission | dichroic that reflects excitation | | `"BSi"` | transmission | 1 − transmission | dichroic that reflects emission | | `"ALL"` | transmission | transmission | e.g. a neutral density filter | @@ -132,7 +136,7 @@ Rough orders of magnitude for the irradiance at the sample: | ----------------------------------- | ----------------------- | | widefield, arc lamp or LED | 0.1–10 W/cm² | | widefield, laser | 10–1000 W/cm² | -| point-scanning confocal, at focus | 1–100 kW/cm² and above | +| point-scanning confocal, at focus | 1–1000 kW/cm² and above | For point-scanning modalities, `power` is the peak irradiance at the center of the focused spot. See [Modalities](modalities.md#confocal). @@ -158,33 +162,89 @@ channels = [ ## Bleedthrough and cross-excitation microsim computes every fluorophore in every channel, so crosstalk needs no -extra setup. A fluorophore excited by another channel's light, or whose -emission tail passes another channel's filter, appears in that channel in -proportion to the overlap of the spectra. +extra setup. A fluorophore excited by another channel's light +(**cross-excitation**), or whose emission passes another channel's filter +(**bleedthrough**), appears in that channel in proportion to the overlap of the +spectra. -To see where the signal in each channel comes from, use +
+/// caption +Detected photons per molecule for two fluorophores in two channels. Dashed +cells are crosstalk: the fluorophore gives less than half of that channel's +signal. tdTomato absorbs only weakly in the FITC excitation band, but it is +bright, and a 500 nm longpass emission filter passes nearly all of its +emission: per molecule, it gives almost as much signal in the green channel as +EGFP. With the 525/50 bandpass emission filter of `FITC`, its share drops below +1%. Select `FITC` as channel 1 to compare. +/// + +To see where the signal in each channel comes from in an image, use [`optical_image_per_fluor()`][microsim.Simulation.optical_image_per_fluor], which keeps the fluorophore dimension: ```py +sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(64, 512, 512), scale=0.064), + sample=[ + ms.FluorophoreDistribution( + distribution=ms.CosemLabel(dataset="jrc_hela-3", label="er-mem_pred"), + fluorophore="EGFP", + ), + ms.FluorophoreDistribution( + distribution=ms.CosemLabel(dataset="jrc_hela-3", label="mito-mem_pred"), + fluorophore="tdTomato", + ), + ], + channels=[lib.FITC, fitc_longpass], # (1)! +) per_fluor = sim.optical_image_per_fluor() # (c, f, z, y, x) per_fluor.sum(["z", "y", "x"]) # total photons/s from each fluorophore in each channel ``` +1. `fitc_longpass` is `lib.FITC` with its emission filter replaced by + `ms.Longpass(cuton=500, placement="EM")`. + +![EGFP and tdTomato in two green channels](../images/generated/crosstalk.png) +/// caption +A HeLa cell with EGFP on the ER and tdTomato on the mitochondria, recorded in +the green channel with a 525/50 bandpass (top) and a 500 nm longpass (bottom) +emission filter. Each row has one display range, so each panel shows its true +contribution. With the longpass filter, the mitochondria appear in the green +channel. +/// + ## Plotting spectra -These need the `view` extra: +These need the `view` extra. For one channel: + +```py +oc = ms.OpticalConfig.from_fpbase("wKqWbg", "Widefield Green") +oc.plot() # all filter spectra +``` + +![Output of OpticalConfig.plot](../images/generated/plot-config.png){ .plot } + +```py +fig, axes = plt.subplots(1, 2, figsize=(12, 3.2)) +oc.plot_excitation( + ax=axes[0] +) # light source and excitation filters, and the photon flux +oc.plot_emission(ax=axes[1]) # emission filters, and their combined transmission +``` + +![Output of plot_excitation and plot_emission](../images/generated/plot-paths.png){ .plot } + +For a whole simulation, [`Simulation.plot()`][microsim.Simulation.plot] shows, +for each channel (columns): the absorption cross sections, the excitation +path, the absorption rates (with the emission spectra, dashed), the emission +path, and the photons collected from each fluorophore. Here EGFP and mCherry +in the `FITC` and `TRITC` channels: ```py -lib.FITC.plot() # all filter spectra of one channel -lib.FITC.plot_excitation() # excitation path, with the combined photon flux -lib.FITC.plot_emission() # emission path, with the combined transmission -sim.plot() # summary of every fluorophore in every channel +sim.plot() ``` -[`Simulation.plot()`][microsim.Simulation.plot] shows, for each channel: -absorption spectra, the excitation path, absorption and emission rates, the -emission path, and the photons collected from each fluorophore. +![Output of Simulation.plot](../images/generated/plot-simulation.png){ .plot } ## Spectral detection diff --git a/docs/guide/modalities.md b/docs/guide/modalities.md index 866ffa7b..7a3e50c4 100644 --- a/docs/guide/modalities.md +++ b/docs/guide/modalities.md @@ -20,17 +20,19 @@ sim = ms.Simulation(..., modality=ms.Confocal(pinhole_au=1)) ![The same sample in widefield and confocal](../images/generated/modalities.png) /// caption -The same random lines in widefield and in confocal with a 1 AU pinhole (XY -plane through the middle of the volume, and an XZ section). The confocal -removes most of the out-of-focus light. +The optical image of the same random lines in widefield and in confocal with a +1 AU pinhole (XY plane through the middle of the volume, and an XZ section). +This is the noise-free optical image, in the truth space: before binning to +the output pixels, and before the detector. The confocal removes most of the +out-of-focus light. /// ## Widefield The whole field is illuminated uniformly and imaged onto a camera. Each fluorophore's emission is convolved with the emission PSF. Every plane of the -PSF sums to about 1, so a defocused point source spreads its light over more -pixels without losing it. +PSF sums to about 1: a defocused point source spreads its light over more +pixels, but the total light recorded from it is the same at any focus. ## Confocal @@ -43,6 +45,16 @@ pinhole before reaching a point detector. The confocal PSF is the product of which is the fraction of collected light that passes the pinhole for an emitter at each position. +\[ +\text{PSF}_\text{confocal}(x, y, z) = \text{PSF}_\text{ex}(x, y, z) +\cdot \big[\text{PSF}_\text{em}(\cdot, \cdot, z) \ast P\big](x, y) +\] + +where \(P\) is the pinhole, a disk of diameter `pinhole_au`, and \(\ast\) is a +2D convolution in each plane. \(\text{PSF}_\text{ex}\) is 1 at the focus, +and \(\text{PSF}_\text{em}\) sums to 1 in each plane, so the confocal PSF at +the focus is the fraction of the collected light that passes the pinhole. + `pinhole_au` is the pinhole diameter in Airy units, where 1 AU is 1.22 λem / NA, measured in the sample. Smaller pinholes reject more out-of-focus light and slightly improve resolution, at the cost of signal. @@ -63,7 +75,9 @@ For a point-scanning modality:
/// caption Widefield and confocal PSFs computed by microsim at 1.4 NA, 525 nm emission and -488 nm excitation, each scaled to its own peak. *Signal from a thin plane*: +488 nm excitation, each scaled to its own peak. With *show the signal lost at +the pinhole*, the confocal PSF is instead scaled by the fraction of the +collected light that passes the pinhole, so it dims as the pinhole closes. *Signal from a thin plane*: the light recorded from a thin, uniform fluorescent layer at each distance from the focus. In widefield it is the same at every depth, so out-of-focus planes contribute as much as the focal plane; the pinhole removes it, more so the diff --git a/docs/guide/photometry.md b/docs/guide/photometry.md index fc5fc80b..67e07252 100644 --- a/docs/guide/photometry.md +++ b/docs/guide/photometry.md @@ -12,10 +12,13 @@ before anything spatial is computed.
/// caption Photons absorbed, emitted, and detected per second by one molecule, calculated -as microsim does. With the defaults (EGFP, FITC channel, 1 W/cm², 1.4 NA, no -detector QE), one molecule gives about 6 photons in a 100 ms exposure. Typical -widefield irradiance is 0.1–10 W/cm²; the peak irradiance of a confocal spot -is 10³–10⁵ W/cm². Fluorophores without a known lifetime do not saturate. +as microsim does, for a specimen refractive index of 1.47 (the +`ObjectiveLens` default). With the defaults (EGFP, FITC channel, 1 W/cm², +1.4 NA, no detector QE), one molecule gives about 6 photons in a 100 ms +exposure. Typical widefield irradiance is 0.1–10 W/cm²; the peak irradiance of +a confocal spot is 1–1000 kW/cm² and above +([Channels](channels.md#illumination-power)). Fluorophores without a known +lifetime do not saturate. /// ## In short diff --git a/docs/guide/samples.md b/docs/guide/samples.md index 587e093e..ebe0fb9b 100644 --- a/docs/guide/samples.md +++ b/docs/guide/samples.md @@ -139,23 +139,31 @@ import numpy as np from microsim import schema as ms -class Beads: - """Point emitters at random positions.""" +class PointSources: + """Single-voxel emitters at random positions.""" - def __init__(self, n: int = 100, fluors_per_bead: int = 500): + def __init__(self, n: int = 100, fluors_per_point: int = 500): self.n = n - self.fluors_per_bead = fluors_per_bead + self.fluors_per_point = fluors_per_point def render(self, space, xp=None): idx = tuple(np.random.randint(0, s, self.n) for s in space.shape) data = np.zeros(space.shape, dtype=space.dtype) - np.add.at(data, idx, self.fluors_per_bead) + np.add.at(data, idx, self.fluors_per_point) return space + data -sample = [ms.FluorophoreDistribution(distribution=Beads(), fluorophore="mCherry")] +sample = [ + ms.FluorophoreDistribution(distribution=PointSources(), fluorophore="mCherry") +] ``` +Each emitter here occupies a single voxel of the truth space, so it is a true +point source, much smaller than the PSF. Fluorescent beads are often 100 nm or +larger, not much smaller than the PSF; to simulate those, fill a sphere of +voxels for each bead instead (as with the shells in the +[own ground truth tutorial](../tutorials/your-own-ground-truth.md)). + `np.random` is seeded from [`Settings.random_seed`](settings.md#reproducibility) before rendering, so this example is reproducible. Generators like this cannot be saved to JSON. To make @@ -179,8 +187,10 @@ coefficient, quantum yield, and lifetime are then loaded from ms.FluorophoreDistribution(distribution=..., fluorophore="mNeonGreen") ``` -Any fluorescent protein or dye on FPbase with spectra can be used. This needs -network access the first time. +Any fluorescent protein or dye on FPbase with spectra can be used. The name +must match the name FPbase displays (case doesn't matter, but other spellings +don't work: `"Alexa Fluor 488"`, not `"AF488"`); the error message suggests the +closest match. This needs network access the first time. To define a fluorophore yourself, give its spectra and photophysical properties: @@ -231,11 +241,25 @@ def patchy(truth): ms.FluorophoreDistribution(distribution=..., fluorophore="EGFP", concentration=patchy) ``` -Distributions such as `MatsLines` and `CosemLabel` put one fluorophore in each -labeled voxel. Brightness depends strongly on voxel size: a 10 nm voxel with -one fluorophore is a very sparse label, while real labeled structures can carry -hundreds to thousands of fluorophores per cubic micrometer. Use -`concentration` to bring the labeling density in line with your experiment. +What the values of a distribution mean depends on the distribution: + +- `MatsLines` puts one fluorophore in each voxel of a line (more where lines + cross). +- `CosemLabel` with a segmentation layer (names ending in `_seg`) gives the + number of labeled 4 nm source voxels inside each truth voxel: up to 4096 in + a 64 nm voxel. With a prediction layer (names ending in `_pred`), it gives + the stored 8-bit prediction score (0–255) at the matching resolution level. + Neither is a number of fluorophores. + +So the brightness of a simulation depends strongly on the distribution and the +voxel size, and `concentration` is how you bring the labeling density in line +with your experiment. For scale: an EGFP molecule is a barrel about 4.2 nm long +and 2.4 nm wide +([Yang, Moss & Phillips 1996](https://doi.org/10.1038/nbt1096-1246)), about +19 nm³. Even a 10 nm voxel (1000 nm³) could hold only about 50 of them packed +solid, and a 64 nm voxel about 14,000. Real labeled structures are much +sparser than that: often hundreds to thousands of fluorophores per cubic +micrometer. ## Caching diff --git a/docs/index.md b/docs/index.md index d84bf8fe..8de00ed9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,7 +41,7 @@ every parameter. Running it takes the sample through four stages: ``` mermaid flowchart LR - A["Sample
fluorophores per voxel"] --> B["Emission
photons/s per fluorophore,
per channel and wavelength"] + A["Sample
fluorophores per voxel,
in a high-resolution space"] --> B["Emission
photons/s per fluorophore,
per channel and wavelength"] B --> C["Optical image
photons/s, blurred
by the PSF"] C --> D["Digital image
gray values, with
pixelation and noise"] ``` diff --git a/docs/javascripts/charts/crosstalk.js b/docs/javascripts/charts/crosstalk.js new file mode 100644 index 00000000..6a77c513 --- /dev/null +++ b/docs/javascripts/charts/crosstalk.js @@ -0,0 +1,129 @@ +// Channels page, bleedthrough section: detected photons from two fluorophores in +// two channels, per molecule (OpticalConfig.filtered_emission_rate and the +// collection efficiency, via physics/photometry.js), and their emission spectra +// against each channel's emission path. +import * as P from "../physics/photometry.js"; +import { + C, + Plot, + baseOptions, + crosshair, + cssSpectrum, + d3, + fmt, + legendItem, + responsive, + select, + shell, + spectrumGradient, + tooltipHTML, + uniqueId, + wavelengthColor, + yRangeMark, +} from "./lib.js"; +import { loadPhotometry } from "./photometry-data.js"; + +const DOMAIN = [400, 750]; +const toRows = (s) => s.w.map((w, i) => ({ w, v: s.v[i] })); +const peakNormalized = (s) => P.scale(s, 1 / Math.max(...s.v)); + +function valueAt(s, nm) { + if (!s) return null; + const i = d3.bisector((d) => d).center(s.w, nm); + return Math.abs(s.w[i] - nm) <= 1 ? s.v[i] : 0; +} + +// detected photons/s per molecule, at 1 W/cm², 1.4 NA, specimen RI 1.47, QE 1 +function detected(channel, fluor) { + const b = P.photonBudget({ channel, fluor, power: 1, qe: null, na: 1.4, specimenRI: 1.47 }); + return P.total(b.collected); +} + +export default async function (el) { + const { fluors, channels } = await loadPhotometry(); + const ui = shell(el); + const state = { + f1: "EGFP", + f2: "tdTomato", + c1: "FITC, 500 LP emission", + c2: "TRITC", + }; + const redraw = () => chart.redraw(); + const pick = (label, key, options) => + select(ui.controls, { + label, + options, + value: state[key], + onInput: (v) => ((state[key] = v), redraw()), + }); + pick("Fluorophore 1", "f1", fluors.map((f) => f.name)); + pick("Fluorophore 2", "f2", fluors.map((f) => f.name)); + pick("Channel 1", "c1", channels.map((c) => c.name)); + pick("Channel 2", "c2", channels.map((c) => c.name)); + + const gid = uniqueId("ms-crosstalk"); + const fill = `url(#${gid})`; + const rainbow = cssSpectrum(DOMAIN); + legendItem(ui.legend, { label: "fluorophore 1 emission", color: rainbow, swatch: "area" }); + legendItem(ui.legend, { label: "fluorophore 2 emission", color: rainbow, swatch: "dash" }); + legendItem(ui.legend, { label: "channel 1 emission path", color: C.ink }); + legendItem(ui.legend, { label: "channel 2 emission path", color: C.muted, swatch: "dot" }); + + const table = document.createElement("table"); + table.className = "ms-matrix"; + ui.stats.append(table); + + const chart = responsive(ui.plot, (width) => { + const f = [state.f1, state.f2].map((n) => fluors.find((x) => x.name === n)); + const c = [state.c1, state.c2].map((n) => channels.find((x) => x.name === n)); + const rates = c.map((ch) => f.map((fl) => detected(ch, fl))); + + const head = `${f.map((fl) => `${fl.name}`).join("")}`; + const body = c + .map((ch, i) => { + const tot = rates[i][0] + rates[i][1]; + const cells = rates[i] + .map((r, j) => { + const share = tot ? r / tot : 0; + const cross = share < 0.5 ? " ms-matrix__cross" : ""; + return `${fmt.sig(r, 3)}`; + }) + .join(""); + return `${ch.name}${cells}`; + }) + .join(""); + table.innerHTML = `detected photons/s per molecule (1 W/cm², 1.4 NA, QE 1)${head}${body}`; + + const em = f.map((fl) => peakNormalized(fl.em)); + const paths = c.map((ch) => ch.emission); + const svg = Plot.plot({ + ...baseOptions(width), + height: Math.max(200, Math.min(280, width * 0.36)), + marginLeft: 40, + marginBottom: 38, + x: { domain: DOMAIN, label: "wavelength (nm)", labelAnchor: "center", labelArrow: "none", labelOffset: 34 }, + y: { domain: [0, 1.05], label: null, grid: true, ticks: 5, tickFormat: d3.format(".0%") }, + marks: [ + spectrumGradient(gid, DOMAIN), + yRangeMark(), + Plot.areaY(toRows(em[0]), { x: "w", y: "v", fill, fillOpacity: 0.45, clip: true }), + Plot.lineY(toRows(em[0]), { x: "w", y: "v", stroke: fill, strokeWidth: 2, clip: true }), + Plot.areaY(toRows(em[1]), { x: "w", y: "v", fill, fillOpacity: 0.12, clip: true }), + Plot.lineY(toRows(em[1]), { x: "w", y: "v", stroke: fill, strokeWidth: 2, strokeDasharray: "5,3", clip: true }), + paths[0] ? Plot.lineY(toRows(paths[0]), { x: "w", y: "v", stroke: C.ink, strokeWidth: 1.5, clip: true }) : null, + paths[1] ? Plot.lineY(toRows(paths[1]), { x: "w", y: "v", stroke: C.muted, strokeWidth: 1.5, strokeDasharray: "1.5,3", strokeLinecap: "round", clip: true }) : null, + ], + }); + ui.plot.replaceChildren(svg); + crosshair(ui.plot, svg, { + x: svg.scale("x"), + content: (nm) => + tooltipHTML(`${Math.round(nm)} nm`, [ + { label: `${f[0].name} emission`, value: fmt.pct(valueAt(em[0], nm)), color: wavelengthColor(nm) }, + { label: `${f[1].name} emission`, value: fmt.pct(valueAt(em[1], nm)), color: wavelengthColor(nm) }, + { label: c[0].name, value: paths[0] ? fmt.pct(valueAt(paths[0], nm)) : "1", color: C.ink }, + { label: c[1].name, value: paths[1] ? fmt.pct(valueAt(paths[1], nm)) : "1", color: C.muted }, + ]), + }); + }); +} diff --git a/docs/javascripts/charts/photon-budget.js b/docs/javascripts/charts/photon-budget.js index 27321a0a..e3a22c3a 100644 --- a/docs/javascripts/charts/photon-budget.js +++ b/docs/javascripts/charts/photon-budget.js @@ -55,7 +55,7 @@ export default async function (el) { detector: detectors[0].name, power: 1, // W/cm^2 na: 1.4, - ns: 1.47, + ns: 1.47, // ObjectiveLens.specimen_ri default exposure: 100, // ms }; const redraw = () => chart.redraw(); @@ -103,15 +103,6 @@ export default async function (el) { format: (v) => v.toFixed(2), onInput: (v) => ((state.na = v), redraw()), }); - slider(row2, { - label: "Specimen RI", - min: 1.33, - max: 1.52, - step: 0.01, - value: state.ns, - format: (v) => v.toFixed(2), - onInput: (v) => ((state.ns = v), redraw()), - }); slider(row2, { label: "Exposure", min: 0.001, diff --git a/docs/javascripts/charts/pinhole.js b/docs/javascripts/charts/pinhole.js index a9153827..a9366398 100644 --- a/docs/javascripts/charts/pinhole.js +++ b/docs/javascripts/charts/pinhole.js @@ -43,6 +43,12 @@ export default async function (el) { ui.controls.append(gammaSel); const sqrt = gammaSel.querySelector("input"); sqrt.addEventListener("input", () => update()); + const dimSel = document.createElement("label"); + dimSel.className = "ms-control ms-check"; + dimSel.innerHTML = ` show the signal lost at the pinhole`; + ui.controls.append(dimSel); + const dim = dimSel.querySelector("input"); + dim.addEventListener("input", () => update()); const panels = document.createElement("div"); panels.className = "ms-panels"; @@ -71,8 +77,13 @@ export default async function (el) { const setLat = stat(ui.stats, { label: "lateral FWHM" }); const setAx = stat(ui.stats, { label: "axial FWHM" }); - const draw = (view, img) => - drawImage(view.canvas, img.data, img.width, img.height, { gamma: sqrt.checked ? 0.5 : 1, lut: LUT.magma }); + // `brightness` scales the image, which is normalized to its own peak + const draw = (view, img, brightness = 1) => + drawImage(view.canvas, img.data, img.width, img.height, { + hi: 1 / brightness, + gamma: sqrt.checked ? 0.5 : 1, + lut: LUT.magma, + }); const zOf = (p) => p.plane_sums.map((v, i) => ({ z: (i - Math.floor(p.plane_sums.length / 2)) * data.dz_um, v })); const chart = responsive(ui.plot, (width) => { @@ -129,8 +140,11 @@ export default async function (el) { const cur = confocal[state.i]; draw(views.wfXY, widefield.xy); draw(views.wfXZ, widefield.xz); - draw(views.cfXY, cur.xy); - draw(views.cfXZ, cur.xz); + // with `dim`, the confocal peak is the fraction of collected light that passes + // the pinhole (1 for an open pinhole), as computed by microsim + const brightness = dim.checked ? cur.peak : 1; + draw(views.cfXY, cur.xy, brightness); + draw(views.cfXZ, cur.xz, brightness); views.cfXY.label.textContent = `confocal ${cur.pinhole_au} AU · XY`; views.cfXZ.label.textContent = `confocal ${cur.pinhole_au} AU · XZ`; setThru(fmt.pct(cur.peak)); diff --git a/docs/javascripts/charts/saturation.js b/docs/javascripts/charts/saturation.js index e69a3ed5..84683651 100644 --- a/docs/javascripts/charts/saturation.js +++ b/docs/javascripts/charts/saturation.js @@ -18,10 +18,10 @@ import { import { loadPhotometry } from "./photometry-data.js"; const POWERS = Array.from({ length: 181 }, (_, i) => 10 ** (-1 + i / 20)); // 0.1 to 1e8 -// typical irradiance, as in the photon budget caption (docs/guide/photometry.md) +// typical irradiance (see the table in docs/guide/channels.md) const RANGES = [ { label: "widefield", x1: 0.1, x2: 10 }, - { label: "confocal spot", x1: 1e3, x2: 1e5 }, + { label: "confocal spot", x1: 1e4, x2: 1e6 }, ]; const tick = (x) => (x < 1 ? `${x}` : fmt.si(x)); @@ -75,7 +75,7 @@ export default async function (el) { setHalf(`${fmt.si(pHalf)} W/cm²`); setMax(`${fmt.si(qy / tau)} photons/s`); - const yMax = Math.max(...rows.map((r) => r.saturated)) * 2; + const yMax = 1e9; const svg = Plot.plot({ ...baseOptions(width), height: Math.max(220, Math.min(300, width * 0.38)), diff --git a/docs/javascripts/charts/spectra.js b/docs/javascripts/charts/spectra.js index a8a11b42..67c10f3f 100644 --- a/docs/javascripts/charts/spectra.js +++ b/docs/javascripts/charts/spectra.js @@ -85,15 +85,6 @@ export default async function (el) { label: "emission passed", note: "fraction of emitted photons through the emission path", }); - const setRel = stat(ui.stats, { - label: "relative signal", - note: "ε × QY × both efficiencies, relative to EGFP in FITC", - }); - const ref = (() => { - const f = fluors.find((x) => x.name === "EGFP"); - const c = channels.find((x) => x.name === "FITC"); - return f.ec * f.qy * excitationEfficiency(c, f) * emissionFraction(c, f); - })(); const chart = responsive(ui.plot, (width) => { const fluor = fluors.find((f) => f.name === state.fluor); @@ -109,7 +100,6 @@ export default async function (el) { const emFrac = emissionFraction(channel, fluor); setEx(fmt.pct(exEff)); setEm(fmt.pct(emFrac)); - setRel(fmt.sig((fluor.ec * (fluor.qy || 1) * exEff * emFrac) / ref, 2) + "×"); const svg = Plot.plot({ ...baseOptions(width), diff --git a/docs/stylesheets/charts.css b/docs/stylesheets/charts.css index 21fd79c8..e5086ae8 100644 --- a/docs/stylesheets/charts.css +++ b/docs/stylesheets/charts.css @@ -546,3 +546,60 @@ .ms-legend__note { margin-right: auto; } + +/* --- 2 x 2 matrix (crosstalk) -------------------------------------------- */ + +.md-typeset table.ms-matrix { + display: table; + width: 100%; + margin: 0; + border: 0; + font-size: 0.66rem; + border-collapse: separate; + border-spacing: 0.4rem; + background: none; +} + +.md-typeset table.ms-matrix caption { + caption-side: top; + text-align: left; + font-size: 0.6rem; + color: var(--ms-ink-muted); + padding: 0 0.4rem; +} + +.md-typeset table.ms-matrix th { + font-size: 0.62rem; + font-weight: 600; + color: var(--ms-ink-muted); + background: none; + border: 0; + padding: 0.2rem 0.4rem; + text-align: left; + vertical-align: middle; +} + +.md-typeset table.ms-matrix td { + padding: 0.45rem 0.6rem; + border: 1px solid var(--ms-card-border); + border-radius: 8px; + background: var(--ms-surface); +} + +.md-typeset table.ms-matrix td.ms-matrix__cross { + border-color: var(--ms-c2); + border-style: dashed; +} + +.ms-matrix__value { + display: block; + font-size: 0.95rem; + font-weight: 600; + color: var(--ms-ink); + font-variant-numeric: tabular-nums; +} + +.ms-matrix__share { + font-size: 0.58rem; + color: var(--ms-ink-muted); +} diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 5c22240d..83ff33b5 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -60,6 +60,11 @@ text-align: left; } +/* code in the first column of a prose table (parameter names) doesn't wrap */ +.md-typeset table:not([class]) td:first-child > code { + white-space: nowrap; +} + /* matplotlib output with a white background (tutorial pages) */ .md-typeset img.plot { background: #fff; @@ -119,9 +124,43 @@ font-weight: 600; } -/* field tables: keep the "default" column compact */ -.md-typeset .doc-param-default code { - white-space: nowrap; +/* parameter tables: fit the content width. Types wrap between the `|` of a + union, not inside names; long defaults (reprs of default factories) wrap + anywhere and are clamped to three lines. */ +.md-typeset .doc-contents .md-typeset__scrollwrap { + margin-left: 0; + margin-right: 0; +} + +.md-typeset .doc-contents .md-typeset__table { + display: block; + width: 100%; + padding: 0; +} + +.md-typeset .doc-contents .md-typeset__table table { + display: table; + width: 100%; + max-width: 100%; +} + +.md-typeset .doc-contents td code { + white-space: normal; + overflow-wrap: normal; + word-break: normal; +} + +.md-typeset .doc-contents table:not([class]) td:first-child > code { + white-space: normal; + overflow-wrap: anywhere; +} + +.md-typeset .doc-contents td:nth-child(4):last-child code { + display: -webkit-box; + overflow: hidden; + overflow-wrap: anywhere; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; } /* external links get a small arrow */ diff --git a/scripts/make_doc_figures.py b/scripts/make_doc_figures.py index b88f8b73..75037296 100644 --- a/scripts/make_doc_figures.py +++ b/scripts/make_doc_figures.py @@ -185,6 +185,7 @@ def pipeline() -> None: "Alexa Fluor 488", "mVenus", "tdTomato", + "mRFP1", "mScarlet-I", "Alexa Fluor 568", "mCherry", @@ -192,6 +193,17 @@ def pipeline() -> None: ] +# FITC with a longpass emission filter: collects more green, and more crosstalk +FITC_LP = ms.OpticalConfig( + name="FITC, 500 LP emission", + filters=[ + ms.Bandpass(bandcenter=470, bandwidth=40, placement="EX"), + ms.Longpass(cuton=495, placement="BS"), + ms.Longpass(cuton=500, placement="EM"), + ], +) + + def _filter_label(f: ms.Filter) -> str: if f.name: return f.name @@ -220,6 +232,7 @@ def photometry_data() -> None: ) configs = [ *(getattr(oc_lib, n) for n in ["DAPI", "ECFP", "FITC", "EYFP", "TRITC"]), + FITC_LP, *(getattr(oc_lib, n) for n in ["DSRED", "CY5", "CY7"]), *( ms.OpticalConfig.from_fpbase(microscope_id="wKqWbg", config_name=n) @@ -424,6 +437,75 @@ def pinhole_data() -> None: ) +@figure +def crosstalk() -> None: + """EGFP (ER) and tdTomato (mito) in a bandpass and a longpass green channel.""" + sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(64, 512, 512), scale=0.064), + sample=[ + ms.FluorophoreDistribution( + distribution=ms.CosemLabel(dataset="jrc_hela-3", label="er-mem_pred"), + fluorophore="EGFP", + ), + ms.FluorophoreDistribution( + distribution=ms.CosemLabel(dataset="jrc_hela-3", label="mito-mem_pred"), + fluorophore="tdTomato", + ), + ], + channels=[oc_lib.FITC, FITC_LP], + settings=ms.Settings(random_seed=0), + ) + per_fluor = _np(sim.optical_image_per_fluor()) # (c, f, z, y, x) + z = 18 + fig, axes = plt.subplots(2, 3, figsize=(11, 7.7), gridspec_kw={"hspace": 0.12}) + for row, oc in enumerate(sim.channels): + totals = per_fluor[row].sum(axis=(1, 2, 3)) + share = totals / totals.sum() + panels = [ + (per_fluor[row, 0, z], f"from EGFP (ER): {share[0]:.1%}"), + (per_fluor[row, 1, z], f"from tdTomato (mito): {share[1]:.1%}"), + (per_fluor[row, :, z].sum(axis=0), f"{oc.name}: recorded"), + ] + # one display range per row, so each panel shows its true share + vmax = np.percentile(panels[2][0], 99.8) + for ax, (img, title) in zip(axes[row], panels, strict=True): + ax.imshow(img, cmap="gray", vmin=0, vmax=vmax) + image_axes(ax, title) + save(fig, OUT / "crosstalk.png") + + +def _save_plain(fig: plt.Figure, name: str) -> None: + """Save a figure made by microsim's own plotting functions (white background).""" + path = OUT / name + fig.savefig(path, dpi=100, bbox_inches="tight") + plt.close(fig) + print("wrote", path.relative_to(DOCS.parent)) + + +@figure +def spectra_plots() -> None: + """The output of the plotting methods on the channels page.""" + oc = ms.OpticalConfig.from_fpbase("wKqWbg", "Widefield Green") + oc.plot(show=False) + _save_plain(plt.gcf(), "plot-config.png") + + fig, axes = plt.subplots(1, 2, figsize=(12, 3.2)) + oc.plot_excitation(ax=axes[0]) + oc.plot_emission(ax=axes[1]) + _save_plain(fig, "plot-paths.png") + + sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(8, 64, 64), scale=0.05), + sample=[ + ms.FluorophoreDistribution(distribution=ms.MatsLines(), fluorophore=f) + for f in ("EGFP", "mCherry") + ], + channels=[oc_lib.FITC, oc_lib.TRITC], + ) + sim.plot() + _save_plain(plt.gcf(), "plot-simulation.png") + + @figure def first_simulation() -> None: """The example from the home page and the "First simulation" page.""" diff --git a/src/microsim/schema/optical_config/config.py b/src/microsim/schema/optical_config/config.py index 7a0e8744..9465e811 100644 --- a/src/microsim/schema/optical_config/config.py +++ b/src/microsim/schema/optical_config/config.py @@ -476,4 +476,5 @@ def plot_emission( em_spectrum.plot.line(ax=ax, color="k", linewidth=2, label="combined") ax.legend() ax.set_xlim(400, 800) - ax.set_xlabel("transmission [%]") + ax.set_xlabel("wavelength [nm]") + ax.set_ylabel("transmission") From 2d9cd7e3ff3337ed2abc266280d431e22ea19c08 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 14:38:50 +0200 Subject: [PATCH 10/28] docs: render math and charts inside link previews Co-Authored-By: Claude Opus 5.5 --- docs/javascripts/charts/index.js | 10 ++++++++++ docs/javascripts/mathjax.js | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/javascripts/charts/index.js b/docs/javascripts/charts/index.js index 26f6bd20..59da7c40 100644 --- a/docs/javascripts/charts/index.js +++ b/docs/javascripts/charts/index.js @@ -24,3 +24,13 @@ function mountAll() { if (window.document$) window.document$.subscribe(mountAll); else mountAll(); + +// Link previews (`.md-tooltip2`) copy page content, charts included, into the +// page after it loads; mount those too. +let pending = 0; +new MutationObserver(() => { + cancelAnimationFrame(pending); + pending = requestAnimationFrame(() => { + if (document.querySelector(".ms-chart[data-chart]:not(:has(*))")) mountAll(); + }); +}).observe(document.body, { childList: true, subtree: true }); diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js index 79464978..db6782c9 100644 --- a/docs/javascripts/mathjax.js +++ b/docs/javascripts/mathjax.js @@ -21,3 +21,20 @@ document$.subscribe(() => { component$.subscribe(({ ref }) => { if (ref.classList.contains("md-annotation")) MathJax.typesetPromise([ref]); }); + +// Link previews (`.md-tooltip2`) insert page content after the page is +// typeset, so typeset their math as it appears (once per element: typesetting +// itself adds nodes). +const typeset = new WeakSet(); +let pending = 0; +new MutationObserver(() => { + cancelAnimationFrame(pending); + pending = requestAnimationFrame(() => { + const math = [...document.querySelectorAll(".md-tooltip2 .arithmatex")].filter( + (el) => !typeset.has(el), + ); + if (!math.length) return; + for (const el of math) typeset.add(el); + MathJax.typesetPromise(math); + }); +}).observe(document.body, { childList: true, subtree: true }); From 9e3431932037fe9adece3c8cf618becbc17dae9e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 14:44:00 +0200 Subject: [PATCH 11/28] docs: sample examples (MatsLines, COSEM, Allen neurons); prose-check skill - MatsLines parameter examples and an Allen neuron mask, made at build time - three labels from two OpenOrganelle datasets, checked in (slow to make) - document microsim.allen, and its current limits at optical resolution - .claude/skills/check-docs-prose: auditing hand-written docs against code Co-Authored-By: Claude Opus 5.5 --- .claude/skills/check-docs-prose/SKILL.md | 82 +++++++++++++++++++++ docs/api/util.md | 10 +++ docs/guide/samples.md | 43 ++++++++++- docs/images/cosem-examples.jpg | Bin 0 -> 54475 bytes scripts/make_doc_figures.py | 89 ++++++++++++++++++++++- 5 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/check-docs-prose/SKILL.md create mode 100644 docs/images/cosem-examples.jpg diff --git a/.claude/skills/check-docs-prose/SKILL.md b/.claude/skills/check-docs-prose/SKILL.md new file mode 100644 index 00000000..94beaeaf --- /dev/null +++ b/.claude/skills/check-docs-prose/SKILL.md @@ -0,0 +1,82 @@ +--- +name: check-docs-prose +description: Check that the hand-written text of the microsim docs (docs/**/*.md) is still true of the code - defaults, required parameters, what each stage uses, what is and isn't modeled, numbers in captions, code examples. Use after changes to src/microsim, before a release, or when asked to audit the docs for stale or wrong statements. +--- + +# Check the docs' prose against the code + +The guide pages are written by hand, and microsim changes quickly. Statements +like "Only `truth_space` and `sample` are required", "the default is 1 W/cm²", +or "sCMOS pixel-to-pixel variation is not modeled" go stale silently. This skill +is how to find them. + +Do not trust the docs, or your memory of the code: check every claim against the +source, or by running it. + +## What the build already checks + +Run these first; fix what they find. + +```bash +uv run pytest tests/test_docs.py # runs every ```python block in the docs +uv run pytest tests/test_js_physics.py # JS chart physics (see check-js-physics) +uv run --group docs python scripts/make_doc_figures.py # figures, chart data +uv run python scripts/make_notebooks.py +uv run --group docs zensical build --strict # broken links and cross-references +``` + +`test_docs.py` skips pages that need the network when it thinks there is none +(`tests/_util.py` `HAVE_INTERNET`). If it skips, run those pages anyway. +`` ```py `` blocks are fragments and are **not** run: check them by reading. + +## What only reading can check + +Go page by page through `docs/` (`zensical.toml` `nav` lists them). For each +statement of fact about microsim, find the code that makes it true, and cite it +(`file:line`) in your report. Kinds of claims that go stale, and where they are: + +- **Defaults and required fields.** Tables of defaults + (`getting-started/first-simulation.md`, `guide/settings.md`, + `guide/detectors.md` "Default camera settings", `guide/channels.md`), and + sentences like "Only `truth_space` and `sample` are required" + (`guide/index.md`). Check the pydantic field definitions, including + `default_factory` and validators that fill values in. +- **What each stage uses.** The stage diagrams (the mermaid blocks in + `index.md` and `guide/index.md`) and the stage table in `guide/index.md` list + which parameters feed which method. Check `Simulation.ground_truth`, + `filtered_emission_rates`, `optical_image`, and `digital_image`, and what they + call. +- **What is and isn't modeled.** `guide/index.md` "What is (and isn't) + modeled", and every admonition that states a limitation or a workaround: + + ```bash + grep -rn '^ *\(!!!\|???\) ' docs + ``` + + A new feature can make a "not modeled" line false; a refactor can make a + "this is how it works" line false. Check each one. +- **Equations.** The formulas in `guide/photometry.md`, `guide/detectors.md`, + and `guide/modalities.md` should match the code step by step (normalizations, + units, which quantity is summed). The JS copies of the same physics are + covered by the `check-js-physics` skill. +- **Library contents.** Tables of `optical_config.lib` filter sets, + `detectors.lib` presets, settings, extras (`getting-started/installation.md`), + and dataset or layer names. Compare with the modules and `pyproject.toml`. +- **Numbers in text and captions.** Figures and charts are regenerated on every + build, but numbers quoted in the text are not (e.g. "392 photons absorbed", + "about 6 photons", "71%"). Recompute each one from the current code, or from + the generated chart data in `docs/data/generated/`. +- **Names.** Every class, method, parameter, and keyword mentioned in prose or + `` ```py `` fragments must exist with that name and signature. The strict build + only checks `[name][microsim.x]` cross-references, not plain `` `code` ``. +- **Links to issues.** Statements tied to a GitHub issue (e.g. #152): check + whether the issue is closed and the statement still holds. + +## Reporting and fixing + +- Fix clear factual errors directly, in the docs' existing style: plain, + scientific, no marketing language, no em dashes. +- If the code looks wrong rather than the docs (a doc describes intended behavior + the code doesn't implement), don't change either; report it. +- Report what you checked, what you changed, and anything you could not verify, + with `file:line` references for both the docs and the code. diff --git a/docs/api/util.md b/docs/api/util.md index 6cb5d893..53b7fb7a 100644 --- a/docs/api/util.md +++ b/docs/api/util.md @@ -8,3 +8,13 @@ - ndview - microsim_cache - clear_cache + +## Allen Institute neurons + +::: microsim.allen + options: + show_root_heading: false + heading_level: 3 + members: + - NeuronReconstruction + - Specimen diff --git a/docs/guide/samples.md b/docs/guide/samples.md index ebe0fb9b..e0bcd1f4 100644 --- a/docs/guide/samples.md +++ b/docs/guide/samples.md @@ -55,10 +55,17 @@ compute and useful for testing. | Parameter | Effect | | --------- | -------------------------------------------------------------------------------------------- | | `density` | number of lines, as a fraction of the X size in voxels (512 voxels × 0.5 = 256 lines) | -| `length` | scales the random line length, relative to the size of the field of view | +| `length` | line length: 1/20 of the field of view, plus a random amount up to `length`/20 of it | | `azimuth` | limits the tilt out of the XY plane: larger values give flatter lines | | `max_r` | lines are only drawn inside an ellipsoid of this relative radius (1 = touching the edges) | +![MatsLines with different parameters](../images/generated/matslines-examples.png) +/// caption +Maximum projections of `MatsLines` ground truths in a 64 × 256 × 256 truth +space, with the defaults (`density=1`, `length=10`, `azimuth=10`, +`max_r=0.9`) and with one parameter changed at a time. +/// + ### OpenOrganelle (COSEM) [`CosemLabel`][microsim.schema.CosemLabel] uses segmentations of organelles @@ -81,6 +88,15 @@ ms.CosemLabel(dataset="jrc_hela-3", label="er-mem_pred") The data are downloaded on first use and cached. This needs the `cosem` extra and network access. +![Three labels from two OpenOrganelle datasets](../images/cosem-examples.jpg) +/// caption +Three labels each from two OpenOrganelle datasets (HeLa and macrophage cells), +on a 64 nm grid: one plane of the ground truth (left) and of the widefield +optical image (right), each label in its own color. In widefield, the top and +bottom of the nuclear envelope (cyan) blur into the plane, and fill the +nucleus. +/// + !!! warning "Voxel size" The source data are stored at 4 nm voxels and at a series of 2× @@ -88,6 +104,31 @@ and network access. matches the truth space without interpolating, so use the same scale on all three axes, equal to 4 nm times a power of two (e.g. `scale=0.064`). +### Allen Institute neurons + +[`microsim.allen`](../api/util.md#allen-institute-neurons) fetches neuron reconstructions from the +[Allen Cell Types Database](https://celltypes.brain-map.org) and renders them as +a 3D mask, which can be used as a [fixed array](#your-own-array): + +```py +from microsim.allen import NeuronReconstruction + +mask = NeuronReconstruction.fetch(638976782).binary_mask(voxel_size=1) # (Z, Y, X) +``` + +![An Allen neuron reconstruction rendered as a mask](../images/generated/allen-neuron.png) +/// caption +Reconstruction 638976782 rendered at 1 µm voxels (maximum projections). +/// + +!!! warning "Not yet practical at optical resolution" + + `binary_mask` renders the whole neuron, hundreds of micrometers across, in + one array, and the line widths it draws do not scale with `voxel_size`. + At the 50 nm voxels that a simulation needs, the array would be far too + large. For now, use it at about 1 µm to explore the morphology, or crop + and resample the mask yourself. + ### Your own array If you already have a ground truth as an array, for example from a diff --git a/docs/images/cosem-examples.jpg b/docs/images/cosem-examples.jpg new file mode 100644 index 0000000000000000000000000000000000000000..83fc8017d7305e68ccbf42fdfcda7da9981f49e3 GIT binary patch literal 54475 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≠nWg<=4=X&r!Uxa;&RVVPlo+(0Nc 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%0A6q80s3_3E}w*&8FYQ)!a5yZ6w7rp4Bq6 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 EPpo38Hvj+t literal 0 HcmV?d00001 diff --git a/scripts/make_doc_figures.py b/scripts/make_doc_figures.py index 75037296..41cac392 100644 --- a/scripts/make_doc_figures.py +++ b/scripts/make_doc_figures.py @@ -28,6 +28,7 @@ from microsim import schema as ms from microsim._data_array import DataArray +from microsim.allen import NeuronReconstruction from microsim.psf import make_psf from microsim.schema.backend import NumpyAPI from microsim.schema.detectors import lib as det_lib @@ -43,6 +44,9 @@ OUT = DOCS / "images" / "generated" DATA = DOCS / "data" / "generated" FIGURES: dict[str, Callable[[], None]] = {} +# figures that are checked in (to docs/images/) because they are slow to make; +# only made when named on the command line +COMMITTED: set[str] = set() # image panels are dark in both schemes, so their labels use a neutral gray IMG_LABEL = "#9a998f" @@ -53,6 +57,11 @@ def figure(func: Callable[[], None]) -> Callable[[], None]: return func +def committed_figure(func: Callable[[], None]) -> Callable[[], None]: + COMMITTED.add(func.__name__) + return figure(func) + + def save(fig: plt.Figure, path: Path) -> None: fig.savefig(path, dpi=160, bbox_inches="tight", transparent=True) plt.close(fig) @@ -506,6 +515,84 @@ def spectra_plots() -> None: _save_plain(plt.gcf(), "plot-simulation.png") +@figure +def matslines_examples() -> None: + """MatsLines ground truth with different parameters (maximum projections).""" + variants = { + "defaults": {}, + "density=0.2": {"density": 0.2}, + "length=2": {"length": 2}, + "azimuth=2": {"azimuth": 2}, + } + fig, axes = plt.subplots(1, 4, figsize=(13, 3.6), gridspec_kw={"wspace": 0.04}) + for ax, (title, kwargs) in zip(axes, variants.items(), strict=True): + sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(64, 256, 256), scale=0.02), + sample=[ms.MatsLines(**kwargs)], + settings=ms.Settings(random_seed=0), + ) + truth = _np(sim.ground_truth())[0] + ax.imshow(norm(truth.max(axis=0), 0, 99.5), cmap="gray") + image_axes(ax, title) + save(fig, OUT / "matslines-examples.png") + + +COSEM_EXAMPLES = { + "jrc_hela-3": ["er-mem_pred", "mito-mem_pred", "ne-mem_pred"], + "jrc_macrophage-2": ["mito-mem_pred", "lyso-mem_pred", "ne-mem_pred"], +} +COSEM_COLORS = ["#33ff66", "#ff33cc", "#33ccff"] + + +@committed_figure +def cosem_examples() -> None: + """Three labels from two OpenOrganelle datasets: ground truth and optical image. + + Checked in as docs/images/cosem-examples.jpg: the downloads take a while. + """ + fig, axes = plt.subplots(2, 2, figsize=(9, 9.4), gridspec_kw={"wspace": 0.03}) + for row, (dataset, labels) in enumerate(COSEM_EXAMPLES.items()): + sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(64, 512, 512), scale=0.064), + sample=[ms.CosemLabel(dataset=dataset, label=lbl) for lbl in labels], + settings=ms.Settings(random_seed=0), + ) + z = 24 + truth = _np(sim.ground_truth())[:, z] + optical = _np(sim.optical_image_per_fluor())[0, :, z] + axes[row, 0].imshow(composite(list(truth), COSEM_COLORS)) + image_axes(axes[row, 0], f"{dataset}: ground truth") + axes[row, 1].imshow(composite(list(optical), COSEM_COLORS)) + image_axes(axes[row, 1], "widefield optical image") + names = ", ".join(lbl.removesuffix("_pred") for lbl in labels) + axes[row, 0].text( + 0, -0.08, names, transform=axes[row, 0].transAxes, color=IMG_LABEL + ) + path = DOCS / "images" / "cosem-examples.jpg" + fig.savefig(path, dpi=90, bbox_inches="tight", facecolor="#111111") + plt.close(fig) + print("wrote", path.relative_to(DOCS.parent)) + + +@figure +def allen_neuron() -> None: + """An Allen Cell Types neuron reconstruction, rendered as a 1 µm mask.""" + mask = NeuronReconstruction.fetch(638976782).binary_mask(voxel_size=1) # (z, y, x) + xy = mask.max(axis=0).T # long axis (y) horizontal + zy = mask.max(axis=2) + fig, axes = plt.subplots( + 2, + 1, + figsize=(10, 10 * (xy.shape[0] + zy.shape[0]) / xy.shape[1] + 0.6), + gridspec_kw={"height_ratios": [xy.shape[0], zy.shape[0]], "hspace": 0.12}, + ) + axes[0].imshow(xy, cmap="gray", interpolation="nearest") + image_axes(axes[0], "maximum projection along Z") + axes[1].imshow(zy, cmap="gray", interpolation="nearest") + image_axes(axes[1], "maximum projection along X") + save(fig, OUT / "allen-neuron.png") + + @figure def first_simulation() -> None: """The example from the home page and the "First simulation" page.""" @@ -585,7 +672,7 @@ def tutorials() -> None: if __name__ == "__main__": OUT.mkdir(parents=True, exist_ok=True) - names = sys.argv[1:] or list(FIGURES) + names = sys.argv[1:] or [n for n in FIGURES if n not in COMMITTED] for name in names: print(f"--- {name}") FIGURES[name]() From addee50fe2a7ab4802a6d2b1113c8b1a7eda46fc Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 15:12:46 +0200 Subject: [PATCH 12/28] docs: crosstalk chart controls in two columns (fluorophores, then channels) Co-Authored-By: Claude Opus 5.5 --- docs/javascripts/charts/crosstalk.js | 2 ++ docs/stylesheets/charts.css | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/docs/javascripts/charts/crosstalk.js b/docs/javascripts/charts/crosstalk.js index 6a77c513..0fe84b44 100644 --- a/docs/javascripts/charts/crosstalk.js +++ b/docs/javascripts/charts/crosstalk.js @@ -42,6 +42,8 @@ function detected(channel, fluor) { export default async function (el) { const { fluors, channels } = await loadPhotometry(); const ui = shell(el); + // two columns: the fluorophores on one row, the channels on the next + ui.controls.classList.add("ms-chart__controls--pairs"); const state = { f1: "EGFP", f2: "tdTomato", diff --git a/docs/stylesheets/charts.css b/docs/stylesheets/charts.css index e5086ae8..fc70dd7a 100644 --- a/docs/stylesheets/charts.css +++ b/docs/stylesheets/charts.css @@ -603,3 +603,13 @@ font-size: 0.58rem; color: var(--ms-ink-muted); } + +/* controls in two equal columns, filled row by row */ +.ms-chart__controls--pairs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.ms-chart__controls--pairs .ms-select { + width: 100%; +} From 4ca0e890ac50675171618b12a6d83452d71d618c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 15:28:48 +0200 Subject: [PATCH 13/28] docs: generate the API reference with api-autonav - one page per public module, from the source, replacing the hand-written API pages; modules re-exported by their package are excluded so each object is documented once - cross-references to members point at their documented paths - microsim.schema gets a module docstring for its (otherwise empty) page Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/about/contributing.md | 5 +++- docs/api/detectors.md | 34 ----------------------- docs/api/index.md | 35 ++++++++++++++++-------- docs/api/modality.md | 13 --------- docs/api/optical-config.md | 35 ------------------------ docs/api/psf.md | 20 -------------- docs/api/sample.md | 19 ------------- docs/api/settings.md | 7 ----- docs/api/simulation.md | 14 ---------- docs/api/space.md | 20 -------------- docs/api/spectrum.md | 3 -- docs/api/util.md | 20 -------------- docs/getting-started/first-simulation.md | 10 +++---- docs/getting-started/installation.md | 2 +- docs/guide/channels.md | 6 ++-- docs/guide/detectors.md | 6 ++-- docs/guide/index.md | 6 ++-- docs/guide/modalities.md | 2 +- docs/guide/photometry.md | 4 +-- docs/guide/samples.md | 6 ++-- docs/guide/saving.md | 2 +- docs/tutorials/your-own-ground-truth.md | 2 +- src/microsim/schema/__init__.py | 7 +++++ zensical.toml | 32 +++++++++++++--------- 25 files changed, 77 insertions(+), 235 deletions(-) delete mode 100644 docs/api/detectors.md delete mode 100644 docs/api/modality.md delete mode 100644 docs/api/optical-config.md delete mode 100644 docs/api/psf.md delete mode 100644 docs/api/sample.md delete mode 100644 docs/api/settings.md delete mode 100644 docs/api/simulation.md delete mode 100644 docs/api/space.md delete mode 100644 docs/api/spectrum.md delete mode 100644 docs/api/util.md diff --git a/README.md b/README.md index e8e9a747..9a24ace5 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ paying attention to your GPU requirements. Support for torch is planned. ## Usage Construct and run a -[`microsim.Simulation`](https://www.talleylambert.com/microsim/api/simulation/) +[`microsim.Simulation`](https://www.talleylambert.com/microsim/api/microsim/schema/simulation/) object. ```python diff --git a/docs/about/contributing.md b/docs/about/contributing.md index 74dfff17..4a09b4a6 100644 --- a/docs/about/contributing.md +++ b/docs/about/contributing.md @@ -29,7 +29,10 @@ uv run prek run -a The docs are built with [Zensical](https://zensical.org) from the Markdown files in `docs/`, configured in `zensical.toml`. The API reference is generated -from docstrings with [mkdocstrings](https://mkdocstrings.github.io). +from the source: the `api-autonav` plugin makes one page per public module, and +[mkdocstrings](https://mkdocstrings.github.io) renders the docstrings. Modules +whose contents are documented by their package are excluded there, so that +each object is documented once. ```bash uv run --group docs zensical serve # live preview at http://localhost:8000 diff --git a/docs/api/detectors.md b/docs/api/detectors.md deleted file mode 100644 index aa0d96e6..00000000 --- a/docs/api/detectors.md +++ /dev/null @@ -1,34 +0,0 @@ -# Detectors - -See [Detectors](../guide/detectors.md) in the user guide. The steps shared by -all detectors are described in -[What every detector does](../guide/detectors.md#what-every-detector-does). - -## Cameras - -::: microsim.schema.CameraCCD - -::: microsim.schema.CameraEMCCD - -::: microsim.schema.CameraCMOS - -## Point detectors - -::: microsim.schema.PMT - -::: microsim.schema.HyD - -## Built-in detectors - -::: microsim.schema.detectors.lib - options: - show_if_no_docstring: true - members: - - ICX285 - - PMT_GAASP - - PMT_MULTIALKALI - - HYD_SP8 - - POWER_HYD_R - - GAASP_PMT_QE - - GAASP_HPD_QE - - RED_GAASP_HPD_QE diff --git a/docs/api/index.md b/docs/api/index.md index a820216b..da992cc0 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -18,15 +18,26 @@ validated when the object is created and when they are assigned, and every field that takes a model also accepts a dictionary with the same keys. Unknown fields raise an error. -| Page | Contents | -| ------------------------------------- | --------------------------------------------------------------- | -| [Simulation](simulation.md) | `Simulation`, the top-level model and its stage methods | -| [Spaces](space.md) | `ShapeScaleSpace`, `DownscaledSpace`, ... | -| [Samples](sample.md) | `Sample`, `FluorophoreDistribution`, `Fluorophore`, distributions | -| [Optical configs](optical-config.md) | `OpticalConfig`, filters, `LightSource`, built-in filter sets | -| [Modalities](modality.md) | `Widefield`, `Confocal`, `Identity`, `ObjectiveLens` | -| [Detectors](detectors.md) | cameras, point detectors, built-in detectors | -| [Settings](settings.md) | `Settings` | -| [Spectrum](spectrum.md) | `Spectrum` | -| [PSF](psf.md) | `microsim.psf`: PSF functions | -| [Utilities](util.md) | plotting and cache helpers | +The pages in this section are generated from the source code, one per public +module, so they always match the installed version. The most useful ones: + +| Module | Contents | +| ------------------------------------------------------------ | ----------------------------------------------------------------- | +| [`schema.simulation`][microsim.schema.simulation] | `Simulation`, the top-level model and its stage methods | +| [`schema.space`][microsim.schema.space] | `ShapeScaleSpace`, `DownscaledSpace`, ... | +| [`schema.sample`][microsim.schema.sample] | `Sample`, `FluorophoreDistribution`, `Fluorophore`, distributions | +| [`schema.optical_config`][microsim.schema.optical_config] | `OpticalConfig`, filters, `LightSource` | +| [`schema.optical_config.lib`][microsim.schema.optical_config.lib] | built-in filter sets | +| [`schema.modality`][microsim.schema.modality] | `Widefield`, `Confocal`, `Identity` | +| [`schema.lens`][microsim.schema.lens] | `ObjectiveLens` | +| [`schema.detectors`][microsim.schema.detectors] | cameras and point detectors | +| [`schema.detectors.lib`][microsim.schema.detectors.lib] | built-in detectors | +| [`schema.settings`][microsim.schema.settings] | `Settings` | +| [`schema.spectrum`][microsim.schema.spectrum] | `Spectrum` | +| [`psf`][microsim.psf] | PSF functions | +| [`util`][microsim.util] | plotting and cache helpers | +| [`allen`][microsim.allen] | neuron reconstructions from the Allen Institute | +| [`cosem`][microsim.cosem] | access to OpenOrganelle (COSEM) datasets | + +Each object is documented once, in the module or subpackage that defines it. +Most of them are also importable from `microsim.schema`, as above. diff --git a/docs/api/modality.md b/docs/api/modality.md deleted file mode 100644 index 0c36664b..00000000 --- a/docs/api/modality.md +++ /dev/null @@ -1,13 +0,0 @@ -# Modalities - -See [Modalities and the PSF](../guide/modalities.md) in the user guide. - -::: microsim.schema.Widefield - -::: microsim.schema.Confocal - -::: microsim.schema.Identity - -## Objective lens - -::: microsim.schema.ObjectiveLens diff --git a/docs/api/optical-config.md b/docs/api/optical-config.md deleted file mode 100644 index 28ba93a3..00000000 --- a/docs/api/optical-config.md +++ /dev/null @@ -1,35 +0,0 @@ -# Optical configurations - -See [Channels](../guide/channels.md) in the user guide. - -::: microsim.schema.OpticalConfig - -::: microsim.schema.LightSource - -## Filters - -::: microsim.schema.Bandpass - -::: microsim.schema.Longpass - -::: microsim.schema.Shortpass - -::: microsim.schema.SpectrumFilter - -::: microsim.schema.optical_config.Placement - -## Built-in configurations - -::: microsim.schema.optical_config.lib - options: - show_if_no_docstring: true - members: - - DAPI - - ECFP - - FITC - - EYFP - - TRITC - - DSRED - - CY5 - - CY7 - - spectral_detector diff --git a/docs/api/psf.md b/docs/api/psf.md deleted file mode 100644 index caa9510b..00000000 --- a/docs/api/psf.md +++ /dev/null @@ -1,20 +0,0 @@ -# PSF - -Functions to compute point spread functions. Simulations call these through -their [modality](modality.md); they can also be used on their own. - -```python -from microsim.psf import make_psf -from microsim.schema import ObjectiveLens - -psf = make_psf(nz=65, nx=65, dx=0.04, dz=0.04, objective=ObjectiveLens(), em_wvl_nm=525) -``` - -::: microsim.psf - options: - show_root_heading: false - members: - - make_psf - - make_confocal_psf - - vectorial_psf - - vectorial_psf_centered diff --git a/docs/api/sample.md b/docs/api/sample.md deleted file mode 100644 index 726930bb..00000000 --- a/docs/api/sample.md +++ /dev/null @@ -1,19 +0,0 @@ -# Samples - -See [Samples](../guide/samples.md) in the user guide. - -::: microsim.schema.Sample - -::: microsim.schema.FluorophoreDistribution - -::: microsim.schema.Fluorophore - -## Distributions - -::: microsim.schema.MatsLines - -::: microsim.schema.sample._distributions.cosem.CosemLabel - options: - members: false - -::: microsim.schema.sample.BaseDistribution diff --git a/docs/api/settings.md b/docs/api/settings.md deleted file mode 100644 index 3242cee2..00000000 --- a/docs/api/settings.md +++ /dev/null @@ -1,7 +0,0 @@ -# Settings - -See [Settings](../guide/settings.md) in the user guide. - -::: microsim.schema.Settings - -::: microsim.schema.settings.CacheSettings diff --git a/docs/api/simulation.md b/docs/api/simulation.md deleted file mode 100644 index 6c1d7040..00000000 --- a/docs/api/simulation.md +++ /dev/null @@ -1,14 +0,0 @@ -# Simulation - -::: microsim.Simulation - options: - members: - - run - - ground_truth - - filtered_emission_rates - - emission_flux - - optical_image - - optical_image_per_fluor - - digital_image - - from_ground_truth - - plot diff --git a/docs/api/space.md b/docs/api/space.md deleted file mode 100644 index 98875a21..00000000 --- a/docs/api/space.md +++ /dev/null @@ -1,20 +0,0 @@ -# Spaces - -See [Spaces](../guide/spaces.md) in the user guide. - -## Concrete spaces - -::: microsim.schema.ShapeScaleSpace - -::: microsim.schema.ExtentScaleSpace - -::: microsim.schema.ShapeExtentSpace - -## Relative spaces - -A relative space is defined with respect to the other space of the -simulation, which must be concrete. - -::: microsim.schema.DownscaledSpace - -::: microsim.schema.space.UpscaledSpace diff --git a/docs/api/spectrum.md b/docs/api/spectrum.md deleted file mode 100644 index 4c2ba6af..00000000 --- a/docs/api/spectrum.md +++ /dev/null @@ -1,3 +0,0 @@ -# Spectrum - -::: microsim.schema.Spectrum diff --git a/docs/api/util.md b/docs/api/util.md deleted file mode 100644 index 53b7fb7a..00000000 --- a/docs/api/util.md +++ /dev/null @@ -1,20 +0,0 @@ -# Utilities - -::: microsim.util - options: - show_root_heading: false - members: - - ortho_plot - - ndview - - microsim_cache - - clear_cache - -## Allen Institute neurons - -::: microsim.allen - options: - show_root_heading: false - heading_level: 3 - members: - - NeuronReconstruction - - Specimen diff --git a/docs/getting-started/first-simulation.md b/docs/getting-started/first-simulation.md index 3537c843..b94dc4ce 100644 --- a/docs/getting-started/first-simulation.md +++ b/docs/getting-started/first-simulation.md @@ -40,7 +40,7 @@ ortho_plot(result) fluorophore per voxel. 4. The **detector** converts photons to gray values and adds noise. This is a CMOS camera with default settings. -5. [`run()`][microsim.Simulation.run] executes every stage and returns the +5. [`run()`][microsim.schema.simulation.Simulation.run] executes every stage and returns the digital image. Everything not given has a default value. In this case: @@ -76,10 +76,10 @@ named dimensions and physical coordinates (µm): | Method | Dimensions | Units | | ------------------------------------------------------------------------------ | ------------------- | --------------------------- | -| [`ground_truth()`][microsim.Simulation.ground_truth] | `(f, z, y, x)` | fluorophores per voxel | -| [`filtered_emission_rates()`][microsim.Simulation.filtered_emission_rates] | `(c, f, w)` | photons/s per fluorophore | -| [`optical_image()`][microsim.Simulation.optical_image] | `(c, z, y, x)` | photons/s per voxel | -| [`digital_image()`][microsim.Simulation.digital_image] | `(c, z, y, x)` | gray values | +| [`ground_truth()`][microsim.schema.simulation.Simulation.ground_truth] | `(f, z, y, x)` | fluorophores per voxel | +| [`filtered_emission_rates()`][microsim.schema.simulation.Simulation.filtered_emission_rates] | `(c, f, w)` | photons/s per fluorophore | +| [`optical_image()`][microsim.schema.simulation.Simulation.optical_image] | `(c, z, y, x)` | photons/s per voxel | +| [`digital_image()`][microsim.schema.simulation.Simulation.digital_image] | `(c, z, y, x)` | gray values | Dimension names: `f` fluorophore, `c` channel, `w` wavelength (nm), and `z`, `y`, `x` in µm. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index cae487e3..b30326f1 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -31,7 +31,7 @@ or fetch ground truth from OpenOrganelle. | Extra | Adds | Needed for | | ------- | ---------------------------------------------- | -------------------------------------------------------------------------- | - | `view` | matplotlib, [ndv](https://github.com/pyapp-kit/ndv) | [`ortho_plot`][microsim.util.ortho_plot], [`Simulation.plot`][microsim.Simulation.plot], interactive viewing | + | `view` | matplotlib, [ndv](https://github.com/pyapp-kit/ndv) | [`ortho_plot`][microsim.util.ortho_plot], [`Simulation.plot`][microsim.schema.simulation.Simulation.plot], interactive viewing | | `io` | tifffile, imageio | writing `.tif` output | | `cosem` | boto3, s3fs, supabase | [`CosemLabel`](../guide/samples.md#openorganelle-cosem) ground truth | | `jax` | jax | the `jax` backend | diff --git a/docs/guide/channels.md b/docs/guide/channels.md index 97a0de96..a47b8668 100644 --- a/docs/guide/channels.md +++ b/docs/guide/channels.md @@ -112,7 +112,7 @@ bandpass filters. Filters in the same path are multiplied together. ### Light sources `lights` is a list of [`LightSource`][microsim.schema.LightSource] objects, -each with an emission `spectrum`. [`LightSource.laser(488)`][microsim.schema.LightSource.laser] +each with an emission `spectrum`. [`LightSource.laser(488)`][microsim.schema.optical_config.LightSource.laser] makes a narrow line at 488 nm. With no light source, the channel is illuminated by flat white light, so the excitation filters alone set the spectrum. @@ -179,7 +179,7 @@ EGFP. With the 525/50 bandpass emission filter of `FITC`, its share drops below /// To see where the signal in each channel comes from in an image, use -[`optical_image_per_fluor()`][microsim.Simulation.optical_image_per_fluor], +[`optical_image_per_fluor()`][microsim.schema.simulation.Simulation.optical_image_per_fluor], which keeps the fluorophore dimension: ```py @@ -234,7 +234,7 @@ oc.plot_emission(ax=axes[1]) # emission filters, and their combined transmissio ![Output of plot_excitation and plot_emission](../images/generated/plot-paths.png){ .plot } -For a whole simulation, [`Simulation.plot()`][microsim.Simulation.plot] shows, +For a whole simulation, [`Simulation.plot()`][microsim.schema.simulation.Simulation.plot] shows, for each channel (columns): the absorption cross sections, the excitation path, the absorption rates (with the emission spectra, dashed), the emission path, and the photons collected from each fluorophore. Here EGFP and mCherry diff --git a/docs/guide/detectors.md b/docs/guide/detectors.md index 54255f6e..597cedda 100644 --- a/docs/guide/detectors.md +++ b/docs/guide/detectors.md @@ -49,7 +49,7 @@ at the cost of extra gain noise. A channel can override it with [`OpticalConfig.exposure_ms`](channels.md#per-channel-exposure), and -[`digital_image(exposure_ms=...)`][microsim.Simulation.digital_image] +[`digital_image(exposure_ms=...)`][microsim.schema.simulation.Simulation.digital_image] overrides both, which is a quick way to compare exposure times on one optical image: @@ -169,7 +169,7 @@ gain an EMCCD has the SNR of a noise-free detector with half its QE.
/// caption Excess noise factor of the EM register, -[`CameraEMCCD.excess_noise_factor`][microsim.schema.CameraEMCCD.excess_noise_factor]. +[`CameraEMCCD.excess_noise_factor`][microsim.schema.detectors.CameraEMCCD.excess_noise_factor]. /// ```py @@ -277,5 +277,5 @@ The source code of [`detectors/lib.py`](https://github.com/tlambert03/microsim/blob/main/src/microsim/schema/detectors/lib.py) lists where each value comes from. -The detector docstrings in the [API reference](../api/detectors.md) describe +The detector docstrings in the [API reference][microsim.schema.detectors] describe each model in more detail, including what is not modeled. diff --git a/docs/guide/index.md b/docs/guide/index.md index 7963a79f..27e8a7c3 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -30,7 +30,7 @@ Only `truth_space` and `sample` are required. ## The stages -[`Simulation.run()`][microsim.Simulation.run] computes four things in order. +[`Simulation.run()`][microsim.schema.simulation.Simulation.run] computes four things in order. Each is also available as a method, so you can stop at any stage, inspect the intermediate result, or change a parameter and repeat only the later stages. @@ -98,10 +98,10 @@ mCherry (magenta). The ground truth is from OpenOrganelle; see the /// There are two more methods for analysis. -[`optical_image_per_fluor()`][microsim.Simulation.optical_image_per_fluor] +[`optical_image_per_fluor()`][microsim.schema.simulation.Simulation.optical_image_per_fluor] returns the optical image before fluorophores are summed, as `(c, f, z, y, x)`, which shows how much of each channel comes from each -fluorophore. [`emission_flux()`][microsim.Simulation.emission_flux] returns the +fluorophore. [`emission_flux()`][microsim.schema.simulation.Simulation.emission_flux] returns the emitted photons per voxel before blurring. ## Units diff --git a/docs/guide/modalities.md b/docs/guide/modalities.md index 7a3e50c4..53507caa 100644 --- a/docs/guide/modalities.md +++ b/docs/guide/modalities.md @@ -141,7 +141,7 @@ image as well as a larger PSF. PSFs are computed with a vectorial model of a high-NA objective that includes the Gibson–Lanni optical path difference for mismatched coverslip, immersion, and specimen refractive indices - ([`microsim.psf`](../api/psf.md)). + ([`microsim.psf`][microsim.psf]). The same PSF is used for the whole volume (it is spatially invariant), and is computed for a point source at the coverslip surface. Aberrations that diff --git a/docs/guide/photometry.md b/docs/guide/photometry.md index 67e07252..aa487ff7 100644 --- a/docs/guide/photometry.md +++ b/docs/guide/photometry.md @@ -6,7 +6,7 @@ icon: lucide/sigma This page explains how microsim calculates the number of photons a fluorophore emits and how many of them reach the detector. These calculations happen in -[`filtered_emission_rates()`][microsim.Simulation.filtered_emission_rates], +[`filtered_emission_rates()`][microsim.schema.simulation.Simulation.filtered_emission_rates], before anything spatial is computed.
@@ -149,7 +149,7 @@ proportional to \] The product \(s = k\tau\) is the saturation parameter -([`OpticalConfig.saturation_parameter`][microsim.schema.OpticalConfig.saturation_parameter]). +([`OpticalConfig.saturation_parameter`][microsim.schema.optical_config.OpticalConfig.saturation_parameter]). When \(s \ll 1\), emission is proportional to irradiance. At \(s = 1\) it is half of what a linear response would give. diff --git a/docs/guide/samples.md b/docs/guide/samples.md index e0bcd1f4..74d3fd30 100644 --- a/docs/guide/samples.md +++ b/docs/guide/samples.md @@ -27,7 +27,7 @@ sample = ms.Sample( ) ``` -[`ground_truth()`][microsim.Simulation.ground_truth] draws each label into the +[`ground_truth()`][microsim.schema.simulation.Simulation.ground_truth] draws each label into the truth space and stacks them along the `f` dimension. The values are fluorophores per voxel. @@ -106,7 +106,7 @@ nucleus. ### Allen Institute neurons -[`microsim.allen`](../api/util.md#allen-institute-neurons) fetches neuron reconstructions from the +[`microsim.allen`][microsim.allen] fetches neuron reconstructions from the [Allen Cell Types Database](https://celltypes.brain-map.org) and renders them as a 3D mask, which can be used as a [fixed array](#your-own-array): @@ -133,7 +133,7 @@ Reconstruction 638976782 rendered at 1 µm voxels (maximum projections). If you already have a ground truth as an array, for example from a segmentation, pass it directly with -[`Simulation.from_ground_truth`][microsim.Simulation.from_ground_truth]: +[`Simulation.from_ground_truth`][microsim.schema.simulation.Simulation.from_ground_truth]: ```py sim = ms.Simulation.from_ground_truth( diff --git a/docs/guide/saving.md b/docs/guide/saving.md index a2e21ed1..bc5b9411 100644 --- a/docs/guide/saving.md +++ b/docs/guide/saving.md @@ -6,7 +6,7 @@ icon: lucide/save ## Saving the result -Set `output_path` and [`run()`][microsim.Simulation.run] writes the digital +Set `output_path` and [`run()`][microsim.schema.simulation.Simulation.run] writes the digital image to disk: ```py diff --git a/docs/tutorials/your-own-ground-truth.md b/docs/tutorials/your-own-ground-truth.md index 8cb29c4e..58ee86c6 100644 --- a/docs/tutorials/your-own-ground-truth.md +++ b/docs/tutorials/your-own-ground-truth.md @@ -34,7 +34,7 @@ for _ in range(12): ## Simulate it -[`Simulation.from_ground_truth`][microsim.Simulation.from_ground_truth] takes +[`Simulation.from_ground_truth`][microsim.schema.simulation.Simulation.from_ground_truth] takes the array and its voxel size in µm, plus any other simulation parameters: ```python diff --git a/src/microsim/schema/__init__.py b/src/microsim/schema/__init__.py index f1d94eea..9c24bdcd 100644 --- a/src/microsim/schema/__init__.py +++ b/src/microsim/schema/__init__.py @@ -1,3 +1,10 @@ +"""The models that describe a simulation. + +Everything a simulation needs can be imported from here, usually as +`from microsim import schema as ms`. Each model is documented in the submodule +(or subpackage) that defines it. +""" + import logging from typing import TYPE_CHECKING, Any diff --git a/zensical.toml b/zensical.toml index 4de24cde..a4123e9b 100644 --- a/zensical.toml +++ b/zensical.toml @@ -39,19 +39,7 @@ nav = [ "tutorials/your-own-ground-truth.md", "tutorials/point-scanning-detectors.md", ] }, - { "API reference" = [ - "api/index.md", - "api/simulation.md", - "api/space.md", - "api/sample.md", - "api/optical-config.md", - "api/modality.md", - "api/detectors.md", - "api/settings.md", - "api/spectrum.md", - "api/psf.md", - "api/util.md", - ] }, + { "API reference" = ["api/index.md"] }, { "About" = [ "about/contributing.md", "about/citing.md", @@ -209,3 +197,21 @@ docstring_section_style = "table" show_if_no_docstring = true [project.plugins.markdown-exec] + +# one page per public module, from the source; see docs/api/index.md +[project.plugins.api-autonav] +modules = ["src/microsim"] +api_root_uri = "api" +nav_section_title = "API reference" +# document each object once: these modules' contents are re-exported, and +# documented, by their package (e.g. microsim.schema.optical_config) +exclude = [ + "microsim.schema.optical_config.config", + "microsim.schema.optical_config.filter", + "microsim.schema.sample.sample", + "microsim.schema.sample.fluorophore", +] + +# the top-level packages re-export everything: list their members, don't repeat them +[project.plugins.api-autonav.module_options."^microsim(\\.schema)?$"] +members = false From 72b6e83d015fde3e913ec1f74d7ad4ee3b792859 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 17:26:27 +0200 Subject: [PATCH 14/28] docs: hide internal modules from the API reference, group the overview by topic Co-Authored-By: Claude Opus 5.5 --- docs/api/index.md | 86 ++++++++++++++++++++++++++++---------- docs/stylesheets/extra.css | 16 +++++++ zensical.toml | 6 +++ 3 files changed, 86 insertions(+), 22 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index da992cc0..7fd7f2eb 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -19,25 +19,67 @@ field that takes a model also accepts a dictionary with the same keys. Unknown fields raise an error. The pages in this section are generated from the source code, one per public -module, so they always match the installed version. The most useful ones: - -| Module | Contents | -| ------------------------------------------------------------ | ----------------------------------------------------------------- | -| [`schema.simulation`][microsim.schema.simulation] | `Simulation`, the top-level model and its stage methods | -| [`schema.space`][microsim.schema.space] | `ShapeScaleSpace`, `DownscaledSpace`, ... | -| [`schema.sample`][microsim.schema.sample] | `Sample`, `FluorophoreDistribution`, `Fluorophore`, distributions | -| [`schema.optical_config`][microsim.schema.optical_config] | `OpticalConfig`, filters, `LightSource` | -| [`schema.optical_config.lib`][microsim.schema.optical_config.lib] | built-in filter sets | -| [`schema.modality`][microsim.schema.modality] | `Widefield`, `Confocal`, `Identity` | -| [`schema.lens`][microsim.schema.lens] | `ObjectiveLens` | -| [`schema.detectors`][microsim.schema.detectors] | cameras and point detectors | -| [`schema.detectors.lib`][microsim.schema.detectors.lib] | built-in detectors | -| [`schema.settings`][microsim.schema.settings] | `Settings` | -| [`schema.spectrum`][microsim.schema.spectrum] | `Spectrum` | -| [`psf`][microsim.psf] | PSF functions | -| [`util`][microsim.util] | plotting and cache helpers | -| [`allen`][microsim.allen] | neuron reconstructions from the Allen Institute | -| [`cosem`][microsim.cosem] | access to OpenOrganelle (COSEM) datasets | - -Each object is documented once, in the module or subpackage that defines it. -Most of them are also importable from `microsim.schema`, as above. +module, so they always match the installed version. Each object is documented +once, in the module or subpackage that defines it; most are also importable +from `microsim.schema`, as above. The main objects, by topic: + +
+ +## Simulation + +| Object | Description | +| --- | --- | +| [`Simulation`][microsim.schema.simulation.Simulation] | the top-level model | +| [`Simulation.run`][microsim.schema.simulation.Simulation.run] | run every stage and return the digital image | +| [`ground_truth`][microsim.schema.simulation.Simulation.ground_truth], [`filtered_emission_rates`][microsim.schema.simulation.Simulation.filtered_emission_rates], [`optical_image`][microsim.schema.simulation.Simulation.optical_image], [`digital_image`][microsim.schema.simulation.Simulation.digital_image] | the stages, run one at a time ([guide](../guide/index.md)) | + +## Spaces + +| Object | Description | +| --- | --- | +| [`ShapeScaleSpace`][microsim.schema.space.ShapeScaleSpace], [`ExtentScaleSpace`][microsim.schema.space.ExtentScaleSpace], [`ShapeExtentSpace`][microsim.schema.space.ShapeExtentSpace] | a space given by two of shape, extent, and scale | +| [`DownscaledSpace`][microsim.schema.space.DownscaledSpace], [`UpscaledSpace`][microsim.schema.space.UpscaledSpace] | a space derived from another one ([guide](../guide/spaces.md)) | + +## Sample + +| Object | Description | +| --- | --- | +| [`Sample`][microsim.schema.sample.Sample], [`FluorophoreDistribution`][microsim.schema.sample.FluorophoreDistribution] | the labels in the sample, and where each one is | +| [`Fluorophore`][microsim.schema.sample.Fluorophore] | spectra, extinction coefficient, quantum yield, lifetime | +| [`MatsLines`][microsim.schema.sample.MatsLines], [`CosemLabel`][microsim.schema.sample.CosemLabel] | built-in distributions ([guide](../guide/samples.md)) | +| [`cosem`][microsim.cosem], [`allen`][microsim.allen] | OpenOrganelle (COSEM) datasets, Allen Institute neuron reconstructions | + +## Optics + +| Object | Description | +| --- | --- | +| [`ObjectiveLens`][microsim.schema.lens.ObjectiveLens] | numerical aperture, immersion and specimen refractive index | +| [`Widefield`][microsim.schema.modality.Widefield], [`Confocal`][microsim.schema.modality.Confocal], [`Identity`][microsim.schema.modality.Identity] | imaging modalities ([guide](../guide/modalities.md)) | +| [`psf`][microsim.psf] | the PSF functions the modalities use | + +## Channels + +| Object | Description | +| --- | --- | +| [`OpticalConfig`][microsim.schema.optical_config.OpticalConfig] | a channel: light sources, filters, and optionally its own detector and exposure ([guide](../guide/channels.md)) | +| [`LightSource`][microsim.schema.optical_config.LightSource] | an excitation spectrum and its power | +| [`Bandpass`][microsim.schema.optical_config.Bandpass], [`Longpass`][microsim.schema.optical_config.Longpass], [`Shortpass`][microsim.schema.optical_config.Shortpass], [`SpectrumFilter`][microsim.schema.optical_config.SpectrumFilter] | filters | +| [`Spectrum`][microsim.schema.spectrum.Spectrum] | a sampled spectrum | +| [`optical_config.lib`][microsim.schema.optical_config.lib] | built-in filter sets | + +## Detectors + +| Object | Description | +| --- | --- | +| [`CameraCCD`][microsim.schema.detectors.CameraCCD], [`CameraCMOS`][microsim.schema.detectors.CameraCMOS], [`CameraEMCCD`][microsim.schema.detectors.CameraEMCCD] | cameras ([guide](../guide/detectors.md)) | +| [`PMT`][microsim.schema.detectors.PMT], [`HyD`][microsim.schema.detectors.HyD] | point detectors | +| [`detectors.lib`][microsim.schema.detectors.lib] | built-in detectors | + +## Settings and utilities + +| Object | Description | +| --- | --- | +| [`Settings`][microsim.schema.settings.Settings] | array backend, precision, random seed, caching ([guide](../guide/settings.md)) | +| [`util`][microsim.util] | plotting (`ortho_plot`, `ndview`, `animate`) and cache helpers | + +
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 83ff33b5..d6dcc2bd 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -65,6 +65,22 @@ white-space: nowrap; } +/* API overview: the same column widths in every table */ +.md-typeset .ms-api-index .md-typeset__table, +.md-typeset .ms-api-index table:not([class]) { + display: table; + width: 100%; + table-layout: fixed; +} + +.md-typeset .ms-api-index th:first-child { + width: 45%; +} + +.md-typeset .ms-api-index table:not([class]) td:first-child > code { + white-space: normal; +} + /* matplotlib output with a white background (tutorial pages) */ .md-typeset img.plot { background: #fff; diff --git a/zensical.toml b/zensical.toml index a4123e9b..57bb9ca2 100644 --- a/zensical.toml +++ b/zensical.toml @@ -210,6 +210,12 @@ exclude = [ "microsim.schema.optical_config.filter", "microsim.schema.sample.sample", "microsim.schema.sample.fluorophore", + # internals: not part of the user-facing API + "microsim.fft", + "microsim.xarray_jax", + "microsim.cosem.manage", + "microsim.schema.backend", + "microsim.schema.dimensions", ] # the top-level packages re-export everything: list their members, don't repeat them From 1b53c9351caf239a245536cca4e7316d5e274d7b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 17:53:12 +0200 Subject: [PATCH 15/28] docs: exclude microsim.illum from the API reference, silence griffe warnings for pydantic models, use the canonical site URL Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++----- scripts/make_notebooks.py | 2 +- zensical.toml | 6 +++++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9a24ace5..984eac7e 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ The goal of this library is to generate highly realistic simulated data such as ## Documentation -Documentation is at . Start with -[First simulation](https://www.talleylambert.com/microsim/getting-started/first-simulation/), -read the [User guide](https://www.talleylambert.com/microsim/guide/) for how each part +Documentation is at . Start with +[First simulation](https://talleylambert.com/microsim/getting-started/first-simulation/), +read the [User guide](https://talleylambert.com/microsim/guide/) for how each part of the simulation works, and see the -[API reference](https://www.talleylambert.com/microsim/api/) for every option. +[API reference](https://talleylambert.com/microsim/api/) for every option. > [!WARNING] > microsim is in early development and changes quickly. Releases may break the API @@ -73,7 +73,7 @@ paying attention to your GPU requirements. Support for torch is planned. ## Usage Construct and run a -[`microsim.Simulation`](https://www.talleylambert.com/microsim/api/microsim/schema/simulation/) +[`microsim.Simulation`](https://talleylambert.com/microsim/api/microsim/schema/simulation/) object. ```python diff --git a/scripts/make_notebooks.py b/scripts/make_notebooks.py index 1ba5b6a6..a7de0d82 100644 --- a/scripts/make_notebooks.py +++ b/scripts/make_notebooks.py @@ -25,7 +25,7 @@ DOCS = Path(__file__).parents[1] / "docs" OUT = DOCS / "notebooks" -SITE = "https://www.talleylambert.com/microsim/" +SITE = "https://talleylambert.com/microsim/" REPO = "https://github.com/tlambert03/microsim" INSTALL = """\ diff --git a/zensical.toml b/zensical.toml index 57bb9ca2..d74a2cdf 100644 --- a/zensical.toml +++ b/zensical.toml @@ -1,6 +1,6 @@ [project] site_name = "microsim" -site_url = "https://www.talleylambert.com/microsim/" +site_url = "https://talleylambert.com/microsim/" site_description = "Physically grounded simulation of fluorescence microscopy images, in Python" site_author = "Talley Lambert" copyright = "Copyright © 2024 - 2026 Talley Lambert" @@ -176,6 +176,9 @@ paths = ["src"] [project.plugins.mkdocstrings.handlers.python.options] docstring_style = "numpy" +# pydantic models document their fields as `Parameters`, but have no explicit +# `__init__` for griffe to compare them with +docstring_options = { warn_unknown_params = false } extensions = [ { griffe_fieldz = { include_inherited = true, remove_fields_from_members = true, strip_annotated = true } }, ] @@ -212,6 +215,7 @@ exclude = [ "microsim.schema.sample.fluorophore", # internals: not part of the user-facing API "microsim.fft", + "microsim.illum", "microsim.xarray_jax", "microsim.cosem.manage", "microsim.schema.backend", From caa7af5bf480c9e0bbc3ba40dd53515fb7157212 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 17:55:47 +0200 Subject: [PATCH 16/28] docs: raise the noise chart's camera offset so high read noise doesn't clip at 0 Co-Authored-By: Claude Opus 5.5 --- docs/javascripts/charts/noise.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/javascripts/charts/noise.js b/docs/javascripts/charts/noise.js index 29fa3b09..3517b4db 100644 --- a/docs/javascripts/charts/noise.js +++ b/docs/javascripts/charts/noise.js @@ -18,12 +18,14 @@ import { } from "./lib.js"; // starting values, as in the "Which camera when" chart; `electronsPerAdu` puts -// the signal well inside a 16-bit range +// the signal well inside a 16-bit range. The offset is 5x the largest read +// noise on the slider (100 e⁻ at 0.5 e⁻/ADU), so the background never clips at 0. +const OFFSET = 1000; const CAMERAS = { "ideal (shot noise only)": { readNoise: 0, electronsPerAdu: 1, offset: 0 }, - CCD: { readNoise: 6, electronsPerAdu: 1 }, - sCMOS: { readNoise: 1.5, electronsPerAdu: 0.5 }, - EMCCD: { readNoise: 50, electronsPerAdu: 20, emGain: 300 }, + CCD: { readNoise: 6, electronsPerAdu: 1, offset: OFFSET }, + sCMOS: { readNoise: 1.5, electronsPerAdu: 0.5, offset: OFFSET }, + EMCCD: { readNoise: 50, electronsPerAdu: 20, emGain: 300, offset: OFFSET }, }; export default async function (el) { From e8c05eaa1dbb7115e3427a81d6d2365b9c247ac8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 17:58:45 +0200 Subject: [PATCH 17/28] docs: play/stop button for the noise chart (new noise at 10 fps) Co-Authored-By: Claude Opus 5.5 --- docs/javascripts/charts/noise.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/javascripts/charts/noise.js b/docs/javascripts/charts/noise.js index 3517b4db..d5f50dd1 100644 --- a/docs/javascripts/charts/noise.js +++ b/docs/javascripts/charts/noise.js @@ -82,7 +82,30 @@ export default async function (el) { const actions = document.createElement("div"); actions.className = "ms-control ms-control--button"; ui.controls.append(actions); - button(actions, { label: "New noise", onClick: () => (state.seed++, update()) }); + // play: a new noise draw every 100 ms (10 fps), until stopped or the page changes + const PLAY = "▶ Play noise"; + const STOP = "■ Stop"; + let timer = 0; + const stop = () => { + clearInterval(timer); + timer = 0; + play.textContent = PLAY; + play.setAttribute("aria-pressed", "false"); + }; + const play = button(actions, { + label: PLAY, + onClick: () => { + if (timer) return stop(); + play.textContent = STOP; + play.setAttribute("aria-pressed", "true"); + timer = setInterval(() => { + if (!el.isConnected) return stop(); + state.seed++; + update(); + }, 100); + }, + }); + play.setAttribute("aria-pressed", "false"); const setSnr = stat(ui.stats, { label: "SNR at the brightest pixel" }); const setRel = stat(ui.stats, { label: "relative to an ideal detector" }); From f40b2c804d115ae2465efac38fb40b7cc8dd2fb2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:00:13 +0200 Subject: [PATCH 18/28] docs: describe detectors and modalities without advising which hardware to use Co-Authored-By: Claude Opus 5.5 --- .claude/skills/check-docs-prose/SKILL.md | 4 ++++ docs/guide/detectors.md | 16 ++++++++-------- docs/guide/modalities.md | 5 +++-- docs/javascripts/charts/noise.js | 2 +- docs/tutorials/two-color-cell.md | 4 ++-- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.claude/skills/check-docs-prose/SKILL.md b/.claude/skills/check-docs-prose/SKILL.md index 94beaeaf..1c1aa327 100644 --- a/.claude/skills/check-docs-prose/SKILL.md +++ b/.claude/skills/check-docs-prose/SKILL.md @@ -76,6 +76,10 @@ statement of fact about microsim, find the code that makes it true, and cite it - Fix clear factual errors directly, in the docs' existing style: plain, scientific, no marketing language, no em dashes. +- The docs describe what microsim computes; they don't advise readers on which + hardware to use (e.g. "which camera when", "the natural choice", "best for + low light"). Rephrase such lines as statements of what the model or the + physics does. - If the code looks wrong rather than the docs (a doc describes intended behavior the code doesn't implement), don't change either; report it. - Report what you checked, what you changed, and anything you could not verify, diff --git a/docs/guide/detectors.md b/docs/guide/detectors.md index 597cedda..84ec0159 100644 --- a/docs/guide/detectors.md +++ b/docs/guide/detectors.md @@ -12,13 +12,13 @@ enters, and where most of the noise comes from. sim = ms.Simulation(..., detector=ms.CameraCMOS(qe=0.8, read_noise=1.5)) ``` -| Detector | `camera_type` | Typical use | -| -------------------------------------------- | ------------- | ------------------------------------------ | -| [`CameraCCD`][microsim.schema.CameraCCD] | `"CCD"` | widefield; interline CCD cameras | -| [`CameraEMCCD`][microsim.schema.CameraEMCCD] | `"EMCCD"` | widefield or spinning disk at very low light | -| [`CameraCMOS`][microsim.schema.CameraCMOS] | `"CMOS"` | widefield; sCMOS cameras | -| [`PMT`][microsim.schema.PMT] | `"PMT"` | point-scanning confocal | -| [`HyD`][microsim.schema.HyD] | `"HyD"` | point-scanning confocal, photon counting | +| Detector | `camera_type` | Models | +| -------------------------------------------- | ------------- | ---------------------------------------- | +| [`CameraCCD`][microsim.schema.CameraCCD] | `"CCD"` | CCD cameras | +| [`CameraEMCCD`][microsim.schema.CameraEMCCD] | `"EMCCD"` | electron-multiplying CCD cameras | +| [`CameraCMOS`][microsim.schema.CameraCMOS] | `"CMOS"` | CMOS and sCMOS cameras | +| [`PMT`][microsim.schema.PMT] | `"PMT"` | photomultiplier tubes, analog readout | +| [`HyD`][microsim.schema.HyD] | `"HyD"` | hybrid photodetectors, photon counting | In dictionary form, `camera_type` selects the class: `detector={"camera_type": "EMCCD", "em_gain": 300}`. @@ -138,7 +138,7 @@ range. integrated for N times the exposure, read noise is added N times, and the result is divided by N. Brightness is unchanged, and noise is reduced. -### Which camera when +### SNR against signal
/// caption diff --git a/docs/guide/modalities.md b/docs/guide/modalities.md index 53507caa..cd384ebb 100644 --- a/docs/guide/modalities.md +++ b/docs/guide/modalities.md @@ -69,8 +69,9 @@ For a point-scanning modality: of the excitation spot rather than to the emission rate. - Downsampling to the output space averages rather than sums ([Spaces](spaces.md#how-the-downsampling-works)). -- A point detector ([PMT or HyD](detectors.md#point-detectors)) is the natural - choice, though any detector works. +- Point-scanning microscopes record with a point detector + ([PMT or HyD](detectors.md#point-detectors)), but microsim accepts any + detector.
/// caption diff --git a/docs/javascripts/charts/noise.js b/docs/javascripts/charts/noise.js index d5f50dd1..fff6deba 100644 --- a/docs/javascripts/charts/noise.js +++ b/docs/javascripts/charts/noise.js @@ -17,7 +17,7 @@ import { stat, } from "./lib.js"; -// starting values, as in the "Which camera when" chart; `electronsPerAdu` puts +// starting values, as in the "SNR against signal" chart; `electronsPerAdu` puts // the signal well inside a 16-bit range. The offset is 5x the largest read // noise on the slider (100 e⁻ at 0.5 e⁻/ADU), so the background never clips at 0. const OFFSET = 1000; diff --git a/docs/tutorials/two-color-cell.md b/docs/tutorials/two-color-cell.md index 0b95fa5f..0b526062 100644 --- a/docs/tutorials/two-color-cell.md +++ b/docs/tutorials/two-color-cell.md @@ -307,6 +307,6 @@ ortho_plot(sim.digital_image(exposure_ms=0.01), z=9) # 10 µs pixel dwell ![Confocal digital image](../images/generated/tutorials/two-color-cell/15.png){ .plot } The channels still use the LED spectrum from the widefield configuration; -only the irradiance and the PSF changed. A CCD camera is also not what a -point-scanning confocal would use; see +only the irradiance and the PSF changed. The detector is still the CCD camera; +point-scanning confocals record with point detectors, as in [point-scanning detectors](point-scanning-detectors.md). From 464c7365c55de99dd872aa9d8308f607917a5687 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:00:31 +0200 Subject: [PATCH 19/28] docs: rename the detector pipeline section Co-Authored-By: Claude Opus 5.5 --- docs/guide/detectors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/detectors.md b/docs/guide/detectors.md index 84ec0159..eb926db7 100644 --- a/docs/guide/detectors.md +++ b/docs/guide/detectors.md @@ -59,7 +59,7 @@ short = sim.digital_image(optical, exposure_ms=10) long = sim.digital_image(optical, exposure_ms=1000) ``` -## What every detector does +## The detector simulation pipeline All detectors run the same sequence of steps. They differ in their parameters, and in which steps do nothing for them. From 9cad3bdeb168a25622dde6dfd9b25ee9c2405e19 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:03:06 +0200 Subject: [PATCH 20/28] docs: make the sCMOS pixel-variation limitation a danger admonition Co-Authored-By: Claude Opus 5.5 --- docs/guide/detectors.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/guide/detectors.md b/docs/guide/detectors.md index eb926db7..0feee36a 100644 --- a/docs/guide/detectors.md +++ b/docs/guide/detectors.md @@ -186,9 +186,12 @@ the EM gain. Typical values for a scientific CMOS: QE 0.7–0.95, read noise 1–2 e⁻, `electrons_per_adu` around 0.5 at 16 bits. -The model applies the same gain, offset, and read noise to every pixel. Real -sCMOS sensors vary from pixel to pixel, and some pixels are much noisier than -average; that is not modeled yet. +!!! danger "sCMOS pixel-to-pixel variation is not modeled" + + The model applies the same gain, offset, and read noise to every pixel. + Real sCMOS sensors vary from pixel to pixel, and some pixels are much + noisier than average, so simulated sCMOS images are more uniform than real + ones. Per-pixel gain, offset, and read noise maps are on the roadmap. !!! example "Default camera settings" From b57bc6e8efed0bafd134f29b72198092c6c02a38 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:04:59 +0200 Subject: [PATCH 21/28] docs: neutral wording for the EMCCD/sCMOS SNR stat Co-Authored-By: Claude Opus 5.5 --- docs/javascripts/charts/detector-snr.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/javascripts/charts/detector-snr.js b/docs/javascripts/charts/detector-snr.js index ef3ed1f6..c6735393 100644 --- a/docs/javascripts/charts/detector-snr.js +++ b/docs/javascripts/charts/detector-snr.js @@ -62,7 +62,7 @@ export default async function (el) { for (const name of Object.keys(color)) legendItem(ui.legend, { label: name, color: color[name] }); legendItem(ui.legend, { label: "microsim simulation (starting values)", color: C.muted, swatch: "dotmark" }); - const setCross = stat(ui.stats, { label: "EMCCD beats sCMOS", note: "" }); + const setCross = stat(ui.stats, { label: "EMCCD SNR above sCMOS", note: "" }); const setCCD = stat(ui.stats, { label: "CCD reaches 90% of ideal", note: "" }); const chart = responsive(ui.plot, (width) => { @@ -89,10 +89,10 @@ export default async function (el) { pts.map((p) => ({ name, e: p.electrons, rel: p.snr / D.snr({ electrons: p.electrons }) })), ); - const better = LEVELS.filter((e) => relative("EMCCD", e) > relative("sCMOS", e)); + const higher = LEVELS.filter((e) => relative("EMCCD", e) > relative("sCMOS", e)); setCross( - better.length ? `< ${fmt.sig(better.at(-1), 2)} e⁻` : "never", - better.length ? "photoelectrons per pixel, with these settings" : "with these settings", + higher.length ? `< ${fmt.sig(higher.at(-1), 2)} e⁻` : "never", + higher.length ? "photoelectrons per pixel, with these settings" : "with these settings", ); const good = LEVELS.find((e) => relative("CCD", e) >= 0.9); setCCD(good ? `${fmt.sig(good, 2)} e⁻` : "> 10k", "photoelectrons per pixel"); From a8d0dc8fbafc1103639992d9c5a305bc69117db6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:08:19 +0200 Subject: [PATCH 22/28] docs: remove the simulated SNR dots from the detector chart (a test, not a figure) Co-Authored-By: Claude Opus 5.5 --- docs/guide/detectors.md | 3 +-- docs/javascripts/charts/detector-snr.js | 19 ++----------------- scripts/make_doc_figures.py | 24 +----------------------- 3 files changed, 4 insertions(+), 42 deletions(-) diff --git a/docs/guide/detectors.md b/docs/guide/detectors.md index 0feee36a..9c1724b1 100644 --- a/docs/guide/detectors.md +++ b/docs/guide/detectors.md @@ -145,8 +145,7 @@ range. SNR of each camera, relative to an ideal detector that adds only shot noise, from the formulas above. Read noise matters at low signal; the EMCCD's gain removes it, at the cost of the excess noise factor, which caps its relative -SNR at \(1/\sqrt{F} \approx 71\%\). Dots: microsim's detector simulation -with the starting values, which the formulas match. +SNR at \(1/\sqrt{F} \approx 71\%\). /// ## Cameras diff --git a/docs/javascripts/charts/detector-snr.js b/docs/javascripts/charts/detector-snr.js index c6735393..a26a084f 100644 --- a/docs/javascripts/charts/detector-snr.js +++ b/docs/javascripts/charts/detector-snr.js @@ -1,7 +1,6 @@ // Detectors page: SNR of a CCD, sCMOS, and EMCCD, relative to an ideal detector, -// against the signal per pixel. Lines use physics/detectors.js; dots are -// microsim's detector simulation at the starting parameters -// (detector_data in scripts/make_doc_figures.py). +// against the signal per pixel, from physics/detectors.js snr. Starting +// parameters from detector_data in scripts/make_doc_figures.py. import * as D from "../physics/detectors.js"; import { C, @@ -60,7 +59,6 @@ export default async function (el) { const color = { CCD: C.series[0], sCMOS: C.series[2], EMCCD: C.series[1] }; for (const name of Object.keys(color)) legendItem(ui.legend, { label: name, color: color[name] }); - legendItem(ui.legend, { label: "microsim simulation (starting values)", color: C.muted, swatch: "dotmark" }); const setCross = stat(ui.stats, { label: "EMCCD SNR above sCMOS", note: "" }); const setCCD = stat(ui.stats, { label: "CCD reaches 90% of ideal", note: "" }); @@ -77,18 +75,6 @@ export default async function (el) { const rows = LEVELS.flatMap((e) => Object.keys(model).map((name) => ({ name, e, rel: relative(name, e) })), ); - // simulated points, only for cameras still at their starting values - const atStart = { - CCD: state.ccdRead === start.CCD.read_noise, - sCMOS: state.cmosRead === start.sCMOS.read_noise, - EMCCD: state.emGain === start.EMCCD.em_gain && state.emRead === start.EMCCD.read_noise, - }; - const dots = Object.entries(data.simulated_snr) - .filter(([name]) => atStart[name]) - .flatMap(([name, pts]) => - pts.map((p) => ({ name, e: p.electrons, rel: p.snr / D.snr({ electrons: p.electrons }) })), - ); - const higher = LEVELS.filter((e) => relative("EMCCD", e) > relative("sCMOS", e)); setCross( higher.length ? `< ${fmt.sig(higher.at(-1), 2)} e⁻` : "never", @@ -110,7 +96,6 @@ export default async function (el) { Plot.ruleY([1], { stroke: C.faint, strokeDasharray: "3,3" }), Plot.text([{}], { x: LEVELS[2], y: 1, text: () => "ideal (shot noise only)", dy: 10, textAnchor: "start", fill: C.muted }), Plot.lineY(rows, { x: "e", y: "rel", z: "name", stroke: "name", strokeWidth: 2.25 }), - Plot.dot(dots, { x: "e", y: "rel", fill: "name", r: 3.5, stroke: C.surface, strokeWidth: 1.5 }), ], }); ui.plot.replaceChildren(svg); diff --git a/scripts/make_doc_figures.py b/scripts/make_doc_figures.py index 41cac392..b7edbfef 100644 --- a/scripts/make_doc_figures.py +++ b/scripts/make_doc_figures.py @@ -27,10 +27,8 @@ import numpy as np from microsim import schema as ms -from microsim._data_array import DataArray from microsim.allen import NeuronReconstruction from microsim.psf import make_psf -from microsim.schema.backend import NumpyAPI from microsim.schema.detectors import lib as det_lib from microsim.schema.optical_config import lib as oc_lib @@ -295,28 +293,9 @@ def photometry_data() -> None: } -def _measured_snr(detector: ms.Detector, electrons: float) -> float: - """SNR of a flat image recorded by `detector` (unclipped), in a 1 s exposure.""" - exact = {"bit_depth": 32, "electrons_per_adu": 0.01, "offset": 10**6} - det = detector.model_copy(update={**exact, "dark_current": 0, "full_well": 10**9}) - xp = NumpyAPI() - xp.set_random_seed(0) - image = DataArray(np.full((1, 1, 2000, 2000), electrons), dims=("c", "z", "y", "x")) - gray = np.asarray(det.render(image, exposure_ms=1000, xp=xp), dtype=float) - signal = (gray - det.offset) * det.conversion_factor - return float(signal.mean() / signal.std()) - - @figure def detector_data() -> None: - """Detector presets, and simulated SNR, for the interactive detector charts.""" - levels = np.logspace(-1, 4, 11) - simulated = { - name: [ - {"electrons": float(n), "snr": _measured_snr(det, float(n))} for n in levels - ] - for name, det in SNR_CAMERAS.items() - } + """Detector presets for the interactive detector charts.""" cameras = { name: { "read_noise": det.read_noise, @@ -340,7 +319,6 @@ def detector_data() -> None: "detectors", { "cameras": cameras, - "simulated_snr": simulated, "pmts": pmts, "hyd_dead_time_ns": det_lib.HYD_SP8.dead_time_ns, }, From de947faa667155b2f3153d0f32892fcd579d3807 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:08:58 +0200 Subject: [PATCH 23/28] =?UTF-8?q?docs:=20sCMOS=20starts=20at=201=20e?= =?UTF-8?q?=E2=81=BB=20read=20noise=20in=20the=20detector=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/javascripts/charts/noise.js | 2 +- scripts/make_doc_figures.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/javascripts/charts/noise.js b/docs/javascripts/charts/noise.js index fff6deba..a57e2924 100644 --- a/docs/javascripts/charts/noise.js +++ b/docs/javascripts/charts/noise.js @@ -24,7 +24,7 @@ const OFFSET = 1000; const CAMERAS = { "ideal (shot noise only)": { readNoise: 0, electronsPerAdu: 1, offset: 0 }, CCD: { readNoise: 6, electronsPerAdu: 1, offset: OFFSET }, - sCMOS: { readNoise: 1.5, electronsPerAdu: 0.5, offset: OFFSET }, + sCMOS: { readNoise: 1, electronsPerAdu: 0.5, offset: OFFSET }, EMCCD: { readNoise: 50, electronsPerAdu: 20, emGain: 300, offset: OFFSET }, }; diff --git a/scripts/make_doc_figures.py b/scripts/make_doc_figures.py index b7edbfef..2b1c0ddc 100644 --- a/scripts/make_doc_figures.py +++ b/scripts/make_doc_figures.py @@ -288,7 +288,7 @@ def photometry_data() -> None: # cameras in the interactive SNR chart, with the parameters it starts from SNR_CAMERAS = { "CCD": ms.CameraCCD(read_noise=6), - "sCMOS": ms.CameraCMOS(read_noise=1.5), + "sCMOS": ms.CameraCMOS(read_noise=1), "EMCCD": ms.CameraEMCCD(em_gain=300, read_noise=50), } From d044439d5690f5e04b3b7c22e7059f62e3e3b6e0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:32:32 +0200 Subject: [PATCH 24/28] docs: fixes from the prose check (failing command, saturation and spot power, readout order, caches, run_batch, irradiance range) Co-Authored-By: Claude Opus 5.5 --- docs/about/contributing.md | 2 +- docs/api/index.md | 1 + docs/guide/channels.md | 6 +++--- docs/guide/detectors.md | 7 ++++--- docs/guide/index.md | 9 ++++++++- docs/guide/modalities.md | 2 +- docs/guide/photometry.md | 2 +- docs/guide/samples.md | 4 ++-- docs/guide/saving.md | 9 +++++---- docs/guide/settings.md | 8 +++++++- docs/tutorials/point-scanning-detectors.md | 4 ++-- 11 files changed, 35 insertions(+), 19 deletions(-) diff --git a/docs/about/contributing.md b/docs/about/contributing.md index 4a09b4a6..7fd528d9 100644 --- a/docs/about/contributing.md +++ b/docs/about/contributing.md @@ -46,7 +46,7 @@ few minutes the first time): ```bash uv run --group docs python scripts/make_doc_figures.py # everything -uv run --group docs python scripts/make_doc_figures.py psf detector_data # some +uv run --group docs python scripts/make_doc_figures.py pinhole_data detector_data # some ``` Images are written to `docs/images/generated/` and chart data to diff --git a/docs/api/index.md b/docs/api/index.md index 7fd7f2eb..0b2f7510 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -31,6 +31,7 @@ from `microsim.schema`, as above. The main objects, by topic: | --- | --- | | [`Simulation`][microsim.schema.simulation.Simulation] | the top-level model | | [`Simulation.run`][microsim.schema.simulation.Simulation.run] | run every stage and return the digital image | +| [`Simulation.run_batch`][microsim.schema.simulation.Simulation.run_batch] | run once per sample, reusing PSFs and emission rates | | [`ground_truth`][microsim.schema.simulation.Simulation.ground_truth], [`filtered_emission_rates`][microsim.schema.simulation.Simulation.filtered_emission_rates], [`optical_image`][microsim.schema.simulation.Simulation.optical_image], [`digital_image`][microsim.schema.simulation.Simulation.digital_image] | the stages, run one at a time ([guide](../guide/index.md)) | ## Spaces diff --git a/docs/guide/channels.md b/docs/guide/channels.md index a47b8668..fdecbfd0 100644 --- a/docs/guide/channels.md +++ b/docs/guide/channels.md @@ -136,7 +136,7 @@ Rough orders of magnitude for the irradiance at the sample: | ----------------------------------- | ----------------------- | | widefield, arc lamp or LED | 0.1–10 W/cm² | | widefield, laser | 10–1000 W/cm² | -| point-scanning confocal, at focus | 1–1000 kW/cm² and above | +| point-scanning confocal, at focus | 10–1000 kW/cm² | For point-scanning modalities, `power` is the peak irradiance at the center of the focused spot. See [Modalities](modalities.md#confocal). @@ -173,8 +173,8 @@ Detected photons per molecule for two fluorophores in two channels. Dashed cells are crosstalk: the fluorophore gives less than half of that channel's signal. tdTomato absorbs only weakly in the FITC excitation band, but it is bright, and a 500 nm longpass emission filter passes nearly all of its -emission: per molecule, it gives almost as much signal in the green channel as -EGFP. With the 525/50 bandpass emission filter of `FITC`, its share drops below +emission: per molecule, it gives about 80% as much signal in the green channel +as EGFP. With the 525/50 bandpass emission filter of `FITC`, its share drops below 1%. Select `FITC` as channel 1 to compare. /// diff --git a/docs/guide/detectors.md b/docs/guide/detectors.md index 9c1724b1..b7edb768 100644 --- a/docs/guide/detectors.md +++ b/docs/guide/detectors.md @@ -72,9 +72,10 @@ parameters, and in which steps do nothing for them. 3. **Full well.** A camera pixel cannot hold more than `full_well` electrons. 4. **Multiplication.** EMCCDs and PMTs multiply each electron by a random gain before readout. The randomness adds noise. -5. **Readout.** The amplifier adds Gaussian read noise, the ADC converts - electrons to gray values, the `offset` is added, and the result is rounded - and clipped to the range of `bit_depth`. +5. **Readout.** The amplifier adds Gaussian read noise, and the ADC converts + electrons to gray values, rounded and clipped to the top of the `bit_depth` + range. Then any digital gain and the `offset` are applied, and the result is + clipped to the range again. The parameters shared by all detectors: diff --git a/docs/guide/index.md b/docs/guide/index.md index 27e8a7c3..283db296 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -45,7 +45,7 @@ flowchart TB direction LR fl[fluorophore spectra] --> em["photons/s per fluorophore
(c, f, w)"] ch[channels] --> em - ob1[objective NA] --> em + ob1[objective NA, specimen RI] --> em dq[detector QE] --> em end subgraph S3 ["3 optical_image()"] @@ -104,6 +104,13 @@ returns the optical image before fluorophores are summed, as fluorophore. [`emission_flux()`][microsim.schema.simulation.Simulation.emission_flux] returns the emitted photons per voxel before blurring. +To simulate many samples with the same optics, +[`run_batch()`][microsim.schema.simulation.Simulation.run_batch] runs the +simulation once per sample and yields each digital image. PSFs and emission +rates are computed once and reused. Each sample gets its own random seed, +derived from `settings.random_seed`, and if `output_path` is set, result `i` +is written to `_.`. + ## Units microsim uses one unit per kind of quantity: diff --git a/docs/guide/modalities.md b/docs/guide/modalities.md index cd384ebb..042b517b 100644 --- a/docs/guide/modalities.md +++ b/docs/guide/modalities.md @@ -91,7 +91,7 @@ passing less light. In a simulation, the confocal excitation PSF is currently computed at the emission wavelength, not at the wavelength of the light source. This makes the simulated excitation spot slightly larger than it should be (by the - ratio of the wavelengths, about 7% for GFP). + ratio of the wavelengths, about 7% for EGFP excited at 488 nm). ## Identity diff --git a/docs/guide/photometry.md b/docs/guide/photometry.md index aa487ff7..cf9ac419 100644 --- a/docs/guide/photometry.md +++ b/docs/guide/photometry.md @@ -16,7 +16,7 @@ as microsim does, for a specimen refractive index of 1.47 (the `ObjectiveLens` default). With the defaults (EGFP, FITC channel, 1 W/cm², 1.4 NA, no detector QE), one molecule gives about 6 photons in a 100 ms exposure. Typical widefield irradiance is 0.1–10 W/cm²; the peak irradiance of -a confocal spot is 1–1000 kW/cm² and above +a confocal spot is 10–1000 kW/cm² ([Channels](channels.md#illumination-power)). Fluorophores without a known lifetime do not saturate. /// diff --git a/docs/guide/samples.md b/docs/guide/samples.md index 74d3fd30..f906beef 100644 --- a/docs/guide/samples.md +++ b/docs/guide/samples.md @@ -205,7 +205,7 @@ larger, not much smaller than the PSF; to simulate those, fill a sphere of voxels for each bead instead (as with the shells in the [own ground truth tutorial](../tutorials/your-own-ground-truth.md)). -`np.random` is seeded from +With the NumPy backend, `np.random` is seeded from [`Settings.random_seed`](settings.md#reproducibility) before rendering, so this example is reproducible. Generators like this cannot be saved to JSON. To make one that can, subclass @@ -261,7 +261,7 @@ What each property does: ### The placeholder fluorophore A label without a fluorophore uses `mockFluorophore`: flat excitation and -emission spectra from 300 to 800 nm, an extinction coefficient of +emission spectra from 300 to 799 nm, an extinction coefficient of 50,000 M⁻¹ cm⁻¹, a quantum yield of 1, and a 1 ns lifetime. It is excited by any light source and passes any emission filter. Use it for testing; for realistic brightness, color, and bleedthrough, use a real fluorophore. diff --git a/docs/guide/saving.md b/docs/guide/saving.md index bc5b9411..259995a1 100644 --- a/docs/guide/saving.md +++ b/docs/guide/saving.md @@ -34,9 +34,9 @@ sim = ms.Simulation.model_validate_json(result.attrs["microsim.Simulation"]) !!! tip "Prefer Zarr" Without the `netCDF4` package installed, xarray writes NetCDF files in the - older NetCDF-3 format, which has no unsigned integer types. Gray values - above 32,767 (possible with a 16-bit detector) do not survive the round - trip. Zarr has no such limit. + older NetCDF-3 format, which has no unsigned integer types. Writing a + 16-bit image with gray values above 32,767 fails with a `ValueError`. + Zarr has no such limit. To save intermediate stages, use xarray directly, for example `sim.optical_image().to_netcdf("optical.nc")`. @@ -55,7 +55,8 @@ sim = ms.Simulation.model_validate_json(Path("sim.json").read_text()) ``` The JSON is complete: fluorophores loaded from FPbase are saved with their -spectra, so loading does not need network access. It also includes the random +spectra, so loading does not need network access (except for a `CosemLabel` +sample, which queries OpenOrganelle when it is loaded). It also includes the random seed, so loading and running the simulation in the same environment reproduces the result. diff --git a/docs/guide/settings.md b/docs/guide/settings.md index e912bdd2..31bd3c37 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -16,7 +16,7 @@ sim = ms.Simulation(..., settings=ms.Settings(random_seed=42, np_backend="jax")) | `random_seed` | random | seed for the ground truth and noise | | `np_backend` | `"auto"` | array library used for computation | | `float_dtype` | `float32` | floating-point precision | -| `max_psf_radius_aus` | 6 | lateral size limit of each PSF, in Airy units | +| `max_psf_radius_aus` | 6 | lateral size limit of each PSF, in Airy radii | | `spectral_bins_per_emission_channel` | 1 | number of PSFs used per fluorophore and channel | | `spectral_bin_threshold_percentage` | 1 | part of the emission spectrum that is ignored when binning | | `cache` | on | read and write the on-disk ground-truth cache | @@ -83,6 +83,12 @@ microsim caches results that are slow to compute and depend only on their parameters: - **PSFs**, in memory (the most recent 64) and on disk. +- **Fourier transforms of PSFs**, in memory, up to + `Settings.cache.in_mem_size.otf_mb` (1000 MB by default; GPU memory for GPU + backends). +- **Emission rates** (the result of `filtered_emission_rates`), in memory, for + the most recent 32 combinations of channels, fluorophores, detector QE, and + objective. - **Ground truth** from `MatsLines` and `CosemLabel`, on disk, keyed by the distribution's parameters, the truth space, the concentration, and the random seed. diff --git a/docs/tutorials/point-scanning-detectors.md b/docs/tutorials/point-scanning-detectors.md index c4854ff4..f585f0e2 100644 --- a/docs/tutorials/point-scanning-detectors.md +++ b/docs/tutorials/point-scanning-detectors.md @@ -39,9 +39,9 @@ sim = ms.Simulation( optical_image = sim.optical_image() ``` -1. 20 kW/cm² at the focus, about 25 µW in a diffraction-limited spot. The +1. 20 kW/cm² at the focus, about 10 µW in a diffraction-limited spot. The brightest pixels receive tens of millions of photons per second. EGFP is - only slightly saturated at this irradiance (\(k\tau \approx 0.03\)). + only slightly saturated at this irradiance (\(k\tau \approx 0.02\)). 2. The detector's QE is applied when the optical image is computed. All the detectors compared below have GaAsP photocathodes with nearly identical QE spectra, so one optical image serves for all of them. From 3fcd780d05d720205765750bee3b7e15e7e3b9c2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:35:57 +0200 Subject: [PATCH 25/28] docs: chart physics into physics/ with tests, presets and lens from exported data - halfSaturationIrradiance, maxEmissionRate, hydMaxRate, roundHalfEven, each tested against microsim - photon budget chart uses photonBudget; test its result against Simulation.filtered_emission_rates - noise, EMCCD, and PMT charts start from exported presets; photon budget and crosstalk use the exported ObjectiveLens defaults - drop unused illumination/emission exports Co-Authored-By: Claude Opus 5.5 --- docs/javascripts/charts/crosstalk.js | 14 ++++----- docs/javascripts/charts/emccd-enf.js | 7 +++-- docs/javascripts/charts/hyd-dead-time.js | 2 +- docs/javascripts/charts/noise.js | 32 +++++++++++++-------- docs/javascripts/charts/photometry-data.js | 7 +++-- docs/javascripts/charts/photon-budget.js | 29 +++++++++---------- docs/javascripts/charts/pmt-gain.js | 3 +- docs/javascripts/charts/saturation.js | 6 ++-- docs/javascripts/physics/detectors.js | 25 ++++++++++++---- docs/javascripts/physics/photometry.js | 13 +++++++++ scripts/make_doc_figures.py | 13 +++++---- tests/js/detectors.mjs | 2 ++ tests/js/photometry.mjs | 6 ++++ tests/test_js_physics.py | 33 +++++++++++++++++++++- 14 files changed, 136 insertions(+), 56 deletions(-) diff --git a/docs/javascripts/charts/crosstalk.js b/docs/javascripts/charts/crosstalk.js index 0fe84b44..d478544b 100644 --- a/docs/javascripts/charts/crosstalk.js +++ b/docs/javascripts/charts/crosstalk.js @@ -33,14 +33,14 @@ function valueAt(s, nm) { return Math.abs(s.w[i] - nm) <= 1 ? s.v[i] : 0; } -// detected photons/s per molecule, at 1 W/cm², 1.4 NA, specimen RI 1.47, QE 1 -function detected(channel, fluor) { - const b = P.photonBudget({ channel, fluor, power: 1, qe: null, na: 1.4, specimenRI: 1.47 }); - return P.total(b.collected); +// detected photons/s per molecule, at 1 W/cm², QE 1, and the default objective +function detected(channel, fluor, lens) { + const opts = { power: 1, qe: null, na: lens.na, specimenRI: lens.specimen_ri }; + return P.total(P.photonBudget({ channel, fluor, ...opts }).collected); } export default async function (el) { - const { fluors, channels } = await loadPhotometry(); + const { fluors, channels, lens } = await loadPhotometry(); const ui = shell(el); // two columns: the fluorophores on one row, the channels on the next ui.controls.classList.add("ms-chart__controls--pairs"); @@ -78,7 +78,7 @@ export default async function (el) { const chart = responsive(ui.plot, (width) => { const f = [state.f1, state.f2].map((n) => fluors.find((x) => x.name === n)); const c = [state.c1, state.c2].map((n) => channels.find((x) => x.name === n)); - const rates = c.map((ch) => f.map((fl) => detected(ch, fl))); + const rates = c.map((ch) => f.map((fl) => detected(ch, fl, lens))); const head = `${f.map((fl) => `${fl.name}`).join("")}`; const body = c @@ -94,7 +94,7 @@ export default async function (el) { return `${ch.name}${cells}`; }) .join(""); - table.innerHTML = `detected photons/s per molecule (1 W/cm², 1.4 NA, QE 1)${head}${body}`; + table.innerHTML = `detected photons/s per molecule (1 W/cm², ${lens.na} NA, QE 1)${head}${body}`; const em = f.map((fl) => peakNormalized(fl.em)); const paths = c.map((ch) => ch.emission); diff --git a/docs/javascripts/charts/emccd-enf.js b/docs/javascripts/charts/emccd-enf.js index b38b5893..8005ab6f 100644 --- a/docs/javascripts/charts/emccd-enf.js +++ b/docs/javascripts/charts/emccd-enf.js @@ -1,5 +1,6 @@ // Detectors page, EMCCD section: excess noise factor against EM gain -// (CameraEMCCD.excess_noise_factor, via physics/detectors.js). +// (CameraEMCCD.excess_noise_factor, via physics/detectors.js), starting at the +// EMCCD of detector_data in scripts/make_doc_figures.py. import * as D from "../physics/detectors.js"; import { C, @@ -8,6 +9,7 @@ import { crosshair, decades, fmt, + loadData, responsive, shell, slider, @@ -19,8 +21,9 @@ import { const GAINS = Array.from({ length: 151 }, (_, i) => 10 ** ((i * 3) / 150)); // 1 to 1000 export default async function (el) { + const { cameras } = await loadData("detectors"); const ui = shell(el); - const state = { stages: 604, gain: 300 }; + const state = { stages: cameras.EMCCD.em_stages, gain: cameras.EMCCD.em_gain }; const redraw = () => chart.redraw(); slider(ui.controls, { label: "EM gain", diff --git a/docs/javascripts/charts/hyd-dead-time.js b/docs/javascripts/charts/hyd-dead-time.js index 4ec0c54c..f1c4704f 100644 --- a/docs/javascripts/charts/hyd-dead-time.js +++ b/docs/javascripts/charts/hyd-dead-time.js @@ -38,7 +38,7 @@ export default async function (el) { const chart = responsive(ui.plot, (width) => { const rows = RATES.map((n) => ({ n, eff: D.hydCountingEfficiency(n, state.tau) })); setLoss(fmt.pct(1 - D.hydCountingEfficiency(4e7, state.tau))); - setMax(`${fmt.si(1e9 / state.tau)}cps`); + setMax(`${fmt.si(D.hydMaxRate(state.tau))}cps`); const svg = Plot.plot({ ...baseOptions(width), height: 220, diff --git a/docs/javascripts/charts/noise.js b/docs/javascripts/charts/noise.js index a57e2924..cb1932fc 100644 --- a/docs/javascripts/charts/noise.js +++ b/docs/javascripts/charts/noise.js @@ -17,23 +17,31 @@ import { stat, } from "./lib.js"; -// starting values, as in the "SNR against signal" chart; `electronsPerAdu` puts -// the signal well inside a 16-bit range. The offset is 5x the largest read -// noise on the slider (100 e⁻ at 0.5 e⁻/ADU), so the background never clips at 0. +// The offset is 5x the largest read noise on the slider (100 e⁻ at 0.5 e⁻/ADU), so +// the background never clips at 0. const OFFSET = 1000; -const CAMERAS = { - "ideal (shot noise only)": { readNoise: 0, electronsPerAdu: 1, offset: 0 }, - CCD: { readNoise: 6, electronsPerAdu: 1, offset: OFFSET }, - sCMOS: { readNoise: 1, electronsPerAdu: 0.5, offset: OFFSET }, - EMCCD: { readNoise: 50, electronsPerAdu: 20, emGain: 300, offset: OFFSET }, -}; export default async function (el) { - const { image } = await loadData("noise"); + const [{ image }, { cameras }] = await Promise.all([loadData("noise"), loadData("detectors")]); + // read noise and EM gain as in the "SNR against signal" chart (detector_data in + // scripts/make_doc_figures.py); `electronsPerAdu` puts the signal well inside a + // 16-bit range + const CAMERAS = { + "ideal (shot noise only)": { readNoise: 0, electronsPerAdu: 1, offset: 0 }, + CCD: { readNoise: cameras.CCD.read_noise, electronsPerAdu: 1, offset: OFFSET }, + sCMOS: { readNoise: cameras.sCMOS.read_noise, electronsPerAdu: 0.5, offset: OFFSET }, + EMCCD: { readNoise: cameras.EMCCD.read_noise, offset: OFFSET }, + }; const truth = decodeImage(image); const ui = shell(el); ui.controls.classList.add("ms-chart__controls--grid"); - const state = { camera: "CCD", photons: 20, readNoise: 6, emGain: 300, seed: 1 }; + const state = { + camera: "CCD", + photons: 20, + readNoise: CAMERAS.CCD.readNoise, + emGain: cameras.EMCCD.em_gain, + seed: 1, + }; const panels = document.createElement("div"); panels.className = "ms-panels"; @@ -116,7 +124,7 @@ export default async function (el) { readSlider.input.closest(".ms-control").hidden = state.camera.startsWith("ideal"); gainSlider.input.closest(".ms-control").hidden = !isEM; const gain = isEM ? state.emGain : 1; - const enf = isEM ? D.emccdExcessNoiseFactor(gain) : 1; + const enf = isEM ? D.emccdExcessNoiseFactor(gain, cameras.EMCCD.em_stages) : 1; const readNoise = state.camera.startsWith("ideal") ? 0 : state.readNoise; const params = { ...cam, readNoise, gain, enf, bitDepth: 16 }; // EMCCD: keep the amplified signal inside the ADC range diff --git a/docs/javascripts/charts/photometry-data.js b/docs/javascripts/charts/photometry-data.js index ef7bb242..ef48af29 100644 --- a/docs/javascripts/charts/photometry-data.js +++ b/docs/javascripts/charts/photometry-data.js @@ -1,5 +1,6 @@ -// Fluorophores, channels, and detector QE from photometry.json, decoded, with each -// channel's illumination and emission path computed as microsim does. +// Fluorophores, channels, detector QE, and the default objective lens from +// photometry.json, decoded, with each channel's illumination and emission path +// computed as microsim does. import * as P from "../physics/photometry.js"; import { loadData } from "./lib.js"; @@ -31,6 +32,6 @@ function prepare(data) { ...d, qe: typeof d.qe === "number" ? d.qe : P.decodeSpectrum(d.qe), })); - return { fluors, channels, detectors }; + return { fluors, channels, detectors, lens: data.lens }; } diff --git a/docs/javascripts/charts/photon-budget.js b/docs/javascripts/charts/photon-budget.js index e3a22c3a..cb58271f 100644 --- a/docs/javascripts/charts/photon-budget.js +++ b/docs/javascripts/charts/photon-budget.js @@ -47,15 +47,15 @@ function flow(parent, labels) { } export default async function (el) { - const { fluors, channels, detectors } = await loadPhotometry(); + const { fluors, channels, detectors, lens } = await loadPhotometry(); const ui = shell(el); const state = { fluor: "EGFP", channel: "FITC", detector: detectors[0].name, power: 1, // W/cm^2 - na: 1.4, - ns: 1.47, // ObjectiveLens.specimen_ri default + na: lens.na, // ObjectiveLens defaults + ns: lens.specimen_ri, exposure: 100, // ms }; const redraw = () => chart.redraw(); @@ -136,25 +136,24 @@ export default async function (el) { const channel = channels.find((c) => c.name === state.channel); const qe = detectors.find((d) => d.name === state.detector).qe; - // OpticalConfig.absorption_rate, total_emission_rate, filtered_emission_rate, - // and Simulation.filtered_emission_rates (collection efficiency) - const absorbed = P.absorptionRate(channel.illumination, state.power, fluor); - const k = P.total(absorbed); - const s = P.saturationParameter(k, fluor.lifetime_ns); - const emitted = P.totalEmissionRate(absorbed, fluor); - const eta = P.collectionEfficiency(state.na, state.ns); - const passed = P.filteredEmissionRate(emitted, channel.emission); - const detected = P.scale(P.filteredEmissionRate(emitted, channel.emission, qe), eta); + // Simulation.filtered_emission_rates for one fluorophore and channel, with and + // without the detector QE + const opts = { channel, fluor, power: state.power, na: state.na, specimenRI: state.ns }; + const b = P.photonBudget({ ...opts, qe }); + const noQE = P.photonBudget({ ...opts, qe: null }); + const { absorbed, emitted, collection: eta, saturation: s } = b; + const detected = b.collected; + const k = P.total(absorbed); const nEm = P.total(emitted); const nColl = nEm * eta; - const nPass = P.total(passed) * eta; - const nDet = P.total(detected); + const nPass = P.total(noQE.collected); + const nDet = P.total(b.collected); const nExp = (nDet * state.exposure) / 1000; const qy = fluor.qy || 1; const satNote = s > 0.005 - ? `
× ${fmt.sig(1 / (1 + s), 2)} saturation (kτ = ${fmt.sig(s, 2)})` + ? `
× ${fmt.sig(nEm / (qy * k), 2)} saturation (kτ = ${fmt.sig(s, 2)})` : !fluor.lifetime_ns ? "
lifetime unknown: no saturation" : ""; diff --git a/docs/javascripts/charts/pmt-gain.js b/docs/javascripts/charts/pmt-gain.js index 761892a0..48129921 100644 --- a/docs/javascripts/charts/pmt-gain.js +++ b/docs/javascripts/charts/pmt-gain.js @@ -22,7 +22,8 @@ import { export default async function (el) { const { pmts } = await loadData("detectors"); const ui = shell(el); - const state = { pmt: pmts[0].name, hv: 0.8 }; + // the preset's `hv`, or `ref_hv` if it has none (as in PMT.multiplication_gain) + const state = { pmt: pmts[0].name, hv: pmts[0].hv ?? pmts[0].ref_hv }; const redraw = () => chart.redraw(); let hvSlider; select(ui.controls, { diff --git a/docs/javascripts/charts/saturation.js b/docs/javascripts/charts/saturation.js index 84683651..a443fa18 100644 --- a/docs/javascripts/charts/saturation.js +++ b/docs/javascripts/charts/saturation.js @@ -68,12 +68,10 @@ export default async function (el) { }; }; const rows = POWERS.map(at); - const tau = fluor.lifetime_ns * 1e-9; - const pHalf = 1 / (k1 * tau); // where the saturation parameter k tau is 1 - const qy = fluor.qy || 1; + const pHalf = P.halfSaturationIrradiance(k1, fluor.lifetime_ns); const halfRate = at(pHalf).saturated; setHalf(`${fmt.si(pHalf)} W/cm²`); - setMax(`${fmt.si(qy / tau)} photons/s`); + setMax(`${fmt.si(P.maxEmissionRate(fluor))} photons/s`); const yMax = 1e9; const svg = Plot.plot({ diff --git a/docs/javascripts/physics/detectors.js b/docs/javascripts/physics/detectors.js index bcc4796f..eaef0611 100644 --- a/docs/javascripts/physics/detectors.js +++ b/docs/javascripts/physics/detectors.js @@ -36,9 +36,23 @@ export function hydCountingEfficiency(rate, deadTimeNs = 1.5) { return 1 / (1 + rate * deadTimeNs * 1e-9); } +// Limit of the recorded rate n / (1 + n tau) (HyD._counting_efficiency) as the +// true rate n grows: 1 / tau counts/s +export function hydMaxRate(deadTimeNs = 1.5) { + return 1 / (deadTimeNs * 1e-9); +} + +// numpy's round (xp.round in _Detector.simulate and apply_multiplication_gain): +// halves round to the nearest even integer, unlike Math.round +export function roundHalfEven(x) { + const r = Math.round(x); + return Math.abs(x % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r; +} + // Signal-to-noise ratio of one pixel, from the steps of _Detector.simulate // (src/microsim/schema/detectors/_detector.py), ignoring quantization, clipping, -// and dead time: +// dead time, clock-induced charge (_Camera `clock_induced_charge`), and the +// PMT's dwell-dependent read noise (PMT._read_noise_per_pass): // shot noise: Poisson, variance = mean electrons (signal + dark) // multiplication: variance x F (apply_multiplication_gain); read noise, added // after the gain G, is G times smaller relative to the signal @@ -55,7 +69,8 @@ export function snr({ electrons, dark = 0, readNoise = 0, gain = 1, enf = 1, ave // One frame from a camera, following _Detector.simulate step by step, for an image // of `expected` mean photoelectrons per pixel (QE and exposure already applied). -// Not modeled here: averaging, binning, dark current, and clock-induced charge. +// Not modeled here: averaging, binning, dark current, clock-induced charge, and the +// serial register full well (_Camera `serial_reg_full_well`). // 1-2. shot noise: Poisson // 3. full well: clip at `fullWell` electrons // 4. multiplication (apply_multiplication_gain): Gamma(n / (F - 1), G (F - 1)), @@ -71,11 +86,11 @@ export function simulateCamera(expected, p, rand) { let e = R.poisson(rand, Math.max(expected[i], 0)); e = Math.min(e, fullWell); if (gain !== 1) { - if (enf - 1 < 1e-9) e = Math.round(e * gain); - else if (e > 0) e = Math.round(R.gamma(rand, e / (enf - 1), gain * (enf - 1))); + if (enf - 1 < 1e-9) e = roundHalfEven(e * gain); + else if (e > 0) e = roundHalfEven(R.gamma(rand, e / (enf - 1), gain * (enf - 1))); } const voltage = e + readNoise * R.normal(rand); - const signal = Math.min(Math.round(voltage / electronsPerAdu), maxGray); + const signal = Math.min(roundHalfEven(voltage / electronsPerAdu), maxGray); out[i] = Math.min(Math.max(signal + offset, 0), maxGray); } return out; diff --git a/docs/javascripts/physics/photometry.js b/docs/javascripts/physics/photometry.js index 61a3ff14..238282e7 100644 --- a/docs/javascripts/physics/photometry.js +++ b/docs/javascripts/physics/photometry.js @@ -124,6 +124,19 @@ export function saturationParameter(totalAbsorption, lifetimeNs) { return totalAbsorption * lifetimeNs * 1e-9; } +// Irradiance (W/cm^2) at which OpticalConfig.saturation_parameter, k tau, is 1. +// The total absorption rate k is proportional to irradiance; `k1` is its value at +// 1 W/cm^2 (total of absorptionRate(illum, 1, fluor)). +export function halfSaturationIrradiance(k1, lifetimeNs) { + return 1 / (k1 * lifetimeNs * 1e-9); +} + +// Limit of OpticalConfig.total_emission_rate (saturated) as irradiance grows: +// QY k / (1 + k tau) tends to QY / tau photons/s (QY is 1 if unknown) +export function maxEmissionRate(fluor) { + return (fluor.qy || 1) / (fluor.lifetime_ns * 1e-9); +} + // OpticalConfig.total_emission_rate: emission spectrum normalized to sum 1, times // QY (1 if unknown) and the total absorption rate, divided by (1 + k tau) if // `saturate` diff --git a/scripts/make_doc_figures.py b/scripts/make_doc_figures.py index 2b1c0ddc..2940da61 100644 --- a/scripts/make_doc_figures.py +++ b/scripts/make_doc_figures.py @@ -267,10 +267,6 @@ def photometry_data() -> None: } for li in oc.lights ], - "illumination": spectrum_json(oc.illumination), - "emission": spectrum_json( - oc.emission.spectrum if oc.emission else None - ), } ) detectors = [ @@ -279,9 +275,15 @@ def photometry_data() -> None: {"name": "GaAsP PMT", "qe": spectrum_json(det_lib.PMT_GAASP.qe)}, {"name": "Power HyD R", "qe": spectrum_json(det_lib.POWER_HYD_R.qe)}, ] + lens = ms.ObjectiveLens() write_json( "photometry", - {"fluorophores": fluors, "channels": channels, "detectors": detectors}, + { + "fluorophores": fluors, + "channels": channels, + "detectors": detectors, + "lens": {"na": lens.numerical_aperture, "specimen_ri": lens.specimen_ri}, + }, ) @@ -307,6 +309,7 @@ def detector_data() -> None: pmts = [ { "name": pmt.name, + "hv": pmt.hv, "ref_hv": pmt.ref_hv, "ref_gain": pmt.ref_gain, "hv_exponent": pmt.hv_exponent, diff --git a/tests/js/detectors.mjs b/tests/js/detectors.mjs index 81ba0048..d5cb2027 100644 --- a/tests/js/detectors.mjs +++ b/tests/js/detectors.mjs @@ -10,6 +10,8 @@ const out = { return [gain, D.pmtExcessNoiseFactor(gain, p.dynode_stages)]; }), hyd: input.hyd.map(([rate, tau]) => D.hydCountingEfficiency(rate, tau)), + hyd_max: input.hyd.map(([, tau]) => D.hydMaxRate(tau)), + round: input.round.map((x) => D.roundHalfEven(x)), snr: input.snr.map((p) => D.snr(p)), }; process.stdout.write(JSON.stringify(out)); diff --git a/tests/js/photometry.mjs b/tests/js/photometry.mjs index 53e37b98..14972137 100644 --- a/tests/js/photometry.mjs +++ b/tests/js/photometry.mjs @@ -15,6 +15,7 @@ const out = input.cases.map((c) => { emission: P.emissionPath(ch.filters), }; const b = P.photonBudget({ channel, fluor, ...c.params }); + const k1 = P.total(P.absorptionRate(channel.illumination, 1, fluor)); return { cross_section: totals(P.crossSection(fluor.ex, fluor.ec)), illumination_flux_density: totals( @@ -23,8 +24,13 @@ const out = input.cases.map((c) => { absorbed: totals(b.absorbed), emitted: totals(b.emitted), filtered: totals(b.filtered), + collected: totals(b.collected), collection: b.collection, saturation: b.saturation, + half_saturation_irradiance: fluor.lifetime_ns + ? P.halfSaturationIrradiance(k1, fluor.lifetime_ns) + : null, + max_emission_rate: fluor.lifetime_ns ? P.maxEmissionRate(fluor) : null, }; }); process.stdout.write(JSON.stringify(out)); diff --git a/tests/test_js_physics.py b/tests/test_js_physics.py index 0d398fd9..3432675e 100644 --- a/tests/test_js_physics.py +++ b/tests/test_js_physics.py @@ -153,6 +153,29 @@ def test_photometry() -> None: assert js["saturation"] == pytest.approx( oc.saturation_parameter(fluor), rel=1e-9 ) + if sat: # widefield saturates the rates; point scanners do it in the PSF + sim = ms.Simulation( + truth_space=ms.ShapeScaleSpace(shape=(1, 8, 8), scale=0.1), + sample=[ + ms.FluorophoreDistribution( + distribution=ms.MatsLines(), fluorophore=fluor + ) + ], + channels=[oc], + objective_lens=lens, + detector=None if qe is None else ms.CameraCCD(qe=qe), + ) + _assert_same(js["collected"], sim.filtered_emission_rates()) + if fluor.lifetime_ns: + half = oc.model_copy(update={"power": js["half_saturation_irradiance"]}) + assert half.saturation_parameter(fluor) == pytest.approx(1, rel=1e-9) + # QY k / (1 + k tau) = (QY / tau) * s / (1 + s), with s = k tau + bright = oc.model_copy(update={"power": 1e9}) + rate = float(bright.total_emission_rate(fluor).sum()) + s = bright.saturation_parameter(fluor) + assert js["max_emission_rate"] * s / (1 + s) == pytest.approx( + rate, rel=1e-9 + ) def _measured_snr(detector: ms.Detector, electrons: float) -> float: @@ -202,7 +225,9 @@ def test_detectors() -> None: snr = [ {"electrons": n, **params} for _, params in SNR_CASES for n in (0.5, 5, 50, 500) ] - js = _run("detectors.mjs", {"emccd": emccd, "pmt": pmt, "hyd": hyd, "snr": snr}) + rounding = [-2.5, -1.5, -0.5, 0.5, 1.5, 2.5, 3.49, 3.5, -3.7, 7] + payload = {"emccd": emccd, "pmt": pmt, "hyd": hyd, "snr": snr, "round": rounding} + js = _run("detectors.mjs", payload) for (g, n), f in zip(emccd, js["emccd"], strict=True): expected = ms.CameraEMCCD(em_gain=g, em_stages=n).excess_noise_factor @@ -214,6 +239,12 @@ def test_detectors() -> None: for (rate, tau), eff in zip(hyd, js["hyd"], strict=True): expected = ms.HyD(dead_time_ns=tau)._counting_efficiency(np.array(rate), 1.0) assert eff == pytest.approx(float(expected), rel=1e-12) + for (_, tau), max_rate in zip(hyd, js["hyd_max"], strict=True): + # the recorded rate n * efficiency(n) at a very high true rate n + n = 1e18 + recorded = n * ms.HyD(dead_time_ns=tau)._counting_efficiency(np.array(n), 1.0) + assert max_rate == pytest.approx(float(recorded), rel=1e-6) + np.testing.assert_array_equal(js["round"], np.round(rounding)) # the analytic SNR used in the docs, against microsim's detector simulation measured = [ From 2a54a068d27314404c36981042271ba3cd35efad Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:37:29 +0200 Subject: [PATCH 26/28] skills: update check-js-physics and check-docs-prose from a test run Co-Authored-By: Claude Opus 5.5 --- .claude/skills/check-docs-prose/SKILL.md | 64 +++++++++++--- .claude/skills/check-js-physics/SKILL.md | 106 ++++++++++++++++------- 2 files changed, 124 insertions(+), 46 deletions(-) diff --git a/.claude/skills/check-docs-prose/SKILL.md b/.claude/skills/check-docs-prose/SKILL.md index 1c1aa327..e3c09b40 100644 --- a/.claude/skills/check-docs-prose/SKILL.md +++ b/.claude/skills/check-docs-prose/SKILL.md @@ -22,12 +22,20 @@ uv run pytest tests/test_docs.py # runs every ```python block in the docs uv run pytest tests/test_js_physics.py # JS chart physics (see check-js-physics) uv run --group docs python scripts/make_doc_figures.py # figures, chart data uv run python scripts/make_notebooks.py -uv run --group docs zensical build --strict # broken links and cross-references +uv run --group docs zensical build --strict --clean # links and cross-references ``` -`test_docs.py` skips pages that need the network when it thinks there is none -(`tests/_util.py` `HAVE_INTERNET`). If it skips, run those pages anyway. -`` ```py `` blocks are fragments and are **not** run: check them by reading. +- `make_doc_figures.py` takes minutes and needs network access (FPbase, + OpenOrganelle, Allen). It only writes gitignored files, except + `cosem_examples`, which is only regenerated when named. It is also the only + thing that runs `tutorials/two-color-cell.md` (through the `tutorials` + figure), and it prints values that captions quote (e.g. `first_simulation`). +- `test_docs.py` never runs `two-color-cell.md` (its `SKIP` set). Without + network access it silently *drops* the pages in `NEEDS_INTERNET` rather than + skipping them. Check what ran with + `uv run pytest tests/test_docs.py --collect-only -q` (one item per page, plus + `README.md`), and run dropped pages another way. +- Without `--clean`, the build can be incremental and miss problems. ## What only reading can check @@ -35,6 +43,13 @@ Go page by page through `docs/` (`zensical.toml` `nav` lists them). For each statement of fact about microsim, find the code that makes it true, and cite it (`file:line`) in your report. Kinds of claims that go stale, and where they are: +- **Code that isn't tested.** `` ```py `` blocks are fragments and are **not** + run, ```` ```json title=... ```` blocks are written out but never validated, + and shell commands in the docs (`about/contributing.md`, + `getting-started/installation.md`) are never run. Run the cheap, + self-contained ones in a scratch script (I/O, small spaces), validate JSON + examples with `ms.Simulation.model_validate_json`, and check + `make_doc_figures.py` arguments against its `@figure` function names. - **Defaults and required fields.** Tables of defaults (`getting-started/first-simulation.md`, `guide/settings.md`, `guide/detectors.md` "Default camera settings", `guide/channels.md`), and @@ -50,36 +65,57 @@ statement of fact about microsim, find the code that makes it true, and cite it modeled", and every admonition that states a limitation or a workaround: ```bash - grep -rn '^ *\(!!!\|???\) ' docs + grep -rn '^ *\(!!!\|???\) ' docs | grep -v 'docs/\(data\|notebooks\)' ``` A new feature can make a "not modeled" line false; a refactor can make a - "this is how it works" line false. Check each one. + "this is how it works" line false. Check each one. Also check that each + setting the docs describe actually takes effect (e.g. that + `max_psf_radius_aus` changes the PSF size in the log line `Need PSF (nz,nx)`). - **Equations.** The formulas in `guide/photometry.md`, `guide/detectors.md`, and `guide/modalities.md` should match the code step by step (normalizations, - units, which quantity is summed). The JS copies of the same physics are - covered by the `check-js-physics` skill. + units, which quantity is summed, the order of steps). The JS copies of the + same physics are covered by the `check-js-physics` skill. - **Library contents.** Tables of `optical_config.lib` filter sets, `detectors.lib` presets, settings, extras (`getting-started/installation.md`), and dataset or layer names. Compare with the modules and `pyproject.toml`. + Figure captions describe what `scripts/make_doc_figures.py` computed (e.g. the + shared `_cell_sim()`): check them against that script. - **Numbers in text and captions.** Figures and charts are regenerated on every build, but numbers quoted in the text are not (e.g. "392 photons absorbed", - "about 6 photons", "71%"). Recompute each one from the current code, or from - the generated chart data in `docs/data/generated/`. + "about 6 photons", "71%"). Recompute each one from the current code, from the + output of `make_doc_figures.py`, or from the generated chart data in + `docs/data/generated/`. - **Names.** Every class, method, parameter, and keyword mentioned in prose or `` ```py `` fragments must exist with that name and signature. The strict build only checks `[name][microsim.x]` cross-references, not plain `` `code` ``. +- **New API.** Public methods and settings added since the docs were written + (`git log` on `src/microsim`) should be mentioned where they belong (e.g. a + new `Simulation` method in `guide/index.md` and `api/index.md`). - **Links to issues.** Statements tied to a GitHub issue (e.g. #152): check whether the issue is closed and the statement still holds. -## Reporting and fixing +## Style + +The docs are plain and scientific. Check these with a search, then by reading: + +```bash +grep -rn '—' docs --include='*.md' | grep -v 'docs/\(data\|notebooks\)' # em dashes +grep -rniE '\b(recommend|best for|ideal for|natural choice|should use|which .* when|beats|outperform)' \ + docs --include='*.md' docs/javascripts/charts | grep -v 'docs/\(data\|notebooks\)' +``` -- Fix clear factual errors directly, in the docs' existing style: plain, - scientific, no marketing language, no em dashes. +- No em dashes, and no marketing language. - The docs describe what microsim computes; they don't advise readers on which hardware to use (e.g. "which camera when", "the natural choice", "best for low light"). Rephrase such lines as statements of what the model or the - physics does. + physics does. Advice on using microsim itself (e.g. "Prefer Zarr") is fine. +- Chart labels and captions in `docs/javascripts/charts/*.js` count too. + +## Reporting and fixing + +- Fix clear factual errors directly, in the docs' existing style, unless you + were asked for a report only. - If the code looks wrong rather than the docs (a doc describes intended behavior the code doesn't implement), don't change either; report it. - Report what you checked, what you changed, and anything you could not verify, diff --git a/.claude/skills/check-js-physics/SKILL.md b/.claude/skills/check-js-physics/SKILL.md index ea99cfe2..a6954cb4 100644 --- a/.claude/skills/check-js-physics/SKILL.md +++ b/.claude/skills/check-js-physics/SKILL.md @@ -12,16 +12,38 @@ code is a copy, and copies drift. This skill is how to check it. ## Layout - `docs/javascripts/physics/*.js`: the copied physics. Pure functions, no DOM, and - no imports from outside `physics/`. Every function has a comment naming the - Python it mirrors. + no imports from outside `physics/`. Every function that mirrors microsim has a + comment naming the Python it mirrors (helpers like `scale`, `total`, and `rng` + don't). - `docs/javascripts/charts/*.js`: rendering. These should contain **no physics**: they call the physics modules, and only choose parameters, axes, and labels. -- `scripts/make_doc_figures.py`: exports the data the charts load - (`*_data` functions, written to `docs/data/generated/*.json`): spectra, presets, - and Monte Carlo results computed by microsim itself. + `lib.js` (plot helpers), `index.js` (mounting), and `photometry-data.js` + (decodes `photometry.json`) are infrastructure. +- `scripts/make_doc_figures.py`: exports the data the charts load, written to + `docs/data/generated/` (gitignored): `photometry_data` → `photometry.json`, + `detector_data` → `detectors.json`, `noise_data` → `noise.json`, + `pinhole_data` → `pinhole.json`. Spectra, presets, default lens values, and + images computed by microsim itself. - `tests/test_js_physics.py` and `tests/js/*.mjs`: run the physics modules in node on the same inputs as microsim, and compare. Skipped if `node` is not installed. +### The charts + +| Chart | Page | Physics used | Data | Starting values | +| --- | --- | --- | --- | --- | +| `spectra.js` | `guide/channels.md` | `absorptionRate`, `crossSection`, `filteredEmissionRate`, `fluxDensity`, `irradiance`, `mulArrays` | `photometry.json` | fluorophore and channel menus | +| `crosstalk.js` | `guide/channels.md` | `photonBudget` | `photometry.json` (incl. `lens`) | EGFP + tdTomato, FITC 500 LP + TRITC; numbers in the caption | +| `photon-budget.js` | `guide/photometry.md` | `photonBudget` | `photometry.json` (incl. `lens`) | EGFP, FITC, 1 W/cm², 100 ms; "about 6 photons" in the page text | +| `saturation.js` | `guide/photometry.md` | `absorptionRate`, `totalEmissionRate`, `halfSaturationIrradiance`, `maxEmissionRate` | `photometry.json` | irradiance bands from the table in `guide/channels.md` | +| `noise.js` | `guide/detectors.md` | `simulateCamera`, `snr`, `emccdExcessNoiseFactor` | `noise.json`, `detectors.json` (`cameras`) | camera presets shared with `detector-snr.js` | +| `detector-snr.js` | `guide/detectors.md` | `snr`, `emccdExcessNoiseFactor` | `detectors.json` (`cameras`) | ≈71% cap in the caption | +| `emccd-enf.js` | `guide/detectors.md` | `emccdExcessNoiseFactor` | `detectors.json` (`cameras.EMCCD`) | | +| `pmt-gain.js` | `guide/detectors.md` | `pmtGain`, `pmtExcessNoiseFactor` | `detectors.json` (`pmts`) | F ≈ 1.3 / 1.5 in the page text | +| `hyd-dead-time.js` | `guide/detectors.md` | `hydCountingEfficiency`, `hydMaxRate` | `detectors.json` | "about 6% low at 40 Mcps" in the page text | +| `pinhole.js` | `guide/modalities.md` | none (displays microsim PSFs) | `pinhole.json` | | + +Update this table when you add, rename, or remove a chart. + ## Procedure 1. **Run the tests.** `uv run pytest tests/test_js_physics.py -v`. They must pass, @@ -32,25 +54,31 @@ code is a copy, and copies drift. This skill is how to check it. branch. For every function in `docs/javascripts/physics/`, open the Python it names and compare line by line: constants, normalizations (sum vs. peak vs. integral), units (nm vs. m, ns vs. s, W vs. photons), defaults (`|| 1`, `?? null`), - and how arrays are aligned. Current pairs: + how arrays are aligned, and rounding (`np.round` rounds halves to even, unlike + `Math.round`; use `roundHalfEven`). Current pairs: - | JavaScript (`docs/javascripts/physics/`) | Python (`src/microsim/schema/`) | + | JavaScript (`docs/javascripts/physics/`) | Python (`src/microsim/` unless noted) | | --- | --- | - | `photometry.js` `mulSpectra` | `spectrum.py` `Spectrum._intensity_op` (outer join, fill 0) | + | `photometry.js` `decodeSpectrum` | `scripts/make_doc_figures.py` `spectrum_json` (inverse) | + | `photometry.js` `mulSpectra` | `schema/spectrum.py` `Spectrum._intensity_op` (outer join, fill 0) | | `photometry.js` `mulArrays` | xarray arithmetic (inner join) | - | `photometry.js` `excitationPath`, `emissionPath` | `optical_config/config.py` `OpticalConfig.excitation`, `.emission`, `._merge` | + | `photometry.js` `inverted` | `schema/spectrum.py` `Spectrum.inverted` | + | `photometry.js` `excitationPath`, `emissionPath` | `schema/optical_config/config.py` `OpticalConfig.excitation`, `.emission`, `._merge` | | `photometry.js` `illumination` | `OpticalConfig.illumination`, `LightSource.scaled_spectrum` | | `photometry.js` `irradiance`, `fluxDensity` | `OpticalConfig.irradiance`, `.illumination_flux_density` | - | `photometry.js` `crossSection` | `sample/fluorophore.py` `Fluorophore.absorption_cross_section` | + | `photometry.js` `crossSection` | `schema/sample/fluorophore.py` `Fluorophore.absorption_cross_section` | | `photometry.js` `absorptionRate`, `saturationParameter`, `totalEmissionRate`, `filteredEmissionRate` | `OpticalConfig` methods of the same names | - | `photometry.js` `collectionEfficiency` | `lens.py` `ObjectiveLens.collection_efficiency` | - | `photometry.js` `photonBudget` | `Simulation.filtered_emission_rates` (collection applied after filtering) | - | `detectors.js` `emccdExcessNoiseFactor` | `detectors/_camera.py` `CameraEMCCD.excess_noise_factor` | - | `detectors.js` `pmtGain`, `pmtExcessNoiseFactor` | `detectors/_point.py` `PMT.multiplication_gain`, `.excess_noise_factor` | - | `detectors.js` `hydCountingEfficiency` | `detectors/_point.py` `HyD._counting_efficiency` | - | `detectors.js` `snr` | derived from `detectors/_detector.py` `_Detector.simulate` and `apply_multiplication_gain` (tested against a Monte Carlo run of `render`) | + | `photometry.js` `halfSaturationIrradiance`, `maxEmissionRate` | derived from `OpticalConfig.saturation_parameter` and `.total_emission_rate` | + | `photometry.js` `collectionEfficiency` | `schema/lens.py` `ObjectiveLens.collection_efficiency` | + | `photometry.js` `photonBudget` | `Simulation.filtered_emission_rates` (tested end to end) | + | `detectors.js` `emccdExcessNoiseFactor` | `schema/detectors/_camera.py` `CameraEMCCD.excess_noise_factor` | + | `detectors.js` `pmtGain`, `pmtExcessNoiseFactor` | `schema/detectors/_point.py` `PMT.multiplication_gain`, `.excess_noise_factor` | + | `detectors.js` `hydCountingEfficiency`, `hydMaxRate` | `schema/detectors/_point.py` `HyD._counting_efficiency` | + | `detectors.js` `roundHalfEven` | `np.round`, as used in `_Detector.simulate` | + | `detectors.js` `snr` | derived from `schema/detectors/_detector.py` `_Detector.simulate` and `apply_multiplication_gain` (tested against a Monte Carlo run of `render`) | | `detectors.js` `simulateCamera` | `_Detector.simulate` steps 1-5 for cameras, and `apply_multiplication_gain` (tested statistically against `render`: mean, variance, quantiles) | - | `random.js` `poisson`, `normal`, `gamma` | the distributions of `backend.py` `poisson_rvs`, `norm_rvs`, and `scipy.stats.gamma` (same distributions, not the same random streams) | + | `random.js` `poisson`, `normal`, `gamma` | the distributions of `schema/backend.py` `poisson_rvs`, `norm_rvs`, and `scipy.stats.gamma` (same distributions, not the same random streams) | + | `random.js` `rng` | no mirror (seeded PRNG for the browser) | Charts that only display arrays computed by microsim (`pinhole.js`, the noise-free image in `noise.js`) have nothing to mirror; check instead that the export @@ -58,27 +86,41 @@ code is a copy, and copies drift. This skill is how to check it. describe what was computed. If the Python has gained a step (a new factor, a new parameter that changes the - result), the JavaScript needs it too, or the chart's caption must say what it - leaves out. + result), the JavaScript needs it too, or the function's comment (and the chart's + caption, if it matters there) must say what it leaves out. -3. **Find physics outside the physics modules.** In `docs/javascripts/charts/`, look - for formulas: constants like `1e-9`, `Math.exp`, `Math.sqrt`, `** 2`, `/ (1 +`. - Ratios of physics outputs for display (e.g. "fraction passed") are fine. Anything - that computes a physical quantity belongs in `physics/`, with a mirror comment - and a test. +3. **Find physics outside the physics modules.** Search the charts for formulas: + + ```bash + grep -nE '1e-?[0-9]|Math\.(exp|sqrt|log|pow)|\*\* ?-?[0-9]|/ ?\(1 \+' docs/javascripts/charts/*.js + ``` + + Ratios of physics outputs for display (e.g. "fraction passed") are fine. + Anything that computes a physical quantity belongs in `physics/`, with a mirror + comment and a test. 4. **Check the exported data.** `scripts/make_doc_figures.py` writes presets and spectra from microsim objects. Make sure the chart uses the exported values - (e.g. PMT presets, the HyD dead time), not numbers typed into the JavaScript. - Starting values typed into a chart (e.g. a default slider position) should match - the parameters named in the page text or caption. + (e.g. PMT presets, the HyD dead time, the `ObjectiveLens` defaults), not numbers + typed into the JavaScript. If two charts share starting values (e.g. the camera + presets of `noise.js` and `detector-snr.js`), both must read them from the same + export. Display-only settings (slider ranges, `electronsPerAdu` chosen to fit + 16 bits) can stay in the chart. + +5. **Check the captions and page text.** Numbers quoted in the docs (see the + "Starting values" column above) should match what the chart shows at its + starting values. Regenerate the data first; without it, a chart shows "This + interactive chart failed to load": + + ```bash + # noise_data needs network access (FPbase, OpenOrganelle) the first time + uv run --group docs python scripts/make_doc_figures.py photometry_data detector_data noise_data pinhole_data + ``` -5. **Check the captions and page text.** Numbers quoted in the docs (e.g. "about 6 - photons in a 100 ms exposure", "71%") should match what the chart shows at its - starting values. Serve the docs and look: + Then either compute the numbers in node, importing `docs/javascripts/physics/*.js` + with the generated JSON (fastest), or serve the docs and look: ```bash - uv run --group docs python scripts/make_doc_figures.py photometry_data detector_data uv run --group docs zensical serve ``` @@ -86,4 +128,4 @@ code is a copy, and copies drift. This skill is how to check it. - Put the physics in `docs/javascripts/physics/`, with a comment naming the Python. - Add a case to `tests/test_js_physics.py` that compares it with microsim. -- Add a row to the table above. +- Add rows to the tables above. From 380334b75581eb0e75cfcebf9a105bd32c5c740b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 18:37:39 +0200 Subject: [PATCH 27/28] docs: tighten the advice search in check-docs-prose; reflow saving.md Co-Authored-By: Claude Opus 5.5 --- .claude/skills/check-docs-prose/SKILL.md | 2 +- docs/guide/saving.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/skills/check-docs-prose/SKILL.md b/.claude/skills/check-docs-prose/SKILL.md index e3c09b40..712173a1 100644 --- a/.claude/skills/check-docs-prose/SKILL.md +++ b/.claude/skills/check-docs-prose/SKILL.md @@ -101,7 +101,7 @@ The docs are plain and scientific. Check these with a search, then by reading: ```bash grep -rn '—' docs --include='*.md' | grep -v 'docs/\(data\|notebooks\)' # em dashes -grep -rniE '\b(recommend|best for|ideal for|natural choice|should use|which .* when|beats|outperform)' \ +grep -rniE '\b(recommend|best for|ideal for|natural choice|should use|which [a-z]+ (when|to use)|beats|outperform)' \ docs --include='*.md' docs/javascripts/charts | grep -v 'docs/\(data\|notebooks\)' ``` diff --git a/docs/guide/saving.md b/docs/guide/saving.md index 259995a1..c6b2d168 100644 --- a/docs/guide/saving.md +++ b/docs/guide/saving.md @@ -56,9 +56,9 @@ sim = ms.Simulation.model_validate_json(Path("sim.json").read_text()) The JSON is complete: fluorophores loaded from FPbase are saved with their spectra, so loading does not need network access (except for a `CosemLabel` -sample, which queries OpenOrganelle when it is loaded). It also includes the random -seed, so loading and running the simulation in the same environment reproduces -the result. +sample, which queries OpenOrganelle when it is loaded). It also includes the +random seed, so loading and running the simulation in the same environment +reproduces the result. You can also write simulations as JSON (or YAML, or anything that loads into a dictionary) by hand. Only the fields you want to change from the defaults are From fb318165af9047e9dde6e536f984749594651f2d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Oct 2026 20:23:23 +0200 Subject: [PATCH 28/28] docs: describe max_psf_radius_aus="auto" (#158) Co-Authored-By: Claude Opus 5.5 --- docs/guide/settings.md | 17 ++++++++++++++--- docs/tutorials/point-scanning-detectors.md | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 31bd3c37..91d8888d 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -16,7 +16,7 @@ sim = ms.Simulation(..., settings=ms.Settings(random_seed=42, np_backend="jax")) | `random_seed` | random | seed for the ground truth and noise | | `np_backend` | `"auto"` | array library used for computation | | `float_dtype` | `float32` | floating-point precision | -| `max_psf_radius_aus` | 6 | lateral size limit of each PSF, in Airy radii | +| `max_psf_radius_aus` | `"auto"` | lateral size limit of each PSF, in Airy radii | | `spectral_bins_per_emission_channel` | 1 | number of PSFs used per fluorophore and channel | | `spectral_bin_threshold_percentage` | 1 | part of the emission spectrum that is ignored when binning | | `cache` | on | read and write the on-disk ground-truth cache | @@ -64,8 +64,19 @@ GPU. Use `.values` or `numpy.asarray()` to get a NumPy array. **`max_psf_radius_aus`** limits the lateral size of each PSF to this many Airy radii from the center. A smaller value makes convolution much faster but drops the faint outer parts of the PSF, which in widefield carry a noticeable -fraction of the out-of-focus light. Set it to `None` to use PSFs as large as -the truth space. +fraction of the out-of-focus light. With `"auto"` (the default), the radius is +chosen to keep the error from truncation at or below about 1% (RMSE, +relative to an untruncated PSF): + +- **Widefield:** 14 Airy radii, plus half the radius of the cone of + out-of-focus light at half the depth of the truth space. At 1.4 NA and + 525 nm, this is about 17 Airy radii for a 1 µm deep volume and 27 for + 5 µm. +- **Confocal:** 8 Airy radii, because the pinhole rejects most out-of-focus + light. + +A number sets the radius directly. `None` uses PSFs as large as the truth +space. The PSF is never larger than the truth space. **`spectral_bins_per_emission_channel`** sets how many wavelengths are used to represent each fluorophore's detected emission. With 1 (the default), one PSF diff --git a/docs/tutorials/point-scanning-detectors.md b/docs/tutorials/point-scanning-detectors.md index f585f0e2..53cd2b0b 100644 --- a/docs/tutorials/point-scanning-detectors.md +++ b/docs/tutorials/point-scanning-detectors.md @@ -33,7 +33,7 @@ sim = ms.Simulation( ], channels=[FITC.model_copy(update={"power": 2e4})], # (1)! modality=ms.Confocal(pinhole_au=1), - settings=ms.Settings(random_seed=100, max_psf_radius_aus=8), + settings=ms.Settings(random_seed=100), detector=lib.HYD_SP8, # (2)! ) optical_image = sim.optical_image()